A winter map from a low camera, with packed trenches carved through deep snow where an army has marched and lit ridges along the trench edges

Snow that remembers where you walked

Rendering & Graphics11 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

Written up later from the commit history. Dated to when the work landed.

Send twenty ironguard across a winter map and they leave a road behind them. The powder packs down into a trench where the column walked, the rims either side catch the light, and the shape holds for a while after the army has gone, so you can read where it went. Rain does the darker version of the same trick: the ground it falls on goes wet and stays wet for a bit.

A dwarf shield line and a goblin horde colliding in the rain on churned open ground, sunset behind burning hills

The brief was weather the world actually notices, rather than particles drawn in front of it. Everything about that was straightforward except the one thing that mattered. The ground already sampled 15 textures against WebGL2's guaranteed limit of 16, and the first version of this bound three more. The shader failed to link, three.js fell back to nothing usable, and the entire playable map rendered flat white, with no exception thrown and nothing in our crash reports. That failure has its own post, the WebGL limit that turns your terrain white. This one is about what the feature had to become to fit in the single texture slot it was allowed.

Four signals, one texture

The trick this whole feature rests on is that a texture does not have to hold a picture. A texture is a grid of numbers that the graphics card can read very quickly, and every one of them has four channels, conventionally called red, green, blue and alpha because that is what they usually mean. Nothing forces that. If you write a snow depth into the red channel, the card will happily store and read a snow depth. One texture is therefore four independent grids of numbers that happen to travel together, and using all four for unrelated things is the difference between this feature costing one texture slot and costing three.

The trail buffer is one such texture covering the whole map, and the game draws into it every frame. Its four channels carry four unrelated things:

ChannelSignalUpdated
RSnow surface height. 1.0 is pristine powder, lower is packed or carvedEvery frame
GSurface dryness. 1.0 is dry, lower is wet. Rain stamps thisEvery frame
BHow much snow can lie on this tile at allOn terrain change
AWhere snow drifts deeper than the surrounding fieldOn terrain change

The last two did not start there. Where snow can lie was its own texture bound alongside the trail buffer, which is the obvious design and genuinely simpler to write. It is also the design that took the map out. Moving the static data into the spare channels of a texture that was already bound is what got the whole feature down to one texture slot instead of three.

Two of those channels update every frame and two update almost never, and that mixture is the reusable part. A channel is not a place to put a related signal. It is a place to put any signal that shares a resolution and a coordinate space, and static data is the cheapest thing to move there, because it never competes with the live passes.

Height, not depth, because of one blend equation

Where two footprints overlap, the deeper one has to win. The cheapest way to get that on a GPU is the fixed-function MIN blend equation, which takes the smaller of what you are drawing and what is already there, with no read of the previous frame at all. Only the pass that gradually heals the snow back to pristine needs to see the previous frame. (In three.js, the equation is set on the material via custom blending. GL ignores the source and destination factors for MIN, but three requires them to be set anyway or the material is not well formed, which is the sort of detail that costs an hour.)

Taking a minimum only works if "more disturbed" means "smaller", which is why the channels store surface height and dryness rather than depth and wetness. That naming looks backwards until you see what it buys. It also gives free channel masking, because 1.0 is the value min ignores: a stamp that wants to touch only the snow channel writes a literal 1.0 into all the others and leaves them exactly as they were.

That is what makes footprints and raindrops a single draw call, and it is the whole technique in one line of shader:

// vChannel is a per-instance mask: (1,0) for a footprint, (0,1) for rain.
// Untargeted channels get 1.0, which min() ignores, so both kinds of stamp go
// through ONE draw call with no colour-mask changes between them. The literal
// 1.0 in the last two channels is the same trick, and it is what carries the
// static terrain data through the live pass untouched.
vec2 outRG = mix(vec2(1.0), vec2(height), vChannel);
gl_FragColor = vec4(outRG, 1.0, 1.0);

Three's colour-buffer mask is all channels or nothing, so the alternative to that one mix is reaching past three's state tracking into the raw GL context. The identity write costs nothing and cannot get the tracking out of step.

There is one thing the blend equation cannot do, and it is the visually important one. The stamp shape is the one Rise of the Tomb Raider used, a single quadratic that dips in the middle and rises at the edges, so a footprint comes with raised rims. The dip composites perfectly under min. The rims cannot, because min can only ever lower a value, so a raised berm is thrown away by construction. Giving up min would have meant reading the previous frame back every time and losing the entire simplification, so the raised half is clipped out of the stamp and the crest is reconstructed afterwards from the slope of the finished field. It lands geometrically where the quadratic's rim would have sat, it is free because the ground already computes that slope for its lighting, and unlike a stamped berm it stays correct where many footprints overlap.

A winter citadel of black stone and gold spires on a snowfield at night, under a green aurora, with a bridge running to its gate

No smearing, because the buffer never moves

Published versions of this technique use a buffer that follows the player, which forces the capture centre to snap to the texel grid and the previous buffer to be resampled at a shifted position every time it moves. Get that wrong by less than one texel and the buffer resamples its own error every frame and dissolves into mush. It is the single most reported failure of the approach.

This buffer covers the whole map at fixed alignment. There is no moving window, no snapping and no resampling, so the entire class of bug is designed out rather than defended against. That is a payoff of an RTS's bounded map that an open-world game cannot take.

The cost is resolution, and it is a trade rather than a limitation. The buffer size targets a fixed number of world pixels per texel and rounds up to a power of two, so a 96-tile map lands on 1,024 and a 256-tile map lands on 2,048 exactly. That works out at roughly 4 world pixels per texel, so the track left by one unit is about 8 texels across on the largest map. This produces trails, not individual footprints. Published guidance for real footprints puts the requirement near 4 cm per texel, which no whole-map buffer reaches at a sane size, and for a camera watching armies carve paths across a snowfield, the trail is the thing you want.

Fold to zero, and a test that pins it

The lowest weather quality setting has to cost literally nothing. Not "nearly nothing": no textures allocated, no video memory, no GPU passes, and a ground surface identical to the build before the feature existed.

The general form matters more than the feature here, because the cheap version of a quality setting is almost always written as a small number rather than as zero. A quality setting's lowest rung should resolve to no allocation and no pass at all, not to a small one. A 128-pixel buffer, four particles or a strength of 0.05 still costs an allocation, a bind, a draw and a branch in every shader that reads it, and the player who selected the lowest setting is on the machine where those are the numbers that were hurting. Make the bottom rung an early return, then pin the zero with a test, because "small" reappears the first time a constant gets tuned.

Two mechanisms give that here. The low setting's buffer cap is 0, and the sizing code returns 0 for any cap that is not positive, so nothing is allocated. The ground guards every snow term on a strength value of zero. The first half is pinned across map sizes of 1, 3,072, 4,096, 5,248, 8,192 and 100,000 world pixels, plus a negative cap, plus an assertion that the low setting really does ask for zero, so the guarantee is reachable at all. That last assertion is the one people leave out. Without it the test proves that zero produces zero, and nothing proves that anything ever asks for zero.

The live settings scale together:

SettingBuffer edgeStamps per framePrecipitation ceiling
low000
medium512241,500
high1,024646,000
ultra2,04812815,000

Rain and snowfall were rebuilt in the same change to hold no state on the GPU. Each particle carries a spawn seed and its position is computed directly from the clock, so the ceiling moved from 720 particles to 15,000 while the per-frame cost on the CPU fell to a single vector copy. Per-flake transparency is compensated by the square root of density rather than by its reciprocal, so a higher setting is never thinner-looking than a lower one, which is what ultra looked like before: a white wall.

The bug that shipped: a white snowfield on a green forest map

Where snow can lie is decided by terrain. Open ground gets the full amount, rock gets a shallow fraction because snow settles into cracks without heaping on a face, and dirt, tree canopy, water, shallow water and oil all get none. Anything unrecognised gets none, so a terrain type I did not think about fails to no effect rather than to a full snowfield.

None of that knows anything about the weather, and that is where the bug was. One object serves both snow and wetness, so it reports itself active when either is on, and the master strength value was driven off that. A rainy biome switched wetness on, strength went non-zero, and every snow term then applied to open ground, which reads as fully snow-bearing in every biome.

The result was a white snowfield across a green forest map, and it was found by playing one. "The buffer is doing something" had been standing in for "snow is one of the things it is doing". The fix is a separate value for snow, and both gates now fold into one number at source rather than being applied term by term, so the undulation, the packed tint, the berm crest, the patches of exposed ground, the parallax and the drifts can never disagree about whether snow is present.

The same change made corrupted ground shed its snow, which needed a sample of the corruption map that the shader could not afford to bind. So it reuses the one the ground shader has already bound: it looks for the declaration in the incoming shader source and removes its own block entirely when it is absent, because re-declaring something another patch already declared is itself a compile error. There is a trap underneath that. The corruption map is baked with its rows the right way up while the snow terrain mask is flipped, so sampling corruption with the buffer's own coordinates mirrors the whole gate down the map. That looks completely plausible on a symmetric map and is wrong everywhere else, which is why it is pinned by a test rather than left to the eye.

Counting samplers instead of looking at the screen

The most useful test in this feature is not a behaviour test. It compiles the ground against a plain three.js material, runs the shader-compile hook, and counts sampler declarations in the resulting shader source, matching every sampler type with or without a precision qualifier. Then it asserts the patch added at most one, and asserts it again with the corruption map already present, to prove the snow reuses that sampler rather than binding a second copy.

A static count beats looking at it, because the failure it prevents is a driver link error that happens at runtime on one machine and produces a blank screen. Nothing in a type check or a build can see it, and a screenshot of a white map does not tell you which sampler was the seventeenth. Any patch that binds a texture to the ground now carries the same guard, and the rule is written into the header of the ground material described in terrain that looks the same in 2D and 3D.

Snow runs in exactly one of the 11 described in what makes a biome more than a colour filter, and wetness runs in six more off the same buffer, the same passes and the same material. Both are judged side by side in the Weather Lab, which renders two quality settings at once precisely because comparing a setting against a memory of a setting is not a comparison. That is the argument for why every system here gets its own lab. Next is working out what else the ground could usefully be made to remember.

Questions

How do you store persistent terrain deformation in WebGL?

A render target covering the world, written by instanced stamp quads and decayed by a full-screen refill pass. Use the gl.MIN blend equation so overlapping stamps resolve deepest-wins without reading the previous buffer, which means storing surface height rather than depth so that more disturbed means smaller.

Why do snow deformation buffers smear?

Because most implementations follow the player, so the capture window moves and the previous buffer has to be resampled at a shifted UV every frame. Any sub-texel error compounds and the field dissolves. A fixed buffer covering the whole map has no window to move, so the failure cannot occur.

Does the effect cost anything when it is switched off?

No. On the low quality tier the buffer size resolves to zero, so no render targets are allocated and both GPU passes are skipped, and the ground material's snow terms are guarded on a strength uniform of zero. A regression test pins the zero result across map sizes so the tier cannot start paying silently.

← All posts