Level — процедурная расстановка

Расставь много копий одной вещи по шаблону — вдоль линии, по сетке, по кольцу, рассеянно по площади или по вручную выбранному списку точек — и получи их обратно как ОДИН узел-группу Node. Сделано для фабрик make() в файлах сцен: линия забора или лес — это один узел сцены с парой редактируемых аргументов вместо сотен записей с координатами. Работает и из обычного кода — добавь возвращённую группу в сцену.

Обзор

TypeScript
// level/props.ts — фабрики make()
export const fence = (args: { posts?: number, length?: number }) =>
  Level.line({
    from: [0, 0, 0], to: [args.length ?? 10, 0, 0], count: args.posts ?? 6,
    build: () => Mesh.box({ size: [0.1, 1, 0.1] }), name: 'post',
  })

export const forest = (args: { count?: number, seed?: number }) =>
  Level.scatter({
    size: [40, 40], count: args.count ?? 30, minSpacing: 2.5, seed: args.seed ?? 1,
    avoid: [{ x: 0, z: 0, r: 5 }],                 // держать спавн свободным
    build: asset('../assets/tree.glb'), collider: 'convex',
  })

// yard.scene.ts
import { fence, forest } from './level/props'

export default defineScene({
  nodes: {
    fence: { make: make(fence, { posts: 6, length: 10 }), position: [-5, 0, 3] },
    trees: { make: make(forest, { count: 30, seed: 7 }) },
  },
})

Каждая координата, которую принимает хелпер, локальна для возвращённой группы; узел сцены, который её make()-ает, владеет мировым трансформом (выше post1 оказывается в мировой [-5, 0, 3]).

Хелперы

TypeScript
Level.line(opts: LevelLineOptions): Promise<Node>        // вдоль отрезка — заборы, стены, ряды фонарей
Level.grid(opts: LevelGridOptions): Promise<Node>        // решётка cols × rows — ящики, парковки
Level.ring(opts: LevelRingOptions): Promise<Node>        // окружность или дуга — колонны, каменные круги
Level.scatter(opts: LevelScatterOptions): Promise<Node>  // сидированный случайный разброс в прямоугольнике — леса, обломки
Level.place(opts: LevelPlaceOptions): Promise<Node>      // явный список точек
Level.rng(seed: number): () => number                    // сидированный ГПСЧ, который они используют (mulberry32), [0, 1)

Каждый возвращает простую, безымянную, неподвешенную группу, чьи дети — размещения. Из фабрики make() её монтирует загрузчик сцены; из кода — scene.add(await Level.grid({ … })).

TypeScript
// line — count: размещения, концы включены (count > 1); или step: шаг в м от `from`
// к `to`, последнее ≤ `to`. Ни того ни другого → 2 (концы). Шаг измеряется в XZ; y интерполируется.
{ from: Vec3Like, to: Vec3Like, count?: number, step?: number }

// grid — cols вдоль X, rows вдоль Z, с центром в `center`
{ cols: number, rows: number, spacing: number | [alongX, alongZ], center?: Vec3Like }   // center по умолчанию [0, 0, 0]

// ring — start: градусы, 0 = +X, растёт к +Z; arc: градусы (360 = полное кольцо, последняя точка
// на один шаг раньше первой; дуга ставит оба конца)
{ radius: number, count: number, center?: Vec3Like, start?: number, arc?: number, facing?: 'in' | 'out' }
//                                                    по умолчанию 0     по умолчанию 360    по умолчанию 'in'

// scatter — прямоугольник `size` [x, z] (одно число = квадрат) вокруг `center`, в плоскости на `y`
{ size: number | [number, number], count: number, center?: Vec3Like, y?: number,   // y по умолчанию: y центра
  minSpacing?: number,                                             // м между любыми двумя размещениями
  avoid?: { x: number, z: number, r: number }[] | ((x: number, z: number) => boolean) }  // круги, или true = отклонить

// place — [x, y, z] или [x, y, z, yawDegrees] на точку
{ at: number[][] }

scatter сэмплирует с отклонением в бюджете count × 30 попыток; когда minSpacing / avoid оставляют слишком мало места, он ставит, что поместилось, и предупреждает ([Level.scatter] placed 3/10 — the area is too tight for minSpacing/avoid).

Общие опции

TypeScript
build: string | ((spot: LevelSpot) => Node | null | Promise<Node | null>)   // что ставить
collider?: 'mesh' | 'convex' | 'box' | 'none'   // только для build по URL; по умолчанию 'none'
yaw?: number | 'random' | 'face' | 'align'      // градусы вокруг Y или правило; умолчание у каждого хелпера своё
jitter?: number | [maxX, maxZ]                  // случайное смещение по XZ на точку, м (по каждой оси ±)
seed?: number                                   // по умолчанию 1
name?: string                                   // префикс имени детей

type LevelSpot = { index: number, position: [x, y, z], yaw: number, rng: () => number }
  • build как URL (asset('./crate.glb')): GLB загружается один раз и клонируется на каждую точку. collider тогда прикрепляет Shape + Physics { motion: 'static' } к каждой копии — 'mesh' точные треугольники, 'convex' их оболочка, 'box' бокс, подогнанный под границы меша (physics.md).
  • build как функция получает каждую точку — её index с нуля, её локальную position (с учётом jitter), её yaw в градусах и rng, сидированный поток для вариаций на точку (отдельный от потока раскладки) — и возвращает узел для размещения или null, чтобы пропустить точку. Она сама прикрепляет свои аспекты (включая коллайдеры); collider не применяется.
  • yaw: 'face' поворачивает «вперёд» каждого узла (+Z) вдоль направления хелпера, 'align' кладёт ось X модели вдоль него (панели заборов и стен), 'random' — целое число градусов из потока, число — этот угол. Умолчания: line 'align'; ring 'face' = к центру (facing: 'out' = от него); grid и place 'face', что там равно 0; scatter 'random'.
  • Имена — префикс + индекс точки с единицы (post1, post2, …); пропущенная точка оставляет свой номер неиспользованным. Префикс — name, иначе имя файла из URL, иначе item.
Note

хелпер пишет position и — когда yaw не 0 — eulerAngles = [0, yaw, 0] на узел, который возвращает твой build, поверх того, что задал билдер. Чтобы наклонить или сместить размещение, верни группу с позированным узлом внутри.

Note

передавай name всякий раз, когда код ищет размещения по имени. Там, где нативный хост загружает ассеты из локальных файлов (desktop run, render --desktop / --sim, приложение, которое везёт ассеты с собой), URL asset() — внутренний handle id:, а не путь, и имена по умолчанию получаются как этот handle плюс индекс (id:1011, id:1012, … под render --sim) вместо tree1, tree2, ….

Детерминизм

Те же опции и seed → та же раскладка, при каждом запуске и на каждом хосте — редактор сцен перезапускает фабрику make() при каждой правке аргумента, и ничего не перемешивается. Каждый хелпер берёт свою раскладку (jitter, случайный yaw, точки scatter) из собственного потока seed; spot.rng — другой поток того же сида, так что то, что функция build с ним делает, никогда не сдвигает раскладку. Level.rng(seed) — тот же генератор, для собственной расстановки, которая должна оставаться воспроизводимой рядом с ними:

TypeScript
const rng = Level.rng(42)
const scale = 0.8 + rng() * 0.4          // [0.8, 1.2), одно и то же при каждом запуске
Note

в уровне с запечённым освещением (env.lightmap) вывод make() — код, а не узлы сцены — регистрируй статику сам через Lightmap.add (lightmap.md).

Смотрите также

  • scene-files.md — узлы make(fn, args), формат файла сцены
  • model.md — что загружает build по URL
  • physics.md — Shape / Physics, что прикрепляет collider