TrackScatter
<TrackScatter> stands copies of registered models along a track: a row of trees each side, a line of posts down the lane, a rock at 120 m and 1.5 m right. You place every copy in track coordinates, the engine seats it on the drawn ground, and each model draws through one InstancedModel, so a forest of two species costs two draws per mesh.
Its props are in TrackScatterProps.
Placements
Section titled “Placements”Track coordinates are metres along the centerline, metres across it (positive to the right of travel), and metres above the surface. A placement is one of the following:
- A copy:
atmetres along. It may name itsmodel,turnandscale; left out, the model is drawn by weight, the turn is hashed and the scale is 1. - A row: copies about
spacingmetres apart fromstarttoend, the whole track by default, each in the middle of its own cell. The spacing rounds to the nearest that cuts the stretch into whole cells, so the row ends where its stretch does. A spacing of 0 or less throws.
Both take the following fields:
across: metres across, measured fromfrom.from:"center"by default, or the track’s"left"or"right"edge, as a TrackSurface cross-section measures. A row measured from an edge keeps its distance to that edge as the track widens.height: metres above the ground the copy is seated on.
A row takes the following fields as well:
jitter: metres a copy wanders across, the whole range.slide: the share of its cell a copy may slide along. Keep it under 1, or neighbours leave a gap.size: the low and high multiple of the model’s size, spread evenly over the copies.stretch: the low and high multiple of a copy’s height over its width, so squat and slender copies mix.
models names each model with how often it is drawn, such as { fir: 3, spruce: 1 }. A model with weight 0 stands only where a copy names it, and a copy that needs a draw throws when no model weighs more than 0.
Every draw is a hash of the copy’s place in the list, so the same placements stand the same copies on every device. placeTrackScatter(options) returns the same list without drawing it, for a headless test or a tool that reads a course as numbers.
Seating and clearing
Section titled “Seating and clearing”Pass the drawn ground as surface, the geometry buildTrackSurface returns. A ray straight down seats each copy on the triangles drawn, so a copy never floats over a wide span of hillside. A copy with no surface under it is left out. Without a surface, a copy stands at the track’s own height.
A copy is also left out where it stands nearer another leg of the track, past the middle of a hairpin or on the inside of a tight bend, by the same rule the surface clips its sides by.
clear keeps rows off the single copies: a row copy within clear metres of one steps that far out, away from the centerline, and is left out if it is still too near.
tint(index, model) returns the colour a copy is multiplied by, from its index among its model’s copies. Hash the index for a colour that differs from copy to copy.
Where it runs
Section titled “Where it runs”Client. The copies have no colliders and spawn no entity, and a headless scene draws nothing. A rock the rider hits or a coin she picks up is a TrackTrigger. The copies are never culled one by one and have no level of detail.
Unity’s SplineInstantiate and Unreal’s PCG spline sampler place copies along a path. Godot and Roblox leave it to a script. TrackScatter shares its word with the map’s scatter, and places in track coordinates so an agent writes a treeline as a short list of numbers.
Sample
Section titled “Sample”Two rows of firs each side, and a rock in the lane:
import { buildTrackSurface, registerModel, Track, TrackScatter, TrackSurface, type TrackScatterCopy, type TrackScatterRow,} from "@daniel-zarinski/engine";import fir from "./fir.glb?url";import rock from "./rock.glb?url";
registerModel("fir", fir);registerModel("rock", rock);
const track = new Track([ { x: 0, y: 0, z: 0, width: 10 }, { x: 10, y: -8, z: -60, width: 12 }, { x: 0, y: -16, z: -120, width: 10 },]);const ground = buildTrackSurface({ track, section: [ { from: "left", across: -30, height: 5 }, { from: "left", across: 0 }, { from: "right", across: 0 }, { from: "right", across: 30, height: 5 }, ],});const models = { fir: 1, rock: 0 };const placements: (TrackScatterCopy | TrackScatterRow)[] = [ { at: 80, across: 1.5, model: "rock" }, ...[-1, 1].flatMap((side) => [3, 10].map((out): TrackScatterRow => ({ from: side < 0 ? "left" : "right", across: side * out, spacing: 6, jitter: 2, size: [2, 3], })), ),];
export function Course() { return ( <> <TrackSurface geometry={ground} materials={{ snow: <meshStandardMaterial color="white" /> }} /> <TrackScatter track={track} surface={ground} models={models} placements={placements} /> </> );}