Lane D round-4: in-shell verify (D1) + decimated-ped validation (D2) + impostor tone-map fix (D3)

D1: peds+keepers upgrade placeholder->rig in the shell; 12 enter/exit shops leak-free
(renderer.info.memory back to exact baseline); determinism holds across reload; ?noassets
fully placeholder with zero ped fetches.

D2 (gate-3 critical path): validated Lane E's decimated fleet — all 19 peds <=3k tris
(1564-2805, avg 2450), rigs bind+animate (skinning/skeleton preserved), 0 broken meshes under
eviction churn, silhouettes+identity intact, atlas bakes clean. Near fleet ~59.6k tris at the
24-cap (was ~1.2M) — F clear to re-measure gate 3.

D3: fixed a real bug found only in-shell — the impostor self-tone-mapped (toneMapped:false +
in-shader ACES), correct on the direct-canvas test page but DOUBLE-tone-mapped under the shell's
EffectComposer/OutputPass. Reworked to a standard toneMapped:true material outputting linear via
three's own tonemapping/colorspace chunks, so it matches the near rigs on both render paths.
setExposure is now a no-op (exposure is global). Verified noon+night in-shell.

Only web/js/citizens/impostor.js changed (+ NOTES/progress/R4 shots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 18:56:28 +10:00
parent 34926324bd
commit a5e4b64a9d
5 changed files with 101 additions and 19 deletions

View File

@ -1,5 +1,33 @@
# PROCITY-D — progress (Lane D · Citizens)
## Round 4 (v1-tag round) — DONE. qa.sh --strict GREEN.
All three Lane-D R4 tasks complete; details + numbers in `docs/LANES/LANE_D_NOTES.md` (top).
- **D1 — in-shell verify** ✅ In the real game (`web/index.html`): peds + keepers upgrade
placeholder→rig; **12 enter/exit shops leak-free** (`renderer.info.memory` back to exactly
baseline, Δ0); determinism holds across reload; `?noassets=1` fully placeholder, zero ped fetches.
- **D2 — decimated peds** ✅ (critical path; E1 landed) Validated E's fleet: **all 19 peds ≤3k tris
(15642805, avg 2450)**, rigs bind + animate (skinning/skeleton preserved, walk/idle retarget
unchanged), **0 broken meshes** under eviction churn, silhouettes + identity variety intact,
impostor atlas bakes clean, determinism + leak re-pass. **Gate-3 sign-off for Lane F: near fleet
now ≈59.6k tris at the 24-cap (was ~1.2M); whole test view 88.9k (was ~1.5M).**
- **D3 — exposure sync** ✅ Verifying it **found + fixed a real bug**: the impostor material
self-tone-mapped (`toneMapped:false` + in-shader ACES), which is correct on my direct-canvas test
page but **double-tone-mapped under the shell's EffectComposer/OutputPass** (three only tone-maps
materials on the canvas path). Reworked it to a standard `toneMapped:true` material that outputs
linear and uses three's own tone-map/colorspace chunks → tone-maps **identically to the near rigs
on both paths**. Verified near+mid match at noon and correct night render in-shell
(`docs/shots/laneD/r4_shell_*.jpg`). `citizens.setExposure()` is now a harmless no-op (exposure is
global); Lane F can keep or drop the call at `web/index.html:245`.
Only `web/js/citizens/impostor.js` changed this round (+ my NOTES/progress/R4 shots). Committed
atomically; `web/index.html`, `dbg.js`, and other lanes' in-flight files left untouched.
---
# (Round 1) — initial Lane D build
**Status: complete and verified in-browser.** 2026-07-14, by PROCITY-D (Opus 4.8).
Standalone test page runs, all Lane-D acceptance gates pass. One cross-cutting finding for Lane E
(ped poly-count) is flagged below and in `docs/LANES/LANE_D_NOTES.md`.

View File

@ -4,6 +4,58 @@
Reference stack ported from `90sDJsim/web/world/index.html` ~405520 (loadRig/spawnRig/_canon/
_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.*
---
## ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F)
**All three R4 tasks done. `tools/qa.sh --strict` GREEN. Sign-off for Lane F below.**
### D1 — in-shell rig/keeper verification (`web/index.html`) ✅
Verified in the real game (not the test page), via `window.PROCITY`:
- **Placeholder→rig upgrade** fires for both peds (`citizens.mode` → `'rig'`, 19 fleet, pool active)
and keepers (keeper spawns at the counter `keeperStand` pose as a **rig**).
- **Leak-free**: baseline `renderer.info.memory` = {geometries 213, textures 198}; after **12
enter/exit shop cycles** it returned to **exactly** 213/198 (Δ geom 0, Δ tex 0). Interiors peak
~303 geoms inside, drop back every exit. Confirms the R-review dispose fix (free the keeper's
Skeleton only, never the fleet-shared geo/mats) holds in-shell alongside Lane C's interior dispose.
- **Determinism in-shell**: seed 20261990 → byte-identical identity signature across a full reload.
- **`?noassets=1`**: `fleet===null`, mode stays `placeholder`, peds + keeper are placeholder actors,
**zero ped GLB fetches** — town fully playable asset-free.
### D2 — decimated ped validation (critical path — E1 landed `518678d`) ✅ SIGN-OFF FOR F
Validated Lane E's decimated fleet (`web/models/peds/`, ref `lane-e/round4-peds`):
- **Per-ped tris: 15642805, avg 2450 — all ≤3k** (matches E's `_peds_decim.json`).
- **Rigs bind + animate**: skinning preserved, the shared `walk.glb`/`idle.glb` retarget unchanged
(skeleton/65 joints intact), near actors animate, mixer 0.10.3 ms.
- **No skinning explosions**: 24-teleport pool-eviction churn → **0 broken meshes**; silhouettes
read at street distance; identity variety intact (verified visually — kid/luchador/hi-vis/elder/
business all distinct, no melted hands/faces).
- **Impostor atlas bakes clean** from the decimated rigs; determinism + leak checks re-pass.
- **Gate-3 number for Lane F**: at the 24-rig near cap the fleet now contributes **≈59.6k tris**
(was ~1.2M). Test-page whole-view total **88.9k tris** (was ~1.5M). In-shell sample near rig 2729
tris, keeper 2073 tris (was 41k). **F is clear to re-measure gate 3** — the fleet leaves ~140k for
the town, comfortably ≤200k. (The R1 "peds too heavy" finding is now resolved by E's decimation.)
### D3 — exposure sync at dusk/night — FOUND + FIXED A REAL BUG ✅
Verifying the shell exposed a genuine rendering bug (only correct on my direct-canvas test page):
**the impostor material was `toneMapped:false` and applied ACES in-shader**, but the shell renders
through an **EffectComposer (RenderPass → bloom → OutputPass)**. three only tone-maps materials when
rendering to the **canvas** (`three.module.js:69216931`, `currentRenderTarget === null`); into a
composer target, materials output **linear** and OutputPass applies ACES once. So my self-tone-mapping
impostors were **double-tone-mapped** in the actual game (too dark), matching the rigs only on the
test page. **Fix (impostor.js):** the impostor is now a normal `toneMapped:true` material — it outputs
**linear** and uses three's own `<tonemapping_fragment>` + `<colorspace_fragment>` call-sites (the
`*_pars_*` are auto-injected — including them ourselves double-defines `RRTAndODTFit` and fails to
compile), so it tone-maps **identically to the near rigs on both paths**: canvas → ACES per-material;
composer → linear → OutputPass ACES. Verified: near rig + mid impostor match brightness at noon AND
the scene reads correctly at night 22:00 in the shell (`docs/shots/laneD/r4_shell_*.jpg`), and the
test-page canvas path still matches.
- **Note for Lane F**: `citizens.setExposure()` is now a **no-op** (exposure is global via
`renderer.toneMappingExposure`, which both three and OutputPass read). The call at
`web/index.html:245` is harmless — keep or drop it, your call.
*(Round-1/3 notes below remain valid; the tri-budget finding #1 is superseded by D2's decimation.)*
## What shipped
| file | role |

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

View File

@ -131,28 +131,29 @@ const IMP_VERT = `
gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
}
`;
// atlas holds LINEAR lit colour → ACES tone-map (three's fit) → sRGB OETF, so mid impostors match
// the ACES-tonemapped near rigs. Without this the linear texels display ~2× too dark.
// The atlas holds LINEAR lit colour. We output linear and let three's own pipeline tone-map +
// colour-manage via the injected chunks, so the impostor behaves EXACTLY like every other material:
// • direct-to-canvas (test page): three applies ACES + sRGB (matches the near rigs)
// • EffectComposer (shell): the RenderPass renders to a target → three uses NoToneMapping → we
// output linear → OutputPass applies ACES once (matches the rigs). A self-tone-mapping shader
// (toneMapped:false + in-shader ACES) would DOUBLE tone-map under the composer's OutputPass —
// correct only on the canvas path. Letting three do it is right on both. (Exposure is global via
// renderer.toneMappingExposure, so no per-material exposure plumbing is needed.)
// NOTE: three injects tonemapping_pars_fragment + colorspace_pars_fragment into the ShaderMaterial
// prefix automatically (WebGLProgram), so we include ONLY the call-sites here — adding the *_pars_*
// ourselves would double-define RRTAndODTFit etc. and fail to compile. <tonemapping_fragment> is a
// no-op when the render target forces NoToneMapping (composer RenderPass), and applies ACES on the
// canvas — exactly the behaviour that keeps impostors matched to the rigs on both paths.
const IMP_FRAG = `
uniform sampler2D uAtlas;
uniform vec3 uTint;
uniform float uExposure;
varying vec2 vUv;
// imp-prefixed to avoid colliding with three's injected tonemapping functions of the same name
vec3 impRRTFit(vec3 v){ vec3 a=v*(v+0.0245786)-0.000090537; vec3 b=v*(0.983729*v+0.4329510)+0.238081; return a/b; }
vec3 impACES(vec3 color){
color *= uExposure / 0.6; // three's ACES normalises exposure by 0.6 — match it exactly
const mat3 ACESInput = mat3(0.59719,0.07600,0.02840, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777);
const mat3 ACESOutput = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602);
color = ACESInput * color; color = impRRTFit(color); color = ACESOutput * color;
return clamp(color, 0.0, 1.0);
}
vec3 impLin2sRGB(vec3 c){ return mix(c*12.92, 1.055*pow(c,vec3(1.0/2.4))-0.055, step(0.0031308,c)); }
void main() {
vec4 c = texture2D(uAtlas, vUv);
if (c.a < 0.5) discard; // hard cut-out — no sorting needed
vec3 col = impLin2sRGB(impACES(c.rgb)) * uTint;
gl_FragColor = vec4(col, 1.0);
gl_FragColor = vec4(c.rgb * uTint, 1.0); // c.rgb is LINEAR
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
@ -178,10 +179,9 @@ export class ImpostorLayer {
uAtlas: { value: atlas.texture },
uCellScale: { value: atlas.cellScale },
uTint: { value: new THREE.Color(1, 1, 1) },
uExposure: { value: atlas.exposure ?? 1.0 },
},
transparent: false, side: THREE.DoubleSide,
toneMapped: false, // we tone-map in-shader (impACES); stop three injecting its own ACES fns
toneMapped: true, // three tone-maps (canvas: ACES; composer: linear→OutputPass). See IMP_FRAG.
});
this.mesh = new THREE.InstancedMesh(iGeo, this.material, maxInstances);
@ -239,8 +239,10 @@ export class ImpostorLayer {
}
setTint(hex) { this.material.uniforms.uTint.value.set(hex); }
// keep impostors matched to the rigs if the shell animates renderer.toneMappingExposure (day/night)
setExposure(e) { this.material.uniforms.uExposure.value = e; }
// No-op: exposure is now global (renderer.toneMappingExposure) — three's tone-mapping (canvas) and
// the composer's OutputPass both read it, so impostors track day/night automatically. Kept so the
// shell's citizens.setExposure(...) call site stays valid (Lane F may drop it — harmless either way).
setExposure(_e) {}
dispose() {
this.mesh.geometry.dispose();