import * as THREE from 'three'; import type { SceneResourceBag } from '@/scene/resources'; import type { RenderQualitySettings } from '@/scene/render-quality'; import type { SectionId } from '@/content/types'; import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics'; /** A bounded kit of physical roadside objects. Instance origins wrap behind the * camera; the entire object moves together, so long beams never tear at a seam. */ export function createExpressway( resources: SceneResourceBag, quality: RenderQualitySettings, ) { const group = new THREE.Group(); group.name = 'expressway-architecture'; const uniforms = { uDistance: { value: 0 }, uEnergy: { value: 0 }, uArchitecture: { value: 0 }, uWarmth: { value: 0 }, }; const vertexShader = ` uniform float uDistance; uniform float uArchitecture; varying vec3 vNormal; varying vec3 vWorld; varying float vDepth; void main() { vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0); vec4 p = instanceMatrix * vec4(position, 1.0); p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z; p.y *= 1.0 + uArchitecture * 0.22; vNormal = normalize(mat3(instanceMatrix) * normal); vWorld = p.xyz; vec4 view = modelViewMatrix * p; vDepth = -view.z; gl_Position = projectionMatrix * view; } `; function material(luminous: boolean, coral = false) { return resources.own( 'material', new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader: ` uniform float uEnergy; uniform float uWarmth; varying vec3 vNormal; varying vec3 vWorld; varying float vDepth; void main() { float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth); float nearFade = smoothstep(0.0, 9.0, vDepth); vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x)); vec3 coral = vec3(1.0, 0.18, 0.095); float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6)))); vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(facing, 2.0)'}; ${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''} if(farFade * nearFade < 0.02) discard; gl_FragColor = vec4(color, farFade * nearFade); #include #include } `, transparent: true, depthWrite: !luminous, depthTest: true, blending: luminous ? THREE.AdditiveBlending : THREE.NormalBlending, }), ); } const graphite = material(false); const cyan = material(true); const coral = material(true, true); const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1)); const dummy = new THREE.Object3D(); type Part = { x: number; y: number; z: number; sx: number; sy: number; sz: number; rz?: number; }; function batch( name: string, parts: Part[], surface: ReturnType, ) { const mesh = resources.own( 'other', new THREE.InstancedMesh(box, surface, parts.length), ); mesh.name = name; mesh.frustumCulled = false; parts.forEach((part, i) => { dummy.position.set(part.x, part.y, part.z); dummy.scale.set(part.sx, part.sy, part.sz); dummy.rotation.set(0, 0, part.rz ?? 0); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); }); mesh.instanceMatrix.needsUpdate = true; group.add(mesh); } const posts: Part[] = [], reflectors: Part[] = [], rails: Part[] = []; // Full density retains a recognisable rhythm even at the lowest quality. const pairs = quality.postEffects ? 56 : 40; for (let i = 0; i < pairs; i++) for (const side of [-1, 1]) { const z = 30 - (i * WORLD_DEPTH) / pairs; posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 }); reflectors.push({ x: side * 9.48, y: 1.1, z: z + 0.18, sx: 0.14, sy: 0.22, sz: 0.03, }); rails.push({ x: side * 9.5, y: 0.56, z, sx: 0.16, sy: 0.18, sz: WORLD_DEPTH / pairs + 0.1, }); } batch('barrier-posts', posts, graphite); batch('barrier-reflectors', reflectors, coral); batch('continuous-low-rails', rails, graphite); const fins: Part[] = [], lights: Part[] = [], feet: Part[] = []; for (let i = 0; i < 8; i++) for (const side of [-1, 1]) { const z = -24 - (i * WORLD_DEPTH) / 8; // The canted cap is the repeated silhouette: road furniture, not a tunnel. fins.push({ x: side * 12.6, y: 5.0, z, sx: 0.38, sy: 10, sz: 0.64 }); fins.push({ x: side * 10.45, y: 10.55, z, sx: 0.32, sy: 4.6, sz: 0.64, rz: side * 1.31, }); lights.push({ x: side * 12.36, y: 5.9, z: z + 0.34, sx: 0.075, sy: 7.5, sz: 0.035, }); lights.push({ x: side * 10.4, y: 10.4, z: z + 0.34, sx: 0.07, sy: 4.35, sz: 0.04, rz: side * 1.31, }); feet.push({ x: side * 12.6, y: 0.24, z, sx: 1.1, sy: 0.48, sz: 1.5 }); } batch('swept-signal-pylons', fins, graphite); batch('recessed-pylon-lights', lights, cyan); batch('pylon-foundations', feet, graphite); // Far roadside monolith pairs give the waves an occasional architectural edge. const landmarks: Part[] = []; for (let i = 0; i < 6; i++) for (const side of [-1, 1]) { const z = -110 - (i * WORLD_DEPTH) / 6; for (let j = 0; j < 3; j++) { landmarks.push({ x: side * (34 + j * 4.5), y: (15 + j * 6) / 2, z: z - j * 3, sx: 1.2, sy: 15 + j * 6, sz: 2.6, rz: side * 0.1, }); } } batch('ridge-observatories', landmarks, graphite); let architecture = 0; return { group, update( distance: number, energy: number, warmth: number, section: SectionId, dt: number, ) { const target = section === 'infrastructure' ? 1 : section === 'toolkit' ? 0.65 : 0; architecture += (target - architecture) * (1 - Math.exp(-dt * 0.7)); uniforms.uDistance.value = distance; uniforms.uEnergy.value = energy; uniforms.uArchitecture.value = architecture; uniforms.uWarmth.value = warmth; }, }; }