Floptle — Large-world space (`floptle-core`)
Floptle — Large-world space (floptle-core)
Default-on, behind-the-scenes coordinate space: the world moves around the player, the player never actually moves — so a galaxy-scale or infinitely-deep world renders and simulates with zero jitter and zero developer work.
Decision & rationale: ADR-0015. Reads-with: the authoring model
./scene-and-nodes.md(Transformlives infloptle-core), the GPU upload path./renderer.md, the SDF sim./physics.md, and the gravity-aligned player frame./gravity-and-density.md. Peers sharing a space:../multiplayer.md.
The headline: the developer writes ordinary world-space code. You place a node at
(0, 0, 0) or at a coordinate twelve light-years out; you read and set
self.transform.pos exactly the same way. Everything below — camera-relative upload,
floating origin, f64 transforms, reference frames — is engine plumbing the game
never has to think about. Most engines make you architect around this; we own the
renderer and the physics, so we bake it in instead.
1. The precision problem, concretely
A 32-bit float is 1 sign · 8 exponent · 23 fraction bits → 24 bits of effective
mantissa, ≈ 7.2 significant decimal digits. The catch is that the step between
representable values scales with magnitude: the gap is 2^(exp-23). Concretely the
absolute resolution near a value x is roughly x · 2^-23 ≈ x · 1.2e-7:
|x| (meters) ULP / smallest step what it means on screen
──────────── ─────────────────── ──────────────────────
1 ~6e-8 m (60 nm) perfect
1 000 ~6e-5 m (0.06 mm) fine
65 536 ~0.004 m (4 mm) sub-pixel wobble begins
1 000 000 ~0.06 m (6 cm) visible jitter, cracks
16 000 000 ~1 m snapping; geometry tears
So somewhere around 10^5–10^6 units the representable gap crosses a meter and
positions snap to a lattice. Vertices that should be still vibrate frame to frame
(the camera transform rounds differently each frame), normals flicker, z-fighting
appears, and a smooth fly-through turns to gravel. GPUs force f32 — wgpu/WGSL
math, vertex attributes, and depth are 32-bit; emulated double on the GPU is slow and
unsupported on most of our backends (ADR-0002). Physics degrades the same way:
penetration depth and ∇f finite differences (see ./physics.md)
are differences of large nearly-equal numbers, where f32 cancellation eats every
useful bit.
Why f64 helps but isn't the whole answer. A double has 52 fraction bits → 53
effective → ≈15.95 significant decimal digits. That buys sub-millimeter precision
out to ~tens of AU (1e12–1e13 m): a solar system is comfortable. But a galaxy is
~10^21 m across, and 1e21 · 2^-52 ≈ 2.2e5 — a 220 km step. Flat f64 cannot
hold a galaxy at meter precision. The fix is not "more bits everywhere," it's
hierarchy: keep precision where the player is, and represent the rest as
high-precision offsets between nested frames. (f128/fixed-point are off the table —
no hardware, and they don't solve the GPU side anyway.)
2. The layered solution (all default-on, all transparent)
Four layers, each independently always-on. They compose: 1+2+3 land near-term and already give you a flawless solar system; layer 4 extends the same idea to a galaxy.
Layer 1 — Camera-relative rendering (always on)
We never upload absolute world positions to the GPU. We upload positions relative
to the camera, with the subtraction done in f64 and the result cast to f32
last:
p_view = (p_world_f64 - cam_world_f64) // f64 subtract — full precision
view_f32 = (proj_f32) * (R_cam_f32) * vec3_to_f32(p_view)
The GPU only ever sees small numbers (the visible scene is a few km across no matter
where it sits in the world), so vertex math, depth, and interpolation are all in
f32's sweet spot. This is the renderer's default upload path
(./renderer.md): the per-draw model matrix is a model-view
matrix built host-side in f64 and downcast, never a model matrix times a giant view
matrix on the GPU. It kills GPU jitter at any world scale, and it is ~free — one
f64 vector subtract per object (or per instance batch) that we already do when
culling. For raymarched fields the ray origin ro is likewise expressed
camera-relative before entering WGSL.
Layer 2 — Floating origin (always on)
Camera-relative upload fixes the GPU. Floating origin fixes the CPU sim: we keep
the active simulation near (0,0,0) so physics and gameplay never integrate large
coordinates. When the anchor camera drifts past a threshold (a few km), we rebase:
if cam.translation.length() > origin.threshold {
let shift = cam.translation; // DVec3, the offset to remove
for t in active_transforms { t.translation -= shift; }
origin.world_offset += shift; // remember where (0,0,0) really is
}
- What rebases: the
translationof every active (player-frame) entity, and theworld_offsetrecord on theFloatingOriginresource. - What must NOT rebase: velocity, acceleration, force, momentum, and any relative
constraint (joints, contact offsets, surface-velocity from
∂f/∂t). These are translation-invariant —(p - shift)' = vbecause the shift is constant — so a rebase is invisible to the integrator. This is the property that makes it safe. - When: at a defined quiet point between fixed steps (see §5), so a physics step never spans a rebase and never sees a big coordinate. The sim is identical before and after, just re-centered.
world_offset is DVec3 and accumulates the true distance from the universe origin;
add it back only when you need an absolute coordinate (serialization, frame math).
As implemented (2026-07-02). The shipped design inverts the sketch above in one important way: instead of rebasing ECS translations, the rebase lives inside the physics sim.
PhysicsWorld.origin(DVec3) anchors an origin-relative frame — bodies, contacts, gravity centers and ray origins are all smallf32numbers near that origin; each static collider is baked relative to its ownf64anchor and re-offset (anchor − origin, computed inf64) on every rebase, so repeated rebases accumulate zero error into geometry. The ECS keeps stable, absolutef64translations at all times, which means scripts and gameplay code never observe a rebase — a Lua variable holding a world position is still valid afterward. The trigger is the active camera drifting >FloatingOrigin.threshold(4 096 m) fromorigin; the new origin is the camera position rounded to whole units (whole-number shifts are exact inf32). Sim → ECS writeback also interpolates between the last two fixed steps by the accumulator fraction, so rendered motion is smooth at any frame rate. Terrain (2026-07-02, same day): per-volume fields are node-local, and there is no combined field at all anymore — every terrain volume is uploaded into one shared 3D atlas at its NATIVE voxel resolution and the shader folds them with the same smin the old CPU combine used (fields continued as air outside their boxes — the near-zero-shell lesson — and the slab-edge taper applied once, post-fold, against the union of the boxes so interior seams stay seamless while true outer faces still slope to air). Placement is per-volume: nodef64anchor + local center composed inf64, then camera-relative, read fresh every frame — moving a terrain costs zero GPU work. Physics likewise anchors each volume as its own collider at native resolution. So neither collision nor rendering degrades with distance from the origin OR with distance between volumes; the only capacity limits are explicit and surfaced (16 volumes per scene / the device's 3D-texture size, warned in the Console, never silent). Visual regression proof:terrain_far_probe(two-volume scene at 1e7 units) is bit-identical toterrain_blend_probe(same scene at the origin).
Layer 3 — f64 authoritative transforms
Transform.translation is DVec3 (glam's f64 vec) — rotation and scale stay
f32 (an f32 quaternion is fine to nanoradians; nobody is 10^6 units of rotation
from origin). Each frame we derive a small camera-relative f32 render transform
from the f64 translation + the f32 rotation/scale. Authoritative state is f64;
the GPU-facing state is the cheap f32 projection of it.
Layer 4 — Hierarchical reference frames (galaxy and beyond)
For scales past flat f64 we nest frames. A frame tree — galaxy → star system → planet → local — where each frame stores a high-precision offset from its parent.
Only the player's local frame is simulated at full precision near the origin;
sibling and ancestor frames are composed camera-relative at render time by walking
the offsets in f64 and downcasting at the end (Layer 1, generalized):
┌─ galaxy ───────────────── offset_from_parent = 0
│ ├─ system "Sol" ─────── DVec3 (light-years, f64)
│ │ ├─ planet "Floo" ── DVec3 (AU-scale, f64)
│ │ │ └─ LOCAL ◀──── the player; simulated at origin
│ │ └─ planet "Bar" ── DVec3
│ └─ system "Vega" ────── DVec3
p_render = downcast_f32( Σ offsets(local..camera_frame) + (p_local - cam_local) )
Each entity carries a frame: FrameId and a local DVec3. To render anything you sum
the chain of f64 offsets from its frame up to the camera's frame, add the
camera-relative local delta, then cast. The whole galaxy is representable because no
single number ever has to hold galaxy-scale and meter precision at once — the scale
lives in the offset chain, the precision lives in the local coordinate.
3. Data structures (Rust-ish)
// floptle-core — the authoritative transform (see ./scene-and-nodes.md catalog)
struct Transform {
translation: DVec3, // f64 authoritative world (within its frame)
rotation: Quat, // f32 — plenty precise
scale: Vec3, // f32
frame: FrameId, // which reference frame (FrameId::ROOT until galaxy-scale)
}
// The single moving-origin record. One per active sim (ECS resource).
struct FloatingOrigin {
world_offset: DVec3, // accumulated true distance of (0,0,0) from universe origin
anchor: Entity, // the camera that drags the origin (usually the active cam)
threshold: f64, // rebase when anchor strays past this (meters), e.g. 4_096.0
}
// A node in the frame tree (galaxy → system → body → local). Layer 4.
struct ReferenceFrame {
id: FrameId,
parent: Option<FrameId>,
offset_from_parent: DVec3, // f64 high-precision offset
rotation: Quat, // optional frame orientation (e.g. gravity-aligned)
}
// Derived each frame, consumed by the GPU. Small numbers only. Never serialized.
struct RenderTransform {
model_view: Mat4, // f32, camera-relative — ready to multiply by projection
}
How the renderer consumes them — once per frame, after the sim settles:
// for each visible entity, in floptle-render's upload step
let rel: DVec3 = frame_chain_offset(t.frame, cam.frame) // Σ f64 offsets (Layer 4)
+ (t.translation - cam.translation); // f64 camera-relative
let mv = Mat4::from_scale_rotation_translation(
t.scale, t.rotation, rel.as_vec3()); // downcast LAST
render.push(RenderTransform { model_view: cam_view_rot * mv });
How physics consumes them — floptle-physics reads translation directly and,
because Layer 2 keeps the active frame near origin, the f32 SDF math
(distance, ∇f, sweeps) already operates on small numbers. It samples
f(p_local, t) in local coordinates; the field never sees the world offset (see §4).
4. Integration — it must "just work"
Scene / Node model (./scene-and-nodes.md). Authoring is
ordinary world space. The inspector shows whatever coordinate the author typed; the
node tree, prefabs, and scripts are unchanged. Under the hood the Transform is f64
and (at galaxy scale) frame-tagged. RON serialization stores the f64 translation
and, when present, the frame — so a saved scene is exact, not a rounded f32:
// far-flung station, authored in plain world coords; frame defaults to Root
Node( id: 42, name: "RelayStation", parent: None,
transform: (
pos: (1.4960e11, 0.0, -3.84e8), // f64 — ~1 AU out, exact
rot: (0, 0, 0, 1),
scale: (1, 1, 1),
frame: "Sol/Floo", // omitted ⇒ Root (most scenes)
),
components: [ MeshRenderer( mesh: "models/relay.glb#Mesh" ) ],
),
┌ Inspector ─────────────────────────────────┐
│ RelayStation │
│ Transform pos[1.4960e11 0 -3.84e8] (f64)│ ← author edits world coords
│ Frame Sol/Floo │ ← auto for galaxy scenes; else Root
└─────────────────────────────────────────────┘
SDF / procedural worlds. Fractals and SDFs evaluate in local coordinates plus a
frame offset, so the field functions are scale/offset-aware: to march or collide a
distant body you transform the sample point into that body's local space first, then
evaluate f(p_local, t). "Infinitely deep" zoom works the same way — descending into a
fractal is a frame with a shrinking scale and an f64 offset, so the local
coordinates the field sees stay O(1) no matter how deep you go. The renderer and
physics share that one offset-aware field (./physics.md,
./renderer.md).
Determinism & the fixed-step loop. The deterministic fixed-timestep loop is preserved. Rebasing is a pure translation applied at one defined point — after the last fixed step of a frame, before render interpolation — never mid-step, so two runs from the same inputs rebase at the same instant and produce bit-identical state. The rebase is order-independent (it touches each entity's translation by the same constant).
Gravity-aligned player frame (./gravity-and-density.md).
The player's local frame can double as the gravity-aligned frame: ReferenceFrame. rotation orients "down" to g(p) so walking up a fractal wall or around a small body
keeps a stable up-vector for the camera and controller. Origin-anchoring and
gravity-alignment are the same frame — one rebase keeps both the position and the
orientation reference local.
Networking. Peers must agree on world_offset and the frame tree so that
everyone shares one space. Because the authoritative sim was already f64 + frames
- deterministic fixed-step, the seam was clean when
floptle-netcame to use it — which is the whole reason this page said not to paint over it.
5. Performance & correctness
- Camera-relative upload is ~free. One
f64subtract per object (or per instance batch), folded into the cull/upload pass we already run. No extra GPU cost — the GPU sees the samef32matrices it always would. - Rebase cost is bounded. A rebase touches only active (player-frame) entities'
translations — a linear sweep of one
DVec3field, cache-friendly, typically a few thousand entities. Distant frames are not touched (their offsets are unchanged). - Avoid hitches: pick a threshold large enough that rebases are rare (km-scale) but small enough that coordinates stay modest; amortize by spreading a very large rebase across the entities over a couple of frames only if needed (usually unnecessary). Because it happens at the quiet inter-step point, it never stalls a physics solve.
- Frame composition cost (Layer 4) is an
f64offset walk up the (shallow) frame tree per visible frame, not per entity — cache the per-frame camera-relative offset once and reuse it for every entity in that frame. - Determinism / repro maintained end-to-end:
f64authoritative state, a translation-invariant rebase at a fixed point, deterministic iteration order. Same inputs → same trajectory → same render, on a platform, bit-for-bit.
6. Authoring / UX — essentially nothing
Zero required developer work is the whole point. There is no API to call, no component to add, no "enable large coordinates" flag, no rule about staying near the origin. You write world-space code; the engine keeps you precise. Minimal knobs exist for the rare case:
FloatingOrigin.threshold— rebase distance (default a few km).- Origin anchor — which camera drags the origin (defaults to the active camera); set per-camera when a cutscene/security cam shouldn't move the world.
- A debug overlay (editor + runtime toggle) showing live
world_offset, the activeFrameId, distance-to-rebase, and last-rebase time:
┌ Large-World ───────────────────────────┐
│ origin offset (1.50e11, 0.0, -3.8e8) │
│ active frame Sol/Floo (depth 3) │
│ to rebase 1.9 km / 4.0 km │
│ last rebase 312 ms ago │
└─────────────────────────────────────────┘
7. Near-term vs Future
- Near-term (lands with the Phase-1 camera + fixed-step loop): camera-relative
rendering, floating origin, and
f64authoritative transforms — Layers 1–3. This alone delivers a jitter-free solar-system-scale world, which covers nearly every scene we'll author first. - Later: the full hierarchical reference-frame tree (Layer 4) for galaxy scale
and infinite fractal depth. The data structures above leave the seam in (every
Transformalready carries aFrameId, defaulting toRoot), so turning it on is additive, not a rewrite.
8. Out of scope
- Relativistic effects — no length contraction, time dilation, light-travel delay, or relativistic aberration. We solve numerical precision and jitter, not astrophysical realism. "Simulate a galaxy" here means represent and render it without floating-point error, not model its physics.
- Per-object adaptive precision / arbitrary-precision arithmetic —
f64+ frames is the model; we don't chasef128or bignum. - Multi-scene streaming / chunk loading — an orthogonal runtime concern
(
./scene-and-nodes.md"out of scope"), not part of the coordinate system.