English · 简体中文
Detail behind the README: how three LOD tiers hand off without cross-fading, how the interaction fields are laid out, and which work is skipped when nothing has moved.
| Constant | Value |
|---|---|
MAX_BLADES |
2,000,000 |
FIELD_EXTENT |
220 m (440 × 440 m field) |
CHUNK_SIZE |
8 m |
CHUNKS_PER_AXIS |
55 |
CHUNK_COUNT |
3,025 |
CULL_WORKGROUP_SIZE |
256 |
Blade records are laid out contiguously by chunk, and within a chunk they are stored in a deterministic random order. That ordering is what makes density LOD cheap: taking the first n records of a chunk yields a uniformly distributed random subset, so reducing density is just shortening a prefix.
Deciding whether a distant blade survives costs no per-instance hash at all. It is a prefix length, computed once for the whole chunk. Past the full-detail radius that length follows a continuous curve, with no visible step:
| Constant | Value |
|---|---|
LOD_FULL_DENSITY_DISTANCE |
28 m |
LOD_DENSITY_POWER |
1.08 |
LOD_DENSITY_FALLOFF |
5.8 |
LOD_MIN_DENSITY |
0.012 |
The three tiers share height, three-blade layout, and outline language on purpose, so switching tiers changes cost without changing what the grass is.
| Tier | Geometry | Segments |
|---|---|---|
| Near | 3-blade cluster | primary 4 segments, two secondary blades 2 each |
| Mid | same 3 blades, same orientation and root offsets | 1 cone segment each |
| Far | single camera-facing card | 1 |
All three reuse the same procedural blade-width curve. An earlier iteration
switched to a sparse GrassOpacity clump at distance and paid for it twice: the
silhouette changed, and the alpha-test blank regions inside each clump became
overdraw. Nothing in the current tiers has an alpha-tested hole in it.
| Transition | Range |
|---|---|
| Near → mid | 18 m → 32 m |
| Mid → far | 60 m → 84 m |
Inside a transition band, each root is assigned to exactly one tier by comparing its fixed per-root random value against the blend factor. A root is never drawn twice. That decision did more for frame time than anything else here:
The first three-tier implementation drew both models fully through the transition and alpha-faded between them. It had 23,936 visible instances against the original's 69,052, and it ran slower (43.5 FPS vs 53.5 FPS). Overlapping draws plus three separate material graphs cost more than the geometry saved.
Coverage survives a switch because all three tiers keep the same root set, and
because the far tier's single card is width-calibrated to the average projected
width of mid's three (3 × 2/π ≈ 1.91). The instance count ends up higher than
the naive version's. Density and silhouette stopped popping, which was the point.
chunk cull compute → compact visible chunk IDs + indirect dispatch args
visible-chunk dispatch → classify roots by LOD → 3 ID partitions + 3 counters
Visible root IDs for all three tiers are compacted with atomicAdd into three
fixed partitions of a single storage buffer. The three atomic counters are read
directly as the instanceCount of three drawIndirect calls, with no readback.
All three geometries share one Lambert NodeMaterial. Three tiers with three
material graphs means three shader compilations and three pipeline states; one
shared graph means the tier is a vertex-stage branch on geometry, not a material
switch.
The passes are skipped entirely when camera XZ, camera heading, draw distance, and blade budget are all unchanged from the previous frame. The visible ID buffer and indirect arguments from the last frame stay valid. Wind, interaction textures, and the draws still run every frame. A stationary camera over moving grass costs no culling at all.
Note what is not in that key: camera pitch and roll. The chunk frustum is tightened horizontally, and pitching does not change which 8 m chunks are in range, so including pitch would invalidate the cache for nothing.
Two separate fields, because trampling and cutting have opposite lifetimes.
| Constant | Value |
|---|---|
INTERACTION_RESOLUTION |
256² |
INTERACTION_EXTENT |
48 m |
| Texel size | 18.75 cm |
TRAIL_HOLD_SECONDS |
0.15 |
TRAIL_RECOVERY_SECONDS |
0.65 |
A ping-pong pair of 256² storage textures that follows the actor. RG stores
bend direction, B stores the walk trail. Grass springs back within 0.65 s after
a 0.15 s hold.
| Constant | Value |
|---|---|
CUT_INTERACTION_RESOLUTION |
512² |
CUT_INTERACTION_EXTENT |
440 m (whole field) |
CUT_STAMP_SIZE |
48² |
Cutting writes to a fixed world-space texture covering the entire site, so it neither follows the actor nor recovers. Near-tier blades are permanently shortened to roughly 14% height, leaving stubble.
The compute pass declares the source texture as read-only storage access and the destination as write-only. Getting this wrong is a silent failure: reading a compute-written texture as an ordinary sampled texture returns empty state rather than an error, so the field appears to do nothing.
The blade vertex stage samples only whichever texture is currently active. Each
frame the CPU swaps the texture underneath the TextureNode, which costs one
sample per vertex. The alternative, reading both interaction maps in the shader
and blending, would cost two samples and a mix on every blade.
Ground and capsule use standard Three.js PBR materials. The two million blades use
a lighter Lambert NodeMaterial that still participates in framework lighting.
Both paths run through the same hemisphere light, shadow-casting directional light,
sRGB output, and ACES tone mapping, so the grass does not read as a separate scene.
Shadows are a 1024² PCF map updated at at most 30 Hz, and only while the actor
moves (SHADOW_UPDATE_INTERVAL = 1/30). A stationary camera and actor stop
re-rendering the same shadow map every frame. Blades keep directional lighting;
the capsule's shadow is received by the ground.
There is no hard-coded green ramp anywhere. A blade's root inherits the ground colour beneath it and the body layers an adjustable tint on top, which is what keeps grass and terrain from drifting apart as either one is retuned.
One deterministic RGBA noise texture drives all the variation, a concern per channel: biome zoning, local patchiness, detail shading, per-blade variation. The ground compresses a hand-painted brush texture into a low-contrast detail layer and reads its green/soil zoning off that same noise. Blade roots run the identical world coordinates through the identical zoning formula, so a patch of soil grows short matching grass instead of green blades on brown ground.
The chunk-count and tier-split readouts re-run the same chunk, density, and LOD formulas on the CPU at low frequency. They are estimates, and they can disagree slightly with what the GPU dispatched. Getting exact numbers would mean reading a buffer back every frame, which stalls the pipeline this whole project is built to keep asynchronous.
FPS, average frame time, and 1% Low all use the most recent one-second window. 1% Low is the mean frame time of the slowest 1% of frames in that window, plus its equivalent FPS. The panel fills its window from real frames at startup rather than waiting a second to show anything.
npm run lint
npm test # production build + static output + base path + GPU pipeline wiringCoverage stops at the build and the wiring. Visual behaviour still needs a live browser pass: LOD hand-off continuity, trail recovery, cut persistence, wind.