Skip to content

Commit

Permalink
feat: Implement Contact Bias Solver order for seams + Deprecate ex.Ph…
Browse files Browse the repository at this point in the history
…ysics Static (#2926)

This PR implements arcade contact solver bias, so you can choose to solve horizontal or vertical first. Or to defer to the default order by distance.

Additionally this PR marks the `ex.Physics` static configuration object as deprecated and will be removed in v0.30. It still will work to configure physics behavior. Now the new way to configure physics will be where everything else is configured, the `ex.Engine({..})` constructor.

```typescript
const engine = new ex.Engine({
    ...
    physics: {
      solver: ex.SolverStrategy.Realistic,
      arcade: {
        contactSolveBias: ex.ContactSolveBias.VerticalFirst
      },
    }
  })
```


Without the bias

`ex.Physics.arcadeContactSolveBias = ex.ContactSolveBias.None;`



https://github.com/excaliburjs/Excalibur/assets/612071/0d7c1217-911d-4791-8425-b45d06f7da8e



Using the bias

`ex.Physics.arcadeContactSolveBias = ex.ContactSolveBias.VerticalFirst;`

https://github.com/excaliburjs/Excalibur/assets/612071/8873bedf-ea07-4e85-9376-8c1f8ae56089
  • Loading branch information
eonarheim authored Feb 8, 2024
1 parent a18ad6a commit a6e666f
Show file tree
Hide file tree
Showing 34 changed files with 869 additions and 129 deletions.
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Breaking Changes

- `ex.Physics` static is marked as deprecated, configuring these setting will move to the `ex.Engine({...})` constructor
```typescript
const engine = new ex.Engine({
...
physics: {
solver: ex.SolverStrategy.Realistic,
gravity: ex.vec(0, 20),
arcade: {
contactSolveBias: ex.ContactSolveBias.VerticalFirst
},
}
})
```
- Changed the `Font` default base align to `Top` this is more in line with user expectations. This does change the default rendering to the top left corner of the font instead of the bottom left.
- Remove confusing Graphics Layering from `ex.GraphicsComponent`, recommend we use the `ex.GraphicsGroup` to manage this behavior
* Update `ex.GraphicsGroup` to be consistent and use `offset` instead of `pos` for graphics relative positioning
Expand All @@ -27,6 +40,21 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added

- Added Arcade Collision Solver bias to help mitigate seams in geometry that can cause problems for certain games.
- `ex.ContactSolveBias.None` No bias, current default behavior collisions are solved in the default distance order
- `ex.ContactSolveBias.VerticalFirst` Vertical collisions are solved first (useful for platformers with up/down gravity)
- `ex.ContactSolveBias.HorizontalFirst` Horizontal collisions are solved first (useful for games with left/right predominant forces)
```typescript
const engine = new ex.Engine({
...
physics: {
solver: ex.SolverStrategy.Realistic,
arcade: {
contactSolveBias: ex.ContactSolveBias.VerticalFirst
},
}
})
```
- Added Graphics `opacity` on the Actor constructor `new ex.Actor({opacity: .5})`
- Added Graphics pixel `offset` on the Actor constructor `new ex.Actor({offset: ex.vec(-15, -15)})`
- Added new `new ex.Engine({uvPadding: .25})` option to allow users using texture atlases in their sprite sheets to configure this to avoid texture bleed. This can happen if you're sampling from images meant for pixel art
Expand Down
21 changes: 16 additions & 5 deletions sandbox/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ var logger = ex.Logger.getInstance();
logger.defaultLevel = ex.LogLevel.Debug;

var fullscreenButton = document.getElementById('fullscreen') as HTMLButtonElement;

// setup physics defaults
// ex.Physics.useArcadePhysics();
// ex.Physics.checkForFastBodies = true;
// ex.Physics.acc = new ex.Vector(0, 10); // global accel
// Create an the game container
var game = new ex.Engine({
width: 800 / 2,
Expand All @@ -53,6 +56,17 @@ var game = new ex.Engine({
fixedUpdateFps: 30,
maxFps: 60,
antialiasing: false,
uvPadding: 0,
physics: {
solver: ex.SolverStrategy.Realistic,
gravity: ex.vec(0, 20),
arcade: {
contactSolveBias: ex.ContactSolveBias.VerticalFirst
},
continuous: {
checkForFastBodies: true
}
},
configurePerformanceCanvas2DFallback: {
allow: true,
showPlayerMessage: true,
Expand Down Expand Up @@ -142,10 +156,7 @@ boot.addResource(jump);
// Set background color
game.backgroundColor = new ex.Color(114, 213, 224);

// setup physics defaults
ex.Physics.useArcadePhysics();
ex.Physics.checkForFastBodies = true;
ex.Physics.acc = new ex.Vector(0, 10); // global accel


// Add some UI
//var heart = new ex.ScreenElement(0, 0, 20, 20);
Expand Down
14 changes: 14 additions & 0 deletions sandbox/tests/arcadeseam/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arcade Collision Solver - Seam</title>
</head>
<body>
<p>Compare with physics.arcade.contactSolveBias: None & physics.arcade.contactSolveBias: VerticalFirst</p>
<script src="../../lib/excalibur.js"></script>
<script src="./index.js"></script>
</body>
</html>
67 changes: 67 additions & 0 deletions sandbox/tests/arcadeseam/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/// <reference path='../../lib/excalibur.d.ts' />

var game = new ex.Engine({
width: 1000,
height: 1000,
fixedUpdateFps: 60,
physics: {
gravity: ex.vec(0, 5000),
solver: ex.SolverStrategy.Arcade,
arcade: {
contactSolveBias: ex.ContactSolveBias.VerticalFirst
}
}
});

game.toggleDebug();

// big tiles so distance heuristic doesn't work
var lastWidth = 200;
var lastPos = ex.vec(0, 0);
for (let x = 0; x < 10; x++) {
const width = (x % 2 === 1 ? 16 : 200);
game.add(
new ex.Actor({
name: 'floor-tile',
x: lastPos.x,
y: 300,
width: width,
height: x % 2 ? 16 : 900,
anchor: ex.Vector.Zero,
color: ex.Color.Red,
collisionType: ex.CollisionType.Fixed
})
);
lastPos.x += width;
}

var player = new ex.Actor({
pos: ex.vec(100, 270),
width: 16,
height: 16,
color: ex.Color.Blue,
collisionType: ex.CollisionType.Active
});

player.onPostUpdate = () => {
const speed = 164;
if (game.input.keyboard.isHeld(ex.Keys.Right)) {
player.vel.x = speed;
}
if (game.input.keyboard.isHeld(ex.Keys.Left)) {
player.vel.x = -speed;
}
if (game.input.keyboard.isHeld(ex.Keys.Up)) {
player.vel.y = -speed;
}
if (game.input.keyboard.isHeld(ex.Keys.Down)) {
player.vel.y = speed;
}
}
game.add(player);


game.currentScene.camera.strategy.elasticToActor(player, .8, .9);
game.currentScene.camera.zoom = 2;

game.start();
2 changes: 1 addition & 1 deletion sandbox/tests/collision/passive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ var game = new ex.Engine({
height: 400
});

ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Arcade;
ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Arcade;

var activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
Expand Down
2 changes: 1 addition & 1 deletion sandbox/tests/physics/physics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ game.debug.collider.showBounds = true;
game.debug.motion.showAll = true;
game.debug.body.showMotion = true;

ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
ex.Physics.bodiesCanSleepByDefault = true;
ex.Physics.gravity = ex.vec(0, 100);

Expand Down
2 changes: 1 addition & 1 deletion sandbox/tests/within/within.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


ex.Physics.acc = new ex.Vector(0, 200);
ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
var game = new ex.Engine({
width: 600,
height: 400
Expand Down
55 changes: 47 additions & 8 deletions src/engine/Collision/BodyComponent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Vector } from '../Math/vector';
import { CollisionType } from './CollisionType';
import { Physics } from './Physics';
import { Clonable } from '../Interfaces/Clonable';
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent';
Expand All @@ -11,11 +10,14 @@ import { clamp } from '../Math/util';
import { ColliderComponent } from './ColliderComponent';
import { Transform } from '../Math/transform';
import { EventEmitter } from '../EventEmitter';
import { DefaultPhysicsConfig, PhysicsConfig } from './PhysicsConfig';
import { DeepRequired } from '../Util/Required';

export interface BodyComponentOptions {
type?: CollisionType;
group?: CollisionGroup;
useGravity?: boolean;
config?: Pick<PhysicsConfig, 'bodies'>['bodies']
}

export enum DegreeOfFreedom {
Expand Down Expand Up @@ -47,19 +49,56 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
*/
public enableFixedUpdateInterpolate = true;

private _bodyConfig: DeepRequired<Pick<PhysicsConfig, 'bodies'>['bodies']>;
private static _DEFAULT_CONFIG: DeepRequired<Pick<PhysicsConfig, 'bodies'>['bodies']> = {
...DefaultPhysicsConfig.bodies
};
public wakeThreshold: number;

constructor(options?: BodyComponentOptions) {
super();
if (options) {
this.collisionType = options.type ?? this.collisionType;
this.group = options.group ?? this.group;
this.useGravity = options.useGravity ?? this.useGravity;
this._bodyConfig = {
...DefaultPhysicsConfig.bodies,
...options.config
};
} else {
this._bodyConfig = {
...DefaultPhysicsConfig.bodies
};
}
this.updatePhysicsConfig(this._bodyConfig);
this._mass = BodyComponent._DEFAULT_CONFIG.defaultMass;
}

public get matrix() {
return this.transform.get().matrix;
}

/**
* Called by excalibur to update physics config defaults if they change
* @param config
*/
public updatePhysicsConfig(config: DeepRequired<Pick<PhysicsConfig, 'bodies'>['bodies']>) {
this._bodyConfig = {
...DefaultPhysicsConfig.bodies,
...config
};
this.canSleep = this._bodyConfig.canSleepByDefault;
this.sleepMotion = this._bodyConfig.sleepEpsilon * 5;
this.wakeThreshold = this._bodyConfig.wakeThreshold;
}
/**
* Called by excalibur to update defaults
* @param config
*/
public static updateDefaultPhysicsConfig(config: DeepRequired<Pick<PhysicsConfig, 'bodies'>['bodies']>) {
BodyComponent._DEFAULT_CONFIG = config;
}

/**
* Collision type for the rigidbody physics simulation, by default [[CollisionType.PreventCollision]]
*/
Expand All @@ -73,7 +112,7 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
/**
* The amount of mass the body has
*/
private _mass: number = Physics.defaultMass;
private _mass: number;
public get mass(): number {
return this._mass;
}
Expand All @@ -94,12 +133,12 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
/**
* Amount of "motion" the body has before sleeping. If below [[Physics.sleepEpsilon]] it goes to "sleep"
*/
public sleepMotion: number = Physics.sleepEpsilon * 5;
public sleepMotion: number;

/**
* Can this body sleep, by default bodies do not sleep
*/
public canSleep: boolean = Physics.bodiesCanSleepByDefault;
public canSleep: boolean;;

private _sleeping = false;
/**
Expand All @@ -117,7 +156,7 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
this._sleeping = sleeping;
if (!sleeping) {
// Give it a kick to keep it from falling asleep immediately
this.sleepMotion = Physics.sleepEpsilon * 5;
this.sleepMotion = this._bodyConfig.sleepEpsilon * 5;
} else {
this.vel = Vector.Zero;
this.acc = Vector.Zero;
Expand All @@ -134,10 +173,10 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
this.setSleeping(true);
}
const currentMotion = this.vel.size * this.vel.size + Math.abs(this.angularVelocity * this.angularVelocity);
const bias = Physics.sleepBias;
const bias = this._bodyConfig.sleepBias;
this.sleepMotion = bias * this.sleepMotion + (1 - bias) * currentMotion;
this.sleepMotion = clamp(this.sleepMotion, 0, 10 * Physics.sleepEpsilon);
if (this.canSleep && this.sleepMotion < Physics.sleepEpsilon) {
this.sleepMotion = clamp(this.sleepMotion, 0, 10 * this._bodyConfig.sleepEpsilon);
if (this.canSleep && this.sleepMotion < this._bodyConfig.sleepEpsilon) {
this.setSleeping(true);
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/engine/Collision/Colliders/CompositeCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { DynamicTreeCollisionProcessor } from '../Detection/DynamicTreeCollision
import { RayCastHit } from '../Detection/RayCastHit';
import { Collider } from './Collider';
import { Transform } from '../../Math/transform';
import { DefaultPhysicsConfig } from '../PhysicsConfig';

export class CompositeCollider extends Collider {
private _transform: Transform;
private _collisionProcessor = new DynamicTreeCollisionProcessor();
private _dynamicAABBTree = new DynamicTree();
private _collisionProcessor = new DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
private _dynamicAABBTree = new DynamicTree(DefaultPhysicsConfig.dynamicTree);
private _colliders: Collider[] = [];

constructor(colliders: Collider[]) {
Expand Down
26 changes: 18 additions & 8 deletions src/engine/Collision/CollisionSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MotionComponent } from '../EntityComponentSystem/Components/MotionCompo
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
import { System, SystemType } from '../EntityComponentSystem/System';
import { CollisionEndEvent, CollisionStartEvent, ContactEndEvent, ContactStartEvent } from '../Events';
import { CollisionResolutionStrategy, Physics } from './Physics';
import { SolverStrategy } from './SolverStrategy';
import { ArcadeSolver } from './Solver/ArcadeSolver';
import { Collider } from './Colliders/Collider';
import { CollisionContact } from './Detection/CollisionContact';
Expand All @@ -23,18 +23,23 @@ export class CollisionSystem extends System {
public query: Query<ComponentCtor<TransformComponent> | ComponentCtor<MotionComponent> | ComponentCtor<ColliderComponent>>;

private _engine: Engine;
private _realisticSolver = new RealisticSolver();
private _arcadeSolver = new ArcadeSolver();
private _configDirty = false;
private _realisticSolver: RealisticSolver;
private _arcadeSolver: ArcadeSolver;
private _lastFrameContacts = new Map<string, CollisionContact>();
private _currentFrameContacts = new Map<string, CollisionContact>();
private _processor: DynamicTreeCollisionProcessor;
private get _processor(): DynamicTreeCollisionProcessor {
return this._physics.collisionProcessor;
};

private _trackCollider: (c: Collider) => void;
private _untrackCollider: (c: Collider) => void;

constructor(world: World, physics: PhysicsWorld) {
constructor(world: World, private _physics: PhysicsWorld) {
super();
this._processor = physics.collisionProcessor;
this._arcadeSolver = new ArcadeSolver(_physics.config.arcade);
this._realisticSolver = new RealisticSolver(_physics.config.realistic);
this._physics.$configUpdate.subscribe(() => this._configDirty = true);
this._trackCollider = (c: Collider) => this._processor.track(c);
this._untrackCollider = (c: Collider) => this._processor.untrack(c);
this.query = world.query([TransformComponent, MotionComponent, ColliderComponent]);
Expand All @@ -61,7 +66,7 @@ export class CollisionSystem extends System {
}

update(elapsedMs: number): void {
if (!Physics.enabled) {
if (!this._physics.config.enabled) {
return;
}

Expand Down Expand Up @@ -122,7 +127,12 @@ export class CollisionSystem extends System {
}

getSolver(): CollisionSolver {
return Physics.collisionResolutionStrategy === CollisionResolutionStrategy.Realistic ? this._realisticSolver : this._arcadeSolver;
if (this._configDirty) {
this._configDirty = false;
this._arcadeSolver = new ArcadeSolver(this._physics.config.arcade);
this._realisticSolver = new RealisticSolver(this._physics.config.realistic);
}
return this._physics.config.solver === SolverStrategy.Realistic ? this._realisticSolver : this._arcadeSolver;
}

debug(ex: ExcaliburGraphicsContext) {
Expand Down
Loading

0 comments on commit a6e666f

Please sign in to comment.