Level — procedural placement

Level places many copies of one thing in a pattern — along a line, on a grid, around a ring, scattered over an area, or at a list of spots — and returns them as ONE group Node. Built for make() factories in scene files (a fence or a forest is one node with a few editable args); from plain code, add the returned group to a scene.

  • place a row of fence posts or lamps along a line
  • lay out crates on a grid
  • arrange pillars in a circle or an arc
  • scatter trees or rocks at random over an area, keeping the spawn clear
  • place copies at a hand-picked list of positions
  • give the placed models static colliders
  • get the same random layout on every run

At a glance

TypeScript
// level/props.ts — make() factories
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 }],                 // keep the spawn clear
    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 }) },
  },
})

Every coordinate a helper takes is local to the returned group; the scene node that make()s it owns the world transform (above, post1 lands at world [-5, 0, 3]).

The helpers: Level.line, grid, ring, scatter, place

TypeScript
Level.line(opts: LevelLineOptions): Promise<Node>        // along a segment — fences, walls, lamp rows
Level.grid(opts: LevelGridOptions): Promise<Node>        // cols × rows lattice — crates, parking lots
Level.ring(opts: LevelRingOptions): Promise<Node>        // a circle or an arc — pillars, stone circles
Level.scatter(opts: LevelScatterOptions): Promise<Node>  // seeded random in a rectangle — forests, rubble
Level.place(opts: LevelPlaceOptions): Promise<Node>      // an explicit spot list
Level.rng(seed: number): () => number                    // the seeded PRNG they use (mulberry32), [0, 1)

Each returns a plain, unnamed, unparented group whose children are the placements. From a make() factory the scene loader mounts it; from code, scene.add(await Level.grid({ … })). Each helper also takes the common options (build, collider, yaw, jitter, seed, name).

Along a line: Level.line

TypeScript
// count: placements, endpoints included (count > 1); or step: spacing in m from `from`
// toward `to`, the last one ≤ `to`. Neither → 2 (the endpoints). Spacing is measured in XZ; y interpolates.
{ from: Vec3Like, to: Vec3Like, count?: number, step?: number }

The fence in At a glance is a line of 6 posts over 10 m.

On a grid: Level.grid

TypeScript
// cols along X, rows along Z, centred on `center`
{ cols: number, rows: number, spacing: number | [alongX, alongZ], center?: Vec3Like }   // center default [0, 0, 0]
TypeScript
const scene = new Scene()
scene.add(Light.sun())
const main = async () => {
  scene.add(await Level.grid({
    cols: 4, rows: 3, spacing: [1.5, 2],          // 4 along X, 3 along Z
    build: () => Mesh.box({ size: 1 }), name: 'crate',
  }))
  scene.open()
}
main()

In a circle or an arc: Level.ring

TypeScript
// start: degrees, 0 = +X, increasing toward +Z; arc: degrees (360 = full ring, the last spot
// one step short of the first; an arc places both endpoints)
{ radius: number, count: number, center?: Vec3Like, start?: number, arc?: number, facing?: 'in' | 'out' }
//                                                    default 0          default 360    default 'in'
TypeScript
const scene = new Scene()
scene.add(Light.sun())
const main = async () => {
  // 8 pillars on a 6 m circle; arc: 180 would make a half ring
  scene.add(await Level.ring({
    radius: 6, count: 8,
    build: () => Mesh.box({ size: [0.6, 3, 0.6] }), name: 'pillar',
  }))
  scene.open()
}
main()

Scatter at random over an area: Level.scatter

TypeScript
// rectangle `size` [x, z] (one number = square) around `center`, planar at `y`
{ size: number | [number, number], count: number, center?: Vec3Like, y?: number,   // y default: center's y
  minSpacing?: number,                                             // m between any two placements
  avoid?: { x: number, z: number, r: number }[] | ((x: number, z: number) => boolean) }  // circles, or true = reject

avoid keeps spots out of circles (a spawn, a path) or wherever the function returns true. The forest in At a glance scatters 30 trees 2.5 m apart around a clear spawn.

scatter rejection-samples within a budget of count × 30 attempts; when minSpacing / avoid leave too little room it places what fits and warns ([Level.scatter] placed 3/10 — the area is too tight for minSpacing/avoid).

At hand-picked spots: Level.place

TypeScript
// [x, y, z] or [x, y, z, yawDegrees] per spot
{ at: number[][] }
TypeScript
const scene = new Scene()
scene.add(Light.sun())
const main = async () => {
  scene.add(await Level.place({
    at: [[0, 0, 0], [3, 0, 1, 90], [-2, 0, 4, 45]],
    build: asset('./crate.glb'), collider: 'box', name: 'crate',
  }))
  scene.open()
}
main()

Common options: what to place, colliders, rotation, names

TypeScript
build: string | ((spot: LevelSpot) => Node | null | Promise<Node | null>)   // what to place
collider?: 'mesh' | 'convex' | 'box' | 'none'   // url builds only; default 'none'
yaw?: number | 'random' | 'face' | 'align'      // degrees about Y, or a rule; default per helper
jitter?: number | [maxX, maxZ]                  // random XZ offset per spot, m (each axis ±)
seed?: number                                   // default 1
name?: string                                   // child name prefix

type LevelSpot = { index: number, position: [x, y, z], yaw: number, rng: () => number }
  • build as a URL (asset('./crate.glb')): the GLB loads once and is cloned per spot. collider then attaches Shape + Physics { motion: 'static' } to each copy — 'mesh' the exact triangles, 'convex' their hull, 'box' a box fitted to the mesh bounds (physics.md).
  • build as a function gets each spot — its 0-based index, its local position (jitter included), its yaw in degrees and rng, a seeded stream for per-spot variation (separate from the layout's) — and returns the node to place, or null to skip the spot. It attaches its own aspects (colliders included); collider does not apply.
  • yaw: 'face' turns each node's forward (+Z) along the helper's direction, 'align' lays the model's X axis along it (fence and wall panels), 'random' a whole degree from the stream, a number that angle. Defaults: line 'align'; ring 'face' = toward the centre (facing: 'out' = away); grid and place 'face', which is 0 there; scatter 'random'.
  • Names are the prefix + the 1-based spot index (post1, post2, …); a skipped spot leaves its number unused. The prefix is name, else the URL's file name, else item.
Note

the helper writes position and — when the yaw is not 0 — eulerAngles = [0, yaw, 0] on the node your build returns, over whatever the builder set. To tilt or offset a placement, return a group with the posed node inside it.

Note

pass name whenever code looks placements up by name. Where a native host loads the assets from local files (desktop run, render --desktop / --sim, an app that ships its assets), an asset() URL is an internal id: handle, not a path, and the default names come out as that handle plus the index (id:1011, id:1012, … under render --sim) instead of tree1, tree2, ….

Same layout every run: seed and Level.rng

Same options and seed → the same layout, on every run and every host — the scene editor re-runs a make() factory on every arg edit, and nothing shuffles. Each helper draws its layout (jitter, random yaw, scatter points) from its own stream of seed; spot.rng is another stream of the same seed, so what a build function does with it never moves the layout. Level.rng(seed) is the same generator, for a custom placement that must stay reproducible beside them:

TypeScript
const rng = Level.rng(42)
const scale = 0.8 + rng() * 0.4          // [0.8, 1.2), the same every run
Note

in a level with baked lighting (env.lightmap), make() output is code, not scene nodes — register the statics yourself with Lightmap.add (lightmap.md).

See also