Floptle — Physics (`floptle-physics`)
Floptle — Physics (floptle-physics)
Custom, SDF-first collision: collide against the same distance function the renderer draws, so morphing fractal worlds are interactable without ever re-meshing.
Decision & rationale: ADR-0012. The shared distance field & raymarch:
./renderer.md. Where this sits in the engine:../ARCHITECTURE.md.
floptle-physics depends only on floptle-core (math · ECS · node facade · time).
Optional crates, pulled in only where noted: parry3d (triangle-mesh BVH
queries for imported Blender meshes), rapier3d (optional rigid-body dynamics).
Neither is on the critical path; the SDF world and controllers stand alone.
Why SDF-first
Floptle renders fractals as real, morphing worlds; the goal is to drive a car across one and roam it on foot while the geometry actively shifts. That is the case rapier/PhysX/Jolt handle worst — they want explicit, mostly-static geometry and choke on per-frame re-meshing.
But a fractal is already a signed-distance function f(p, t) we evaluate to draw it,
and an SDF is a gift for collision: the inside test, penetration depth, contact
normal, and continuous casts all fall out of evaluating f and its gradient — no
BVH, no marching cubes. Pass the current t and morph is free; ∂f/∂t even lets a
character on shifting ground be carried by it (below). Precedent that this ships
and looks great: Media Molecule's Dreams (SDF rendering and physics) and
Claybook (SDF worlds you roll and deform).
The collision world
One world per scene, rebuilt-free and queried each fixed step. It holds colliders and a cheap broadphase; everything physics needs is expressed through one query trait so controllers don't care whether they hit a fractal, a primitive, or a mesh.
/// Anything physics can query. Implemented by SDF, baked-field, and mesh colliders.
pub trait CollisionShape {
/// Signed distance at world point p, at sim time t. Negative = inside.
fn distance(&self, p: Vec3, t: f64) -> f32;
/// Outward unit normal at p (analytic if available, else finite-difference).
fn normal(&self, p: Vec3, t: f64) -> Vec3;
/// Conservative world-space AABB for the broadphase (may grow with morph).
fn aabb(&self, t: f64) -> Aabb;
}
pub struct CollisionWorld {
colliders: Vec<Collider>, // dense, ECS-backed (see "ECS / Node tie-in")
broadphase: Bvh<ColliderId>, // over collider AABBs, refit (not rebuilt) per step
pub gravity: Vec3,
}
The query API every controller and the solver build on:
impl CollisionWorld {
/// Nearest surface point + distance + normal to p. The workhorse.
fn closest(&self, p: Vec3, t: f64) -> ClosestHit; // { point, dist, normal }
/// Is a sphere overlapping anything? penetration > 0 when so.
fn sphere_overlap(&self, c: Vec3, r: f32, t: f64) -> Option<Contact>;
/// Capsule = swept sphere over a segment a..b.
fn capsule_overlap(&self, a: Vec3, b: Vec3, r: f32, t: f64) -> Option<Contact>;
/// Ray vs world via sphere-marching. Robust, continuous.
fn raycast(&self, ray: Ray, max_t: f32, t: f64) -> Option<RayHit>;
/// Move a sphere of radius r along dir until first contact (CCD-by-construction).
fn spherecast(&self, origin: Vec3, dir: Vec3, r: f32, max_t: f32, t: f64) -> Option<SweepHit>;
/// Generic convex sweep (sphere/capsule) — used by the character & vehicle.
fn sweep(&self, shape: SweepShape, motion: Vec3, t: f64) -> Option<SweepHit>;
}
Contact = { point, normal, penetration }; SweepHit = { toi, point, normal }
where toi ∈ [0,1] is the fraction of motion travelled before contact.
The SDF math, concretely
Inside / penetration. Sign of f is the whole inside test. For a point:
f(p) > 0 → outside, distance to surface is f(p)
f(p) = 0 → on the surface
f(p) < 0 → inside, penetration depth = -f(p)
So depenetration is "push out along the normal by -f(p)" — no contact-manifold
bookkeeping required for the single-point case.
Contact normal = normalize(∇f(p)). Where an analytic gradient exists (the
primitives below, and many fractal folds), use it — it's exact and free. Otherwise
take the gradient by central finite differences. The cheap, robust form is the
4-tap tetrahedron trick (4 evals instead of 6):
ε = small (e.g. 1e-3 · world scale)
k1 = ( 1,-1,-1), k2 = (-1,-1, 1), k3 = (-1, 1,-1), k4 = (1,1,1)
∇f ≈ normalize(
k1·f(p + ε·k1) + k2·f(p + ε·k2)
+ k3·f(p + ε·k3) + k4·f(p + ε·k4) )
Choose ε near the field's Lipschitz scale: too small amplifies eval noise (bad on
deep fractals), too large rounds off sharp features. This is the same gradient the
renderer uses for shading normals — see ./renderer.md.
Casts via sphere-marching. This is the renderer's own march, reused verbatim:
start at the origin, repeatedly step forward by the distance to the nearest surface,
because f(p) is exactly the radius of a guaranteed-empty sphere at p.
t = 0
loop:
p = origin + dir * t
d = f(p, time)
if d < hit_eps: return Hit { p, t, normal = ∇f(p) } // surface reached
t += d
if t > max_t: return Miss
For a spherecast of radius r, hit when f(p) < r (the swept sphere kisses the
surface) and step by f(p) - r. Because every step is bounded by the true distance,
this cannot tunnel: a bullet at any speed is caught at the first surface crossing.
Continuous collision is not a bolt-on here, it's the native behavior.
Sphere / capsule vs SDF. A sphere of radius r at center c overlaps when
f(c) - r < 0; penetration = r - f(c), normal = ∇f(c). A capsule is a
sphere swept along segment a→b: find the segment point nearest the surface and run
the sphere test there. In practice we sample f along the segment, take the minimum,
refine, and treat that as the deepest contact:
sphere: pen = r - f(c); if pen > 0: contact at c - r·n, n = ∇f(c)
capsule: q = argmin_{s∈[a,b]} f(s); then sphere test at q
A capsule is swept, not two spheres. Its end spheres are always tested, and so is the point of its axis closest to each collider (three coarse samples, five golden-section refinements), when that point is strictly between the ends and nearer than either — so a face steep enough to reach a character's shins pushes the body out instead of letting the legs walk through it, and a crouching body cannot put its waist through a low wall.
Feet (capsules only). A sphere resting on a slope touches it off to one
side, so the point straight under the centre — where a character's feet are
drawn — hangs r·(1 − cos θ) above the ground; in a crease it bridges the two
sides, on a bump it perches, and over a crest it coasts off. On a coarse
terrain that is visible hover. So a capsule's bottom sphere resolves
walkable ground by a probe straight down its up axis from the sphere's
centre (a sphere-trace over the same signed distances, reaching 2r): the body
is pushed up until that point clears the surface, and pulled down onto it while
it was standing last step and is not leaving its floor — a jump being velocity
away from the floor along its normal and upward; either alone is walking. A
snapped body also has its velocity projected onto the new floor, so a ramp
cannot launch it. Anything the probe does not find — a wall, a slope past
slope_limit, a ledge the centre has stepped past, a centre already inside
something — is left to the rounded bottom exactly as above, so walls push and a
ledge still holds a body by its rim. Moving platforms are surfaces for the
probe too. Not a knob: every capsule has feet, and only a capsule.
Which terrain surface. Matter::Terrain::collision picks Drawn (the
extracted triangles — ChunkTerrain::mesh_accurate, the default) or Field.
The editor's Terrain collider wireframe draws whichever the node collides
with, through the node's full transform, from the same extraction
(floptle_physics::drawn_surface) — a guard holds every drawn triangle's
centre to distance zero on the collider.
Morph is automatic. Every query takes the current sim time t and passes it
straight to f(p, t). There is no per-frame rebuild, no dirty flag, no re-bake of
analytic colliders — the collision surface is the rendered surface, at the same
instant. This is the single biggest reason the system is simpler than a mesh
pipeline for this game, not harder.
Surface velocity from ∂f/∂t — the magic. When the ground morphs under a
standing character, the surface is moving and the character should ride it. The local
surface velocity along the normal is the field's time derivative, turned from
"distance change" into "surface motion":
f(p, t+dt) - f(p, t)
v_surf = - ───────────────────── · n where n = ∇f(p)
dt
Intuition: if f at a fixed p is increasing, the surface is receding (the
point gets more "outside"), so the surface near p moves in -n; the sign and the
projection onto n recover that velocity. Tangential drift (sideways slide that
doesn't change distance) is invisible to ∂f/∂t alone — fine for "stand and be
carried"; add a per-collider tangential advection field only for conveyor-belt carry.
The controller adds v_surf before integrating, so a character on a shifting
fractal is carried by it — clean here, effectively impossible to do well with a
triangle collider re-meshed every frame (old↔new vertex correspondence is lost).
morphing surface at t ... at t+dt
____ ___
____/ \___ n↑ ____/ \__ character keeps contact,
/ \ ● char / \ inherits v_surf = -Δf/dt·n
surface rises under the feet → body is lifted with the ground
Collider types
Collider ─┬─ Sdf(SdfSource) // fractals + scene-builder primitives
├─ BakedField(BrickGrid) // sampled distance grid, LOD for the far field
└─ Mesh(MeshBvh) // static/imported Blender geometry
1. Analytic SDF colliders. Fractals (Mandelbulb/Menger/IFS folds, animated by
t) and the scene-builder primitives. The key unification: Cube / Sphere /
Capsule / Wedge / Stairs are themselves trivial SDFs with closed-form distance and
gradient, so they ride the exact same collision path as fractals — no special cases.
sdSphere(p,r) = |p| - r
sdBox(p,b) = |max(|p|-b,0)| + min(max(|p|-b),0) // b = half-extents
sdCapsule(p,a,b,r) = |p - closest_on_segment(p,a,b)| - r
// wedge = box ∩ halfspace; stairs = repeated box union (mod-space tiling)
2. Baked sparse SDF / voxel field colliders. Analytic fractals are expensive; physics touches few points per step but we still don't want each touch to cost a deep fractal iteration. So bake the analytic SDF into a grid and query that:
- Generation: sample
f(p, t)over a sparse brickmap (grid of occupied 8³/16³ bricks; empty space stored once). Skip far-from-surface bricks. - Query: trilinear interpolation of the 8 corner distances for
distance; gradient from the 3 axis-differences of the interpolated field (analytic, cheap). - Refresh cadence: as the world morphs, re-bake bricks on a rolling budget (N/frame), prioritizing those near active bodies; the field lags the analytic surface by a frame or two — invisible at gameplay scale.
- Near/far LOD: analytic SDF in a radius around the player (exact contact
where it matters), baked field everywhere else; the
CollisionWorldpicks per-query by distance to active bodies.
This is the primary performance mitigation — it decouples physics cost from raymarch cost (precedent: Dreams, Claybook).
3. Triangle-mesh BVH colliders. Imported Blender meshes are static/rigid, so a
one-time BVH is cheap and never rebuilt. Use a small custom BVH or parry3d for
the closest-point / raycast / sweep queries. These coexist with SDF colliders in the
same world behind CollisionShape; a mesh just answers distance/normal via its
BVH instead of a field. (No re-meshing here — that's the whole point of staying SDF
for the things that do deform.)
What a query hit is made of (v0.81)
A hit — from raycast, spherecast, capsulecast or overlapSphere — names
the node it landed on, and where the surface has more than one material, the
material slot it landed on.
hit.node is the entity every static collider was already registered with. The
shape queries reported it from the start; the ray march had it in hand and threw
it away, so raycast answered nothing for the whole of static geometry while
spherecast — documented as returning the same fields — answered the node
(an earlier report). One builder now assembles every hit table, which is what stops
the two drifting apart again.
hit.material is the interesting half. A map mesh carries a material slot per
face, and a level of any size is a few big nodes with several slots each — a
building whose brick, grass and floorboards are one node answers "brick"
everywhere if you ask the node. So the per-triangle slot rides into the collider:
TriMeshCollider keeps a parallel Vec<u16> of labels beside its triangles,
and CollisionShape::face_label(p) reports the label of the triangle nearest
p. Two things about that:
- The labels are opaque here. Physics stores strings it never interprets; the editor fills them with the map mesh's own slot names. This crate has no business knowing what a material is.
- Nothing on the query path calls it.
face_labelcosts a closest-point search of its own, and a line-of-sight ray — cast far more often than a ground check, and never interested in the answer — must not pay for a footstep's question. The script layer resolveshit.materiallazily, on read.
Anything with one surface — an analytic box, a terrain field, an imported model,
a body hull — answers None, deliberately. A name that is right for map meshes
and quietly wrong for terrain is worse than no name.
Friction and slopes (v0.49)
Friction on a RigidBody is a Coulomb coefficient, not a damping factor. The
surface can resist sideways at most friction × the load it is carrying, so a
ramp holds a body while
tan(slope angle) ≤ friction
and lets go above it. 0 is ice, 0.3 lets go at about 17°, 1 holds exactly 45°, and a grippier surface than that goes above 1 (rubber on rubber is around 1.5). The answer does not depend on the frame rate, on how many colliders the body happens to be touching, or on the solver's iteration count.
The budget is spent in two places inside one step, and each has a distinct job:
| When | Budget | What it does |
|---|---|---|
| Before the position integrates | the weight the floor holds up, (−g·n)·dt |
Stops a parked body creeping. Removing the downhill velocity after the move leaves g_t·dt² of travel per step — a position error no amount of friction can cancel, and the reason everything used to slide downhill eventually. |
| After the contacts resolve | the impulse they absorbed, minus the weight already spent above | Makes a fast landing skid and a gentle one stick. Subtracting the weight matters: a resting body pushes into its floor every step and the contact dutifully cancels that, so counting it twice would make every surface exactly twice as grippy as its number says. |
A wall is handled by the same expression with no special case: a surface you are not pushing into absorbs nothing, so there is nothing to spend, so sliding down a wall does not slow you.
slope_limit (degrees, 60 by default) is the steepest surface that counts as
ground. Past it the body is not grounded, the surface is reported as
node.wallNormal rather than node.groundNormal, and — the part that makes it a
design knob rather than a label — it stops carrying the body's weight, so
friction has no budget on it and the body slides off however grippy it is.
Kinematic character controller (capsule)
A capsule, moved by move-and-slide built on spherecast/sweep — no rigid-body
solver involved. Per fixed step:
1. desired = (input_dir · speed + inherited_surface_v) · dt
2. for up to K iterations:
hit = world.sweep(capsule, remaining_motion, t)
if none: move full remaining_motion; break
move to hit.toi; slide: remaining -= (remaining · hit.normal) · hit.normal
3. depenetrate: while (pen = world.capsule_overlap(...)) > 0:
move += pen.normal * pen.penetration // push out along ∇f
4. ground check: short spherecast down; grounded if hit & slope ≤ slope_limit
5. inherit v_surf from the ground contact (see ∂f/∂t above) for next step
- Ground & slopes:
groundedwhen the down-cast hits within a small probe and the surface normal's angle to up≤ slope_limit(e.g. 50°). Steeper → slide. - Steps: to climb a
≤ step_heightledge, try an up-cast → forward-cast → down-cast (the classic "step offset") so stairs and fractal terraces are walkable. - Depenetration along the gradient: because the normal is
∇f, pushing out by-f(p)is the shortest exit — robust even as the surface closes in during a morph. - Carried by morphing ground: step 5 is the "roam a shifting fractal" goal —
v_surfis folded intodesirednext step, so standing still on rising terrain lifts the player with it.
Raycast vehicle
A rigid chassis body with N raycast (spherecast) wheels — no wheel colliders, no mesh contact. Each wheel casts downward along the suspension axis against the SDF world:
for each wheel:
hit = world.spherecast(wheel_pos, -chassis_up, wheel_r, rest+travel, t)
if hit:
compression = rest - hit.toi_distance
F_spring = k · compression
F_damp = c · (compression - prev_compression)/dt
F_susp = (F_spring + F_damp) · hit.normal // along ground normal
// tire forces in the contact plane:
F_long = engine/brake torque mapped through grip
F_lat = -lateral_slip · grip // resist sideways slide
apply (F_susp + F_long + F_lat) at hit.point to the chassis
The chassis integrates as one rigid body (position + orientation + linear/angular
velocity). Because wheels probe via sphere-marching, the car drives on a living,
morphing fractal: each frame the casts find the current surface, and ∂f/∂t at the
contacts lets rising terrain push the wheels up naturally. The "drive a car across
one" goal.
Optional rigid-body dynamics (object-vs-object)
Stacking crates and joints are out of the launch core and added only when a game needs them. Two paths, the world/controllers depend on neither:
-
Lightweight sequential-impulse solver (custom). Generate contacts straight from SDF queries: for each body pair (and body-vs-world),
sphere_overlap/capsule_overlapgive{ point, normal, penetration }. Then run sequential impulses over a few iterations:for iter in 0..solver_iters: for c in contacts: v_rel = dot(relative_velocity_at(c.point), c.normal) lambda = -(1+e)·v_rel / effective_mass(c) // e = restitution apply ±lambda·c.normal as impulses to the two bodies // + Baumgarte/position bias to resolve c.penetration without adding energy -
Delegate to
rapier3dfor full-featured stacking/joints, while the world stays SDF: feed rapier static colliders sampled from the SDF near dynamic bodies, or run rapier bodies and resolve them against the SDF world via the same contact generation. Bolt-on, not a dependency of the character/vehicle.
Stepping & determinism
Physics runs in the fixed-timestep stage of the frame loop (see
../ARCHITECTURE.md §3), driven by an accumulator:
accumulator += frame_dt
while accumulator >= FIXED_DT:
physics.step(FIXED_DT) // sub-stepped further for fast bodies
accumulator -= FIXED_DT
alpha = accumulator / FIXED_DT // render interpolates poses by alpha
- Sub-stepping: split
FIXED_DTfor very fast bodies; sphere-marching already prevents tunneling, so sub-steps are about solver/force stability, not CCD. - Determinism: a custom fixed-step, fixed-iteration solver with a deterministic
collider iteration order is reproducible bit-for-bit on a platform — important for
game-feel and the future networking goal (authoritative fixed-step sim,
serializable state;
../ARCHITECTURE.md§10). A reason to own the solver rather than inherit a third party's float ordering.
Performance
- Budget asymmetry: the renderer evaluates
ffor millions of pixels; physics for a few bodies × a few taps per step. Even costly analytic fractals are affordable at that count — the only danger is very deep iteration counts. - Baked field is the main mitigation: queries hit a trilinear grid, not the analytic fold (analytic only in the near radius around the player).
- Caching: memoize
f/∇fper body per step (the controller queries the same neighborhood repeatedly); reuse the renderer's eval at a shared point/time. - Broadphase: a BVH/grid over collider AABBs, refit (not rebuilt) each step; narrowphase SDF evals run only for colliders whose AABB overlaps the query.
ECS / Node tie-in
Physics is just another ECS system over a Collider component (ADR-0005's
Node/Component facade, ../ARCHITECTURE.md §2):
struct Collider {
source: ColliderSource, // Sdf(handle) | BakedField(handle) | Mesh(asset_id)
kind: Body, // Static | Character | Vehicle | Dynamic
collidable: bool, // the scene-builder "collidable" toggle
}
- A
Colliderreferencing an SDF source points at the samef(p, t)the renderer's raymarch pass draws — one source of truth for shape and collision. - Scene-builder shapes (Cube/Sphere/Capsule/Wedge/Stairs) expose a collidable
toggle; flipping it on inserts/removes the
Collidercomponent — no geometry conversion, since the primitive already is an SDF. - Everything is RON-serialized like the rest of the project, so collider setup is diffable and hand/AI-editable.
Out of scope at launch
- A full general-purpose rigid-body sandbox (rich stacking, joints, motors as a first-class feature) — the optional solver covers incidental dynamics only.
- Soft-body / cloth simulation.
Both can layer on later behind the same CollisionWorld; nothing in the SDF world or
the character/vehicle controllers needs to change to add them.