Skip to content

Vegetation placed on the GPU, and starter scenes - #10

Merged
Lincoln504 merged 2 commits into
pr/03-water-and-beddingfrom
pr/04-plants-and-starter-scenes
Jul 29, 2026
Merged

Vegetation placed on the GPU, and starter scenes#10
Lincoln504 merged 2 commits into
pr/03-water-and-beddingfrom
pr/04-plants-and-starter-scenes

Conversation

@Lincoln504

Copy link
Copy Markdown
Collaborator
  • Vegetation, placed and drawn without the CPU seeing an instance
  • Starter scenes, because a generator name is not a place

23 files changed, 2017 insertions(+), 37 deletions(-) · gate green at 172ac04 (fmt, clippy, workspace tests against a real adapter, wasm build, headless-Chromium smoke) · payload 382847 gz (113476 from the previous PR, against the 6 MB NFR-2 budget)

Part of a 12-PR stack over pr/00-ci. Based on pr/03-water-and-bedding; retargets to main once that lands.

⚠️ Please merge with a merge commit — squash or rebase rewrites the SHAs and breaks every PR above this one in the stack.

The design question is where the instances come from, and every obvious answer
routes tens of thousands of transforms through the CPU — and through the wasm
boundary — every time the terrain moves, which is every frame of a slider drag.
So they never leave the GPU. A compute pass walks a jittered grid over the map,
reads the finished heightmap, decides what grows at each slot, and appends it
straight into the instance buffer, raising the `instanceCount` word of that
species' `drawIndexedIndirect` arguments with an atomic. The render pass issues
one indirect draw per species and never learns how many plants it drew.

Changing the density is one dispatch of 256 workgroups. Moving the camera is
nothing at all. And because placement reads the *same* heightmap texture the
terrain is displaced from, a plant cannot end up hovering over ground that
moved after it was placed.

The constraints did most of the design:

- `firstInstance` is 0 in every draw, because non-zero needs the
  `indirect-first-instance` feature and compatibility mode has no features. A
  species' slice is reached by offsetting the instance vertex buffer, which is
  not a feature and cannot be missing.
- The heightmap arrives as a *texture* in both stages. Compatibility mode
  allows zero storage buffers in a vertex stage and four in a compute stage;
  spending one on heights a texture fetch could supply would leave nothing for
  the output.
- Instance data is one 8-byte vertex stream — `unorm16x2` position and a packed
  `uint32` — not four one-byte attributes, because V3D charges vertex pull per
  component and the unpacking is free ALU.
- Small solid meshes, not alpha-tested billboards. V3D has no hidden-surface
  removal and no Forward Pixel Kill, so a discarded fragment costs exactly what
  a drawn one costs and a cutout quad is *more* expensive than the geometry it
  was standing in for. It would also want a texture atlas, which is payload.

Where things grow is the material's business, not the document's: the species
mix comes from the preset and the altitude/slope windows echo the material's
own layer windows, so plants never stand on painted rock, on a slope, or under
water. What is left for the document is two numbers — how much, and how big.

No LOD chain (three draws is 0.6% of Arm's stated 500-draw budget) and no
per-frame depth sort. The second one is a real omission rather than a free
choice: front-to-back ordering does matter on V3D, and a GPU radix sort simply
costs more than the overdraw it saves at this instance count. It is the first
thing to revisit if the scatter grows, and that is written down where the
decision is.
The Create rail listed the four generators. "Ridged multifractal" is an
implementation detail, and now that the filter chain has bedding, erosion
concentration, plants and water-as-a-surface, the interesting settings
interact: a canyon is bedded rock with the carving biased to the lowlands and a
desert palette and no vegetation and the right sky, and reaching it from a
generator menu means finding four panels and knowing what to do in each.

So the rail offers eight landscapes instead — Alpine, Foothills, Red Canyon,
Monument, Island, Fjord, Ashfields, Moonscape. Each is a table entry rather
than a function, so the source reads as a description of eight places instead
of eight near-identical builders, and each lowers to plain commands: one undo
step, no special path through the document, and every part of it reachable by
hand afterwards. A preset is a starting point, not a mode.

Two things the tests pin down because they are the mistakes this shape invites.
A preset that wants no water has to *remove* the water the last one left, or
every scene after Island is quietly drowned. And the seed survives, so clicking
through the rail shows the same land under different conditions rather than
rerolling the world at every click — which is the difference between comparing
presets and gambling.

The browser smoke test earned its keep here: the rail is mounted before
`init()` runs, so reading the preset list at module scope threw inside
wasm-bindgen before any of our code could see it. Native tests were green and
the app was blank.
@Lincoln504
Lincoln504 merged commit 92ed11f into pr/03-water-and-bedding Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant