Skip to content

Large-World Rendering (RTE)

When objects in a scene are very far from the world origin (for example, at planetary scale, orbital scale, or extremely large maps, where coordinates can easily reach millions of meters), the precision of 32-bit floating-point numbers is no longer sufficient to represent these coordinates stably, leading to problems such as image jitter, z-fighting, and misaligned mesh seams. Orillusion provides a set of capabilities including RTE (Relative-To-Eye rendering) to solve large-world precision problems.

Version Note

The RTE-related settings require @orillusion/core 0.9.0 or above. The examples in this article are distilled from the engine's official sample Sample_RTE.

Principle

In ordinary rendering, a vertex's world coordinates are fed directly to the GPU. When these coordinate values are very large (such as 6378137, on the order of Earth's radius), the floating-point number has hardly any significant bits left to allocate to the "fractional/local offset", so as soon as the camera moves slightly, the object jumps on the screen.

The core idea of RTE is to transform the world into a space "with the camera as the origin" before rendering. The coordinate values near the camera thus fall back to a very small range, allowing floating-point precision to be concentrated on the local detail that actually needs it, thereby eliminating jitter when far from the origin.

It is usually used together with two other capabilities:

  • Double-precision matrices (doublePrecision): compute world matrices in double precision to further preserve transformation precision under large coordinates.
  • Logarithmic depth (useLogDepth): use a logarithmic depth buffer to allocate depth precision (more near, less far), pairing with an extremely large far clipping plane (the far-to-near ratio can reach tens of millions) to avoid z-fighting.

Enabling RTE

Just turn on the relevant toggles in the setting of Engine3D.init:

ts
import { Engine3D } from '@orillusion/core';

const engine = await Engine3D.init({
    setting: {
        useRTE: true,            // enable relative-to-eye rendering
        RTEScale: 1.0,           // RTE coordinate scaling factor, generally keep the default
        doublePrecision: true,   // enable double-precision matrices
        render: {
            useLogDepth: true,   // enable the logarithmic depth buffer
        },
    },
});
SettingTypeDefaultDescription
useRTEbooleanfalseWhether to render based on the camera position (relative-to-eye space); enable for large-world scenes
RTEScalenumber1.0The scaling factor for RTE coordinates; generally keep the default, adjust only when a unit conversion of the whole world is needed
doublePrecisionbooleanfalseWhether to use double-precision matrices to compute world transformations
render.useLogDepthbooleanfalseWhether to use the logarithmic depth buffer, paired with an extremely large far clipping plane to avoid z-fighting

Companion: Extremely Large Far Clipping Plane

A large-world camera's far clipping plane must be set large enough to cover the entire scene. For example, taking Earth's radius (about 6378137 meters) as the baseline, set the far clipping plane to several times that:

ts
import { CameraUtil, Vector3 } from '@orillusion/core';

const camera = CameraUtil.createCamera3DObject(scene);
// Near 1 meter, far = Earth's radius × 4; combined with logarithmic depth this renders stably
camera.perspective(60, engine.aspect, 1.0, 6378137 * 4);

// At planetary scale, the camera generally lookAt the target point directly
camera.lookAt(viewPoint, targetPoint, Vector3.UP);

Companion: Floating-Origin Geometry

RTE solves precision at the "rendering stage"; if the geometry's own vertex data is stored directly with absolute large coordinates, precision is already lost when building it on the CPU side. The best practice is to use a floating origin: pick a center point for each object, store the vertex data as "small offsets relative to the center", then place the object node at the world coordinates of that center.

ts
import { Object3D, MeshRenderer, GeometryBase, VertexAttributeName, Vector3 } from '@orillusion/core';

// 1) Inside the geometry: vertices store offsets "relative to the center point"
class TileGeometry extends GeometryBase {
    public centerPoint: Vector3 = new Vector3();

    constructor(/* ... */) {
        super();
        // Compute the center of this tile (absolute world coordinates, possibly a million-scale large number)
        this.centerPoint = computeCenter(/* ... */);

        const vertices = new Float32Array(vertexCount * 3);
        for (let i = 0; i < vertexCount; i++) {
            const absolute = computeVertexWorldPos(i);        // absolute large coordinates
            const relative = absolute.sub(this.centerPoint);  // subtract the center → small offset
            vertices[i * 3 + 0] = relative.x;
            vertices[i * 3 + 1] = relative.y;
            vertices[i * 3 + 2] = relative.z;
        }
        this.setAttribute(VertexAttributeName.position, vertices);
        // ... set index / normal / uv
    }
}

// 2) Node: place the object at the absolute world coordinates of the center point
const geo = new TileGeometry(/* ... */);
const obj = new Object3D();
obj.localPosition = geo.centerPoint;   // large coordinates only appear in the node position, absorbed by RTE during rendering

const mr = obj.addComponent(MeshRenderer);
mr.geometry = geo;
scene.addChild(obj);

This way, the "large values" only exist in the node's localPosition, and are uniformly transformed into camera space and absorbed by RTE during the rendering stage; what enters the vertex buffer is always the precision-friendly small offset.

Example

This example demonstrates a complete Earth-scale scene: it converts latitude/longitude coordinates into Earth ellipsoid coordinates, loads satellite imagery by tile, builds each tile's geometry with a floating origin, and provides toggles to compare in real time the image stability before and after enabling useRTE / doublePrecision.

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { Engine3D, Scene3D, View3D, CameraUtil, Object3D, MeshRenderer, UnLitMaterial, Vector3, PlaneGeometry, GeometryBase, VertexAttributeName, RADIANS_TO_DEGREES, DEGREES_TO_RADIANS, HoverCameraController, Camera3D } from "@orillusion/core";
import * as dat from 'dat.gui';

class Sample_LogDepth {
    camera!: Camera3D;
    gpsCoord = { lon: 121.4737, lat: 31.2304 };
    groundCoord!: Vector3;
    engine!: Engine3D;
    async run() {
        const doublePrecision = sessionStorage.doublePrecision !== 'false';
        const useRTE = sessionStorage.useRTE !== 'false';
        console.log('doublePrecision:', doublePrecision, ' useRTE:', useRTE);
        const engine = await Engine3D.init({
            setting: {
                render: {
                    useLogDepth: true,
                },
                doublePrecision: doublePrecision,
                useRTE: useRTE,
            },
            renderLoop: () => this.renderLoop()
        });
        this.engine = engine;
        const gui = new dat.GUI();

        let scene = new Scene3D();
        let camera = CameraUtil.createCamera3DObject(scene);
        camera.perspective(60, engine.aspect, 1.0, 6378137 * 4);
        this.camera = camera;

        this.groundCoord = GISMath.latLonToEllipsoidCoords(this.gpsCoord.lon, this.gpsCoord.lat, 0);
        const viewPoint = GISMath.latLonToEllipsoidCoords(this.gpsCoord.lon, this.gpsCoord.lat, 100);
        this.camera.lookAt(viewPoint, this.groundCoord, Vector3.UP);
        // camera.object3D.addComponent(HoverCameraController).setCamera(0, 0, 6378137 * 2.5);

        const tileZoom = 20;
        const centerTile = GISMath.lngLatToTile(this.gpsCoord.lon, this.gpsCoord.lat, tileZoom);
        for (let x = centerTile.x - 2; x <= centerTile.x + 2; x++) {
            for (let y = centerTile.y - 2; y <= centerTile.y + 2; y++) {
                scene.addChild(this.createGlobeTile(x, y, tileZoom));
            }
        }

        let view = new View3D();
        view.scene = scene;
        view.camera = camera;
        this.engine.startRenderView(view);

        // change cull mode by click dropdown box
        gui.add(engine.setting, 'doublePrecision').onChange((v: boolean) => {
            sessionStorage.doublePrecision = v
            location.reload()
        });
        gui.add(engine.setting, 'useRTE').onChange((v: boolean) => {
            sessionStorage.useRTE = v
            location.reload()
        });
        gui.open();
    }

    public createGlobeTile(tileX: number, tileY: number, level: number): Object3D {
        let mat = new UnLitMaterial();
        const url = `https://mt1.google.com/vt/lyrs=s&x=${tileX}&y=${tileY}&z=${level}`;
        // const url = `textures/grid.jpg`;
        this.engine.res.loadTexture(url).then((texture) => {
            texture.addressModeU = texture.addressModeV = 'clamp-to-edge';
            mat.baseMap = texture;
        });

        let geo = new GlobeTileGeometry(tileX, tileY, level);

        let obj = new Object3D();
        obj.localPosition = geo.centerPoint;

        let mr = obj.addComponent(MeshRenderer);
        mr.material = mat;
        mr.geometry = geo;
        return obj;
    }

    renderLoop() {
        if (this.camera) {
            const heightCoord = GISMath.latLonToEllipsoidCoords(this.gpsCoord.lon, this.gpsCoord.lat, (60 + Math.sin(Date.now() * 0.0001) * 40));
            this.camera.lookAt(heightCoord, this.groundCoord, Vector3.UP);
        }
    }
}

class GlobeTileGeometry extends GeometryBase {
    public static readonly tileResolution: number = 32;

    public tileX: number;
    public tileY: number;
    public level: number;
    public north!: number;
    public south!: number;
    public west!: number;
    public east!: number;
    public center_lon!: number;
    public center_lat!: number;
    public centerPoint: Vector3 = new Vector3();

    constructor(tileX: number, tileY: number, level: number) {
        super();
        this.tileX = tileX;
        this.tileY = tileY;
        this.level = level;
        const tileResolution = GlobeTileGeometry.tileResolution;

        const tileSize = (tileResolution + 1) * (tileResolution + 1);
        this.buildTileBounds();

        const step1 = tileResolution + 1;
        const vertexCount = tileSize;
        GISMath.latLonToEllipsoidCoords(this.center_lon, this.center_lat, 0, this.centerPoint);

        let numIndices = 0;
        const indexs = new Uint32Array(tileResolution * tileResolution * 6);
        const vertexs = new Float32Array(vertexCount * 3);
        const normals = new Float32Array(vertexCount * 3);
        const uvs = new Float32Array(vertexCount * 2);
        for (let i = 0; i < vertexCount; i++) {
            const vertex = this.getPointFromIndex(i);

            const relativePosition = vertex.sub(this.centerPoint);
            vertexs[i * 3 + 0] = relativePosition.x;
            vertexs[i * 3 + 1] = relativePosition.y;
            vertexs[i * 3 + 2] = relativePosition.z;

            relativePosition.normalize();
            normals[i * 3 + 0] = relativePosition.x;
            normals[i * 3 + 1] = relativePosition.y;
            normals[i * 3 + 2] = relativePosition.z;

            const col = i % step1;
            const row = Math.floor(i / step1);
            uvs[i * 2 + 0] = col / tileResolution;
            uvs[i * 2 + 1] = row / tileResolution;

            if (col != tileResolution && row != tileResolution) {
                indexs[numIndices++] = i + 1;
                indexs[numIndices++] = i + 0;
                indexs[numIndices++] = i + step1;

                indexs[numIndices++] = i + 1;
                indexs[numIndices++] = i + step1;
                indexs[numIndices++] = i + step1 + 1;
            }
        }

        this.setIndices(indexs);
        this.setAttribute(VertexAttributeName.position, vertexs);
        this.setAttribute(VertexAttributeName.normal, normals);
        this.setAttribute(VertexAttributeName.uv, uvs);
        this.addSubGeometry({
            indexStart: 0,
            indexCount: indexs.length,
            vertexStart: 0,
            vertexCount: 0,
            firstStart: 0,
            index: 0,
            topology: 0,
        });
    }

    protected buildTileBounds() {
        const n = Math.pow(2, this.level);

        const lon_min = (this.tileX + 0) / n * 360.0 - 180.0;
        const lon_max = (this.tileX + 1) / n * 360.0 - 180.0;

        const lat_min_rad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (this.tileY + 1) / n)));
        const lat_max_rad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (this.tileY + 0) / n)));

        const lat_min = lat_min_rad * 180.0 / Math.PI;
        const lat_max = lat_max_rad * 180.0 / Math.PI;

        this.north = lat_max;
        this.south = lat_min;
        this.west = lon_min;
        this.east = lon_max;
        this.center_lon = (lon_min + lon_max) / 2;
        this.center_lat = (lat_min + lat_max) / 2;
    }

    protected getPointFromIndex(i: number, target: Vector3 = new Vector3()): Vector3 {
        const tileResolution = GlobeTileGeometry.tileResolution;
        const step1 = tileResolution + 1;
        const col = i % step1;
        const row = Math.floor(i / step1);
        const tileTotalNum = Math.pow(2, this.level);

        const lonX = this.mapNumberToInterval(col, 0, tileResolution, this.west, this.east);

        const latY = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * (this.tileY + row / tileResolution) / tileTotalNum))) * RADIANS_TO_DEGREES;

        return GISMath.latLonToEllipsoidCoords(lonX, latY, 0, target);
    }

    protected mapNumberToInterval(v0: number, minV0: number, maxV0: number, minV1: number, maxV1: number): number {
        return (v0 - minV0) * (maxV1 - minV1) / (maxV0 - minV0) + minV1;
    }
}

class GISMath {
    public static readonly RADIUS: number = 6378137;
    public static readonly f: number = 1 / 298.257223563;
    public static readonly e2: number = 2 * GISMath.f - GISMath.f * GISMath.f;

    public static latLonToEllipsoidCoords(longitude: number, latitude: number, altitude: number = 0, target: Vector3 = new Vector3()): Vector3 {
        const phiRad = latitude * DEGREES_TO_RADIANS;
        const lambdaRad = longitude * DEGREES_TO_RADIANS;

        const N = GISMath.RADIUS / Math.sqrt(1 - GISMath.e2 * Math.sin(phiRad) * Math.sin(phiRad));

        const x = (N + altitude) * Math.cos(phiRad) * Math.cos(lambdaRad);
        const y = (N + altitude) * Math.cos(phiRad) * Math.sin(lambdaRad);
        const z = (N * (1 - GISMath.e2) + altitude) * Math.sin(phiRad);
        target.set(y, z, x);
        return target;
    }

    public static lngLatToTile(lng: number, lat: number, zoom: number): { x: number; y: number } {
        const x = (lng + 180) / 360;

        const latRad = (lat * Math.PI) / 180;
        const y = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;

        const n = Math.pow(2, zoom);

        const tileX = Math.floor(x * n);
        const tileY = Math.floor(y * n);

        return {
            x: Math.max(0, Math.min(tileX, n - 1)),
            y: Math.max(0, Math.min(tileY, n - 1))
        };
    }
}

new Sample_LogDepth().run();

Summary

  • When a large world (Earth/planetary scale) exhibits jitter or z-fighting, enable the trio of useRTE, doublePrecision, and render.useLogDepth;
  • Set the camera's far clipping plane large enough;
  • Use a floating origin for geometry: vertices store relative offsets, and the large coordinates are placed only in the node's localPosition.

Released under the MIT License