diff --git a/src-tauri/src/ui/scene/mod.rs b/src-tauri/src/ui/scene/mod.rs index 9ac2d22..e9a34ab 100644 --- a/src-tauri/src/ui/scene/mod.rs +++ b/src-tauri/src/ui/scene/mod.rs @@ -179,8 +179,8 @@ pub fn open_window(app_handle: &AppHandle) -> Result(null); + let renderInputs: SceneRenderInputs = $derived({ + puppets, + scale: $sceneConfiguration.puppetScale, + idleOpacity: $sceneConfiguration.puppetOpacity, + selectedPuppetId, + skinHashes, + }); let world: World | null = null; let puppetManager: PuppetManager | null = null; let resizeObserver: ResizeObserver | null = null; - let clock: THREE.Timer | null = null; - let animationFrameId = 0; - let lastBoundsUpdate = 0; + let scheduler: SceneScheduler | null = null; let previousBounds: PuppetScreenBounds[] = []; + let boundsPuppetIds = new Set(); + let lastBoundsUpdate = -Infinity; - function animate() { - if (!world || !puppetManager || !clock) return; + function animate(deltaSeconds: number, elapsedSeconds: number) { + if (!world || !puppetManager) return false; - animationFrameId = requestAnimationFrame(animate); - - clock.update(); - puppetManager.update( - puppets, - $sceneConfiguration.puppetScale, - $sceneConfiguration.puppetOpacity, - selectedPuppetId, - clock.getDelta(), - clock.getElapsed(), - skinHashes, + const active = puppetManager.update( + renderInputs, + deltaSeconds, + elapsedSeconds, ); world.render(); - const elapsed = clock.getElapsed(); - if (elapsed - lastBoundsUpdate >= 1 / 30) { - lastBoundsUpdate = elapsed; + const membershipChanged = + boundsPuppetIds.size !== renderInputs.puppets.length || + renderInputs.puppets.some(({ id }) => !boundsPuppetIds.has(id)); + // Always flush the final pose; a sleeping scene has no later frame to do it. + if ( + !active || + membershipChanged || + elapsedSeconds - lastBoundsUpdate >= 1 / 20 + ) { + lastBoundsUpdate = elapsedSeconds; const nextBounds = puppetManager.screenBounds(); + boundsPuppetIds = new Set(nextBounds.map(({ id }) => id)); if (!sameBounds(previousBounds, nextBounds)) { previousBounds = nextBounds; onBoundsChange(nextBounds); } } + + return active; + } + + $effect(() => { + void renderInputs; + scheduler?.invalidate(); + }); + + function syncVisibility() { + scheduler?.setSuspended(document.hidden); } function sameBounds( @@ -78,20 +97,24 @@ if (!renderDiv) return; world = new World(renderDiv); - puppetManager = new PuppetManager(world); - clock = new THREE.Timer(); - clock.connect(document); + scheduler = new SceneScheduler(animate); + puppetManager = new PuppetManager(world, scheduler.invalidate); - resizeObserver = new ResizeObserver(world.resizeWorld); + resizeObserver = new ResizeObserver(() => { + world?.resizeWorld(); + lastBoundsUpdate = -Infinity; + scheduler?.invalidate(); + }); resizeObserver.observe(renderDiv); - animate(); + document.addEventListener("visibilitychange", syncVisibility); + syncVisibility(); }); onDestroy(() => { - cancelAnimationFrame(animationFrameId); + scheduler?.dispose(); + document.removeEventListener("visibilitychange", syncVisibility); resizeObserver?.disconnect(); - clock?.dispose(); puppetManager?.dispose(); world?.dispose(); onBoundsChange([]); diff --git a/src/routes/scene/components/renderer/puppet/animation.ts b/src/routes/scene/components/renderer/puppet/animation.ts index 17b3f68..a513b4d 100644 --- a/src/routes/scene/components/renderer/puppet/animation.ts +++ b/src/routes/scene/components/renderer/puppet/animation.ts @@ -3,6 +3,8 @@ import type { PuppetRig } from "./visual"; const MIN_IDLE_FACING_OFFSET_DEGREES = 3; const MAX_IDLE_FACING_OFFSET_DEGREES = 5; +const ANGLE_EPSILON = 0.001; +const LIMB_DAMPING = -60 * Math.log(0.8); export class PuppetAnimation { private readonly smoothedGroundPosition = new THREE.Vector3(); @@ -17,6 +19,7 @@ export class PuppetAnimation { this.idleTargetRotationY = this.idleRotationY; } + /** Return true until motion and pose settling no longer need another frame. */ update( targetGroundPosition: THREE.Vector3, isMoving: boolean, @@ -36,12 +39,12 @@ export class PuppetAnimation { .sub(this.smoothedGroundPosition); if (this.movementDelta.lengthSq() <= 0.01) { - this.pause(); + const relaxing = this.pause(deltaSeconds); + this.smoothedGroundPosition.copy(targetGroundPosition); this.rig.root.position.copy(targetGroundPosition); - if (!isMoving) { - this.turnTowards(this.idleTargetRotationY, deltaSeconds); - } - return; + const turning = + !isMoving && this.turnTowards(this.idleTargetRotationY, deltaSeconds); + return relaxing || turning; } const alpha = 1 - Math.exp(-12 * deltaSeconds); @@ -56,15 +59,25 @@ export class PuppetAnimation { this.animateWalkCycle(elapsedSeconds); } else { this.turnTowards(this.idleTargetRotationY, deltaSeconds); - this.pause(); + this.pause(deltaSeconds); } + return true; } - pause() { - this.rig.leftLeg.rotation.x *= 0.8; - this.rig.rightLeg.rotation.x *= 0.8; - this.rig.leftArm.rotation.x *= 0.8; - this.rig.rightArm.rotation.x *= 0.8; + pause(deltaSeconds: number) { + let active = false; + const damping = Math.exp(-LIMB_DAMPING * deltaSeconds); + for (const limb of [ + this.rig.leftLeg, + this.rig.rightLeg, + this.rig.leftArm, + this.rig.rightArm, + ]) { + limb.rotation.x *= damping; + if (Math.abs(limb.rotation.x) <= ANGLE_EPSILON) limb.rotation.x = 0; + else active = true; + } + return active; } walk(elapsedSeconds: number) { @@ -91,26 +104,18 @@ export class PuppetAnimation { } private turnTowards(targetRotationY: number, deltaSeconds: number) { - this.rig.root.rotation.y = this.dampAngle( - this.rig.root.rotation.y, - targetRotationY, - 14, - deltaSeconds, + const current = this.rig.root.rotation.y; + const delta = Math.atan2( + Math.sin(targetRotationY - current), + Math.cos(targetRotationY - current), ); - } - - private dampAngle( - currentRadians: number, - targetRadians: number, - smoothingSpeed: number, - deltaSeconds: number, - ) { - const deltaRadians = Math.atan2( - Math.sin(targetRadians - currentRadians), - Math.cos(targetRadians - currentRadians), - ); - const alpha = 1 - Math.exp(-smoothingSpeed * deltaSeconds); - return currentRadians + deltaRadians * alpha; + if (Math.abs(delta) <= ANGLE_EPSILON) { + this.rig.root.rotation.y = targetRotationY; + return false; + } + this.rig.root.rotation.y = + current + delta * (1 - Math.exp(-14 * deltaSeconds)); + return true; } private animateWalkCycle(elapsedSeconds: number) { diff --git a/src/routes/scene/components/renderer/puppet/index.ts b/src/routes/scene/components/renderer/puppet/index.ts index 6fc8f41..146e803 100644 --- a/src/routes/scene/components/renderer/puppet/index.ts +++ b/src/routes/scene/components/renderer/puppet/index.ts @@ -12,8 +12,9 @@ export class Puppet { constructor( readonly id: string, scale: number, + onChange: () => void, ) { - this.visual = new PuppetVisual(id); + this.visual = new PuppetVisual(id, onChange); this.root = this.visual.root; this.visual.setScale(scale); this.visual.setOpacity(1); @@ -35,8 +36,7 @@ export class Puppet { this.visual.setSkin(skinHash); this.visual.setOpacity(opacity); if (frozen) { - this.visual.pause(); - return; + return this.visual.pause(deltaSeconds); } world.normalizedPointToGroundPoint( @@ -44,7 +44,7 @@ export class Puppet { state.position.y, this.targetGroundPosition, ); - this.visual.updateMotion( + return this.visual.updateMotion( this.targetGroundPosition, state.isMoving, deltaSeconds, diff --git a/src/routes/scene/components/renderer/puppet/manager.ts b/src/routes/scene/components/renderer/puppet/manager.ts index 78505b0..a7338c3 100644 --- a/src/routes/scene/components/renderer/puppet/manager.ts +++ b/src/routes/scene/components/renderer/puppet/manager.ts @@ -1,26 +1,32 @@ import type { PuppetState } from "$lib/bindings"; -import type { PuppetScreenBounds } from "../types"; +import type { PuppetScreenBounds, SceneRenderInputs } from "../types"; import type { World } from "../world"; import { Puppet } from "."; export class PuppetManager { private readonly puppets = new Map(); - constructor(private readonly world: World) {} + constructor( + private readonly world: World, + private readonly onChange: () => void, + ) {} update( - states: readonly PuppetState[], - scale: number, - idleOpacity: number, - selectedPuppetId: string | null, + { + puppets: states, + scale, + idleOpacity, + selectedPuppetId, + skinHashes, + }: SceneRenderInputs, deltaSeconds: number, elapsedSeconds: number, - skinHashes: ReadonlyMap, ) { this.syncPuppets(states, scale); + let active = false; for (const state of states) { - this.puppets + const puppetActive = this.puppets .get(state.id) ?.update( state, @@ -31,7 +37,9 @@ export class PuppetManager { skinHashes.get(state.id) ?? null, state.id === selectedPuppetId ? 1 : idleOpacity, ); + active = puppetActive || active; } + return active; } screenBounds(): PuppetScreenBounds[] { @@ -55,7 +63,7 @@ export class PuppetManager { for (const state of states) { if (this.puppets.has(state.id)) continue; - const puppet = new Puppet(state.id, scale); + const puppet = new Puppet(state.id, scale, this.onChange); this.puppets.set(puppet.id, puppet); this.world.addObject(puppet.root); } diff --git a/src/routes/scene/components/renderer/puppet/skin.ts b/src/routes/scene/components/renderer/puppet/skin.ts index 8fc90d4..c7a70cd 100644 --- a/src/routes/scene/components/renderer/puppet/skin.ts +++ b/src/routes/scene/components/renderer/puppet/skin.ts @@ -126,6 +126,7 @@ export class PuppetSkin { constructor( private readonly userId: string, rig: PuppetRig, + private readonly onChange: () => void, ) { this.material = new THREE.MeshStandardMaterial({ color: "white", @@ -188,6 +189,7 @@ export class PuppetSkin { this.depthMaterial.map = texture; this.material.needsUpdate = true; this.depthMaterial.needsUpdate = true; + this.onChange(); }) .catch((error) => console.error(`Failed to load skin for ${this.userId}`, error), diff --git a/src/routes/scene/components/renderer/puppet/visual.ts b/src/routes/scene/components/renderer/puppet/visual.ts index bbf8fc3..c367fbe 100644 --- a/src/routes/scene/components/renderer/puppet/visual.ts +++ b/src/routes/scene/components/renderer/puppet/visual.ts @@ -39,12 +39,12 @@ export class PuppetVisual { private readonly animation: PuppetAnimation; private readonly skin: PuppetSkin; - constructor(userId: string) { + constructor(userId: string, onChange: () => void = () => {}) { this.rig = createRig(); this.root = this.rig.root; this.animation = new PuppetAnimation(this.rig); const placeholderMaterial = this.rig.body.material; - this.skin = new PuppetSkin(userId, this.rig); + this.skin = new PuppetSkin(userId, this.rig, onChange); if (!Array.isArray(placeholderMaterial)) placeholderMaterial.dispose(); } @@ -77,7 +77,7 @@ export class PuppetVisual { deltaSeconds: number, elapsedSeconds: number, ) { - this.animation.update( + return this.animation.update( targetGroundPosition, isMoving, deltaSeconds, @@ -90,8 +90,8 @@ export class PuppetVisual { this.animation.walk(elapsedSeconds); } - pause() { - this.animation.pause(); + pause(deltaSeconds: number) { + return this.animation.pause(deltaSeconds); } dispose() { diff --git a/src/routes/scene/components/renderer/scheduler.ts b/src/routes/scene/components/renderer/scheduler.ts new file mode 100644 index 0000000..9b8db6e --- /dev/null +++ b/src/routes/scene/components/renderer/scheduler.ts @@ -0,0 +1,47 @@ +/** One render loop for the scene. Updates return true while animation remains. */ +export class SceneScheduler { + private frameId: number | null = null; + private previousTimestamp: number | null = null; + private elapsedSeconds = 0; + private suspended = false; + private disposed = false; + + constructor( + private readonly update: (deltaSeconds: number, elapsedSeconds: number) => boolean, + ) {} + + invalidate = () => { + if (this.disposed || this.suspended || this.frameId !== null) return; + this.frameId = requestAnimationFrame(this.frame); + }; + + setSuspended(suspended: boolean) { + this.suspended = suspended; + if (suspended) this.cancel(); + else this.invalidate(); + } + + dispose() { + this.disposed = true; + this.cancel(); + } + + private cancel() { + if (this.frameId !== null) cancelAnimationFrame(this.frameId); + this.frameId = null; + this.previousTimestamp = null; + } + + private frame = (timestamp: number) => { + this.frameId = null; + if (this.disposed || this.suspended) return; + // Sleep and long stalls must not advance an animation by a huge step. + const delta = this.previousTimestamp === null + ? 0 + : Math.min((timestamp - this.previousTimestamp) / 1000, 0.1); + this.previousTimestamp = timestamp; + this.elapsedSeconds += delta; + if (this.update(delta, this.elapsedSeconds)) this.invalidate(); + if (this.frameId === null) this.previousTimestamp = null; + }; +} diff --git a/src/routes/scene/components/renderer/types.ts b/src/routes/scene/components/renderer/types.ts index a635326..9a081cb 100644 --- a/src/routes/scene/components/renderer/types.ts +++ b/src/routes/scene/components/renderer/types.ts @@ -1,3 +1,13 @@ +import type { PuppetState } from "$lib/bindings"; + +export type SceneRenderInputs = { + puppets: readonly PuppetState[]; + scale: number; + idleOpacity: number; + selectedPuppetId: string | null; + skinHashes: ReadonlyMap; +}; + export type PuppetScreenBounds = { id: string; x: number; diff --git a/tests/scene-animation.test.ts b/tests/scene-animation.test.ts new file mode 100644 index 0000000..e93bd28 --- /dev/null +++ b/tests/scene-animation.test.ts @@ -0,0 +1,99 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as THREE from 'three'; +import { PuppetVisual } from '../src/routes/scene/components/renderer/puppet/visual'; +import { SceneScheduler } from '../src/routes/scene/components/renderer/scheduler'; + +test('scheduler coalesces wakeups, sleeps, resets time, and cancels on suspension/disposal', () => { + const originalRequest = globalThis.requestAnimationFrame; + const originalCancel = globalThis.cancelAnimationFrame; + const pending = new Map(); + let id = 0; + globalThis.requestAnimationFrame = (callback) => { + pending.set(++id, callback); + return id; + }; + globalThis.cancelAnimationFrame = (frameId) => { pending.delete(frameId); }; + let active = true; + const deltas: number[] = []; + const scheduler = new SceneScheduler((delta) => { + deltas.push(delta); + return active; + }); + const tick = (timestamp: number) => { + assert.equal(pending.size, 1); + const [frameId, callback] = [...pending][0]; + pending.delete(frameId); + callback(timestamp); + }; + try { + scheduler.invalidate(); + scheduler.invalidate(); + tick(100); + active = false; + tick(116); + assert.equal(pending.size, 0); + scheduler.invalidate(); + tick(100_000); + assert.deepEqual(deltas, [0, 0.016, 0]); + scheduler.invalidate(); + scheduler.setSuspended(true); + scheduler.invalidate(); + assert.equal(pending.size, 0); + scheduler.setSuspended(false); + tick(200_000); + assert.equal(deltas.at(-1), 0); + scheduler.invalidate(); + scheduler.dispose(); + scheduler.invalidate(); + assert.equal(pending.size, 0); + } finally { + scheduler.dispose(); + globalThis.requestAnimationFrame = originalRequest; + globalThis.cancelAnimationFrame = originalCancel; + } +}); + +test('movement and idle turn finish at an exact stable pose', () => { + const visual = new PuppetVisual('test'); + const origin = new THREE.Vector3(); + const destination = new THREE.Vector3(100, 0, 100); + try { + assert.equal(visual.updateMotion(origin, false, 0, 0), false); + for (let i = 1; i <= 20; i++) { + assert.equal(visual.updateMotion(destination, true, 1 / 60, i / 60), true); + } + let active = true; + let frames = 0; + while (active && frames < 600) { + active = visual.updateMotion(destination, false, 1 / 60, (20 + ++frames) / 60); + } + assert.ok(frames > 1 && frames < 600); + assert.deepEqual(visual.root.position, destination); + const rotation = visual.root.rotation.y; + assert.equal(visual.updateMotion(destination, false, 1 / 60, 20), false); + assert.equal(visual.root.rotation.y, rotation); + } finally { + visual.dispose(); + } +}); + +test('limb relaxation has the same speed at 30 and 120 FPS and finishes', () => { + const slow = new PuppetVisual('slow'); + const fast = new PuppetVisual('fast'); + try { + slow.walkInPlace(0, 0.1); + fast.walkInPlace(0, 0.1); + for (let i = 0; i < 6; i++) slow.pause(1 / 30); + for (let i = 0; i < 24; i++) fast.pause(1 / 120); + slow.root.children.forEach((limb, index) => { + assert.ok(Math.abs(limb.rotation.x - fast.root.children[index].rotation.x) < 1e-10); + }); + for (let i = 0; i < 120; i++) fast.pause(1 / 120); + assert.equal(fast.pause(0), false); + assert.ok(fast.root.children.every((limb) => limb.rotation.x === 0)); + } finally { + slow.dispose(); + fast.dispose(); + } +});