The value bracket that was never on screen
The design doc said the lit wall was brighter than the floor. It had said so for weeks. Then somebody measured a screenshot.
docs/visual-design.md had a rule in it, and the rule was good: because you can
spin the camera through a full circle under an orthographic projection, hue
has to tell you what you can roll on, and value can’t be trusted to. The
lit wall was supposed to be brighter than the floor, specifically so that
“bright means walkable” would stop being a shortcut your eye could
take.
It read like a description of the game. It was a description of the
Palette.swift constants.
What a screenshot said instead
The numbers in the doc were albedo. The sun in this scene is steep, so a vertical face catches very little of it, and the compensating albedo bump was short by roughly a factor of two. On a device screenshot the lit wall measured 140 against the top’s 191 — the old hierarchy, tops brightest, exactly the one the rule existed to get rid of.
Two more things fell out of the same pass, both from pixels rather than source:
- 63–69% of the playfield was one flat pale grey. Near-neutral, luma 191, with a 25% dip where the grid line lands and no other variation anywhere. Graph paper over a blockout.
- Two perpendicular sunlit walls barely broke at the corner — under
10%, against 24% for a grid line. The fill light travels
(0.55, -0.38, 0.42), so it separates the −x/−z pair nicely. The sun is so steep it has almost no horizontal component, so the +x/+z pair got nothing. The fill light was doing all the corner separation, and only on the half of the walls that didn’t need it.
Three fixes
Split the walls by world axis as well as by sun side. Six index buffers
instead of four. The alternative was a fourth light, and it lost on maintenance
rather than on looks: the sun-side classification already existed one line
above, so the axis split is three lines and a switch, while a light’s
direction and category mask would need hand-keeping in sync with sunDirection
forever. Corners now break at 19.7%.
Per-cell value jitter at 0.05, hashed from the world-space UV. Tuned on screenshots at three settings: 0.03 vanishes at frame scale, 0.06 starts reading as a faint second grid. The obvious objection — that the jitter is per-cell, not per-block — doesn’t show up in practice, because the grid line already draws a boundary at every cell edge. The shade step lands exactly where the eye has been told to expect one.
Tops dropped from 0.68 to 0.47. That’s the actual rebalance. The floor now sits between the two sunlit wall values, which means there is no brightness threshold anywhere that separates walkable from wall. Hue carries it alone, which is what the doc claimed all along. Checked at four camera yaws.
What it cost
The marble used to read as dark-against-bright in one direction and bright-against-dark in the other; now the contrast is similar both ways, so the googly eyes and the wisp carry more of the job of finding it. The floor is no longer brighter than the sky, so the skyline reads on saturation instead.
Frame time is still unmeasured. Six draw calls instead of four, about six ALU added to a shader modifier covering most of the screen, and no numbers — same as the water. Worth writing down as an open cost rather than assuming it away.
The tool that made it possible
Every one of those measurements needed the same frame twice. The demo driver
spins the camera, which means no two builds could be compared. So the commit
also adds -yaw <degrees> to pin it.
Look work is A/B work. Without a way to hold the frame still, you are not comparing renders, you are comparing memories of renders.
Also worth knowing
Orthographic projection means every ray is parallel, so texel density is constant across the whole frame. Grid-line contrast measured at four different screen depths in the same frame: 24.5 / 24.6 / 24.6 / 24.6%. There is no mip falloff here and no LOD problem, which flips the usual rule — fine sub-cell detail is safe in a way it never is in a perspective game. One cell is about 150 device pixels against a 128px tile, so the texture is being magnified. Aliasing is not an argument against anything in this project. Competing with the cell line for attention is.