PROCITY/web/js/citizens/impostor.js
m3ultra 9eb1acc61b Lane D (Citizens): deterministic LOD crowd — rigs/impostors/keepers + test page
Populate the town at city scale, on budget, deterministic per-citizen identity:
- rigs.js — ported 90sDJsim rig stack: mixamorig canonicalisation so one shared
  walk clip drives all 19 peds, head-bone height-normalise, feet-plant, pooled
  near-tier actors with walk<->idle crossfade
- impostor.js — 4-yaw sprite-atlas baker + instanced billboards: whole mid crowd
  in 1 draw call, tone-matched to the ACES near rigs
- sim.js — deterministic roster, footpath lanes off the street graph, near/mid/far
  LOD with hysteresis + hard 24-cap, staggered mixer budget, time-of-day density
- placeholder.js — seeded low-poly box humanoids (frame-one population + ?noassets)
- keepers.js — one keeper per shop at the counter, idle + greet-turn
- 21 ped GLBs (byte-identical from 90sDJsim), citizens_test.html harness,
  LANE_D_NOTES.md, 5 beauty shots

Verified in-browser: 200 citizens at max mixer 0.2ms (budget 2ms), near capped 24,
determinism holds (200 identities match seed), no T-pose, ?noassets crash-free.
Consumes Lane A plan.streets with zero adapter. Six adversarial-review bugs fixed
(non-deterministic fleet order, shared-resource disposal on pool eviction,
impostor colour-management).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:43:05 +10:00

251 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);
}
`;
// 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.
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);
}
`;
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) },
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
});
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); }
// keep impostors matched to the rigs if the shell animates renderer.toneMappingExposure (day/night)
setExposure(e) { this.material.uniforms.uExposure.value = e; }
dispose() {
this.mesh.geometry.dispose();
this.material.dispose();
this.atlas.dispose();
}
}