factory: palette hexes reach the screen — sRGB->linear in hex_to_rgba, pinned

hex_to_rgba put sRGB fractions straight into Blender's LINEAR Base Color
socket, so baseColorFactor shipped linear-encoded-as-sRGB and three's
linear->sRGB display step made every surface one gamma step too light
and desaturated, on every asset, since Sprint 1. Measured: leaf_gum
#7C8F5E displayed #B9C5A3, bark_gum #BFB8A8 displayed #E0DDD4 (the
near-white gum trunk THREADS blamed on Blender's view transform).

Fixed at source with the exact IEC curve (alpha untouched — coverage,
not colour) and the whole factory rebuilt: ALL ASSETS PASS. e.test now
pins mat.color.getHexString(SRGBColorSpace) against the authored PAL hex
— factory intent vs what the runtime draws, the one comparison neither
end can fake. Mutation-checked red-then-green against the pre-fix GLB.
523/0/0, verified by eye in the running game before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-28 21:26:26 +10:00
parent a7a417c02a
commit 9ef0941ac4
42 changed files with 195 additions and 3 deletions

View File

@ -9476,3 +9476,73 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
For A: N7's same-client double offer reads odd under "the other goes to somebody else".
Gate 4 is JOHN'S: partly.party/hardyards — seven nights, the board, the Vasilaros cap,
and the Karalis pool yard in the pool. Your three sentences steer Sprint 18.
[E] 2026-07-28 — 🎨 **THE PALETTE HAS BEEN ONE GAMMA STEP TOO LIGHT SINCE SPRINT 1, ON EVERY
OBJECT IN THE GAME. FOUND, MEASURED, FIXED AT SOURCE, PINNED.** (Integrator wearing E's hat,
John's ask: "more detailed textures and objects". The most detailed thing available turned out
to be the art we already had, drawn correctly.)
**THE BUG.** `hex_to_rgba` (build_yard_assets.py:165) divided a palette hex by 255 and stopped
— the docstring says it was "lifted from racks_to_glb.py, kept deliberately close to the
original", and the original's bug came with it. But `Base Color` and `Emission Color` are
LINEAR sockets. The exporter copies what it finds into `baseColorFactor` (also linear), and
three converts linear→sRGB to display. An sRGB fraction parked in a linear socket therefore
renders one whole gamma step too light and too desaturated — every material, every asset,
seventeen sprints.
**MEASURED OFF THE SHIPPED GLB, before and after:**
| PAL entry | its own comment | factor before | displayed before | factor after | displayed after |
|---|---|---|---|---|---|
| `leaf_gum #7C8F5E` | "sage/olive, **not lawn green**" | 0.4863 | **#B9C5A3** | 0.2016 | **#7C8F5E** ✓ |
| `bark_gum #BFB8A8` | "eucalypt: **pale, chalky**" | 0.7490 | **#E0DDD4** | 0.5210 | **#BFB8A8** ✓ |
The game was drawing a NEAR-WHITE gum trunk where E wrote "pale, chalky", and a grey-green
where E wrote "not lawn green". **THREADS' own [E] entry blaming Blender's contact-sheet view
transform for a near-white canopy was half right — the view transform was real, and it masked
this, which shipped.** It is also why the ped has always looked like he belongs to a richer
render than the yard: he is the only textured object in the scene and his maps were correct,
so he alone was never washed out.
**THE FIX** is `_srgb_to_linear` on the three colour channels (not alpha — alpha is coverage,
not colour), the exact IEC 61966-2-1 curve rather than a 2.2 approximation because three's own
decode is that curve. Both call sites are colour sockets, both convert. Full factory rebuild:
**ALL ASSETS PASS**, 37 GLBs re-exported, one-time churn that is exactly what a deterministic
factory is for. Nothing was hand-edited; `--only` still reproduces a full run.
**THE PIN** (e.test): for three PAL materials, `mat.color.getHexString(SRGBColorSpace)` — the
exact question a player's eye asks — must equal the authored hex. It compares the FACTORY's
intent against what the RUNTIME draws, which is the one comparison neither end can fake by
agreeing with itself. **Mutation-checked red-then-green**: restoring the pre-fix GLB from HEAD
reds it with "PAL.leaf_gum is authored #7C8F5E but tree_gum_01/Mat_Leaf DISPLAYS #B9C5A3".
Selftest **523/0/0** (522 + the pin).
**WHY THIS AND NOT NEW TEXTURES (John asked for textures; here is the honest reason he got a
gamma fix first).** A texture is an sRGB map multiplied into `mat.color`. Wire a correctly
decoded map on top of a base that is already a gamma step too bright and the result is muddy
AND inconsistent with every surface you have not touched yet. The palette had to be right
before any map goes on top of it. **Verified by eye in the running game, same camera, same
night, before and after** — canopy reads eucalypt olive instead of chalky sage, sleepers read
hardwood instead of pale tan, the shed reads Colorbond grey instead of near-white, and the ped
stops looking pasted in.
**STILL OPEN, and the next shift's work — filed with evidence rather than done half:**
· **7 of 8 factory textures have ZERO runtime consumers** (`grass_atlas` — now ten sprints —
plus `hail_pips`, `moon`, `plant_shred`, `pond_normal`, `pond_water`, `sail_tears`). Only
`sail_weave` is wired (sail.js:1275). Their systems are all LIVE now — ponding, breaks,
hail, garden damage, the night sky — so this is detail the game already paid for and does
not draw. Wire them, or bin them, but the ten-sprint drift stops.
· **THE LAWN is 2550% of frame** (measured across five camera poses), flat #4a7c3f, no map,
and it is what the sail's shadow lands on — "the shadow IS the product". Its `PlaneGeometry`
already emits clean 0..1 UVs, so it needs NO factory UV work: one procedural seamless tile
+ one loader block. Biggest visual win left, cheapest wiring in the repo.
· ⚠️ **AND THE REASON THE OTHER SURFACES CANNOT HAVE MAPS YET:** every factory mesh carries
Blender's untouched default primitive unwrap. Measured by parsing TEXCOORD_0 out of the
binaries: texel density varies **95.8× WITHIN a single mesh** on `fence_panel_v1`
(0.381736.5497), 92.0× on `house_yardside_v1`'s roof; `uvArea` is *exactly* 0.3750 and U is
*exactly* [0.125, 0.875] on fence, shed, table, pergola, carport and all four debris tubs —
the literal default cube cross. There is no `smart_project`/`cube_project` anywhere in the
factory's ~4,300 lines. So a `hasAttribute('uv')` check PASSES and a wood grain would still
arrive 10× finer on a paling's edge than its face. **UV-unwrapping the factory is its own
gate** — it must land with a texel-density assert, or the next lane ships stretched wood and
calls it detail.

View File

@ -955,5 +955,42 @@
"problems": []
}
],
"debris": []
"debris": [
{
"file": "BlueCrate_v2.glb",
"dims": [
0.36,
0.36,
0.29
],
"sane": true
},
{
"file": "BlackTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WhiteTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WoodenBin_v2.glb",
"dims": [
0.35,
0.36,
0.31
],
"sane": true
}
]
}

View File

@ -162,12 +162,49 @@ WATER_DEEP = (0.22, 0.28, 0.26)
# ============================================================================
# UTILS — lifted from racks_to_glb.py, kept deliberately close to the original
# ============================================================================
def _srgb_to_linear(c):
"""One sRGB channel (0..1) -> linear. The exact IEC 61966-2-1 curve, not a
2.2 approximation, because three.js's own decode is this curve and a
mismatched gamma is what this function exists to stop."""
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def hex_to_rgba(hex_str, alpha=1.0):
"""A palette hex (which is an sRGB colour, as read off any colour picker)
-> the LINEAR floats Blender's colour sockets actually want.
THE BUG THIS FIXES, MEASURED (2026-07-28, integrator wearing E's hat).
This was lifted from racks_to_glb.py and kept "deliberately close to the
original", and the original divided by 255 and stopped. But `Base Color`
and `Emission Color` are LINEAR sockets; the glTF exporter writes what it
finds straight into `baseColorFactor` (also linear); and three.js then
converts linear -> sRGB for display. So an sRGB fraction parked in that
socket comes out one whole gamma step too light and too desaturated, on
every object in the game, since Sprint 1.
Read out of the shipped tree_gum_01_v1.glb before the fix:
PAL entry baseColorFactor displayed as
leaf_gum #7C8F5E 0.4863 0.5608 0.3686 #B9C5A3
bark_gum #BFB8A8 0.7490 0.7216 0.6588 #E0DDD4
i.e. the comment beside leaf_gum says "sage/olive, not lawn green" and the
game drew a pale grey-green; the comment beside bark_gum says "pale,
chalky" and the game drew a near-white trunk. THREADS has an [E] entry
blaming Blender's contact-sheet view transform for a canopy reading
near-white the view transform was real, but so was this, and this one
shipped. It is also why the ped (the one properly textured object in the
yard) has always looked like he belonged to a richer render than the yard.
Alpha is NOT converted: it is coverage, not colour.
"""
h = (hex_str or "#888888").lstrip("#")
if len(h) != 6:
h = "888888"
return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0,
int(h[4:6], 16) / 255.0, alpha)
return (_srgb_to_linear(int(h[0:2], 16) / 255.0),
_srgb_to_linear(int(h[2:4], 16) / 255.0),
_srgb_to_linear(int(h[4:6], 16) / 255.0),
alpha)
_MAT_CACHE = {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

View File

@ -159,6 +159,54 @@ export default async function run(t) {
assert(failed.size === 0, `failed to load: ${lost}`);
});
/**
* THE COLOUR THE PALETTE ASKED FOR IS THE COLOUR THE PLAYER SEES.
*
* Shipped broken from Sprint 1 to Sprint 17 and nothing could see it:
* `hex_to_rgba` divided a palette hex by 255 and put that sRGB fraction
* straight into Blender's LINEAR `Base Color` socket. The exporter copies it
* to `baseColorFactor` (linear), and three converts linear -> sRGB to
* display so every surface in the game rendered one whole gamma step too
* light and too desaturated. Measured before the fix: bark_gum #BFB8A8
* displayed #E0DDD4 (a near-white gum trunk), leaf_gum #7C8F5E displayed
* #B9C5A3. THREADS has an [E] entry blaming Blender's contact-sheet view
* transform for a near-white canopy; that was real too, but it masked this.
*
* The pin closes the loop the same way the post-rating pin does: it asserts
* the FACTORY's authored intent against what the RUNTIME will draw. The
* expected values are the PAL entries in build_yard_assets.py verbatim
* literals here on purpose, because the whole failure mode was the two ends
* agreeing with each other while disagreeing with the author.
*
* `getHexString(SRGBColorSpace)` is the exact question a player asks: three
* has already decoded baseColorFactor into its linear working space, so this
* re-encodes it the way the display will. Comparing the raw factor instead
* would re-implement the bug inside its own test.
*/
t.test('the palette hex the factory authored is the hex the game DISPLAYS', () => {
// [asset, material, PAL key, authored hex] — build_yard_assets.py PAL @102-130
const PINS = [
['tree_gum_01', 'Mat_Leaf', 'leaf_gum', '7c8f5e'],
['tree_gum_01', 'Mat_Bark', 'bark_gum', 'bfb8a8'],
['tree_gum_01', 'Mat_Leaf2', 'leaf_gum_2', '6b7e52'],
];
const seen = [];
for (const [assetName, matName, palKey, want] of PINS) {
const g = loaded.get(assetName);
assert(g, `${assetName} did not load`);
let mat = null;
g.scene.traverse((o) => { if (o.isMesh && o.material?.name === matName) mat = o.material; });
assert(mat, `${assetName} has no material named ${matName} — the pin is aimed at a material that moved`);
const got = mat.color.getHexString(THREE.SRGBColorSpace);
assert(got === want,
`PAL.${palKey} is authored #${want.toUpperCase()} but ${assetName}/${matName} DISPLAYS #${got.toUpperCase()}. `
+ 'That is the sRGB-into-a-linear-socket bug (or its over-correction) back again — fix hex_to_rgba '
+ 'in tools/blender/build_yard_assets.py and re-run the factory; do not touch the GLB.');
seen.push(`${palKey} #${got}`);
}
return seen.join(', ');
});
// The anchor of the whole scale system. If this is wrong, every judgement
// made against the contact sheet was made against a lie.
t.test('ref_capsule is 1.70 m tall on +Y — the scale everything is judged against', () => {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.