Files
Portfolio/src/scene/world/districts.ts

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;
},
};
}