Physics: collision shapes follow the renderable, and segments collide as segments - #1693
Merged
Merged
Conversation
`anchorPoint` says where in its own bounds a renderable sits on its `pos`: `updateBounds()` shifts its bounds by `-size * anchorPoint` and `preDraw` shifts its pixels by the same amount. Collision shapes were measured from `pos` regardless, so anything not anchored at its top-left corner collided where it was not drawn, by half a body at the default centred anchor and by a full body height at the bottom anchor a platformer actor uses. `Entity` sets its own anchor to (0, 0), as Tiled objects do, which is why the legacy routes never saw this and only the `bodyDef` path is affected. The physics was never wrong in itself, which is what made it slippery: a body rested exactly on the floor at either anchor, and collisions fired normally. Only the hitbox sat somewhere the artwork was not, and anything drawn from `draw()` shifted with the sprite and hid the gap. Applied at the one place local shape coordinates become world positions: the SAT for the builtin solver, and the body origin in each adapter. Authored shapes keep their own coordinates, so the rotation pivot, `applyForce`'s lever arm and the shape pools all still read what the caller wrote. The readback follows the same frame, or the debug overlay would draw a body half its size away from where it collides. The planck offset that maps the body back onto `renderable.pos` keeps referencing the real `pos` rather than the shifted origin; feeding the anchor back in twice made a static body appear to jump on its first step. Also fixes matter placing a polygon by the arithmetic mean of its vertices where `Bodies.fromVertices` puts the area centroid, which shifted every polygon by the difference. The two coincide for a rectangle and for any triangle, so no simple fixture could see it; a traced outline from a shape editor drifted 39px on a 200px blob. Tests pin the contract as behaviour rather than as coordinates: a body comes to rest with its drawn bottom edge on the floor whatever the anchor, across the builtin, matter and planck adapters, with anchor 0 as a passing control. Fixtures that did their arithmetic in `pos + size` terms now state the corner anchor they always assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The scene drew only the collision outlines each adapter reports, which looks correct by construction: the outline IS the visual, so a body placed wrong draws an outline that is wrong in exactly the same way and nothing seems amiss. That is how the matter polygon placement bug survived in a scene built to show off shape loading. The sprites are generated FROM the shape file by `scripts/generate-physics-shape-sprites.mjs` (`npm run sprites`), in the same coordinate space, so artwork and collision geometry share one source of truth and any gap between them on screen is a real bug. The generator rasterises the outlines itself and encodes the PNGs through `node:zlib`, so it pulls in no new dependency and is byte-for-byte reproducible. `autoTransform` is on now. It was off because the reported shapes come back already posed and `preDraw` would have turned them twice, but with a sprite in the scene that flag also left `updateBounds()` reporting the UNROTATED frame, so the debug panel's green box sat square around a tilted body. The sprite rides `preDraw` like any ordinary renderable and the shapes have the transform undone instead. On the builtin solver the example calls `body.rotate()` rather than `setAngle`, since the SAT never reads `body.angle`: `setAngle` would have turned the artwork alone and left every hitbox upright under it. Also registers the debug panel (S), which is what makes all of this checkable, and matches gravity across the three backends: measured in this scene the defaults spread thirteenfold (builtin ~4450 px/s2, matter ~900, planck ~330), so the piles were never comparable. Matter is the reference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
…irst The narrowphase reports ONE contact per body pair: it returns at the first overlapping shape pair it finds. The extra separation pass for compound bodies re-ran that same first-hit-wins scan, so it kept re-resolving the pair it had already resolved, always with an overlap of zero, and never looked at the siblings. A crate resting across the junction of a Tiled polyline, which arrives as one body carrying one shape per segment, therefore rode the segment it was found against while sinking into its neighbour unmeasured: 0.9px per frame with no vertical velocity, depth growing 3, 5, 7, 10. When the first pair finally separated the neighbour was reached at 18px deep, and a zero-area segment's shortest exit from that far below points through the line, so the crate was ejected downward and fell out of the world. The pass now enumerates the shape pairs itself and corrects each overlapping solid pair, measuring every pair against the position the previous corrections left, and exits on the first clean sweep. `collides()` keeps its semantics exactly: it is public, the adapters and the quadtree spec use it, and making an overlap of zero stop counting as a contact there would have broken `onCollisionActive` and the falling flags for every resting body. Counted on a scene of 20 crates over a 60-segment ground, this costs 2070 SAT tests per step against the old 2760: the early exit more than pays for enumerating, because each of the three old re-scans walked most of the shape list before reaching the resting contact. The detector's private members lose their underscore prefix to match the rest of the API. `_contactId` keeps its own, deliberately: it is not a Detector field but a marker `Body#addShape` stamps onto shape objects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Three shapes a game can author were being silently replaced by something else, each in its own way. A `Line` is two points and no area, and it is what Tiled emits for every polyline, so it is the natural way to draw a slope or a strip of ground. matter refuses a zero-area outline and the adapter fell back to the line's AXIS-ALIGNED BOUNDING BOX, turning a diagonal into a solid block. Box2D does not refuse it at all: `b2PolygonShape` substitutes `SetAsBox(1, 1)` for a polygon with fewer than three vertices, so the segment became a one-metre square, 64px at the default scale, sitting wherever the body was. Both now build a thin quad along the segment. It is built from explicit vertices rather than a rectangle with an angle, because an angle belongs to the BODY: `syncFromPhysics` mirrors it onto the renderable's transform, and a sprite would have been turned by the slope of its own ground, with the already-posed shapes drawn turned a second time. A `RoundRect` extends `Polygon`, not `Rect`, and carries 36 points for its corner arcs. Box2D caps a polygon's vertex count and truncates past it without a word: an 80x40 rounded rect was simulated as a 10x34 blob. The cap was measured against planck rather than taken from the header, and it is 12. An outline over it is now sampled down evenly, which keeps the extent and the silhouette, with the worst deviation from the authored outline at 0.7px on a 10px corner radius. One fixture rather than a decomposition on purpose: splitting a convex outline introduces interior seams for a body to catch on. `Point`, `Box3d` and `Sphere` now throw on planck as they already did on matter. Returning null left the body with no collision geometry at all and said nothing, so it simply never collided, and it did so on only one of the two backends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
…to date The example draws two layers on every piece of ground: the shape as AUTHORED, and the geometry the adapter reports as colliding. Four cases, a Rect control, a horizontal and a diagonal `Line`, and a polyline of three segments, on a backend switcher with the debug panel registered. The two layers are the point. An overlay drawn from the adapter alone always looks self-consistent, because the outline IS the visual, so a body placed wrong draws an outline that is wrong in exactly the same way. Every placement bug fixed in this line of work hid behind that. The skill gains a shape-support matrix across the three backends, the vertex cap, and a warning not to diagnose a physics failure by reading a body's final `y`: a body that slid off the end of a ramp and one that fell straight through it both finish far below and are indistinguishable in that number. Tracking against the surface at the body's current x is what separates them, and it is what localised two of these bugs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
matter-adapter 1.5.0 and planck-adapter 1.6.0 are being released. melonjs stays `_unreleased_`: the adapter fixes read only `anchorPoint`, `width` and `height`, API that has always existed, so neither needs a newer engine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five fixes across the engine and both adapters, each found by measurement and each pinned by a mutation-tested spec.
Collision shapes follow the anchor
anchorPointshifts a renderable's bounds and its rendering by-size * anchorPoint, but collision shapes were measured fromposregardless. Anything not anchored at its top-left corner collided where it was not drawn — half a body off at the default centred anchor, a full body height at the bottom anchor a platformer actor uses.Entityand Tiled objects set their own anchor to (0,0), which is why only the newerbodyDefpath was exposed.Verified against the reporter's own build: with their generator's compensation removed, the reported geometry matches where the sprite draws to 0.00px at 0°, 40° and 90°.
A
Linecollides as the segment it isLineis what Tiled emits for every polyline. Both adapters silently replaced it:SetAsBox(1,1), Box2D's fallback for <3 vertices)Built from explicit vertices, not a rectangle with an
angle: an angle belongs to the body, andsyncFromPhysicsmirrors it onto the renderable, which would turn a sprite by the slope of its own ground. That one has its own regression test.Every shape of a compound body is separated
The narrowphase returns at the first overlapping shape pair, and the extra separation pass for compound bodies re-ran that same first-hit-wins scan — re-resolving the pair it had already resolved, always at overlap zero, while a sibling was penetrated unmeasured.
A crate on a Tiled polyline junction sank 0.9px/frame with no vertical velocity (depth 3 → 5 → 7 → 10), then was ejected through the ground once the neighbour was finally reached at 18px deep.
collides()keeps its exact semantics — it is public, and making a zero overlap stop counting there would breakonCollisionActiveand the falling flags for every resting body. Measured cost on 20 crates over a 60-segment ground: 2070 SAT tests/step vs 2760 before.RoundRect, and shapes a 2D solver cannot expressRoundRectextendsPolygonand carries 36 points. Box2D caps vertices and truncates silently — an 80×40 rounded rect was simulated as a 10×34 blob. The cap was measured against planck (it is 12, not the documented 8); outlines over it are sampled down, worst deviation 0.7px on a 10px radius.Point,Box3dandSpherenow throw on planck as they already did on matter. Returningnullleft the body with no collision geometry at all, silently, on one backend only.Also
Bodies.fromVerticesputs the area centroid; a traced outline drifted 39px on a 200px blob. The two coincide for rectangles and triangles, which is why no simple fixture caught it.detector.jsprivate members drop their underscore prefix._contactIdkeeps its own: it is a markerBody#addShapestamps on shape objects, not a Detector field.Examples
A new Line Collision example draws the shape as authored against the geometry each adapter reports as colliding. That comparison is the point — an overlay drawn from the adapter alone always looks self-consistent, because the outline is the visual, which is how every one of these placement bugs stayed hidden. The physics shapes example gains generated artwork for the same reason.
Verification
melonjstrigger_level_changeflake)@melonjs/matter-adapter@melonjs/planck-adapter@melonjs/debug-plugin~20 new specs; six behaviours mutation-tested (reverting each fix fails its test). Adapters bumped to matter 1.5.0 and planck 1.6.0, both minors per the precedent for behaviour-changing fixes, and both independent of the engine change.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t