import * as THREE from 'three'; /* three ships untyped here (see src/scene/three.d.ts). */ type Mesh = { visible: boolean; geometry?: Geometry }; type Geometry = { dispose(): void }; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import districtsUrl from '@/assets/nightdrive-districts.glb?url'; import type { SceneResourceBag } from '@/scene/resources'; import type { RenderQualitySettings } from '@/scene/render-quality'; import type { SectionId } from '@/content/types'; import { signAccentFor } from './sign'; import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics'; import { BEAM_PARS, beamUniforms } from './headlights'; import { DAWN_PARS, dawnLightUniforms } from './dawn-light'; /** Roadside districts: each chapter raises its own landscape out of the ground * (server rows for Infrastructure, a workshop and cranes for Builds, a container * yard, radio masts, a destination arch). The kit is authored in Blender * (assets/blender/districts) and drawn as instanced, script-wrapped geometry, * like the expressway furniture. Districts at rest are hidden, so only the * current chapter's pieces cost draw calls. */ export type DistrictPiece = | 'rack-row' | 'mast' | 'workshop' | 'crane' | 'container' | 'arch'; interface Placement { x: number; z: number; y?: number; /** Yaw in radians, applied before mirroring. */ ry?: number; s?: number; /** Extra vertical stretch (the arch clears the chase camera). */ sy?: number; } /** How strongly each chapter raises a piece (0 = flat and hidden). */ export const DISTRICT_PRESENCE: Readonly< Record>> > = { // Pieces rise fully or not at all: a part-raised piece is squashed flat. 'rack-row': { infrastructure: 1 }, mast: { infrastructure: 1, toolkit: 1 }, workshop: { 'selected-builds': 1 }, crane: { 'selected-builds': 1, 'additional-work': 1 }, container: { 'additional-work': 1 }, arch: { contact: 1 }, }; const span = (count: number, from: number, to: number) => Array.from({ length: count }, (_, i) => from + ((to - from) * i) / count); /** Placements in three.js units: negative x is the left verge. Pieces face +x; * right-side copies are mirrored so they face the carriageway too. */ function placementsFor(piece: DistrictPiece, dense: boolean): Placement[] { const n = (full: number) => (dense ? full : Math.ceil(full / 2)); switch (piece) { case 'rack-row': return span(n(18), -26, -26 - WORLD_DEPTH).flatMap((z, i) => { const side = i % 2 ? 1 : -1; return [ { x: side * 14.6, z, s: 1.5 }, { x: side * 19.2, z: z - 3, s: 1.5 }, ]; }); case 'mast': return span(n(6), -90, -90 - WORLD_DEPTH).map((z, i) => ({ x: (i % 2 ? 1 : -1) * (30 + (i % 3) * 7), z, ry: i * 0.6, })); case 'workshop': return span(n(6), -48, -48 - WORLD_DEPTH).map((z, i) => ({ x: (i % 2 ? 1 : -1) * 17, z, s: 1.3, })); case 'crane': return span(n(4), -120, -120 - WORLD_DEPTH).map((z, i) => ({ x: (i % 2 ? 1 : -1) * 34, z, ry: (i % 2 ? 1 : -1) * 0.9 + i * 0.4, })); case 'container': return span(n(12), -30, -30 - WORLD_DEPTH).flatMap((z, i) => { const side = i % 2 ? 1 : -1; const stack: Placement[] = [ { x: side * 16, z, ry: Math.PI / 2, s: 1.2 }, { x: side * 16.3, z: z - 3.2, ry: Math.PI / 2, s: 1.2 }, ]; if (i % 3 !== 1) stack.push({ x: side * 16.1, y: 3.14, z: z - 1.5, ry: Math.PI / 2 + 0.06, s: 1.2 }); return stack; }); case 'arch': return span(2, -60, -60 - WORLD_DEPTH).map((z) => ({ x: 0, z, sy: 1.15 })); } } const VERTEX = ` uniform float uDistance; uniform float uRise; varying vec3 vWorld; varying float vDepth; varying float vRise; varying float vHeight; varying float vStagger; void main() { vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0); float stagger = fract(sin(dot(origin.xz, vec2(12.9898, 78.233))) * 43758.5453); vStagger = stagger; float rise = clamp(uRise * 1.45 - stagger * 0.45, 0.0, 1.0); rise = rise * rise * (3.0 - 2.0 * rise); vRise = rise; vHeight = position.y; vec4 p = instanceMatrix * vec4(position.x, position.y * rise - (1.0 - rise) * 0.4, position.z, 1.0); p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z; vWorld = p.xyz; vec4 view = modelViewMatrix * p; vDepth = -view.z; gl_Position = projectionMatrix * view; } `; const FADES = ` float farFade = 1.0 - smoothstep(360.0, 640.0, vDepth); float nearFade = smoothstep(1.5, 10.0, vDepth); float fade = farFade * nearFade; if (fade < 0.02) discard; `; /** Pieces the 03:12 window powers down and re-lights (the Infrastructure district). */ const SERVICED: ReadonlySet = new Set(['rack-row', 'mast']); /** The infrastructure accent the service re-lights in. */ export const SERVICE_ACCENT = 0x8be9ed; /* The 03:12 window. Each piece (a rack row, a mast) powers down to about 10% on its own staggered beat, then re-lights lamp row by lamp row, bottom up, in the infrastructure accent. Scroll-driven, so it never flickers. */ const SERVICE_PARS = ` uniform float uServiceDim; uniform float uServiceRelight; uniform float uPieceHeight; uniform vec3 uServiceColor; varying float vStagger; float serviceRowDim() { return clamp(uServiceDim * 1.6 - vStagger * 0.6, 0.0, 1.0); } float serviceRelit() { float rows = 7.0; float row = floor(clamp(vHeight / max(0.001, uPieceHeight), 0.0, 0.999) * rows); float front = uServiceRelight * 1.3 - vStagger * 0.3; float at = (row + 0.5) / rows; return smoothstep(at - 0.05, at + 0.05, front); } `; export interface DistrictsWorld { group: { name: string; add(...objects: unknown[]): void }; update( distance: number, energy: number, section: SectionId, elapsed: number, dt: number, ): void; /** Pieces currently raised (for tests and diagnostics). */ raised(): DistrictPiece[]; /** The 03:12 window: power-down (0..1) and re-light (0..1). Uniform writes only. */ setServiceState(dim: number, relight: number): void; } export function createDistricts( resources: SceneResourceBag, quality: RenderQualitySettings, signal: AbortSignal, ): DistrictsWorld { const group = new THREE.Group(); group.name = 'roadside-districts'; const shared = { uDistance: { value: 0 }, uEnergy: { value: 0 }, uTime: { value: 0 }, uAccent: { value: new THREE.Color(signAccentFor('start')) }, }; const service = { uServiceDim: { value: 0 }, uServiceRelight: { value: 0 }, uServiceColor: { value: new THREE.Color(SERVICE_ACCENT) }, }; const targetAccent = new THREE.Color(signAccentFor('start')); const state = new Map< DistrictPiece, { rise: { value: number }; meshes: Mesh[] } >(); const pieces = Object.keys(DISTRICT_PRESENCE) as DistrictPiece[]; for (const piece of pieces) state.set(piece, { rise: { value: 0 }, meshes: [] }); const bodyMaterial = (rise: { value: number }, serviced: boolean, height: { value: number }) => resources.own( 'material', new THREE.ShaderMaterial({ uniforms: { ...shared, ...service, ...beamUniforms, ...dawnLightUniforms, uRise: rise, uPieceHeight: height }, vertexShader: VERTEX, fragmentShader: ` uniform vec3 uAccent; varying vec3 vWorld; varying float vDepth; varying float vRise; varying float vHeight; ${BEAM_PARS} ${DAWN_PARS} ${serviced ? SERVICE_PARS : ''} void main() { ${FADES} // Opaque dark steel, like the sign truss: fades dissolve through a // fixed screen-door pattern instead of alpha, so pieces never read // as translucent sheets over the sky and the sun. float cover = fade * smoothstep(0.0, 0.25, vRise); vec2 cell = mod(floor(gl_FragCoord.xy), 4.0); float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0; if (cover < threshold) discard; vec3 n = normalize(cross(dFdx(vWorld), dFdy(vWorld))); float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35)))); float fill = max(0.0, dot(n, normalize(vec3(0.6, 0.2, -0.4)))); vec3 color = vec3(0.022, 0.018, 0.034) + vec3(0.07, 0.06, 0.1) * key + vec3(0.02, 0.015, 0.035) * fill; // Emissive edge: the silhouette catches the chapter colour, and // the tops of the pieces carry a thin lit band. float rim = pow(1.0 - abs(dot(n, normalize(cameraPosition - vWorld))), 4.0); float crown = smoothstep(0.92, 1.0, n.y) * smoothstep(1.0, 6.0, vHeight); ${serviced ? ` float dimmed = serviceRowDim(); float relit = serviceRelit(); float power = mix(1.0, mix(0.1, 1.0, relit), dimmed); color += mix(uAccent, uServiceColor, dimmed * relit) * (rim * 0.22 + crown * 0.05) * power;` : ` color += uAccent * (rim * 0.22 + crown * 0.05);`} // HIGH BEAM: the headlights rake the facades they face. vec3 toLamp = normalize(uBeamOrigin - vWorld); color += vec3(1.0, 0.84, 0.66) * headlightBeam(vWorld) * (0.25 + 0.75 * abs(dot(n, toLamp))) * 0.35; // END OF SHIFT: the facades catch first light from the low sun ahead. color += dawnRim(n, normalize(cameraPosition - vWorld)) * 1.4; gl_FragColor = vec4(color, 1.0); #include #include } `, transparent: false, depthWrite: true, side: THREE.DoubleSide, }), ); const lightMaterial = (rise: { value: number }, accentMix: number, serviced: boolean, height: { value: number }) => resources.own( 'material', new THREE.ShaderMaterial({ uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height }, vertexShader: VERTEX, fragmentShader: ` uniform vec3 uAccent; uniform float uEnergy; uniform float uTime; varying vec3 vWorld; varying float vDepth; varying float vRise; varying float vHeight; ${serviced ? SERVICE_PARS : ''} void main() { ${FADES} // Neon tubes warm up as the district finishes rising. A smooth // ignite, never a stutter: nothing in the scene flashes faster // than 3 Hz (photosensitivity, round 5). float on = smoothstep(0.8, 1.0, vRise); on *= on; vec3 tone = mix(vec3(0.55, 0.95, 1.0), uAccent, ${accentMix.toFixed(2)}); // Close passes dim, so overhead neon never floods the bloom pass. float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth)); vec3 color = tone * (1.1 + uEnergy * 0.4) * near; ${serviced ? ` float dimmed = serviceRowDim(); float relit = serviceRelit(); color = mix(color, uServiceColor * (1.1 + uEnergy * 0.4) * near, dimmed * relit * 0.85); color *= mix(1.0, mix(0.1, 1.0, relit), dimmed);` : ''} gl_FragColor = vec4(color * on * fade, 1.0); #include #include } `, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, }), ); const dummy = new THREE.Object3D(); // Draw freshly installed (still flat) pieces for a couple of frames so their // shader programs compile while parked at the on-ramp, not mid-drive. let warmFrames = 0; const install = (piece: DistrictPiece, body: Geometry, light: Geometry) => { const entry = state.get(piece); if (!entry) return; const placements = placementsFor(piece, quality.postEffects); const accentMix = piece === 'arch' ? 1 : 0.35; const serviced = SERVICED.has(piece); const height = { value: 1 }; if (serviced) { const bounds = (body as Geometry & { computeBoundingBox?(): void; boundingBox?: { max: { y: number } } | null }); bounds.computeBoundingBox?.(); height.value = Math.max(0.5, bounds.boundingBox?.max.y ?? 1); } for (const [geometry, material] of [ [body, bodyMaterial(entry.rise, serviced, height)], [light, lightMaterial(entry.rise, accentMix, serviced, height)], ] as const) { resources.own('geometry', geometry); const mesh = resources.own( 'other', new THREE.InstancedMesh(geometry, material, placements.length), ); mesh.name = `district-${piece}`; mesh.frustumCulled = false; mesh.visible = false; placements.forEach((placement, i) => { const mirror = placement.x > 0 ? -1 : 1; dummy.position.set(placement.x, placement.y ?? 0, placement.z); dummy.rotation.set(0, (placement.ry ?? 0) * mirror, 0); const s = placement.s ?? 1; dummy.scale.set(s * mirror, s * (placement.sy ?? 1), s); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); }); mesh.instanceMatrix.needsUpdate = true; entry.meshes.push(mesh); group.add(mesh); warmFrames = 2; } }; // The kit streams in beside the first frame; a failed fetch leaves the road // exactly as it was before districts existed. void (async () => { try { const response = await fetch(districtsUrl, { signal }); if (!response.ok) return; const bytes = await response.arrayBuffer(); if (signal.aborted) return; const gltf = await new GLTFLoader().parseAsync(bytes, districtsUrl); if (signal.aborted) return; for (const piece of pieces) { const body = gltf.scene.getObjectByName(`${piece}_body`) as Mesh | undefined; const light = gltf.scene.getObjectByName(`${piece}_light`) as Mesh | undefined; if (body?.geometry && light?.geometry) install(piece, body.geometry, light.geometry); } } catch { /* Districts are optional scenery. */ } })(); let current: SectionId = 'start'; return { group, update(distance, energy, section, elapsed, dt) { current = section; const warming = warmFrames > 0; if (warming) warmFrames -= 1; shared.uDistance.value = distance; shared.uEnergy.value = energy; shared.uTime.value = elapsed; targetAccent.set(signAccentFor(section)); shared.uAccent.value.lerp(targetAccent, 1 - Math.exp(-dt * 2.5)); for (const [piece, entry] of state) { const target = DISTRICT_PRESENCE[piece][section] ?? 0; const rate = target > entry.rise.value ? 0.9 : 1.4; entry.rise.value += (target - entry.rise.value) * (1 - Math.exp(-dt * rate)); if (Math.abs(target - entry.rise.value) < 0.002) entry.rise.value = target; const visible = warming || entry.rise.value > 0.002; for (const mesh of entry.meshes) mesh.visible = visible; } }, raised: () => pieces.filter((piece) => (DISTRICT_PRESENCE[piece][current] ?? 0) > 0), setServiceState(dim, relight) { service.uServiceDim.value = Number.isFinite(dim) ? Math.min(1, Math.max(0, dim)) : 0; service.uServiceRelight.value = Number.isFinite(relight) ? Math.min(1, Math.max(0, relight)) : 0; }, }; }