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