Skip to content

fix(physics): compound colliders for rigidly-attached children - #256

Merged
marco-ponds merged 3 commits into
masterfrom
fix/compound-child-colliders
Jul 14, 2026
Merged

fix(physics): compound colliders for rigidly-attached children#256
marco-ponds merged 3 commits into
masterfrom
fix/compound-child-colliders

Conversation

@marco-ponds

Copy link
Copy Markdown
Collaborator

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 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 per-child collision identity is preserved.

Changes

Compound bodies (worker + engine)

  • constants.js / messages.js — new TYPES.COMPOUND and ADD.COMPOUND event.
  • worker/elements.jsaddCompound() builds a btCompoundShape (parent shape + child shapes at their local offsets) plus a childMap.
  • 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-child OnCollision / applyImpulse still fire.
  • physics/index.jsPhysics.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.jsdefer 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

  • New suites: worker/__tests__/compound.test.js, worker/__tests__/collisionResolve.test.js, __tests__/realizeSubtree.test.js, __tests__/rebuildReparent.test.js.
  • Full suite: 528 passing.
  • scripts/verify-compound-colliders.js — a real-Ammo integration harness (loads the vendored dist/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

  • Verified in mage-studio: a tilting-labyrinth platform with a wall child and a rolling ball now collides correctly at any orientation.
  • Out of scope: independently-simulated (non-welded) physics children.

🤖 Generated with Claude Code

marco-ponds and others added 3 commits July 6, 2026 09:33
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
marco-ponds marked this pull request as ready for review July 14, 2026 15:14
@marco-ponds
marco-ponds merged commit 6b97a31 into master Jul 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant