clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

M6 goal 2: per-instance color variation via instanced island rendering

Dami committed 63ba97f
USER RULE: texture variance must be per-part, never a blanket tint.

Islands (blocks, caps, vegetation) now render as InstancedMesh per
(piece, submesh) with a color per instance: geological strata that darken
and cool with depth, position-hash hue/value jitter, moss accents on the
top course (forest/meadow), rust mottling (barren), a per-island foliage
hue lean (most islands green, some autumn, some teal) with per-plant
jitter, and field-parity alternation on cap tiles. All deterministic from
world position + island id; pure presentation. streamAnim animates
instances individually via base matrices, so the assembly/sink waves
survive instancing. Scene nodes drop 3.4k -> ~300; ~35 draw calls/frame.

Root-caused en route: Kenney GLTFs omit metallicFactor, so the glTF spec
default (metallic=1) made GLTFLoader build full-metal materials that
render black wherever direct sun misses (no environment map). assets.ts
normalizes metalness=0 on load - visibly fixes loop-mode decor too.
commit
63ba97fa8b35a25e5da1b77f5310bb22862eb37d
parent
78448d19d35504a11bd0735b1d5805f0f4491346
author
Dami <dami.etoile@gmail.com>
6 files changed, +416 -44
Mprogress.md32+++-
Msrc/client/assets.ts19++
Msrc/client/cua.ts2+
Msrc/client/endless.ts34+++-
Asrc/client/islandRender.ts254++++++++++++++++++++++++++++++
Msrc/client/streamAnim.ts119+++++++++++---
M progress.md +22 -10
@@ -497,16 +497,28 @@ deterministic position jitter), sink-out on disposal (segment lingers in
497497 teardown immediate. Validated rule-based via CUA stream state498498 (spawningSegments/exitingSegments lifecycle: saw spawn on build, saw exit, all499499 settled; sceneNodes bounded 2.9k–4.2k over 4 sim-min; zero console errors) +500-screenshots. REMAINING in M6: texture variation (biome-tinted cliffs, checkered501-goal gate, prototype-texture biome row evaluation), InstancedMesh for island502-blocks (mind the per-instance animation matrices).] Placements animate IN as the503-stream builds (transform-based rise/scale settle, staggered per placement by504-distance from segment entry; island blocks bottom-up, decor after) and animate505-OUT before disposal (segment lingers one window while exiting). Visual-only506-wall-clock easing — generation untouched, determinism unaffected. Textures:507-biome-tinted cliff materials (shared material clone PER BIOME, not per block),508-per-theme accents, the checkered goal-gate texture (old backlog), and evaluate509-extra Kenney texture packs (e.g. Prototype Textures as an "abstract" biome row).500+screenshots. GOAL 2 DONE: per-instance color variation + instancing —501+src/client/islandRender.ts. USER RULE: variation must be per-part, never a502+blanket tint. Islands render as InstancedMesh per (piece, submesh) with503+setColorAt per instance: geological strata (courses darken/cool with depth),504+position-hash hue/value jitter, moss accents on the top course (forest/meadow),505+rust mottling (barren), per-island foliage hue lean (most green, some autumn,506+some teal — foliage submeshes detected by green dominance, trunks value-jitter507+only), cap tiles with field-parity alternation. streamAnim animates instances508+individually (base matrices in userData.animInstances). Scene nodes 3.4k → ~300,509+whole frame ~35 draw calls. FOUND + FIXED en route: Kenney GLTFs omit510+metallicFactor → glTF default metallic=1.0 → GLTFLoader builds full-metal511+materials that render BLACK off-sun (no envmap); assets.ts now normalizes512+metalness=0 on load — this also visibly fixed loop-mode nature decor. REMAINING513+in M6: checkered goal-gate texture, prototype-texture "abstract" biome row514+evaluation, per-biome fog/light tints.] Placements animate IN as the stream515+builds (transform-based rise/scale settle, staggered per placement by distance516+from segment entry; island blocks bottom-up, decor after) and animate OUT before517+disposal (segment lingers one window while exiting). Visual-only wall-clock518+easing — generation untouched, determinism unaffected. Textures: biome-tinted519+cliff materials (shared material clone PER BIOME, not per block), per-theme520+accents, the checkered goal-gate texture (old backlog), and evaluate extra521+Kenney texture packs (e.g. Prototype Textures as an "abstract" biome row).510522 Validation: headless build/dispose cycle asserting renderer.info counts return511523 to baseline (no leaks), animation state-machine unit tests, screenshot review.512524
M src/client/assets.ts +19 -0
M src/client/cua.ts +2 -0
M src/client/endless.ts +22 -12
@@ -21,6 +21,7 @@ import { buildOpenCurve } from "./centerline.ts";
2121 import { type Cameras, type CamMode, createCameras } from "./camera.ts";2222 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";2323 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";24+import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";2425 import type { DecorCatalog } from "../gen/decor.ts";2526 import { genVersion } from "../gen/version.ts";2627 import { installCua } from "./cua.ts";
@@ -47,6 +48,8 @@ interface BuiltSegment {
4748 islands: number;4849 /** Entry pose, kept for the exit animation's wave direction. */4950 entry: { x: number; y: number; z: number; dir: number };51+ /** Per-build GPU state (island instance buffers + cloned materials). */52+ disposables: InstancedBuild[];5053 }5154 5255 interface UiRefs {
@@ -139,6 +142,8 @@ export function bootEndless(
139142 // Animation lifecycle, for rule-based verification.140143 spawningSegments: session.spawningCount,141144 exitingSegments: session.exitingCount,145+ // Instancing gate: island pieces share draw calls now.146+ drawCalls: ctx.renderer.info.render.calls,142147 }143148 : null,144149 }));
@@ -157,7 +162,11 @@ class Session {
157162 private readonly built = new Map<number, BuiltSegment>();158163 /** Segments assembling in (index → wave) and sinking out. */159164 private readonly spawning = new Map<number, SpawnAnim>();160- private exiting: { group: THREE.Group; anim: ExitAnim }[] = [];165+ private exiting: {166+ group: THREE.Group;167+ anim: ExitAnim;168+ disposables: InstancedBuild[];169+ }[] = [];161170 /** The session's first fill builds instantly — the opening view should162171 * exist, not assemble around the spawning marble. */163172 private initialBuild = true;
@@ -277,15 +286,11 @@ class Session {
277286 spinners,278287 missing,279288 );280- for (const isle of data.islands) {281- instantiatePlacements(282- isle.placements,283- this.assets,284- group,285- spinners,286- missing,287- );288- }289+ // Islands render INSTANCED with per-instance color variation (strata,290+ // moss/rust accents, per-island foliage lean) — islandRender.ts.291+ const disposables = data.islands.map((isle) =>292+ buildIslandInstanced(isle, this.assets, group, missing)293+ );289294 for (const id of missing) {290295 console.warn(`[endless] unknown pieceId "${id}" — skipped`);291296 }
@@ -314,6 +319,7 @@ class Session {
314319 z: data.plan.entry.z,315320 dir: data.plan.entry.dir,316321 },322+ disposables,317323 };318324 this.built.set(i, seg);319325 this.islandsSeen += data.islands.length;
@@ -349,6 +355,7 @@ class Session {
349355 new THREE.Vector3(entry.x, entry.y, entry.z),350356 new THREE.Vector3(step[0], 0, step[1]),351357 ),358+ disposables: seg.disposables,352359 });353360 }354361
@@ -425,10 +432,11 @@ class Session {
425432 for (const [idx, anim] of [...this.spawning]) {426433 if (anim.update(dt)) this.spawning.delete(idx);427434 }428- this.exiting = this.exiting.filter(({ group, anim }) => {435+ this.exiting = this.exiting.filter(({ group, anim, disposables }) => {429436 if (!anim.update(dt)) return true;430437 this.root.remove(group);431438 group.clear();439+ for (const d of disposables) d.dispose();432440 return false;433441 });434442
@@ -463,12 +471,14 @@ class Session {
463471 for (const seg of [...this.built.values()]) {464472 this.root.remove(seg.group);465473 seg.group.clear();474+ for (const d of seg.disposables) d.dispose();466475 }467476 this.built.clear();468477 this.spawning.clear();469- for (const { group } of this.exiting) {478+ for (const { group, disposables } of this.exiting) {470479 this.root.remove(group);471480 group.clear();481+ for (const d of disposables) d.dispose();472482 }473483 this.exiting = [];474484 this.ctx.scene.remove(this.root);
A src/client/islandRender.ts +254 -0
@@ -0,0 +1,254 @@
1+/**2+ * Instanced island rendering with PER-INSTANCE color variation.3+ *4+ * Blanket biome tints read as a filter; real kits vary per part. Every5+ * island placement (cliff blocks, cap tiles, vegetation) becomes an6+ * instance in an InstancedMesh per (piece, submesh), and every instance7+ * gets its own color via setColorAt (multiplied over the kit texture):8+ *9+ * - blocks: geological STRATA (each course below the top drifts darker and10+ * cooler), per-block hue/value jitter keyed by a position hash, MOSS11+ * accents on the top course (forest/meadow), RUST mottling (barren).12+ * - caps: hue/value spread around the grass tone + a subtle (x+z) parity13+ * alternation so meadows read as tended fields.14+ * - vegetation: each ISLAND draws a foliage hue lean (some islands run15+ * autumn-warm, some deep green), each tree jitters around it. Foliage16+ * materials are detected by green dominance; trunks/rocks get value17+ * jitter only.18+ *19+ * All variation is deterministic from world position + island id — pure20+ * presentation, no RNG streams touched. One draw call per (piece, submesh,21+ * island) instead of one per placement.22+ *23+ * The spawn/exit waves animate instances individually: each InstancedMesh24+ * carries `userData.animInstances` (base matrices + world positions) that25+ * streamAnim.ts consumes.26+ */27+import * as THREE from "three";28+import type { Placement } from "../shared/types.ts";29+import type { Assets } from "./assets.ts";30+import type { Island } from "../gen/endless/islands.ts";31+32+export interface InstancedBuild {33+ meshes: THREE.InstancedMesh[];34+ /** Dispose per-build GPU state (instance buffers, cloned materials). */35+ dispose(): void;36+}37+38+/** Per-instance animation data consumed by streamAnim. */39+export interface AnimInstances {40+ base: THREE.Matrix4[];41+ worldPos: THREE.Vector3[];42+}43+44+interface SubMesh {45+ geometry: THREE.BufferGeometry;46+ material: THREE.Material;47+ local: THREE.Matrix4;48+ foliage: boolean;49+}50+51+const tmpM = new THREE.Matrix4();52+const tmpQ = new THREE.Quaternion();53+const tmpS = new THREE.Vector3();54+const tmpP = new THREE.Vector3();55+const tmpC = new THREE.Color();56+57+/** Deterministic hash of a world position (+salt) → [0, 1). */58+function hash01(x: number, y: number, z: number, salt: number): number {59+ let h = Math.imul((x * 4 + 1024) | 0, 374761393) ^60+ Math.imul((y * 4 + 2048) | 0, 668265263) ^61+ Math.imul((z * 4 + 4096) | 0, 2246822519) ^62+ Math.imul(salt | 0, 973913579);63+ h = Math.imul(h ^ (h >>> 13), 3266489917);64+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;65+}66+67+/** Submeshes of a template with their root-relative transforms. */68+function subMeshesOf(template: THREE.Object3D): SubMesh[] {69+ const out: SubMesh[] = [];70+ const walk = (node: THREE.Object3D, parent: THREE.Matrix4) => {71+ node.updateMatrix();72+ const local = node === template73+ ? new THREE.Matrix4()74+ : new THREE.Matrix4().multiplyMatrices(parent, node.matrix);75+ const mesh = node as THREE.Mesh;76+ if (mesh.isMesh) {77+ const mat = mesh.material as THREE.MeshStandardMaterial;78+ const foliage = Boolean(79+ mat.color && mat.color.g > mat.color.r * 1.15 &&80+ mat.color.g > mat.color.b * 1.15,81+ );82+ out.push({83+ geometry: mesh.geometry,84+ material: mat,85+ local,86+ foliage,87+ });88+ }89+ for (const child of node.children) walk(child, local);90+ };91+ walk(template, new THREE.Matrix4());92+ return out;93+}94+95+function clamp(v: number, lo: number, hi: number): number {96+ return Math.min(hi, Math.max(lo, v));97+}98+99+/** Color for a mass block: strata + jitter + biome accents (multiplied100+ * over the cliff texture, so values hover near white). */101+function blockColor(102+ biome: string,103+ depthBelowTop: number,104+ x: number,105+ y: number,106+ z: number,107+): THREE.Color {108+ const band = Math.max(0, Math.floor(depthBelowTop));109+ let h = 0.075 + band * 0.012 + (hash01(x, y, z, 11) - 0.5) * 0.05;110+ let s = 0.06 + band * 0.02;111+ let l = 0.88 - band * 0.055 + (hash01(x, y, z, 23) - 0.5) * 0.12;112+113+ const accent = hash01(x, y, z, 37);114+ if ((biome === "forest" || biome === "meadow") && depthBelowTop <= 1.5) {115+ // Moss creeps over the top course.116+ if (accent < 0.22) {117+ h = 0.3 + (accent - 0.11) * 0.3;118+ s = 0.3;119+ l = clamp(l + 0.02, 0.6, 0.95);120+ }121+ } else if (biome === "barren" && accent < 0.3) {122+ // Rust mottling.123+ h = 0.055 + accent * 0.05;124+ s = 0.28;125+ l = clamp(l - 0.04, 0.5, 0.95);126+ }127+ return tmpC.setHSL(h, clamp(s, 0, 0.6), clamp(l, 0.5, 0.98)).clone();128+}129+130+/** Color for a cap tile: grass spread + field parity. */131+function capColor(biome: string, x: number, z: number): THREE.Color {132+ const warm = biome === "meadow" ? 0.035 : 0;133+ const h = 0.29 + warm + (hash01(x, 0, z, 41) - 0.5) * 0.07;134+ const s = 0.16 + hash01(x, 0, z, 43) * 0.18;135+ let l = 0.88 + (hash01(x, 0, z, 47) - 0.5) * 0.12;136+ l += (Math.abs(x + z) | 0) % 2 === 0 ? 0.025 : -0.025;137+ return tmpC.setHSL(h, s, clamp(l, 0.55, 0.98)).clone();138+}139+140+/** Color for vegetation submeshes: island-wide foliage lean + per-plant141+ * jitter on foliage; value-only jitter elsewhere. */142+function decorColor(143+ islandLean: number,144+ foliage: boolean,145+ x: number,146+ y: number,147+ z: number,148+): THREE.Color {149+ if (foliage) {150+ const h = 0.3 + islandLean + (hash01(x, y, z, 53) - 0.5) * 0.06;151+ const s = 0.25 + hash01(x, y, z, 59) * 0.2;152+ const l = 0.85 + (hash01(x, y, z, 61) - 0.5) * 0.16;153+ // Autumn lean pushes hue below green — clamp into a leaf-plausible arc.154+ return tmpC.setHSL(clamp(h, 0.06, 0.42), s, clamp(l, 0.55, 0.98)).clone();155+ }156+ const l = 0.9 + (hash01(x, y, z, 67) - 0.5) * 0.14;157+ return tmpC.setHSL(0.08, 0.05, clamp(l, 0.6, 0.98)).clone();158+}159+160+/**161+ * Build one island's placements as instanced meshes with per-instance162+ * colors, appended directly to `group` (flat — the spawn/exit waves treat163+ * each InstancedMesh as a bundle of individually-animated instances).164+ */165+export function buildIslandInstanced(166+ island: Island,167+ assets: Assets,168+ group: THREE.Group,169+ missing: Set<string>,170+): InstancedBuild {171+ // Bucket placements by pieceId.172+ const byPiece = new Map<string, Placement[]>();173+ for (const p of island.placements) {174+ const list = byPiece.get(p.pieceId) ?? [];175+ list.push(p);176+ byPiece.set(p.pieceId, list);177+ }178+179+ // Island-wide foliage lean: most islands stay green, a few run warm180+ // (autumn) or cool. Deterministic per island id.181+ const leanDraw = hash01(island.id, 1, island.id * 7, 71);182+ const islandLean = leanDraw < 0.6183+ ? (leanDraw - 0.3) * 0.05 // subtle green spread184+ : leanDraw < 0.85185+ ? -0.12 - (leanDraw - 0.6) * 0.3 // autumn arc (toward orange)186+ : 0.06; // cool teal-leaning187+188+ const meshes: THREE.InstancedMesh[] = [];189+ const cloned: THREE.Material[] = [];190+191+ for (const [pieceId, placements] of byPiece) {192+ const template = assets.templates.get(pieceId);193+ if (!template) {194+ missing.add(pieceId);195+ continue;196+ }197+ const isBlock = pieceId.includes("cliff_block");198+ const isCap = !isBlock && pieceId.includes("ground_");199+ for (const sub of subMeshesOf(template)) {200+ const mat = sub.material.clone();201+ cloned.push(mat);202+ const imesh = new THREE.InstancedMesh(203+ sub.geometry,204+ mat,205+ placements.length,206+ );207+ imesh.castShadow = true;208+ imesh.receiveShadow = true;209+ const anim: AnimInstances = { base: [], worldPos: [] };210+211+ placements.forEach((p, i) => {212+ const s = p.scale ?? 1;213+ tmpP.set(p.pos[0], p.pos[1], p.pos[2]);214+ tmpQ.setFromAxisAngle(215+ new THREE.Vector3(0, 1, 0),216+ p.rotation * Math.PI / 2,217+ );218+ tmpS.set(s, s, s);219+ tmpM.compose(tmpP, tmpQ, tmpS);220+ tmpM.multiply(sub.local);221+ imesh.setMatrixAt(i, tmpM);222+ anim.base.push(tmpM.clone());223+ anim.worldPos.push(tmpP.clone());224+225+ const color = isBlock226+ ? blockColor(227+ island.biome,228+ island.topY - p.pos[1],229+ p.pos[0],230+ p.pos[1],231+ p.pos[2],232+ )233+ : isCap234+ ? capColor(island.biome, p.pos[0], p.pos[2])235+ : decorColor(islandLean, sub.foliage, p.pos[0], p.pos[1], p.pos[2]);236+ imesh.setColorAt(i, color);237+ });238+ imesh.instanceMatrix.needsUpdate = true;239+ if (imesh.instanceColor) imesh.instanceColor.needsUpdate = true;240+ imesh.computeBoundingSphere();241+ imesh.userData.animInstances = anim;242+ group.add(imesh);243+ meshes.push(imesh);244+ }245+ }246+247+ return {248+ meshes,249+ dispose() {250+ for (const m of meshes) m.dispose(); // instance buffers only251+ for (const m of cloned) m.dispose();252+ },253+ };254+}
M src/client/streamAnim.ts +97 -22
@@ -1,13 +1,21 @@
11 /**22 * Stream animations: segments and islands ASSEMBLE into the void and SINK3- * away, instead of popping. Purely visual, wall-clock-eased, per-placement:4- * a wave of construction flows along the direction of travel (each child's3+ * away, instead of popping. Purely visual, wall-clock-eased, per-piece:4+ * a wave of construction flows along the direction of travel (each piece's55 * delay is its projection onto the segment's heading, plus a deterministic66 * per-position jitter). Generation and physics are untouched — the marble77 * only ever rides segments whose spawn wave finished long before it arrives88 * (the render window builds ~3 segments = ~75 units ahead).9+ *10+ * Two kinds of animated piece:11+ * - plain Object3D children (track piece clones) — position/scale eased;12+ * - instances inside an InstancedMesh (island blocks/caps/vegetation,13+ * islandRender.ts) — each instance carries a base matrix in14+ * `userData.animInstances` and is eased by rewriting its matrix, so the15+ * per-piece wave survives instancing.916 */1017 import * as THREE from "three";18+import type { AnimInstances } from "./islandRender.ts";1119 1220 /** Seconds of stagger per unit of distance along the travel direction. */1321 const STAGGER_PER_UNIT = 0.045;
@@ -18,7 +26,8 @@ const SPAWN_DROP = 2.0;
1826 const EXIT_DURATION = 0.4;1927 const EXIT_SINK = 1.6;2028 21-interface Entry {29+interface ObjEntry {30+ kind: "obj";2231 obj: THREE.Object3D;2332 baseY: number;2433 baseScale: THREE.Vector3;
@@ -26,6 +35,20 @@ interface Entry {
2635 t: number;2736 }2837 38+interface InstEntry {39+ kind: "inst";40+ mesh: THREE.InstancedMesh;41+ index: number;42+ base: THREE.Matrix4;43+ delay: number;44+ t: number;45+}46+47+type Entry = ObjEntry | InstEntry;48+49+const tmpM = new THREE.Matrix4();50+const tmpS = new THREE.Matrix4();51+2952 function easeOutBack(t: number): number {3053 const c1 = 1.70158;3154 const c3 = c1 + 1;
@@ -45,6 +68,16 @@ function posJitter(x: number, y: number, z: number): number {
4568 return ((h ^ (h >>> 16)) >>> 0) / 4294967296;4669 }4770 71+function delayFor(72+ pos: THREE.Vector3,73+ origin: THREE.Vector3,74+ heading: THREE.Vector3,75+): number {76+ const proj = (pos.x - origin.x) * heading.x + (pos.z - origin.z) * heading.z;77+ return Math.max(0, proj) * STAGGER_PER_UNIT +78+ posJitter(pos.x, pos.y, pos.z) * JITTER;79+}80+4881 function collectEntries(4982 group: THREE.Group,5083 origin: THREE.Vector3,
@@ -52,20 +85,45 @@ function collectEntries(
5285 ): Entry[] {5386 const out: Entry[] = [];5487 for (const obj of group.children) {55- const proj = (obj.position.x - origin.x) * heading.x +56- (obj.position.z - origin.z) * heading.z;57- out.push({58- obj,59- baseY: obj.position.y,60- baseScale: obj.scale.clone(),61- delay: Math.max(0, proj) * STAGGER_PER_UNIT +62- posJitter(obj.position.x, obj.position.y, obj.position.z) * JITTER,63- t: 0,64- });88+ const anim = (obj as THREE.InstancedMesh).userData89+ ?.animInstances as AnimInstances | undefined;90+ if (anim && (obj as THREE.InstancedMesh).isInstancedMesh) {91+ const mesh = obj as THREE.InstancedMesh;92+ for (let i = 0; i < anim.base.length; i++) {93+ out.push({94+ kind: "inst",95+ mesh,96+ index: i,97+ base: anim.base[i],98+ delay: delayFor(anim.worldPos[i], origin, heading),99+ t: 0,100+ });101+ }102+ } else {103+ out.push({104+ kind: "obj",105+ obj,106+ baseY: obj.position.y,107+ baseScale: obj.scale.clone(),108+ delay: delayFor(obj.position, origin, heading),109+ t: 0,110+ });111+ }65112 }66113 return out;67114 }68115 116+/** Write an instance matrix = base offset by dy and scaled by s (about the117+ * piece's own origin). */118+function writeInstance(e: InstEntry, dy: number, s: number): void {119+ tmpM.copy(e.base);120+ tmpS.makeScale(s, s, s);121+ tmpM.multiply(tmpS);122+ tmpM.elements[13] += dy;123+ e.mesh.setMatrixAt(e.index, tmpM);124+ e.mesh.instanceMatrix.needsUpdate = true;125+}126+69127 export class SpawnAnim {70128 private entries: Entry[];71129
@@ -77,8 +135,12 @@ export class SpawnAnim {
77135 this.entries = collectEntries(group, origin, heading);78136 // First frame state: everything hidden above its target, tiny.79137 for (const e of this.entries) {80- e.obj.position.y = e.baseY + SPAWN_DROP;81- e.obj.scale.copy(e.baseScale).multiplyScalar(0.001);138+ if (e.kind === "obj") {139+ e.obj.position.y = e.baseY + SPAWN_DROP;140+ e.obj.scale.copy(e.baseScale).multiplyScalar(0.001);141+ } else {142+ writeInstance(e, SPAWN_DROP, 0.001);143+ }82144 }83145 }84146
@@ -93,15 +155,23 @@ export class SpawnAnim {
93155 continue;94156 }95157 if (k >= 1) {96- e.obj.position.y = e.baseY;97- e.obj.scale.copy(e.baseScale);158+ if (e.kind === "obj") {159+ e.obj.position.y = e.baseY;160+ e.obj.scale.copy(e.baseScale);161+ } else {162+ writeInstance(e, 0, 1);163+ }98164 continue;99165 }100166 done = false;101167 const f = easeOutBack(k);102- e.obj.position.y = e.baseY + SPAWN_DROP * (1 - f);103168 const s = Math.max(0.001, Math.min(1, k * 1.6));104- e.obj.scale.copy(e.baseScale).multiplyScalar(s);169+ if (e.kind === "obj") {170+ e.obj.position.y = e.baseY + SPAWN_DROP * (1 - f);171+ e.obj.scale.copy(e.baseScale).multiplyScalar(s);172+ } else {173+ writeInstance(e, SPAWN_DROP * (1 - f), s);174+ }105175 }106176 return done;107177 }
@@ -129,13 +199,18 @@ export class ExitAnim {
129199 continue;130200 }131201 if (k >= 1) {132- e.obj.visible = false;202+ if (e.kind === "obj") e.obj.visible = false;203+ else writeInstance(e, -EXIT_SINK, 0.001);133204 continue;134205 }135206 done = false;136207 const f = easeInCubic(k);137- e.obj.position.y = e.baseY - EXIT_SINK * f;138- e.obj.scale.copy(e.baseScale).multiplyScalar(Math.max(0.001, 1 - f));208+ if (e.kind === "obj") {209+ e.obj.position.y = e.baseY - EXIT_SINK * f;210+ e.obj.scale.copy(e.baseScale).multiplyScalar(Math.max(0.001, 1 - f));211+ } else {212+ writeInstance(e, -EXIT_SINK * f, Math.max(0.001, 1 - f));213+ }139214 }140215 return done;141216 }