@threecyborgs/infinite-inversion
v0.2.0
Published
Render an unbounded physics space into a bounded spherical room via spherical inversion, warping geometry exactly on the GPU. Three.js core with optional Rapier physics and a flight controller.
Maintainers
Readme
@threecyborgs/infinite-inversion

Render objects from an unbounded physics space into a bounded spherical room using spherical inversion:
x = (R² / |p|²) · p physics space → room spaceThe map is its own inverse. The room wall is the fixed sphere |p| = R; the room
center is physics infinity and can never be reached. Geometry is warped exactly,
per-vertex, on the GPU — straight rods bow into arcs and far ends shrink more
than near ones, instead of the flat per-object approximation a single matrix gives.
The core is physics-engine agnostic (it only needs three). A Rapier binding
and a spaceship flight controller ship as optional subpath exports.
Install
npm i @threecyborgs/infinite-inversion three
# optional, for the /rapier binding:
npm i @dimforge/rapier3d-compatthree is a peer dependency. @dimforge/rapier3d-compat is an optional peer —
only needed if you import @threecyborgs/infinite-inversion/rapier.
Quick start
The InvertedWorld facade wires the room shell, the warp + matching shadow
depth material, frustumCulled/double-sided setup, per-frame sync, and camera
clip planes for you:
import * as THREE from "three";
import {
DEFAULT_ROOM_RADIUS,
InvertedWorld,
invert,
recommendedRendererParams,
} from "@threecyborgs/infinite-inversion";
const R = DEFAULT_ROOM_RADIUS; // 50 (a 100 m-wide room)
// A logarithmic depth buffer is required — recommendedRendererParams() sets it.
const renderer = new THREE.WebGLRenderer(recommendedRendererParams());
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.01, 100);
const world = new InvertedWorld({ scene, roomRadius: R });
// Place markers in PHYSICS space with real, physical dimensions. The GPU warp
// shrinks and curves them into the room automatically — no room-space tweaking.
const mat = new THREE.MeshStandardMaterial({ color: 0x4dabf7 });
world.addStatic(new THREE.BoxGeometry(10, 10, 10), mat, {
position: new THREE.Vector3(0, 0, 400),
});
// View the room from a physics-space position.
const viewerPhysics = new THREE.Vector3(0, R * 0.3, R * 1.5);
function frame() {
requestAnimationFrame(frame);
camera.position.copy(invert(viewerPhysics, R)); // physics -> room
camera.lookAt(0, 0, 0);
world.sync(); // push physics poses into meshes
world.configureCamera(camera, viewerPhysics); // set near/far for this depth
renderer.render(scene, camera);
}
frame();For dynamic objects bound to a physics body, use world.addBody(geometry, material, body)
where body is any PoseSource (a Rapier RigidBody works directly).
A full runnable version is in examples/minimal.
If you'd rather assemble the primitives yourself, the lower-level building blocks
(RoomRenderer, InvertedStatic, makeInvertedMesh, applyInversionWarp,
createWarpDepthMaterial, configureInversionCamera) are all exported too.
Rules of the road
- Author everything in physics space: real positions and real dimensions (geometry radius, collider size, spacing in metres). Let inversion produce the room appearance — never hand-set room-space scale/position to "fix" looks.
- Meshes that warp must have
frustumCulled = false(their physics-space bounds don't match the room-space camera frustum).InvertedBody/InvertedStaticset this for you. - Use a
logarithmicDepthBufferon your renderer — the inversion spans a huge depth range (metre-scale wall → sub-mm centre markers).
Subpath exports
@threecyborgs/infinite-inversion/rapier
A minimal Rapier world bounded by the room wall, with a fixed-timestep stepper.
import { Physics } from "@threecyborgs/infinite-inversion/rapier";
import { InvertedBody } from "@threecyborgs/infinite-inversion";
const physics = await Physics.create({ roomRadius: R, rhoMax: 500, restitution: 1 });
const body = physics.createDynamicSphere(pos, radius, velocity);
room.add(new InvertedBody(mesh, body, R)); // body satisfies PoseSource
// each frame: physics.step(dt); room.sync();Any object implementing PoseSource ({ translation(), rotation() }) works with
InvertedBody, so you can drive it from any engine — not just Rapier.
@threecyborgs/infinite-inversion/controls
A frictionless, momentum-based spaceship Player flown in physics space and
rendered through the inversion (thrust toward where you look, boost, dampener,
first/third-person). See examples/vanilla for full wiring.
@threecyborgs/infinite-inversion/webgpu
WebGPU + TSL port of the warp, plus a GPU-compute level-of-detail field for millions of instances. LOD is keyed off the inverted (room-space) apparent size, never the physics distance — because under inversion two objects far apart in physics can both collapse toward the room centre and appear tiny and adjacent. A compute pass projects each instance, computes its apparent pixel size, and writes a per-instance morph factor + cull flag (with hysteresis); one instanced draw then geomorphs the geometry and discards sub-pixel instances. No indirect draw and no per-instance CPU readback (only a 16-byte atomic counter buffer for telemetry).
import { WebGPURenderer } from "three/webgpu";
import { InvertedLODField } from "@threecyborgs/infinite-inversion/webgpu";
const field = new InvertedLODField({
renderer, roomRadius: R, count: 1_000_000,
instances, // Float32Array of [x, y, z, featureRadius] * count (physics space)
});
field.addTo(scene);
// each frame, before render:
field.update(camera); // projects + classifies LOD on the GPUSee examples/webgpu-lod (npm run dev:webgpu). Validated by
tests-webgpu/lod.spec.ts (npm run test:webgpu) — including a deterministic
test that two instances 4.75× apart in physics, engineered to the same apparent
size, receive the same computed LOD.
WebGPU testing caveat. WebGPU needs a real adapter, so the WebGPU suite uses its own
playwright.webgpu.config.ts(flags--enable-unsafe-webgpu, not the WebGL config's SwiftShader, which has no WebGPU adapter). It is kept out ofnpm test/ CI because Linux CI runners may lack a WebGPU adapter; run it locally on a GPU-backed machine.
API
Core
| Export | Description |
|---|---|
| InvertedWorld | High-level facade: room shell + warp + sync + camera in one object. |
| invert(v, R, out?) | Invert a point through the sphere (physics ↔ room). |
| roomScale(p, R) | Uniform render scale R²/|p|² at physics point p. |
| reflectionMatrix(n, out?) | Householder reflection H = I − 2nnᵀ. |
| inversionMatrix(p, q, R, out?) | Full room-space Matrix4 for a pose (CPU proxy). |
| applyInversionWarp(material, R) | Patch a material to warp position + normals on the GPU. |
| createWarpDepthMaterial(R) | Matching depth material so shadows warp too. |
| makeInvertedMesh(geo, mat, R, opts?) | Build a warp-ready mesh (warp + frustumCulled + double-side + depth). |
| prepareInvertedMesh(mesh, R, opts?) / prepareInvertedMaterial(mat, R) | Same setup for existing meshes/materials. |
| configureInversionCamera(camera, R, physicsPos) | Set near/far for the viewer's depth. |
| recommendedRendererParams() | WebGLRenderer params (logarithmic depth buffer). |
| RoomRenderer | Holds InvertedRenderables, sync()s them each frame. |
| InvertedBody<B> / InvertedStatic | Bind a mesh to a physics-space pose. |
| PoseSource | { translation(), rotation() } — any body type drives InvertedBody. |
| buildRoom(R) | The visible room shell (wall + grid). |
| DEFAULT_ROOM_RADIUS | 50. |
Compatibility
The GPU warp injects GLSL by replacing three's built-in vertex shader chunks
(#include <project_vertex> etc.) via Material.onBeforeCompile. Those chunk
names are stable but can change between major three releases.
- Peer range:
three >= 0.160. - Tested against
[email protected]. Thetests/render-smoke.spec.tstest fails loudly if the warp shader stops compiling against the installed three, so a version bump can't silently break consumers. Runnpm testafter upgrading.
Development
npm run dev # Vite dev server for examples/vanilla (full demo)
npm run dev:minimal # Vite dev server for examples/minimal (core only)
npm run dev:webgpu # Vite dev server for examples/webgpu-lod (GPU-compute LOD)
npm run build # build the library (tsup → dist/, ESM + .d.ts)
npm run typecheck
npm test # headed Playwright tests (WebGL)
npm run test:webgpu # headed Playwright tests (WebGPU; needs a real adapter)License
MIT
