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>
253 lines
12 KiB
JavaScript
253 lines
12 KiB
JavaScript
// 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();
|
||
}
|
||
}
|