From 6dcc4e92ac31eac2d500d3992ef095777b5ef67d Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 22 Sep 2026 15:49:14 +0800 Subject: [PATCH] Physics: a renderable that places itself collides at its pos 20.7 moved every backend onto the frame a renderable DRAWS in, subtracting `size * anchorPoint` from where a body is built. That assumed every renderable takes that offset, and the engine already has a flag saying otherwise: `applyAnchorTransform`, which is what `preDraw` reads before shifting anything. `GLTFModel` clears it in its constructor and `Mesh` clears it on the `Camera3d` world-space path, because both emit world coordinates and pivot about their own model origin rather than a bounds box, so they draw at `pos` while their `anchorPoint` still holds the default (0.5, 0.5), where it means nothing. Reading it anyway moved each of those bodies by half its OWN bounds box. A scene sizes that box per node, so two objects overlapping on screen were displaced by different amounts and the contact between them was not merely shifted, it was never reported at all. The same condition now guards all five places that compute the offset: the 2D and 3D narrowphases, the builtin adapter's geometry readback, and both external adapters, which see this through any ordinary `Rect` on such a renderable and not only through a `Box3d`. `raycast3d` is fixed with it. 20.7 moved the narrowphase onto the drawn frame and left `raycast3d` building its world AABB from raw `pos`, so a floor probe and the solver disagreed by half a renderable on any anchored body. Nothing caught that because no test compared the two, which is what the new case in `raycast3d-box3d.spec.js` does. Six regression tests, each verified to fail with the defect put back. `matter-adapter` 1.5.1 and `planck-adapter` 1.6.1, since 1.5.0 and 1.6.0 went to npm carrying this. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/matter-adapter/CHANGELOG.md | 5 + packages/matter-adapter/package.json | 2 +- packages/matter-adapter/src/index.ts | 26 ++-- packages/matter-adapter/tests/parity.spec.ts | 36 +++++ packages/melonjs/CHANGELOG.md | 2 +- .../melonjs/skills/melonjs-physics/SKILL.md | 10 ++ .../src/physics/builtin/builtin-adapter.ts | 63 ++++++--- packages/melonjs/src/physics/builtin/sat.js | 17 ++- packages/melonjs/src/physics/builtin/sat3d.js | 24 +++- packages/melonjs/src/renderable/renderable.js | 8 +- packages/melonjs/tests/box3d-world.spec.js | 128 +++++++++++++++++- .../melonjs/tests/builtin-resting.spec.js | 20 ++- .../melonjs/tests/raycast3d-box3d.spec.js | 53 +++++++- packages/planck-adapter/CHANGELOG.md | 5 + packages/planck-adapter/package.json | 2 +- packages/planck-adapter/src/index.ts | 26 ++-- packages/planck-adapter/tests/parity.spec.ts | 36 +++++ 17 files changed, 408 insertions(+), 55 deletions(-) diff --git a/packages/matter-adapter/CHANGELOG.md b/packages/matter-adapter/CHANGELOG.md index 1952be754..d4069173d 100644 --- a/packages/matter-adapter/CHANGELOG.md +++ b/packages/matter-adapter/CHANGELOG.md @@ -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 diff --git a/packages/matter-adapter/package.json b/packages/matter-adapter/package.json index 2c341ac00..6bc3769c8 100644 --- a/packages/matter-adapter/package.json +++ b/packages/matter-adapter/package.json @@ -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", diff --git a/packages/matter-adapter/src/index.ts b/packages/matter-adapter/src/index.ts index de3426f4e..e8d8fa8df 100644 --- a/packages/matter-adapter/src/index.ts +++ b/packages/matter-adapter/src/index.ts @@ -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 diff --git a/packages/matter-adapter/tests/parity.spec.ts b/packages/matter-adapter/tests/parity.spec.ts index 0beb699e6..bf01ebec9 100644 --- a/packages/matter-adapter/tests/parity.spec.ts +++ b/packages/matter-adapter/tests/parity.spec.ts @@ -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", () => { diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index b2aa20549..734d83f03 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -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)) diff --git a/packages/melonjs/skills/melonjs-physics/SKILL.md b/packages/melonjs/skills/melonjs-physics/SKILL.md index 5966680c1..cbd51d877 100644 --- a/packages/melonjs/skills/melonjs-physics/SKILL.md +++ b/packages/melonjs/skills/melonjs-physics/SKILL.md @@ -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 diff --git a/packages/melonjs/src/physics/builtin/builtin-adapter.ts b/packages/melonjs/src/physics/builtin/builtin-adapter.ts index 924d2f6f1..cd2afdfda 100644 --- a/packages/melonjs/src/physics/builtin/builtin-adapter.ts +++ b/packages/melonjs/src/physics/builtin/builtin-adapter.ts @@ -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; @@ -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; @@ -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 @@ -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; } @@ -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 diff --git a/packages/melonjs/src/physics/builtin/sat.js b/packages/melonjs/src/physics/builtin/sat.js index 6cf4f5839..4b5fee04f 100644 --- a/packages/melonjs/src/physics/builtin/sat.js +++ b/packages/melonjs/src/physics/builtin/sat.js @@ -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. @@ -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; } /** @@ -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; } /** diff --git a/packages/melonjs/src/physics/builtin/sat3d.js b/packages/melonjs/src/physics/builtin/sat3d.js index 9f31f89f8..8517c35c7 100644 --- a/packages/melonjs/src/physics/builtin/sat3d.js +++ b/packages/melonjs/src/physics/builtin/sat3d.js @@ -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; diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 92f3457db..db6a84d67 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -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. + * 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. * @type {ObservablePoint} * @default <0.5,0.5> */ diff --git a/packages/melonjs/tests/box3d-world.spec.js b/packages/melonjs/tests/box3d-world.spec.js index 6bc412a6d..81960ab58 100644 --- a/packages/melonjs/tests/box3d-world.spec.js +++ b/packages/melonjs/tests/box3d-world.spec.js @@ -29,12 +29,34 @@ import { * at insertion time), so a body assigned afterwards never enters the * simulation and the object silently never collides. */ -function addBox(world, { x, y, z, w, h, d, isStatic = false, type }) { +function addBox( + world, + { + x, + y, + z, + w, + h, + d, + isStatic = false, + type, + placesItself = false, + // the collision box, when it is not simply the renderable's size. A + // mesh's bounds box and its hitbox are independent numbers, and an + // anchor bug is invisible while they move together. + bw = w, + bh = h, + bd = d, + }, +) { const r = new Renderable(x, y, w, h); r.anchorPoint.set(0.5, 0.5); + // what a GLTFModel is, and what a Mesh becomes under a Camera3d: it emits + // world coordinates itself, so `preDraw` applies no anchor offset + r.applyAnchorTransform = !placesItself; r.isKinematic = false; r.alwaysUpdate = true; - r.body = new Body(r, new Box3d(0, 0, 0, w, h, d)); + r.body = new Body(r, new Box3d(0, 0, 0, bw, bh, bd)); r.body.collisionType = type ?? collision.types.ENEMY_OBJECT; r.body.collisionMask = collision.types.ALL_OBJECT; r.body.isStatic = isStatic; @@ -228,4 +250,106 @@ describe("Box3d — resolution through a full world step", () => { expect(b.pos.z).toEqual(50); expect(Number.isNaN(a.pos.x)).toBe(false); }); + // Regression for #1693's follow-up. A body is measured from the frame its + // renderable DRAWS in, and `anchorPoint` normally shifts that frame by + // `-size * anchorPoint`. But a renderable can opt out of the anchor + // altogether by clearing `applyAnchorTransform`, which is exactly what the + // things that carry a `Box3d` do: `GLTFModel` sets it `false` outright and + // `Mesh` clears it on the `Camera3d` world-space path, because both place + // themselves by their own transform and pivot about their model origin. + // + // Reading `anchorPoint` on those anyway moved each body by half its OWN + // bounds box. A scene sizes that box per node, so two objects sitting on + // top of each other on screen were displaced by DIFFERENT amounts, and the + // contact between them was not merely shifted, it was never reported. + describe("a renderable that places itself", () => { + it("collides where it draws, whatever its bounds box measures", () => { + // Same world position, same 20-cube, bounds boxes deliberately + // nothing alike: a hull model against a tall prop mesh. Identical + // boxes at one point is the least ambiguous overlap there is, so a + // miss here can only be a frame error. + const hull = addBox(world, { + x: 300, + y: 300, + z: 100, + w: 40, + h: 60, + d: 20, + bw: 20, + bh: 20, + bd: 20, + placesItself: true, + type: collision.types.PLAYER_OBJECT, + }); + const prop = addBox(world, { + x: 300, + y: 300, + z: 100, + w: 120, + h: 200, + d: 20, + bw: 20, + bh: 20, + bd: 20, + isStatic: true, + placesItself: true, + type: collision.types.ENEMY_OBJECT, + }); + + let hits = 0; + hull.onCollisionStart = () => { + hits++; + // a sensor-style report: leave the positions alone so the + // assertion measures detection, not the push-out + return false; + }; + world.update(16); + + expect(prop).toBeDefined(); + expect(hits).toBe(1); + }); + + it("still reads the anchor on a renderable that does not opt out", () => { + // The other half of the contract, so the fix cannot be "ignore + // `anchorPoint` in 3D". A centred 200-wide renderable draws from + // x=0, and its box sits at the centre of THAT frame, so the two + // boxes below overlap on screen while their raw `pos` values are + // 100 apart. + const anchored = addBox(world, { + x: 200, + y: 300, + z: 100, + w: 200, + h: 40, + d: 20, + bw: 20, + bh: 20, + bd: 20, + type: collision.types.PLAYER_OBJECT, + }); + const corner = addBox(world, { + x: 100, + y: 280, + z: 100, + w: 20, + h: 20, + d: 20, + bw: 20, + bh: 20, + bd: 20, + isStatic: true, + type: collision.types.ENEMY_OBJECT, + }); + corner.anchorPoint.set(0, 0); + + let hits = 0; + anchored.onCollisionStart = () => { + hits++; + return false; + }; + world.update(16); + + expect(hits).toBe(1); + }); + }); }); diff --git a/packages/melonjs/tests/builtin-resting.spec.js b/packages/melonjs/tests/builtin-resting.spec.js index a4a55536d..5121175fd 100644 --- a/packages/melonjs/tests/builtin-resting.spec.js +++ b/packages/melonjs/tests/builtin-resting.spec.js @@ -236,8 +236,10 @@ describe("a body whose renderable is not anchored at its corner", () => { * Stated as behaviour, not as shape coordinates: the bottom edge of what * is drawn comes to rest on the floor, whatever the anchor. * @param {number} anchor - anchorPoint on both axes + * @param {boolean} [placesItself] - clear `applyAnchorTransform`, the way + * a `GLTFModel` and a world-space `Mesh` do */ - const restingDrawnBottom = (anchor) => { + const restingDrawnBottom = (anchor, placesItself = false) => { const floor = new Renderable(0, 360, 400, 40); floor.anchorPoint.set(0, 0); floor.isKinematic = false; @@ -246,6 +248,7 @@ describe("a body whose renderable is not anchored at its corner", () => { const crate = new Renderable(100, 100, 44, 44); crate.anchorPoint.set(anchor, anchor); + crate.applyAnchorTransform = !placesItself; crate.isKinematic = false; crate.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 44, 44)] }; app.world.addChild(crate); @@ -253,7 +256,11 @@ describe("a body whose renderable is not anchored at its corner", () => { for (let i = 0; i < 400; i++) { app.world.update(16); } - return crate.pos.y + crate.height * (1 - crate.anchorPoint.y); + // where the bottom of the artwork ended up. A renderable that has + // opted out of the anchor draws from `pos` whatever its anchor says, + // so its own drawn bottom is a full height below `pos`. + const drawnTop = placesItself ? 0 : crate.height * crate.anchorPoint.y; + return crate.pos.y - drawnTop + crate.height; }; it("rests on the floor when anchored at its corner", () => { @@ -269,6 +276,15 @@ describe("a body whose renderable is not anchored at its corner", () => { // the platformer anchor: feet on the ground expect(restingDrawnBottom(1)).toBeCloseTo(360, 6); }); + + it("ignores the anchor on a renderable that places itself", () => { + // `applyAnchorTransform === false` is a renderable saying it draws at + // `pos` and pivots about its own origin, which is what `preDraw` + // reads and what `GLTFModel` and a `Camera3d`-space `Mesh` set. Its + // `anchorPoint` still holds the default (0.5, 0.5) and means nothing, + // so reading it here put the body half a bounds box off the artwork. + expect(restingDrawnBottom(0.5, true)).toBeCloseTo(360, 6); + }); }); describe("preDraw anchor offset", () => { diff --git a/packages/melonjs/tests/raycast3d-box3d.spec.js b/packages/melonjs/tests/raycast3d-box3d.spec.js index 6549996b3..7b7c60218 100644 --- a/packages/melonjs/tests/raycast3d-box3d.spec.js +++ b/packages/melonjs/tests/raycast3d-box3d.spec.js @@ -22,8 +22,15 @@ import { } from "../src/index.js"; /** - * A renderable centred on its position, carrying a `Box3d` body, added to - * `world` at depth `z`. + * A corner-anchored renderable carrying a `Box3d` body, added to `world` at + * depth `z`, so its collision frame origin IS its `pos` and every expectation + * below can be read straight off the numbers passed in. + * + * Corner-anchored on purpose: since 20.7 a body is measured from the frame its + * renderable DRAWS in, which `anchorPoint` shifts by `-size * anchorPoint`, so + * a centred renderable would put the box half its own size from `pos`. That + * shift is the subject of its own test at the end of this file rather than a + * term buried in every other one. * * Body attached BEFORE `addChild`, which is what registers it with the * physics adapter (it reads `child.body` at insertion time). `raycast3d` @@ -36,7 +43,7 @@ import { */ function addBoxBody(world, { x, y, z, w, h, d }) { const r = new Renderable(x, y, w, h); - r.anchorPoint.set(0.5, 0.5); + r.anchorPoint.set(0, 0); r.isKinematic = false; r.body = new Body(r, new Box3d(0, 0, 0, w, h, d)); world.addChild(r, z); @@ -244,4 +251,44 @@ describe("raycast3d — exact ray vs Box3d", () => { expect(hit).not.toBeNull(); expect(hit.renderable).toBe(target); }); + // Regression for the frame `raycast3d` measures in. + // + // 20.7 moved the narrowphase onto the renderable's DRAWN frame, so a body + // collides where its artwork is whatever the anchor. `raycast3d` builds + // its own world AABB and was left measuring from raw `pos`, which put a + // ray hit half a renderable away from the surface the same body collides + // on. Nothing failed: every existing case here was one body, and both + // halves of a query agreed with each other because neither was compared + // to the narrowphase. + // + // A floor probe is the whole reason this path exists, and it is exactly + // where the two disagreeing is worst: the character is placed on a surface + // the solver does not have there. + it("hits the box where the narrowphase collides with it", () => { + const world = new World(0, 0, 800, 600); + world.sortOn = "depth"; + + // A centred renderable: 200 wide and 20 tall at (300, 200), so it + // DRAWS from (200, 190) and its box top face is 10 below that. + const slab = new Renderable(300, 200, 200, 20); + slab.anchorPoint.set(0.5, 0.5); + slab.isKinematic = false; + slab.body = new Body(slab, new Box3d(100, 10, 0, 200, 20, 200)); + world.addChild(slab, 100); + world.update(16); + + // straight down the middle of the drawn frame + const hit = world.adapter.raycast3d( + { x: 300, y: 0, z: 100 }, + { x: 300, y: 400, z: 100 }, + ); + expect(hit).not.toBeNull(); + + // the top of the box, in the frame the renderable draws in + const drawnTop = slab.pos.y - slab.height * slab.anchorPoint.y; + expect(hit.point.y).toBeCloseTo(drawnTop, 5); + + // and the bounds the engine culls and draws with agree with it + expect(slab.getBounds().top).toBeCloseTo(drawnTop, 5); + }); }); diff --git a/packages/planck-adapter/CHANGELOG.md b/packages/planck-adapter/CHANGELOG.md index acd08576b..94aed1b4f 100644 --- a/packages/planck-adapter/CHANGELOG.md +++ b/packages/planck-adapter/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.6.1 - _2026-09-22_ + +### Fixed +- A renderable that places itself is built at its `pos`, whatever its `anchorPoint` holds. 1.6.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.6.0 - _2026-09-22_ ### Fixed diff --git a/packages/planck-adapter/package.json b/packages/planck-adapter/package.json index 060c3c7da..a3cd5acd1 100644 --- a/packages/planck-adapter/package.json +++ b/packages/planck-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@melonjs/planck-adapter", - "version": "1.6.0", + "version": "1.6.1", "description": "melonJS physics adapter for planck.js (Box2D)", "homepage": "https://www.npmjs.com/package/@melonjs/planck-adapter", "type": "module", diff --git a/packages/planck-adapter/src/index.ts b/packages/planck-adapter/src/index.ts index 044e6efb3..0dacf2075 100644 --- a/packages/planck-adapter/src/index.ts +++ b/packages/planck-adapter/src/index.ts @@ -395,15 +395,23 @@ export class PlanckAdapter 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; diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts index d2572a481..c3e206be5 100644 --- a/packages/planck-adapter/tests/parity.spec.ts +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -373,6 +373,42 @@ for (const { name, make, aabbPrecision, 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", () => {