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", () => {