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 { OnDestroy } from './events/OnDestroy';
|
||||||
import { OnDisable } from './events/OnDisable';
|
import { OnDisable } from './events/OnDisable';
|
||||||
import { OnEnable } from './events/OnEnable';
|
import { OnEnable } from './events/OnEnable';
|
||||||
|
import { OnFixedUpdate } from './events/OnFixedUpdate';
|
||||||
import { OnKeyDown } from './events/OnKeyDown';
|
import { OnKeyDown } from './events/OnKeyDown';
|
||||||
import { OnKeyHeld } from './events/OnKeyHeld';
|
import { OnKeyHeld } from './events/OnKeyHeld';
|
||||||
import { OnKeyUp } from './events/OnKeyUp';
|
import { OnKeyUp } from './events/OnKeyUp';
|
||||||
@@ -159,6 +160,7 @@ import { Variables } from './variable/Variables';
|
|||||||
export const BUILT_IN_NODES: readonly NodeDef[] = [
|
export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||||
OnStart,
|
OnStart,
|
||||||
OnEnable,
|
OnEnable,
|
||||||
|
OnFixedUpdate,
|
||||||
OnUpdate,
|
OnUpdate,
|
||||||
OnDisable,
|
OnDisable,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
|||||||
@@ -17,6 +17,25 @@ export function effectiveScale(scale: number, paused: boolean): number {
|
|||||||
return scale;
|
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. */
|
/** How far the clock moves in one frame. */
|
||||||
export function timeStep(dt: number, scale: number, paused: boolean): number {
|
export function timeStep(dt: number, scale: number, paused: boolean): number {
|
||||||
return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused);
|
return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
shapesOverlap,
|
shapesOverlap,
|
||||||
} from '../../core/properties/collider';
|
} from '../../core/properties/collider';
|
||||||
import { hasTransformProps } from '../../core/properties/PropertySchema';
|
import { hasTransformProps } from '../../core/properties/PropertySchema';
|
||||||
|
import { FIXED_STEP, fixedSteps } from '../../core/time';
|
||||||
import {
|
import {
|
||||||
type BodyType,
|
type BodyType,
|
||||||
colliderMaterial,
|
colliderMaterial,
|
||||||
@@ -66,10 +67,8 @@ import { type Rapier, toMetres, toPixels } from './engine';
|
|||||||
import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints';
|
import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints';
|
||||||
import { passesOneWay, reachOf } from './oneWay';
|
import { passesOneWay, reachOf } from './oneWay';
|
||||||
|
|
||||||
/** The engine always steps this long, whatever the frame took. */
|
/** The engine always steps this long, whatever the frame took – as often as `On fixed update` runs. */
|
||||||
export const PHYSICS_STEP = 1 / 60;
|
export const PHYSICS_STEP = FIXED_STEP;
|
||||||
/** After a hitch the world catches up this many steps at most, and forgets the rest. */
|
|
||||||
const MAX_STEPS_PER_TICK = 8;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below
|
* 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.exits = [];
|
||||||
this.sync(world);
|
this.sync(world);
|
||||||
engine.gravity = this.gravity(world);
|
engine.gravity = this.gravity(world);
|
||||||
this.backlog += dt;
|
const { steps, backlog } = fixedSteps(this.backlog, dt);
|
||||||
let steps = 0;
|
this.backlog = backlog;
|
||||||
const entered = new Set<string>();
|
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;
|
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current;
|
||||||
this.decideOneWay(world);
|
this.decideOneWay(world);
|
||||||
engine.step(this.queue!, this.hooks ?? undefined);
|
engine.step(this.queue!, this.hooks ?? undefined);
|
||||||
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body);
|
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body);
|
||||||
this.collect(world, entered);
|
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.writeBack(this.backlog / PHYSICS_STEP);
|
||||||
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
|
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
|
||||||
this.triggers.update(world);
|
this.triggers.update(world);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties';
|
|||||||
import { treeOrder } from '../../core/model/hierarchy';
|
import { treeOrder } from '../../core/model/hierarchy';
|
||||||
import { resolveObjects } from '../../core/model/instance';
|
import { resolveObjects } from '../../core/model/instance';
|
||||||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
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 AudioSink, SILENT_AUDIO } from './Audio';
|
||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
import type { World } from '../World';
|
import type { World } from '../World';
|
||||||
@@ -182,6 +183,8 @@ export class BehaviorSystem implements System {
|
|||||||
private restarted: ReadonlySet<string> = new Set();
|
private restarted: ReadonlySet<string> = new Set();
|
||||||
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
|
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
|
||||||
private pending: StateChange[] = [];
|
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. */
|
/** Set when the scene may have been edited, so the next sync puts the instances in order again. */
|
||||||
private unranked = true;
|
private unranked = true;
|
||||||
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
|
/** 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,
|
y: pointer.y,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
() => pulsesOf(ticking, 'OnUpdate', { dt }),
|
|
||||||
];
|
];
|
||||||
for (const kind of kinds) {
|
const run = (pulses: Pulse[], context = contextOf) => {
|
||||||
this.runPulses(kind(), contextOf, true);
|
this.runPulses(pulses, context, true);
|
||||||
// After the kind, not inside it: a state entered halfway through `On update` must not push
|
// 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.
|
// its events between the nodes that are still to run.
|
||||||
this.flushStateChanges();
|
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.reap(world);
|
||||||
this.flushStateChanges();
|
this.flushStateChanges();
|
||||||
this.persist();
|
this.persist();
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ describe('the order rule', () => {
|
|||||||
const events = BUILT_IN_NODES.filter(
|
const events = BUILT_IN_NODES.filter(
|
||||||
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
|
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
|
||||||
);
|
);
|
||||||
expect(events).toHaveLength(17);
|
expect(events).toHaveLength(18);
|
||||||
for (const def of events) {
|
for (const def of events) {
|
||||||
expect(
|
expect(
|
||||||
def.params.map((p) => p.key),
|
def.params.map((p) => p.key),
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* `On fixed update`: 60 steps a second of game time, each with dt 1/60, however the frames fall –
|
||||||
|
* counted by the same rule the physics steps by.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { FIXED_STEP, fixedSteps, MAX_FIXED_STEPS } from '../../src/core/time';
|
||||||
|
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
||||||
|
|
||||||
|
describe('fixed steps', () => {
|
||||||
|
it('spends a frame in steps of 1/60 and keeps the rest for the next', () => {
|
||||||
|
expect(fixedSteps(0, FIXED_STEP).steps).toBe(1);
|
||||||
|
expect(fixedSteps(0, 1 / 30).steps).toBe(2);
|
||||||
|
expect(fixedSteps(0, 0).steps).toBe(0);
|
||||||
|
const half = fixedSteps(0, 1 / 120);
|
||||||
|
expect(half.steps).toBe(0);
|
||||||
|
expect(fixedSteps(half.backlog, 1 / 120).steps).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds up to the time that passed, however uneven the frames', () => {
|
||||||
|
let backlog = 0;
|
||||||
|
let steps = 0;
|
||||||
|
for (let i = 0; i < 20; i++)
|
||||||
|
for (const dt of [0.01, 0.02, 0.005, 0.015]) {
|
||||||
|
const next = fixedSteps(backlog, dt);
|
||||||
|
backlog = next.backlog;
|
||||||
|
steps += next.steps;
|
||||||
|
}
|
||||||
|
expect(steps).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches up a hitch only so far and forgets the rest', () => {
|
||||||
|
expect(fixedSteps(0, 1)).toEqual({ steps: MAX_FIXED_STEPS, backlog: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A behavior logging `fixed <dt of the event> <dt a node reads>` per step and `update` per frame. */
|
||||||
|
function stepped(runWhilePaused = false) {
|
||||||
|
const f = makeSpriteScene('Box');
|
||||||
|
const behavior = makeBehavior(f, 'Engine');
|
||||||
|
if (runWhilePaused) f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'runWhilePaused', true));
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const fixed = edit.addNode('OnFixedUpdate');
|
||||||
|
const eventDt = edit.addNode('Log');
|
||||||
|
const nodeDt = edit.addNode('Log');
|
||||||
|
const update = edit.addNode('OnUpdate');
|
||||||
|
edit.wire([
|
||||||
|
[fixed, 'exec', eventDt, 'exec'],
|
||||||
|
[fixed, 'dt', eventDt, 'message'],
|
||||||
|
[eventDt, 'exec', nodeDt, 'exec'],
|
||||||
|
[edit.addNode('Time'), 'dt', nodeDt, 'message'],
|
||||||
|
[update, 'exec', edit.addNode('Log', { message: 'update' }), 'exec'],
|
||||||
|
]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
return startWorld(f, { behaviors: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP = String(FIXED_STEP);
|
||||||
|
|
||||||
|
describe('On fixed update', () => {
|
||||||
|
it('runs once per 1/60 of game time, before On update, and dt inside is the step', () => {
|
||||||
|
const { world, frame } = stepped();
|
||||||
|
frame(1 / 30);
|
||||||
|
expect(world.log).toEqual([STEP, STEP, STEP, STEP, 'update']);
|
||||||
|
|
||||||
|
world.log.length = 0;
|
||||||
|
frame(1 / 120);
|
||||||
|
expect(world.log).toEqual(['update']);
|
||||||
|
frame(1 / 120);
|
||||||
|
expect(world.log).toEqual(['update', STEP, STEP, 'update']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands still while the game is paused, also for a behavior that runs then', () => {
|
||||||
|
const { world, frame } = stepped(true);
|
||||||
|
world.paused = true;
|
||||||
|
frame(1 / 30);
|
||||||
|
frame(1 / 30);
|
||||||
|
expect(world.log).toEqual(['update', 'update']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2131,6 +2131,7 @@ const EVENT_NODES: Record<string, string> = {
|
|||||||
OnClick: 'nodes.test.ts',
|
OnClick: 'nodes.test.ts',
|
||||||
OnMouseWheel: 'clip-areas.test.ts',
|
OnMouseWheel: 'clip-areas.test.ts',
|
||||||
OnAnimationFinished: 'animation.test.ts',
|
OnAnimationFinished: 'animation.test.ts',
|
||||||
|
OnFixedUpdate: 'fixed-update.test.ts',
|
||||||
OnStateEntered: 'state-machine.test.ts',
|
OnStateEntered: 'state-machine.test.ts',
|
||||||
OnStateExited: 'state-machine.test.ts',
|
OnStateExited: 'state-machine.test.ts',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user