feat(scene): add destination skyline, traffic, wet-road light and a saturated sun

This commit is contained in:
2026-10-10 19:50:38 +03:00
parent 2080265568
commit 604a7765ff
4 changed files with 336 additions and 4 deletions

View File

@@ -27,6 +27,8 @@ interface WorldGroup {
const ROAD_WIDTH = 20.4; const ROAD_WIDTH = 20.4;
const FLOW_PERIOD = 5_694; 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( export function createRoad(
resources: SceneResourceBag, resources: SceneResourceBag,
@@ -45,6 +47,7 @@ export function createRoad(
const initialPalette = CHAPTER_PALETTES['deep-night']; const initialPalette = CHAPTER_PALETTES['deep-night'];
const uniforms = { const uniforms = {
uDistance: { value: 0 }, uDistance: { value: 0 },
uPylonPhase: { value: 0 },
uSpeed: { value: 0 }, uSpeed: { value: 0 },
uAcceleration: { value: 0 }, uAcceleration: { value: 0 },
uAsphalt: { value: new THREE.Color(0x090511) }, uAsphalt: { value: new THREE.Color(0x090511) },
@@ -77,6 +80,7 @@ export function createRoad(
`, `,
fragmentShader: ` fragmentShader: `
uniform float uDistance; uniform float uDistance;
uniform float uPylonPhase;
uniform float uSpeed; uniform float uSpeed;
uniform float uAcceleration; uniform float uAcceleration;
uniform vec3 uAsphalt; uniform vec3 uAsphalt;
@@ -173,6 +177,23 @@ export function createRoad(
float sunReflection = exp(-pow(vRoadX / (0.55 + vViewDistance * 0.011), 2.0)) float sunReflection = exp(-pow(vRoadX / (0.55 + vViewDistance * 0.011), 2.0))
* smoothstep(36.0, 180.0, vViewDistance); * smoothstep(36.0, 180.0, vViewDistance);
color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17; 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) float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
* (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance)) * (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance))
@@ -203,6 +224,8 @@ export function createRoad(
update(distance, speed, acceleration, palette) { update(distance, speed, acceleration, palette) {
uniforms.uDistance.value = uniforms.uDistance.value =
((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD; ((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.uSpeed.value = clamp01(speed);
uniforms.uAcceleration.value = clamp01(acceleration); uniforms.uAcceleration.value = clamp01(acceleration);
if (palette) { if (palette) {

137
src/scene/world/skyline.ts Normal file
View File

@@ -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 <tonemapping_fragment>
#include <colorspace_fragment>
}`,
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<MutableChapterPalette>, 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;
},
};
}

View File

@@ -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 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)) float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
* sin(solar.y * 31.0 - uTime * 0.032); * 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 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.16, 0.13), 0.48); vec3 coral = mix(uColor, vec3(1.0, 0.3, 0.06), 0.6);
vec3 crown = mix(vec3(1.0, 0.63, 0.24), vec3(1.0, 0.88, 0.64), uArrival); 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)); vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
core = mix(core, crown, smoothstep(0.47, 1.0, 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); 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); core += vec3(1.0, 0.24, 0.095) * (limb * 0.22 + lip * 0.20);
float extinction = smoothstep(0.025, 0.19, solar.y); float extinction = smoothstep(0.025, 0.19, solar.y);
if(softEdge * bandMask * extinction < 0.02) discard; 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 <tonemapping_fragment> #include <tonemapping_fragment>
#include <colorspace_fragment> #include <colorspace_fragment>
} }

171
src/scene/world/traffic.ts Normal file
View File

@@ -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 <tonemapping_fragment>
#include <colorspace_fragment>
}`;
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;
},
};
}