Skip to content

ground-up 2D/3D rewrite with GLM, C++20, GJK+EPA, dynamic BVH, GLFW/OpenGL - #16

Open
IanRPage wants to merge 6 commits into
mainfrom
SUPER-REFACTOR
Open

ground-up 2D/3D rewrite with GLM, C++20, GJK+EPA, dynamic BVH, GLFW/OpenGL#16
IanRPage wants to merge 6 commits into
mainfrom
SUPER-REFACTOR

Conversation

@IanRPage

@IanRPage IanRPage commented Sep 3, 2026

Copy link
Copy Markdown
Owner

From-scratch rewrite of RPEngine's core, replacing SFML with GLFW/OpenGL, adding genuine 3D support alongside first-class 2D, and fixing three root-cause bugs from the current architecture in the process rather than patching around them:

  • A shape's cached transform going stale relative to its body's real position (two copies of transform state that only sync under specific conditions).
  • The solver silently ignoring the SAT-computed collision manifold and re-deriving penetration/normal from raw circle math instead.
  • An O(n²) pairwise collision-dispatch matrix that doesn't extend to 3D, plus a confirmed bug where broadphase is fully rebuilt on every solver iteration instead of once per step.

Some key architectural decisions

  • Math: adopt GLM (Vec2f/Vec3f, Quatf, Mat3f/Mat4f) for generic linear algebra only — GJK, EPA, the BVH, the solver, and mass-property formulas stay hand-written.
  • C++20, for concepts (a Shape concept over support/localInertiaTensor/boundingRadius) and std::span.
  • One Vec3f + Quatf transform for every body, 2D or 3D — "2D mode" is a constraint applied via a free-function seam, not baked into Body/solver/BVH.
  • Body is the only place a transform lives. Collider and Renderable take it as a parameter on every call — nothing caches a second copy that can go stale.
  • std::variant-based shapes, each implementing its own support(), boundingRadius(), localInertiaTensor(), localAABB() — no shared cross-shape formula, ever. One GJK+EPA implementation replaces the pairwise SAT dispatch matrix entirely.
  • Persistent dynamic AABB tree (BVH) for broadphase, running exactly once per fixed step — the direct fix for the once-per-solver-iteration rebuild bug.
  • Persistent, warm-started multi-point manifolds with real Coulomb friction; the solver's only input is const Manifold& — no code path re-derives geometry from raw positions.
  • Fixed timestep + render interpolation, decoupled from display refresh rate.
  • GLFW + GLAD + OpenGL 4.1 core (capped at 4.1 for macOS compatibility), GPU-instanced rendering for the 100k-particle/60fps target.
  • World boundaries are static bodies, not special-cased wall-bounce code.

Claude helped write this

remove SFML, add GLM, remove old sources, general cleanup
* finalize Types.hpp w matrix_transform header

* add Transform struct w point/direction/inverse/compose operations

* add quaternion integration and nlerp for rotation

* add 2D constraint "seam" for position and velocity

* addressing code rabbit comments

- use linear interpolation for `nlerp` in Rotation.cpp
- derive expected midpoint from corrected endpoint in test_rotation.cpp
- test `compose` directly in test_transform.cpp
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 7f7d9bfd-b7a9-4c02-92a1-56bec048b97b


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

* add collision shapes with support/inertia/AABB formulas

adds SphereShape, BoxShape, CapsuleShape, and ConvexHullShape, each
owning its own support(), boundingRadius(), localInertiaTensor() &
localAABB()

- ShapeVariant + worldSupport()/worldAABB() free functions. later,
  GJK/EPA will take these in
- MassProperties + computeMassProperties()
- math/Constants.hpp for VECTOR_LENGTH_EPSILON and PI. for future shared
  constants so there's no magic numbers anywhere
- test_shapes.cpp, test_world_support.cpp, test_mass_properties.cpp

* fix CodeRabbit findings in shape support/inertia and mass-property guards

- CapsuleShape::support normalized only XZ radial projection instead of
  full direction. a capsule's support point is segment.support(d)
  + sphere.support(d), and sphere term needs full 3D direction
    normalized (confirmed via Minkowski-sum reasoning)

- CapsuleShape::localInertiaTensor's cap parallel-axis coefficient was
  3/8, but correct value is 3/4. confirmed by integration via thin-disk
  decomposition over the hemisphere caps

- ConvexHullShape::localInertiaTensor set Ixx = Iyy = Izz/2, which only
  holds for X/Y-symmetric shapes

- ConvexHullShape's constructor accepted zero vertices, which
  support()/localAABB() then index/dereference unconditionally (UB). now
  throws std::invalid_argument

- MassProperties::invertDiagonal only guarded isStatic/mass<=0 case, but
  degenerate ConvexHullShape (near-zero area) w positive mass produced
  zero inertia diagonal that then divided by zero, and thus to +inf. now
  guards each diagonal entry individually.

updated test_shapes.cpp's hand-derived reference values to match the
corrected formulas, added regression tests for each fix
* delete QuadTree from code that uses it, and apply clang-format

* committing more clang formatted files

* broadphase: add PairCache for incremental candidate-pair tracking

add persistent candidate-pair tracking that only requeries tree for
bodies whose moveProxy() call ACTUALLY mutated their leaf. fully
rechecks pass to drop pairs that stopped overlapping

* broadphase: add persistent dynamic BVH

add fattened-leaf AABB tree: cost-based sibling insertion, AVL
rebalancing, and moveProxy()'s no-op fast path for in-margin moves

* core: add BodyHandle and a minimal BodyStore

added stable body handle & slot-map that later broadphase code will need
to reference bodies by AABB + displacement

* broadphase: IBroadphase & Naive/Grid/DynamicBVH implementations

add debug-layer and its three impls: brute-force port of old
naiveBroadphase, 3D port of SpatialGrid, and DynamicBVH-backed default

NOTE: selection between these meant to happen once on a cold ImGui path

* deprecate/reture QuadTree

remove QuadTree now that DynamicBVH succeeds it. it used to be that
every particle was reinserted on every solver iteration, not once per
step

* update core and test CMakeLists.txt

* format test_world_support.cpp

* fix two conservative overlap bugs mentioned from review

PairCache::update() checked its new-pair dedup set before purging stale
entries, so a DynamicBVH node id that's freed and reused for a different
body within the same step **could** have its new overlap silently
swallowed by a leftover key. reordered it so stale entries purged first,
then discover

GridBroadphase scanned each body's neighborhood using only its own
radius, so a small body could miss a large center-distant body whose
AABB reached it. scans now expand by the grid's largest body radius
* collision: add dimension-agnostic GJK overlap test

implements gjkOverlap/SupportPoint/minkowskiSupport.
line/triangle/tetrahedron simplex reduction handles 2D and 3D uniformly
w no shape-specific branching. GjkResult::simplex carries each point's
originating A-side/B-side support since EPA's contact recovery needs
that provenance. also includes fixes for exact-tie floating-point cases
that surface readily on axis-aligned test geometry

* collision: add EPA penetration depth and normal recovery

expands GJK's terminal simplex into a polytope and returns penetration
depth, world-space normal, and contact points on each shape. branches on
GJK's simplex size (3 -> 2D edge-insertion polygon, 4 -> 3D
face-expansion polytope) rather than a body-level 2D flag, since a
coplanar Z=0 simplex can never form a 3D tetrahedron

* collision: generate multi-point manifolds via face/edge clipping

adds Manifold/ManifoldPoint and buildManifold, turning EPA's single
deepest point into a stable multi-point contact via Sutherland-Hodgman
clipping. spheres and other 3D non-box pairs fall back to EPA's single
point

* collision: add persistent ManifoldCache with warm-start point matching

per body pair manifold storage backed by a flat, index-addressed vector
instead of per pair heap allocs. updateManifold carries
normalImpulse/tangentImpulse forward across frames by matching points on
current frame world anchor proximity

* test: port double-dispatcher SAT narrowphase scenarios as GJK/EPA regression

* update all CMakeLists.txt

* address code rabbit review comments
* core: extend BodyStore with full per-body dynamics state

BodyStore held only AABB/displacement for broadphase. added
position/orientation, velocities, invMass, invInertiaBody,
friction/restitution, and 2D-constraint flag via new BodyDesc
constructor that derives initial AABB from shape+transform. kept
AABB-only overload for defaulting to a static body

* dynamics: add semi-implicit Euler integrator

integrateVelocity/integratePosition apply gravity and advance
position/orientation for non-static bodies, then apply2DConstraint where
flagged. they're behind a small IIntegrator seam so future ntegrators
are a new class, not a full-blown call site rewrite

* dynamics: add tangent-basis construction for friction

computeTangentBasis builds orthonormal basis from a contact normal for
Coulomb friction, using the standard two-branch construction to avoid
near-parallel degeneracy. also added INV_SQRT_3 to math/Constants.hpp so
we don't have magic numbers all over

* dynamics: add warm-started velocity/position solver with friction

warmStart seeds velocities from cached impulses. solveVelocity resolves
normal impulse plus box-clamped friction. solvePosition applies
NGS-style correction from penetration recomputed off current transforms.
everything reads from Manifold/BodyStore state, so no rederivation from
raw positions

also adds prepareRestitutionBias. restitution target needs to be sampled
once from pre warm-start relative velocity and held fixed across
iterations (recomputing it fresh each iteration makes impulse oscillate
and decay instead of converging)

* core: add World, wiring broadphase/narrowphase/solver into one step()

World owns BodyStore, IBroadphase, ManifoldCache, SolverConfig, and
implements world step. step() runs broadphase and
GJK/EPA/manifold-update once, then iterates solveVelocity/solvePosition.
Broadphase and GJK/EPA are swappable via setBroadphase/setNarrowphaseFns

addWorldBoundaries replaces old applyWall logic w thin static BoxShape
bodies at world bounds, going through same step() pipeline as other
bodies

* update all CMakeLists.txts with added files

* add .clang-format to enforce codestyle

* apply formatting to all code

* address code rabbit review findings

- BodyStore::allocateSlot: fully reset a recycled slot. it was leaking
  linearVelocity/angularVelocity from a removed body into whatever new
  body reuses its index
- Solver::solvePosition: fix reversed penetration sign (worldAnchorA -
  worldAnchorB, not B - A). currentPenetration was always negative for
  real overlaps, making position correction a silent no-op
- Integrator::integratePosition: apply2DConstraint before
  integrateOrientation, not after. out-of-plane angular velocity can't
  get baked into orientation first
- World: normalize a null broadphase to the default DynamicBVHBroadphase
  and ignore empty narrowphase callbacks. step() is noexcept and calls
  both unconditionally
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