1/**2 * Floating void decor renderer (M18b): one InstancedMesh per (pieceId,3 * submesh), flat in the segment group so the spawn/exit waves animate4 * instances exactly like island decor (`animInstances`), plus a per-frame5 * FLOAT pass — bob (amp·sin over sim time) and slow drift — that owns the6 * instance matrices only AFTER the assemble wave finished (the session7 * gates tick() on its spawning/exiting sets; a 2s envelope ramps the bob8 * in so the handoff never pops).9 *10 * Void decor never collides (the generator keeps it out of `placements`),11 * never casts shadows (the sun fits the track box), and never occludes12 * the camera (a drifting shoal must not yank the boom): castShadow off,13 * no `occluder` flag, and every mesh carries `userData.voidDecor` so the14 * session's fit box skips it (far-band silhouettes would inflate focus15 * and shadow fit otherwise).16 */17import * as THREE from "three";18import type { Placement } from "../shared/types.ts";19import type { Assets } from "./assets.ts";2021interface FloatParams {22 amp: number;23 period: number;24 phase: number;25 drift: readonly [number, number, number] | null;26}2728interface AnimInstances {29 base: THREE.Matrix4[];30 worldPos: THREE.Vector3[];31}3233export interface FloatBuild {34 meshes: THREE.InstancedMesh[];35 /** Advance the float pass to sim time t (seconds). First call stamps36 * the epoch: bob eases in over ~2s from there, drift time is37 * segment-local (bounded — a long session never walks a far shoal38 * across the track). */39 tick(simT: number): void;40 dispose(): void;41}4243const tmpM = new THREE.Matrix4();44const RAMP_SECONDS = 2;45/** Drift displacement saturates after this many seconds. */46const DRIFT_CAP_SECONDS = 45;4748/** Submeshes of a template with root-relative transforms (the49 * islandRender walk, minus foliage classification). */50function subMeshesOf(51 template: THREE.Object3D,52): { geometry: THREE.BufferGeometry; material: THREE.Material; local: THREE.Matrix4 }[] {53 const out: {54 geometry: THREE.BufferGeometry;55 material: THREE.Material;56 local: THREE.Matrix4;57 }[] = [];58 const walk = (node: THREE.Object3D, parent: THREE.Matrix4) => {59 node.updateMatrix();60 const local = node === template61 ? new THREE.Matrix4()62 : new THREE.Matrix4().multiplyMatrices(parent, node.matrix);63 const mesh = node as THREE.Mesh;64 if (mesh.isMesh) {65 out.push({66 geometry: mesh.geometry,67 material: mesh.material as THREE.Material,68 local,69 });70 }71 for (const child of node.children) walk(child, local);72 };73 walk(template, new THREE.Matrix4());74 return out;75}7677export function buildVoidDecor(78 placements: readonly Placement[],79 assets: Assets,80 group: THREE.Group,81 missing: Set<string>,82): FloatBuild | null {83 if (placements.length === 0) return null;8485 const byPiece = new Map<string, Placement[]>();86 for (const p of placements) {87 const list = byPiece.get(p.pieceId) ?? [];88 list.push(p);89 byPiece.set(p.pieceId, list);90 }9192 const meshes: THREE.InstancedMesh[] = [];93 const floatParams: FloatParams[][] = [];94 const tmpP = new THREE.Vector3();95 const tmpQ = new THREE.Quaternion();96 const tmpS = new THREE.Vector3();9798 for (const [pieceId, list] of byPiece) {99 const template = assets.templates.get(pieceId);100 if (!template) {101 missing.add(pieceId);102 continue;103 }104 for (const sub of subMeshesOf(template)) {105 const imesh = new THREE.InstancedMesh(106 sub.geometry,107 sub.material,108 list.length,109 );110 imesh.castShadow = false;111 imesh.receiveShadow = false;112 const anim: AnimInstances = { base: [], worldPos: [] };113 const params: FloatParams[] = [];114 list.forEach((p, i) => {115 const s = p.scale ?? 1;116 tmpP.set(p.pos[0], p.pos[1], p.pos[2]);117 tmpQ.setFromAxisAngle(118 new THREE.Vector3(0, 1, 0),119 p.rotation * Math.PI / 2,120 );121 tmpS.set(s, s, s);122 tmpM.compose(tmpP, tmpQ, tmpS);123 tmpM.multiply(sub.local);124 imesh.setMatrixAt(i, tmpM);125 anim.base.push(tmpM.clone());126 anim.worldPos.push(tmpP.clone());127 const f = p.float;128 params.push({129 amp: f?.amp ?? 0,130 period: f?.period ?? 8,131 phase: f?.phase ?? 0,132 drift: f?.drift ?? null,133 });134 });135 imesh.instanceMatrix.needsUpdate = true;136 imesh.computeBoundingSphere();137 // Bounding sphere must survive the bob/drift walk or instances138 // vanish to frustum culling at the offset extremes.139 if (imesh.boundingSphere) imesh.boundingSphere.radius += 12;140 imesh.userData.animInstances = anim;141 imesh.userData.voidDecor = true;142 group.add(imesh);143 meshes.push(imesh);144 floatParams.push(params);145 }146 }147148 if (meshes.length === 0) return null;149150 let epoch: number | null = null;151 return {152 meshes,153 tick(simT: number): void {154 if (epoch === null) epoch = simT;155 const local = simT - epoch;156 const env = Math.min(1, local / RAMP_SECONDS);157 const driftT = Math.min(local, DRIFT_CAP_SECONDS);158 for (let m = 0; m < meshes.length; m++) {159 const mesh = meshes[m];160 const anim = mesh.userData.animInstances as AnimInstances;161 const params = floatParams[m];162 for (let i = 0; i < params.length; i++) {163 const f = params[i];164 const bob = f.amp * env *165 Math.sin((2 * Math.PI * (simT + f.phase)) / f.period);166 tmpM.copy(anim.base[i]);167 tmpM.elements[13] += bob;168 if (f.drift) {169 tmpM.elements[12] += f.drift[0] * driftT;170 tmpM.elements[13] += f.drift[1] * driftT;171 tmpM.elements[14] += f.drift[2] * driftT;172 }173 mesh.setMatrixAt(i, tmpM);174 }175 mesh.instanceMatrix.needsUpdate = true;176 }177 },178 dispose(): void {179 for (const m of meshes) m.dispose(); // instance buffers only180 },181 };182}