// 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. 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 #include } `; 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(); } }