Files
Portfolio/src/scene/world/yard.ts
LucaP 75108562a4 polish: time the scene payoffs to the gaps between cards
One shared window (journey/card-gaps.ts, CARD_GAP) describes the open road
after each chapter's panel in section-local progress; the district relight
and cutover, the last yard lift and the relay station's beams now key off
it instead of their own numbers, and the chapter margin widens from 44vh
to 48vh so the gap reads as a stretch of road. Unit tests prove every
payoff's peak sits inside the window for every chapter span.
2026-10-12 19:20:43 +03:00

502 lines
21 KiB
TypeScript

import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { CARD_GAP } from '@/journey/card-gaps';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import {
CRANE_FROM_Z,
CRANE_TO_Z,
craneZ,
createLamp,
floodLight,
floodWarmup,
plateLit,
spreaderDrop,
stepLamp,
STACK_TIERS,
STACK_ZS,
} from '@/journey/yard-progress';
import type { SceneResourceBag } from '@/scene/resources';
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
type ThreeGroup = InstanceType<typeof THREE.Group>;
type ThreeCamera = InstanceType<typeof THREE.Camera>;
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
/**
* NIGHT FREIGHT, scene side. A container yard on the free left verge of the
* Additional work chapter (the reading panel covers 15-95% of the width): four
* stacks (three, three, two and one container, far to near) on a raised apron
* along a pair of crane rails, two high-mast floods, and a rail-mounted gantry
* crane that rides the chapter. The rails are a straight siding that runs from
* the camera's pivot into the distance at the angle that keeps the whole yard
* at the same screen x for the viewport aspect (RAIL_NDC: the middle of the
* left margin), so every stop, plate and the crane stay inside the margin at
* any aspect, and the stacks step up with distance so none hides the next.
* With the live camera passed in, the rails are aimed from the camera's
* current lens every frame (position, fov and view offset mid-ease), so the
* yard holds its screen x while the lens settles into the chapter instead of
* sliding in with the world. The containers (and the spreader) stay square to
* the road whatever the rail yaw, so a stack never reads as leaning. Placements are pure
* functions of section-local progress (journey/yard-progress.ts). At each stop
* the spreader lowers onto the stack, the top container's end plate lights in
* the chapter accent and the two work lamps strike. The floods warm up from
* sodium orange to blue-white HID over 1.5 s at chapter entry and cool on the
* way out. Structure stays under the 0.85 bloom threshold (round 9); only the
* lamps bloom, modestly. The crane fades out (screen door) while an underpass
* deck is within reach, so it never crosses a deck or its piers.
* Lite (low tier, touch): crane, stacks and plates; no floods, pools, lamps or pulse.
*
* The world is drawn ~2.3x life size: a 20 ft box is 14 x 5.6 x 6 m here.
*/
const LENS = CHAPTER_COMPOSITIONS['additional-work'];
export const DESIGN_ASPECT = 1.6;
/** Screen x of the rail centre line (NDC): the middle of the 15% left margin, clear of its edge at -0.7. */
const RAIL_NDC = -0.86;
/** The yard floor (apron top): a quay above the verge, over most of the terrain swell. */
const YARD_Y = 5;
const BOX_L = 14;
const BOX_W = 5.6;
const BOX_H = 6;
/** Portal: legs at +- SPAN/2 around the rail line, girders at PORTAL_H, legs +- LEG_Z along the rails. */
/* 14 m: a road-square 14 m box seen across a ~30 degree rail yaw is 11.9 m wide between the legs. */
const SPAN = 14;
const PORTAL_H = 29;
const LEG_Z = 5.5;
const SPREADER_UP = PORTAL_H - 3;
/** Clear of the girder ends (SPAN + 3) / 2 as the crane passes the masts. */
const FLOOD_X = -10.5;
const FLOOD_ZS = [-124, -88] as const;
const FLOOD_H = 36;
const APRON = { halfWidth: SPAN / 2 + 5, depth: 12, from: CRANE_FROM_Z - 10, to: CRANE_TO_Z + 12 } as const;
/**
* The rail line's yaw for a viewport aspect: the angle whose projection lands
* at RAIL_NDC once the chapter's lens shift (setViewOffset) is applied.
*/
export function railYaw(aspect: number = DESIGN_ASPECT): number {
const tanHalfHeight = Math.tan(THREE.MathUtils.degToRad(LENS.fov / 2));
const portrait = aspect < 1.18;
const raw = portrait ? -0.9 : RAIL_NDC - 2 * LENS.shift;
return Math.atan(-raw * tanHalfHeight * aspect);
}
/** Screen x (NDC, after the lens shift) the rails aim at on a portrait viewport (no lens shift there). */
const PORTRAIT_NDC = -0.9;
/** The crane hides (screen door) while a deck is this close along the road (m). */
const DECK_CLEAR = { from: 16, to: 34 } as const;
const PULSE_GAIN = 0.08;
const ORANGE = new THREE.Color(1.0, 0.42, 0.12);
const HID = new THREE.Color(0.72, 0.84, 1.0);
const TINTS = [[0.42, 0.13, 0.08], [0.08, 0.26, 0.3], [0.3, 0.31, 0.34], [0.44, 0.3, 0.08]] as const;
type Box = readonly [w: number, h: number, d: number, x: number, y: number, z: number, part?: number];
/** Boxes merged into one indexed geometry, with a part id per vertex. */
function boxes(list: readonly Box[]): ThreeBufferGeometry {
const positions: number[] = [];
const normals: number[] = [];
const parts: number[] = [];
const indices: number[] = [];
for (const [w, h, d, x, y, z, part = 0] of list) {
const piece = new THREE.BoxGeometry(w, h, d).translate(x, y, z);
const offset = positions.length / 3;
const position = piece.getAttribute('position');
positions.push(...position.array);
normals.push(...piece.getAttribute('normal').array);
for (let i = 0; i < position.count; i += 1) parts.push(part);
indices.push(...Array.from(piece.getIndex()!.array as ArrayLike<number>, (i) => i + offset));
piece.dispose();
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices);
return merged;
}
const legs = (y: number, h: number, t: number): Box[] =>
[-1, 1].flatMap((sx) => [-1, 1].map((sz) => [t, h, t, (sx * SPAN) / 2, y, sz * LEG_Z] as Box));
/** Portal frame around the rail line (x 0): legs, sills, girders, end ties, cab. */
const portalBoxes = (): Box[] => [
...legs(PORTAL_H / 2, PORTAL_H, 1.3),
...[-1, 1].map((sx) => [1.6, 1.4, LEG_Z * 2 + 3, (sx * SPAN) / 2, 1.2, 0] as Box),
...[-1, 1].map((sx) => [1.3, 1.3, LEG_Z * 2, (sx * SPAN) / 2, PORTAL_H - 4, 0] as Box),
...[-1, 1].map((sz) => [SPAN + 3, 2, 1.5, 0, PORTAL_H, sz * LEG_Z] as Box),
...[-1, 1].map((sx) => [1.5, 2, LEG_Z * 2 + 1.5, (sx * SPAN) / 2, PORTAL_H, 0] as Box),
[3, 3, 3.4, -SPAN / 2 + 2.3, PORTAL_H - 5, LEG_Z + 1.2],
];
const SCREEN_DOOR = /* glsl */ `
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
if (presence < (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) discard;
`;
/** Dark steel and containers, lit by a key, the floods (uFlood) and, for plates, the accent. */
function solid(uniforms: Record<string, { value: unknown }>, instanced: boolean) {
return new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
attribute float aPart;
${instanced ? 'attribute vec2 aState;' : ''}
varying float vPart;
varying vec2 vState;
varying vec3 vNormal;
varying vec3 vWorld;
void main() {
mat4 model = modelMatrix${instanced ? ' * instanceMatrix' : ''};
vec4 world = model * vec4(position, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(model) * normal);
vPart = aPart;
vState = ${instanced ? 'aState' : 'vec2(0.0)'};
gl_Position = projectionMatrix * viewMatrix * world;
}
`,
fragmentShader: /* glsl */ `
uniform float uPresence;
uniform vec3 uFlood;
uniform vec3 uAccent;
uniform float uPulse;
varying float vPart;
varying vec2 vState;
varying vec3 vNormal;
varying vec3 vWorld;
void main() {
float presence = uPresence;
${SCREEN_DOOR}
vec3 n = normalize(vNormal);
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
float floodSide = max(0.0, dot(n, normalize(vec3(-0.6, 0.6, 0.5))));
vec3 light = vec3(0.1, 0.1, 0.13) + vec3(0.16, 0.15, 0.2) * key + uFlood * (0.25 + 0.55 * floodSide);
vec3 color;
${instanced ? /* glsl */ `
int tintIndex = int(vState.y + 0.5);
vec3 tint = tintIndex == 0 ? vec3(${TINTS[0].join(', ')}) : tintIndex == 1 ? vec3(${TINTS[1].join(', ')})
: tintIndex == 2 ? vec3(${TINTS[2].join(', ')}) : vec3(${TINTS[3].join(', ')});
float rib = 0.82 + 0.18 * step(0.5, fract((abs(n.x) > 0.5 ? vWorld.z : vWorld.x) * 0.9));
color = tint * light * rib * 1.6;
if (vPart > 0.5) {
// The end plate: a door at rest, the chapter accent once the spreader has set down (< 0.85).
color = mix(color * 0.7, uAccent * (0.2 + 0.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse)), vState.x);
}` : /* glsl */ `
color = vec3(0.16, 0.17, 0.2) * light * 1.5 + uAccent * 0.025;
if (vPart > 0.5) color = vec3(0.55, 0.42, 0.1) * light * 1.8;
if (vPart > 1.5) {
// The apron: poured concrete with a painted bay line every 14 m.
float bay = step(0.985, fract(vWorld.z * 0.0714)) * step(0.5, n.y);
color = vec3(0.09, 0.095, 0.12) * light * 1.4 + uAccent * (0.02 + 0.12 * bay);
}`}
gl_FragColor = vec4(min(color, vec3(0.82)), 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
fog: false,
});
}
/** Additive lights: camera-facing glows (billboard) or ground pools; aGlow = rgb, level. */
function additive(billboard: boolean) {
return new THREE.ShaderMaterial({
vertexShader: /* glsl */ `
attribute vec4 aGlow;
varying vec2 vUv;
varying vec4 vGlow;
void main() {
vUv = uv;
vGlow = aGlow;
${billboard ? /* glsl */ `
float size = length(instanceMatrix[0].xyz);
vec4 centre = viewMatrix * modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
gl_Position = projectionMatrix * (centre + vec4(position.xy * size, 0.0, 0.0));` : `
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);`}
}
`,
fragmentShader: /* glsl */ `
varying vec2 vUv;
varying vec4 vGlow;
void main() {
float r = 1.0 - min(1.0, length(vUv - 0.5) * 2.0);
float shape = ${billboard ? 'pow(max(r, 0.0), 3.0) * 1.3 + pow(max(r, 0.0), 1.2) * 0.22' : 'pow(max(r, 0.0), 1.8) * 0.7'};
gl_FragColor = vec4(vGlow.rgb * shape * vGlow.w, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false,
});
}
export interface YardInspection {
visible: boolean;
presence: number;
cranePresence: number;
/** Rail coordinate (metres from the lens along the rails, negative ahead) and its world z. */
craneZ: number;
craneWorldZ: number;
spreader: number;
/** Stack-top plates fully lit (= stops passed). */
litPlates: number;
lamp: number;
strikes: number;
flood: number;
}
export interface YardWorld {
readonly group: ThreeGroup;
/**
* Section-local progress of 'additional-work' (null outside), bass pulse,
* frame delta, the underpass deck z (null: none) and the viewport aspect.
*/
update(
section: SectionId,
p: number | null,
pulse: number,
deltaSeconds: number,
deckZ?: number | null,
aspect?: number,
camera?: ThreeCamera | null,
): void;
inspect(): YardInspection;
}
export function createYard(resources: SceneResourceBag, full: boolean): YardWorld {
const own = <T extends { dispose(): void }>(kind: 'geometry' | 'material', value: T): T => resources.own(kind, value);
const accent = new THREE.Color(SIGN_ACCENTS['additional-work']);
const flood = new THREE.Color(0, 0, 0);
const pulse = { value: 0 };
const shared = { uFlood: { value: flood }, uAccent: { value: accent }, uPulse: pulse };
const yardPresence = { value: 0 };
const cranePresence = { value: 0 };
const group = new THREE.Group();
group.name = 'night-freight';
group.visible = false;
// The group's -z axis is the rail line, pivoting on the chapter lens.
group.position.set(LENS.x, 0, LENS.z);
let yaw = Number.NaN;
let squaredYaw = Number.NaN;
const ray = new THREE.Vector3();
const setYaw = (next: number) => {
if (next === yaw) return;
yaw = next;
group.rotation.y = yaw;
};
const place = (aspect: number, camera: ThreeCamera | null) => {
if (camera === null) {
group.position.set(LENS.x, 0, LENS.z);
setYaw(railYaw(aspect));
return;
}
// The live lens: the vertical plane through the camera and the ray at the
// rail's screen x projects to that x, whatever the ease is doing.
camera.updateMatrixWorld();
ray.set(aspect < 1.18 ? PORTRAIT_NDC : RAIL_NDC, 0, 0.5).unproject(camera).sub(camera.position);
group.position.set(camera.position.x, 0, camera.position.z);
if (ray.x * ray.x + ray.z * ray.z > 1e-9) setYaw(Math.atan2(-ray.x, -ray.z));
};
place(DESIGN_ASPECT, null);
const yardMaterial = own('material', solid({ ...shared, uPresence: yardPresence }, false));
const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false));
const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => {
const m = new THREE.Mesh(own('geometry', geometry), material);
m.frustumCulled = false;
return m;
};
// Apron (part 2), rails and the two flood masts (heads are part 1: warm metal housings).
const apronLength = APRON.to - APRON.from;
group.add(mesh(boxes([
[APRON.halfWidth * 2, APRON.depth, apronLength, 0, YARD_Y - APRON.depth / 2, APRON.from + apronLength / 2, 2],
...[-1, 1].map((sx) => [0.9, 0.5, apronLength - 4, (sx * SPAN) / 2, YARD_Y + 0.25, APRON.from + apronLength / 2] as Box),
...FLOOD_ZS.flatMap((z): Box[] => [
[1.1, FLOOD_H, 1.1, FLOOD_X, YARD_Y + FLOOD_H / 2, z],
[6, 0.5, 0.5, FLOOD_X + 1, YARD_Y + FLOOD_H - 0.6, z],
[5.2, 2, 1.6, FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.6, z + 0.4, 1],
]),
])));
// The stacks: one InstancedMesh, the end plate (part 1) faces the camera.
const stackGeometry = own('geometry', boxes([
[BOX_W, BOX_H - 0.1, BOX_L, 0, BOX_H / 2, 0],
[BOX_W - 0.7, BOX_H - 0.8, 0.16, 0, BOX_H / 2, BOX_L / 2 + 0.06, 1],
]));
const topIndex: number[] = [];
let count = 0;
STACK_TIERS.forEach((tiers) => {
count += tiers;
topIndex.push(count - 1);
});
const stackState = new THREE.InstancedBufferAttribute(new Float32Array(count * 2), 2);
stackState.setUsage(THREE.DynamicDrawUsage);
stackGeometry.setAttribute('aState', stackState);
const stacks = new THREE.InstancedMesh(
stackGeometry,
own('material', solid({ ...shared, uPresence: yardPresence }, true)),
count,
);
stacks.name = 'yard-stacks';
stacks.frustumCulled = false;
const matrix = new THREE.Matrix4();
/*
* The stacks counter-turn the rail yaw so every box is square to the road
* (end plate to the camera). Only the bottom tier keeps a hair of stacking
* slop, so the column reads as boxes and the upper tiers sit plumb.
*/
const squareStacks = () => {
if (squaredYaw === yaw) return;
squaredYaw = yaw;
let i = 0;
STACK_ZS.forEach((z, s) => {
for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) {
const slop = tier === 0 ? ((i * 7) % 5 - 2) * 0.006 : 0;
matrix.makeRotationY(slop - yaw).setPosition(((s * 3) % 4 - 1.5) * 0.12, YARD_Y + tier * BOX_H, z);
stacks.setMatrixAt(i, matrix);
stackState.setXY(i, 0, (s + tier * 3) % 4);
}
});
stacks.instanceMatrix.needsUpdate = true;
};
squareStacks();
group.add(stacks);
// The crane: portal, trolley, hoist ropes (unit height, hung from the top) and spreader.
const crane = new THREE.Group();
crane.position.y = YARD_Y;
const ropes = mesh(boxes([-1, 1].flatMap((sx) => [-1, 1].map((sz) => [0.18, 1, 0.18, sx * 2, -0.5, sz * 4.5] as Box))), craneMaterial);
ropes.position.y = PORTAL_H - 1;
const spreader = mesh(boxes([
[BOX_W + 0.3, 0.7, BOX_L - 0.4, 0, 0.35, 0],
[1.2, 1.2, BOX_L - 2, 0, 1.2, 0, 1],
]), craneMaterial);
crane.add(
mesh(boxes(portalBoxes()), craneMaterial),
mesh(boxes([[5, 2.2, LEG_Z * 2 + 1, 0, PORTAL_H + 2, 0, 1]]), craneMaterial),
ropes,
spreader,
);
group.add(crane);
// Lamps (two work lamps, two flood heads) and two flood pools on the apron: full only.
let glows: ThreeInstancedMesh | null = null;
let pools: ThreeInstancedMesh | null = null;
let glowState: ThreeInstancedBufferAttribute | null = null;
let poolState: ThreeInstancedBufferAttribute | null = null;
if (full) {
const glowGeometry = own('geometry', new THREE.PlaneGeometry(1, 1));
glowState = new THREE.InstancedBufferAttribute(new Float32Array(16), 4);
glowState.setUsage(THREE.DynamicDrawUsage);
glowGeometry.setAttribute('aGlow', glowState);
glows = new THREE.InstancedMesh(glowGeometry, own('material', additive(true)), 4);
const poolGeometry = own('geometry', new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2));
poolState = new THREE.InstancedBufferAttribute(new Float32Array(8), 4);
poolState.setUsage(THREE.DynamicDrawUsage);
poolGeometry.setAttribute('aGlow', poolState);
pools = new THREE.InstancedMesh(poolGeometry, own('material', additive(false)), 2);
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const none = new THREE.Quaternion();
FLOOD_ZS.forEach((z, i) => {
glows!.setMatrixAt(2 + i, matrix.compose(position.set(FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.2, z + 1.4), none, scale.setScalar(13)));
pools!.setMatrixAt(i, matrix.compose(position.set(-1.5, YARD_Y + 0.06, z + 8), none, scale.set(24, 1, 40)));
});
for (const m of [glows, pools]) {
m.frustumCulled = false;
m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
group.add(m);
}
}
const lamp = createLamp();
const lampMatrix = new THREE.Matrix4();
const hue = new THREE.Color();
let warm = 0;
let state: YardInspection = {
visible: false, presence: 0, cranePresence: 0, craneZ: craneZ(0), craneWorldZ: group.position.z + craneZ(0) * Math.cos(yaw), spreader: 0, litPlates: 0, lamp: 0, strikes: 0, flood: 0,
};
return {
group,
inspect: () => ({ ...state }),
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT, camera = null) {
place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT, camera);
if (group.visible || section === 'additional-work') {
squareStacks();
spreader.rotation.y = -yaw;
ropes.rotation.y = -yaw;
}
const live = section === 'additional-work' && p !== null && Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : null;
const edge = (from: number, to: number, v: number) => {
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
return t * t * (3 - 2 * t);
};
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(CARD_GAP.to, 1, live));
const z = craneZ(live ?? 0);
const worldZ = group.position.z + z * Math.cos(yaw);
const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
const drop = spreaderDrop(live);
// The spreader lands on whichever stack the crane is over (the nearest by rail distance).
let over = 0;
STACK_ZS.forEach((stackZ, s) => { if (Math.abs(stackZ - z) < Math.abs(STACK_ZS[over]! - z)) over = s; });
const spreaderDown = STACK_TIERS[over]! * BOX_H + 0.45;
const visible = presence > 0;
yardPresence.value = presence;
cranePresence.value = presence * clear;
crane.position.z = z;
crane.visible = cranePresence.value > 0;
const spreaderY = SPREADER_UP + (spreaderDown - SPREADER_UP) * drop;
spreader.position.y = spreaderY;
ropes.scale.y = Math.max(0.1, PORTAL_H - 1 - spreaderY - 1.8);
let litPlates = 0;
STACK_ZS.forEach((_, s) => {
const lit = plateLit(live, s);
if (lit >= 1) litPlates += 1;
stackState.setX(topIndex[s]!, lit);
});
stackState.needsUpdate = true;
warm = floodWarmup(warm, full && live !== null && live < CARD_GAP.to, deltaSeconds);
const light = floodLight(warm);
flood.copy(ORANGE).lerp(HID, light.hue).multiplyScalar(full ? light.output * 0.5 : 0);
pulse.value = full ? Math.min(1, Math.max(0, beat)) : 0;
stepLamp(lamp, full && crane.visible && drop > 0.6 ? 1 : 0, deltaSeconds);
if (glows !== null && pools !== null && glowState !== null && poolState !== null) {
const workLamp = [1.0, 0.86, 0.66] as const;
hue.copy(flood).multiplyScalar(1 / 0.5);
[-1, 1].forEach((sx, i) => {
lampMatrix.makeScale(5, 5, 5).setPosition(sx * (SPAN / 2 + 0.8), YARD_Y + PORTAL_H - 1.4, z + LEG_Z + 1.2);
glows!.setMatrixAt(i, lampMatrix);
glowState!.setXYZW(i, workLamp[0], workLamp[1], workLamp[2], (0.12 + 0.88 * lamp.level) * cranePresence.value);
glowState!.setXYZW(2 + i, hue.r, hue.g, hue.b, presence);
poolState!.setXYZW(i, hue.r, hue.g, hue.b, presence * (1 + PULSE_GAIN * pulse.value));
});
glows.instanceMatrix.needsUpdate = true;
glowState.needsUpdate = true;
poolState.needsUpdate = true;
}
group.visible = visible;
state = {
visible,
presence,
cranePresence: cranePresence.value,
craneZ: z,
craneWorldZ: worldZ,
spreader: drop,
litPlates,
lamp: lamp.level,
strikes: lamp.strikes,
flood: warm,
};
},
};
}