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

## 1.5.1 - _2026-09-22_

### Fixed
- A renderable that places itself is built at its `pos`, whatever its `anchorPoint` holds. 1.5.0 started measuring a body from the frame its renderable draws in, which `anchorPoint` shifts by `-size * anchorPoint`, but a renderable can opt out of that offset entirely by clearing `applyAnchorTransform`, which is what `preDraw` itself reads: a `GLTFModel` sets it outright and a `Mesh` clears it under a `Camera3d`, because both emit world coordinates and pivot about their own model origin rather than a bounds box. Reading the anchor on those anyway moved each body by half its OWN bounds box, and a scene sizes that box per node, so two objects that overlap on screen were displaced by different amounts and stopped colliding at all

## 1.5.0 - _2026-09-22_

### 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.5.0",
"version": "1.5.1",
"description": "melonJS physics adapter for matter-js",
"homepage": "https://www.npmjs.com/package/@melonjs/matter-adapter",
"type": "module",
Expand Down
26 changes: 17 additions & 9 deletions packages/matter-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,15 +400,23 @@ export class MatterAdapter implements PhysicsAdapter {
// its pixels by the same amount, and collision shapes are authored in
// that same frame, so the body is built there rather than on `pos`.
// Zero for an anchor of (0, 0) — what `Entity` and Tiled objects set —
// so those paths are unchanged. Guarded on `Number.isFinite` as
// `preDraw` is: a `Container`'s default size is `Infinity`, and
// `Infinity * 0` is `NaN`.
const anchorX = Number.isFinite(renderable.width)
? renderable.width * renderable.anchorPoint.x
: 0;
const anchorY = Number.isFinite(renderable.height)
? renderable.height * renderable.anchorPoint.y
: 0;
// so those paths are unchanged. Zero too when the renderable opts out
// of the anchor entirely via `applyAnchorTransform === false`, the
// flag `preDraw` itself reads: a `GLTFModel`, and a `Mesh` on the
// `Camera3d` world-space path, place themselves by their own transform
// and draw at `pos` with no anchor shift, so taking one off here would
// build the body half a bounds box away from the model. Guarded on
// `Number.isFinite` as `preDraw` is: a `Container`'s default size is
// `Infinity`, and `Infinity * 0` is `NaN`.
const anchored = renderable.applyAnchorTransform;
const anchorX =
anchored && Number.isFinite(renderable.width)
? renderable.width * renderable.anchorPoint.x
: 0;
const anchorY =
anchored && Number.isFinite(renderable.height)
? renderable.height * renderable.anchorPoint.y
: 0;
const baseX = renderable.pos.x - anchorX;
const baseY = renderable.pos.y - anchorY;
// `isActive === false` keeps a shape out of the simulation without
Expand Down
36 changes: 36 additions & 0 deletions packages/matter-adapter/tests/parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,42 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) {
expect(box.getBounds().bottom).toBeCloseTo(drawnBottom, 1);
});
}

it("ignores the anchor on a renderable that places itself", () => {
// `applyAnchorTransform === false` is a renderable declaring
// that it draws at `pos` and pivots about its own origin,
// which is the flag `preDraw` reads before applying any
// offset. `GLTFModel` sets it outright and `Mesh` clears it on
// the `Camera3d` world-space path, and both keep the default
// `anchorPoint` of (0.5, 0.5) underneath, where it means
// nothing. Reading it anyway built the body half a bounds box
// off the model it belongs to.
const floorY = 200;
const floor = new Renderable(0, floorY, 800, 20);
floor.alwaysUpdate = true;
floor.anchorPoint.set(0, 0);
floor.bodyDef = {
type: "static",
shapes: [new Rect(0, 0, 800, 20)],
};
world.addChild(floor);

const box = new Renderable(100, 120, 32, 32);
box.alwaysUpdate = true;
box.applyAnchorTransform = false;
box.bodyDef = {
type: "dynamic",
shapes: [new Rect(0, 0, 32, 32)],
};
world.addChild(box);

for (let i = 0; i < 180; i++) {
world.update(16);
}

// drawn from `pos`, so the whole height is below it
expect(Math.abs(box.pos.y + box.height - floorY)).toBeLessThan(2);
});
});

describe("polygon placement", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
- Body: rotation pivoted about the wrong point for any renderable away from the world origin, since `body.bounds` is already renderable-local and the pivot subtracted `renderable.pos` from it a second time
- `Body.rotate()` no longer throws on a `Box3d` or `Point` shape, neither of which can rotate; such a shape keeps its orientation and still contributes its bounds
- Physics: on the builtin solver, a body is pushed out of every shape of a multi-shape body it overlaps, not only the first one found. A crate resting across the junction of a Tiled polyline, which arrives as one body carrying one shape per segment, sank a little further into the neighbouring segment every frame and eventually fell through the ground: the narrowphase reports the first overlapping pair it finds, and the extra separation passes kept re-resolving that same already-resolved pair while the sibling went unmeasured. Every pair is enumerated once per pass now, which costs fewer narrowphase tests than the re-scan it replaces
- Physics: on the builtin solver, a body collides where its renderable draws. `anchorPoint` shifts a renderable's bounds and its rendering by `-size * anchorPoint`, but collision shapes were measured from `pos` regardless, so a renderable anchored anywhere other than its top-left corner had a hitbox sitting where it was not drawn: half a body off at the default centred anchor, and a full body height at the bottom anchor a platformer actor uses. **A game that compensated for this by offsetting its shapes by hand should remove those offsets.** `Entity` and Tiled objects set their own anchor to (0, 0), so anything built either of those ways is unchanged
- Physics: on the builtin solver, a body collides where its renderable draws. `anchorPoint` shifts a renderable's bounds and its rendering by `-size * anchorPoint`, but collision shapes were measured from `pos` regardless, so a renderable anchored anywhere other than its top-left corner had a hitbox sitting where it was not drawn: half a body off at the default centred anchor, and a full body height at the bottom anchor a platformer actor uses. **A game that compensated for this by offsetting its shapes by hand should remove those offsets.** `Entity` and Tiled objects set their own anchor to (0, 0), so anything built either of those ways is unchanged, and so is a renderable that places itself by clearing `applyAnchorTransform`, which a `GLTFModel` does outright and a `Mesh` does under a `Camera3d`: those draw at `pos` whatever their anchor holds, so that is where they collide. `raycast3d` reports hits in the same frame, so a floor probe and the solver agree on where a surface is
- Physics: on the builtin solver, a body held against static geometry by another dynamic body is no longer pushed into it. A crate shoved against a wall now stops at the wall instead of creeping inside it or crossing thin geometry entirely; the interior of a stacked pile of dynamic bodies still settles with a small overlap, which is what the matter and planck adapters are for
- Physics: on the builtin solver, two overlapping dynamic bodies now separate instead of being shifted the same way together: SAT reports one minimum translation vector per pair, oriented for one side of it, and the other side was applying it unmirrored. **A game that leaned on a colliding dynamic pair drifting along in convoy will see them push apart instead**, while contacts against static geometry are unchanged, since the dynamic body is always the side the vector is oriented for
- Mesh: `lit: true` under a `Camera2d` warns once and degrades to unlit instead of silently doing nothing, naming `Camera3d` as the way to light it ([#1576](https://github.com/melonjs/melonJS/issues/1576))
Expand Down
10 changes: 10 additions & 0 deletions packages/melonjs/skills/melonjs-physics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,16 @@ ways is affected.
The debug panel is the quickest check: green is the renderable's bounds, red
is what actually collides, and the two now agree.

One renderable does not take that offset at all: one that has cleared
`applyAnchorTransform`, the flag `preDraw` reads before shifting anything.
`GLTFModel` sets it `false` outright and `Mesh` clears it under a `Camera3d`,
because both emit world coordinates and pivot about their own model origin
rather than a bounds box, so they draw at `pos` whatever their `anchorPoint`
still holds. Their bodies are built there too, on every backend, and
`raycast3d` reports hits in that same frame. If you clear the flag on a
renderable of your own, the same applies: you place it, and your shapes are
measured from `pos`.

### `autoTransform: false` opts out of rotated bounds

`updateBounds()` only applies `currentTransform` when `autoTransform` is
Expand Down
63 changes: 47 additions & 16 deletions packages/melonjs/src/physics/builtin/builtin-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ function worldBox3d(
if (body === undefined || !body.hasDepth) {
return false;
}
// the same frame the narrowphase measures in, so a ray hits a body where
// it actually collides rather than where its raw `pos` is
const anchor = anchorOffset(renderable);
const ox = cx - anchor.x;
const oy = cy - anchor.y;
let found = false;
const shapes = body.shapes as unknown as {
type: string;
Expand All @@ -71,11 +76,11 @@ function worldBox3d(
// an inactive shape is out of collision, and raycast3d is a collision
// query — skip it rather than let it contribute a depth extent (#1590)
if (shape.isActive === false) continue;
const minX = cx + shape.pos.x - shape.halfExtents.x;
const minY = cy + shape.pos.y - shape.halfExtents.y;
const minX = ox + shape.pos.x - shape.halfExtents.x;
const minY = oy + shape.pos.y - shape.halfExtents.y;
const minZ = cz + shape.pos.z - shape.halfExtents.z;
const maxX = cx + shape.pos.x + shape.halfExtents.x;
const maxY = cy + shape.pos.y + shape.halfExtents.y;
const maxX = ox + shape.pos.x + shape.halfExtents.x;
const maxY = oy + shape.pos.y + shape.halfExtents.y;
const maxZ = cz + shape.pos.z + shape.halfExtents.z;
if (!found) {
found = true;
Expand All @@ -97,6 +102,42 @@ function worldBox3d(
return found;
}

/** scratch for `anchorOffset`; never escapes its callers */
const _anchor = { x: 0, y: 0 };
/** shared zero, for renderables that place themselves */
const _zeroAnchor = { x: 0, y: 0 };

/**
* How far a renderable's drawn frame sits from its `pos`, per axis.
*
* The narrowphase measures collision shapes from the frame the renderable
* DRAWS in, which `anchorPoint` shifts by `-size * anchorPoint`, so anything
* reporting or querying that geometry has to use the same frame or it reads a
* body half its own size away from where it collides.
*
* `applyAnchorTransform === false` is the renderable saying it draws at `pos`
* with no anchor shift at all, which is what `preDraw` itself reads: a
* `GLTFModel` sets it outright, and a `Mesh` clears it on the `Camera3d`
* world-space path, both because they place themselves by their own transform
* and pivot about their model origin rather than a bounds box. `Number.isFinite`
* guards a `Container`'s default size of `Infinity`, where `Infinity * 0` would
* yield `NaN` and poison every position derived from it.
* @param renderable - the renderable to measure
* @returns the x and y offsets to subtract, both zero for a corner anchor
*/
function anchorOffset(renderable: Renderable): { x: number; y: number } {
if (!renderable.applyAnchorTransform) {
return _zeroAnchor;
}
_anchor.x = Number.isFinite(renderable.width)
? renderable.width * renderable.anchorPoint.x
: 0;
_anchor.y = Number.isFinite(renderable.height)
? renderable.height * renderable.anchorPoint.y
: 0;
return _anchor;
}

/**
* Ray-vs-AABB via the slab method, for `t ∈ [0, 1]` along the segment
* `from → from + d`. Returns the entry fraction and the face normal of the
Expand Down Expand Up @@ -574,12 +615,7 @@ export default class BuiltinAdapter implements PhysicsAdapter {
const b = body.bounds;
// same frame as `getBodyShapes`: where the body collides, which is
// the renderable's drawn frame rather than raw `pos`
const ax = Number.isFinite(renderable.width)
? renderable.width * renderable.anchorPoint.x
: 0;
const ay = Number.isFinite(renderable.height)
? renderable.height * renderable.anchorPoint.y
: 0;
const { x: ax, y: ay } = anchorOffset(renderable);
out.setMinMax(b.min.x - ax, b.min.y - ay, b.max.x - ax, b.max.y - ay);
return out;
}
Expand All @@ -602,12 +638,7 @@ export default class BuiltinAdapter implements PhysicsAdapter {
// `applyForce`'s lever arm and the shape pools all keep reading the
// coordinates the caller wrote. Reporting those raw would put the
// debug overlay half a body away from where the collision happens.
const ax = Number.isFinite(renderable.width)
? renderable.width * renderable.anchorPoint.x
: 0;
const ay = Number.isFinite(renderable.height)
? renderable.height * renderable.anchorPoint.y
: 0;
const { x: ax, y: ay } = anchorOffset(renderable);
if (ax === 0 && ay === 0) {
// the overwhelmingly common case, and the one `Entity` and Tiled
// objects always take: hand back the live list, allocating nothing
Expand Down
17 changes: 15 additions & 2 deletions packages/melonjs/src/physics/builtin/sat.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ function vornoiRegion(line, point) {
* Zero whenever the anchor is (0, 0) — which is what `Entity` and Tiled
* objects set — so every legacy path is bit-for-bit unchanged.
*
* Also zero when the renderable opts out of the offset altogether via
* `applyAnchorTransform === false`, which is the flag `preDraw` itself reads:
* a {@link GLTFModel}, and a {@link Mesh} on the `Camera3d` world-space path,
* place themselves by their own transform and draw at `pos` with no anchor
* shift at all. Reading the anchor for those moved their shapes off the model
* by half its bounds box, and because a scene sizes that box per node, two
* objects that overlap on screen were pushed apart by DIFFERENT amounts and
* stopped colliding entirely.
*
* Guarded on `Number.isFinite` exactly as `preDraw` is: a `Container`'s
* default size is `Infinity`, and `Infinity * 0` is `NaN`, which would poison
* every position derived from it.
Expand All @@ -236,7 +245,9 @@ function vornoiRegion(line, point) {
* @ignore
*/
function anchorOffsetX(r) {
return Number.isFinite(r.width) ? r.width * r.anchorPoint.x : 0;
return r.applyAnchorTransform !== false && Number.isFinite(r.width)
? r.width * r.anchorPoint.x
: 0;
}

/**
Expand All @@ -246,7 +257,9 @@ function anchorOffsetX(r) {
* @ignore
*/
function anchorOffsetY(r) {
return Number.isFinite(r.height) ? r.height * r.anchorPoint.y : 0;
return r.applyAnchorTransform !== false && Number.isFinite(r.height)
? r.height * r.anchorPoint.y
: 0;
}

/**
Expand Down
24 changes: 18 additions & 6 deletions packages/melonjs/src/physics/builtin/sat3d.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,24 @@ function absCenter(renderable, box, out) {
const anc = renderable.ancestor.getAbsolutePosition();
// `anchorPoint` moves the drawn frame in XY; shapes are measured from
// it, so the same offset comes off here. It has no Z term.
const ax = Number.isFinite(renderable.width)
? renderable.width * renderable.anchorPoint.x
: 0;
const ay = Number.isFinite(renderable.height)
? renderable.height * renderable.anchorPoint.y
: 0;
//
// Unless the renderable opts out of the anchor the way `preDraw` reads it,
// which is the normal case for the things that carry a `Box3d`: a
// `GLTFModel` sets `applyAnchorTransform = false` outright, and a `Mesh`
// clears it on the `Camera3d` world-space path, because both emit world
// coordinates and pivot about their own model origin. Taking an anchor off
// those moved each body by half its OWN bounds box, and a scene sizes that
// box per node, so a hull and the props it should hit were displaced by
// different amounts and the contact was simply never reported.
const anchored = renderable.applyAnchorTransform !== false;
const ax =
anchored && Number.isFinite(renderable.width)
? renderable.width * renderable.anchorPoint.x
: 0;
const ay =
anchored && Number.isFinite(renderable.height)
? renderable.height * renderable.anchorPoint.y
: 0;
out[0] = renderable.pos.x + anc.x + box.pos.x - ax;
out[1] = renderable.pos.y + anc.y + box.pos.y - ay;
out[2] = renderable.pos.z + anc.z + box.pos.z;
Expand Down
8 changes: 5 additions & 3 deletions packages/melonjs/src/renderable/renderable.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ export default class Renderable extends Rect {
* places the renderable in, so a shape of `Rect(0, 0, width, height)` covers the
* renderable whatever the anchor is (since 20.7; before that shapes were measured
* from `pos` regardless, and a non-corner anchor collided where it was not drawn).
* A shape's own `pos` still offsets it inside that frame. Note also that the
* adapters read the anchor when the body is created, so changing it afterwards
* moves the drawing but not an already-built body.</i>
* A shape's own `pos` still offsets it inside that frame. A renderable that has
* cleared {@link Renderable#applyAnchorTransform} takes no offset at all, on the
* drawing or on its shapes. Note also that the adapters read the anchor when the
* body is created, so changing it afterwards moves the drawing but not an
* already-built body.</i>
* @type {ObservablePoint}
* @default <0.5,0.5>
*/
Expand Down
Loading
Loading