fix(physics): compound colliders for rigidly-attached children - #256
Merged
Conversation
Child colliders (e.g. a wall parented to a kinematic floor) had no working collision box: the engine created one independent world-space rigid body per element and never told a child's body about its parent's motion, so rotating a kinematic parent moved the child visually but left its collider frozen and mis-placed. A sphere would pass straight through the wall. Represent a parent + its rigidly-attached (scene-graph child) colliders as a single Ammo btCompoundShape body, the engine-native way to model one rigid body made of several shapes. Rotating the root now carries every child collider with it for free — no per-frame sync — and it works for dynamic parents too. - worker: ADD.COMPOUND event + TYPES.COMPOUND; addCompound() builds a btCompoundShape and a childMap; compound bodies report transforms like boxes. - collisions: resolveChildUuid() maps a contact's body-local point back to the specific child shape so per-child OnCollision/applyImpulse still fire, and dispatches to both the child and the compound root. - Physics.realizeSubtree() gathers a root's welded physics descendants, computes each collider's transform in the root's local frame, and emits one compound (or the normal single-body path when there are no physics children). - Importer defers physics during element creation (deferPhysics) and realizes it after the hierarchy is wired up, so parent+children resolve into one body. Tests: jest logic tests for addCompound, resolveChildUuid, and realizeSubtree's root-local transform math; a real-Ammo integration harness (scripts/verify-compound-colliders.js) proves collision response end-to-end (sphere blocked by wall child, wall collider rotates with the parent, control passes through). Full suite: 524 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The compound body built at import time became stale when an element was reparented at runtime: a child leaving its parent left its collider welded to the parent's compound (a ghost collider), and the departed child got no collider of its own. Reparenting only touched the THREE scene graph, never physics. Reconcile physics on reparent. Element.reparent now calls Physics.rebuildAfterReparent(element, oldParent), which tears down the affected compound bodies on both sides of the move and re-realizes the impacted roots from the current hierarchy: - child leaves to scene root: old compound rebuilt without it; child becomes its own body. - child moves between two physics parents: both roots rebuilt (one loses it, one gains it). - the whole thing works for a moved subtree, and folds independent bodies back into a new parent's compound. It no-ops until physics has been realized (elements empty), so import-time parenting — which uses parent.add(), not reparent() — is unaffected, and it no-ops in the editor where physics is disabled. Tests: jest logic tests for the leave-to-root, move-between-parents, and import-safety cases; the real-Ammo harness gains a rebuild scenario proving the wall collider physically disappears after a reparent-out. Full suite: 527 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wall child collided but its collider sat in the wrong place. Two placement bugs in buildCompoundShape: 1. Size came from the world-axis-aligned AABB (getBoxDescriptionForElement). For a child rotated relative to the root — e.g. an upright wall on a platform — that AABB is inflated, and localQuaternion then rotated the inflated box again, so the collider was both too big and mis-placed. 2. Position used the element origin, ignoring geometry that is offset from it, and a parent shape measured via Box3.setFromObject swallowed its children's geometry (child bodies are THREE children of the parent body). Replace with measureCollider(): measure each collider from its OWN geometry only (skipping the compound's other collider bodies), take the size from the *un-rotated* extents (apply unrotate*matrixWorld in one transform so the box isn't collapsed to an inflated AABB first), and centre the shape at the true world-AABB centre. Handles rotation, scale, geometry offset, and multi-mesh models; dist rebuilt so the builder/editor pick it up. Adds a real-geometry test: a wall rotated 45° keeps its true 1x4x0.2 extents (not the ~0.85 inflated footprint) and the floor shape no longer swallows it. Full suite 528 passing; real-Ammo harness 4/4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
marco-ponds
marked this pull request as ready for review
July 14, 2026 15:14
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.
Problem
When an element is parented to another (e.g. a wall added to a kinematic floor/platform), only the parent got a physics collider. The engine created one independent, world-space rigid body per element and never told a child's body about its parent's motion — so rotating/tilting a kinematic parent moved the child visually but left its collider frozen and mis-placed. A ball rolling on the platform passed straight through the wall.
Approach
Represent a parent plus its rigidly-attached (scene-graph child) colliders as a single Ammo
btCompoundShapebody — the engine-native way to model one rigid body made of several shapes. Rotating the root now carries every child collider with it for free (no per-frame sync), and per-child collision identity is preserved.Changes
Compound bodies (worker + engine)
constants.js/messages.js— newTYPES.COMPOUNDandADD.COMPOUNDevent.worker/elements.js—addCompound()builds abtCompoundShape(parent shape + child shapes at their local offsets) plus achildMap.worker/index.js/worker/world.js— route the new event, step compound bodies, and resolve each contact back to the specific child (resolveChildUuid) so per-childOnCollision/applyImpulsestill fire.physics/index.js—Physics.realizeSubtree()gathers a subtree's physics elements and emits one compound (or the normal single-body path when there are no physics children).entities/Element.js/core/Importer.js— defer physics during import (deferPhysics+isPhysicsEnabled), then realize each root after the hierarchy is wired up.Rebuild on runtime reparent
Element.reparent()→Physics.rebuildAfterReparent(): when a child leaves / joins / moves at runtime, the affected compound bodies are torn down and rebuilt from the current hierarchy. No-ops during import and in the editor (physics disabled), so those paths are unaffected.Correct collider placement
measureCollider()sizes each shape from its un-rotated extents (so a wall rotated relative to the platform isn't inflated by a world-AABB and then rotated again), positions it at the true geometry center (handles geometry offset from the element origin), and measures only the element's own geometry (a parent shape no longer swallows its children).Tests
worker/__tests__/compound.test.js,worker/__tests__/collisionResolve.test.js,__tests__/realizeSubtree.test.js,__tests__/rebuildReparent.test.js.scripts/verify-compound-colliders.js— a real-Ammo integration harness (loads the vendoreddist/ammo.js, steps a real world) proving collision response end-to-end: wall child blocks the ball, a rotated compound carries the wall collider, and a reparent-out removes it (4/4).Notes / follow-ups
🤖 Generated with Claude Code