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
// 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
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
// 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
// cols along X, rows along Z, centred on `center`
{ cols: number, rows: number, spacing: number | [alongX, alongZ], center?: Vec3Like } // center default [0, 0, 0]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
// 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'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
// 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 = rejectavoid 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
// [x, y, z] or [x, y, z, yawDegrees] per spot
{ at: number[][] }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
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 }buildas a URL (asset('./crate.glb')): the GLB loads once and is cloned per spot.colliderthen attachesShape+Physics { motion: 'static' }to each copy —'mesh'the exact triangles,'convex'their hull,'box'a box fitted to the mesh bounds (physics.md).buildas a function gets each spot — its 0-basedindex, its localposition(jitter included), itsyawin degrees andrng, a seeded stream for per-spot variation (separate from the layout's) — and returns the node to place, ornullto skip the spot. It attaches its own aspects (colliders included);colliderdoes 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);gridandplace'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 isname, else the URL's file name, elseitem.
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.
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:
const rng = Level.rng(42)
const scale = 0.8 + rng() * 0.4 // [0.8, 1.2), the same every runin 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
- scene-files.md —
make(fn, args)nodes, the scene-file format - model.md — what a URL build loads
- physics.md —
Shape/Physics, whatcolliderattaches