feat(scene): add destination skyline, traffic, wet-road light and a saturated sun
This commit is contained in:
@@ -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) {
|
||||
|
||||
137
src/scene/world/skyline.ts
Normal file
137
src/scene/world/skyline.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
|
||||
171
src/scene/world/traffic.ts
Normal file
171
src/scene/world/traffic.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user