2D Grass Rendering with WebGPU Compute Shaders

· Jarl ·

22 min read Original article ↗

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:

Six numbered panels: density mask, wind field, atomic buffer, unpacked color, blade depth, and the final composited grass image
  1. Density mask: how much grass each map tile wants. The dark spots end up as bare ground.
  2. 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.
  3. 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.
  4. Unpacked color: the second pass turns the winning integers into colored pixels with a root-to-tip gradient.
  5. Blade depth: where each pixel’s blade is rooted on screen. The compositor sorts against sprites with it.
  6. 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)
}

Three captures from the renderer at low density: blades scattered over the tile grid, one magnified blade as a straight pixel column in calm air, and the same blade bent sideways by wind

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Grass pipeline data flow

What each box does:

  1. The four inputs on the left are the textures described above: density, wind + entity velocity, floor mask, and blue noise.
  2. grass_generate.wgsl: compute pass one hashes, bends, and rasterizes every blade into the atomic buffer.
  3. atomic buffer: one packed u32 per pixel, cleared every frame; atomicMax on it is the depth test.
  4. grass_unpack.wgsl: compute pass two decodes the winning words, shades them, fills AA gaps, and culls blades standing on floors.
  5. grass color / grass depth: the two outputs; alpha carries AA coverage, depth carries the blade-root screen row.
  6. 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:

Memory layout of the 192-byte GrassParams uniform: 48 32-bit words in 16-byte rows, color-coded by type, with two padding words at the end

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:

A dense grass field leaning under a wind gust, warm evening colors, a chest half-buried in the grass

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:

Packed u32 bit layout

The three fields, high bits to low:

  1. 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.
  2. height (bits 15-8): position along the blade (root = 0, tip = 255). Used for the dark-base/bright-tip gradient.
  3. 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:

The grass color target and the grass depth target for the same frame, compared with a sliding divider

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.

Grass field meeting a stone floor: blades rooted on the grass side lean their tips over the floor, and no blade grows out of the stone

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
}

Compositor layer order

The paint order, left to right:

  1. floor: the lit floor layer.
  2. fog + shadow: ground fog blends over the floor, then a 4-tap grass ground shadow darkens the dirt under dense patches.
  3. grass, behind all: blades that lost both depth comparisons.
  4. walls: the wall layer paints over that grass.
  5. grass, over walls: blades rooted below a wall’s tile edge.
  6. objects: creatures, trees, items.
  7. grass, over objects: the grass a character wades through.
  8. 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:

Two zoomed captures of the same bent blades: drawn one row at a time they break apart into dashes, with row bridging they stay continuous

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.