diff --git a/D-progress.md b/D-progress.md index 37335de..d9cb591 100644 --- a/D-progress.md +++ b/D-progress.md @@ -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 + (1564–2805, 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`. diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 056e440..78b8464 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -4,6 +4,58 @@ Reference stack ported from `90sDJsim/web/world/index.html` ~405–520 (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: 1564–2805, 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.1–0.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:6921–6931`, `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 `` + `` 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 | diff --git a/docs/shots/laneD/r4_shell_night.jpg b/docs/shots/laneD/r4_shell_night.jpg new file mode 100644 index 0000000..df1d412 Binary files /dev/null and b/docs/shots/laneD/r4_shell_night.jpg differ diff --git a/docs/shots/laneD/r4_shell_noon_near+mid.jpg b/docs/shots/laneD/r4_shell_noon_near+mid.jpg new file mode 100644 index 0000000..1f75e86 Binary files /dev/null and b/docs/shots/laneD/r4_shell_noon_near+mid.jpg differ diff --git a/web/js/citizens/impostor.js b/web/js/citizens/impostor.js index 1b83444..7f428a2 100644 --- a/web/js/citizens/impostor.js +++ b/web/js/citizens/impostor.js @@ -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. 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 + #include } `; @@ -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();