Skip to content
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
14 changes: 14 additions & 0 deletions packages/matter-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 1.3.0 - _2026-09-18_

### Fixed
- `getBodyShapes()` is read back from matter rather than derived, which fixes a body whose shapes came apart under rotation. 1.2.1 rotated the authored shapes about the renderable's origin, and `Polygon#rotate` moves a shape's points without its `pos`, so a body built from several shapes at different offsets saw each one spin about its own origin: the parts visibly separated, and shapes carrying their offset in `points` instead swung away from the body altogether. The reported geometry now comes from `part.vertices`, which matter keeps in world space and already rotated, so there is no pivot to derive and nothing to keep in step with the sprite
- `shape.isActive === false` keeps a shape out of the simulation, as it already did on the planck and builtin backends. matter simulated it regardless, so one body definition collided differently depending on which backend was driving it
- `getBodyShapes()` no longer reports a body's previous geometry after `updateShape()` at an unchanged angle, and no longer pins a removed renderable and its shapes for the adapter's lifetime. The cache was keyed by angle and cleared on no path but one
- `getBodyShapes()` allocates nothing once a body's structure is settled. It is called once per body per frame by the debug overlay, and the previous cache missed on every frame of a body that was actually turning, allocating a fresh shape set each time through the object pools without ever releasing it

### Changed
- `getBodyShapes()` reports the geometry matter **simulates**, not the shapes as authored. A `Rect` comes back as the `Polygon` matter holds, an `Ellipse` as the circle of average radius it is simulated as, a concave polygon as the convex parts it is decomposed into, and a degenerate one as the box it falls back to. The overlay previously drew outlines that did not describe what collides. The authored definitions are unchanged and remain available on `renderable.bodyDef.shapes`

### Notes
- The peer range stays `>=20.0.0`: nothing here needs a newer engine

## 1.2.1 - _2026-09-17_

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion packages/matter-adapter/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@melonjs/matter-adapter",
"version": "1.2.1",
"version": "1.3.0",
"description": "melonJS physics adapter for matter-js",
"homepage": "https://www.npmjs.com/package/@melonjs/matter-adapter",
"type": "module",
Expand Down
158 changes: 104 additions & 54 deletions packages/matter-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export class MatterAdapter implements PhysicsAdapter {
*/
private readonly posOffsets = new Map<Renderable, { x: number; y: number }>();

/**
* Shapes handed back by {@link MatterAdapter#getBodyShapes}: one reusable
* array per renderable whose members are refreshed in place, so a spinning
* body costs no allocation on the debug overlay's per-frame path. Dropped
* in `removeBody`, so nothing outlives the body it describes.
*/
private readonly reportedShapes = new Map<Renderable, BodyShape[]>();

private readonly matterOptions: Matter.IEngineDefinition | undefined;

private readonly subSteps: number;
Expand Down Expand Up @@ -285,6 +293,7 @@ export class MatterAdapter implements PhysicsAdapter {
this.defMap.clear();
this.bodyGravityScale.clear();
this.posOffsets.clear();
this.reportedShapes.clear();
}

step(dt: number): void {
Expand Down Expand Up @@ -369,11 +378,29 @@ export class MatterAdapter implements PhysicsAdapter {
// matter compound body (Matter.Body.create with parts).
const baseX = renderable.pos.x;
const baseY = renderable.pos.y;
const parts = def.shapes.map((s, i) => {
// `isActive === false` keeps a shape out of the simulation without
// removing it from the definition — the portable flag the builtin and
// the planck adapter both honour. Skipped here rather than created and
// disabled, so the same body collides the same way on every backend.
// The index carried into `partShapeMap` is the shape's index in the
// ORIGINAL list, so collision events still name the right shape.
const parts: Matter.Body[] = [];
def.shapes.forEach((s, i) => {
if ((s as { isActive?: boolean }).isActive === false) {
return;
}
const part = this._shapeToMatter(s, baseX, baseY);
this.partShapeMap.set(part, { shape: s, index: i });
return part;
parts.push(part);
});
if (parts.length === 0) {
// every shape disabled: matter cannot build a body from nothing, so
// keep one inert part rather than throwing — the body still exists
// for the caller to re-enable a shape on later via `updateShape`
parts.push(
Matter.Bodies.rectangle(baseX, baseY, 1, 1, { isSensor: true }),
);
}
let body: Matter.Body;
if (parts.length === 1) {
body = parts[0];
Expand Down Expand Up @@ -596,6 +623,9 @@ export class MatterAdapter implements PhysicsAdapter {
this.velocityLimits.delete(renderable);
this.defMap.delete(renderable);
this.posOffsets.delete(renderable);
// `updateShape` is a remove + add, so clearing here also stops a
// reshaped body reporting its previous geometry
this.reportedShapes.delete(renderable);
this.bodyGravityScale.delete(body);
}
}
Expand Down Expand Up @@ -852,63 +882,83 @@ export class MatterAdapter implements PhysicsAdapter {
* @param renderable - the renderable whose body shapes to read
*/
getBodyShapes(renderable: Renderable): readonly BodyShape[] {
const shapes = this.defMap.get(renderable)?.shapes;
if (shapes === undefined) {
// Reported from MATTER'S OWN geometry, not by re-rotating the authored
// shapes. `part.vertices` are world-space and already carry the body's
// current pose, so there is no pivot to derive and nothing to keep in
// sync with `syncFromPhysics` — and it reports what actually collides,
// which the authored shapes do not: an Ellipse is simulated as a circle
// of the average radius, a degenerate polygon as its bounding box, and
// a concave one as convex chunks.
const body = this.bodyMap.get(renderable);
if (body === undefined) {
return [];
}
const angle = this.getAngle(renderable);
if (angle === 0) {
// much the commonest case, and the one that must stay allocation
// free: hand back the authored array exactly as before
this.rotatedShapes.delete(renderable);
return shapes;
}
const cached = this.rotatedShapes.get(renderable);
if (cached !== undefined && cached.angle === angle) {
return cached.shapes;
const parts = body.parts;
// a compound body's real shapes are `parts[1..N]`; `parts[0]` is the
// wrapper whose vertices are the convex hull. Same walk as the raycast.
const startIdx = parts.length > 1 ? 1 : 0;
const count = parts.length - startIdx;

let cached = this.reportedShapes.get(renderable);
// rebuilt only when the STRUCTURE changes — the pose is refreshed in
// place below, so a spinning body allocates nothing per frame (the
// debug overlay calls this once per body per frame)
if (cached === undefined || cached.length !== count) {
cached = [];
this.reportedShapes.set(renderable, cached);
}
const rotated = this.rotateShapes(shapes, angle);
this.rotatedShapes.set(renderable, { angle, shapes: rotated });
return rotated;
}

/**
* Rotated copies of the authored shapes, keyed by renderable, with the
* angle they were built for. Rebuilt only when the body has actually
* turned — a scene of unrotated bodies never allocates, and a spinning one
* allocates once per angle change rather than once per read.
*/
private readonly rotatedShapes = new Map<
Renderable,
{ angle: number; shapes: BodyShape[] }
>();

/**
* Rotate the authored shapes to the body's current pose.
*
* The shapes are rotated about the body's own origin, which is where the
* engine rotates it, so the result stays in the renderable-local frame the
* contract promises.
* @param shapes - the authored shape definitions
* @param angle - the body's current angle, in radians
* @returns fresh shapes at that angle
*/
private rotateShapes(
shapes: readonly BodyShape[],
angle: number,
): BodyShape[] {
const pivot = new Vector2d(0, 0);
return shapes.map((shape) => {
// `clone()` keeps each shape's own type — a Rect rotated off-axis
// becomes a Polygon, which is what Rect#toPolygon is for; an
// Ellipse has no rotated form and is returned as it came
if (shape instanceof Ellipse) {
return shape;
const rx = renderable.pos.x;
const ry = renderable.pos.y;
for (let p = startIdx; p < parts.length; p++) {
const part = parts[p];
const i = p - startIdx;
const radius = (part as { circleRadius?: number }).circleRadius;
if (typeof radius === "number" && radius > 0) {
// a circle has no vertex list; report the circle matter
// actually simulates rather than the ellipse that was authored
const existing = cached[i];
if (existing instanceof Ellipse) {
existing.pos.set(part.position.x - rx, part.position.y - ry);
existing.radiusV.set(radius, radius);
} else {
cached[i] = new Ellipse(
part.position.x - rx,
part.position.y - ry,
radius * 2,
radius * 2,
);
}
continue;
}
const rotated = shape instanceof Rect ? shape.toPolygon() : shape.clone();
rotated.rotate(angle, pivot);
return rotated;
});
const vertices = part.vertices;
const existing = cached[i];
if (
existing instanceof Polygon &&
existing.points.length === vertices.length
) {
// mutate in place: `recalc` reuses its edge/normal slots, so
// the steady state is allocation-free
for (let v = 0; v < vertices.length; v++) {
existing.points[v].set(vertices[v].x - rx, vertices[v].y - ry);
}
existing.pos.set(0, 0);
existing.recalc();
existing.updateBounds();
} else {
// matter guarantees at least three vertices for a polygon part;
// the cast satisfies Polygon's "three or more" tuple type,
// which a `map` result cannot prove on its own
cached[i] = new Polygon(
0,
0,
vertices.map((v) => {
return new Vector2d(v.x - rx, v.y - ry);
}) as unknown as ConstructorParameters<typeof Polygon>[2],
);
}
}
return cached;
}

isGrounded(renderable: Renderable): boolean {
Expand Down
Loading
Loading