A project with three tilemap layers, two of them with collision on water and hill tiles, ran at 33 to 52 fps. A CPU profile showed that almost all of the time went into the collision geometry of the map. It was rebuilt from scratch every frame although nothing had changed. The animated water makes the views redraw every frame, so the cost was never skipped. - New module tilemapMemo keeps what is derived from the cells: the tiles of a layer, the blocking cells, the rectangles and polygons. It is reused as long as the content stays the same. - A brush stroke paints into the existing cells object. The memo therefore compares the content cell by cell, not the identity. The comparison walks two arrays in insertion order. - Clipped outlines are kept per outline list. The model always replaces that list instead of changing it. - PhysicsSystem no longer rebuilds the shapes of a tilemap each tick while its cells, placement and settings are unchanged. Before, it also built a JSON key from all world shapes every tick. - The overlaid layers after Set tile in a run stay stable. Otherwise the memo would miss on every call after the first edit. The #184 work only helped Move and raycasts, not this path. Measured on stein2 (i5-7500T, HD 630), script time per frame: - Scene, nothing selected: 17.7 ms, 52 fps -> 5.8 ms, 60 fps - Tilemap selected: 26.2 ms, 38 fps -> 8.6 ms, 60 fps - Play: 29.9 ms, 33 fps -> 8.7 ms, 60 fps About 3 ms per frame remain. The content comparison costs roughly 60 us per 700-cell layer and runs about 40 times a frame. The mesh geometries in TilemapField are still rebuilt every frame. Tests: - ./check.sh: 162 files, 2377 tests, CHECK EXIT 0 (before 2370) - e2e 1-5 and 39-43: ALL PASSED, 289 checks - 7 new unit tests in tests/core/tilemap-memo.test.ts - 5 counter checks (content ignored, no memo, outline by kind only, physics always rebuilds, layer settings ignored) each fail their test. All originals are back, checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
862 lines
35 KiB
TypeScript
862 lines
35 KiB
TypeScript
import type {
|
||
Collider,
|
||
ColliderDesc,
|
||
EventQueue,
|
||
PhysicsHooks,
|
||
RigidBody,
|
||
World as RapierWorld,
|
||
} from '@dimforge/rapier2d-compat';
|
||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||
import { apply, determinant, IDENTITY, invert } from '../../core/model/matrix';
|
||
import type { Vec2 } from '../../core/model/Properties';
|
||
import { parentChain } from '../../core/model/walk';
|
||
import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers';
|
||
import type { TileMaterial } from '../../core/model/tilemapCollision';
|
||
import {
|
||
type ColliderShape,
|
||
contactBetween,
|
||
isTrigger,
|
||
type OneWaySide,
|
||
movedShape,
|
||
shapeArea,
|
||
shapesOverlap,
|
||
} from '../../core/properties/collider';
|
||
import { hasTransformProps } from '../../core/properties/PropertySchema';
|
||
import {
|
||
type BodyType,
|
||
colliderMaterial,
|
||
type RigidbodySettings,
|
||
rigidbodySettings,
|
||
sceneGravity,
|
||
} from '../../core/properties/rigidbody';
|
||
import type { Entity } from '../Entity';
|
||
import type { World } from '../World';
|
||
import {
|
||
type Blocked,
|
||
blockingCollidersOf,
|
||
bodyOf,
|
||
type CollisionPair,
|
||
type CollisionSource,
|
||
type Contact,
|
||
type MoveResult,
|
||
partsOf,
|
||
type RayFilter,
|
||
type RayResult,
|
||
shapeOf,
|
||
type SolidShape,
|
||
Solids,
|
||
surfaceOf,
|
||
} from '../systems/solids';
|
||
import type { System } from '../systems/System';
|
||
import {
|
||
angleBetween,
|
||
blend,
|
||
matrixPose,
|
||
type Piece,
|
||
pieceArea,
|
||
pieceKey,
|
||
piecesInBody,
|
||
type Pose,
|
||
poseOf,
|
||
round,
|
||
samePose,
|
||
} from './bodyGeometry';
|
||
import { type Rapier, toMetres, toPixels } from './engine';
|
||
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;
|
||
|
||
/**
|
||
* One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below
|
||
* it; `free` is a collider of an object without one, a body of its own that goes where the object
|
||
* is put; `tilemap` is a placed map with a box per merged rectangle of its solid cells.
|
||
*/
|
||
interface Body {
|
||
kind: 'rigid' | 'free' | 'tilemap';
|
||
body: RigidBody;
|
||
/** The engine's colliders – several for a polygon, one per convex piece – whose each is, and its shape. */
|
||
colliders: Collider[];
|
||
sources: Entity[];
|
||
solids: SolidShape[];
|
||
/** What the colliders were built from; they are built again when it changes (a scaled object). */
|
||
shapeKey: string;
|
||
/** For a tilemap, what its shapes were last worked out from (see `build`). */
|
||
source: string;
|
||
settingsKey: string;
|
||
/** Whether the engine may turn it – what `writeBack` hands on as the object's rotation. */
|
||
turns: boolean;
|
||
/** The mass it carries of its own because no collider gives it one (kg, 0 = none). */
|
||
ownMass: number;
|
||
/** A dynamic body the editor is dragging: kinematic until let go, then at rest (`World.holds`). */
|
||
held: boolean;
|
||
/** Where the pose was last put, by us or by the object moving. */
|
||
placed: Pose;
|
||
/** A dynamic body's pose before and after the last step, for drawing in between. */
|
||
previous: Pose;
|
||
current: Pose;
|
||
}
|
||
|
||
/**
|
||
* An engine collider: the entity it stands for, its world shape at the last sync and, for a one-way
|
||
* one, the side it blocks from (#155).
|
||
*/
|
||
interface Part {
|
||
entity: Entity;
|
||
shape: ColliderShape;
|
||
facing: OneWaySide | null;
|
||
owner: Body;
|
||
}
|
||
|
||
/**
|
||
* The played game's physics on Rapier (#146): rigidbodies fall, bounce and rest, and the collision
|
||
* events come from the engine's contact pairs. Ticked first, like the collision system it stands in
|
||
* for, so the behaviors see this frame's events.
|
||
*
|
||
* What has no rigidbody keeps behaving as it did before the engine: a collider is a body of its own
|
||
* that goes wherever the object is put, and whether two such colliders touch is decided by the
|
||
* editor's own overlap test – the engine only proposes the pairs. Touching without overlapping is
|
||
* no collision, as it never was. Only where a dynamic body is involved does the engine's contact say it.
|
||
*/
|
||
export class PhysicsSystem implements System, CollisionSource {
|
||
enters: CollisionPair[] = [];
|
||
stays: CollisionPair[] = [];
|
||
exits: CollisionPair[] = [];
|
||
private engine: RapierWorld | null = null;
|
||
private queue: EventQueue | null = null;
|
||
private readonly bodies = new Map<Entity, Body>();
|
||
private readonly parts = new Map<number, Part>();
|
||
/** Collider handle pairs the engine reports in contact or intersecting, keyed `h1|h2`. */
|
||
private readonly candidates = new Map<string, [number, number]>();
|
||
private current = new Map<string, CollisionPair>();
|
||
/** The engine colliders each current pair was found with, `a`'s first – where its contact is read. */
|
||
private pairColliders = new Map<string, [number, number]>();
|
||
/**
|
||
* Pairs of a dynamic body and a one-way collider near each other, keyed `h1|h2`: whether the body
|
||
* passes through (#155). Decided before each step, since the engine's hook must not touch the engine.
|
||
*/
|
||
private passes = new Map<string, boolean>();
|
||
private hooks: PhysicsHooks | null = null;
|
||
private readonly solids = new Solids();
|
||
private backlog = 0;
|
||
/** The length of the frame being played – the time a move of the graph's is spread over. */
|
||
private frameDt = PHYSICS_STEP;
|
||
private readonly hinted = new Set<string>();
|
||
private world: World | null = null;
|
||
|
||
constructor(private readonly rapier: Rapier) {}
|
||
|
||
start(world: World): void {
|
||
const engine = new this.rapier.World(this.gravity(world));
|
||
engine.timestep = PHYSICS_STEP;
|
||
this.engine = engine;
|
||
this.queue = new this.rapier.EventQueue(true);
|
||
this.world = world;
|
||
const { SolverFlags } = this.rapier;
|
||
this.hooks = {
|
||
filterContactPair: (h1, h2) =>
|
||
this.passes.get(handlePairKey(h1, h2)) ? SolverFlags.EMPTY : SolverFlags.COMPUTE_IMPULSE,
|
||
filterIntersectionPair: () => true,
|
||
};
|
||
}
|
||
|
||
stop(): void {
|
||
this.engine?.free();
|
||
this.queue?.free();
|
||
this.engine = null;
|
||
this.world = null;
|
||
this.queue = null;
|
||
this.bodies.clear();
|
||
this.parts.clear();
|
||
this.candidates.clear();
|
||
this.current = new Map();
|
||
this.pairColliders = new Map();
|
||
this.passes = new Map();
|
||
this.hooks = null;
|
||
this.enters = [];
|
||
this.stays = [];
|
||
this.exits = [];
|
||
this.backlog = 0;
|
||
this.frameDt = PHYSICS_STEP;
|
||
this.hinted.clear();
|
||
}
|
||
|
||
update(world: World, dt: number): void {
|
||
const engine = this.engine;
|
||
if (!engine) return;
|
||
this.solids.reset();
|
||
this.frameDt = dt > 0 ? dt : PHYSICS_STEP;
|
||
this.enters = [];
|
||
this.exits = [];
|
||
this.sync(world);
|
||
engine.gravity = this.gravity(world);
|
||
this.backlog += dt;
|
||
let steps = 0;
|
||
const entered = new Set<string>();
|
||
while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) {
|
||
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);
|
||
}
|
||
|
||
/** Whether any collider of `a` is in contact with any collider of `b` as of the last step. */
|
||
touching(world: World, a: Entity, b: Entity): boolean {
|
||
for (const ca of partsOf(world, a))
|
||
for (const cb of partsOf(world, b))
|
||
if (this.current.has(`${ca.id}|${cb.id}`) || this.current.has(`${cb.id}|${ca.id}`)) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* `Move` with "Collide with solids": the object stops at what is in the way and slides along it,
|
||
* as in the editor run, and a dynamic body that stops it gets a push (#147).
|
||
*/
|
||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult {
|
||
return this.solids.moveAndSlide(world, entity, dx, dy, (blocked) => this.push(world, entity, blocked));
|
||
}
|
||
|
||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||
return this.solids.raycast(world, from, direction, length, filter);
|
||
}
|
||
|
||
raycastAll(
|
||
world: World,
|
||
from: Vec2,
|
||
direction: Vec2,
|
||
length: number,
|
||
filter: RayFilter,
|
||
maxHits?: number,
|
||
): RayResult[] {
|
||
return this.solids.raycastAll(world, from, direction, length, filter, maxHits);
|
||
}
|
||
|
||
/** How a current pair touches as of the last step: read from the engine's contact. */
|
||
contactOf(pair: CollisionPair): Contact | null {
|
||
const handles = this.pairColliders.get(`${pair.a.id}|${pair.b.id}`);
|
||
if (!handles || !this.parts.has(handles[0]) || !this.parts.has(handles[1])) return null;
|
||
return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1]));
|
||
}
|
||
|
||
/** A dynamic body's velocity in world units per second; null for an object the engine does not move. */
|
||
velocityOf(target: Entity): Vec2 | null {
|
||
const body = this.dynamicBodyOf(target)?.body;
|
||
if (!body) return null;
|
||
const v = body.linvel();
|
||
return { x: toPixels(v.x), y: toPixels(v.y) };
|
||
}
|
||
|
||
/** Sets a dynamic body's velocity (world units per second); false when the object has none. */
|
||
setVelocity(target: Entity, velocity: Vec2): boolean {
|
||
const body = this.dynamicBodyOf(target)?.body;
|
||
body?.setLinvel({ x: toMetres(velocity.x), y: toMetres(velocity.y) }, true);
|
||
return Boolean(body);
|
||
}
|
||
|
||
/** Degrees per second, counted the way the object's rotation is; null as for `velocityOf`. */
|
||
angularVelocityOf(target: Entity): number | null {
|
||
const body = this.dynamicBodyOf(target);
|
||
if (!body) return null;
|
||
return body.turns ? (this.turnSign(target) * body.body.angvel() * 180) / Math.PI : 0;
|
||
}
|
||
|
||
setAngularVelocity(target: Entity, degrees: number): boolean {
|
||
const body = this.dynamicBodyOf(target);
|
||
// With fixed rotation the engine would keep the speed and never turn: nothing to set.
|
||
if (body?.turns) body.body.setAngvel((this.turnSign(target) * degrees * Math.PI) / 180, true);
|
||
return Boolean(body);
|
||
}
|
||
|
||
/** A kick (world units × kg per second): the velocity changes by impulse ÷ mass, at once. */
|
||
applyImpulse(target: Entity, impulse: Vec2): boolean {
|
||
const body = this.dynamicBodyOf(target)?.body;
|
||
body?.applyImpulse({ x: toMetres(impulse.x), y: toMetres(impulse.y) }, true);
|
||
return Boolean(body);
|
||
}
|
||
|
||
/** The scene's gravity for the rest of this run, in world units per second squared. */
|
||
setGravity(world: World, gravity: Vec2): void {
|
||
world.setSceneProp('gravityX', gravity.x);
|
||
world.setSceneProp('gravityY', gravity.y);
|
||
if (this.engine) this.engine.gravity = this.gravity(world);
|
||
}
|
||
|
||
/**
|
||
* Says once per run and object what the physics did not do, in the log and the console – the
|
||
* one place a quiet no-op gets noticed.
|
||
*/
|
||
hint(world: World, target: Entity, text: string): void {
|
||
const key = `${target.id} ${text}`;
|
||
if (this.hinted.has(key)) return;
|
||
this.hinted.add(key);
|
||
const line = `Physics: "${target.name}" ${text}`;
|
||
world.log.push(line);
|
||
console.warn(`[physics] ${line}`);
|
||
}
|
||
|
||
/** The engine body of the object a graph names – the object with the rigidbody, or the rigidbody. */
|
||
private dynamicBodyOf(target: Entity): Body | null {
|
||
const owner = target.type === 'rigidbody' ? target.parent : target;
|
||
if (!owner) return null;
|
||
// On start, and an object spawned this frame, come before the engine has its body: it gets it now.
|
||
const world = this.world;
|
||
if (world && !this.bodies.has(owner))
|
||
if (world.childrenOf(owner).some((e) => e.type === 'rigidbody' && isHandleEnabled(e))) this.sync(world);
|
||
const body = this.bodies.get(owner);
|
||
return body?.kind === 'rigid' && body.body.isDynamic() && body.body.isEnabled() ? body : null;
|
||
}
|
||
|
||
/** Under a mirrored parent the engine turns against the rotation property. */
|
||
private turnSign(target: Entity): number {
|
||
const owner = target.type === 'rigidbody' ? target.parent : target;
|
||
return owner?.parent && determinant(worldMatrixOf(owner.parent)) < 0 ? -1 : 1;
|
||
}
|
||
|
||
/**
|
||
* A mover that runs into a dynamic body pushes it into the contact, as one body meeting another
|
||
* without bounce: the wanted speed is the move spread over the frame, and a light mover barely
|
||
* shifts a heavy crate. Both keep moving – the mover as the graph says, the crate as the engine does.
|
||
*/
|
||
private push(world: World, mover: Entity, { by, along, distance }: Blocked): void {
|
||
const part = [...this.parts.values()].find((p) => p.entity === by);
|
||
const body = part?.owner.body;
|
||
if (!body || part.owner.kind !== 'rigid' || !body.isDynamic() || !body.isEnabled()) return;
|
||
const v = body.linvel();
|
||
const closing = toMetres(distance / this.frameDt) - (v.x * along.x + v.y * along.y);
|
||
const m = this.massOf(world, mover);
|
||
const M = body.mass();
|
||
if (closing <= 0 || m <= 0 || M <= 0) return;
|
||
const impulse = ((m * M) / (m + M)) * closing;
|
||
body.applyImpulse({ x: impulse * along.x, y: impulse * along.y }, true);
|
||
}
|
||
|
||
/**
|
||
* What a moved object weighs against what it pushes: a dynamic body what the engine says, else
|
||
* the mass of its rigidbody, else what its colliders weigh by their density.
|
||
*/
|
||
private massOf(world: World, entity: Entity): number {
|
||
const own = this.bodies.get(entity);
|
||
if (own?.kind === 'rigid' && own.body.isDynamic()) return own.body.mass();
|
||
const rigidbody = world.childrenOf(entity).find((e) => e.type === 'rigidbody' && isHandleEnabled(e));
|
||
const mass = rigidbody ? rigidbodySettings(rigidbody.props).mass : 0;
|
||
if (mass > 0) return mass;
|
||
return blockingCollidersOf(world, entity).reduce(
|
||
(sum, c) => sum + colliderMaterial(c.props).density * shapeArea(shapeOf(c)) * toMetres(1) ** 2,
|
||
0,
|
||
);
|
||
}
|
||
|
||
private gravity(world: World): Vec2 {
|
||
const g = sceneGravity(world.sceneProps);
|
||
return { x: toMetres(g.x), y: toMetres(g.y) };
|
||
}
|
||
|
||
/** Makes the engine's bodies match the entities: new ones in, gone ones out, moved ones moved. */
|
||
private sync(world: World): void {
|
||
const wanted = this.wantedBodies(world);
|
||
for (const [key, body] of this.bodies) {
|
||
const want = wanted.get(key);
|
||
if (!want || want.kind !== body.kind) this.remove(key, body);
|
||
}
|
||
for (const [key, want] of wanted) {
|
||
let body = this.bodies.get(key);
|
||
if (!body) {
|
||
body = this.create(want, key);
|
||
this.bodies.set(key, body);
|
||
}
|
||
this.place(body, want, key, world);
|
||
this.build(body, want, key, world);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Every body the entities ask for, keyed by the entity that stands for it. A collider belongs to
|
||
* the nearest object above it with a rigidbody switched on; without one it is a body of its own.
|
||
*/
|
||
private wantedBodies(world: World): Map<Entity, Wanted> {
|
||
const owners = new Map<Entity, Entity>();
|
||
for (const e of world.entities)
|
||
if (
|
||
e.type === 'rigidbody' &&
|
||
e.parent &&
|
||
e.parent.type !== 'tilemap' &&
|
||
!owners.has(e.parent) &&
|
||
isHandleEnabled(e)
|
||
)
|
||
owners.set(e.parent, e);
|
||
const wanted = new Map<Entity, Wanted>();
|
||
for (const [owner, rigidbody] of owners)
|
||
wanted.set(owner, { kind: 'rigid', settings: rigidbodySettings(rigidbody.props), colliders: [] });
|
||
for (const e of world.entities) {
|
||
if (e.type === 'tilemap') wanted.set(e, { kind: 'tilemap', settings: null, colliders: [] });
|
||
if (e.type !== 'collider') continue;
|
||
const owner = [...parentChain(e, (x) => x.parent)].slice(1).find((x) => owners.has(x));
|
||
if (owner) wanted.get(owner)!.colliders.push(e);
|
||
else wanted.set(e, { kind: 'free', settings: null, colliders: [e] });
|
||
}
|
||
return wanted;
|
||
}
|
||
|
||
private create(want: Wanted, key: Entity): Body {
|
||
const R = this.rapier;
|
||
const pose = this.poseWanted(want, key);
|
||
const desc =
|
||
want.kind === 'tilemap'
|
||
? R.RigidBodyDesc.fixed()
|
||
: want.kind === 'free'
|
||
? R.RigidBodyDesc.kinematicPositionBased()
|
||
: want.settings!.type === 'dynamic'
|
||
? R.RigidBodyDesc.dynamic()
|
||
: want.settings!.type === 'static'
|
||
? R.RigidBodyDesc.fixed()
|
||
: R.RigidBodyDesc.kinematicPositionBased();
|
||
desc.setTranslation(toMetres(pose.x), toMetres(pose.y)).setRotation(pose.angle);
|
||
return {
|
||
kind: want.kind,
|
||
body: this.engine!.createRigidBody(desc),
|
||
colliders: [],
|
||
sources: [],
|
||
solids: [],
|
||
shapeKey: '',
|
||
source: '',
|
||
settingsKey: '',
|
||
turns: false,
|
||
ownMass: 0,
|
||
held: false,
|
||
placed: pose,
|
||
previous: pose,
|
||
current: pose,
|
||
};
|
||
}
|
||
|
||
private remove(key: Entity, body: Body): void {
|
||
for (const collider of body.colliders) this.parts.delete(collider.handle);
|
||
this.engine!.removeRigidBody(body.body);
|
||
this.bodies.delete(key);
|
||
}
|
||
|
||
/** Where the entities say the body is: the object's origin, a free collider's shape, a map at zero. */
|
||
private poseWanted(want: Wanted, key: Entity): Pose {
|
||
if (want.kind === 'tilemap') return { x: 0, y: 0, angle: 0 };
|
||
if (want.kind === 'free') {
|
||
const shape = shapeOf(key);
|
||
return { x: shape.x, y: shape.y, angle: shape.kind === 'circle' ? 0 : shape.angle };
|
||
}
|
||
return matrixPose(worldMatrixOf(key));
|
||
}
|
||
|
||
/**
|
||
* Puts the body where its object is. A dynamic body is only moved when something else moved the
|
||
* object since we last wrote it – that is a teleport, and the body keeps its velocity (#146, 3).
|
||
* One the editor drags goes where the drag puts it, as if it had no rigidbody, and is let go at rest.
|
||
*/
|
||
private place(body: Body, want: Wanted, key: Entity, world: World): void {
|
||
const settings = want.settings;
|
||
const held = settings?.type === 'dynamic' && world.holds(key);
|
||
const letGo = body.held && !held;
|
||
if (held !== body.held) {
|
||
body.held = held;
|
||
body.settingsKey = '';
|
||
}
|
||
const settingsKey = settings ? JSON.stringify(settings) + hasTransformProps(key.type) : '';
|
||
if (settings && settingsKey !== body.settingsKey) {
|
||
body.settingsKey = settingsKey;
|
||
this.applySettings(body, settings, key);
|
||
}
|
||
if (letGo) {
|
||
body.body.setLinvel({ x: 0, y: 0 }, true);
|
||
body.body.setAngvel(0, true);
|
||
}
|
||
const on = isHandleEnabled(key);
|
||
if (body.body.isEnabled() !== on) body.body.setEnabled(on);
|
||
const pose = this.poseWanted(want, key);
|
||
if (samePose(pose, body.placed)) return;
|
||
body.placed = pose;
|
||
const t = { x: toMetres(pose.x), y: toMetres(pose.y) };
|
||
if (body.body.isKinematic() && want.kind === 'rigid' && !body.held) {
|
||
body.body.setNextKinematicTranslation(t);
|
||
body.body.setNextKinematicRotation(pose.angle);
|
||
return;
|
||
}
|
||
if (body.body.isDynamic())
|
||
this.hint(
|
||
world,
|
||
key,
|
||
'has a dynamic rigidbody and was moved directly; it keeps its velocity. ' +
|
||
'Apply force, Apply impulse and Set velocity move it with the physics.',
|
||
);
|
||
body.body.setTranslation(t, true);
|
||
body.body.setRotation(pose.angle, true);
|
||
body.previous = pose;
|
||
body.current = pose;
|
||
}
|
||
|
||
private applySettings(target: Body, settings: RigidbodySettings, owner: Entity): void {
|
||
const body = target.body;
|
||
body.setBodyType(this.bodyType(target.held ? 'kinematic' : settings.type), true);
|
||
body.setGravityScale(settings.gravityScale, true);
|
||
body.setLinearDamping(settings.linearDamping);
|
||
body.setAngularDamping(settings.angularDamping);
|
||
// An object without a rotation of its own could not show the body turning, so it does not turn.
|
||
target.turns = !settings.fixedRotation && hasTransformProps(owner.type);
|
||
body.lockRotations(!target.turns, true);
|
||
body.enableCcd(settings.ccd);
|
||
}
|
||
|
||
private bodyType(type: BodyType) {
|
||
const T = this.rapier.RigidBodyType;
|
||
return type === 'dynamic' ? T.Dynamic : type === 'static' ? T.Fixed : T.KinematicPositionBased;
|
||
}
|
||
|
||
/** The body's colliders, built again whenever what they were built from changed. */
|
||
private build(body: Body, want: Wanted, key: Entity, world: World): void {
|
||
// A map's shapes follow from its cells, placement and settings alone. While none of those
|
||
// changed, its thousands of pieces are not worked out again only to find the same key.
|
||
const source = body.kind === 'tilemap' ? this.tilemapSource(key, world) : '';
|
||
const local = source !== '' && source === body.source ? [] : this.rebuild(body, want, key, world);
|
||
body.source = source;
|
||
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
|
||
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
|
||
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
|
||
body.colliders.forEach((collider, i) => {
|
||
const entity = body.sources[i]!;
|
||
const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!;
|
||
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
|
||
const on = isHandleEnabled(entity);
|
||
if (collider.isEnabled() === on) return;
|
||
collider.setEnabled(on);
|
||
// A resting body sleeps, and losing what held it up does not wake it by itself.
|
||
body.body.wakeUp();
|
||
});
|
||
if (body.kind !== 'rigid') return;
|
||
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
|
||
const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity));
|
||
const own = weighed ? 0 : want.settings?.mass || 1;
|
||
if (own !== body.ownMass) {
|
||
body.ownMass = own;
|
||
body.body.setAdditionalMass(own, true);
|
||
}
|
||
}
|
||
|
||
/** Works out the body's shapes and builds its colliders anew when they differ from the last ones. */
|
||
private rebuild(body: Body, want: Wanted, key: Entity, world: World) {
|
||
const shapes = this.shapesWanted(want, key, world);
|
||
const origin = body.placed;
|
||
const local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) }));
|
||
const shapeKey =
|
||
JSON.stringify(
|
||
local.map(({ entity, pieces, facing, surface }) => [
|
||
entity.id,
|
||
pieces.map(pieceKey),
|
||
facing && [round(facing.x), round(facing.y), facing.cone],
|
||
surface ?? 0,
|
||
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
|
||
// The layers sit on the engine collider, so changing one has to rebuild it (#152).
|
||
interactionGroups(collisionFilterOf(entity.props)),
|
||
]),
|
||
) + JSON.stringify(want.settings?.mass ?? 0);
|
||
if (shapeKey !== body.shapeKey) {
|
||
body.shapeKey = shapeKey;
|
||
for (const collider of body.colliders) {
|
||
this.parts.delete(collider.handle);
|
||
this.engine!.removeCollider(collider, true);
|
||
}
|
||
body.colliders = [];
|
||
body.sources = [];
|
||
body.solids = [];
|
||
const mass = want.settings?.mass ?? 0;
|
||
const area = local.reduce(
|
||
(sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0),
|
||
0,
|
||
);
|
||
for (const { entity, shape, facing, surface, pieces } of local)
|
||
for (const piece of pieces) {
|
||
const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0;
|
||
const desc = this.colliderDesc(entity, piece, share, facing !== null, surface);
|
||
if (!desc) continue;
|
||
const collider = this.engine!.createCollider(desc, body.body);
|
||
body.colliders.push(collider);
|
||
body.sources.push(entity);
|
||
body.solids.push({ shape, facing });
|
||
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
|
||
}
|
||
}
|
||
return local;
|
||
}
|
||
|
||
private tilemapSource(map: Entity, world: World): string {
|
||
const m = worldMatrixOf(map);
|
||
return [
|
||
this.solids.blocksToken(world, map),
|
||
m.a,
|
||
m.b,
|
||
m.c,
|
||
m.d,
|
||
m.tx,
|
||
m.ty,
|
||
JSON.stringify(map.props),
|
||
].join('|');
|
||
}
|
||
|
||
private shapesWanted(want: Wanted, key: Entity, world: World): (SolidShape & { entity: Entity })[] {
|
||
if (want.kind === 'tilemap')
|
||
return this.solids.solidShapesOf(world, key).map((solid) => ({ ...solid, entity: key }));
|
||
return want.colliders.flatMap((entity) =>
|
||
this.solids.solidShapesOf(world, entity).map((solid) => ({ ...solid, entity })),
|
||
);
|
||
}
|
||
|
||
/** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */
|
||
private colliderDesc(
|
||
entity: Entity,
|
||
piece: Piece,
|
||
mass: number,
|
||
oneWay: boolean,
|
||
surface?: TileMaterial,
|
||
): ColliderDesc | null {
|
||
const R = this.rapier;
|
||
if (pieceArea(piece) <= 0) return null;
|
||
let desc: ColliderDesc | null;
|
||
switch (piece.kind) {
|
||
case 'ball':
|
||
desc = R.ColliderDesc.ball(toMetres(piece.r));
|
||
break;
|
||
case 'cuboid':
|
||
desc = R.ColliderDesc.cuboid(toMetres(piece.hw), toMetres(piece.hh));
|
||
break;
|
||
case 'capsule':
|
||
desc = R.ColliderDesc.capsule(toMetres(piece.half), toMetres(piece.r));
|
||
break;
|
||
case 'convex':
|
||
desc = R.ColliderDesc.convexHull(
|
||
new Float32Array(piece.points.flatMap((p) => [toMetres(p.x), toMetres(p.y)])),
|
||
);
|
||
break;
|
||
}
|
||
if (!desc) return null;
|
||
if (piece.kind !== 'convex')
|
||
desc.setTranslation(toMetres(piece.x), toMetres(piece.y)).setRotation(piece.angle);
|
||
// A tile's cell brings its tile's surface (#193); a density it does not need, a map never moves.
|
||
const material = colliderMaterial(entity.type === 'collider' ? entity.props : {});
|
||
const { friction, bounciness } = surfaceOf(entity, surface);
|
||
desc.setFriction(friction).setRestitution(bounciness);
|
||
// The livelier side wins, as in Box2D: a ball that should bounce does so on plain ground too.
|
||
desc.setRestitutionCombineRule(R.CoefficientCombineRule.Max);
|
||
if (!isSolid(entity)) desc.setSensor(true).setDensity(0);
|
||
else if (mass > 0) desc.setMass(mass);
|
||
else desc.setDensity(material.density);
|
||
desc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS);
|
||
desc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL);
|
||
// The layers (#152). Rapier applies one group to detecting and to blocking alike, which is the
|
||
// single setting the issue asked for: what does not collide reports nothing either.
|
||
const groups = interactionGroups(collisionFilterOf(entity.props));
|
||
desc.setCollisionGroups(groups).setSolverGroups(groups);
|
||
if (oneWay && isSolid(entity)) desc.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS);
|
||
return desc;
|
||
}
|
||
|
||
/**
|
||
* Whether each dynamic body near a one-way collider passes through it in the coming step (#155),
|
||
* by `passesOneWay`. One that is to pass is woken: a resting body sleeps, and a new answer alone
|
||
* would not move it.
|
||
*/
|
||
private decideOneWay(world: World): void {
|
||
const platforms: [number, Part][] = [];
|
||
const movers: [number, Part][] = [];
|
||
for (const [handle, part] of this.parts) {
|
||
if (!isHandleEnabled(part.entity) || !isSolid(part.entity)) continue;
|
||
if (part.facing) platforms.push([handle, part]);
|
||
else if (part.owner.kind === 'rigid' && part.owner.body.isDynamic()) movers.push([handle, part]);
|
||
}
|
||
const decided = new Map<string, boolean>();
|
||
for (const [hp, platform] of platforms) {
|
||
const surface = this.shapeNow(platform);
|
||
const facing = this.facingNow(platform);
|
||
const vp = platform.owner.body.linvel();
|
||
for (const [hm, mover] of movers) {
|
||
if (mover.owner === platform.owner) continue;
|
||
const shape = this.shapeNow(mover);
|
||
const vm = mover.owner.body.linvel();
|
||
const relative = { x: toPixels(vm.x - vp.x), y: toPixels(vm.y - vp.y) };
|
||
const margin = Math.hypot(relative.x, relative.y) * PHYSICS_STEP * 2 + 16;
|
||
if (Math.hypot(shape.x - surface.x, shape.y - surface.y) > reachOf(shape) + reachOf(surface) + margin)
|
||
continue;
|
||
const key = handlePairKey(hp, hm);
|
||
const contact = contactBetween(shape, surface, relative);
|
||
const landed = this.passes.get(key) === false;
|
||
const passes = passesOneWay(contact, facing, relative, landed, world.ignoresOneWay(mover.entity));
|
||
if (passes && mover.owner.body.isSleeping()) mover.owner.body.wakeUp();
|
||
decided.set(key, passes);
|
||
}
|
||
}
|
||
this.passes = decided;
|
||
}
|
||
|
||
/** A part's shape where the engine has its body now: a dynamic body has moved on since the sync. */
|
||
private shapeNow(part: Part): ColliderShape {
|
||
const body = part.owner;
|
||
if (!body.body.isDynamic()) return part.shape;
|
||
const from = body.placed;
|
||
const to = poseOf(body.body);
|
||
const turn = to.angle - from.angle;
|
||
const cos = Math.cos(turn);
|
||
const sin = Math.sin(turn);
|
||
const move = (p: Vec2): Vec2 => {
|
||
const dx = p.x - from.x;
|
||
const dy = p.y - from.y;
|
||
return { x: to.x + dx * cos - dy * sin, y: to.y + dx * sin + dy * cos };
|
||
};
|
||
return movedShape(part.shape, move, turn);
|
||
}
|
||
|
||
private facingNow(part: Part): OneWaySide {
|
||
const facing = part.facing!;
|
||
if (!part.owner.body.isDynamic()) return facing;
|
||
const turn = part.owner.body.rotation() - part.owner.placed.angle;
|
||
const cos = Math.cos(turn);
|
||
const sin = Math.sin(turn);
|
||
return { x: facing.x * cos - facing.y * sin, y: facing.x * sin + facing.y * cos, cone: facing.cone };
|
||
}
|
||
|
||
/**
|
||
* The pairs in contact after a step, compared with those before it. The engine proposes the
|
||
* pairs; where no dynamic body takes part, the overlap test decides as it always did.
|
||
*/
|
||
private collect(world: World, entered: Set<string>): void {
|
||
this.queue!.drainCollisionEvents((h1, h2, started) => {
|
||
const key = h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`;
|
||
if (started) this.candidates.set(key, [h1, h2]);
|
||
else this.candidates.delete(key);
|
||
});
|
||
const order = new Map(world.entities.map((e, i) => [e, i]));
|
||
const now = new Map<string, CollisionPair>();
|
||
const first = new Map<string, [Collider, Collider]>();
|
||
for (const [key, [h1, h2]] of this.candidates) {
|
||
let p = this.parts.get(h1);
|
||
let q = this.parts.get(h2);
|
||
if (!p || !q) {
|
||
this.candidates.delete(key);
|
||
continue;
|
||
}
|
||
const a0 = p.entity;
|
||
const b0 = q.entity;
|
||
if (!order.has(a0) || !order.has(b0) || !isHandleEnabled(a0) || !isHandleEnabled(b0)) continue;
|
||
if (bodyOf(a0) === bodyOf(b0) || (a0.type === 'tilemap' && b0.type === 'tilemap')) continue;
|
||
const engineSays = p.owner.body.isDynamic() || q.owner.body.isDynamic();
|
||
if (!engineSays && !shapesOverlap(p.shape, q.shape)) continue;
|
||
let c1 = h1;
|
||
let c2 = h2;
|
||
if (order.get(a0)! > order.get(b0)!) {
|
||
[p, q] = [q, p];
|
||
[c1, c2] = [c2, c1];
|
||
}
|
||
const pairKey = `${p.entity.id}|${q.entity.id}`;
|
||
if (now.has(pairKey)) continue;
|
||
now.set(pairKey, { a: p.entity, b: q.entity });
|
||
first.set(pairKey, [this.engine!.getCollider(c1), this.engine!.getCollider(c2)]);
|
||
}
|
||
this.pairColliders = new Map();
|
||
for (const [key, pair] of now) {
|
||
const [c1, c2] = first.get(key)!;
|
||
this.pairColliders.set(key, [c1.handle, c2.handle]);
|
||
if (this.current.has(key)) {
|
||
now.set(key, this.current.get(key)!);
|
||
continue;
|
||
}
|
||
this.enters.push(Object.assign(pair, this.measure(c1, c2)));
|
||
entered.add(key);
|
||
}
|
||
for (const [key, pair] of this.current) if (!now.has(key)) this.exits.push(pair);
|
||
this.current = now;
|
||
}
|
||
|
||
/**
|
||
* How the pair touches: the normal from the second collider towards the first, the impulse of the
|
||
* last step and the middle of the contact points (world units; zero without a contact).
|
||
*/
|
||
private measure(c1: Collider, c2: Collider): Contact {
|
||
let normal = { x: 0, y: 0 };
|
||
let impulse = 0;
|
||
let sum = { x: 0, y: 0 };
|
||
let points = 0;
|
||
this.engine!.contactPair(c1, c2, (manifold, flipped) => {
|
||
if (manifold.numContacts() === 0) return;
|
||
const n = manifold.normal();
|
||
// The manifold's normal runs from its first collider to its second; ours runs back to `a`.
|
||
const sign = flipped ? 1 : -1;
|
||
if (normal.x === 0 && normal.y === 0) normal = { x: sign * n.x, y: sign * n.y };
|
||
// Each of its points has a side on either collider, in that collider's space: the contact
|
||
// point is the middle of the two, which is also where two bodies that sank in meet.
|
||
const [first, second] = flipped ? [c2, c1] : [c1, c2];
|
||
const toWorld = (collider: Collider, p: { x: number; y: number }) => {
|
||
const at = collider.translation();
|
||
const cos = Math.cos(collider.rotation());
|
||
const sin = Math.sin(collider.rotation());
|
||
return { x: at.x + p.x * cos - p.y * sin, y: at.y + p.x * sin + p.y * cos };
|
||
};
|
||
for (let i = 0; i < manifold.numContacts(); i++) {
|
||
impulse += manifold.contactImpulse(i);
|
||
const p1 = manifold.localContactPoint1(i);
|
||
const p2 = manifold.localContactPoint2(i);
|
||
if (!p1 || !p2) continue;
|
||
const a = toWorld(first, p1);
|
||
const b = toWorld(second, p2);
|
||
sum = { x: sum.x + (a.x + b.x) / 2, y: sum.y + (a.y + b.y) / 2 };
|
||
points++;
|
||
}
|
||
});
|
||
const point = points > 0 ? { x: toPixels(sum.x / points), y: toPixels(sum.y / points) } : { x: 0, y: 0 };
|
||
return { normal, impulse: toPixels(impulse), point };
|
||
}
|
||
|
||
/**
|
||
* Hands the dynamic bodies' poses to their objects, a parent before its children, drawn `alpha`
|
||
* of the way from the step before to the last one so the picture moves evenly between steps.
|
||
*/
|
||
private writeBack(alpha: number): void {
|
||
const dynamic = [...this.bodies].filter(([, body]) => body.kind === 'rigid' && body.body.isDynamic());
|
||
dynamic.sort(([a], [b]) => depthOf(a) - depthOf(b));
|
||
for (const [owner, body] of dynamic) {
|
||
const pose = blend(body.previous, body.current, alpha);
|
||
const parent = owner.parent ? worldMatrixOf(owner.parent) : IDENTITY;
|
||
const local = apply(invert(parent), pose);
|
||
owner.x = local.x;
|
||
owner.y = local.y;
|
||
if (body.turns) {
|
||
const turned = angleBetween(matrixPose(worldMatrixOf(owner)).angle, pose.angle);
|
||
const sign = determinant(parent) < 0 ? -1 : 1;
|
||
owner.props.rotation = Number(owner.props.rotation ?? 0) + (sign * turned * 180) / Math.PI;
|
||
}
|
||
body.placed = matrixPose(worldMatrixOf(owner));
|
||
}
|
||
}
|
||
}
|
||
|
||
interface Wanted {
|
||
kind: Body['kind'];
|
||
settings: RigidbodySettings | null;
|
||
colliders: Entity[];
|
||
}
|
||
|
||
function handlePairKey(h1: number, h2: number): string {
|
||
return h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`;
|
||
}
|
||
|
||
/** A tilemap's cells always block; a collider unless it is a trigger. */
|
||
function isSolid(entity: Entity): boolean {
|
||
return entity.type !== 'collider' || !isTrigger(entity.props);
|
||
}
|
||
|
||
function depthOf(entity: Entity): number {
|
||
return [...parentChain(entity, (e) => e.parent)].length;
|
||
}
|