feat: floodlit roadside billboard carries the selected-builds case studies

This commit is contained in:
2026-10-11 07:09:31 +03:00
parent 19f3ce79e6
commit d71b0ecfd4
31 changed files with 1216 additions and 6 deletions

View File

@@ -0,0 +1,618 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import type { JourneySnapshot } from '@/journey/types';
import {
billboardProgress,
floodTargets,
readBillboard,
screenKind,
type BillboardProgress,
type BillboardProject,
} from '@/journey/billboard-progress';
import type { SceneResourceBag } from '@/scene/resources';
import { buildsPresence, stepDistrictRise } from './districts';
import { BILLBOARD_NUDGE } from './composition';
import { BILLBOARD_ACCENT } from './palette';
/**
* DRIVE-IN, scene side. A highway billboard on two stilts stands on the
* right shoulder through Selected builds, rising on the same envelope as the
* chapter's districts and drifting from z -36 to -26 as the chapter is read.
* Its screen carries the case-study media (the gallery's own files). When a
* case study's header crosses the activation line, the left flood switches
* on, then the right (project-local t 0.08), and the screen fades from unlit
* grey to lit over 0.6 s. Scrolling back runs it all backwards.
*
* At 1440 the reading panel is opaque from 5% to 95% of the width, so the
* board shows through the band above the panel as the chapter opens and the
* gaps between case studies, which pass the screen's height exactly when a
* header crosses the line, i.e. when the board relights. Placed right, above
* the gallery column. At 390 it sits right of centre in the gaps.
*
* One mesh, one draw call: steel, accent frame, screen, flood heads and the
* two light cones share a ShaderMaterial with premultiplied blending (opaque
* parts write alpha 1, the cones alpha 0, so they add). The cones come last
* in the index buffer, after the screen they light.
*/
/*
* The world is drawn ~2.3x life size (the road is 20 m wide), so a 9 x 6 m
* roadside board is 22 x 15 m here. It stands 50 m ahead on the right
* shoulder, outside the workshop blocks (x 17), where it fills ~350 px of a
* 1440 x 900 frame between the sun and the right edge. Tall viewports have
* the camera on the centre line and see only 16 m to each side at that
* range, so the board steps in to x 12 at 90% size, 30 m further on.
*/
export const BILLBOARD_X = 24;
export const BILLBOARD_NARROW_X = 12;
export const BILLBOARD_NARROW_SCALE = 0.9;
/** Tall viewports see the board this much further down the road, so it stays inside the frame. */
export const BILLBOARD_NARROW_DZ = -30;
export const BILLBOARD_Z_FROM = -36;
export const BILLBOARD_Z_TO = -26;
/** The face turns this far toward the carriageway (rad). */
const BILLBOARD_YAW = -0.38;
const STILT_W = 1;
export const FRAME_W = 22;
export const FRAME_H = 15;
export const FRAME_TOP = 21;
const STILT_H = FRAME_TOP - FRAME_H + 0.5;
const FRAME_BAR = 0.6;
const SCREEN_W = FRAME_W - 2 * FRAME_BAR;
const SCREEN_H = FRAME_H - 2 * FRAME_BAR;
/** Screen aspect: composed textures are drawn at this ratio, so nothing stretches. */
export const SCREEN_ASPECT = SCREEN_W / SCREEN_H;
const FLOOD_X = 6.4;
const HEAD = { y: FRAME_TOP + 1.1, z: 3.6 };
const CONE_AIM = { y: FRAME_TOP - FRAME_H * 0.78, z: 0.25, x: 5.2 };
/** The warm pool the board throws on the shoulder (x across, z along the road). */
const POOL = { w: FRAME_W * 1.5, l: FRAME_W * 0.8, z: 4, y: 1.4 };
/** Seconds. */
export const BILLBOARD_TIMING = {
lit: 0.6,
/** A new project: the old one drops to grey this fast before the swap. */
swap: 0.15,
flood: 0.08,
zoom: 0.35,
flicker: 0.12,
/** Flicker at most this often (2 Hz cap, photosensitivity). */
flickerGap: 0.5,
shimmer: 0.8,
shimmerGap: 2,
} as const;
/** Flood intensity = base + PULSE_GAIN * smoothed bass pulse (full tier only). */
export const PULSE_GAIN = 0.35;
export const FLICKER_THRESHOLD = 0.62;
export const SHIMMER_ENERGY = 0.7;
/** The floods stay off until the board has risen this far (the districts' envelope takes ~2.6 s). */
export const STANDING = 0.9;
/** Texture edge: high tier 1024 px, medium/low 512. */
export const textureSize = (tier: 'high' | 'medium' | 'low'): number => (tier === 'high' ? 1024 : 512);
const clamp01 = (value: number): number => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0);
const toward = (value: number, target: number, step: number): number =>
value < target ? Math.min(target, value + step) : Math.max(target, value - step);
const ease = (t: number): number => t * t * (3 - 2 * t);
/** Where the board stands along the road: it drifts toward the camera as the chapter is read. */
export const billboardZ = (p: number): number =>
THREE.MathUtils.lerp(BILLBOARD_Z_FROM, BILLBOARD_Z_TO, clamp01(p));
/** Start building textures once the chapter is within one viewport of the viewport. */
export function billboardNear(snapshot: Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>): boolean {
const id = snapshot.activeSectionId;
if (id === 'selected-builds' || id === 'additional-work') return true;
const next = snapshot.sectionActivation.nextHeadingOffsetVh;
return id === 'infrastructure' && next !== null && next < 1.4;
}
/** A rectangle inside the composed screen texture, in UV (origin bottom-left). */
export interface ShotRect { u: number; v: number; w: number; h: number }
export interface ComposedScreen {
canvas: HTMLCanvasElement;
/** Shot id -> where it was drawn; empty for the plate. */
rects: Map<string, ShotRect>;
kind: 'triptych' | 'pair' | 'plate';
}
export interface BillboardIO {
/** Resolve with a drawable image, reject on any failure. */
loadImage(url: string): Promise<CanvasImageSource & { width: number; height: number }>;
/** A 2D canvas, or null when there is none (the screen then stays grey). */
canvas(width: number, height: number): { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D } | null;
}
const browserIO: BillboardIO = {
async loadImage(url) {
const image = new Image();
image.decoding = 'async';
image.src = url;
await image.decode();
return image;
},
canvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
return context === null ? null : { canvas, context };
},
};
const INK = '#0d0915';
/** Charts are drawn at this opacity over the ink so their white stays below the bloom threshold when lit. */
export const CHART_GAIN = 0.68;
/** The typographic plate: project title and its route plate. No imagery. */
export function drawPlate(target: { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D }, project: BillboardProject): ComposedScreen {
const { canvas, context: c } = target;
const { width: w, height: h } = canvas;
const unit = w / 1024;
c.fillStyle = INK;
c.fillRect(0, 0, w, h);
c.fillStyle = BILLBOARD_ACCENT;
c.fillRect(72 * unit, 96 * unit, 120 * unit, 8 * unit);
c.textBaseline = 'alphabetic';
c.font = `700 ${Math.round(44 * unit)}px "Geist Mono Variable", ui-monospace, monospace`;
c.fillText(project.plate, 72 * unit, 180 * unit, w - 144 * unit);
c.fillStyle = '#f6efe9';
let size = 132;
c.font = `650 ${Math.round(size * unit)}px "Geist Variable", sans-serif`;
while (size > 64 && c.measureText(project.title).width > w - 144 * unit) {
size -= 8;
c.font = `650 ${Math.round(size * unit)}px "Geist Variable", sans-serif`;
}
c.fillText(project.title, 72 * unit, h * 0.62, w - 144 * unit);
return { canvas, rects: new Map(), kind: 'plate' };
}
/** Shots laid out side by side at their own aspect (contain), centred. */
export function drawShots(
target: { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D },
project: BillboardProject,
images: ReadonlyArray<{ width: number; height: number } & CanvasImageSource>,
): ComposedScreen {
const { canvas, context: c } = target;
const { width: w, height: h } = canvas;
c.fillStyle = INK;
c.fillRect(0, 0, w, h);
const pad = Math.round(w * 0.03);
const count = images.length;
const cellW = (w - pad * (count + 1)) / count;
const cellH = h - pad * 2;
const rects = new Map<string, ShotRect>();
images.forEach((image, i) => {
const scale = Math.min(cellW / image.width, cellH / image.height);
const dw = image.width * scale;
const dh = image.height * scale;
const x = pad + i * (cellW + pad) + (cellW - dw) / 2;
const y = (h - dh) / 2;
// White charts sit under the bloom threshold once lit; dark phone UIs need no help.
c.globalAlpha = project.shots[i]!.kind === 'chart' ? CHART_GAIN : 1;
c.drawImage(image, x, y, dw, dh);
c.globalAlpha = 1;
rects.set(project.shots[i]!.id, { u: x / w, v: 1 - (y + dh) / h, w: dw / w, h: dh / h });
});
return { canvas, rects, kind: screenKind(project) };
}
/** Composes one project's screen; any failed shot falls back to the plate. Never rejects. */
export async function composeScreen(project: BillboardProject, size: number, io: BillboardIO): Promise<ComposedScreen | null> {
let target: ReturnType<BillboardIO['canvas']>;
try {
target = io.canvas(size, Math.round(size / SCREEN_ASPECT));
} catch {
return null;
}
if (target === null) return null;
if (screenKind(project) !== 'plate') {
try {
const images = await Promise.all(project.shots.map((shot) => io.loadImage(shot.url)));
return drawShots(target, project, images);
} catch {
/* fall through to the plate */
}
}
try {
return drawPlate(target, project);
} catch {
return null;
}
}
/** Zoom-to-fit for one shot: the screen-UV scale that letterboxes it at its own aspect. */
export function zoomFit(rect: ShotRect): [number, number] {
const shot = (rect.w * SCREEN_ASPECT) / rect.h;
return shot < SCREEN_ASPECT ? [SCREEN_ASPECT / shot, 1] : [1, shot / SCREEN_ASPECT];
}
// ---------------------------------------------------------------- geometry
/** Three is untyped here (src/scene/three.d.ts); this is the slice of a geometry the merge reads. */
interface Geometry {
getAttribute(name: string): { array: ArrayLike<number>; count: number };
getIndex(): { array: ArrayLike<number> };
dispose(): void;
}
type Piece = { geometry: Geometry; part: number };
function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number): Piece {
return { geometry: new THREE.BoxGeometry(width, height, depth).translate(x, y, z), part };
}
function cone(side: -1 | 1, part: number): Piece {
const head = new THREE.Vector3(side * FLOOD_X, HEAD.y - 0.15, HEAD.z);
const aim = new THREE.Vector3(side * CONE_AIM.x, CONE_AIM.y, CONE_AIM.z);
const axis = aim.clone().sub(head);
const length = axis.length();
axis.normalize();
const geometry = new THREE.CylinderGeometry(0.35, 6, length, 18, 1, true).translate(0, -length / 2, 0);
geometry.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, -1, 0), axis));
geometry.translate(head.x, head.y, head.z);
// uv.y = 0 at the lamp, 1 where the cone meets the screen.
const position = geometry.getAttribute('position');
const uv = geometry.getAttribute('uv');
const point = new THREE.Vector3();
for (let i = 0; i < position.count; i += 1) {
point.fromBufferAttribute(position, i).sub(head);
uv.setY(i, clamp01(point.dot(axis) / length));
}
return { geometry, part };
}
function boardGeometry(): { dispose(): void } {
const mid = FRAME_TOP - FRAME_H / 2;
const pieces: Piece[] = [
box(STILT_W, STILT_H, STILT_W, -7, STILT_H / 2, -0.9, 0),
box(STILT_W, STILT_H, STILT_W, 7, STILT_H / 2, -0.9, 0),
box(FRAME_W, FRAME_H, 0.7, 0, mid, -0.45, 0),
// The catwalk arm the floods hang from.
box(FLOOD_X * 2 + 3, 0.4, 0.4, 0, HEAD.y, HEAD.z - 0.35, 0),
box(0.35, 0.35, HEAD.z, -FLOOD_X, HEAD.y - 0.25, HEAD.z / 2 - 0.5, 0),
box(0.35, 0.35, HEAD.z, FLOOD_X, HEAD.y - 0.25, HEAD.z / 2 - 0.5, 0),
// The accent frame.
box(FRAME_W, FRAME_BAR, 1, 0, FRAME_TOP - FRAME_BAR / 2, 0, 1),
box(FRAME_W, FRAME_BAR, 1, 0, FRAME_TOP - FRAME_H + FRAME_BAR / 2, 0, 1),
box(FRAME_BAR, FRAME_H, 1, -FRAME_W / 2 + FRAME_BAR / 2, mid, 0, 1),
box(FRAME_BAR, FRAME_H, 1, FRAME_W / 2 - FRAME_BAR / 2, mid, 0, 1),
{ geometry: new THREE.PlaneGeometry(SCREEN_W, SCREEN_H).translate(0, mid, 0.12), part: 2 },
box(1.7, 1, 1.1, -FLOOD_X, HEAD.y, HEAD.z, 3),
box(1.7, 1, 1.1, FLOOD_X, HEAD.y, HEAD.z, 4),
// Last: the light adds over what it lights. The pool on the shoulder, then the cones.
{ geometry: new THREE.PlaneGeometry(POOL.w, POOL.l).rotateX(-Math.PI / 2).translate(0, POOL.y, POOL.z), part: 7 },
cone(-1, 5),
cone(1, 6),
];
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const parts: number[] = [];
const indices: number[] = [];
for (const { geometry, part } of pieces) {
const offset = positions.length / 3;
const position = geometry.getAttribute('position');
positions.push(...Array.from(position.array));
normals.push(...Array.from(geometry.getAttribute('normal').array));
uvs.push(...Array.from(geometry.getAttribute('uv').array));
for (let i = 0; i < position.count; i += 1) parts.push(part);
indices.push(...Array.from(geometry.getIndex().array, (i) => i + offset));
geometry.dispose();
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
merged.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices);
return merged;
}
// ---------------------------------------------------------------- world
export interface BillboardFrame {
section: SectionId;
/** Selected builds chapter progress, null outside the chapter. */
p: number | null;
direction: -1 | 0 | 1;
near: boolean;
/** Smoothed bass pulse 0..1 (0 with audio off). */
pulse: number;
/** Drive energy 0..1. */
energy: number;
/** html[data-billboard-focus]: the hovered gallery shot. */
focus: string | undefined;
/** Tall viewport (camera on the centre line): the board steps in and shrinks. */
narrow?: boolean;
deltaSeconds: number;
}
export interface BillboardState {
rise: number;
z: number;
progress: BillboardProgress;
/** Project whose texture is on the screen. */
shown: string | null;
lit: number;
floods: [number, number];
zoom: number;
nudge: number;
flickers: number;
shimmers: number;
textures: number;
}
export interface BillboardWorld {
readonly mesh: { visible: boolean; name: string };
/** Returns the camera nudge, 0..1 (eased). Never throws. */
update(frame: BillboardFrame): number;
/** Test and instrumentation hook. */
inspect(): BillboardState;
}
export interface BillboardOptions {
/** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): one static flood. */
mode: 'full' | 'lite';
textureSize: number;
io?: BillboardIO;
}
export function createBillboard(resources: SceneResourceBag, options: BillboardOptions): BillboardWorld {
const full = options.mode === 'full';
const io = options.io ?? browserIO;
const geometry = resources.own('geometry', boardGeometry());
const uniforms = {
uRise: { value: 0 },
uScreen: { value: null as unknown },
uLit: { value: 0 },
uFlood: { value: new THREE.Vector2() },
uHeads: { value: 1 },
uAccent: { value: new THREE.Color(BILLBOARD_ACCENT) },
uZoom: { value: 0 },
uZoomRect: { value: new THREE.Vector4(0, 0, 1, 1) },
uZoomFit: { value: new THREE.Vector2(1, 1) },
uShimmer: { value: 0 },
};
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
attribute float aPart;
uniform float uRise;
varying float vPart;
varying vec2 vUv;
varying vec3 vWorld;
varying vec3 vNormal;
void main() {
float rise = uRise * uRise * (3.0 - 2.0 * uRise);
vec3 p = vec3(position.x, position.y * rise - (1.0 - rise) * 0.4, position.z);
vec4 world = modelMatrix * vec4(p, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(modelMatrix) * normal);
vPart = aPart;
vUv = uv;
gl_Position = projectionMatrix * viewMatrix * world;
}
`,
fragmentShader: /* glsl */ `
uniform float uRise;
uniform sampler2D uScreen;
uniform float uLit;
uniform vec2 uFlood;
uniform float uHeads;
uniform vec3 uAccent;
uniform float uZoom;
uniform vec4 uZoomRect;
uniform vec2 uZoomFit;
uniform float uShimmer;
varying float vPart;
varying vec2 vUv;
varying vec3 vWorld;
varying vec3 vNormal;
void main() {
// Screen-door rise, like the districts: never a translucent sheet.
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 (smoothstep(0.0, 0.25, uRise) < threshold) discard;
vec3 n = normalize(vNormal);
vec3 toCamera = normalize(cameraPosition - vWorld);
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
float flood = 0.5 * (uFlood.x + uFlood.y);
vec3 warm = vec3(1.0, 0.88, 0.7);
vec3 color;
float alpha = 1.0;
if (vPart < 0.5) {
color = vec3(0.03, 0.026, 0.045) + vec3(0.08, 0.07, 0.11) * key + warm * 0.05 * flood * max(0.0, n.y);
color += uAccent * 0.05 * pow(1.0 - abs(dot(n, toCamera)), 3.0);
} else if (vPart < 1.5) {
color = uAccent * (0.55 + 0.9 * flood);
// Drive-energy shimmer: one bright band runs round the frame.
float band = 1.0 - smoothstep(0.0, 0.08, abs(fract((vWorld.x + vWorld.y) * 0.05) - (1.0 - uShimmer)));
color += vec3(1.0, 0.95, 0.9) * band * uShimmer * 1.6;
} else if (vPart < 2.5) {
vec4 frame = texture2D(uScreen, vUv);
vec2 q = (vUv - 0.5) * uZoomFit + 0.5;
float inside = step(0.0, q.x) * step(q.x, 1.0) * step(0.0, q.y) * step(q.y, 1.0);
vec4 shot = texture2D(uScreen, uZoomRect.xy + clamp(q, 0.0, 1.0) * uZoomRect.zw) * inside;
vec3 image = mix(frame.rgb, shot.rgb, uZoom);
// Top-down floods: the left lamp lights the left of the face, the right the right.
float lamps = uFlood.x * smoothstep(0.95, 0.15, vUv.x) + uFlood.y * smoothstep(0.05, 0.85, vUv.x);
float fall = mix(0.7, 1.0, vUv.y);
// Full flood ~1.0, a bass hit ~1.25; charts are drawn at CHART_GAIN so their white stays below blow-out.
vec3 lit = image * (0.25 + 0.8 * min(1.35, lamps) * fall);
vec3 grey = vec3(0.13, 0.13, 0.15) * (0.7 + 0.3 * vUv.y) + warm * 0.08 * lamps * fall;
color = mix(grey, lit, uLit);
} else if (vPart < 4.5) {
float on = vPart < 3.5 ? uFlood.x : uFlood.y;
color = mix(vec3(0.08, 0.07, 0.09), warm * 3.2, on * uHeads);
} else if (vPart > 6.5) {
// The pool on the shoulder: the lamps' spill and the lit screen's glow.
vec2 d = (vUv - 0.5) * vec2(2.0, 2.2);
float pool = pow(max(0.0, 1.0 - length(d)), 1.6);
color = mix(warm, uAccent, 0.25) * pool * (1.3 * flood * uHeads + 0.7 * uLit);
alpha = 0.0;
} else {
// The cones add: bright at the lamp, soft at the edges, gone at the face.
float on = (vPart < 5.5 ? uFlood.x : uFlood.y) * uHeads;
float facing = pow(abs(dot(n, toCamera)), 1.6);
float along = (1.0 - vUv.y * 0.75) * smoothstep(1.0, 0.85, vUv.y);
color = warm * 0.32 * on * facing * along;
alpha = 0.0;
}
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: true,
blending: THREE.CustomBlending,
blendSrc: THREE.OneFactor,
blendDst: THREE.OneMinusSrcAlphaFactor,
fog: false,
}));
const mesh = new THREE.Mesh(geometry, material);
mesh.name = 'drive-in-billboard';
mesh.frustumCulled = false;
mesh.rotation.y = BILLBOARD_YAW;
mesh.position.set(BILLBOARD_X, 0, BILLBOARD_Z_FROM);
mesh.visible = false;
const screens = new Map<string, { texture: unknown; rects: Map<string, ShotRect> }>();
let loading = false;
let rise = 0;
let lastP = 0;
let shown: string | null = null;
let lit = 0;
const floods: [number, number] = [0, 0];
let zoom = 0;
let nudgeRun = 0;
let pulseWasHigh = false;
let sinceFlicker = Number.POSITIVE_INFINITY;
let flicker = 0;
let flickers = 0;
let energyWasHigh = false;
let sinceShimmer = Number.POSITIVE_INFINITY;
let shimmer = 0;
let shimmers = 0;
let warmFrames = 2;
let progress: BillboardProgress = { activeProjectId: null, index: -1, t: 0, direction: 0 };
const load = (projects: readonly BillboardProject[]): void => {
loading = true;
for (const project of projects) {
void composeScreen(project, options.textureSize, io).then((screen) => {
if (screen === null) return;
const texture = resources.own('texture', new THREE.CanvasTexture(screen.canvas));
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
screens.set(project.id, { texture, rects: screen.rects });
}, () => undefined);
}
};
const step = (frame: BillboardFrame): number => {
const dt = Math.max(0, frame.deltaSeconds);
const feed = readBillboard();
if (!loading && frame.near && feed.projects.length > 0) load(feed.projects);
// Up with the workshop, and already standing as the chapter heading
// approaches so the entry band shows it whole; down after the chapter.
const presence = frame.section === 'infrastructure' && frame.near ? 1 : buildsPresence(frame.section);
rise = stepDistrictRise(rise, presence, dt);
if (frame.p !== null) lastP = frame.p;
else if (frame.section !== 'selected-builds') lastP = frame.section === 'additional-work' || frame.section === 'toolkit' || frame.section === 'contact' ? 1 : 0;
const narrow = frame.narrow === true;
mesh.position.z = billboardZ(lastP) + (narrow ? BILLBOARD_NARROW_DZ : 0);
mesh.position.x = narrow ? BILLBOARD_NARROW_X : BILLBOARD_X;
mesh.scale.setScalar(narrow ? BILLBOARD_NARROW_SCALE : 1);
const standing = rise > STANDING;
// On the approach the first case study already has the board (its stop is 0).
const p = frame.p ?? (presence === 1 && frame.section === 'infrastructure' ? 0 : null);
progress = billboardProgress(standing ? p : null, feed.stops, feed.projects.map((project) => project.id), frame.direction);
// Floods: off while rising; lite keeps one steady lamp.
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, 0];
floods[0] = toward(floods[0], left, dt / BILLBOARD_TIMING.flood);
floods[1] = toward(floods[1], right, dt / BILLBOARD_TIMING.flood);
// Screen: drop the old project to grey, swap, light the new one over 0.6 s.
const wanted = progress.activeProjectId;
if (wanted !== shown && wanted !== null) {
lit = toward(lit, 0, dt / BILLBOARD_TIMING.swap);
if (lit === 0 && screens.has(wanted)) shown = wanted;
} else {
lit = toward(lit, wanted !== null && screens.has(wanted) ? 1 : 0, dt / BILLBOARD_TIMING.lit);
}
const screen = shown === null ? undefined : screens.get(shown);
uniforms.uScreen.value = screen?.texture ?? null;
// Hover zoom (fine pointer, full tier): crossfade to the hovered shot.
const rect = full && frame.focus !== undefined ? screen?.rects.get(frame.focus) : undefined;
if (rect !== undefined) {
uniforms.uZoomRect.value.set(rect.u, rect.v, rect.w, rect.h);
uniforms.uZoomFit.value.set(...zoomFit(rect));
}
zoom = toward(zoom, rect !== undefined && lit > 0 ? 1 : 0, dt / BILLBOARD_TIMING.zoom);
nudgeRun = toward(nudgeRun, wanted !== null ? 1 : 0, dt / BILLBOARD_NUDGE.seconds);
// Sound reward: the floods breathe with the bass and flicker once on a hit.
const pulse = full ? clamp01(frame.pulse) : 0;
sinceFlicker += dt;
const high = pulse > FLICKER_THRESHOLD;
if (high && !pulseWasHigh && sinceFlicker >= BILLBOARD_TIMING.flickerGap && floods[0] + floods[1] > 0) {
flicker = 1;
sinceFlicker = 0;
flickers += 1;
}
pulseWasHigh = high;
flicker = Math.max(0, flicker - dt / BILLBOARD_TIMING.flicker);
sinceShimmer += dt;
const charged = full && frame.energy >= SHIMMER_ENERGY;
if (charged && !energyWasHigh && sinceShimmer >= BILLBOARD_TIMING.shimmerGap && standing) {
shimmer = 1;
sinceShimmer = 0;
shimmers += 1;
}
energyWasHigh = charged;
shimmer = Math.max(0, shimmer - dt / BILLBOARD_TIMING.shimmer);
const gain = 1 + PULSE_GAIN * pulse;
uniforms.uRise.value = rise;
uniforms.uLit.value = ease(lit);
uniforms.uFlood.value.set(floods[0] * gain, floods[1] * gain);
uniforms.uHeads.value = 1 - 0.75 * flicker;
uniforms.uZoom.value = ease(zoom);
uniforms.uShimmer.value = shimmer;
const warming = warmFrames > 0;
if (warming) warmFrames -= 1;
mesh.visible = warming || rise > 0.002;
return ease(nudgeRun);
};
return {
mesh,
update(frame) {
try {
return step(frame);
} catch {
mesh.visible = false;
return 0;
}
},
inspect: () => ({
rise,
z: mesh.position.z,
progress,
shown,
lit,
floods: [floods[0], floods[1]],
zoom,
nudge: ease(nudgeRun),
flickers,
shimmers,
textures: screens.size,
}),
};
}

View File

@@ -87,3 +87,10 @@ export const CHAPTER_COMPOSITIONS: Readonly<
shift: 0.24,
},
};
/**
* DRIVE-IN: while a case study holds the billboard the lens leans toward it.
* Added on top of the chapter's composition, eased in over 0.9 s and back
* out when the board goes dark, so it is fully reversible.
*/
export const BILLBOARD_NUDGE = { targetZ: -6, shift: 0.03, seconds: 0.9 } as const;

View File

@@ -51,6 +51,19 @@ export const DISTRICT_PRESENCE: Readonly<
arch: { contact: 1 },
};
/**
* The rise envelope every district piece follows toward its chapter
* presence: up at 0.9/s, down at 1.4/s, snapping within 0.002. The DRIVE-IN
* billboard rises on the same curve as the Selected builds districts.
*/
export function stepDistrictRise(rise: number, target: number, dt: number): number {
const next = rise + (target - rise) * (1 - Math.exp(-dt * (target > rise ? 0.9 : 1.4)));
return Math.abs(target - next) < 0.002 ? target : next;
}
/** How strongly a chapter raises the Selected builds district (the workshop). */
export const buildsPresence = (section: SectionId): number => DISTRICT_PRESENCE.workshop[section] ?? 0;
const span = (count: number, from: number, to: number) =>
Array.from({ length: count }, (_, i) => from + ((to - from) * i) / count);
@@ -372,10 +385,7 @@ export function createDistricts(
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;
entry.rise.value = stepDistrictRise(entry.rise.value, DISTRICT_PRESENCE[piece][section] ?? 0, dt);
const visible = warming || entry.rise.value > 0.002;
for (const mesh of entry.meshes) mesh.visible = visible;
}

View File

@@ -37,6 +37,8 @@ import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { createMilestones } from '@/scene/world/milestones';
import { billboardNear, createBillboard, textureSize } from '@/scene/world/billboard';
import { BILLBOARD_NUDGE } from '@/scene/world/composition';
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
@@ -174,7 +176,15 @@ export async function createProceduralSceneGeneration(
// MILE MARKERS: kilometre posts roll past through Experience and Profile.
// Low tier and touch draw them without beam, pulse or flash.
const milestones = createMilestones(context.resources, maintenanceTier === 'full');
// DRIVE-IN: the Selected builds billboard. Low tier and touch: one steady
// flood, no pulse, flicker or hover zoom, 512 px screens.
const billboard = createBillboard(context.resources, {
mode: maintenanceTier,
textureSize: textureSize(context.quality),
});
let billboardKey = '';
scene.add(
billboard.mesh,
maintenance.group,
milestones.mesh,
milestones.pool,
@@ -366,6 +376,19 @@ export async function createProceduralSceneGeneration(
musicPulse,
deltaSeconds,
);
const billboardNudge = billboard.update({
section: snapshot.activeSectionId,
p: snapshot.activeSectionId === 'selected-builds'
? sectionLocalProgress(snapshot, 'selected-builds')
: null,
direction: snapshot.direction,
near: billboardNear(snapshot),
pulse: musicPulse,
energy: overdrive,
focus: document.documentElement.dataset.billboardFocus,
narrow: camera.aspect <= 1.18,
deltaSeconds,
});
// END OF SHIFT: parked at the destination, the night ends. One scalar
// drives it all; leaving rewinds it as fast as the outro returns.
const dawn = easeInOutCubic(outroProgress);
@@ -511,7 +534,7 @@ export async function createProceduralSceneGeneration(
baseComposition.targetZ,
upcoming.targetZ,
anticipation,
),
) + BILLBOARD_NUDGE.targetZ * billboardNudge,
fov: THREE.MathUtils.lerp(
baseComposition.fov,
upcoming.fov,
@@ -521,7 +544,7 @@ export async function createProceduralSceneGeneration(
baseComposition.shift,
upcoming.shift,
anticipation,
),
) + BILLBOARD_NUDGE.shift * billboardNudge,
};
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0;
@@ -682,6 +705,16 @@ export async function createProceduralSceneGeneration(
dawnState = nextDawnState;
document.documentElement.dataset.dawn = nextDawnState;
}
if (TEST_BUILD) {
const board = billboard.inspect();
const key = `${board.progress.activeProjectId}|${board.shown}|${board.floods.map(Math.round).join()}|${Math.round(board.lit * 10)}|${Math.round(board.rise * 10)}|${Math.round(board.zoom * 10)}|${board.flickers}|${board.shimmers}|${board.textures}`;
if (key !== billboardKey) {
billboardKey = key;
window.dispatchEvent(new CustomEvent('portfolio-billboard-frame', {
detail: { now, activeSectionId: snapshot.activeSectionId, direction: snapshot.direction, ...board },
}));
}
}
if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
export interface ChapterPaletteDefinition {
sky: number;
@@ -170,6 +171,9 @@ export function engineOffStarScale(engineOff: number, dawn: number): number {
* (about 55%) while the maintenance window is open. A tint, not a chapter.
*/
export const STORM_TINT = 0x060c26;
/** DRIVE-IN: the billboard frame wears the Selected builds chapter accent. */
export const BILLBOARD_ACCENT: string = SIGN_ACCENTS['selected-builds'];
/** Star visibility while the storm is in. */
export const STORM_STAR_SCALE = 0.4;