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