Sheet 002 — 2026
Making stylized grass
Art direction first, then the engineering that keeps a painted meadow responsive. Build it step by step with the real WebGL renderer.
01 / 12
Art direction first
Making stylized grass is primarily an art problem. What shape should the field have? How soft should it feel? Where should the eye rest? The engineering supports those decisions, with much of the work going into drawing and animating enough blades without slowing the page down. This tutorial builds the grass used on this site, from a single blade to a complete field. The renderer behind every demo is on GitHub, at sangloo/stylized-grass.
One reference was Ghost of Tsushima, particularly Eric Wohllaib’s GDC 2021 talk, Procedural Grass in ‘Ghost of Tsushima’. It covers generating, rendering and animating large fields within a memory and performance budget. Our visual target is different, but the relationship between individual blades and the field they form was a useful starting point.
For this meadow, I wanted something closer to a hand-drawn landscape: a slightly 2D appearance, fluffy clumps, soft silhouettes and broad, watercolor-like patches. Sharp, narrow tips made the field feel too wiry. Rounded leaves and overlapping color masses brought it closer to the illustration I had in mind. We are borrowing that visual language, not simulating paint.
Every demo uses the production WebGL geometry and shaders. Start by comparing low and high outline coverage: too many outlines separate the leaves; fewer let them read as a painted mass. We will make these artistic choices explicit, then look at the engineering that keeps them affordable.
02 / 12
Build one blade
A blade begins as a flat ribbon: two points at the root, another pair farther up, and more pairs toward the tip. Each neighboring pair of rows is connected with two triangles. This collection of points and connections is a mesh. A ribbon is enough because our goal is a readable silhouette; modeling the thickness of every leaf would add work with little benefit at this scale.
JavaScript creates this small strip and the data describing each blade. The GPU then runs a vertex shader—a program that positions each mesh point—to shape and bend the copies during rendering. We are not moving thousands of individual leaf objects in JavaScript every frame. Later, instancing lets those copies share the same strip.
// The actual shader profile: t runs from root to tip.
float outer = mix(0.5, 0.36, clamp((iShape - 2.0) / 2.6, 0.0, 1.0));
float w = iParams.y * uWidth
* pow(max(0.0, 1.0 - pow(t, iShape)), outer);Let t describe progress up the blade: zero at the root, one at the tip. The width profile decides how quickly the blade narrows. The iShape exponent controls that taper: larger values keep the sides wide for longer before closing at the tip. Blade width scales the profile sideways; Blade height scales it vertically. Compare the tip shapes at the same width and height to see which change is doing what.
Roundness is an art-direction control. Keeping more width near the top makes overlapping leaves read as soft, rounded lobes. A sharper taper feels thinner and more wiry. Neither is more correct; the rounded version fits the fluffy, cartoon-like field we want. The final row still meets at a point, but the rows below it create a much fuller outline.
03 / 12
Bend without moving the root
Moving every point sideways by the same amount would slide the entire blade out of the ground. Instead, multiply the sideways displacement by t². The root receives zero movement, halfway up receives a quarter of the displacement, and the tip receives all of it. The bend builds gradually along the blade.
This is a designed curve, not a physics simulation. The shader also lowers the tip as it bends, adds some unevenness and limits the combined push from lean, wind and interaction. Those constraints keep the blade from stretching unnaturally. Move Lean and watch the root: anchoring it is what makes the motion read as bending.
// In the vertex shader, push includes the blade’s lean.
float curve = t * t;
// This displacement is added to the ribbon position:
vec3 bend = push * (curve * h);04 / 12
How many triangles does a leaf need?
A single rectangle can tilt, but it cannot represent much of a curve. More rows give the shader more points to bend. Each extra segment adds two triangles per blade, so the decision quickly matters across a field: 200,000 blades at three segments submit 1.2 million triangles; at ten segments, four million.
Try the real strip’s three detail levels: 3, 6 and 10 segments. Turn on Show triangles and zoom in. Fewer segments reduce vertex processing and triangle setup; more segments smooth the bend and rounded tip. The strip places rows closer together near the tip, where the profile changes fastest, so those points do more useful work.
Choose detail for the viewing distance. A simpler strip may look identical when the field is small on screen; a close-up makes its corners easier to see. That is the idea behind level of detail, although this demo lets you choose the level explicitly. Fewer triangles help, but they do not remove the cost of shading all the overlapping pixels.
The vertex shader positions the points. The fragment shader colors the pixels covered by their triangles. Keeping those jobs separate helps diagnose a slow scene: reducing geometry addresses a different cost from reducing the area covered by overlapping leaves.
05 / 12
Build a clump
Equally spaced copies look planted on a grid. Instead, scatter roots around a shared center and relate their height and lean to their position within the clump. The center carries more mass, while the perimeter becomes lower and more open. The group then has a deliberate silhouette rather than looking like independent blades placed together.
This is inspired by the uneven conditions plants grow in. Along a worn path, trampling and disturbance can leave shorter, sparser growth; a sheltered patch can remain fuller. That is a useful visual reference, not a universal rule for every roadside. Our planting algorithm uses interior depth and local coverage to create that variation, with occasional taller exceptions so the result does not look mechanically trimmed.
The demo isolates one rounded clump made by the same planting code. Its blade budget is expanded to 800 for this experiment; ordinary meadow clumps are much smaller. Increase the count, then compare Roundness at a fixed width. Fuller tips overlap into a soft mass; pointed tips separate into thinner strokes. Height and root shading let you test the silhouette and the depth between leaves independently.
06 / 12
Randomness with a structure
Pure randomness is not the same as natural composition. We need dense interiors, quieter gaps and ragged edges. A mask is a map of where grass is allowed: light areas mean yes, dark areas mean no, and gray areas mean partial coverage.
For a first patch, measure each candidate root’s distance from the center. Accept roots well inside and gradually reduce their chance near the boundary. Smooth variation breaks up the circular outline. The full renderer stores the painted zone, local thickness and an estimate of interior depth in a texture, so the planting rules can respond to the shape of the field.
// Use a seeded random generator for repeatable layouts.
if (random() < coverageAt(x, z)) plant(x, z);Interior depth also affects clump height and spread. At the rim, smaller clumps and isolated blades soften the transition to bare ground; farther inside, larger groups build volume. Growth has a stored delay per blade, with nearby blades in a clump receiving related delays. This makes emergence staggered rather than switching the whole field on at once. It is a visual growth model, not a simulation of plant biology.
A seed starts a repeatable sequence of random-looking numbers. Keep it fixed while tuning: otherwise each comparison changes the arrangement as well as the rule. Density cutoff filters stored blades by their random rank and shrinks those near the threshold. Growth controls emergence; width, height and roundness change the surviving blades. Replant gives you another arrangement using the same rules.
07 / 12
Use the ground as a palette
The ground and the blades read the same color field. Each blade samples the color at its root, helping the base blend into the surface underneath. That shared color is part of the painted look: a dense patch can read as one shape while individual leaves remain visible around its edges.
Start with Ground only. Move over it, or choose Sample center, to inspect a texel: one cell in the texture. Change a palette color or the patch strength, then choose Both. The blade roots inherit the same color pattern. Set root shading to zero to see that relationship clearly; increase it to restore depth between the leaves.
vec4 field = fieldAt(iPos.xz);
float ground = field.a;
vec3 green = field.rgb;The RGB channels store color; alpha stores ground height. A texture is a grid of values, so it can carry data as well as an image. We bake these relatively stable results into a lookup texture and refresh it when their inputs change. The renderer filters neighboring texels while drawing; the sample readout shows the nearest texel.
08 / 12
Color in patches
If every blade chooses an unrelated color, the field becomes noisy. Shared patches give neighboring roots related colors, with a smaller variation layered on top. Establish the broad color areas first, then add enough local variation to keep them from looking flat.
The palette includes pale leaves, deep teal shadows, warm rusty accents and muted cool areas. This is a deliberate departure from uniformly green grass. Root shading creates separation inside each clump without drawing a dark outline around every leaf. Change Color patches while keeping the seed fixed to judge color without changing the composition.
The baked color-and-height texture also avoids repeating the full field calculation for every blade vertex every frame. Sampling is still work, but it reuses a shared result. Wind and interaction stay dynamic in the vertex shader; they are not frozen into that texture.
09 / 12
Move the field together
Unrelated motion on every blade looks like vibration. Wind should connect neighboring plants. Imagine a sheet of slowly varying values moving across the ground: nearby roots sample similar values, so a gust travels through the field instead of each blade moving on its own.
Noise here means smoothly changing numbers, not sound. The shader combines several scales of noise and adds a smaller, faster flutter. Broad motion gives the field direction; fine motion keeps it from moving as one rigid sheet. Both feed into the same anchored bend we built earlier.
float wave = fbm(iPos.xz * 0.13
- uWindDir * uTime * 1.15) * 2.0 - 1.0;
float flutter = sin(uTime * 3.3 + phase * 2.1) * 0.09;
float force = max(0.0,
uWind * (0.42 + uGust * wave) + flutter * uWind);Adjust Wind strength, Gust strength and Wind direction separately. Scrub Time to compare a particular moment, or press Play. The demos start paused and stop rendering offscreen. Reduced-motion preferences keep automatic playback still, while manual scrubbing remains available. Running fewer frames when nobody is looking is also a performance technique.
10 / 12
Push the grass aside
We do not need to test the pointer against every triangle. Convert its screen position to a point on the ground, then pass that point and a radius to the shader. Each blade compares its root with that point and bends away. Nearby roots receive a stronger push; distant ones receive none.
Smoothstep creates a gradual transition between zero and one. Without it, the edge of the interaction radius would form a visible ring of suddenly bent grass. The small minimum in the denominator prevents division by zero when the pointer is exactly on a root.
vec2 d = iPos.xz - uBall.xz;
float k = 1.0 - smoothstep(
uBall.w * 0.5, uBall.w * 2.1, length(d));
push += vec3(d.x, 0.0, d.y)
/ max(length(d), 0.001) * k * 1.7;Move across the patch, or use Push position with the keyboard. Radius controls the area of influence. This demo isolates the live push; the homepage also supplies eight fading positions to leave a short wake. A small set of shared inputs produces the effect without maintaining a separate physics simulation for every blade.
11 / 12
Share the geometry
We now have one blade mesh and a list of roots. Creating a scene object for each leaf would add CPU bookkeeping and many individual draw submissions. Instancing shares the mesh and supplies a compact record for each copy: its root, height, width, lean and variation. The GPU combines the shared strip with each record.
The renderer uses Three.js InstancedBufferGeometry and custom attributes. Submitted blades changes its actual instanceCount, up to 200,000 in this experiment. Start low and increase it. Unlike Density cutoff, this control changes how many records are submitted for drawing; the cutoff keeps the records and filters blades in the shader. The displayed count is not a frame-rate benchmark.
Instancing reduces overhead and duplicated geometry. It does not make 200,000 blades free: the GPU still processes their vertices and shades overlapping pixels. Keep detail, screen coverage and device capability in mind. A dense field viewed close up can be more expensive than a larger field occupying a small part of the screen.
The ribbons also turn toward the camera around their vertical axis so they do not disappear edge-on. This is billboarding. Together with an orthographic camera, which does not shrink distant objects, it helps the meadow feel closer to a 2D illustration while retaining enough depth to orbit around.
12 / 12
Put the field together
This final demo combines the same geometry, planting rules, terrain, colors and motion. It starts at 35,000 submitted blades and allows up to 200,000. Drag to orbit, scroll to zoom, or use the focus icon for more room. Compare roundness and width first, then terrain, color and wind. Keeping one variable fixed makes the next decision easier to see.
Build your own version in this order: one ribbon, an anchored bend, a readable clump, repeatable placement, shared color, wind, then interaction. Decide what should look soft or sharp before increasing the detail budget. Art direction tells you what to preserve; performance work helps you preserve it at a useful frame rate.
When a field feels wrong, inspect its outline and empty spaces before adding more blades. Often a clearer gap or a shorter edge does more than another thousand leaves. That is the nice part of this kind of rendering: sometimes the visual improvement also makes it cheaper.
The grass renderer is on GitHub, at sangloo/stylized-grass: the same modules these demos run, with a small example you can turn and recolor. Follow the links below for what comes next.
Further reading: Three.js instanced geometry and shader materials.

