WASD · SHIFT to boost · mouse to look
3D engines store positions as 32-bit floats. They get roughly 7 digits of precision. At X = 50,000, the smallest distinguishable step is around 0.004. At X = 1,000,000 it's 0.06 ; bigger than a footstep. Vertices jitter, physics rattle, the camera shakes. This is the wall every open-world game eventually hits.
Lie to the engine. Keep the player physically near (0, 0, 0) at all times. When they drift past a threshold (say 300 units), teleport them back to zero, and shift the entire world the same amount in the same direction. From inside the engine, nothing changed: same relative positions, same view, same physics. But your camera coordinates stayed small and precise.
Game logic still needs to know where the player actually is, so it can seed chunks, save the game, talk to a server. So you maintain a separate globalOffset in 64-bit doubles (in JavaScript every number is already a double; in C# use double). Each shift adds to it. The player's absolute position is always engineCamera + globalOffset.
A common first instinct is to parent every chunk to one big worldRoot transform and translate that root instead of the player. It seems clean, but it breaks for the same reason as before: once the root drifts to 4,000,000, your child translations like 0.001 round down to zero. The float precision lives at the final composed transform, not just at the player. Shifting each chunk individually (or shifting a small pool of nearby roots) is the version that actually works.
(0, 0, 0). As you walk, it stays put, because it does. When the floating-origin event fires, the pillar appears to teleport toward you, but actually you teleported to it. Everything else shifts to compensate, so the world looks seamless. Hold SHIFT to traverse fast and trigger shifts in seconds.
Two coordinate systems running side by side: engine space (small, near zero, lossy floats) and world space (huge, precise, doubles or 64-bit ints).
const THRESHOLD = 300 // distance before shift
const CHUNK_SIZE = 100
vec engineCam = (0, 0, 0) // what the renderer sees, kept near 0
vec globalOffset = (0, 0, 0) // accumulated shifts (doubles)
map chunks = {} // chunkKey -> mesh
// 1. Apply input to the engine camera (small numbers)
engineCam += inputDir * speed * dt
// 2. Derive your absolute world position
absPos = engineCam + globalOffset
// 3. Generate / unload chunks based on absPos
chunkX = floor(absPos.x / CHUNK_SIZE)
chunkZ = floor(absPos.z / CHUNK_SIZE)
loadChunksAround(chunkX, chunkZ)
// 4. If the engine camera drifted too far, recenter
if length(engineCam.xz) > THRESHOLD:
shift = (engineCam.x, 0, engineCam.z)
globalOffset += shift
engineCam -= shift
for chunk in chunks: chunk.position -= shift
A chunk at world coordinates lives in engine space by subtracting the offset:
engineX = (chunkX + 0.5) * CHUNK_SIZE - globalOffset.x
engineZ = (chunkZ + 0.5) * CHUNK_SIZE - globalOffset.z
Hash the chunk's world coordinates (never engine coords) with a fixed seed to feed a PRNG. Same chunk → same contents, every time, every machine. No need to save anything to disk:
seed = hash(chunkX, chunkZ, WORLD_SEED)
rng = Mulberry32(seed)
for i in 0..fixtureCount:
placeShelf(rng() * width, rng() * height)
300–1000 works in most engines. Browsers tend toward the lower end; native engines like Unity / Unreal handle larger ranges comfortably.
Attach to a manager GameObject. player is whatever you consider the center of the world (usually the main camera root or character controller). Threshold of 1000 is a typical Unity choice (the float-stable range is generous before precision visibly suffers).
using UnityEngine;
using UnityEngine.SceneManagement;
public class FloatingOrigin : MonoBehaviour
{
public Transform player;
public float threshold = 1000f;
// Doubles survive way past Unity float range.
public double absoluteX, absoluteZ;
// Cache these at chunk load ; FindObjectsOfType is slow.
[SerializeField] Rigidbody[] trackedBodies;
[SerializeField] ParticleSystem[] trackedParticles;
void LateUpdate()
{
Vector3 p = player.position;
if (p.x * p.x + p.z * p.z < threshold * threshold) return;
Vector3 shift = new Vector3(p.x, 0f, p.z);
absoluteX += shift.x;
absoluteZ += shift.z;
// 1. Recenter every root transform.
foreach (GameObject root in SceneManager.GetActiveScene().GetRootGameObjects())
root.transform.position -= shift;
// 2. Nudge active rigidbodies so interpolation doesn't snap.
foreach (Rigidbody rb in trackedBodies)
if (rb && !rb.isKinematic) rb.position -= shift;
// 3. Translate every live particle in world-simulated systems.
foreach (ParticleSystem ps in trackedParticles)
{
if (!ps || ps.main.simulationSpace != ParticleSystemSimulationSpace.World) continue;
var buf = new ParticleSystem.Particle[ps.particleCount];
int n = ps.GetParticles(buf);
for (int i = 0; i < n; i++) buf[i].position -= shift;
ps.SetParticles(buf, n);
}
}
}
// Anywhere in your code:
Vector3 engine = player.position;
double worldX = origin.absoluteX + engine.x;
double worldZ = origin.absoluteZ + engine.z;
// Feed worldX/worldZ to chunk generation, save files, network sync, etc.
int chunkX = (int)System.Math.Floor(worldX / CHUNK_SIZE);
int chunkZ = (int)System.Math.Floor(worldZ / CHUNK_SIZE);
Set position directly on shift. Don't use MovePosition ; it interpolates and you'll see a snap.
Only world-simulated particles need translation. Local-space particles move with their transform automatically.
Their internal points live in world space. Either Clear() them on shift, or iterate GetPositions and offset every vertex.
3D positional audio on transforms shifts cleanly. For streaming sources, parent them to the player so they never drift at all.
Bake per chunk with NavMeshSurface. A monolithic baked mesh tied to world coords won't survive shifts.
Each client has its own origin. Sync the absolute position over the wire, then convert to local engine space on receive.
FindObjectsOfType in LateUpdate would tank your frame time. Cache the rigidbodies and particle systems when chunks spawn, drop them when chunks despawn. The shift itself is cheap once the lists are pre-built.