Run behaviors on a fixed step with an On fixed update node
Adding up the frame's dt in On update makes anything that counts in steps depend on the frame rate. A pitch sweep sounds a little different on every run. On fixed update runs 60 times per second of game time with dt exactly 1/60, so the same graph gives the same result every time. - fixedSteps in src/core/time.ts is the one rule for the fixed step: spend the frame in steps of 1/60, keep the rest, catch up 8 steps at most after a hitch. - The physics now counts its steps with fixedSteps too. It behaves as before, and On fixed update runs exactly as often as the engine steps. - The steps run after On mouse wheel and before On update, each as a kind of its own with the usual Order setting. - Each step gets its own context, so a node inside that reads dt (Time, Move, Apply force) gets 1/60 rather than the frame. - A paused game has no steps, also for a behavior that runs while paused. - The engine still takes all of a frame's steps before the behaviors run. A force set in On fixed update acts from the next frame on. Tests: - fixed-update.test.ts (new): 5 tests. Step counting, uneven frames adding up to 60 steps a second, the hitch cap, two steps at 30 Hz before On update with dt 1/60 for the event and for Time, one step every other frame at 120 Hz, none while paused. - event-order, node-catalog, physics-system, game-pause: 430 tests together with the new file, all green. - Counter-check: with the frame's dt inside a step and without the pause guard both world tests fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c7e9769f90
commit
ded0022903
8 files changed
+140
-14
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { exec, num } from '../shared';
|
||||
|
||||
export const OnFixedUpdate: NodeDef = {
|
||||
type: 'OnFixedUpdate',
|
||||
label: 'On fixed update',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs 60 times a second of game time, with dt always 1/60, however long the frame took: a short ' +
|
||||
'frame may have none, a long one several, each before On update. As often as physics steps. ' +
|
||||
'Stands still while the game is paused.',
|
||||
inputs: [],
|
||||
outputs: [exec(), num('dt')],
|
||||
params: [orderParam],
|
||||
hiddenParams: hiddenOrder,
|
||||
};
|
||||
@@ -124,6 +124,7 @@ import { OnCollisionStay } from './events/OnCollisionStay';
|
||||
import { OnDestroy } from './events/OnDestroy';
|
||||
import { OnDisable } from './events/OnDisable';
|
||||
import { OnEnable } from './events/OnEnable';
|
||||
import { OnFixedUpdate } from './events/OnFixedUpdate';
|
||||
import { OnKeyDown } from './events/OnKeyDown';
|
||||
import { OnKeyHeld } from './events/OnKeyHeld';
|
||||
import { OnKeyUp } from './events/OnKeyUp';
|
||||
@@ -159,6 +160,7 @@ import { Variables } from './variable/Variables';
|
||||
export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
OnStart,
|
||||
OnEnable,
|
||||
OnFixedUpdate,
|
||||
OnUpdate,
|
||||
OnDisable,
|
||||
OnDestroy,
|
||||
|
||||
@@ -17,6 +17,25 @@ export function effectiveScale(scale: number, paused: boolean): number {
|
||||
return scale;
|
||||
}
|
||||
|
||||
/** The fixed step: physics and `On fixed update` move this far at a time, whatever the frame took. */
|
||||
export const FIXED_STEP = 1 / 60;
|
||||
/** After a hitch a fixed clock catches up this many steps at most, and forgets the rest. */
|
||||
export const MAX_FIXED_STEPS = 8;
|
||||
|
||||
/**
|
||||
* How many fixed steps a frame of `dt` brings, and what it leaves for the next frame. A short frame
|
||||
* may bring none, a long one several; over time the steps add up to the scaled time that passed.
|
||||
*/
|
||||
export function fixedSteps(backlog: number, dt: number): { steps: number; backlog: number } {
|
||||
let left = backlog + Math.max(0, dt);
|
||||
let steps = 0;
|
||||
while (left >= FIXED_STEP - 1e-9 && steps < MAX_FIXED_STEPS) {
|
||||
left -= FIXED_STEP;
|
||||
steps++;
|
||||
}
|
||||
return { steps, backlog: steps === MAX_FIXED_STEPS ? 0 : Math.max(0, left) };
|
||||
}
|
||||
|
||||
/** How far the clock moves in one frame. */
|
||||
export function timeStep(dt: number, scale: number, paused: boolean): number {
|
||||
return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
import { hasTransformProps } from '../../core/properties/PropertySchema';
|
||||
import { FIXED_STEP, fixedSteps } from '../../core/time';
|
||||
import {
|
||||
type BodyType,
|
||||
colliderMaterial,
|
||||
@@ -66,10 +67,8 @@ import { type Rapier, toMetres, toPixels } from './engine';
|
||||
import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints';
|
||||
import { passesOneWay, reachOf } from './oneWay';
|
||||
|
||||
/** The engine always steps this long, whatever the frame took. */
|
||||
export const PHYSICS_STEP = 1 / 60;
|
||||
/** After a hitch the world catches up this many steps at most, and forgets the rest. */
|
||||
const MAX_STEPS_PER_TICK = 8;
|
||||
/** The engine always steps this long, whatever the frame took – as often as `On fixed update` runs. */
|
||||
export const PHYSICS_STEP = FIXED_STEP;
|
||||
|
||||
/**
|
||||
* One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below
|
||||
@@ -217,19 +216,16 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
this.exits = [];
|
||||
this.sync(world);
|
||||
engine.gravity = this.gravity(world);
|
||||
this.backlog += dt;
|
||||
let steps = 0;
|
||||
const { steps, backlog } = fixedSteps(this.backlog, dt);
|
||||
this.backlog = backlog;
|
||||
const entered = new Set<string>();
|
||||
while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) {
|
||||
for (let step = 0; step < steps; step++) {
|
||||
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current;
|
||||
this.decideOneWay(world);
|
||||
engine.step(this.queue!, this.hooks ?? undefined);
|
||||
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body);
|
||||
this.collect(world, entered);
|
||||
this.backlog -= PHYSICS_STEP;
|
||||
steps++;
|
||||
}
|
||||
this.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog);
|
||||
this.writeBack(this.backlog / PHYSICS_STEP);
|
||||
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
|
||||
this.triggers.update(world);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
import { treeOrder } from '../../core/model/hierarchy';
|
||||
import { resolveObjects } from '../../core/model/instance';
|
||||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||||
import { FIXED_STEP, fixedSteps } from '../../core/time';
|
||||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -182,6 +183,8 @@ export class BehaviorSystem implements System {
|
||||
private restarted: ReadonlySet<string> = new Set();
|
||||
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
|
||||
private pending: StateChange[] = [];
|
||||
/** Game time not yet spent on a fixed step (see `fixedSteps`); counted like the physics' own. */
|
||||
private fixedBacklog = 0;
|
||||
/** Set when the scene may have been edited, so the next sync puts the instances in order again. */
|
||||
private unranked = true;
|
||||
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
|
||||
@@ -630,14 +633,22 @@ export class BehaviorSystem implements System {
|
||||
y: pointer.y,
|
||||
})
|
||||
: [],
|
||||
() => pulsesOf(ticking, 'OnUpdate', { dt }),
|
||||
];
|
||||
for (const kind of kinds) {
|
||||
this.runPulses(kind(), contextOf, true);
|
||||
const run = (pulses: Pulse[], context = contextOf) => {
|
||||
this.runPulses(pulses, context, true);
|
||||
// After the kind, not inside it: a state entered halfway through `On update` must not push
|
||||
// its events between the nodes that are still to run.
|
||||
this.flushStateChanges();
|
||||
};
|
||||
for (const kind of kinds) run(kind());
|
||||
if (!frozen) {
|
||||
const fixed = fixedSteps(this.fixedBacklog, dt);
|
||||
this.fixedBacklog = fixed.backlog;
|
||||
// A context of its own per step: what a node reads as `dt` inside is the step, not the frame.
|
||||
for (let step = 0; step < fixed.steps; step++)
|
||||
run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP));
|
||||
}
|
||||
run(pulsesOf(ticking, 'OnUpdate', { dt }));
|
||||
this.reap(world);
|
||||
this.flushStateChanges();
|
||||
this.persist();
|
||||
|
||||
Reference in new issue
Block a user