---
name: fluffy-grass
description: >
  Build billboard-card grass in one Three.js file: a six-card fanned clump,
  a canvas alpha atlas, instancing through a TSL positionNode, fake AO along
  uv.y, dome normals, and a depth-only shadow cut-out. Use when adding or
  changing grass, tufts, foliage cards or wind on alpha-tested planes. The
  reference file is grass.html beside this skill. If they disagree, the HTML wins.
---

# Fluffy grass

One HTML file. No bundler, no npm, no texture pack. Three.js 0.186.1 from a CDN via an import map (`three.webgpu.js`, `three.tsl.js`, addons). `WebGPURenderer` only. Set `renderer._getFallback = null`. If `navigator.gpu` is missing, or the backend is not WebGPU after `init()`, stop. No WebGL fallback. No GLSL strings.

`MeshLambertNodeMaterial` for the grass. Lambert because the material has to be lit for three to set up shadow nodes. Standard adds specular and roughness the blades do not use. The alpha atlas is a canvas texture drawn at startup.

Open it through a server. `file://` will not load the ES modules.

## The clump

Six cards, merged, about 60 vertices.

- Two near-vertical, crossed at 90°, with a slight opposite lean.
- Four leaning outward at about 0.46 radians, spaced at 45° increments.

Two crossed cards read as a criss-cross from above. The fan reads as a rounded tuft.

Each card samples a different quarter of a 4-tile atlas. Remap UVs at build time so all six stay one draw. Keep blades inside the tile or mipmaps bleed across tiles.

`uv.y` is 0 at the root and 1 at the tip on every card. Wind bend and the AO gradient both use it. Do not break that if the geometry changes.

Blade silhouette, in `createGrassAlphaAtlas`: `BLADE_BASE` (half-width at the ground), `BLADE_BASE_JITTER`, `BLADE_TAPER` (width surviving to mid-height, lower is pointier).

## Instancing

8 by 8 chunks. Each chunk is one `InstancedBufferGeometry` sharing the clump's index, position, normal and uv, plus:

- `aOffset` vec3, absolute world position
- `aParams` vec4, Y rotation, XZ scale, Y scale, wind phase
- `aVar` vec2, patch noise, per-clump brightness

Do not use `InstancedMesh`. Own the transform in `positionNode`, and assign that same node to `castShadowPositionNode`. Without it the shadow pass knows nothing about instancing or wind, and every clump casts from the origin.

Chunk meshes sit at the origin. `aOffset` is absolute, so the wind field does not seam. Set `boundingBox` and `boundingSphere` yourself. `computeBoundingSphere()` on an instanced geometry only sees the base clump.

## Shading

`positionNode`, in order:

1. Scale by `aParams.yz`, rotate about Y by `aParams.x`, translate by `aOffset`.
2. Wind: a travelling sine from world XZ plus `aParams.w`, plus a scrolling `mx_noise_float`.
3. Weight by `uv.y` squared. Roots stay pinned.
4. Subtract a little Y from `abs(displacement)` so the blade bows instead of shearing.

`colorNode`:

- Fake AO: `mix(root, tip, smoothstep(0, 0.95, uv.y) ^ 1.35)`. This is most of the look. Equal root and tip colours make the field flat.
- Tip mix: `mix(patchNoise, shortness, uHeightTint)`.
- `aShortness` maps vertical scale through `smoothstep(uScaleMin, uScaleMax, aParams.z)`, 0 tallest, 1 shortest. `buildGrass()` writes the real range into those uniforms.
- Short clumps blend the dark root toward the tip by `uHeightLift`. `uv.y` is normalised per card, so without this a short clump has the same black root as a tall one.

`normalNode`: a dome normal, straight up blended toward the clump's outward direction, then view space. Card normals flip per face under double-sided rendering. Dome blend 0 shows that artefact on purpose.

`opacityNode` is the atlas alpha. `alphaTestNode` eases from 0.42 to 0.14 between 8m and 48m, because mipmaps thin the blades.

## Growth

`grassGrowth(x, z)` is low-frequency fbm. 0 is bare, 1 is lush. It drives density, height and, through height, colour. One field, or the patches disagree.

- Reject below 0.02. Otherwise keep with probability `0.2 + growth * 0.8`, so edges fray.
- `scaleY = (0.42 + growth * heightRange) * random`.
- Thin the steepest slopes so cards do not float off a cliff.

`terrainHeight(x, z)` is the only elevation. The terrain mesh and the scatter both call it. They must not drift.

## Shadows

Assign `castShadowPositionNode` to the grass position node.

Cut the shadow with `alphaMap` and a fixed `alphaTest` (0.32). Do not use `castShadowNode` for a plain discard. That node returns a vec4, so three requires `shadowMap.transmitted`, a colour target and an extra pass per casting light. `alphaMap` / `alphaTest` stay depth-only. They are inert in the camera pass, where `opacityNode` and `alphaTestNode` override them. The distance ramp is camera-relative and wrong from the light, so the shadow threshold stays fixed.

`receivedShadowNode`: `mix(tint, white, shadow)`. Shadows lift instead of going black. Same hook on the terrain, or the shadow changes colour when it crosses onto soil.

Grass casting is the expensive switch. It rasterises the alpha-tested cards again from the light. Receiving is cheap. Default the demo to casting so the hook is visible, and say so.

`sun.shadow.bias` around -0.0012. Thin alpha-tested blades acne.

## Cost, if this goes into a level

Alpha testing gives up early-Z. Overdraw is the technique: six cards, overlapping. Measure fragment invocations, not frame time alone. Then:

1. Depth-prepass the opaque world, excluding the grass. Draw grass after, depth test less-equal. Do not put the grass in the prepass.
2. Cut draw distance before adding LODs. LOD per chunk, not per clump: full fan near, fewer cards mid, one card or baked terrain colour far. Match the terrain tint to the grass or the edge pops.
3. Wider blades in the atlas cover the same density with fewer shaded pixels.
4. Keep the fragment shader at the fake AO. More richness belongs in the vertex stage or in more geometry, not in a normal map.

Shadow order, cheap to costly: grass casts nothing (fake contact darkening on the terrain), grass casts only on the nearest chunks, then the full map this file uses.

Beyond a few hundred metres, keep the clump, the atlas, the AO, the wind, the dome normal and the growth coupling. Replace the startup scatter, the fixed chunk grid, absolute offsets and the single shadow camera. Those are demo scaffolding. A streaming ring of chunks, a floating origin once float32 position snaps, and the same heightmap the terrain is displaced by. Grass on a different height floats.
