Файлы сцен — defineScene
Сцена как данные: один вызов defineScene({...}), по конвенции — дефолтный экспорт файла
.scene.ts. Аргумент — простой литерал: узлы по именам (уникальным среди соседей; рантайм
адресует их абсолютным путём из имён, соединённых /), у каждого один блок-источник
(mesh / model / light, или никакого = узел-группа), трансформ, опциональный material,
aspects: [use(Ctor, props), …] и children. Всё это опускается до обычных вызовов SDK
(фабрики Mesh, node.aspect(), scene.add),
поэтому файл сцены выполняется на каждой платформе как любой другой код. Визуальный редактор сцен
le.codes читает и пишет эти файлы; писать их руками так же нормально.
Обзор
// city.scene.ts
import { Spinner } from './spinner'
export default defineScene({
env: { skybox: '#10131a', bloom: true },
camera: { position: [0, 6, 12], target: [0, 0, 0] },
nodes: {
ground: {
mesh: { kind: 'box', size: [20, 1, 20] },
material: { lit: { color: '#444444' } },
position: [0, -0.5, 0],
aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
},
hero: {
model: asset('./hero.glb'),
position: [2, 0, 0],
aspects: [use(Spinner, { speed: 2 })],
children: {
halo: { mesh: { kind: 'sphere', radius: 0.2 }, position: [0, 2, 0] },
},
},
sun: { light: { kind: 'sun', shadowsQuality: 2 } },
},
})// main.ts
import city from './city.scene'
const { scene, nodes, get } = await city.open()
nodes.hero.anim.play('idle') // nodes[path] — у корневого узла путь это его голое имя
nodes['hero/halo'].visible = false // вложенные узлы адресуются полным путём
get('hero/halo') // динамический поиск — Node | null для неизвестных путейХэндл
defineScene возвращает SceneHandle:
| Член | Что делает |
|---|---|
load() |
Инстанцировать сцену (async — модели загружаются). Идемпотентно: один экземпляр на хэндл. |
open() |
load() + сделать сцену активной. Возвращает те же { scene, nodes, get }. |
def |
Литерал, который ты передал. |
nodes ключуется абсолютным путём — именами от корня сцены, соединёнными / ('hero/halo');
у корневого узла путь — просто его имя. Каждая запись типизирована по своему источнику:
mesh → Mesh, model → Model, light →
Light, никакого → Node — с use(...)-аспектами, отражёнными в типе.
Имена должны быть уникальны только среди соседей (без / и : в имени) — дублирование или
смена родителя у группы никогда не заставляет переименовывать что-то внутри неё. get(path) —
динамический аксессор (типизирован по литеральным путям сцены, Node | null для произвольных
строк). Не полагайся на порядок ключей записи — он следует завершению сборки, а не порядку в файле.
Записи узлов
| Ключ | Описание |
|---|---|
mesh |
{ kind: 'box' | 'sphere' | 'cylinder' | 'plane', …опции геометрии } |
model |
URL GLB — asset('./hero.glb') |
light |
{ kind: 'sun', …SunOptions } |
make |
Поддерево, построенное кодом — make(factoryFn, { …литеральные аргументы }) (ниже) |
prefab |
Другой файл сцены как переиспользуемая композиция — его импортированный хэндл (ниже) |
camera |
{} — этот узел И ЕСТЬ камера сцены (ниже) |
material |
Для mesh: { lit: { color?, roughness?, metallic? } }, { unlit: { color? } }, { shadow: color } или разделяемый экземпляр Material |
position, eulerAngles, scale, visible |
Трансформ / видимость |
locked |
Только в редакторе: манипуляции во вьюпорте не нацеливаются на этот узел (поля по-прежнему редактируются). Нет эффекта в рантайме |
castShadows, receiveShadows |
Флаги отрисовки меша |
aspects |
[use(Ctor, props), …] — порядок прикрепления важен (например, Shape до Physics) |
children |
Вложенные записи узлов, родитель — этот узел |
overrides |
Для источников model/prefab: переопределения трансформов внутренних узлов (ниже) |
mount |
На ребёнке узла-модели/префаба: сделать родителем эту внутреннюю часть (ниже) |
Не больше одного блока-источника на узел. Каждый узел дефа попадает в набор отрисовки сцены (членство и родительство — раздельны, см. node.md).
overrides — позирование внутренних узлов GLB
Узел-модель может переставить узлы внутри своего GLB, не трогая файл. Ключи — пути частей:
имена узлов от корня модели вниз, соединённые /; каждое значение принимает position,
eulerAngles, scale, visible:
robot: {
model: asset('./robot.glb'),
position: [2, 0, 0],
overrides: {
'Body/Head': { eulerAngles: [0, 30, 0] },
'Body/ArmL': { position: [-0.7, 0.2, 0], visible: false },
},
},Сегмент — имя ребёнка; дубликаты среди соседей различаются как name[i], безымянные узлы — как
[i] (индекс внутри группы с одним именем). Эти пути частей — внутренняя для ассета половина
адреса: редактор соединяет их с путём узла дефа через :: (city/lamp::Body/Head). Редактор сцен
пишет их за тебя — раскрой узел-модель в дереве и потяни часть. Пути, которые больше не резолвятся
(GLB изменился), молча пропускаются. Переопределения применяются один раз при загрузке, после
инстанцирования модели; играющий анимационный клип перезапишет трансформы суставов, которыми он
управляет.
mount — прикрепление узла к внутренней части
Ребёнок узла-модели/префаба может сделать своим родителем один из внутренних узлов ассета
вместо его корня — фонарик в руке, маркер на кости. mount принимает ту же грамматику путей
частей, что и ключи overrides:
robot: {
model: asset('./robot.glb'),
children: {
flashlight: { mesh: { kind: 'cylinder', radius: 0.05 }, mount: 'Body/ArmR', position: [0, 0.2, 0] },
},
},Деф остаётся обычным ребёнком узла-модели — его путь (robot/flashlight), рефы и редактирование
работают без изменений; отличается только родитель в рантайме. Трансформ локален относительно
части, поэтому узел следует за частью через переопределения и анимацию. Путь, который больше не
резолвится, откатывается к корню ассета с предупреждением в консоль. В редакторе сцен перетащи узел
на строку части, чтобы примонтировать его; перетащи на модель (или куда-либо ещё), чтобы отмонтировать.
Узлы-камеры
camera: {} делает узел камерой сцены: пока сцена запущена, вид каждый кадр следует за мировым
трансформом узла (позиция + ориентация; камера смотрит вдоль локальной −Z узла). Поскольку это
обычный узел, всё компонуется — делай его дочерним, анимируй
сценарными аспектами (FollowPath на узле-камере — это облёт) или веди его
из своего аспекта:
camera: { camera: {}, position: [0, 5, 10], eulerAngles: [-26, 0, 0] },Блок несёт и объектив — fov (вертикальный, в градусах, по умолчанию 60), near (0.01) и far
(1000, дальность видимости). Опущенные ключи оставляют умолчание хоста, и, в отличие от позы,
проекция применяется и в редакторе (это свойство сцены, а не твоей точки обзора):
camera: { camera: { fov: 45, far: 5000 }, position: [0, 5, 10] },Побеждает первый узел-камера в порядке файла; узлы-камеры внутри prefab игнорируются (как и
собственные блоки camera:/env: префаба). Старый верхнеуровневый блок
camera: { position, target, fov?, near?, far? } продолжает работать как фолбэк, когда узла-камеры
нет — он один раз задаёт вид, и дальше ничто его не отслеживает. В визуальном редакторе узел-камера рисуется
маркером-фрустумом, собственная орбитальная камера редактора остаётся независимой, кнопка
инспектора Set from current view ставит узел из твоей точки обзора, а последний узел-камеру
нельзя удалить.
Empty (простые узлы-группы)
Узел без блока-источника — группа, невидимый трансформ. Редактор называет такие узлы Empty
и рисует для них маленький маркер-крест из осей во время редактирования (маркеры существуют только
в редакторе и никогда не входят в запущенную сцену). Empty — рабочий материал no-code-сцен: цели
MoveTo, вейпоинты FollowPath (дети узла-пути), объекты LookAt и просто папки для организации
children.
Заголовок сгенерированного файла
Каждая запись из визуального редактора начинает файл двухстрочным комментарием о том, что файл управляется редактором. Ручные правки остаются первоклассными — значения вне литеральной грамматики сохраняются дословно и показываются в инспекторе только для чтения — но форматирование, порядок ключей и округление чисел (4 знака) нормализуются при следующей записи редактором. Написанные вручную файлы сцен получают заголовок при первой записи редактором.
make(fn, args) — узлы, построенные кодом
Когда поддерево — естественный результат функции (линия забора, винтовая лестница, процедурный кластер), сошлись на фабрику вместо ручного описания узлов:
// props.ts — обычная функция, возвращающая Node
export const buildFence = (args: { posts?: number, gap?: number }): Node => { … }
// city.scene.ts
fence: {
make: make(buildFence, { posts: 6, gap: 1.2 }),
position: [-5.5, 0, 3.5],
},Фабрика выполняется после того, как существует каждый узел сцены (поэтому ref()-аргументы
резолвятся, включая ссылки вперёд), и может быть async; возвращённое поддерево монтируется под
узел дефа. Разделение владения — в этом суть:
- Деф владеет трансформом. Подвинуть/повернуть узел — обычная правка трансформа, фабрика для этого не перевызывается.
- Аргументы владеют содержимым. Аргументы должны быть литеральными данными (та же грамматика,
что у пропсов аспектов, включая
ref()); в редакторе сцен каждый аргумент — поле инспектора, и правка одного перевызывает фабрику вживую — без компиляции, ведь функция уже в запущенном бандле.ref()-аргумент также перевызывает при изменении узла, на который он ссылается.
Держи фабрики чистыми строителями: одни аргументы → одно поддерево, никаких побочных эффектов вне
возвращаемых узлов. make намеренно зеркалит use(Ctor, props) — вызываемый по идентификатору,
литеральные пропсы — именно это делает вызов редактируемым; вызов, записанный любым другим
способом (вычисленные аргументы, инлайн-функция), сохраняется дословно, но показывается как
«задано в коде».
prefab — сцена в сцене
Файл .scene.ts уже есть композиция, описанная данными — поэтому файлы сцен могут инстанцировать
друг друга. Импортируй хэндл другой сцены и используй его как блок-источник:
import streetlamp from './streetlamp.scene'
lamp: {
prefab: streetlamp,
position: [4, 0, 2],
overrides: { head: { visible: false } },
},Каждый экземпляр строит узлы префаба заново под простым узлом-обёрткой — экземпляры независимы, а правка файла префаба меняет каждый экземпляр при следующей загрузке. Семантика:
- Обёртка — обычный узел: трансформ,
visible,locked,aspects,childrenработают; внутренности экземпляра живут под ней. ref()внутри префаба резолвятся локально для файла, на экземпляр — аспекты иmake()-фабрики префаба видят узлы своего экземпляра и никогда — узлы инстанцирующей сцены. Внутренности префаба не появляются в ключуемой путями картеnodesинстанцирующего хэндла.overridesпозирует внутренности ровно как у GLB — та же грамматика путей частей, адресуемая именами узлов префаба ('pole/bulb'); вложенные модели внутри префаба сверлят глубже. В редакторе сцен раскрой узел-префаб в дереве и потяни часть — правка сохранится как переопределение.env/cameraпрефаба игнорируются при инстанцировании — сценой владеет инстанцирующий файл.- Префабы могут вкладываться; цикл импортов (сцена, инстанцирующая себя напрямую или транзитивно) обнаруживается при загрузке, и такой экземпляр пропускается с ошибкой в консоли.
use(Ctor, props)
Ссылается на аспект по классу — импорт и есть регистрация, а props проверяется типами по
полям аспекта ровно как node.aspect(Ctor, props). Поведение никогда не живёт в
файле сцены: напиши свой аспект в обычном .ts-файле и прикрепи его через use(...).
ref(path) — ссылки на узлы в пропсах аспектов
Пропс аспекта может указывать на другой узел сцены:
class Road extends Aspect<'road'> {
from: Node | null = null
to: Node | null = null
}
// в файле сцены — `road` может ссылаться на узлы, объявленные ниже него
road: { aspects: [use(Road, { from: ref('pointA'), to: ref('pointB') })] },
pointA: { position: [1, 0, 0] },
pointB: { position: [5, 0, 0] },Резолвинг идёт с подъёмом по области видимости от узла, несущего реф, как у переменных: сначала
пробуются собственные дети хоста, затем его соседи, затем область каждого предка вплоть до корня
сцены. Поэтому внутренние рефы продублированной группы связываются с её собственными копиями —
ref('pt1') внутри in1 находит in1/pt1, а не in0/pt1. Многосегментный путь (ref('lane/pt1'))
спускается из той области, которая содержит его первый сегмент — и эта привязка окончательна:
более близкая область, где есть первый сегмент, но нет остального, даёт null, а не продолжает
поиск наружу (лексическое затенение). Чтобы дотянуться в поддерево соседа, пиши путь через него
(ref('path1/p1')).
Сначала инстанцируются узлы, аспекты прикрепляются после, поэтому порядок объявления не важен.
Маркеры ref() резолвятся в живые узлы при прикреплении (верхнеуровневые пропсы и один уровень
массива вглубь — waypoints: [ref('a'), ref('b')] работает); неизвестный путь резолвится в null,
поэтому типизируй такие поля Node | null. Рефы адресуют только узлы дефа — никаких внутренних для
ассета сегментов :: или name[i]. Только для файлов сцен — рукописный код передаёт узлы
напрямую: node.aspect(Road, { from: nodes.pointA }). Редактор сцен показывает ref-поля как
дропдаун путей с кнопкой выбора во вьюпорте (достаточно аннотации типа Node | null;
editor: 'node' в static fields тоже работает) и пишет кратчайший реф, который резолвится от
хоста.
Метаданные инспектора (static fields)
Визуальный редактор выводит виджет для каждого публичного поля из его значения по умолчанию
(число, булев переключатель, цвет '#rrggbb', vec3 [x, y, z], строка). Опциональный
static fields на классе аспекта уточняет это — диапазоны, подписи, варианты дропдауна или
скрытие поля:
class Spinner extends Aspect<'spinner'> {
speed = 1
mode: 'local' | 'world' = 'local'
static fields: FieldMeta<Spinner> = {
speed: { min: 0, max: 20, step: 0.1 },
mode: { options: ['local', 'world'] },
}
}Аспекты-генераторы (static editor)
Аспект, который выводит содержимое сцены из своих пропсов — дорога между двумя узлами, забор вдоль пути — может подписаться на выполнение во время редактирования сцены:
class Road extends Aspect<'road'> {
from: Node | null = null
to: Node | null = null
width = 2
static editor = { rebuild: true }
onAttach() { this.rebuild() }
rebuild() {
this.generated.clear() // идемпотентно: очистить, затем создать
if (!this.from || !this.to) return
// …создаём меши через this.generated.add(mesh)…
}
}Контракт:
rebuild()перегенерирует вывод подthis.generated— добавленный в сцену узел-контейнер, предоставляемый до запускаonAttach/rebuild(узлы,add()-нутые в него, попадают в набор отрисовки автоматически;clear()уничтожает предыдущий вывод). Предоставляется для прикреплений из файла сцены; прикреплённые вручную аспекты его не получают.- Режим Play: ничего особенного —
onAttachвыполняется как обычно и сам вызываетrebuild(). - Режим редактирования: класс инстанцируется по-настоящему (в отличие от обычных аспектов,
которые остаются инертными данными) —
node/generatedустановлены,ref()-пропсы разрезолвлены,rebuild()вызван — но никогдаonAttach(никакой физики, таймеров и циклов во время редактирования). Затем редактор перезапускаетrebuild()при каждом изменении пропса в инспекторе или движении узла, на который ссылаетсяref()-поле — потяниpointA, и дорога последует. Бросающийrebuild()логируется и пропускается; уронить редактор он не может. - Сгенерированный вывод — производный, никогда не сохраняется: он не появляется ни в файле сцены, ни в дереве узлов редактора; файл хранит рецепт (запись аспекта), вьюпорт показывает результат.
Свои карточки инспектора (static inspector)
Для полного контроля над тем, как аспект выглядит в инспекторе редактора сцен, объяви
static inspector(ui, aspect) — immediate-mode-функцию (думай imgui): она перезапускается
при каждой правке или взаимодействии и описывает карточку вызовами InspectorUI. Без неё редактор
показывает выведенные поля; ui.auto() эмитит те же поля, поэтому своя карточка обычно начинается
с него и добавляет строки статуса, кнопки или динамические дропдауны:
class Road extends Aspect<'road'> {
from: Node | null = null
to: Node | null = null
width = 2
static editor = { rebuild: true }
static inspector(ui: InspectorUI, road: Road) {
ui.auto() // выведенные поля, как обычно
if (road.from && road.to) ui.info(`${road.generated.children.length} pieces`)
else ui.warn('Assign both endpoints')
if (ui.button('Shuffle')) road.rebuild() // true на прогоне с кликом — действуй прямо тут
}
}Словарь InspectorUI: поля — number / slider / text / color / switch /
select(key, options) / vec2 / vec3 / vec4 / node (каждое эмитит виджет И возвращает
текущее значение); действия и текст — button(label) (true на прогоне, потребившем клик),
header, info, warn; и auto(...keys) для выведенных полей.
Правило привязки — один словарь, два времени жизни: поле, чей ключ — объявленное поле
класса, привязано к документу (редактор сохраняет правки в файл сцены, с undo); любой другой
ключ — временное состояние редактора, хранится на карточку и никуда не пишется (выбор клипа
для превью, размер кисти). Опции select можно вычислять заново на каждом прогоне, так что живые
данные дают дропдауны бесплатно.
Аргумент aspect — живой редакторный экземпляр для классов-генераторов (static editor) или
превью-экземпляр (с установленным node, разрезолвленными ref'ами, никогда не прикреплённый) для
обычных аспектов. Бросающий инспектор логируется и рендерит строку-предупреждение — уронить
редактор он не может. Узлы-модели получают встроенную карточку Animation (клип / скорость / луп,
Play/Stop), построенную на этом же протоколе.
Код только для редактора (EDITOR + *.editor.ts)
EDITOR — булева константа времени компиляции: true в бандлах редактора сцен, false в
отгружаемых — и в продакшене компилятор сворачивает её в константу, поэтому ветки
if (EDITOR) { … } (и всё, на что ссылаются только они) удаляются целиком. Оборачивай в неё
отладочные хелперы, редакторные предупреждения или дорогую валидацию — по нулевой цене в поставке:
if (EDITOR && this.segments > 500) console.warn("road: very dense — consider fewer segments")*.editor.ts — файловая форма того же: такие файлы компилируются и выполняются только в
бандлах редактора (импортируются автоматически, после файла сцены) и невидимы для
продакшен-компиляций — продакшен-сборка их даже не парсит, и определение точек входа их
пропускает. Плагины редактора (окна, инструменты вьюпорта) живут в этих файлах — см.
editor-plugins.md.
Режим редактирования (как редактор сцен запускает файл сцены)
Когда редактор сцен le.codes запускает бандл сцены, он выставляет флаг хоста до выполнения:
источники инстанцируются по-настоящему (вьюпорт показывает сцену), но аспекты удерживаются как
данные без прикрепления — никаких побочных эффектов onAttach, никаких тиков update(),
физика инертна. Нажатие Play запускает проект нормально. Рукописный код никогда не видит этот
режим.