From 604a7765ff0e0944c908dfa988c6e4c8d2e80719 Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 10 Oct 2026 19:50:38 +0300 Subject: [PATCH] feat(scene): add destination skyline, traffic, wet-road light and a saturated sun --- src/scene/world/road.ts | 23 +++++ src/scene/world/skyline.ts | 137 +++++++++++++++++++++++++++++ src/scene/world/sun.ts | 9 +- src/scene/world/traffic.ts | 171 +++++++++++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 src/scene/world/skyline.ts create mode 100644 src/scene/world/traffic.ts diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts index 43e524a..8ecabc8 100644 --- a/src/scene/world/road.ts +++ b/src/scene/world/road.ts @@ -27,6 +27,8 @@ interface WorldGroup { const ROAD_WIDTH = 20.4; const FLOW_PERIOD = 5_694; +// Matches the pylon rhythm in expressway.ts: eight pairs over the world depth. +const PYLON_SPACING = WORLD_DEPTH / 8; export function createRoad( resources: SceneResourceBag, @@ -45,6 +47,7 @@ export function createRoad( const initialPalette = CHAPTER_PALETTES['deep-night']; const uniforms = { uDistance: { value: 0 }, + uPylonPhase: { value: 0 }, uSpeed: { value: 0 }, uAcceleration: { value: 0 }, uAsphalt: { value: new THREE.Color(0x090511) }, @@ -77,6 +80,7 @@ export function createRoad( `, fragmentShader: ` uniform float uDistance; + uniform float uPylonPhase; uniform float uSpeed; uniform float uAcceleration; uniform vec3 uAsphalt; @@ -173,6 +177,23 @@ export function createRoad( float sunReflection = exp(-pow(vRoadX / (0.55 + vViewDistance * 0.011), 2.0)) * smoothstep(36.0, 180.0, vViewDistance); color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17; + // Wet glare: the sun lays a broken column down the asphalt. + float glare = exp(-pow(vRoadX / (1.1 + vViewDistance * 0.02), 2.0)) + * smoothstep(50.0, 220.0, vViewDistance) * (1.0 - smoothstep(360.0, 560.0, vViewDistance)); + color += vec3(1.0, 0.32, 0.22) * glare * pow(film, 2.0) * asphaltMask * 0.24; + + // Headlight throw: two warm cones ahead of the car merging into a pool. + float ahead = -vWorldZ - 5.0; + float throwZ = smoothstep(-3.0, 5.0, ahead) * (1.0 - smoothstep(26.0, 92.0, ahead)); + float spread = 0.9 + max(0.0, ahead) * 0.07; + float cones = exp(-pow((vRoadX - 1.25) / spread, 2.0)) + exp(-pow((vRoadX + 1.25) / spread, 2.0)); + color += vec3(1.0, 0.84, 0.66) * min(cones, 1.4) * throwZ * (0.07 + 0.16 * film) * asphaltMask; + + // Pylon lamps reflect as long streaks towards the camera. + float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)}); + float pylonStreak = exp(-pylonGap * 0.075) * smoothstep(0.0, 1.5, pylonGap) + * exp(-pow((roadX - 6.6) * 1.1, 2.0)); + color += edgeColor * pylonStreak * film * 0.5 * (1.0 - smoothstep(300.0, 520.0, vViewDistance)); float horizonPickup = smoothstep(280.0, 620.0, vViewDistance) * (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance)) @@ -203,6 +224,8 @@ export function createRoad( update(distance, speed, acceleration, palette) { uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD; + uniforms.uPylonPhase.value = + ((finiteOrZero(distance) % PYLON_SPACING) + PYLON_SPACING) % PYLON_SPACING; uniforms.uSpeed.value = clamp01(speed); uniforms.uAcceleration.value = clamp01(acceleration); if (palette) { diff --git a/src/scene/world/skyline.ts b/src/scene/world/skyline.ts new file mode 100644 index 0000000..dab2c5e --- /dev/null +++ b/src/scene/world/skyline.ts @@ -0,0 +1,137 @@ +import * as THREE from 'three'; +import type { SceneResourceBag } from '@/scene/resources'; +import type { MutableChapterPalette } from '@/scene/world/palette'; +import { SUN_DISTANCE_Z } from '@/scene/world/metrics'; + +const WIDTH = 1_640; +const HEIGHT = 150; + +/** + * The city at the end of the road: one procedural plane between the far ridge + * and the sun. Two layers of towers in silhouette against the sun and the + * horizon glow, sparse lit windows, sun-caught roof edges and blinking masts. + * One draw call, no texture. + */ +export function createSkyline(resources: SceneResourceBag) { + const group = new THREE.Group(); + group.name = 'destination-skyline'; + const uniforms = { + uTime: { value: 0 }, + uSun: { value: new THREE.Color(0xff649d) }, + uFog: { value: new THREE.Color(0x291236) }, + uHaze: { value: new THREE.Color(0xaf287d) }, + uWarmth: { value: 0 }, + uLights: { value: 1 }, + }; + const material = resources.own( + 'material', + new THREE.ShaderMaterial({ + uniforms, + vertexShader: /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + }`, + fragmentShader: /* glsl */ ` + uniform float uTime; + uniform vec3 uSun; + uniform vec3 uFog; + uniform vec3 uHaze; + uniform float uWarmth; + uniform float uLights; + varying vec2 vUv; + + float hash(float n) { return fract(sin(n * 127.1) * 43758.5453); } + float hash2(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 15731.743); } + + // Height of a tower row at world x, with towers of jittered width. + vec4 towers(float x, float cell, float seed, float envelope, float tall) { + float id = floor(x / cell); + float local = fract(x / cell); + float r = hash(id + seed); + float r2 = hash(id * 1.7 + seed + 3.1); + float inset = 0.06 + r2 * 0.18; + float inside = step(inset, local) * step(local, 1.0 - inset * 0.5); + float h = (0.1 + pow(r, 2.4) * tall) * envelope; + // Stepped crowns on the tall ones. + float crown = step(0.62, r) * step(0.32, abs(local - 0.5)) * 0.06 * envelope; + return vec4(h - crown, inside, id, r); + } + + void main() { + float x = (vUv.x - 0.5) * ${WIDTH.toFixed(1)}; + float y = vUv.y; + float xn = vUv.x - 0.5; + float core = exp(-pow(xn / 0.2, 2.0)); + float outskirts = 0.28 + 0.72 * core + 0.2 * exp(-pow((xn + 0.27) / 0.06, 2.0)) + + 0.16 * exp(-pow((xn - 0.31) / 0.05, 2.0)); + float fade = smoothstep(0.5, 0.36, abs(xn)); + + vec4 far = towers(x + 3.0, 15.0, 11.0, outskirts * fade, 0.74); + vec4 near = towers(x, 10.0, 47.0, (0.42 + 0.58 * core) * fade, 0.5); + + float inNear = near.y * step(y, near.x); + float inFar = far.y * step(y, far.x); + float mast = step(0.9, far.w) * step(abs(fract((x + 3.0) / 15.0) - 0.5), 0.022) + * step(far.x, y) * step(y, far.x + 0.1 * outskirts); + if (inNear + inFar + mast < 0.5) discard; + + bool isNear = inNear > 0.5; + vec4 t = isNear ? near : far; + float top = t.x; + // Silhouette: near is darkest, far takes more of the horizon air. + vec3 base = mix(vec3(0.012, 0.008, 0.03), uFog * 0.55, isNear ? 0.18 : 0.42); + base += uHaze * (isNear ? 0.02 : 0.05) * (1.0 - y); + vec3 color = base; + + // Windows: a fixed grid in world units, sparse and warm or cool. + vec2 grid = vec2(x / 1.6, y * ${HEIGHT.toFixed(1)} / 2.1); + vec2 win = fract(grid); + vec2 wid = floor(grid); + float pane = step(0.28, win.x) * step(win.x, 0.78) * step(0.3, win.y) * step(win.y, 0.74); + float lit = step(isNear ? 0.86 : 0.9, hash2(wid + t.z * 3.7)); + float flicker = 0.75 + 0.25 * step(0.5, fract(sin(dot(wid, vec2(7.1, 3.3))) * 91.7 + uTime * 0.07)); + vec3 warm = mix(vec3(1.0, 0.62, 0.3), vec3(0.35, 0.9, 1.0), step(0.72, hash2(wid * 1.31))); + float belowRoof = step(y, top - 0.02); + color += warm * pane * lit * flicker * belowRoof * (isNear ? 1.1 : 0.7) * uLights; + + // Roof edges catch the sun behind them. + float edge = smoothstep(top - 0.018, top, y) * step(y, top); + color += uSun * edge * (isNear ? 0.55 : 1.15) * (0.7 + 0.3 * core); + + // Aircraft warning lights on masts and the tallest roofs. + float blink = step(0.82, fract(uTime * 0.55 + t.z * 0.13)); + float beacon = mast * step(far.x + 0.09 * outskirts, y) * 4.0 * blink; + color = mix(color, vec3(1.0, 0.1, 0.12) * 3.0, beacon); + color = mix(color, base * 1.4, mast * (1.0 - beacon)); + + gl_FragColor = vec4(color, 1.0); + #include + #include + }`, + depthWrite: true, + depthTest: true, + fog: false, + }), + ); + const geometry = resources.own('geometry', new THREE.PlaneGeometry(WIDTH, HEIGHT)); + const plane = new THREE.Mesh(geometry, material); + plane.name = 'skyline-silhouette'; + plane.position.set(0, HEIGHT / 2 - 8, SUN_DISTANCE_Z + 18); + plane.renderOrder = -1; + group.add(plane); + + return { + group, + update(palette: Readonly, time: number) { + uniforms.uTime.value = time; + uniforms.uSun.value.copy(palette.sun); + uniforms.uFog.value.copy(palette.fog); + uniforms.uHaze.value.copy(palette.haze); + uniforms.uWarmth.value = palette.warmth; + // Lit windows thin out towards dawn. + uniforms.uLights.value = 1 - palette.warmth * 0.55; + }, + }; +} diff --git a/src/scene/world/sun.ts b/src/scene/world/sun.ts index 669e387..fe21a09 100644 --- a/src/scene/world/sun.ts +++ b/src/scene/world/sun.ts @@ -80,9 +80,9 @@ export function createSun(resources: SceneResourceBag): SunWorld { float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65); float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055)) * sin(solar.y * 31.0 - uTime * 0.032); - vec3 vermilion = mix(vec3(0.85, 0.018, 0.28), vec3(1.0, 0.24, 0.12), uArrival); - vec3 coral = mix(uColor, vec3(1.0, 0.16, 0.13), 0.48); - vec3 crown = mix(vec3(1.0, 0.63, 0.24), vec3(1.0, 0.88, 0.64), uArrival); + vec3 vermilion = mix(vec3(1.0, 0.03, 0.4), vec3(1.0, 0.24, 0.12), uArrival); + vec3 coral = mix(uColor, vec3(1.0, 0.3, 0.06), 0.6); + vec3 crown = mix(vec3(1.0, 0.8, 0.12), vec3(1.0, 0.9, 0.6), uArrival); vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y)); core = mix(core, crown, smoothstep(0.47, 1.0, solar.y)); float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0); @@ -91,7 +91,8 @@ export function createSun(resources: SceneResourceBag): SunWorld { core += vec3(1.0, 0.24, 0.095) * (limb * 0.22 + lip * 0.20); float extinction = smoothstep(0.025, 0.19, solar.y); if(softEdge * bandMask * extinction < 0.02) discard; - gl_FragColor = vec4(core * (1.0 + uWarmth * 0.10), softEdge * bandMask * extinction); + // Kept below ACES's shoulder so the disc stays saturated instead of cream. + gl_FragColor = vec4(core * (0.74 + uWarmth * 0.08), softEdge * bandMask * extinction); #include #include } diff --git a/src/scene/world/traffic.ts b/src/scene/world/traffic.ts new file mode 100644 index 0000000..0017cd5 --- /dev/null +++ b/src/scene/world/traffic.ts @@ -0,0 +1,171 @@ +import * as THREE from 'three'; +import type { SceneResourceBag } from '@/scene/resources'; +import type { RenderQualitySettings } from '@/scene/render-quality'; + +/** + * Other cars on the expressway, seen only as their lamps: taillight pairs, + * long-exposure trails whose length follows the speed difference, and red + * pools on the wet asphalt. Scrolling faster than the traffic overtakes it; + * resting lets the faster lanes pull away. Instances wrap through a hidden band + * behind the camera, so nothing pops in view. + */ +const NEAR_Z = 46; +const FAR_Z = -660; +const SPAN = NEAR_Z - FAR_Z; + +interface Vehicle { + x: number; + z: number; + speed: number; + width: number; + height: number; + tint: number; +} + +export function createTraffic( + resources: SceneResourceBag, + quality: RenderQualitySettings, +) { + const group = new THREE.Group(); + group.name = 'expressway-traffic'; + const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7; + const lanes = [-5.1, -5.1, -2.6, 4.9, 5.2]; + const vehicles: Vehicle[] = []; + let seed = 7; + const random = () => { + seed = (seed * 16807) % 2147483647; + return (seed - 1) / 2147483646; + }; + for (let i = 0; i < count; i++) { + const lane = lanes[i % lanes.length]!; + // Left lanes run faster than the cruise, the right lane is slower trucks. + const fast = lane < 0; + vehicles.push({ + x: lane + (random() - 0.5) * 0.5, + z: FAR_Z + random() * (SPAN - 120) + 20, + speed: fast ? 26 + random() * 22 : 9 + random() * 6, + width: fast ? 0.78 + random() * 0.12 : 1.0, + height: fast ? 0.95 : 1.55 + random() * 0.4, + tint: random(), + }); + } + + const uniforms = { uTime: { value: 0 } }; + const vertexShader = /* glsl */ ` + attribute float aLevel; + varying vec2 vUv; + varying float vDepth; + varying float vLevel; + void main() { + vUv = uv; + vLevel = aLevel; + vec4 view = modelViewMatrix * instanceMatrix * vec4(position, 1.0); + vDepth = -view.z; + gl_Position = projectionMatrix * view; + }`; + const fragment = (shape: string) => /* glsl */ ` + varying vec2 vUv; + varying float vDepth; + varying float vLevel; + void main() { + float far = 1.0 - smoothstep(420.0, 660.0, vDepth); + float near = smoothstep(1.5, 7.0, vDepth); + ${shape} + vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), vLevel); + gl_FragColor = vec4(color * glow * far * near, 1.0); + #include + #include + }`; + const additive = (shape: string) => + resources.own( + 'material', + new THREE.ShaderMaterial({ + uniforms, + vertexShader, + fragmentShader: fragment(shape), + transparent: true, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }), + ); + // Lamps: a hot core in a soft disc, bright enough to bloom. + const lampMaterial = additive(` + vec2 c = vUv - 0.5; + float r = length(c * vec2(1.0, 1.6)); + float glow = exp(-r * r * 26.0) * 3.2 + exp(-r * r * 5.0) * 0.5;`); + // Trails: thin, brightest at the lamp, fading over their length. + const trailMaterial = additive(` + float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0)); + float glow = across * pow(vUv.y, 1.6) * 1.1;`); + // Wet-road pools: long and soft under each car. + const poolMaterial = additive(` + float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0)); + float glow = across * pow(vUv.y, 2.2) * 0.2;`); + + const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1)); + const lampCount = count * 2; + const mesh = (name: string, material: unknown, instances: number) => { + const instanced = new THREE.InstancedMesh(plane, material, instances); + instanced.name = name; + instanced.frustumCulled = false; + instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + const levels = new Float32Array(instances); + for (let i = 0; i < instances; i++) levels[i] = vehicles[Math.floor(i / 2) % count]!.tint * 0.6; + instanced.geometry = plane.clone(); + resources.own('geometry', instanced.geometry); + instanced.geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1)); + group.add(instanced); + return instanced; + }; + const lamps = mesh('traffic-taillights', lampMaterial, lampCount); + const trails = mesh('traffic-light-trails', trailMaterial, lampCount); + const pools = mesh('traffic-road-pools', poolMaterial, count); + + const dummy = new THREE.Object3D(); + return { + group, + /** + * @param travelSpeed world units per second the camera car is covering. + */ + update(deltaSeconds: number, travelSpeed: number, time: number) { + uniforms.uTime.value = time; + const dt = Math.min(0.1, Math.max(0, deltaSeconds)); + for (let i = 0; i < count; i++) { + const v = vehicles[i]!; + const relative = travelSpeed - v.speed; // > 0: we are overtaking + v.z += relative * dt; + if (v.z > NEAR_Z) v.z -= SPAN; + else if (v.z < FAR_Z) v.z += SPAN; + const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.16, 0.3, 14); + // Trails stream away from the direction of relative travel. + const trailDirection = relative >= 0 ? -1 : 1; + for (let side = 0; side < 2; side++) { + const index = i * 2 + side; + const x = v.x + (side === 0 ? -v.width : v.width); + dummy.position.set(x, v.height, v.z); + dummy.rotation.set(0, 0, 0); + dummy.scale.set(0.62, 0.36, 1); + dummy.updateMatrix(); + lamps.setMatrixAt(index, dummy.matrix); + // Horizontal ribbon at lamp height, uv.y = 1 at the lamp. + dummy.position.set(x, v.height - 0.02, v.z + (trailDirection * trailLength) / 2); + dummy.rotation.set(trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0); + dummy.scale.set(0.2, trailLength, 1); + dummy.updateMatrix(); + trails.setMatrixAt(index, dummy.matrix); + } + const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08); + dummy.position.set(v.x, 0.04, v.z + poolLength / 2); + dummy.rotation.set(-Math.PI / 2, 0, 0); + dummy.scale.set(v.width * 2.3, poolLength, 1); + dummy.updateMatrix(); + pools.setMatrixAt(i, dummy.matrix); + } + lamps.instanceMatrix.needsUpdate = true; + trails.instanceMatrix.needsUpdate = true; + pools.instanceMatrix.needsUpdate = true; + }, + }; +}