Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement Fixed timestep #2339

Merged
merged 8 commits into from
Jun 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ This project adheres to [Semantic Versioning](http://semver.org/).
-

### Added
- Added new fixed update step to Excalibur! This allows developers to configure a fixed FPS for the update loop. One advantage of setting a fix update is that you will have a more consistent and predictable physics simulation. Excalibur graphics will be interpolated automatically to avoid any jitter in the fixed update.
* If the fixed update FPS is greater than the display FPS, excalibur will run multiple updates in a row (at the configured update elapsed) to catch up, for example there could be X updates and 1 draw each clock step.
* If the fixed update FPS is less than the display FPS, excalibur will skip updates until it meets the desired FPS, for example there could be no update for 1 draw each clock step.
```typescript
const game = new ex.Engine({
fixedUpdateFps: 20 // 20 fps fixed update, or a fixed update delta of 50 milliseconds
});
// turn off interpolation on a per actor basis
const actor = new ex.Actor({...});
actor.body.enableFixedUpdateInterpolate = false;
game.add(game);
```

- Allowed setting playback `ex.Sound.duration` which will limit the amount of time that a clip plays from the current playback position.
- Added a new lightweight `ex.StateMachine` type for building finite state machines
```typescript
Expand Down
7 changes: 4 additions & 3 deletions sandbox/tests/side-collision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
var game = new ex.Engine({
width: 400,
height: 400,
fixedUpdateFps: 10,
displayMode: ex.DisplayMode.FitScreenAndFill
});

Expand Down Expand Up @@ -40,7 +41,7 @@ class Player2 extends ex.Actor {
// After main update, once per frame execute this code
onPreUpdate(engine) {
// Reset x velocity
// this.vel.x = 0;
this.vel.x = 0;

// Player input
if (engine.input.keyboard.isHeld(ex.Input.Keys.Left)) {
Expand Down Expand Up @@ -83,7 +84,7 @@ game.add(
collisionType: ex.CollisionType.Fixed
})
);

game.add(new Player2());
var player2 = new Player2();
game.add(player2);

game.start();
1 change: 1 addition & 0 deletions sandbox/tests/side-collision2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
var game = new ex.Engine({
width: 400,
height: 400,
fixedUpdateFps: 10,
displayMode: ex.DisplayMode.FitScreenAndFill
});

Expand Down
12 changes: 12 additions & 0 deletions src/engine/Collision/BodyComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable<Body

private _oldTransform = Matrix.identity();

/**
* Indicates whether the old transform has been captured at least once for interpolation
* @internal
*/
public __oldTransformCaptured: boolean = false;

/**
* Enable or disabled the fixed update interpolation, by default interpolation is on.
*/
public enableFixedUpdateInterpolate = true;

constructor(options?: BodyComponentOptions) {
super();
if (options) {
Expand Down Expand Up @@ -371,6 +382,7 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable<Body
*/
public captureOldTransform() {
// Capture old values before integration step updates them
this.__oldTransformCaptured = true;
this.transform.getGlobalMatrix().clone(this._oldTransform);
this.oldVel.setTo(this.vel.x, this.vel.y);
this.oldAcc.setTo(this.acc.x, this.acc.y);
Expand Down
58 changes: 54 additions & 4 deletions src/engine/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,19 @@ export interface EngineOptions {
*/
maxFps?: number;

/**
* Optionally configure a fixed update fps, this can be desireable if you need the physics simulation to be very stable. When set
* the update step and physics will use the same elapsed time for each tick even if the graphical framerate drops. In order for the
* simulation to be correct, excalibur will run multiple updates in a row (at the configured update elapsed) to catch up, for example
* there could be X updates and 1 draw each clock step.
*
* **NOTE:** This does come at a potential perf cost because each catch-up update will need to be run if the fixed rate is greater than
* the current instantaneous framerate, or perf gain if the fixed rate is less than the current framerate.
*
* By default is unset and updates will use the current instantaneous framerate with 1 update and 1 draw each clock step.
*/
fixedUpdateFps?: number

/**
* Default `true`, optionally configure excalibur to use optimal draw call sorting, to opt out set this to `false`.
*
Expand Down Expand Up @@ -258,6 +271,19 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw {
*/
public maxFps: number = Number.POSITIVE_INFINITY;

/**
* Optionally configure a fixed update fps, this can be desireable if you need the physics simulation to be very stable. When set
* the update step and physics will use the same elapsed time for each tick even if the graphical framerate drops. In order for the
* simulation to be correct, excalibur will run multiple updates in a row (at the configured update elapsed) to catch up, for example
* there could be X updates and 1 draw each clock step.
*
* **NOTE:** This does come at a potential perf cost because each catch-up update will need to be run if the fixed rate is greater than
* the current instantaneous framerate, or perf gain if the fixed rate is less than the current framerate.
*
* By default is unset and updates will use the current instantaneous framerate with 1 update and 1 draw each clock step.
*/
public fixedUpdateFps?: number;

/**
* Direct access to the excalibur clock
*/
Expand Down Expand Up @@ -699,6 +725,7 @@ O|===|* >________________>\n\
}

this.maxFps = options.maxFps ?? this.maxFps;
this.fixedUpdateFps = options.fixedUpdateFps ?? this.fixedUpdateFps;

this.clock = new StandardClock({
maxFps: this.maxFps,
Expand Down Expand Up @@ -1198,7 +1225,7 @@ O|===|* >________________>\n\
* Draws the entire game
* @param delta Number of milliseconds elapsed since the last draw.
*/
private _draw(delta: number) {
private _draw(delta: number, _lag: number) {
this.graphicsContext.beginDrawLifecycle();
this.graphicsContext.clear();
this._predraw(this.graphicsContext, delta);
Expand All @@ -1213,7 +1240,7 @@ O|===|* >________________>\n\
// TODO move to graphics systems?
this.graphicsContext.backgroundColor = this.backgroundColor;

this.currentScene.draw(this.graphicsContext, delta);
this.currentScene.draw(this.graphicsContext, delta, _lag);

this._postdraw(this.graphicsContext, delta);

Expand Down Expand Up @@ -1340,9 +1367,21 @@ O|===|* >________________>\n\
return this._isReadyPromise;
}

/**
* Returns the current frames elapsed milliseconds
*/
public currentFrameElapsedMs = 0;

/**
* Returns the current frame lag when in fixed update mode
*/
public currentFrameLagMs = 0;

private _lagMs = 0;
private _mainloop(elapsed: number) {
this.emit('preframe', new PreFrameEvent(this, this.stats.prevFrame));
const delta = elapsed * this.timescale;
this.currentFrameElapsedMs = delta;

// reset frame stats (reuse existing instances)
const frameId = this.stats.prevFrame.id + 1;
Expand All @@ -1353,9 +1392,20 @@ O|===|* >________________>\n\
GraphicsDiagnostics.clear();

const beforeUpdate = this.clock.now();
this._update(delta);
const fixedTimestepMs = 1000 / this.fixedUpdateFps;
if (this.fixedUpdateFps) {
this._lagMs += delta;
while (this._lagMs >= fixedTimestepMs) {
this._update(fixedTimestepMs);
this._lagMs -= fixedTimestepMs;
}
} else {
this._update(delta);
}
const afterUpdate = this.clock.now();
this._draw(delta);
// TODO interpolate offset
this.currentFrameLagMs = this._lagMs;
this._draw(delta, this._lagMs);
const afterDraw = this.clock.now();

this.stats.currFrame.duration.update = afterUpdate - beforeUpdate;
Expand Down
33 changes: 29 additions & 4 deletions src/engine/Graphics/GraphicsSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Engine } from '../Engine';
import { GraphicsGroup } from '.';
import { Particle } from '../Particles';
import { ParallaxComponent } from './ParallaxComponent';
import { BodyComponent } from '../Collision/BodyComponent';

export class GraphicsSystem extends System<TransformComponent | GraphicsComponent> {
public readonly types = ['ex.transform', 'ex.graphics'] as const;
Expand Down Expand Up @@ -107,7 +108,7 @@ export class GraphicsSystem extends System<TransformComponent | GraphicsComponen
this._graphicsContext.translate(parallaxOffset.x, parallaxOffset.y);
}

// Position the entity
// Position the entity + estimate lag
this._applyTransform(entity);

// Optionally run the onPreDraw graphics lifecycle draw
Expand Down Expand Up @@ -183,11 +184,35 @@ export class GraphicsSystem extends System<TransformComponent | GraphicsComponen
const ancestors = entity.getAncestors();
for (const ancestor of ancestors) {
const transform = ancestor?.get(TransformComponent);
const optionalBody = ancestor?.get(BodyComponent);
let interpolatedPos = transform.pos;
let interpolatedScale = transform.scale;
let interpolatedRotation = transform.rotation;
if (optionalBody) {
if (this._engine.fixedUpdateFps &&
optionalBody.__oldTransformCaptured &&
optionalBody.enableFixedUpdateInterpolate) {

// Interpolate graphics if needed
const blend = this._engine.currentFrameLagMs / (1000 / this._engine.fixedUpdateFps);
interpolatedPos = optionalBody.pos.scale(blend).add(
optionalBody.oldPos.scale(1.0 - blend)
);
interpolatedScale = optionalBody.scale.scale(blend).add(
optionalBody.oldScale.scale(1.0 - blend)
);
// Rotational lerp https://stackoverflow.com/a/30129248
const cosine = (1.0 - blend) * Math.cos(optionalBody.oldRotation) + blend * Math.cos(optionalBody.rotation);
const sine = (1.0 - blend) * Math.sin(optionalBody.oldRotation) + blend * Math.sin(optionalBody.rotation);
interpolatedRotation = Math.atan2(sine, cosine);
}
}

if (transform) {
this._graphicsContext.z = transform.z;
this._graphicsContext.translate(transform.pos.x, transform.pos.y);
this._graphicsContext.scale(transform.scale.x, transform.scale.y);
this._graphicsContext.rotate(transform.rotation);
this._graphicsContext.translate(interpolatedPos.x, interpolatedPos.y);
this._graphicsContext.scale(interpolatedScale.x, interpolatedScale.y);
this._graphicsContext.rotate(interpolatedRotation);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/engine/Scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,10 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact
* @param ctx The current rendering context
* @param delta The number of milliseconds since the last draw
*/
public draw(ctx: ExcaliburGraphicsContext, delta: number) {
public draw(ctx: ExcaliburGraphicsContext, delta: number, _lag?: number) {
this._predraw(ctx, delta);

this.world.update(SystemType.Draw, delta);
this.world.update(SystemType.Draw, _lag ?? delta);

if (this.engine?.isDebug) {
this.debugDraw(ctx);
Expand Down
38 changes: 38 additions & 0 deletions src/spec/EngineSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,44 @@ describe('The engine', () => {
expect(engine.useCanvas2DFallback).toHaveBeenCalled();
});

it('can use a fixed update fps and can catch up', async () => {
engine = TestUtils.engine({
fixedUpdateFps: 30
});

const clock = engine.clock as ex.TestClock;

await TestUtils.runToReady(engine);

spyOn(engine as any, '_update');
spyOn(engine as any, '_draw');

clock.step(101);

expect((engine as any)._update).toHaveBeenCalledTimes(3);
expect((engine as any)._draw).toHaveBeenCalledTimes(1);
});

it('can use a fixed update fps and will skip updates', async () => {
engine = TestUtils.engine({
fixedUpdateFps: 30
});

const clock = engine.clock as ex.TestClock;

await TestUtils.runToReady(engine);

spyOn(engine as any, '_update');
spyOn(engine as any, '_draw');

clock.step(16);
clock.step(16);
clock.step(16);

expect((engine as any)._update).toHaveBeenCalledTimes(1);
expect((engine as any)._draw).toHaveBeenCalledTimes(3);
});

it('can flag on to the canvas fallback', async () => {
engine = TestUtils.engine({
suppressPlayButton: false
Expand Down
63 changes: 63 additions & 0 deletions src/spec/GraphicsSystemSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,67 @@ describe('A Graphics ECS System', () => {
engine.graphicsContext.flush();
await expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('src/spec/images/GraphicsSystemSpec/graphics-system.png');
});

it('will interpolate body graphics when fixed update is enabled', async () => {
const game = TestUtils.engine({
fixedUpdateFps: 30
});

await TestUtils.runToReady(game);

const actor = new ex.Actor({
x: 100,
y: 100,
rotation: 2,
scale: ex.vec(2, 2)
});

actor.body.__oldTransformCaptured = true;

spyOn(game.graphicsContext, 'translate');
spyOn(game.graphicsContext, 'rotate');
spyOn(game.graphicsContext, 'scale');

const graphicsSystem = new ex.GraphicsSystem();
graphicsSystem.initialize(game.currentScene);
graphicsSystem.preupdate();
graphicsSystem.notify(new ex.AddedEntity(actor));

game.currentFrameLagMs = 8; // current lag in a 30 fps frame
graphicsSystem.update([actor], 30);

expect(game.graphicsContext.translate).toHaveBeenCalledWith(24, 24);
});

it('will not interpolate body graphics if disabled', async () => {
const game = TestUtils.engine({
fixedUpdateFps: 30
});

await TestUtils.runToReady(game);

const actor = new ex.Actor({
x: 100,
y: 100,
rotation: 2,
scale: ex.vec(2, 2)
});

actor.body.__oldTransformCaptured = true;

spyOn(game.graphicsContext, 'translate');
spyOn(game.graphicsContext, 'rotate');
spyOn(game.graphicsContext, 'scale');

const graphicsSystem = new ex.GraphicsSystem();
graphicsSystem.initialize(game.currentScene);
graphicsSystem.preupdate();
graphicsSystem.notify(new ex.AddedEntity(actor));

actor.body.enableFixedUpdateInterpolate = false;
game.currentFrameLagMs = 8; // current lag in a 30 fps frame
graphicsSystem.update([actor], 30);

expect(game.graphicsContext.translate).toHaveBeenCalledWith(100, 100);
});
});