Skip to content

Commit

Permalink
fix: Camera interpolation on fixed update (#2868)
Browse files Browse the repository at this point in the history
Fixes an issue spotted by @mattjennings where the camera is not interpolated during fixed update causing noticeable stutter on the screen with camera locked strategies
  • Loading branch information
eonarheim authored Jan 5, 2024
1 parent 3c820fe commit 1fce21f
Show file tree
Hide file tree
Showing 7 changed files with 73 additions and 31 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Fixed

- Fixed issue where the `Camera` wasn't interpolated during fixed update, which is very noticeable when using camera locked strategies
- Fixed issue where `IsometricMap` would debug draw collision geometry on non-solid tiles
- Fixed issue where `CompositeCollider` offset was undefined if not set
- Fixed Actor so it receives `predraw`/`postdraw` events per the advertised strongly typed events
Expand Down
36 changes: 30 additions & 6 deletions src/engine/Camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export enum Axis {
*/
export class LockCameraToActorStrategy implements CameraStrategy<Actor> {
constructor(public target: Actor) {}
public action = (target: Actor, _cam: Camera, _eng: Engine, _delta: number) => {
public action = (target: Actor, camera: Camera, engine: Engine, delta: number) => {
const center = target.center;
return center;
};
Expand Down Expand Up @@ -314,6 +314,14 @@ export class Camera implements CanUpdate, CanInitialize {
this._pos = watchAny(vec, () => (this._posChanged = true));
this._posChanged = true;
}
/**
* Interpolated camera position if more draws are running than updates
*
* Enabled when `Engine.fixedUpdateFps` is enabled
*/
public interpolatedPos: Vector = this.pos.clone();

private _oldPos = this.pos.clone();

/**
* Get or set the camera's velocity
Expand Down Expand Up @@ -622,7 +630,7 @@ export class Camera implements CanUpdate, CanInitialize {

// Ensure camera tx is correct
// Run update twice to ensure properties are init'd
this.updateTransform();
this.updateTransform(this.pos);

// Run strategies for first frame
this.runStrategies(engine, engine.clock.elapsed());
Expand All @@ -632,7 +640,8 @@ export class Camera implements CanUpdate, CanInitialize {

// It's important to update the camera after strategies
// This prevents jitter
this.updateTransform();
this.updateTransform(this.pos);
this.pos.clone(this._oldPos);

this.onInitialize(engine);
this.events.emit('initialize', new InitializeEvent(engine, this));
Expand Down Expand Up @@ -693,6 +702,7 @@ export class Camera implements CanUpdate, CanInitialize {
public update(engine: Engine, delta: number) {
this._initialize(engine);
this._preupdate(engine, delta);
this.pos.clone(this._oldPos);

// Update placements based on linear algebra
this.pos = this.pos.add(this.vel.scale(delta / 1000));
Expand Down Expand Up @@ -760,7 +770,7 @@ export class Camera implements CanUpdate, CanInitialize {

// It's important to update the camera after strategies
// This prevents jitter
this.updateTransform();
this.updateTransform(this.pos);

this._postupdate(engine, delta);
}
Expand All @@ -770,14 +780,28 @@ export class Camera implements CanUpdate, CanInitialize {
* @param ctx Canvas context to apply transformations
*/
public draw(ctx: ExcaliburGraphicsContext): void {
// default to the current position
this.pos.clone(this.interpolatedPos);

// interpolation if fixed update is on
// must happen on the draw, because more draws are potentially happening than updates
if (this._engine.fixedUpdateFps) {
const blend = this._engine.currentFrameLagMs / (1000 / this._engine.fixedUpdateFps);
const interpolatedPos = this.pos.scale(blend).add(
this._oldPos.scale(1.0 - blend)
);
interpolatedPos.clone(this.interpolatedPos);
this.updateTransform(interpolatedPos);
}

ctx.multiply(this.transform);
}

public updateTransform() {
public updateTransform(pos: Vector) {
// center the camera
const newCanvasWidth = this._screen.resolution.width / this.zoom;
const newCanvasHeight = this._screen.resolution.height / this.zoom;
const cameraPos = vec(-this.x + newCanvasWidth / 2 + this._xShake, -this.y + newCanvasHeight / 2 + this._yShake);
const cameraPos = vec(-pos.x + newCanvasWidth / 2 + this._xShake, -pos.y + newCanvasHeight / 2 + this._yShake);

// Calculate camera transform
this.transform.reset();
Expand Down
10 changes: 5 additions & 5 deletions src/engine/Collision/BodyComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable<Body
public readonly id: Id<'body'> = createId('body', BodyComponent._ID++);
public events = new EventEmitter();

private _oldTransform = new Transform();
public oldTransform = new Transform();

/**
* Indicates whether the old transform has been captured at least once for interpolation
Expand Down Expand Up @@ -250,7 +250,7 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable<Body
* The position of the actor last frame (x, y) in pixels
*/
public get oldPos(): Vector {
return this._oldTransform.pos;
return this.oldTransform.pos;
}

/**
Expand Down Expand Up @@ -301,7 +301,7 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable<Body
* Gets/sets the rotation of the body from the last frame.
*/
public get oldRotation(): number {
return this._oldTransform.rotation;
return this.oldTransform.rotation;
}

/**
Expand Down Expand Up @@ -330,7 +330,7 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable<Body
* The scale of the actor last frame
*/
public get oldScale(): Vector {
return this._oldTransform.scale;
return this.oldTransform.scale;
}

/**
Expand Down Expand Up @@ -427,7 +427,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.get().clone(this._oldTransform);
this.transform.get().clone(this.oldTransform);
this.oldVel.setTo(this.vel.x, this.vel.y);
this.oldAcc.setTo(this.acc.x, this.acc.y);
}
Expand Down
4 changes: 3 additions & 1 deletion src/engine/Collision/Colliders/CompositeCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export class CompositeCollider extends Collider {
}
}

// TODO composite offset


clearColliders() {
this._colliders = [];
}
Expand All @@ -51,7 +54,6 @@ export class CompositeCollider extends Collider {
}

get worldPos(): Vector {
// TODO transform component world pos
return this._transform?.pos ?? Vector.Zero;
}

Expand Down
26 changes: 8 additions & 18 deletions src/engine/Graphics/GraphicsSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { ParallaxComponent } from './ParallaxComponent';
import { CoordPlane } from '../Math/coord-plane';
import { BodyComponent } from '../Collision/BodyComponent';
import { FontCache } from './FontCache';
import { PostDrawEvent, PreDrawEvent } from '..';
import { PostDrawEvent, PreDrawEvent, Transform } from '..';
import { blendTransform } from './TransformInterpolation';

export class GraphicsSystem extends System<TransformComponent | GraphicsComponent> {
public readonly types = ['ex.transform', 'ex.graphics'] as const;
Expand Down Expand Up @@ -211,6 +212,7 @@ export class GraphicsSystem extends System<TransformComponent | GraphicsComponen
}
}

private _targetInterpolationTransform = new Transform();
/**
* This applies the current entity transform to the graphics context
* @param entity
Expand All @@ -220,34 +222,22 @@ export class GraphicsSystem extends System<TransformComponent | GraphicsComponen
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;
let tx = transform.get();
if (optionalBody) {
if (this._engine.fixedUpdateFps &&
optionalBody.__oldTransformCaptured &&
optionalBody.enableFixedUpdateInterpolate) {

// Interpolate graphics if needed
const blend = this._engine.currentFrameLagMs / (1000 / this._engine.fixedUpdateFps);
interpolatedPos = transform.pos.scale(blend).add(
optionalBody.oldPos.scale(1.0 - blend)
);
interpolatedScale = transform.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(transform.rotation);
const sine = (1.0 - blend) * Math.sin(optionalBody.oldRotation) + blend * Math.sin(transform.rotation);
interpolatedRotation = Math.atan2(sine, cosine);
tx = blendTransform(optionalBody.oldTransform, transform.get(), blend, this._targetInterpolationTransform);
}
}

if (transform) {
this._graphicsContext.z = transform.z;
this._graphicsContext.translate(interpolatedPos.x, interpolatedPos.y);
this._graphicsContext.scale(interpolatedScale.x, interpolatedScale.y);
this._graphicsContext.rotate(interpolatedRotation);
this._graphicsContext.translate(tx.pos.x, tx.pos.y);
this._graphicsContext.scale(tx.scale.x, tx.scale.y);
this._graphicsContext.rotate(tx.rotation);
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/engine/Graphics/TransformInterpolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Transform } from '../Math/transform';

/**
* Blend 2 transforms for interpolation
*/
export function blendTransform(oldTx: Transform, newTx: Transform, blend: number, target?: Transform): Transform {
let interpolatedPos = newTx.pos;
let interpolatedScale = newTx.scale;
let interpolatedRotation = newTx.rotation;

interpolatedPos = newTx.pos.scale(blend).add(
oldTx.pos.scale(1.0 - blend)
);
interpolatedScale = newTx.scale.scale(blend).add(
oldTx.scale.scale(1.0 - blend)
);
// Rotational lerp https://stackoverflow.com/a/30129248
const cosine = (1.0 - blend) * Math.cos(oldTx.rotation) + blend * Math.cos(newTx.rotation);
const sine = (1.0 - blend) * Math.sin(oldTx.rotation) + blend * Math.sin(newTx.rotation);
interpolatedRotation = Math.atan2(sine, cosine);

const tx = target ?? new Transform();
tx.setTransform(interpolatedPos, interpolatedRotation, interpolatedScale);
return tx;
}
2 changes: 1 addition & 1 deletion src/spec/CameraSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('A camera', () => {
Camera._initialize(engine);
Camera.rotation = Math.PI / 2;

Camera.updateTransform();
Camera.updateTransform(Camera.pos);

expect(Camera.transform.getRotation()).toBe(Math.PI / 2);
});
Expand Down

0 comments on commit 1fce21f

Please sign in to comment.