Skip to content

大世界渲染(RTE)

当场景中的物体离世界原点非常远(例如地球尺度、星球轨道、超大地图,坐标动辄几百万米)时,32 位浮点数的精度已不足以稳定表示这些坐标,会出现画面抖动(jitter)深度冲突(z-fighting)模型接缝错位等问题。Orillusion 提供了 RTE(Relative-To-Eye,相对相机渲染) 等一组能力来解决大世界的精度问题。

版本说明

RTE 相关设置需要 @orillusion/core 0.9.0 及以上版本。本文示例提炼自引擎官方示例 Sample_RTE

原理

普通渲染中,顶点的世界坐标直接送入 GPU。当这些坐标的数值很大(如 6378137,地球半径量级)时,浮点数能分配给"小数/局部偏移"的有效位所剩无几,相机稍一移动,物体在屏幕上就会跳动。

RTE 的核心思想是:把世界变换到"以相机为原点"的空间再渲染。相机附近的坐标数值因此回落到很小的范围,浮点精度得以集中在真正需要的局部细节上,从而消除远离原点时的抖动。

它通常与另外两项能力配合使用:

  • 双精度矩阵(doublePrecision:用双精度计算世界矩阵,进一步保住大坐标下的变换精度。
  • 对数深度(useLogDepth:用对数深度缓冲分配近大远小的深度精度,配合极大的远裁剪面(远近比可达上千万)避免 z-fighting。

开启 RTE

Engine3D.initsetting 中开启相关开关即可:

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

const engine = await Engine3D.init({
    setting: {
        useRTE: true,            // 开启相对相机渲染
        RTEScale: 1.0,           // RTE 坐标缩放系数,一般保持默认
        doublePrecision: true,   // 开启双精度矩阵
        render: {
            useLogDepth: true,   // 开启对数深度缓冲
        },
    },
});
设置项类型默认值说明
useRTEbooleanfalse是否基于相机位置渲染(相对相机空间),大世界场景开启
RTEScalenumber1.0RTE 坐标的缩放系数,一般保持默认,仅在需要对整体世界做单位换算时调整
doublePrecisionbooleanfalse是否使用双精度矩阵计算世界变换
render.useLogDepthbooleanfalse是否使用对数深度缓冲,配合超大远裁剪面避免 z-fighting

配套:超大远裁剪面

大世界相机的远裁剪面要设得足够大才能覆盖整个场景。例如以地球半径(约 6378137 米)为基准,把远裁剪面设为其数倍:

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

const camera = CameraUtil.createCamera3DObject(scene);
// 近 1 米、远 = 地球半径 × 4,配合对数深度即可稳定渲染
camera.perspective(60, engine.aspect, 1.0, 6378137 * 4);

// 在地球尺度下,相机一般直接 lookAt 目标点
camera.lookAt(viewPoint, targetPoint, Vector3.UP);

配套:浮动原点几何体

RTE 解决的是"渲染阶段"的精度;几何体本身的顶点数据如果直接用绝对大坐标存储,在 CPU 侧构建时就已经损失了精度。最佳实践是采用浮动原点(floating origin):为每块物体选一个中心点,顶点数据存"相对中心的小偏移",再把物体节点摆放到该中心的世界坐标上。

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

// 1) 几何体内部:顶点存“相对中心点”的偏移
class TileGeometry extends GeometryBase {
    public centerPoint: Vector3 = new Vector3();

    constructor(/* ... */) {
        super();
        // 计算该块的中心(绝对世界坐标,可能是百万级大数)
        this.centerPoint = computeCenter(/* ... */);

        const vertices = new Float32Array(vertexCount * 3);
        for (let i = 0; i < vertexCount; i++) {
            const absolute = computeVertexWorldPos(i);        // 绝对大坐标
            const relative = absolute.sub(this.centerPoint);  // 减去中心 → 小偏移
            vertices[i * 3 + 0] = relative.x;
            vertices[i * 3 + 1] = relative.y;
            vertices[i * 3 + 2] = relative.z;
        }
        this.setAttribute(VertexAttributeName.position, vertices);
        // ... 设置 index / normal / uv
    }
}

// 2) 节点:把物体摆到中心点的绝对世界坐标上
const geo = new TileGeometry(/* ... */);
const obj = new Object3D();
obj.localPosition = geo.centerPoint;   // 大坐标只出现在节点位置,由 RTE 在渲染时消化

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

这样,"大数值"只存在于节点的 localPosition,由 RTE 在渲染阶段统一变换到相机空间消化掉;而进入顶点缓冲的始终是精度友好的小偏移。

示例

这个示例演示了一个完整的地球尺度场景:把经纬度坐标换算成地球椭球坐标、按瓦片(tile)加载卫星影像、用浮动原点构建每块瓦片几何,并提供开关实时对比 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();

小结

  • 大世界(地球/星球尺度)出现抖动、z-fighting 时,开启 useRTEdoublePrecisionrender.useLogDepth 三件套;
  • 相机远裁剪面要设得足够大;
  • 几何体采用浮动原点:顶点存相对偏移,大坐标只放在节点 localPosition 上。