Simple 2D Grass Rendering with WebGPU

vladimirzaytsev1 pts0 comments

Simple 2D Grass Rendering with WebGPU - Jarl

Skip to content<br>Simple 2D Grass Rendering with WebGPU<br>August 10, 2026 ◆ comments

Jarl is a top-down 2D Viking colony simulator we’re building on a<br>customized Bevy 0.11 fork. It uses pixel art, a<br>2D global-illumination renderer,<br>and a procedurally generated world that streams in chunks. Much of that<br>world is open meadow and forest floor, so grass is on screen almost all<br>the time. It had to be procedural: cheap enough to cover most of the<br>map, fit the pixel-art look, and react to lighting, wind, and creatures<br>moving through it.

This is the world it grows in:

Here is the result, running live in your browser:

The live demo needs WebGPU.<br>Your browser doesn't expose it (or it's disabled). Note that WebGPU<br>only exists in secure contexts : over plain<br>http:// (other than localhost) it vanishes entirely.<br>It works out of the box in current Chrome, Edge, Firefox and Safari<br>on Windows and macOS; on Linux, Chrome may need<br>chrome://flags/#enable-vulkan and<br>#enable-unsafe-webgpu.

Here is a capture of the demo instead:

density height width blade scale clumping clump size tile size wind strength wind speed gust size tool radius push strength zoom temporal color bend<br>away from center drag direction swirl final depth buffer wind mask pointy

The grass above is an image, not geometry: two compute passes regrow<br>every blade from a hash each frame, and one atomicMax per pixel<br>serves as the depth test.

Our final result borrows a lot of techniques. The main inspiration came<br>from work on grass rendering:<br>GPU Gems’ “Rendering Countless Blades of Waving Grass”<br>(the starting point for most real-time grass work),<br>Ghost of Tsushima’s GPU grass<br>(GPU-generated blades at production scale),<br>John Wigg’s 2D Grass<br>(the closest 2D relative), and<br>God of War’s wind system<br>(velocity-coupled wind). For a broader survey of how games render<br>grass, Acerola and<br>SimonDev both have fantastic overview<br>videos. The more general building blocks, Nanite’s packed-word depth<br>test, PCG hashing, and blue-noise culling, are introduced and credited<br>in the sections where they appear. Here we explain how we built the<br>final version by combining these techniques.

The demo above runs in the browser through<br>WebGPU,<br>the modern graphics API that lives in your browser and, through<br>implementations like wgpu, in native apps as well<br>(Bevy is built on it). Its big advantage is more control over the GPU<br>and access to compute shaders, which is exactly what this work<br>leverages. Since the game uses a modified Bevy and wgpu, we were able<br>to share a good amount of rendering code with it, though it is not the<br>same exact code. The demo is mostly an<br>illustration and does not necessarily hit the same performance targets:<br>the camera sits much closer than the game’s, which is an expensive<br>configuration for this algorithm. Hover to bend the grass, left-drag to<br>pan. The side panels show the intermediate targets: blade depth, the raw<br>atomic buffer, and the wind field.

Playtest invites and development updates go out via the<br>newsletter.

General idea

The system we ended up with treats grass as an image rather than<br>geometry. To keep things simple, there are no blade entities, no meshes,<br>and no persistent state: every frame, two compute shaders regenerate every visible blade<br>from a hash and write it, pixel by pixel, into a buffer that the<br>compositor then sorts into the scene. This is not the fastest possible<br>approach, but it is a reasonable one for our performance target at this<br>stage of development: at 4K on an RTX 3080 Ti, with tuned parameters, the<br>full pass stays way under 1 ms.

Every frame, the grass is rebuilt from scratch. A compute shader walks<br>the visible screen tiles, asks a small density texture how much grass<br>grows there, and generates each blade from a hash of its tile coordinates<br>and index. The hash provides everything a blade is: its position inside<br>the tile, its height, its color variation, its sway phase. Because the<br>hash inputs are world coordinates, the same blade appears in the same<br>place every frame, no matter where the camera is. Slightly simplified,<br>the walk looks like this:

// One 64-thread workgroup per 16x16 pixel screen tile.<br>let tile_world = camera_world_min + tile_px * px_to_world;<br>let density = textureLoad(density_tex, world_tile(tile_world), 0).r;<br>let blade_count = u32(density * params.density_scale * MAX_BLADES);<br>for (var i = local_idx; i += 64u) {<br>let seed = pcg2d(world_tile_xy + i * primes);<br>// seed → root position, height, color seed, sway phase<br>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<br>end up as bare ground.

Wind field : the wind vector grid, one cell per few tiles;<br>brightness is gust strength. The demo generates it from a few<br>overlapping sine waves: a broad slow breeze, gust fronts traveling<br>across the field, and a bit of per-cell turbulence. In the game it<br>comes from the climate simulation instead.

Atomic buffer...

grass wind blade from webgpu rendering

Related Articles