A full walkthrough of Jarl’s fully procedural 2D grass renderer: two WGSL compute passes generate, depth-sort, and shade all visible animated pixel-art grass every frame. The game runs them in a customized Bevy fork on wgpu; the live demo below runs the same shaders in your browser through WebGPU. Both shader files are published in full at the end.
Jarl is a top-down 2D Viking colony simulator we’re building on Bevy 0.11. It uses pixel art, a 2D global-illumination renderer, and a procedurally generated world that streams in chunks. Much of that world is open meadow and forest floor, so grass is on screen almost all the time. It had to be procedural: cheap enough to cover most of the map, fit the pixel-art look, and react to lighting, wind, and creatures moving through it.
This is the world it grows in:
Here is the result, running live in your browser:
The grass above is an image, not geometry: two compute passes regrow
every blade from a hash each frame, and one atomicMax per pixel
serves as the depth test.
Our final result borrows a lot of techniques. The main inspiration came from work on grass rendering: GPU Gems’ “Rendering Countless Blades of Waving Grass” (the starting point for most real-time grass work), Ghost of Tsushima’s GPU grass (GPU-generated blades at production scale), John Wigg’s 2D Grass (the closest 2D relative), and God of War’s wind system (velocity-coupled wind). For a broader survey of how games render grass, Acerola and SimonDev both have fantastic overview videos. The more general building blocks, Nanite’s packed-word depth test, PCG hashing, and blue-noise culling, are introduced and credited in the sections where they appear. Here we explain how we built the final version by combining these techniques.
The demo above runs in the browser through WebGPU, the modern graphics API that lives in your browser and, through implementations like wgpu, in native apps as well (Bevy is built on it). Its big advantage is more control over the GPU and access to compute shaders, which is exactly what this work leverages. Since the game uses a modified Bevy and wgpu, we were able to share a good amount of rendering code with it, though it is not the same exact code. The demo is mostly an illustration and does not necessarily hit the same performance targets: the camera sits much closer than the game’s, which is an expensive configuration for this algorithm. Hover to bend the grass, left-drag to pan. The side panels show the intermediate targets: blade depth, the raw atomic buffer, and the wind field.
![]()

![]()
How the 2D grass shader works
The system we ended up with treats grass as an image rather than geometry. To keep things simple, there are no blade entities, no meshes, and no persistent state: every frame, two compute shaders regenerate every visible blade from a hash and write it, pixel by pixel, into a buffer that the compositor then sorts into the scene. This is not the fastest possible approach, but it is a reasonable one for our performance target at this stage of development: at 4K on an RTX 3080 Ti, with tuned parameters, the full pass stays way under 1 ms.
Every frame, the grass is rebuilt from scratch. A compute shader walks the visible screen tiles, asks a small density texture how much grass grows there, and generates each blade from a hash of its tile coordinates and index. The hash provides everything a blade is: its position inside the tile, its height, its color variation, its sway phase. Because the hash inputs are world coordinates, the same blade appears in the same place every frame, no matter where the camera is. Slightly simplified, the walk looks like this:
// One 64-thread workgroup per 16x16 pixel screen tile.
let tile_world = camera_world_min + tile_px * px_to_world;
let density = textureLoad(density_tex, world_tile(tile_world), 0).r;
let blade_count = u32(density * params.density_scale * MAX_BLADES);
for (var i = local_idx; i < blade_count; i += 64u) {
let seed = pcg2d(world_tile_xy + i * primes);
// seed → root position, height, color seed, sway phase
}
Here is one frame of the demo as data, numbered in pipeline order:

- Density mask: how much grass each map tile wants. The dark spots end up as bare ground.
- Wind field: the wind vector grid, one cell per few tiles; brightness is gust strength. The demo generates it from a few overlapping sine waves: a broad slow breeze, gust fronts traveling across the field, and a bit of per-cell turbulence. In the game it comes from the climate simulation instead.
- Atomic buffer: the result of the first pass, one integer per pixel. Each integer packs three values: the blade’s depth (its root row on screen), how far up the blade this pixel sits, and a color variation seed. In the panel, red shows the depth and green the position along the blade.
- Unpacked color: the second pass turns the winning integers into colored pixels with a root-to-tip gradient.
- Blade depth: where each pixel’s blade is rooted on screen. The compositor sorts against sprites with it.
- Final composite: background, objects, and grass merged, front or behind decided per pixel.

An individual blade starts as a handful of numbers from the hash: a root position inside its tile, a height, a color seed, a sway phase. To draw it, the shader walks from root to tip one pixel row at a time and offsets each row sideways by the local wind and creature-velocity bend. The tip moves further than the root, so the column curves. A blade is one to three pixels wide and a few dozen tall, so drawing one costs a few dozen writes into the buffer.
for (var row = 0u; row < blade_h; row++) {
let h = f32(row) / f32(blade_h - 1u); // 0 at root, 1 at tip
let bend = disp * h * h * h; // the tip moves most
let px = base_px.x + i32(round(bend.x));
let py = base_px.y - i32(row) + i32(round(bend.y));
// write this pixel into the buffer (next snippet)
}

These are captures from the demo renderer with density turned down: the tile grid with its hashed blades, then one blade magnified, first in calm air and then with the wind on. It is the same blade in both close-ups, regrown from the same hash.
Where blades overlap, a packed integer compared with atomicMax decides
which one owns the pixel; the comparison itself is the depth test. A
second compute pass reads the winning values, colors them into an image
with a root-to-tip gradient, and fills anti-aliasing pixels along the
edges. The whole trick fits in two lines:
// One write per blade pixel: higher root row wins the pixel.
let word = (root_y << 16u) | (height_on_blade << 8u) | color_seed;
atomicMax(&buffer[py * width + px], word);
Finally, the compositor places that image into the scene. Each grass pixel carries its blade’s root position, and the compositor compares it per pixel against the feet of sprites and walls, so a blade can pass in front of a character’s boots and behind their head. The lighting and fog that apply to the rest of the scene apply to the grass at this stage as well.
The sections below go through each of these stages in detail, along with the optimization work behind them.
Input data: density, wind, and the floor mask
Nothing exotic feeds the system: everything the two passes know arrives as a handful of small textures and one block of tuning values. The five inputs:
-
Density texture: a small texture with one value per map tile saying how much grass grows there, rebuilt from the terrain each frame. It is aligned to the world grid, not the screen, so the grass pattern stays fixed to the ground as the camera moves.
-
Wind and entity-velocity textures: two vector fields from the climate simulation, one for wind and one for creature movement. The fog and foliage shaders read the same textures, so the grass gets its weather for free. Creatures splat their velocity into the second texture as they move, comparable to God of War’s wind system, which combines simulated wind fields with object velocity (“counter wind”); theirs is a 32x16x32 3D volume with five diffusion iterations per frame, while a 2D world gets away with a flat grid.
-
Floor-mask texture: an offscreen pass renders only floors and rugs into a mask. This lets the grass passes suppress blades pixel-perfectly where players build floors, rather than per tile.
-
Blue-noise texture: a noise texture from Christoph Peters’ free blue-noise set (CC0), used for random-but-even decisions like which blades to skip. It is also looked up by world position rather than screen position, so the noise sticks to the ground instead of shimmering when the camera moves.
-
A parameter block: the camera mapping, time, and about thirty tuning values (blade height, sway, density, colors), all editable live in a debug panel.

The grass image is rendered at half the screen resolution and scaled up during compositing; the performance section comes back to why this one decision matters more than anything else.
What each box does:
- The four inputs on the left are the textures described above: density, wind + entity velocity, floor mask, and blue noise.
- grass_generate.wgsl: compute pass one hashes, bends, and rasterizes every blade into the atomic buffer.
- atomic buffer: one packed
u32per pixel, cleared every frame;atomicMaxon it is the depth test. - grass_unpack.wgsl: compute pass two decodes the winning words, shades them, fills AA gaps, and culls blades standing on floors.
- grass color / grass depth: the two outputs; alpha carries AA coverage, depth carries the blade-root screen row.
- compositor: lights the grass with GI floor irradiance, applies fog, and inserts it at one of three depth slots.
Here is the parameter block from input 5 laid out exactly as the GPU sees it, with the alignment rules WGSL imposes on uniform structs:

Pass 1: generating grass blades in a compute shader
The generate pass dispatches one 64-thread workgroup per 16x16 screen tile. Each workgroup finds the world tiles overlapping its screen tile, reads their density, and splits the blade work across its threads with a stride of 64. Here is one workgroup rendering its tile, slowed way down; each color is a different thread:
The animation also shows most threads sitting idle, which looks more wasteful than it measured. The null-shader test attributed almost all of this pass’s cost to its atomic writes, and idle lanes are masked off and issue none of them, so on the hardware we profiled the empty threads were not a meaningful cost; the GPU fills the gaps with workgroups from other tiles. The imbalance that did matter was the original one-thread-per-tile version, where a single thread rasterized every blade of its tile; splitting that work is where the speedup in the performance section came from.
Each blade comes from one hash call. We use pcg2d, following
Jarzynski & Olano’s JCGT study,
which places PCG-family hashes on the quality/performance Pareto frontier
under TestU01 BigCrush testing (Nathan Reed’s
summary
is a shorter read):
let seed = pcg2d(vec2<u32>(
u32(world_tile_x) + blade_idx * 73856093u,
u32(world_tile_y) + blade_idx * 19349663u,
));
let jitter_x = f32(seed.x & 0xFFu) / 255.0 * (gts - 1.0);
let jitter_y = f32(seed.y & 0xFFu) / 255.0 * (gts - 1.0);
// Blue noise gives a more even stochastic coverage pattern than
// the old hash-derived threshold while staying fixed in world space.
let survive = blue_noise_scalar(base_world);
if survive > params.grass_cull_ratio * density_mod { continue; }
The seed’s bit budget: 8 bits each for X jitter, Y jitter, blade height, color variation, sway phase, and per-blade frequency variation. Same tile, same blade index, same seed: the field is identical every frame; only the wind input changes.
The survival check uses blue noise rather than a hash threshold because hash culling produces white-noise clumps and voids, while a blue-noise threshold produces evenly spaced survivors. This is the same family of techniques as hashed alpha testing (Wyman & McGuire, 2017) and NVIDIA’s spatiotemporal blue noise work on stochastic transparency, applied here to whole blades in world space.

Motion combines two parts: a dual-harmonic wind sway (a single sine reads as a metronome), and a velocity push with exponential easing that approaches a bend limit asymptotically, so grass leans far but never lies flat and never snaps back:
// Wind sway: dual-harmonic oscillation for organic motion.
let primary = sin(params.time * blade_freq + phase);
let secondary = sin(params.time * blade_freq * 0.4 + phase * 1.7) * 0.3;
let sway = (primary + secondary) * params.sway_amplitude * cs;
// Entity velocity push: 1-exp(-x) easing for natural bend limit.
// Approaches vel_max_bend asymptotically; never goes fully horizontal.
let eased_vel_mag = vel_max * (1.0 - exp(-raw_vel_mag / max(vel_max, 0.1)));
// Smooth fade to zero at low velocities (prevents snap-back).
let vel_fade = smoothstep(0.0, 0.005, vel_len);
let vel_disp = vel_dir * eased_vel_mag * vel_fade;
More in this family of motion models: GPU Gems 3’s procedural tree wind, and AMD’s procedural grass sample for a modern mesh-shader approach.
A dense field mid-gust, rendered by this pass with a warm evening grade:

Sorting overlapping blades with an atomicMax depth buffer
Blades overlap, so something must decide per pixel which blade is
visible. Instead of a z-buffer or sorting, each written pixel is a single
u32 with depth in the high bits, and atomicMax selects the winner:
The three fields, high bits to low:
- depth (bits 31-16): the blade’s root screen-Y. In a top-down 2D scene, larger screen-Y means lower on screen, which means closer to the camera, so the numerically largest packed word is the front-most blade.
- height (bits 15-8): position along the blade (root = 0, tip = 255). Used for the dark-base/bright-tip gradient.
- variation (bits 7-0): a per-blade random byte for brightness and hue jitter.
A zero word means “no grass here”, and blade tips above 95% height are never written, so where a tall and a short blade overlap, ties break toward the shorter one instead of a floating tip.
The rasterizer is a row loop with one atomic per written pixel:
for (var row = 0u; row < blade_h; row += row_stride) {
let h_weight = f32(row) / f32(blade_h - 1u);
if h_weight > TIP_CULL_START { continue; }
// Cubic bending curve: tips bend more than midpoints.
let hw_cu = h_weight * h_weight * h_weight;
let dx = i32(round(clamped_disp.x * hw_cu));
let dy = i32(round(clamped_disp.y * hw_cu));
let px_y = base_px_y - i32(row) + dy;
let h_byte = u32(clamp(h_weight * 255.0, 0.0, 255.0));
let packed = (depth << 16u) | (h_byte << 8u) | variation;
for (var col = 0; col < thick; col++) {
let px_x = base_px_x + dx - half_thick + col;
if px_x < 0 || px_x >= screen_w { continue; }
let buf_idx = u32(px_x) + u32(px_y) * u32(screen_w);
atomicMax(&atomic_buf[buf_idx], packed);
}
}
The depth test is an atomicMax on a packed integer, with depth in the high bits.
The same construction appears in
Nanite’s software rasterizer:
a 64-bit word (30 bits of depth, 27 of cluster ID, 7 of triangle index)
written with InterlockedMax, used for clusters with triangle edges
under 32 pixels, where Epic measured a 3x average win over their fastest
hardware path. Jarl’s version is a 32-bit, 2D variant with the depth
direction inverted for screen-Y. For a WebGPU-oriented walkthrough of the
idea, see
Omar Shehata’s compute rasterizer tutorial.
For current research on when compute rasterization wins and loses,
CuRast reports 2-5x wins on dense
geometry and order-of-magnitude losses on low-poly scenes.
One detail worth knowing: writes with equal depth are not a
last-writer-wins race. atomicMax compares the entire 32-bit word, so at
equal depth the comparison falls through to the lower fields: larger
height byte first, then larger variation. The
WGSL spec
guarantees that atomic read-modify-writes on one object are mutually
ordered, and max is associative and commutative, so the result is
deterministic regardless of thread scheduling. The bit layout is
effectively a priority function: tip-most sample wins ties. To make roots
win instead, you would store 255 - h_byte.
Here are both outputs for one frame, same framing as the shot above; the checkerboard shows through where the alpha is empty. Move the divider to compare them:
grass color grass depth
Pass 2: unpacking, shading and anti-aliasing each blade
The unpack pass runs one thread per pixel. Reading the packed word plus its four cardinal neighbors provides everything needed for shading and anti-aliasing.
Shading is a series of mixes: height gradient, per-blade jitter, a warm shift toward the tips, and edge dimming where a pixel has few grass neighbors:
let brightness = mix(params.base_darkness, params.tip_brightness, h_weight);
// Per-blade variation: slight brightness offset for natural look.
let var_offset = (variation - 0.5) * 0.12;
// Subtle hue shift: tips slightly warmer (yellowish), base cooler.
let hue_shift = h_weight * 0.08;
let shifted_color = vec3<f32>(
base_color.r + hue_shift, base_color.g, base_color.b - hue_shift * 0.5);
// Edge coverage dimming: fewer grass neighbors → slightly darker.
let coverage = f32(n_count + 1u) / 5.0;
let edge_dim = mix(EDGE_DIM_FLOOR, 1.0, coverage);
let final_color = shifted_color * (brightness + var_offset) * edge_dim;
![]()
The anti-aliasing works on empty pixels: an empty pixel with grassy neighbors can become a half-bright edge pixel, gated by blue noise so the edge dissolves stochastically instead of aliasing:
if packed == 0u {
if n_count < FILL_MIN_NEIGHBORS { /* stay empty */ }
let fill_keep = mix(FILL_KEEP_FLOOR, 1.0, fill_support);
if blue_noise_scalar(world_pos + BLUE_NOISE_EDGE_OFFSET) > fill_keep {
/* stay empty */
}
let fill_h = (n_h_sum / f32(n_count)) / 255.0; // averaged height
let fill_depth = n_depth_sum / n_count; // averaged depth
// write half-brightness edge pixel ...
}
This is also why depth lives in its own image: the anti-aliasing needed the alpha channel for coverage, so the blade-root position moved into a separate texture.
The floor mask check samples the mask at the blade’s base position, not the current pixel. Blades rooted on a stone floor are culled entirely, but a blade rooted on grass can lean its tip over the floor’s edge. The cost is one texture load, because the mask is produced by an ordinary camera.


Compositing grass with the world: a three-way depth sort
This stage is what makes the grass feel like part of the world rather than a layer drawn on top: each blade has to slot in between floors, walls, and creatures at the right depth.
The compositor receives grass color, grass depth, the sprite layers, and the sprite-height buffer, a per-pixel “how high above its feet is this sprite pixel” channel baked into the sprite atlases. From those it computes two “feet” lines and makes one comparison per occluder:
// Object feet: per-pixel sprite height from the height buffer.
let object_feet_y = pixel_y + sprite_height * 255.0 * custom_data.camera_scale;
// Wall feet: reconstruct world Y, snap to the 16px tile grid so every
// pixel of a wall tile shares one depth reference (no diagonal cutoffs).
let ndc_y = 1.0 - 2.0 * uv.y;
let world_y = ivp[1][1] * ndc_y + ivp[3][1];
let tile_frac = world_y - floor(world_y / 16.0) * 16.0;
let wall_feet_y = pixel_y + tile_frac * custom_data.camera_scale;
// 2px bias reduces z-fighting flicker during movement.
let grass_in_front_of_obj = has_grass && has_object && (grass_base_y > object_feet_y + 2.0);
let grass_in_front_of_wall = has_grass && has_wall && (grass_base_y > wall_feet_y + 2.0);
This is the standard top-down convention (higher screen-Y renders in front, the same rule as Godot’s Y-sort), applied per pixel in the compositor against two kinds of occluder at once. The wall case has one subtlety: comparing against the pixel’s own Y creates diagonal cutoffs within a wall tile, so the world Y is reconstructed and snapped to the 16px grid, giving every pixel of a tile the same depth reference.
Grass gets three insertion points in the blend chain:
if has_grass && !grass_in_front_of_wall && !grass_in_front_of_obj {
out = alpha_blend(out, fogged_grass); // behind all
}
out = alpha_blend(out, final_walls);
if has_grass && grass_in_front_of_wall && !grass_in_front_of_obj {
out = alpha_blend(out, fogged_grass); // over walls
}
out = alpha_blend_objects(out, fogged_objects);
if grass_in_front_of_obj {
out = alpha_blend(out, fogged_grass); // over objects
}
The paint order, left to right:
- floor: the lit floor layer.
- fog + shadow: ground fog blends over the floor, then a 4-tap grass ground shadow darkens the dirt under dense patches.
- grass, behind all: blades that lost both depth comparisons.
- walls: the wall layer paints over that grass.
- grass, over walls: blades rooted below a wall’s tile edge.
- objects: creatures, trees, items.
- grass, over objects: the grass a character wades through.
- UI + outlines: always on top.
Two details: grass is pre-fogged (fog applied via premultiplied-alpha blending before insertion), so a blade in front of a wall cannot render un-fogged; and grass gets the same height-based fog clipping as sprites, so tall blade tips extend out of ground fog.
And here is how it all comes together in the game, in a live scene with trees, fog, structures, and a player wading through the grass:
Here is the velocity part of that chain in motion: a runner’s wake, splatted into the velocity grid and decaying behind it:

Making the grass shader fast enough to ship
The first working version of the generate pass was slow enough to
dominate the frame’s whole compute budget. A null-shader test (dispatch
everything, write nothing) attributed almost all of that cost to the
atomicMax scatter writes, so everything that helped was some form of
“write less”:
- Cap blades per tile. The density formula could ask for over a hundred blades in one tile, and overlap means most of them lose every pixel they write. Capping the count per tile was the cheapest large win and is invisible in the final image.
- Cooperative dispatch. Originally one thread rasterized all blades of its tile. Splitting a tile’s blades across a 64-thread workgroup measured roughly four times faster at full density.
- Render at half resolution. Grass is low-frequency content: half the width and height means a quarter of the pixels, and blade heights and thicknesses shrink with them, so the write count drops sharply. Upscaled pixel-art grass loses almost nothing visually. This beat every algorithmic optimization we tried.
- Stochastic culling. Skipping a fraction of blades by blue-noise threshold cuts writes proportionally and is visually free where blades overlap.
Not every optimization helped: two made the pass measurably slower.
Two attempts made things measurably worse, and both are informative:
Shared-memory tiling. Staging the tile in var<workgroup> memory
regressed: the buffer caused a register spill to local memory. Thread
utilization was the entire win in this workload; memory locality added
nothing.
Read before atomic. The idea: atomicLoad first, skip the
atomicMax when the write would lose. Measured about half again
slower. The likely explanation is that
NVIDIA’s caches operate on 32-byte sectors,
so the read generates its own memory traffic, and since blades are
thin, most pixels are written once and the check saves nothing. Atomic
throughput varies across vendors and workloads (see
Devon McKee’s cross-vendor microbenchmarks),
so this result should be re-measured on any other target.

Keeping bent grass blades connected under strong wind
Strong bend exposes a limit of the row-by-row walk. Here is the same patch of grass under a hard gust, rendered by the plain walk and then with the improvement described below:

The same comparison in motion, with gusts passing over the field; the right pane is a 4x zoom of the marked square. First the plain walk:
And with the rows bridged:
The walk writes one pixel per row, so two consecutive rows stay connected only while the sideways step between them is at most one pixel. The bend curve concentrates movement at the tip: under a strong gust the tip moves several pixels sideways per row, and the blade splits into dashes. This is the |slope| > 1 case that Bresenham’s algorithm handles: a curve cannot be rasterized by stepping one axis when it locally runs faster along the other. The game’s own camera keeps the step subpixel, so it took the demo’s close-up camera and stronger bend to reach this regime.
The improvement keeps the row sampling and bridges each consecutive
pair of rows with n = max(|Δx|, |Δy|) interpolated writes:
let dseg = p - prev_p;
let n_i = max(max(abs(dseg.x), abs(dseg.y)), 1);
for (var i = 1; i <= n_i; i++) {
let a = f32(i) / f32(n_i);
let q = prev_p + vec2<i32>(i32(round(f32(dseg.x) * a)),
i32(round(f32(dseg.y) * a)));
emit_row(q, mix(prev_h, h_weight, a), ...);
}
When the blade is upright, n = 1 and nothing changes; under hard bend
the loop adds only the missing pixels. The height value interpolates
along the blade parameter, so the root-to-tip gradient the second pass
shades stays correct. The underlying principle, stepping a curve so
roughly one sample lands per pixel, goes back to
adaptive forward differencing;
Wu’s algorithm
covers the anti-aliased single-pixel-line case.
One side effect: bridging renders whatever the math produces, including hooks. Push a blade down hard enough and the curve folds back on itself; instead of dashes you get a continuous curl. We left this in, it reads as grass being crushed.
Conclusion and further reading
That’s basically the whole system: a density texture, a wind field, two
compute passes, and an atomicMax depth test. The blades have no
persistent state; they’re regenerated from hashes every frame. They stay
consistent with the pixel-art look, sort correctly against sprites and
walls, and are cheap enough to cover most of the world.
![]()
For more conventional approaches, Daniel Ilett’s six-technique comparison surveys mesh grass, geometry and tessellation shaders, billboards, and more, and Roystan’s grass shader tutorial goes deep on the geometry-shader route. In the Bevy ecosystem, bevy_procedural_grass is a grass-specific implementation with instancing, culling, LOD, and wind, and bevy_feronia is a newer, broader environment-scattering toolkit.
For the compute side,
WebGPU Fundamentals
is a good introduction to WGSL compute shaders, and the
WGSL specification is worth keeping
nearby, particularly the atomics section, since our depth test depends
on exact atomicMax semantics.
Sucker Punch’s GDC talks on procedural grass and wind simulation in Ghost of Tsushima face the same problems at a very different scale. And Noita’s falling-sand talk is a kindred approach: treating pixels themselves as simulation and rendering data instead of reaching for scene geometry.