391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
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<DistrictPiece, Partial<Record<SectionId, number>>>
|
|
> = {
|
|
// 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<DistrictPiece> = 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 <tonemapping_fragment>
|
|
#include <colorspace_fragment>
|
|
}
|
|
`,
|
|
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 <tonemapping_fragment>
|
|
#include <colorspace_fragment>
|
|
}
|
|
`,
|
|
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;
|
|
},
|
|
};
|
|
}
|