feat(physics): phase 16 - ludic.physics over Jolt Physics v5.6.0

Our own shim (native/shim/jph_shim.cpp) over Jolt built from the pinned tag with cross-platform
determinism and no fp contraction: shapes as handles (box, sphere, capsule, cylinder, dome,
offset, heightfield, mesh), still/kinematic/dynamic bodies by id with real removal, rays,
top_at and push (what CharacterGround asks), overlaps, buoyancy against the PhysWater port,
contacts recorded in C and drained as PHYS_HIT / PHYS_SPLASH facts. Fixed 1/60 steps, at most four
a frame. Nine tests against the real library, including a pile run twice to the bit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-26 23:41:37 +03:00
parent 5d3a799e33
commit 72dbdca1f3
21 changed files with 1152 additions and 0 deletions

View file

@ -0,0 +1,28 @@
#!/bin/sh
# builds lib/<target>/ for ludic.physics: Jolt Physics at a pinned tag, and the shim over it.
# Cross-platform determinism is on (the Mac and the PC must agree in co-op), and floating point
# contraction off, which it needs. Objects go to build/native; about a minute on eight cores.
set -eu
PKG="$(cd "$(dirname "$0")/.." && pwd)"
. "$PKG/../../tools/native/lib.sh"
JOLT_TAG=v5.6.0
JOLT_SHA=6e069ee0172478cc78182047aac87e5310ba14a67a53348ae14cc37801fd3f8e
SRC="$PKG/build/src/jolt-$JOLT_TAG"
native_fetch "$SRC" "https://github.com/jrouwe/JoltPhysics/archive/refs/tags/$JOLT_TAG.tar.gz" "$JOLT_SHA"
OBJ="$PKG/build/native/$(native_target)"
mkdir -p "$OBJ"
CXX="$(native_cxx)"
DEFS="-DNDEBUG -DJPH_CROSS_PLATFORM_DETERMINISTIC -DJPH_OBJECT_LAYER_BITS=16"
FLAGS="$(native_cflags) -std=c++17 -ffp-contract=off -fno-exceptions -fno-rtti $DEFS -I$SRC"
case "$(native_target)" in windows-*) FLAGS="$FLAGS -D_CRT_SECURE_NO_WARNINGS" ;; esac
JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
# one object per Jolt source, named by its path so two files called the same do not collide
( cd "$SRC" && find Jolt -name '*.cpp' ) | while read -r f; do
o="$OBJ/$(echo "$f" | tr '/' '_' | sed 's/\.cpp$/.o/')"
[ "$o" -nt "$SRC/$f" ] || echo "$f $o"
done | xargs -P "$JOBS" -n 2 sh -c '"$0" '"$FLAGS"' -c "'"$SRC"'/$1" -o "$2"' "$CXX"
"$CXX" $FLAGS -I"$PKG/native/shim" -c "$PKG/native/shim/jph_shim.cpp" -o "$OBJ/jph_shim.o"
native_link "$PKG" ludicjolt "$OBJ"/*.o

View file

@ -0,0 +1,64 @@
// jph_bodies.inl - bodies by id: added, removed, moved, pushed, read back
// motion 0 still, 1 kinematic, 2 dynamic; layer 0 ground, 1 static, 2 moving; mass 0 = from shape
JPH_SHIM int jph_body_add(void *w, void *shape, float x, float y, float z, float yaw, int motion, int layer,
float mass, float friction, float bounce) {
World *ww = static_cast<World *>(w);
EMotionType mt = motion == 2 ? EMotionType::Dynamic : (motion == 1 ? EMotionType::Kinematic : EMotionType::Static);
BodyCreationSettings st(static_cast<Shape *>(shape), RVec3(x, y, z), yaw_q(yaw), mt, ObjectLayer(layer));
st.mFriction = friction;
st.mRestitution = bounce;
if (mt == EMotionType::Dynamic && mass > 0.0f) {
st.mOverrideMassProperties = EOverrideMassProperties::CalculateInertia;
st.mMassPropertiesOverride.mMass = mass;
}
EActivation act = mt == EMotionType::Static ? EActivation::DontActivate : EActivation::Activate;
BodyID id = ww->sys.GetBodyInterface().CreateAndAddBody(st, act);
if (id.IsInvalid()) return -1;
return int(id.GetIndexAndSequenceNumber());
}
JPH_SHIM void jph_body_remove(void *w, int id) {
BodyInterface &bi = static_cast<World *>(w)->sys.GetBodyInterface();
bi.RemoveBody(bid(id));
bi.DestroyBody(bid(id));
}
// position, rotation (x, y, z, w) and velocity into out[10]
JPH_SHIM int jph_body_read(void *w, int id, float *out) {
BodyInterface &bi = static_cast<World *>(w)->sys.GetBodyInterface();
if (!bi.IsAdded(bid(id))) return -1;
RVec3 p; Quat q;
bi.GetPositionAndRotation(bid(id), p, q);
Vec3 v = bi.GetLinearVelocity(bid(id));
float vals[10] = { float(p.GetX()), float(p.GetY()), float(p.GetZ()), q.GetX(), q.GetY(), q.GetZ(), q.GetW(),
v.GetX(), v.GetY(), v.GetZ() };
for (int i = 0; i < 10; i++) out[i] = vals[i];
return 0;
}
JPH_SHIM void jph_body_place(void *w, int id, float x, float y, float z, float yaw) {
static_cast<World *>(w)->sys.GetBodyInterface().SetPositionAndRotation(bid(id), RVec3(x, y, z), yaw_q(yaw), EActivation::Activate);
}
JPH_SHIM void jph_body_impulse(void *w, int id, float x, float y, float z) {
static_cast<World *>(w)->sys.GetBodyInterface().AddImpulse(bid(id), Vec3(x, y, z));
}
JPH_SHIM void jph_body_velocity(void *w, int id, float x, float y, float z) {
static_cast<World *>(w)->sys.GetBodyInterface().SetLinearVelocity(bid(id), Vec3(x, y, z));
}
// a kinematic body driven to where it should be after dt, pushing what it meets
JPH_SHIM void jph_body_target(void *w, int id, float x, float y, float z, float yaw, float dt) {
static_cast<World *>(w)->sys.GetBodyInterface().MoveKinematic(bid(id), RVec3(x, y, z), yaw_q(yaw), dt);
}
JPH_SHIM int jph_body_awake(void *w, int id) { return static_cast<World *>(w)->sys.GetBodyInterface().IsActive(bid(id)) ? 1 : 0; }
// water under a body: the surface's height and normal, how buoyant, the drags and the current
JPH_SHIM int jph_body_buoyancy(void *w, int id, float sy, float nx, float ny, float nz, float buoy, float lin, float ang,
float cx, float cz, float dt) {
World *ww = static_cast<World *>(w);
BodyInterface &bi = ww->sys.GetBodyInterface();
RVec3 p = bi.GetPosition(bid(id));
bool in = bi.ApplyBuoyancyImpulse(bid(id), RVec3(p.GetX(), sy, p.GetZ()), Vec3(nx, ny, nz), buoy, lin, ang,
Vec3(cx, 0.0f, cz), ww->sys.GetGravity(), dt);
return in ? 1 : 0;
}

View file

@ -0,0 +1,81 @@
// jph_queries.inl - what is there: a ray, the top a body could step onto, overlaps, a push out
// a ray from o along d (its length the reach) against the layers in mask; the body id or -1, and
// out[7] the fraction, the point and the normal
JPH_SHIM int jph_ray(void *w, float ox, float oy, float oz, float dx, float dy, float dz, int mask, float *out) {
World *ww = static_cast<World *>(w);
RRayCast ray{ RVec3(ox, oy, oz), Vec3(dx, dy, dz) };
RayCastResult hit;
MaskFilter f(mask);
if (!ww->sys.GetNarrowPhaseQuery().CastRay(ray, hit, {}, f)) return -1;
RVec3 p = ray.GetPointOnRay(hit.mFraction);
Vec3 n = Vec3::sAxisY();
BodyLockRead lock(ww->sys.GetBodyLockInterface(), hit.mBodyID);
if (lock.Succeeded()) n = lock.GetBody().GetWorldSpaceSurfaceNormal(hit.mSubShapeID2, p);
float vals[7] = { hit.mFraction, float(p.GetX()), float(p.GetY()), float(p.GetZ()), n.GetX(), n.GetY(), n.GetZ() };
for (int i = 0; i < 7; i++) out[i] = vals[i];
return int(hit.mBodyID.GetIndexAndSequenceNumber());
}
// a ball of radius r let down from (x, from_y, z) at most `depth`: the height of what it lands
// on, or -FLT_MAX when it lands on nothing in mask
JPH_SHIM float jph_top_at(void *w, float x, float z, float r, float from_y, float depth, int mask) {
World *ww = static_cast<World *>(w);
SphereShape ball(r);
ball.SetEmbedded();
RShapeCast cast = RShapeCast::sFromWorldTransform(&ball, Vec3::sOne(), RMat44::sTranslation(RVec3(x, from_y, z)),
Vec3(0.0f, -depth, 0.0f));
ClosestHitCollisionCollector<CastShapeCollector> hit;
MaskFilter f(mask);
ww->sys.GetNarrowPhaseQuery().CastShape(cast, ShapeCastSettings(), RVec3::sZero(), hit, {}, f);
if (!hit.HadHit()) return -FLT_MAX;
return float(hit.mHit.mContactPointOn2.GetY());
}
// the bodies (in mask) a ball at (x, y, z) touches, up to cap into ids; returns how many
JPH_SHIM int jph_overlap(void *w, float x, float y, float z, float r, int mask, int *ids, int cap) {
World *ww = static_cast<World *>(w);
SphereShape ball(r);
ball.SetEmbedded();
AllHitCollisionCollector<CollideShapeCollector> hits;
MaskFilter f(mask);
ww->sys.GetNarrowPhaseQuery().CollideShape(&ball, Vec3::sOne(), RMat44::sTranslation(RVec3(x, y, z)),
CollideShapeSettings(), RVec3::sZero(), hits, {}, f);
hits.Sort();
int n = 0;
for (const CollideShapeResult &h : hits.mHits) {
int id = int(h.mBodyID2.GetIndexAndSequenceNumber());
bool seen = false;
for (int i = 0; i < n; i++) seen = seen || ids[i] == id;
if (!seen && n < cap) ids[n++] = id;
}
return n;
}
// an upright body r wide, from y0 to y1, at (x, z): how far to move it in x and z to stand clear
// of what it overlaps in mask, into out[2]; returns how many things it overlapped
JPH_SHIM int jph_push(void *w, float x, float z, float r, float y0, float y1, int mask, float *out) {
World *ww = static_cast<World *>(w);
float half = (y1 - y0) * 0.5f;
CylinderShape body(half, r, 0.0f);
body.SetEmbedded();
AllHitCollisionCollector<CollideShapeCollector> hits;
MaskFilter f(mask);
CollideShapeSettings cs;
cs.mBackFaceMode = EBackFaceMode::CollideWithBackFaces;
ww->sys.GetNarrowPhaseQuery().CollideShape(&body, Vec3::sOne(), RMat44::sTranslation(RVec3(x, y0 + half, z)),
cs, RVec3::sZero(), hits, {}, f);
hits.Sort();
float px = 0.0f, pz = 0.0f;
for (const CollideShapeResult &h : hits.mHits) {
Vec3 a = h.mPenetrationAxis;
Vec3 flat(a.GetX(), 0.0f, a.GetZ());
if (flat.LengthSq() < 1.0e-8f) continue;
flat = flat.Normalized() * h.mPenetrationDepth;
px -= flat.GetX();
pz -= flat.GetZ();
}
out[0] = px;
out[1] = pz;
return int(hits.mHits.size());
}

View file

@ -0,0 +1,42 @@
// jph_shapes.inl - shapes, each a handle the package keeps and frees; null when Jolt refused it
JPH_SHIM void *jph_shape_box(float hx, float hy, float hz) {
return keep(BoxShapeSettings(Vec3(hx, hy, hz), 0.02f).Create());
}
JPH_SHIM void *jph_shape_sphere(float r) { return keep(SphereShapeSettings(r).Create()); }
JPH_SHIM void *jph_shape_capsule(float half_h, float r) { return keep(CapsuleShapeSettings(half_h, r).Create()); }
JPH_SHIM void *jph_shape_cylinder(float half_h, float r) {
float cr = r < 0.05f ? r * 0.5f : 0.02f;
return keep(CylinderShapeSettings(half_h, r, cr).Create());
}
// a dome r across and h tall standing on y = 0: the cap of a sphere whose centre is below
JPH_SHIM void *jph_shape_dome(float r, float h) {
float big = (r * r + h * h) / (2.0f * h);
RefConst<Shape> s = SphereShapeSettings(big).Create().Get();
return keep(RotatedTranslatedShapeSettings(Vec3(0.0f, h - big, 0.0f), Quat::sIdentity(), s).Create());
}
// a shape moved and turned about y, inside its body
JPH_SHIM void *jph_shape_offset(void *s, float x, float y, float z, float yaw) {
return keep(RotatedTranslatedShapeSettings(Vec3(x, y, z), yaw_q(yaw), static_cast<Shape *>(s)).Create());
}
// n x n heights (row z, column x) at ox, oz, cell metres apart; FLT_MAX is a hole
JPH_SHIM void *jph_shape_heightfield(const float *h, int n, float ox, float oz, float cell) {
HeightFieldShapeSettings st(h, Vec3(ox, 0.0f, oz), Vec3(cell, 1.0f, cell), uint32(n));
return keep(st.Create());
}
// a triangle mesh for a dock or a cabin: nv vertices (x, y, z) and nt triangles (i, j, k)
JPH_SHIM void *jph_shape_mesh(const float *v, int nv, const int *tri, int nt) {
VertexList vs;
vs.reserve(nv);
for (int i = 0; i < nv; i++) vs.push_back(Float3(v[i * 3], v[i * 3 + 1], v[i * 3 + 2]));
IndexedTriangleList ts;
ts.reserve(nt);
for (int i = 0; i < nt; i++) ts.push_back(IndexedTriangle(tri[i * 3], tri[i * 3 + 1], tri[i * 3 + 2]));
return keep(MeshShapeSettings(vs, ts).Create());
}
JPH_SHIM void jph_shape_free(void *s) { if (s) static_cast<Shape *>(s)->Release(); }

View file

@ -0,0 +1,126 @@
// jph_shim.cpp - ludic.physics' door into Jolt Physics (phase 16). It keeps the shim rules of
// packages/README.md: int, long, float and opaque handles cross; no struct by value, no callback
// into Ludic - contacts are recorded during the step and drained after it; errors are return
// codes. Jolt's job threads stay in here.
#include <Jolt/Jolt.h>
#include <Jolt/RegisterTypes.h>
#include <Jolt/Core/Factory.h>
#include <Jolt/Core/TempAllocator.h>
#include <Jolt/Core/JobSystemThreadPool.h>
#include <Jolt/Physics/PhysicsSettings.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include <Jolt/Physics/Collision/Shape/BoxShape.h>
#include <Jolt/Physics/Collision/Shape/SphereShape.h>
#include <Jolt/Physics/Collision/Shape/CapsuleShape.h>
#include <Jolt/Physics/Collision/Shape/CylinderShape.h>
#include <Jolt/Physics/Collision/Shape/HeightFieldShape.h>
#include <Jolt/Physics/Collision/Shape/MeshShape.h>
#include <Jolt/Physics/Collision/Shape/RotatedTranslatedShape.h>
#include <Jolt/Physics/Collision/RayCast.h>
#include <Jolt/Physics/Collision/CastResult.h>
#include <Jolt/Physics/Collision/ShapeCast.h>
#include <Jolt/Physics/Collision/CollideShape.h>
#include <Jolt/Physics/Collision/CollisionCollectorImpl.h>
#include <Jolt/Physics/Body/BodyCreationSettings.h>
#include <mutex>
#include <thread>
#include <cfloat>
#if defined(_WIN32)
#define JPH_SHIM extern "C" __declspec(dllexport)
#else
#define JPH_SHIM extern "C" __attribute__((visibility("default")))
#endif
using namespace JPH;
// ---- layers: the ground, the still things on it, and what moves --------------------------
namespace {
const ObjectLayer L_GROUND = 0, L_STATIC = 1, L_MOVING = 2, L_COUNT = 3;
const BroadPhaseLayer BP_STILL(0), BP_MOVING(1);
class BPLayers final : public BroadPhaseLayerInterface {
public:
uint GetNumBroadPhaseLayers() const override { return 2; }
BroadPhaseLayer GetBroadPhaseLayer(ObjectLayer l) const override { return l == L_MOVING ? BP_MOVING : BP_STILL; }
#if defined(JPH_EXTERNAL_PROFILE) || defined(JPH_PROFILE_ENABLED)
const char *GetBroadPhaseLayerName(BroadPhaseLayer l) const override { return l == BP_MOVING ? "moving" : "still"; }
#endif
};
class ObjVsBP final : public ObjectVsBroadPhaseLayerFilter {
public:
bool ShouldCollide(ObjectLayer l, BroadPhaseLayer bp) const override { return l == L_MOVING || bp == BP_MOVING; }
};
class ObjPairs final : public ObjectLayerPairFilter {
public:
bool ShouldCollide(ObjectLayer a, ObjectLayer b) const override { return a == L_MOVING || b == L_MOVING; }
};
// a query's layers as a bit mask (1 ground, 2 static, 4 moving)
class MaskFilter final : public ObjectLayerFilter {
public:
explicit MaskFilter(int m) : mask(m) {}
bool ShouldCollide(ObjectLayer l) const override { return (mask >> l) & 1; }
int mask;
};
// ---- contacts: recorded on Jolt's threads, drained on the game's -------------------------
const int C_CAP = 512;
struct Contact { uint32 a, b; float x, y, z, speed; };
class Contacts final : public ContactListener {
public:
void OnContactAdded(const Body &b1, const Body &b2, const ContactManifold &m, ContactSettings &) override {
Vec3 n = m.mWorldSpaceNormal;
float speed = (b2.GetLinearVelocity() - b1.GetLinearVelocity()).Dot(n);
RVec3 p = m.GetWorldSpaceContactPointOn1(0);
std::lock_guard<std::mutex> g(lock);
if (count >= C_CAP) { lost++; return; }
items[count++] = { b1.GetID().GetIndexAndSequenceNumber(), b2.GetID().GetIndexAndSequenceNumber(),
float(p.GetX()), float(p.GetY()), float(p.GetZ()), std::abs(speed) };
}
std::mutex lock;
Contact items[C_CAP];
int count = 0;
int lost = 0;
};
struct World {
World(int max_bodies, int threads)
: temp(16 * 1024 * 1024), jobs(cMaxPhysicsJobs, cMaxPhysicsBarriers, threads) {
sys.Init(max_bodies, 0, max_bodies, max_bodies * 4, bpl, ovb, olp);
sys.SetContactListener(&contacts);
}
TempAllocatorImpl temp;
JobSystemThreadPool jobs;
BPLayers bpl;
ObjVsBP ovb;
ObjPairs olp;
PhysicsSystem sys;
Contacts contacts;
};
bool g_ready = false;
BodyID bid(int id) { return BodyID(uint32(id)); }
Quat yaw_q(float yaw) { return Quat::sRotation(Vec3::sAxisY(), yaw); }
void *keep(const ShapeSettings::ShapeResult &r) {
if (r.HasError()) return nullptr;
Shape *s = const_cast<Shape *>(r.Get().GetPtr());
s->AddRef();
return s;
}
} // namespace
// Jolt's allocator, factory and types, once per process; 0 when ready
JPH_SHIM int jph_init(void) {
if (g_ready) return 0;
RegisterDefaultAllocator();
Factory::sInstance = new Factory();
RegisterTypes();
g_ready = true;
return 0;
}
#include "jph_world.inl"
#include "jph_shapes.inl"
#include "jph_bodies.inl"
#include "jph_queries.inl"

View file

@ -0,0 +1,49 @@
// jph_world.inl - a physics world: made, stepped, emptied of its contacts, freed
// a world for up to max_bodies; threads 0 means the machine's cores less one, at most four
JPH_SHIM void *jph_world_new(int max_bodies, int threads) {
if (jph_init() != 0) return nullptr;
if (threads <= 0) {
int hw = int(std::thread::hardware_concurrency()) - 1;
threads = hw < 1 ? 1 : (hw > 4 ? 4 : hw);
}
World *w = new World(max_bodies, threads);
w->sys.SetGravity(Vec3(0.0f, -9.81f, 0.0f));
return w;
}
JPH_SHIM void jph_world_free(void *w) { delete static_cast<World *>(w); }
JPH_SHIM void jph_world_gravity(void *w, float y) { static_cast<World *>(w)->sys.SetGravity(Vec3(0.0f, y, 0.0f)); }
// one step of dt seconds in `sub` collision steps; 0 when Jolt reports nothing wrong
JPH_SHIM int jph_world_step(void *w, float dt, int sub) {
World *ww = static_cast<World *>(w);
EPhysicsUpdateError e = ww->sys.Update(dt, sub < 1 ? 1 : sub, &ww->temp, &ww->jobs);
return e == EPhysicsUpdateError::None ? 0 : -int(e);
}
// after adding many still bodies at once, before the first query
JPH_SHIM void jph_world_optimize(void *w) { static_cast<World *>(w)->sys.OptimizeBroadPhase(); }
JPH_SHIM int jph_world_bodies(void *w) { return int(static_cast<World *>(w)->sys.GetNumBodies()); }
// up to cap contacts into ids (a, b pairs) and vals (x, y, z, speed); returns how many
JPH_SHIM int jph_contacts_drain(void *w, int *ids, float *vals, int cap) {
Contacts &c = static_cast<World *>(w)->contacts;
std::lock_guard<std::mutex> g(c.lock);
int n = c.count < cap ? c.count : cap;
for (int i = 0; i < n; i++) {
ids[i * 2] = int(c.items[i].a);
ids[i * 2 + 1] = int(c.items[i].b);
vals[i * 4] = c.items[i].x;
vals[i * 4 + 1] = c.items[i].y;
vals[i * 4 + 2] = c.items[i].z;
vals[i * 4 + 3] = c.items[i].speed;
}
for (int i = n; i < c.count; i++) c.items[i - n] = c.items[i];
c.count -= n;
return n;
}
JPH_SHIM int jph_contacts_lost(void *w) { return static_cast<World *>(w)->contacts.lost; }