Making an item's condition four numbers on one shader
Six looks share one material each, and wear, grime, dust and glow are set per piece, so worn and new items can sit side by side without extra materials.
Every look in the game now has one material, and an item’s condition is four numbers set on that item: wear, grime, dust and glow, plus a dead switch. Peak, declining, decayed and dead are four sets of those numbers, so a new piece and a rotten one can sit next to each other on the same material. Nothing in the game uses it yet. This is the first chunk of the plan for buildings, and it is also the shader half of worn armor.
Why not a material per state?
Back on 10 October I decided to take the performance improvement of making states a material setting, and that “the item should have its own condition”. A material or texture per state means a draw state for every item in a different condition, and an item can’t be worn while its neighbor is new. Buildings need the same thing, and I’d asked for a generator that builds them before placing any in the world, so the shader came first.
What does a palette look like?
A look has three small pictures: base color, occlusion with roughness and metal packed together, and emission. They’re lossless, with no mipmaps, 128 by 64 pixels each (fantasy is 128 by 128 because it has 19 materials). A material is a row four pixels tall. Four blocks run along each row: the material as made, what it decays to, the dead version, and a dead mottle.

Armor and buildings share one set per look. Every look has those seven building rows, using one of the look’s existing materials where it had a match and a new one where it didn’t. Then I had the kit compute a table of 240 points, 40 per look, from a plain Python version of the arithmetic, so the shader had something exact to be checked against.
How does the shader work?
A piece’s UV is its light along its material’s row. Its second UV set holds a wear threshold and how closed the point is, its crevice. The sample point is a texel center, u = 2 + 28 x light pixels into a block and v = 4 x row + 2. With linear filtering, clamped edges and no mipmaps, that never reads a neighbor’s texel at any distance.
The numbers are instance uniform floats, so every mesh using the shared material has its own values. The arithmetic is in an include file, as a function of the numbers alone, so the building shader can reuse it and feed it from INSTANCE_CUSTOM later.
vec2 base = uv + vec2(dead > 0.5 ? 0.5 : 0.0, 0.0);
vec2 decay = base + vec2(0.25, 0.0);
float worn = step(uv2.x, numbers.x);
albedo = mix(textureLod(palette_bc, base, 0.0).rgb, textureLod(palette_bc, decay, 0.0).rgb, worn);
albedo = mix(albedo, grime_colour, clamp(2.0 * numbers.y * uv2.y, 0.0, 1.0));
albedo = mix(albedo, dust_colour, clamp(numbers.z * max(up, 0.0), 0.0, 1.0));
A point is worn once the wear is at least its threshold, so wear starts in the creases and spreads. Only that step changes roughness and metal. Grime darkens by the crevice amount, and dust only settles on what faces up. Dead reads the last two blocks, where the emission is black, so the dead don’t glow. Emission is the emission picture times the glow number times the look’s largest emission, which can go past 1.
Colors have to be mixed in linear light. Godot’s source_color on the base and emission samplers decodes each texel from sRGB before the filter, the grime and dust colors are stored linear, and occlusion, roughness and metal are filtered raw. The golden table follows the same rules. The four states, in the order wear, grime, dust, glow:
peak 0.0 0.0 0.0 1.0
declining 0.3 0.2 0.0 0.6
decayed 0.68 0.45 0.08 0.25
dead 1.0 0.25 0.3 0.0 (and dead on)
The game has no state files, so I copied that table and each look’s grime and dust colors from the kit. A palette rebuilt with new colors needs them rebuilt too.

Does it match the table?
The test checks a CPU copy of the arithmetic against all 240 points, then draws tiny quads with the real shader, reads the pixels back and compares them. Both come in at 240 of 240 within 2/255, and the worst GPU point is 0.73/255. Nothing bleeds between cells at 2, 8, 24 and 64 meters, which was the main risk. A control with a wrong number gets caught, and all six looks’ materials compile. The GPU half needs a window, so it goes through the GPU queue.
I’m pretty happy with how the four states read apart. The one thing I don’t like is the dead row, which looks glossy, like wet plastic. The dead roughness in the palette is too low, so that’s a palette fix in the kit and I filed it instead of changing it here.
Also done
- Filed a work item so armor of the six looks names the shared set of rows.
- Gave the new
scripts/lookfolder its rule in the package file.