Floptle — Gravity & Density (`floptle-physics::gravity`)
Floptle — Gravity & Density (floptle-physics::gravity)
Gravity is not a global constant — it is a composable vector field g(p) you sample as "down," and density is a first-class material property from which mass, the gravity matter emits, and crush/compaction all follow.
Decision & rationale: ADR-0014, with density-as-matter from ADR-0013. Reads-with: the SDF collision core
./physics.md(gradient, character controller, baked brickmap) and the matter solver./deformable-matter.md(compaction, XPBD).
gravity is a module inside floptle-physics. It consumes scalar/vector
spatial fields from floptle-field (which now holds density ρ(p),
potential Φ(p), and acceleration g(p) alongside SDFs and CSG) and is read by the
character/vehicle controllers in this same crate. floptle-matter writes
per-material density into the field; gravity reads it. One-way flow, no cycle.
1. Core principle — space, matter, gravity, density are one thing
The engine already understands space (the SDF f(p, t)) and matter
(MatterModel over that field). Gravity closes the loop with two equations:
mass m = ρ · V (V = volume from the SDF/mesh; ρ = material density)
gravity g(p) = field emitted by that mass, sampled anywhere in space
So "the more mass/density, the stronger the gravity that matter creates" is the
literal data path: density is authored per material, volume comes from the shape
the engine already has, mass falls out, and from mass comes the field everything
(you, a car, a spaceship, debris) samples as g(p). There is no global
(0,-9.8,0) baked anywhere — that is merely the cheapest tier, entirely opt-in.
The payoff the vision asks for: if a fractal swirls up into the air, the space itself emits a pull that keeps you grounded, so you run up that wall — not anti-gravity floatiness, but a real "down" that follows the surface.
2. The composable gravity tiers (cheapest → heaviest)
Same philosophy as the rest of Floptle: pick the cheapest tier that gets the feel; a tier never drags in the machinery of the tiers above it.
cost ▁▁▂▂▃▃▅▅▇▇█
Global Sources SDF-surface Density-field
│ │ │ └ Poisson ∇²Φ=4πGρ, FFT/multigrid/Barnes–Hut (research)
│ │ └ g ∝ -∇f, reuses physics' gradient — nearly free (the headline)
│ └ Newtonian point/sphere/line, O(bodies×sources)
└ constant vector or authored regions (most games)
Tier 0 — Global / volume gravity (most games)
A constant vector, or authored gravity regions (a box or sphere with either a fixed direction or a radial pull toward/away from a center):
constant: g(p) = g0 // e.g. (0, -9.8, 0)
region box: g(p) = dir · strength if p ∈ box // local "down" volume
region rad: g(p) = -strength · normalize(p - c) if |p-c| < R (radial well)
Evaluated by a point-in-volume test; effectively free. The default for any game that just wants things to fall down. Regions let a gravity-flip room, an elevator shaft, or a stylized vortex exist without any physics tier.
Use when: normal games, authored set-pieces. Cost: a few AABB/sphere tests.
Tier 1 — Analytic sources (planets, O(bodies × sources))
Point / sphere / line sources with Newtonian falloff — the planet tier:
point/sphere: g(p) = -G · M · (p - c) / |p - c|³ (M = ρ·V of the body)
line/rod: g(p) = -2G·λ · (p - c⊥) / |p - c⊥|² (λ = mass per length)
G is a game-feel constant, not 6.674e-11 — tune it for your scale (see §10). A
cheaper, jitter-free alternative for "stand on a planet" ignores 1/r² and just
points at the center with constant strength inside an outer radius:
simple planet: g(p) = -strength · normalize(p - c) for |p-c| < R_outer
O(bodies × sources) — trivial for a handful of planets. The Mario-Galaxy /
Outer-Wilds tier: walk around a planet (down points at its center), orbit it,
fly between several. Multiple sources just sum (§3).
Use when: space exploration with planets/moons; orbital flight; "walk all the way around a small planet." Cost: a few vector ops per source per body.
Tier 2 — SDF-surface gravity (the headline)
Pull a body onto the nearest implicit surface so "down" follows the geometry — this is what makes you run on a fractal and up its swirling walls:
g_sdfSurface(p) = -strength · normalize(∇f(p)) · falloff(f(p))
∇f(p) is the SDF gradient — the exact same gradient physics computes for
contact normals and the renderer for shading (./physics.md §"The
SDF math": analytic where available, else the 4-tap tetrahedron difference). So this
tier is nearly free: it reuses an evaluation the frame already needed.
-normalize(∇f) points from p into the nearest wall/floor — your local "down,"
whatever wild angle the fractal folds into.
fractal wall swirls up g points INTO the surface (toward it),
───────╮ so the player's "down" tracks the wall:
│ ● player → g ● runs straight up the vertical face,
│ ↘ down kept grounded by the field, not floaty
───────╯
Smoothing (avoid jitter). Near a deep fractal ∇f flips wildly between nearby
surfaces. Three fixes, in order of preference:
- Sample the gradient of the baked / blurred distance field (the brickmap,
./physics.md§"Baked sparse SDF"), not the raw analytic fold — trilinear interpolation already low-pass-filters it, so its gradient is smooth. - Widen
εin the finite difference toward the field's Lipschitz scale, trading feature sharpness for stability (theεtradeoffphysics.mdcalls out). - Temporally smooth the resulting
gper body (EMA) so the up-vector eases.
Range clamping. falloff(d) zeroes the pull far from any surface so you are not
yanked toward distant geometry: e.g. falloff(d) = clamp(1 - d/R_grav, 0, 1), full
on the surface, gone past R_grav. Beyond range this tier contributes nothing and
you blend back to whatever else is active (§3) — leap off the fractal and you fall
under Global/Source gravity until you near a surface again.
Use when: infinite fractal block-worlds; walking arbitrary morphing implicit geometry; "run up the wall." Cost: ~one gradient eval — usually already cached for collision that step.
Tier 3 — Calculated density-field gravity (research / later)
The literal "engine calculates gravity from mass and density" tier. Derive a
density field ρ(p) from matter — per-material density times SDF occupancy on
the sparse brickmap — then solve for the potential and take its gradient:
density: ρ(p) = Σ_materials ρ_mat · occupancy_mat(p) (occupancy from f<0 on the brickmap)
Poisson: ∇²Φ(p) = 4πG · ρ(p) (gravitational Poisson eq.)
gravity: g(p) = -∇Φ(p)
The only tier where gravity is emergent: a dense fractal core genuinely out-pulls a hollow shell, and a compacted clay heap (§5) gains pull as its density rises. The trap is cost — naïve O(N²) n-body or a dense Poisson solve is a non-starter. We do not do that. Pick by structure:
- Continuous field (the brickmap is a grid): solve
∇²Φ = 4πGρwith a grid Poisson solver — an FFT (particle-mesh) solve on a padded block, or geometric multigrid for non-periodic boundaries. Both areO(N)–O(N log N)in cells, run as wgpu compute, and reuse the sparse bricks (empty space contributes nothing). - Many discrete masses (debris, ships, chunks): a Barnes–Hut octree treecode
(group distant mass into one multipole),
O(N log N), or FMM forO(N)at largeN. The standard computational-astrophysics answer; we don't invent it.
Low cadence + LOD. Gravity changes slowly, so the solve runs at a low
cadence (every Nth step / a rolling budget) and g(p) is sampled cheaply
(trilinear on Φ) every step between. Far/coarse bricks solve lower-res and slower.
Bodies read the cached field; only the re-solve costs, and it is amortized.
Honesty. For bounded scenes this is achievable. For huge/infinite worlds, a globally consistent density-field solve is later/research — the near-term engine ships Tiers 0–2 (which already deliver fractal-walking and planets) and treats Tier 3 as the long arc. Flagged, not promised.
3. Composition — sum of opt-in fields
A body's total gravity is the sum of whatever tiers are enabled for it:
g(p) = g_global + Σ_s g_source(p, s) + g_sdfSurface(p) + g_densityField(p)
Each term is opt-in and independently weighted; absent tiers cost nothing. The
surface range clamp (§2.2) and source falloff keep the blend smooth — leap off a
fractal wall and g_sdfSurface fades while g_global takes over, so you arc into a
clean fall rather than snap.
/// What a body asks the world for, each fixed step, at its position.
pub struct GravityField {
pub global: Option<GlobalGravity>, // constant or region lookup
pub sources: Vec<GravitySource>, // analytic planets/rods (Tier 1)
pub sdf: Option<SdfGravity>, // surface-follow (Tier 2): strength, R_grav, smoothing
pub density: Option<DensityGravity>, // sampled Φ grid handle (Tier 3)
}
pub enum GravitySource {
Point { c: Vec3, mass: f32, g_const: f32 }, // Newtonian or simple-radial
Sphere { c: Vec3, mass: f32, radius: f32, r_outer: f32 },
Line { a: Vec3, b: Vec3, lambda: f32, r_outer: f32 },
}
impl GravityField {
/// The workhorse: total "down" at p. Reuses physics' ∇f for the sdf term.
pub fn sample(&self, p: Vec3, world: &CollisionWorld, t: f64) -> Vec3 {
let mut g = Vec3::ZERO;
if let Some(gl) = &self.global { g += gl.at(p); }
for s in &self.sources { g += s.at(p); }
if let Some(sd) = &self.sdf { g += sd.at(p, world, t); } // -strength·n̂·falloff
if let Some(df) = &self.density { g += df.sample(p); } // -∇Φ, trilinear
g
}
}
A Gravity component (§7) carries the per-body GravityField (or a shared
reference). The controller calls sample once per step and treats the result as the
only "down" it knows.
4. Movement in a gravity field
The kinematic character controller (./physics.md §"Kinematic
character controller") needs only one change: "up" is -normalize(g(p)), not a
hardcoded +Y. Everything else — move-and-slide on spherecast/sweep, ground
probe, step offset, v_surf carry — runs in that local frame:
1. g = gravity.sample(pos, world, t)
2. up = -normalize(g) // field-defined "up"
3. align body orientation toward `up` SMOOTHLY (slerp, not snap):
target = look_rotation(forward_projected_onto_plane(up), up)
orient = slerp(orient, target, align_rate · dt)
4. accelerate along g (when airborne), then move-and-slide in the {forward, up} frame
5. ground check = short spherecast along -up; grounded if hit & angle(n, up) ≤ slope_limit
6. inherit v_surf from the ground contact (∂f/∂t) as before
Because the controller is expressed in the local up, the same code yields
wall-running (Tier 2 up tracks a vertical fractal face), planet-walking (Tier 1 up
points at the planet center as you circle it), and arbitrary fractal-surface-walking
— no special cases, just a different g.
flat ground around a planet up a fractal wall
up↑ up up→
● ╱ ● ╲ ┌───● (up points out of
───── ( ●●● ) ← walk │ ↑ the wall; player
╲ ● ╱ all the way │ walks the vertical
up around └─── face, grounded)
Smooth alignment matters. Snapping orientation to a fast-changing fractal normal
is nausea-inducing; the slerp in step 3 (plus the temporal smoothing from §2.2)
gives stable, readable reorientation. The camera follows the same up: the
controller exposes the current up-vector and the camera rig
(./camera-and-dialogue.md) eases its own up toward it,
so the horizon rolls with the surface instead of the world spinning under a fixed
camera.
The spaceship case. A free-flying body sets its gravity response low or zero:
it samples g(p) (for HUD "down," or a gentle pull when near a planet) but
integrates under its own thrust. A response: f32 scales how much of g it obeys:
v += (response · g(p)) · dt // response = 1 grounded walker, ≈0 free spaceship
So one planet can be orbited in a ship (low response, Newtonian source pull) and landed on and walked (response = 1, source gravity + optional surface alignment) — same field, different response.
5. Density → matter behavior (crush & compaction)
Density is not only mass and gravity — per ADR-0013 it governs whether matter
crushes under pressure (soft clay) or resists (hard metal). This ties into
the XPBD soft-body / matter solver (./deformable-matter.md
§"Soft body"). Two material constants drive it:
mass: m = ρ · V
bulk modulus K: resistance to volume change → volume-constraint stiffness
yield strength: pressure past which compaction is PLASTIC (permanent)
Pressure → strain. A material under pressure P changes volume by the linear
elastic relation, and density rises as volume falls (mass is conserved):
volumetric strain: ΔV/V = -P / K (large K ⇒ tiny strain ⇒ "hard")
new density: ρ' = ρ · V / V' ≈ ρ · (1 + P/K)
Elastic vs plastic. Below the yield pressure the strain is elastic — it
springs back when load is removed. Above it, compaction is plastic — the rest
volume V₀ itself permanently shrinks (and ρ permanently rises):
if P ≤ P_yield: elastic — V relaxes back to V₀ when unloaded
if P > P_yield: plastic — V₀ ← V₀ · (1 - (P - P_yield)/K) (compaction sticks)
In XPBD terms this is exactly the volume constraint C = V(tet) − V₀ with
compliance α = 1/K (high K ⇒ stiff, near-incompressible metal; low K ⇒
soft clay) plus a plastic yield on volume strain that rewrites V₀ — the same
elastoplastic return-mapping the matter doc uses for stretch-to-strands, applied to
volume instead of length.
Where pressure comes from. Contacts (something pressing on the matter) plus
gravity load — a tall dense column self-loads under its own g, so the bottom
bricks feel P ≈ ρ · |g| · h and compact first. Closing the loop: compaction
raises ρ, which (Tier 3) raises the gravity that region emits.
load ↓↓↓ soft clay (low K): hard metal (high K):
┌─────────┐ P>P_yield ┌─────────┐ squashed & ┌─────────┐ barely
│ ▓▓▓▓▓▓▓ │ ──────────▶ │▓▓▓▓▓▓▓▓▓│ permanently │ ▓▓▓▓▓▓▓ │ moves
│ ▓▓▓▓▓▓▓ │ └─────────┘ denser (ρ↑) └─────────┘ (springs back)
└─────────┘
Near-term ships density→mass and basic elastic+plastic volume compaction on Tier-3 soft bodies; rich, large-scale plastic flow is the matter doc's research arc.
6. Performance posture
Hyperoptimization is the requirement (ARCHITECTURE §9); the tier ladder is the optimization:
- Tiers + opt-in. Most games never leave Tier 0 (a few AABB tests); sources are a handful of vector ops. You pay only for the tier you reach for.
- SDF-surface gravity is nearly free — reuses the
∇fphysics already evaluates for collision that step (./physics.md), usually a cache hit. - Density-field Poisson at low cadence + LOD. Solved on the sparse brickmap
(empty space free), re-solved every Nth step on a rolling budget, coarser/slower
with distance;
gis sampled trilinearly from cachedΦcheaply each step. - Barnes–Hut / FMM for discrete masses —
O(N log N)/O(N), never O(N²). - GPU compute. The Poisson solve (FFT/multigrid), treecode, and per-body
gsampling run as wgpu dispatches; the CPU orchestrates. - Fixed-step determinism. Sampling and solve run in the fixed-step stage;
deterministic ordering keeps the sim reproducible — a head start on the future
networking goal (
../ARCHITECTURE.md§10). - Sleep/wake. Grounded idle bodies sample lazily; sleeping matter
(
./deformable-matter.md§5) does not re-solve its density contribution until disturbed.
7. Authoring & scripting
Gravity component / inspector. A Gravity component picks which tiers a body
participates in and how strongly:
struct Gravity {
field: GravityField, // global? sources? sdf? density? (§3)
response: f32, // how much this body obeys g (1 = walker, ~0 = spaceship)
align: AlignMode, // None | UpToField(rate) — orientation follow (§4)
}
The inspector exposes tier toggles, per-tier strength/range (R_grav, source mass or
g_const, smoothing), response, and align rate. A body emits gravity by carrying
a GravitySource (mass = material density × SDF volume) — "this rock is heavy enough
to pull things" is a checkbox, not code.
Per-material density. Density lives on the material (alongside the matter
constants of ./deformable-matter.md /
./materials-and-textures.md): density,
bulk_modulus, yield_strength — authoring a material authors its mass-per-volume,
crushability, and (via Tier 3) emitted gravity all at once.
Lua API (../ARCHITECTURE.md §6) — the runtime control
surface:
gravity.set_mode(obj, "sdf_surface", { strength = 18.0, range = 4.0 }) -- pick a tier
gravity.add_source(planet, { mass = 5.0e6, g_const = 12.0 }) -- emit gravity
gravity.set_response(ship, 0.0) -- free flight
gravity.align_to_field(player, true) -- wall-running on
local g = gravity.sample(some_point) -- query g at a point
Everything is RON-serialized like the rest of Floptle — diffable, hand/AI-editable:
// A fractal world you run on, with one orbital planet and two materials.
Gravity(
field: GravityField(
global: None, // no global "down" — the surface defines it
sdf: Some(SdfGravity( // Tier 2: run on / up the fractal
strength: 18.0,
range: 4.0, // R_grav: pull only near a surface
smoothing: Baked(blur: 0.5), // gradient from the blurred brickmap
)),
sources: [ // Tier 1: a planet you can also orbit
Sphere(c: (0, 0, 900), mass: 5.0e6, radius: 200, r_outer: 1200),
],
density: None, // Tier 3 off (near-term)
),
response: 1.0, // grounded walker obeys g fully
align: UpToField(rate: 8.0), // smooth reorientation to the surface
)
// Per-material density / compaction (read by gravity AND the matter solver).
Material(id: "soft_clay", density: 1.6, bulk_modulus: 0.05, yield_strength: 0.2) // crushes
Material(id: "hard_iron", density: 7.8, bulk_modulus: 8.0, yield_strength: 6.0) // resists
8. Use cases mapped to tiers
- Infinite fractal block-world — run on the surfaces. Tier 2 (SDF-surface gravity) + Tier 0 fallback when airborne. "Down" follows the fold; you run up the swirling wall, kept grounded. No anti-gravity floatiness.
- Space exploration with fractal planets. Tier 1 (analytic sources) for the
planets' pull; the spaceship flies free (
response ≈ 0), feeling each planet only as it nears. Procedural fractal planets are just SDF bodies with aGravitySourcewhose mass comes fromρ·V. - A planet you orbit AND land on. One
Spheresource: orbit it in the ship (low response, Newtonian falloff for the orbit), then land and walk all the way around (response = 1, source gravity points at the center; optionally add Tier 2 surface alignment for fractal terrain on the planet). - Emergent gravity from a dense core (later). Tier 3 — a procedurally dense
fractal genuinely out-pulls a hollow one; compacted clay (§5) gains pull as
ρrises. Research arc.
9. Near-term vs future (the honest roadmap)
Near-term — achievable now:
- Tier 0 Global / volume gravity (constant + authored regions).
- Tier 1 Analytic sources (point/sphere/line, Newtonian or simple-radial).
- Tier 2 SDF-surface gravity — the headline; reuses
∇f, smoothing, range clamp, blend with other tiers. Wall-running / fractal-walking ships here. - Density → mass (
m = ρ·V) and basic compaction (elastic volume strain + simple plastic yield on Tier-3 soft bodies). - Character controller field-up + smooth alignment; spaceship
response.
Future / research-grade — flagged, not promised:
- Tier 3 calculated density-field Poisson gravity (FFT/multigrid + Barnes–Hut/ FMM) at scale, especially for huge/infinite worlds with a globally consistent field.
- Rich plastic compaction (large-scale plastic flow, density-driven phase-like behavior) beyond the basic volume-yield.
- Two-way gravity↔compaction feedback (compaction raises
ρ, raises emitted gravity) at world scale.
10. Out of scope / honesty
We want plausible, controllable, game-feel gravity — not a science simulator.
- No general relativity, no relativistic corrections, no realistic astrophysical
accuracy.
Gis a tuning knob, not a physical constant. - No realistic orbital mechanics as a goal — orbits are stable and fun, not ephemeris-accurate.
- Gravity is opt-in per game: the engine makes it easy when desired (because it inherently understands space, matter, gravity, and density), and absent when not. Most games stay at Tier 0 and never think about any of this.
Nothing here changes the renderer or the SDF collision world: gravity is a reader
of the shared field and a per-step "down" for controllers that already move in a
local frame — so the risk is concentrated in floptle-physics::gravity and the
Tier-3 solver, not spread across the engine.