limit FPS of render on idle
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user