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
@@ -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