Skip to content

Latest commit

 

History

History
187 lines (142 loc) · 7.91 KB

File metadata and controls

187 lines (142 loc) · 7.91 KB

Implementation Notes

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.

Field and chunk layout

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

Three tiers, one silhouette

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.

The hand-off is complementary, not a cross-fade

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.

Two compute passes, and skipping both

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.

Interaction fields

Two separate fields, because trampling and cutting have opposite lifetimes.

Trail: local, recovering

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.

Cut: global, permanent

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 ping-pong access declaration matters

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.

Lighting and shadows

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.

Colouring

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.

Diagnostics

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.

Verification

npm run lint
npm test     # production build + static output + base path + GPU pipeline wiring

Coverage stops at the build and the wiring. Visual behaviour still needs a live browser pass: LOD hand-off continuity, trail recovery, cut persistence, wind.