The map went white. Not black, not missing: a featureless white plane, edge to edge, at every zoom level and in every biome, with the units, the buildings and the trees still drawn correctly on top of it. Everything that owned its own material was fine. The ground underneath all of it was gone.

The cause is a ceiling the project never chose, and three words are needed to state it.
A shader is a small program that runs on the graphics card rather than on the processor, once per pixel, to decide what colour that pixel is. The ground in this game is drawn by one shader that blends every terrain layer together: grass under trees, mud at a river edge, snow settling on rock. It is written in a C-like language, shipped as source, and compiled by the graphics driver on the player's machine at the moment it is first used.
That compile has two steps, and the second is where this bug lives. The pieces are compiled, then linked into one runnable program. A shader that fails to link is not a slow shader or an ugly shader. It is not a program at all.
A sampler is the shader's handle on one texture — the thing it reads pixels out of. Every texture a shader wants to read costs one, and a graphics card only has so many.
The ground shader here uses exactly 16. WebGL2 guarantees only 16, a value it
calls MAX_TEXTURE_IMAGE_UNITS, and adding a seventeenth does not make the
ground coarser or slower. The shader fails to link, and a shader that does not
link draws nothing.
The obvious guess about what happens next is wrong. three.js does not substitute a fallback material. It records the failed program as not runnable and the renderer keeps drawing with it, so the ground is submitted every frame against a program that never linked. What the driver returns for that is its own business, and on this hardware it is flat white. Nothing is thrown, the frame rate is fine, and nothing reached the tool that turns an uncaught exception into a filed report, because there was no exception. three.js prints shader errors rather than throwing them, and every part of this failure follows from that. This shipped once, in August.
The only evidence was one line in the console
A build cannot see a driver link error. Neither can a type check, and neither can any test that does not compile shaders. The single piece of evidence anywhere was a line the WebGL driver printed to the browser console, naming the material and saying that its fragment shader referenced more texture image units than the maximum of 16.
That console line is why the game now mirrors the browser console to a file during development. A failure that is logged rather than thrown is invisible to every mechanism built for catching failures.
The tally: thirteen, plus three that are not optional
The ground shader composites every terrain layer in one pass, and binds 13 textures of its own:
| Group | Count | What they are |
|---|---|---|
| Ground layer arrays | 6 | rock, dirt, forest floor, grass, water, shore |
| Single-purpose data maps | 7 | material coverage, macro tint, shallow water, light sheet, light variation, corruption mask, corruption atlas |
Three more arrive from effects that every outdoor map carries: a fog map, a cloud shadow map and the snow trail buffer. Six plus seven plus three is 16. There is no headroom at all, and until August nothing written down anywhere said so.
That table is a hand count of the sampler declarations in the shader and in each effect, which is the slow way to get the number and the only way that works when the program will not link. There is a fast way and an automated way, further down.

Snow trails are what found the ceiling. Adam had asked for weather the ground reacts to, and the first version of that arrived with three textures rather than one, which took the shader to 18. Weather was not the only thing that could have done it. It just happened to be next.
Turning the quality down did not help, and that is why it was expensive
The obvious diagnostic was to set Weather Quality to Low. The map stayed white. That reads as strong evidence that weather is not the cause, and it is completely wrong.
An effect that patches a shader declares its textures when the shader source is generated, not when the effect is doing anything. The quality setting decides what the effect does. It has no bearing on whether the declarations exist. Switched off, snow trails still contributed three declarations to the program that failed to link.
That single wrong inference is most of what the bug cost. The change that fixed it was about twenty lines.
The fix was to stop needing the textures
Getting from three textures to one used two of the three escape routes that exist.
First, the map of where snow can lie moved into the spare channels of the trail buffer. That buffer became RGBA, and one texture now carries snow height, surface dryness, where snow can lie and where it drifts deeper. The work that computes the snow map on the CPU was left completely alone, with the same terrain values, the same blur and the same clamp that stops snow bleeding onto dirt and shorelines, and all 18 of its tests were unmodified. A small extra pass copies the result into the two spare channels when the terrain changes, so the source texture is bound during that copy only and costs no permanent slot.
Second, the corruption check stopped binding its own copy of the corruption map. It now samples the one the ground shader has already declared, which was the same texture all along. It detects the declaration in the incoming shader source rather than re-declaring it, because re-declaring something another effect already declared is itself a compile error, and it removes its own block entirely when the declaration is absent.
The same reasoning had already driven an earlier decision, where how shallow the water is, which elevation tier a tile sits on and whether it is a ramp all share the channels of one per-tile texture rather than three. Both are the same move: a texture role costs a slot and a channel does not.
Variants are free, roles are not
This is the distinction that makes the budget feel roomier than it is, and it is the part people get wrong.
Each ground material is stored as a texture array, holding all of that material's variants as slices of one texture. A biome shipping 14 ground images still costs 6 slots, not 14. Every biome carries 11 ground images, the whole set is delivered at three quality tiers, and none of that touches the count. Adding a fifth grass variant is free.
Adding a role is not. A new signal that cannot be expressed as another slice of a material that already exists needs its own texture, and there is not one spare. So the rule now recorded at the top of the ground shader is: do not add a texture, do one of three things instead. Pack the signal into spare channels of something already bound. Reuse a texture another effect has already declared, detecting that declaration in the incoming source. Add a slice to an existing array. If none of those work, the feature does not go on the ground.
Auditing your own shader's budget
None of this is specific to terrain. If you composite anything in three.js by patching a material, and fog, decals, wetness, snow, paint and damage systems all do, the same ceiling is sitting under it. Three checks tell you how close you are, and all three work in a project with none of our code in it.
Read the real limit off the machine you are on. Do not assume 16 and do not
assume 32. three.js exposes the fragment limit on the renderer's capabilities,
where it is literally the value the GL context reports for
MAX_TEXTURE_IMAGE_UNITS. That is the one that fails the link. WebGL2
guarantees 16 there and a desktop GPU commonly reports 32, which is exactly how
a shader passes on the machine it was written on and takes out the map on the
machine it ships to. Vertex-stage samplers come from a separate pool with its
own floor of 16, so a texture read in the vertex shader does not spend a
fragment slot.
Count what each material actually binds. The authoritative count is the driver's, and you can ask for it after a single render. three.js keeps every compiled program in the renderer's info, and for each one you can walk its active uniforms and total up the ones whose type is a sampler, remembering that an array of samplers costs one slot per element. Two things will bite you. That list only reports uniforms that survived the shader compiler, so a texture that is declared and genuinely never read does not count, which happens to match what the link check counts. And it only reads programs that linked, so the material you actually care about reports zero. A material you know binds a dozen textures showing zero is a material whose program is dead. Set a name on every material you patch, too. three copies it onto the program and into the driver's error text, and without it your audit is a column of blanks at the exact moment you need to know which material is the problem.
Make a link failure loud. three.js reports compile and link failures by
calling console.error, so nothing reaches a global error handler, an error
boundary or a crash reporter. That is the entire reason this was found by
looking at a white screen rather than by an alert. One hook changes it:
// Route three.js's silent console.error into whatever files your reports.
renderer.debug.checkShaderErrors = true; // the default. Leaving it on costs a
// stall per compile and buys the log.
renderer.debug.onShaderError = (gl, program, glVertexShader, glFragmentShader) => {
const detail = [
gl.getProgramInfoLog(program),
gl.getShaderInfoLog(glVertexShader),
gl.getShaderInfoLog(glFragmentShader),
].filter(Boolean).join('\n');
reportToYourErrorService(new Error(`shader link failed\n${detail}`));
};Setting that callback replaces three's own reporting completely, so whatever it does is the only record that exists. The other half of the advice runs against the usual tuning tip. Plenty of projects switch shader error checking off in production to skip the cost of fetching the info log, and that converts a loud failure into a blank surface with no diagnostics at all.
A static count, because the failure mode is a blank screen
The runtime audit answers "which of my materials is nearest the ceiling", and it needs a GPU, a canvas and a program that linked. The guard against a repeat has to work before any of that exists, and it can, because the whole checkable surface of a sampler limit is the text of the shader, and that text is available the moment the patch has run.
So the guard renders nothing and needs no GPU. It builds a plain three.js material, runs the patch over it, and counts sampler declarations in the resulting source with a regular expression covering every sampler type with or without a precision qualifier. Then it asserts the patch added at most one, and that it added none in the vertex stage.
The part that matters is that it runs twice. Once against a bare material, where there is no existing texture to reuse and every declaration has to be the patch's own. Then again against a source that already declares the texture the patch is supposed to reuse, asserting the count did not go up. A patch tested only against a bare material passes while quietly re-declaring a texture the real shader already has, and that is a compile error on the only material the game actually ships.
The rule that came out of it is standing instruction here: any effect that binds a texture to the ground carries a count like that one. The reasoning is not that tests are good. It is that this specific failure produces a blank screen and no error, so counting beats looking.
Both halves pair with the argument in one field feeding two renderers: the more of the ground that is derived from shared state, the more of it can be checked without a GPU. The packing trick that made the whole thing possible is the subject of snow that remembers where you walked, where the four-signal buffer was designed for this constraint rather than retrofitted to it. There is no test framework here at all, which is covered in testing a game without a test framework, so the guard is a standalone script with a run line in its header and nothing else to set up.
The budget has not moved and will not. The ground is at 16 of 16, so the next feature that wants a texture there has to pack into a channel that is currently spare, and counting the spare channels is now the first question asked of any ground feature rather than the last.





