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.

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:
| Channel | Signal | Updated |
|---|---|---|
| R | Snow surface height. 1.0 is pristine powder, lower is packed or carved | Every frame |
| G | Surface dryness. 1.0 is dry, lower is wet. Rain stamps this | Every frame |
| B | How much snow can lie on this tile at all | On terrain change |
| A | Where snow drifts deeper than the surrounding field | On 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.

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:
| Setting | Buffer edge | Stamps per frame | Precipitation ceiling |
|---|---|---|---|
| low | 0 | 0 | 0 |
| medium | 512 | 24 | 1,500 |
| high | 1,024 | 64 | 6,000 |
| ultra | 2,048 | 128 | 15,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.





