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())?;
// #[cfg(debug_assertions)]
// window.open_devtools();
#[cfg(debug_assertions)]
window.open_devtools();
Ok(window)
}
+51 -28
View File
@@ -2,9 +2,9 @@
import type { PuppetState } from "$lib/bindings";
import { sceneConfiguration } from "$lib/listeners/scene-configuration";
import { onDestroy, onMount } from "svelte";
import * as THREE from "three";
import { SceneScheduler } from "./renderer/scheduler";
import { PuppetManager } from "./renderer/puppet/manager";
import type { PuppetScreenBounds } from "./renderer/types";
import type { PuppetScreenBounds, SceneRenderInputs } from "./renderer/types";
import { World } from "./renderer/world";
let {
@@ -20,41 +20,60 @@
} = $props();
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 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<string>();
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([]);
@@ -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) {
@@ -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,
@@ -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<string, Puppet>();
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<string, string | null>,
) {
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);
}
@@ -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),
@@ -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() {
@@ -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 = {
id: string;
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();
}
});