Skip to content

feat: physics contact callbacks, trigger fix, real ray and overlap queries - #201

Open
CanReader wants to merge 2 commits into
fix/physics-fixed-timestepfrom
feat/physics-contacts-and-queries
Open

CanReader wants to merge 2 commits into
fix/physics-fixed-timestepfrom
feat/physics-contacts-and-queries

Conversation

@CanReader

Copy link
Copy Markdown
Owner

What this changes

Stacked on #197 (it touches the same PhysicsWorld::Step), so the diff against fix/physics-fixed-timestep is the part to review.

PhysicsWorld::Step is split into integrate, broadphase, collect pairs, resolve, dispatch. Pairs are collected from the tree first (only pairs where at least one side has a non-static rigidbody), then each one gets its narrowphase against the current poses and is resolved:

  • a trigger never pushes anything, on either side
  • two dynamic bodies split the correction by inverse mass instead of both taking the full penetration
  • a kinematic body pushes a dynamic one without being pushed, which is what the RigidbodyComponent doc already promised

Touching pairs are kept in a list sorted by proxy id pair and diffed against the previous step's, and the result goes out as new Component virtuals: OnCollisionEnter/Stay/Exit for solid pairs, OnTriggerEnter/Stay/Exit when either side is a trigger, sent to every component on both objects with a Physics::CollisionEvent (self, other, point, normal pointing from self to other, penetration). A callback can remove or destroy objects, its own included; the world notices and skips whatever is gone.

Raycast now uses DynamicAABBTree::RayCast as the broadphase and tests the real shape: slab for boxes, ray vs sphere, ray vs capsule (cylinder body plus the two cap spheres), and ray vs triangle mesh done in the mesh's local space so a scaled mesh is not copied per ray. It returns the exact point, surface normal and distance in world units, takes an unnormalized direction, and skips colliders that contain the origin (so a ray cast from inside the player's own collider does not hit the player). The shape tests are public in CollisionDetection.hpp (RaycastAABB/Sphere/Capsule/Triangle/Shape).

OverlapSphere and OverlapAABB run TestCollision on every broadphase candidate and return std::vector<OverlapHit> (collider, point, normal, penetration) instead of CollisionPairs with a == nullptr and an empty manifold.

ColliderComponent caches its rigidbody, transform and camera, keyed on a new GameObject::GetComponentVersion() that AddComponent/RemoveComponent bump, so a step no longer runs GetComponent (a dynamic_cast per component) several times per collider and per pair, and a removed rigidbody can never leave a dangling cached pointer. GameObject::GetComponents() is new for the callback dispatch. RigidbodyComponent::ApplyContactNormal is the velocity and ground/wall half of ResolveCollision so the world can move the body through the cached transform. PhysicsWorld and DynamicAABBTree get ENGINE_API.

Why

Triggers: the old loop only skipped pairs where both colliders were triggers, so trigger vs dynamic fell through to ResolveCollision and shoved the body out of the volume, while the ColliderComponent doc shows a pickup volume that "never pushes". The manifold was also thrown away on every path, so a trigger could not report anything either.

Double separation: both sides of a dynamic pair got ResolveCollision(normal, penetration) with the full penetration and translated by it, so two bodies ended up twice as far apart as needed and popped. Mass was stored and never read.

Raycast was SphereSweep(origin, direction, 0.01f, ...), which tests the ray against each collider's world AABB grown by 1cm. Every sphere and capsule was hit as its bounding box, grazing rays hit things a ray misses, and the normal was guessed from which side of the box center the point was on. DynamicAABBTree::RayCast existed and nothing called it.

Fixes #11
Fixes #12
Fixes #17
Fixes #24

Not covered here: SphereSweep is still the fattened AABB approximation. Capsule vs mesh and mesh vs mesh still have no narrowphase (pre-existing, TestCollision returns no hit), so those pairs neither resolve nor report. Static vs static pairs are no longer tested at all, which also means no callbacks between two static colliders. A pair whose collider is unregistered (object removed or destroyed) ends silently, no Exit, so user code never runs against an object that is being deleted. SleakCraft does not touch any of this: its player has the only collider in the scene and resolves against voxels itself, which I did not change.

How it was verified

  • Builds on Linux
  • Ran the sample scene on OpenGL

SleakCraft built against this branch and ran under xvfb on Mesa llvmpipe (-r opengl -world citest -seed 42 -rd 4) for 90s: world created, no errors, survived to the timeout. Also repeated the temporary grounded/jump logging from #197 here, same result (grounded on all but the first couple of frames, jump peak 1.033m).

Throwaway harness with the five physics sources compiled in under ASan and UBSan, the rest linked from libEngined.so, driving a Scene subclass:

== trigger + floor, falling ball
  [zone] OnTriggerEnter other=ball-7
  [ball] OnTriggerEnter other=zone-4
  [zone] OnTriggerExit other=ball-7
  [ball] OnTriggerExit other=zone-4
  [floor] OnCollisionEnter other=ball-7 normal=(0.00 1.00 0.00) pen=0.005
  [ball] OnCollisionEnter other=floor-0 normal=(-0.00 -1.00 -0.00) pen=0.005
zone trigger enter/stay/exit=1/14/1  ball trigger enter/exit=1/1
ball collision enter/stay/exit=1/128/0  ball y=0.250 max x drift=0.0000 grounded=1
== mass split and kinematic
light moved 0.1500, heavy moved 0.0500, gap now 1.0000 (touching = 1.0)
kinematic x=5.0000 (was 5), dynamic x=6.0000 (pushed to 6)
== raycast
+z from origin (sphere first)      sphere-34 d=4.500 p=(0.00 0.00 4.50) n=(0.00 0.00 -1.00)
+z grazing sphere at x=0.505       box-30 d=9.000 p=(0.50 0.00 9.00) n=(0.00 0.00 -1.00)
+z maxDist 4 (short)               miss
+z layer mask 0                    miss
from inside sphere                 box-30 d=4.000 p=(0.00 0.00 9.00) n=(0.00 0.00 -1.00)
-x into capsule side               capsule-38 d=6.500 p=(3.50 0.30 0.00) n=(1.00 0.00 0.00)
-y onto capsule cap                capsule-38 d=8.500 p=(3.00 1.50 0.00) n=(0.00 1.00 0.00)
along capsule axis from below      capsule-38 d=8.500 p=(3.00 -1.50 0.00) n=(0.00 -1.00 0.00)
zero direction                     miss
mesh quad scaled x4, inside footprint hit=1 d=3.000 n=(-0.00 1.00 -0.00)
mesh quad, just outside footprint  hit=0
mesh quad from below (two-sided)   hit=1 d=7.000 n=(0.00 -1.00 0.00)
== overlap
sphere near box corner (AABB overlap, shape miss): 0 hits
sphere touching box face: box-41 n=(-1.00 -0.00 -0.00) pen=0.100
aabb spanning both: 2 hits
aabb in sphere's bounds corner only: 0 hits
== remove objects from inside a callback, and rigidbody removal
trigger enters=2 after removing one occupant inside the callback
object removed itself inside its own callback, zone enters=3
rigidbody removed, step ok, other still at x=0.500
clean exit

The ball falls through the trigger zone with zero sideways drift (the zone used to push it out), the dynamic pair separates by exactly the penetration split 1:3, and the grazing ray at 0.505 now misses the 0.5 sphere instead of hitting its padded box.

Checklist

  • Public headers documented with /// comments
  • No new includes from src/ or include/private/ in public headers
  • No game-specific vocabulary added to engine API

…eries

Pairs are collected first and resolved second with fresh poses. Triggers no longer push, dynamic pairs split the correction by inverse mass, and touching pairs are tracked across steps to send OnCollision/OnTrigger Enter/Stay/Exit to every component on both objects. Raycast walks the tree's ray query and tests the actual shapes; the overlap queries do a narrowphase and return OverlapHit. Colliders cache their rigidbody and transform keyed on a new GameObject component version.
… feat/physics-contacts-and-queries

# Conflicts:
#	src/Physics/PhysicsWorld.cpp
@CanReader
CanReader added this pull request to stack #215 October 8, 2026 11:10

This branch has not been deployed

No deployments
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