PROCITY/web/js/citizens/impostor.js
m3ultra a5e4b64a9d 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>
2026-07-14 18:56:28 +10:00

253 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane D — impostor atlas + instanced billboard layer (the mid-tier LOD).
//
// Rigged mixers are expensive, so past ~25m a citizen becomes a flat billboard. To keep the mid
// crowd automatically in sync with the fleet, we render each ped ONCE to an offscreen render target
// from 4 yaw angles → one sprite atlas → an InstancedMesh of camera-facing quads that each pick the
// atlas cell for its nearest baked angle. One draw call for the entire mid crowd, per atlas.
//
// Cylindrical billboard (locked upright, rotates around Y toward the camera). No per-frame CPU
// matrix math beyond writing instance translation/scale + the chosen uv-offset.
import * as THREE from 'three';
// ---- bake: subjects[] (each { object3D, height }) × yaws → one atlas texture on a WebGLRenderTarget.
// Returns an atlas descriptor; keep it for the layer, call dispose() when done with the crowd.
export function bakeImpostorAtlas(renderer, subjects, { yaws = 4, cell = 128, maxTex = 2048, environment = null } = {}) {
const n = subjects.length;
const total = n * yaws;
const cols = Math.max(1, Math.min(total, Math.floor(maxTex / cell)));
const rows = Math.ceil(total / cols);
const atlasW = cols * cell, atlasH = rows * cell;
const rt = new THREE.WebGLRenderTarget(atlasW, atlasH, {
minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat, generateMipmaps: false, depthBuffer: true,
});
// store LINEAR lit colour (no tone-map, no OETF) — the layer shader tone-maps + sRGB-encodes so
// impostors match the ACES-tonemapped rigs exactly (seamless near↔mid). See IMP_FRAG.
rt.texture.colorSpace = THREE.LinearSRGBColorSpace;
const exposure = renderer.toneMappingExposure;
// a tiny bake scene lit to roughly match a warm street key so impostors read like the rigs.
// the environment (PMREM) matters: the peds are metallic-PBR and render dark without one.
const scene = new THREE.Scene();
scene.environment = environment;
const key = new THREE.DirectionalLight(0xfff2e0, 2.1); key.position.set(0.6, 1.4, 0.9); scene.add(key);
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x50463a, 1.2));
const holder = new THREE.Group(); scene.add(holder);
const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100);
// preserve renderer state
const prevRT = renderer.getRenderTarget();
const prevAutoClear = renderer.autoClear;
const prevClear = new THREE.Color(); renderer.getClearColor(prevClear);
const prevAlpha = renderer.getClearAlpha();
const prevScissorTest = renderer.getScissorTest();
const prevToneMapping = renderer.toneMapping;
const prevViewport = new THREE.Vector4(); renderer.getViewport(prevViewport);
const prevScissor = new THREE.Vector4(); renderer.getScissor(prevScissor);
renderer.toneMapping = THREE.NoToneMapping; // bake linear; the shader tone-maps at display time
renderer.setRenderTarget(rt);
renderer.setClearColor(0x000000, 0);
renderer.clear(); // one clear to transparent, then paint cells without clearing
renderer.autoClear = false;
renderer.setScissorTest(true);
// square framing (frustum aspect MUST equal the square cell or the figure stretches). The cell
// frames a square world region 1.14h tall, from y=-0.07h (below the feet) to y=1.07h (headroom).
const SPAN = 1.14, FOOT = 0.07;
for (let s = 0; s < n; s++) {
const subj = subjects[s];
const obj = subj.object3D;
const h = subj.height || 1.9;
const half = h * SPAN * 0.5; // square: half-width == half-height
cam.left = -half; cam.right = half;
cam.top = h * (SPAN - FOOT); cam.bottom = -h * FOOT; // total height = h*SPAN, feet at world y=0
cam.position.set(0, h * 0.5, -h * 4); // camera on -Z, looking toward +Z (unit y=0 = feet)
cam.lookAt(0, h * 0.5, 0);
cam.updateProjectionMatrix();
holder.clear();
holder.add(obj);
obj.position.set(0, 0, 0);
for (let y = 0; y < yaws; y++) {
obj.rotation.y = (y / yaws) * Math.PI * 2; // bake evenly-spaced yaws; y=0 faces the camera
obj.updateWorldMatrix(true, true);
const k = s * yaws + y;
const col = k % cols, row = (k / cols) | 0;
// WebGL viewport/scissor origin is bottom-left
const px = col * cell, py = atlasH - (row + 1) * cell;
renderer.setViewport(px, py, cell, cell);
renderer.setScissor(px, py, cell, cell);
renderer.render(scene, cam);
}
holder.remove(obj);
}
// restore renderer
renderer.setScissorTest(prevScissorTest);
renderer.setViewport(prevViewport); // per-cell setViewport clobbered these — restore both
renderer.setScissor(prevScissor);
renderer.autoClear = prevAutoClear;
renderer.setClearColor(prevClear, prevAlpha);
renderer.setRenderTarget(prevRT);
renderer.toneMapping = prevToneMapping;
return {
texture: rt.texture, rt, yaws, cols, rows, n, exposure,
frame: { span: SPAN, foot: FOOT }, // billboard sizing must match the bake framing
cellScale: new THREE.Vector2(1 / cols, 1 / rows),
// uv min corner (top-left in texture space) for a (subject, yaw) cell
cellUV(s, y) {
const k = s * yaws + y;
const col = k % cols, row = (k / cols) | 0;
return [col / cols, 1 - (row + 1) / rows];
},
dispose() { rt.dispose(); },
};
}
// ---- the instanced billboard layer ----
const IMP_VERT = `
attribute vec2 iUvOffset;
uniform vec2 uCellScale;
varying vec2 vUv;
void main() {
vUv = iUvOffset + uv * uCellScale;
// instance translation + non-uniform scale (x = width, y = height) live in instanceMatrix.
// Fold in modelMatrix so impostors track the citizens group's transform (chunk offsets in Lane B),
// exactly like the rig children do — cameraPosition is world space, so the base must be too.
vec3 base = (modelMatrix * vec4(instanceMatrix[3][0], instanceMatrix[3][1], instanceMatrix[3][2], 1.0)).xyz;
float w = length(vec3(instanceMatrix[0][0], instanceMatrix[0][1], instanceMatrix[0][2]));
float h = length(vec3(instanceMatrix[1][0], instanceMatrix[1][1], instanceMatrix[1][2]));
vec3 toCam = cameraPosition - base;
vec3 toCamH = normalize(vec3(toCam.x, 0.0, toCam.z));
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), toCamH)); // camera-right, horizontal
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 world = base + right * (position.x * w) + up * (position.y * h);
gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
}
`;
// 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;
varying vec2 vUv;
void main() {
vec4 c = texture2D(uAtlas, vUv);
if (c.a < 0.5) discard; // hard cut-out — no sorting needed
gl_FragColor = vec4(c.rgb * uTint, 1.0); // c.rgb is LINEAR
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
export class ImpostorLayer {
constructor(atlas, { maxInstances = 256 } = {}) {
this.atlas = atlas;
this.max = maxInstances;
// unit quad, origin at bottom-centre (x in [-0.5,0.5], y in [0,1]) so it plants on the ground
const geo = new THREE.PlaneGeometry(1, 1);
geo.translate(0, 0.5, 0);
const iGeo = new THREE.InstancedBufferGeometry();
iGeo.index = geo.index;
iGeo.attributes.position = geo.attributes.position;
iGeo.attributes.uv = geo.attributes.uv;
this._uvOffset = new THREE.InstancedBufferAttribute(new Float32Array(maxInstances * 2), 2);
this._uvOffset.setUsage(THREE.DynamicDrawUsage);
iGeo.setAttribute('iUvOffset', this._uvOffset);
this.material = new THREE.ShaderMaterial({
vertexShader: IMP_VERT, fragmentShader: IMP_FRAG,
uniforms: {
uAtlas: { value: atlas.texture },
uCellScale: { value: atlas.cellScale },
uTint: { value: new THREE.Color(1, 1, 1) },
},
transparent: false, side: THREE.DoubleSide,
toneMapped: true, // three tone-maps (canvas: ACES; composer: linear→OutputPass). See IMP_FRAG.
});
this.mesh = new THREE.InstancedMesh(iGeo, this.material, maxInstances);
this.mesh.frustumCulled = false; // we cull citizens ourselves; billboards span chunks
this.mesh.count = 0;
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this._m = new THREE.Matrix4();
this._q = new THREE.Quaternion();
this._s = new THREE.Vector3();
this._p = new THREE.Vector3();
}
// pick the baked yaw whose view best matches how the camera sees this citizen's facing.
// facing = the citizen's heading angle (atan2(-dx,-dz) convention, matches rig fig.rotation.y).
_yawIndex(x, z, facing, camX, camZ) {
// ped forward (world) for heading `facing`: (-sin, -cos)
const fx = -Math.sin(facing), fz = -Math.cos(facing);
// ped→camera (horizontal)
let cx = camX - x, cz = camZ - z;
const len = Math.hypot(cx, cz) || 1; cx /= len; cz /= len;
// signed angle from forward to ped→cam, about +Y
const dot = fx * cx + fz * cz;
const cross = fx * cz - fz * cx; // y-component of (forward × toCam)
let ang = Math.atan2(cross, dot); // -π..π ; 0 = camera in front of ped
const step = (Math.PI * 2) / this.atlas.yaws;
let idx = Math.round(ang / step);
idx = ((idx % this.atlas.yaws) + this.atlas.yaws) % this.atlas.yaws;
return idx;
}
// list: [{ x, z, groundY, height, subject, facing }] — subject = atlas subject index (ped type)
update(list, camera) {
const camX = camera.position.x, camZ = camera.position.z;
const span = this.atlas.frame.span, foot = this.atlas.frame.foot;
const count = Math.min(list.length, this.max);
const off = this._uvOffset.array;
for (let i = 0; i < count; i++) {
const it = list[i];
const h = it.height || 1.75;
const cell = h * span; // square billboard, matches the baked cell
// instanceMatrix carries translation (feet-minus-margin) + scale (width in x, height in y)
this._p.set(it.x, (it.groundY || 0) - h * foot, it.z);
this._q.identity();
this._s.set(cell, cell, 1);
this._m.compose(this._p, this._q, this._s);
this.mesh.setMatrixAt(i, this._m);
const yaw = this._yawIndex(it.x, it.z, it.facing || 0, camX, camZ);
const [u, v] = this.atlas.cellUV(it.subject % this.atlas.n, yaw);
off[i * 2] = u; off[i * 2 + 1] = v;
}
this.mesh.count = count;
this.mesh.instanceMatrix.needsUpdate = true;
this._uvOffset.needsUpdate = true;
}
setTint(hex) { this.material.uniforms.uTint.value.set(hex); }
// 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();
this.material.dispose();
this.atlas.dispose();
}
}