Two crossed planes look like a mess from above, so I fan six of them out and darken the root, and from a standing height that reads as a tuft.
This is one HTML file on Three.js 0.186.1, WebGPU and TSL, with no bundler and no texture pack. The alpha is drawn on a canvas when the page starts. The clump itself comes from Ebenezer's Codrops piece, and the shading here is node materials. I use AI to write a lot of the TSL. The shape of the clump, and whether the grass is allowed to cast a shadow, come from 25+ years of JavaScript, game development, modding and level design.
The clump
A clump is six cards merged into one geometry. Two stand nearly upright and cross, and four lean out at about 26°, spaced around the stem. From above, that lean is what makes the tuft look rounded. Two cards on their own just read as a criss-cross.
Each card takes a quarter of a 4-tile alpha atlas, and those UVs are written in at build time so the six cards stay one draw. uv.y is 0 at the root and 1 at the tip on every card. Wind and the dark root both key off that, so if a later edit flips the geometry, both of them break together.
The field is an 8 by 8 grid of chunks. Each chunk is an InstancedBufferGeometry that shares the clump's vertices, plus aOffset, aParams and aVar. The transform lives in a positionNode I own, and the shadow pass gets that same node, because an InstancedMesh would leave every clump casting a shadow from the origin.
Offsets are absolute world positions and every chunk mesh sits at the origin, which is what stops the wind from seaming between chunks. Bounding spheres are set by hand. computeBoundingSphere() on an instanced geometry only sees the clump at the origin, and then the whole field culls itself the moment the camera looks away.
The look
Most of the look is one gradient:
const gradient = smoothstep(0.0, 0.95, uv().y).pow(1.35);
const root = mix(uBaseColor, tip, aShortness.mul(uHeightLift));
const col = mix(root, tip, gradient);
Set the root colour equal to the tip and the field goes flat, which is a useful test when you think the lighting is the problem. Short clumps lift that root toward the tip, because uv.y is normalised per card. A 20cm clump would otherwise carry the same black base as a 60cm one, and there isn't that much blade left above it.
The tip colour mixes a patch noise with how short the clump is. One growth field decides whether a clump gets planted and how tall it is, and the height is what shifts the colour, so the patches fray at the edges. A hard contour looks planted.
Card normals flip when the material is double-sided, so the two faces of a blade fight the light as you orbit. The normal node blends straight up toward the clump's outward direction, which settles that. Slide dome normals to 0 if you want to see the fight.
Wind is a travelling sine plus a bit of noise, weighted by uv.y squared, so the root stays pinned in the ground. A little Y comes off the displacement, and the blade bows as it swings.
Shadows
grassMaterial.castShadowPositionNode = grassPositionNode;
grassMaterial.alphaMap = alphaMap;
grassMaterial.alphaTest = 0.32;
castShadowNode returns a colour, and three then wants a transmitted shadow pass, which is a lot of machinery when all I wanted was a discard. alphaMap and alphaTest cut the shadow into blades and leave the shadow map depth-only. The camera pass ignores those two, because opacityNode and alphaTestNode override them there. In the camera pass the alpha test relaxes with distance, so mipmaps don't eat the blades. The shadow pass uses a fixed threshold, since the light is the wrong vantage point for a ramp that was tuned from the camera.
Alpha testing gives up early-Z, and six cards overlap, so a pixel near the camera can be shaded many times. That is the cost of the trick. Grass that casts shadows pays it twice, once from the camera and once from the light. Receiving shadows is cheap, and it's what seats the field in the scene. The switch in the panel is the one I watch.
The frame is that file. Drag to orbit. The clump budget starts at 45,000 attempts, so pull it down if the frame time climbs.
WebGPU only, on Three.js 0.186.1, with no WebGL fallback. If the browser has no WebGPU, the page stops. Open it through a server, because file:// will not load the modules. php -S localhost:8000 or npx serve in the folder is enough.
- Open it on its own
grass.html, the project-
SKILL.md
If you drop this into a level, cut the draw distance before you add anything else. Match the terrain colour to the grass, or the edge pops. I usually turn grass-casts-shadow off once the field is in.