limit FPS of render on idle

This commit is contained in:
2026-09-04 12:49:39 +08:00
parent 0c1440f926
commit b3a74d45c1
10 changed files with 272 additions and 78 deletions
+2 -2
View File
@@ -179,8 +179,8 @@ pub fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, Strin
let window = builder.build().map_err(|e: tauri::Error| e.to_string())?; let window = builder.build().map_err(|e: tauri::Error| e.to_string())?;
// #[cfg(debug_assertions)] #[cfg(debug_assertions)]
// window.open_devtools(); window.open_devtools();
Ok(window) Ok(window)
} }
+51 -28
View File
@@ -2,9 +2,9 @@
import type { PuppetState } from "$lib/bindings"; import type { PuppetState } from "$lib/bindings";
import { sceneConfiguration } from "$lib/listeners/scene-configuration"; import { sceneConfiguration } from "$lib/listeners/scene-configuration";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import * as THREE from "three"; import { SceneScheduler } from "./renderer/scheduler";
import { PuppetManager } from "./renderer/puppet/manager"; import { PuppetManager } from "./renderer/puppet/manager";
import type { PuppetScreenBounds } from "./renderer/types"; import type { PuppetScreenBounds, SceneRenderInputs } from "./renderer/types";
import { World } from "./renderer/world"; import { World } from "./renderer/world";
let { let {
@@ -20,41 +20,60 @@
} = $props(); } = $props();
let renderDiv = $state<HTMLDivElement | null>(null); let renderDiv = $state<HTMLDivElement | null>(null);
let renderInputs: SceneRenderInputs = $derived({
puppets,
scale: $sceneConfiguration.puppetScale,
idleOpacity: $sceneConfiguration.puppetOpacity,
selectedPuppetId,
skinHashes,
});
let world: World | null = null; let world: World | null = null;
let puppetManager: PuppetManager | null = null; let puppetManager: PuppetManager | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let clock: THREE.Timer | null = null; let scheduler: SceneScheduler | null = null;
let animationFrameId = 0;
let lastBoundsUpdate = 0;
let previousBounds: PuppetScreenBounds[] = []; let previousBounds: PuppetScreenBounds[] = [];
let boundsPuppetIds = new Set<string>();
let lastBoundsUpdate = -Infinity;
function animate() { function animate(deltaSeconds: number, elapsedSeconds: number) {
if (!world || !puppetManager || !clock) return; if (!world || !puppetManager) return false;
animationFrameId = requestAnimationFrame(animate); const active = puppetManager.update(
renderInputs,
clock.update(); deltaSeconds,
puppetManager.update( elapsedSeconds,
puppets,
$sceneConfiguration.puppetScale,
$sceneConfiguration.puppetOpacity,
selectedPuppetId,
clock.getDelta(),
clock.getElapsed(),
skinHashes,
); );
world.render(); world.render();
const elapsed = clock.getElapsed(); const membershipChanged =
if (elapsed - lastBoundsUpdate >= 1 / 30) { boundsPuppetIds.size !== renderInputs.puppets.length ||
lastBoundsUpdate = elapsed; 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(); const nextBounds = puppetManager.screenBounds();
boundsPuppetIds = new Set(nextBounds.map(({ id }) => id));
if (!sameBounds(previousBounds, nextBounds)) { if (!sameBounds(previousBounds, nextBounds)) {
previousBounds = nextBounds; previousBounds = nextBounds;
onBoundsChange(nextBounds); onBoundsChange(nextBounds);
} }
} }
return active;
}
$effect(() => {
void renderInputs;
scheduler?.invalidate();
});
function syncVisibility() {
scheduler?.setSuspended(document.hidden);
} }
function sameBounds( function sameBounds(
@@ -78,20 +97,24 @@
if (!renderDiv) return; if (!renderDiv) return;
world = new World(renderDiv); world = new World(renderDiv);
puppetManager = new PuppetManager(world); scheduler = new SceneScheduler(animate);
clock = new THREE.Timer(); puppetManager = new PuppetManager(world, scheduler.invalidate);
clock.connect(document);
resizeObserver = new ResizeObserver(world.resizeWorld); resizeObserver = new ResizeObserver(() => {
world?.resizeWorld();
lastBoundsUpdate = -Infinity;
scheduler?.invalidate();
});
resizeObserver.observe(renderDiv); resizeObserver.observe(renderDiv);
animate(); document.addEventListener("visibilitychange", syncVisibility);
syncVisibility();
}); });
onDestroy(() => { onDestroy(() => {
cancelAnimationFrame(animationFrameId); scheduler?.dispose();
document.removeEventListener("visibilitychange", syncVisibility);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
clock?.dispose();
puppetManager?.dispose(); puppetManager?.dispose();
world?.dispose(); world?.dispose();
onBoundsChange([]); onBoundsChange([]);
@@ -3,6 +3,8 @@ import type { PuppetRig } from "./visual";
const MIN_IDLE_FACING_OFFSET_DEGREES = 3; const MIN_IDLE_FACING_OFFSET_DEGREES = 3;
const MAX_IDLE_FACING_OFFSET_DEGREES = 5; const MAX_IDLE_FACING_OFFSET_DEGREES = 5;
const ANGLE_EPSILON = 0.001;
const LIMB_DAMPING = -60 * Math.log(0.8);
export class PuppetAnimation { export class PuppetAnimation {
private readonly smoothedGroundPosition = new THREE.Vector3(); private readonly smoothedGroundPosition = new THREE.Vector3();
@@ -17,6 +19,7 @@ export class PuppetAnimation {
this.idleTargetRotationY = this.idleRotationY; this.idleTargetRotationY = this.idleRotationY;
} }
/** Return true until motion and pose settling no longer need another frame. */
update( update(
targetGroundPosition: THREE.Vector3, targetGroundPosition: THREE.Vector3,
isMoving: boolean, isMoving: boolean,
@@ -36,12 +39,12 @@ export class PuppetAnimation {
.sub(this.smoothedGroundPosition); .sub(this.smoothedGroundPosition);
if (this.movementDelta.lengthSq() <= 0.01) { if (this.movementDelta.lengthSq() <= 0.01) {
this.pause(); const relaxing = this.pause(deltaSeconds);
this.smoothedGroundPosition.copy(targetGroundPosition);
this.rig.root.position.copy(targetGroundPosition); this.rig.root.position.copy(targetGroundPosition);
if (!isMoving) { const turning =
this.turnTowards(this.idleTargetRotationY, deltaSeconds); !isMoving && this.turnTowards(this.idleTargetRotationY, deltaSeconds);
} return relaxing || turning;
return;
} }
const alpha = 1 - Math.exp(-12 * deltaSeconds); const alpha = 1 - Math.exp(-12 * deltaSeconds);
@@ -56,15 +59,25 @@ export class PuppetAnimation {
this.animateWalkCycle(elapsedSeconds); this.animateWalkCycle(elapsedSeconds);
} else { } else {
this.turnTowards(this.idleTargetRotationY, deltaSeconds); this.turnTowards(this.idleTargetRotationY, deltaSeconds);
this.pause(); this.pause(deltaSeconds);
} }
return true;
} }
pause() { pause(deltaSeconds: number) {
this.rig.leftLeg.rotation.x *= 0.8; let active = false;
this.rig.rightLeg.rotation.x *= 0.8; const damping = Math.exp(-LIMB_DAMPING * deltaSeconds);
this.rig.leftArm.rotation.x *= 0.8; for (const limb of [
this.rig.rightArm.rotation.x *= 0.8; 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) { walk(elapsedSeconds: number) {
@@ -91,26 +104,18 @@ export class PuppetAnimation {
} }
private turnTowards(targetRotationY: number, deltaSeconds: number) { private turnTowards(targetRotationY: number, deltaSeconds: number) {
this.rig.root.rotation.y = this.dampAngle( const current = this.rig.root.rotation.y;
this.rig.root.rotation.y, const delta = Math.atan2(
targetRotationY, Math.sin(targetRotationY - current),
14, Math.cos(targetRotationY - current),
deltaSeconds,
); );
if (Math.abs(delta) <= ANGLE_EPSILON) {
this.rig.root.rotation.y = targetRotationY;
return false;
} }
this.rig.root.rotation.y =
private dampAngle( current + delta * (1 - Math.exp(-14 * deltaSeconds));
currentRadians: number, return true;
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;
} }
private animateWalkCycle(elapsedSeconds: number) { private animateWalkCycle(elapsedSeconds: number) {
@@ -12,8 +12,9 @@ export class Puppet {
constructor( constructor(
readonly id: string, readonly id: string,
scale: number, scale: number,
onChange: () => void,
) { ) {
this.visual = new PuppetVisual(id); this.visual = new PuppetVisual(id, onChange);
this.root = this.visual.root; this.root = this.visual.root;
this.visual.setScale(scale); this.visual.setScale(scale);
this.visual.setOpacity(1); this.visual.setOpacity(1);
@@ -35,8 +36,7 @@ export class Puppet {
this.visual.setSkin(skinHash); this.visual.setSkin(skinHash);
this.visual.setOpacity(opacity); this.visual.setOpacity(opacity);
if (frozen) { if (frozen) {
this.visual.pause(); return this.visual.pause(deltaSeconds);
return;
} }
world.normalizedPointToGroundPoint( world.normalizedPointToGroundPoint(
@@ -44,7 +44,7 @@ export class Puppet {
state.position.y, state.position.y,
this.targetGroundPosition, this.targetGroundPosition,
); );
this.visual.updateMotion( return this.visual.updateMotion(
this.targetGroundPosition, this.targetGroundPosition,
state.isMoving, state.isMoving,
deltaSeconds, deltaSeconds,
@@ -1,26 +1,32 @@
import type { PuppetState } from "$lib/bindings"; import type { PuppetState } from "$lib/bindings";
import type { PuppetScreenBounds } from "../types"; import type { PuppetScreenBounds, SceneRenderInputs } from "../types";
import type { World } from "../world"; import type { World } from "../world";
import { Puppet } from "."; import { Puppet } from ".";
export class PuppetManager { export class PuppetManager {
private readonly puppets = new Map<string, Puppet>(); private readonly puppets = new Map<string, Puppet>();
constructor(private readonly world: World) {} constructor(
private readonly world: World,
private readonly onChange: () => void,
) {}
update( update(
states: readonly PuppetState[], {
scale: number, puppets: states,
idleOpacity: number, scale,
selectedPuppetId: string | null, idleOpacity,
selectedPuppetId,
skinHashes,
}: SceneRenderInputs,
deltaSeconds: number, deltaSeconds: number,
elapsedSeconds: number, elapsedSeconds: number,
skinHashes: ReadonlyMap<string, string | null>,
) { ) {
this.syncPuppets(states, scale); this.syncPuppets(states, scale);
let active = false;
for (const state of states) { for (const state of states) {
this.puppets const puppetActive = this.puppets
.get(state.id) .get(state.id)
?.update( ?.update(
state, state,
@@ -31,7 +37,9 @@ export class PuppetManager {
skinHashes.get(state.id) ?? null, skinHashes.get(state.id) ?? null,
state.id === selectedPuppetId ? 1 : idleOpacity, state.id === selectedPuppetId ? 1 : idleOpacity,
); );
active = puppetActive || active;
} }
return active;
} }
screenBounds(): PuppetScreenBounds[] { screenBounds(): PuppetScreenBounds[] {
@@ -55,7 +63,7 @@ export class PuppetManager {
for (const state of states) { for (const state of states) {
if (this.puppets.has(state.id)) continue; 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.puppets.set(puppet.id, puppet);
this.world.addObject(puppet.root); this.world.addObject(puppet.root);
} }
@@ -126,6 +126,7 @@ export class PuppetSkin {
constructor( constructor(
private readonly userId: string, private readonly userId: string,
rig: PuppetRig, rig: PuppetRig,
private readonly onChange: () => void,
) { ) {
this.material = new THREE.MeshStandardMaterial({ this.material = new THREE.MeshStandardMaterial({
color: "white", color: "white",
@@ -188,6 +189,7 @@ export class PuppetSkin {
this.depthMaterial.map = texture; this.depthMaterial.map = texture;
this.material.needsUpdate = true; this.material.needsUpdate = true;
this.depthMaterial.needsUpdate = true; this.depthMaterial.needsUpdate = true;
this.onChange();
}) })
.catch((error) => .catch((error) =>
console.error(`Failed to load skin for ${this.userId}`, error), console.error(`Failed to load skin for ${this.userId}`, error),
@@ -39,12 +39,12 @@ export class PuppetVisual {
private readonly animation: PuppetAnimation; private readonly animation: PuppetAnimation;
private readonly skin: PuppetSkin; private readonly skin: PuppetSkin;
constructor(userId: string) { constructor(userId: string, onChange: () => void = () => {}) {
this.rig = createRig(); this.rig = createRig();
this.root = this.rig.root; this.root = this.rig.root;
this.animation = new PuppetAnimation(this.rig); this.animation = new PuppetAnimation(this.rig);
const placeholderMaterial = this.rig.body.material; 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(); if (!Array.isArray(placeholderMaterial)) placeholderMaterial.dispose();
} }
@@ -77,7 +77,7 @@ export class PuppetVisual {
deltaSeconds: number, deltaSeconds: number,
elapsedSeconds: number, elapsedSeconds: number,
) { ) {
this.animation.update( return this.animation.update(
targetGroundPosition, targetGroundPosition,
isMoving, isMoving,
deltaSeconds, deltaSeconds,
@@ -90,8 +90,8 @@ export class PuppetVisual {
this.animation.walk(elapsedSeconds); this.animation.walk(elapsedSeconds);
} }
pause() { pause(deltaSeconds: number) {
this.animation.pause(); return this.animation.pause(deltaSeconds);
} }
dispose() { dispose() {
@@ -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;
};
}
@@ -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<string, string | null>;
};
export type PuppetScreenBounds = { export type PuppetScreenBounds = {
id: string; id: string;
x: number; x: number;
+99
View File
@@ -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<number, FrameRequestCallback>();
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();
}
});