feat: scroll-driven maintenance window set-piece in the infrastructure chapter

This commit is contained in:
2026-10-11 04:25:28 +03:00
parent bd9bd692b9
commit 52b4a1ad14
45 changed files with 1063 additions and 10 deletions

View File

@@ -105,9 +105,11 @@ const VERTEX = `
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;
@@ -128,6 +130,32 @@ const FADES = `
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(
@@ -139,6 +167,8 @@ export interface DistrictsWorld {
): 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(
@@ -154,6 +184,11 @@ export function createDistricts(
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,
@@ -162,11 +197,11 @@ export function createDistricts(
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 }) =>
const bodyMaterial = (rise: { value: number }, serviced: boolean, height: { value: number }) =>
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, uRise: rise },
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -174,6 +209,7 @@ export function createDistricts(
varying float vDepth;
varying float vRise;
varying float vHeight;
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
// Opaque dark steel, like the sign truss: fades dissolve through a
@@ -191,7 +227,12 @@ export function createDistricts(
// 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);
color += uAccent * (rim * 0.22 + crown * 0.05);
${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);`}
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
@@ -202,11 +243,11 @@ export function createDistricts(
side: THREE.DoubleSide,
}),
);
const lightMaterial = (rise: { value: number }, accentMix: number) =>
const lightMaterial = (rise: { value: number }, accentMix: number, serviced: boolean, height: { value: number }) =>
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, uRise: rise },
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -215,6 +256,8 @@ export function createDistricts(
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
@@ -226,6 +269,11 @@ export function createDistricts(
// 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>
@@ -247,9 +295,16 @@ export function createDistricts(
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)],
[light, lightMaterial(entry.rise, accentMix)],
[body, bodyMaterial(entry.rise, serviced, height)],
[light, lightMaterial(entry.rise, accentMix, serviced, height)],
] as const) {
resources.own('geometry', geometry);
const mesh = resources.own(
@@ -318,5 +373,9 @@ export function createDistricts(
},
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;
},
};
}