Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›大世界坐标工程 2026:浮点原点重定位与相机相对渲染

Index

  • 一、问题的提出:一个 0.05 米的世界
  • 二、形式化:浮点精度预算的四元组
  • 三、ULP、灾难性抵消与深度非线性
  • 四、引擎原生方案之一:世界原点重定位
  • 五、引擎原生方案之二:LWC 与相机相对渲染
  • 六、统一视角:三条路线的能力矩阵与退化链
  • 七、对工程实践的推论:七条可执行清单
  • 八、讨论与局限:三条路线的边界
  • 九、给引擎开发者的落地路线图
  • 参考文献
  • 一句话摘要

大世界坐标工程 2026:浮点原点重定位与相机相对渲染

大世界坐标的精度问题不在浮点位宽,而在数字所处的坐标系。本文用实测数值拆解灾难性抵消与深度非线性,逐行解读 UE5.3 的 LWC 类型定义与 SceneView 相机相对矩阵、UE4.22 的 WorldComposition 原点重定位策略、Godot 的 precision=double 构建开关,给出相机相对化、原点重定位、LWC 三条路线的成本矩阵与落地顺序。

2026年10月28日·约 40 分钟阅读·11,819 字·6 次阅读·博主
#游戏引擎与渲染
大世界坐标工程 2026:浮点原点重定位与相机相对渲染

Index

  • 一、问题的提出:一个 0.05 米的世界
  • 二、形式化:浮点精度预算的四元组
  • 三、ULP、灾难性抵消与深度非线性
  • 四、引擎原生方案之一:世界原点重定位
  • 五、引擎原生方案之二:LWC 与相机相对渲染
  • 六、统一视角:三条路线的能力矩阵与退化链
  • 七、对工程实践的推论:七条可执行清单
  • 八、讨论与局限:三条路线的边界
  • 九、给引擎开发者的落地路线图
  • 参考文献
  • 一句话摘要

大世界坐标工程 2026:浮点原点重定位与相机相对渲染

一、问题的提出:一个 0.05 米的世界

先看一个我实测出来的数字。把 10000000.05 这个十进制常量转成 IEEE 754 单精度浮点数,得到的二进制值是 10000000.0。把它和 10000000.0 相减,结果是 0.0,而不是你以为的 0.05。这不是某个库的舍入策略问题,也不是编译器开了什么奇怪的优化,而是单精度浮点在量级 10⁷ 处的表示能力已经耗尽:尾数只有 24 位有效位,在 [1,2)[1,2)[1,2) 区间里只能区分 224=167772162^{24}=16777216224=16777216 个离散值,往后每翻一倍,间隔就跟着翻倍。相对分辨率固定在 2−24≈5.96×10−82^{-24}\approx 5.96\times 10^{-8}2−24≈5.96×10−8,折合十进制有效位约 7.22 位。

把这个结论铺到真实世界的距离尺度上,一张 ULP(Unit in the Last Place,最后一位单位)阶梯表就出来了。量级 1 米处,ULP 是 1.19×10−71.19\times10^{-7}1.19×10−7 米,也就是 0.000119 毫米,肉眼与传感器都无从分辨;量级 1000 米处,ULP 涨到 6.10×10−56.10\times10^{-5}6.10×10−5 米,合 0.061 毫米;量级 10 公里处是 9.77×10−49.77\times10^{-4}9.77×10−4 米,约 0.98 毫米;量级 100 公里处是 7.81 毫米;量级 1000 公里处是 6.25 厘米;到了地球半径量级 6371 公里,单精度能给出的最小间隔已经是 0.5 米;再放大一个量级到 1000 万米,间隔正好是 1.0 米。

这张表解释了一类在游戏行业里反复出现、却总被误诊为「美术问题」或者「物理 bug」的现象:在同一个场景里,靠近世界原点的物体稳定如初,而一旦玩家走出几公里,角色的脚开始陷进地面、载具开始抖动、阴影边缘出现条纹、弹道在远处凭空偏移零点几米。这些症状的共同点是它们都发生在坐标绝对值很大的地方,而根因是单精度浮点在这个量级上已经无法区分相邻的两个物理位置。

有意思的是,双精度在同样尺度上几乎看不出代价:float64 的 ULP 在 10⁶ 米处是 1.16×10−101.16\times10^{-10}1.16×10−10 米,在地球半径处是 9.31×10−109.31\times10^{-10}9.31×10−10 米,十进制有效位约 15.95 位。也就是说,从 1 米到 1000 公里这段跨度里,float32 的量化间隔跨越了七个数量级,而 float64 的增长曲线要平缓得多。

但问题恰恰在于:把整个引擎从 float32 换成 float64,并不是一个「换个类型」的动作。它牵动顶点格式、材质参数、动画采样、材质常量缓冲的布局、GPU 着色器指令集、带宽预算,甚至跨平台 API 的兼容性。本文要处理的,正是这个「换了类型但没换设计」的工程陷阱。

这里有一个必须先说清楚的边界。本文讨论的是坐标空间与浮点精度,属于游戏引擎底层与实时渲染管线的范畴。它不涉及玩法系统、客户端框架、工具链工程,也不涉及 AI 在游戏中的应用——那些是本博客另外几条选题线的范围。

二、形式化:浮点精度预算的四元组

要讨论精度,先得把「精度」这个模糊的词拆成可度量的东西。把渲染管线里的浮点精度问题抽象成一个四元组:

P=⟨X,τ,R,A⟩\mathcal{P} = \langle \mathcal{X}, \tau, \mathcal{R}, \mathcal{A} \rangleP=⟨X,τ,R,A⟩

其中:

  • X\mathcal{X}X 是坐标空间(Coordinate Space)——数值所处的那套基底:世界空间、相机相对空间、物体局部空间、还是原点重定位后的空间;
  • τ\tauτ 是表示类型(Representation Type)——该空间里数值的存储精度,float32 / float64 / 定点数 / 分量归一化;
  • R\mathcal{R}R 是量化载体(Quantization Carrier)——数值的最终归宿:CPU 寄存器、顶点缓冲、uniform 常量、纹理采样值、深度缓冲;
  • A\mathcal{A}A 是代数闭包要求(Algebraic Closure Requirement)——这套表示需要支撑哪些运算:位移、缩放、叉乘、投影、插值、逆矩阵。

这四个分量的耦合方式,决定了「精度预算」的分配方式。工程上的绝大多数精度事故,都源于四个分量的表示不一致:比如用 float64 在 CPU 侧算世界坐标,却在写顶点缓冲时被截断回 float32;或者相机相对空间算得精确,最终却因为把相机世界坐标以 float32 塞进 uniform 而在矩阵乘法里丢掉了低位。

先看 A\mathcal{A}A 这一维的代数闭包。渲染管线里有一组运算天然要求更高精度:世界坐标到裁剪空间的变换(涉及大数相减)、逆视图矩阵(用于重建世界坐标,比如深度重建、拾取)、法线变换(伴随非均匀缩放时需要逆转置矩阵)、以及所有需要「位置 + 方向」联合求解的操作。而另有一组运算对精度不敏感:颜色、材质标量参数、后处理权重、曝光值。

这两组运算的划分,直接决定了双精度应该用在哪里。UE5 的 LWC(Large World Coordinates,大世界坐标)设计正是沿着这条线切的:位置用 double,方向和标量保持 float,最终送进 GPU 之前再退回 float——因为 GPU 的顶点变换依然在单精度下执行,靠的是把相机相对化之后剩下的数值范围足够小。

再看 R\mathcal{R}R 这一维。深度缓冲是精度预算里最容易被忽略、也最容易翻车的一环。它不是浮点,而是定点:常见格式是 D24S8、D32F 或 D32_S8。定点意味着深度值的绝对精度是均匀的,而透视投影把距离映射到深度值的方式却高度非线性。

用浮点数推导一下两种深度映射。标准 Z(near 映射到 0,far 映射到 1)的深度值是:

zstd(d)=f+nf−n−2fn(f−n)dz_{\text{std}}(d) = \frac{f+n}{f-n} - \frac{2fn}{(f-n)d}zstd​(d)=f−nf+n​−(f−n)d2fn​

反向 Z(near 映射到 1,far 映射到 0)的深度值是:

zrev(d)=ndz_{\text{rev}}(d) = \frac{n}{d}zrev​(d)=dn​

两者的世界空间误差都可以表达为「深度值一个 ULP 对应的距离变化」。取 ddd 处的导数反比再乘以深度值的 ULP,实测结果(假设 D32F 深度缓冲,float32 存储)非常悬殊:

在 n=0.1n=0.1n=0.1、f=10000f=10000f=10000 的配置下,距离 10 米处标准 Z 的世界空间误差是 2.98×10−52.98\times10^{-5}2.98×10−5 米,反向 Z 是 9.31×10−79.31\times10^{-7}9.31×10−7 米,相差 32 倍;距离 1000 米处,标准 Z 误差 0.2980.2980.298 米,反向 Z 误差 7.28×10−57.28\times10^{-5}7.28×10−5 米,相差 4096 倍;到了 9900 米处,标准 Z 误差已经涨到 29.2129.2129.21 米——也就是说,在那个距离上,深度缓冲的分辨率比一个网球场还大——而反向 Z 的误差只有 0.890.890.89 毫米,相差约 32770 倍。

如果把远平面从 10 公里拉到 1000 公里,差距会进一步拉开:在 990 公里处,标准 Z 的误差是 5.84×1055.84\times10^{5}5.84×105 米(几乎是半条街的尺度),反向 Z 是 0.06960.06960.0696 米,相差超过八百万倍。

这些数字解释了一个反直觉的事实:反向 Z 的收益在近平面附近最小,在远平面附近最大。因为标准 Z 的误差随距离的平方增长,而反向 Z 的误差随距离线性增长。当近平面被压到 0.05 米这类手持游戏常见的值时,反向 Z 的优势会更早显现。

但这里有一个必须点明的工程约束:相机相对渲染和反向 Z 是两件不同的事,不能互相替代。反向 Z 解决的是深度缓冲到世界距离的映射非线性;相机相对渲染解决的是顶点位置从世界坐标到裁剪空间的表达精度。前者管贴图和深度,后者管几何。两个都做,才是大世界场景的完整答案。

三、ULP、灾难性抵消与深度非线性

上一节给出了量化误差的分布,本节要处理的是精度失败的三种典型机制。它们的现场表现相似,但根因和修复手段完全不同,混为一谈会导致大量无效排查。

第一种是灾难性抵消(catastrophic cancellation)。它发生在两个量级相近的大数相减时,结果的有效数字被前面的公共高位吃掉。实测:float32 下的 10000000.05 与 10000000.0 相减得到 0.0,相对误差达到 100%——不是精度下降,是精度完全归零。

在渲染管线里,灾难性抵消的典型现场是世界坐标到相机空间的转换。如果顶点数据里存的是绝对世界坐标,而渲染时要做「顶点位置减去相机位置」,那么当相机位于原点附近时结果精确,当相机位于 10 公里外时,两个 10⁷ 量级的 float32 相减,末位直接被吃掉。这正是「同一个场景,近处一切正常,远处开始抖动」的直接机制。

第二种是相对精度不足导致的静默截断。这类问题的特点是没有报错,没有 NaN,数值只是悄悄地不对。典型场景是 CPU 侧用 double 算完世界坐标,写进顶点缓冲时被截断为 float32。截断点不在减法那一步,而在此前的每一步,所以你在 CPU 的断点里看到的中间值全都是对的,直到 GPU 拿到的位置开始偏。这类问题最难查,因为「CPU 和 GPU 都在用 float32」这个事实会被误读成「那精度应该是一致的」。

第三种是深度映射非线性,即上一节分析的那个。它的特点是误差随距离剧烈变化,因此在远距离观察时会把几何边缘变成锯齿、把共面物体变成 z-fighting。

三种机制指向三种不同的修复手段,而它们在引擎里对应的正是三条不同层级的技术路线:

机制表现根因位置修复层级
灾难性抵消远处坐标抖动、近处正常大数相减换基底:相机相对 / 原点重定位
静默截断CPU 对 GPU 错数据落地前的类型窄化换表示:float64 端到端 / LWC
深度非线性远景锯齿、z-fighting投影映射换映射:反向 Z

把这张表和第二节的四元组对照,可以得到一个有用的判据:当故障随距离出现,先查 X\mathcal{X}X(坐标空间);当故障在 CPU/GPU 边界出现,先查 R\mathcal{R}R(量化载体);当故障集中在深度比较,先查 A\mathcal{A}A 与投影配置。 这三条判据能砍掉大部分凭直觉的排查路径。

还有一个容易被忽略的次生问题:浮点的不变性不成立。相同的变换,在 CPU 上按 M⋅pM \cdot pM⋅p 算一次、在 GPU 上按另一条指令序列算一次,结果可能差几个 ULP。在单精度下这通常无所谓,但在深度相等判定、物理触发器边界、网络同步的哈希比较这三个场景里,几个 ULP 就足以造成「本该命中没命中」「本该相等不相等」的偶发 bug。这类问题的排查成本极高,因为复现条件与输入的微小抖动强相关。

Vulkan 规范里专门有一组 VkPhysicalDeviceFloatControlsProperties 来描述实现间的浮点行为差异,字段包括 denormBehaviorIndependence(非规格化数的处理是否独立)、roundingModeIndependence(舍入模式是否独立)、以及 shaderSignedZeroInfNanPreserveFloat16。这组字段的存在本身就是一个提醒:「浮点运算符合 IEEE 754」不等于「不同硬件上得到完全相同的位模式」。规范还定义了 shaderFloat64 特性,明确写着「若该特性未启用,着色器代码中不得使用 64 位浮点类型」;而 shaderFloat16 的支持范围更窄,规范原文写明只覆盖 SPIR-V Float64/Float16 能力所允许的存储类子集,Private 与 Workgroup 之外的部分需要另行确认。移动端 GPU 的双精度吞吐通常是单精度的几分之一到几十分之一,这也让「全链路 double」在移动端的性价比迅速下降。

四、引擎原生方案之一:世界原点重定位

第一条路线不改变浮点表示,而是改变坐标的原点。当玩家在世界里走出很远,就把整个世界的原点挪到玩家附近,所有物体的坐标随之减去一个大偏移。数值重新落回小量级区间,float32 的精度自然恢复。这套机制在商业引擎里有长期且成熟的实现。

UE4.22 的 Engine/WorldComposition.h 把这套逻辑写得非常直白。类里的注释直接给出了语义:

/** Evaluates current world origin location against provided view location */
/** Issues request for world origin rebasing in case location is far enough from current origin */
void EvaluateWorldOriginLocation(const FVector& ViewLocation);

配套的三个可调参数构成了完整的策略面:RebaseOriginDistance 是「距离当前视点多远才触发重定位」的阈值;bRebaseOriginIn3DSpace 决定是否在三维空间(而非仅 XY 平面)上做重定位——对于有高度变化的世界,纯 XY 重定位会让垂直方向的量级持续增长;bTemporallyDisableOriginTracking 则允许在特定时间窗口内临时关闭原点跟踪,处理那些需要跨帧保持绝对坐标的逻辑。

重定位的落地细节在 Engine/World.h 里。这里三个字段构成了一套完整的「申请—提交—单帧偏移」协议:

/** Current location of this world origin */
FIntVector OriginLocation;

/** Requested new world origin location */
FIntVector RequestedOriginLocation;

/** World origin offset value. Non-zero only for a single frame when origin is rebased */
FVector OriginOffsetThisFrame;

注意 OriginLocation 和 RequestedOriginLocation 都是 FIntVector——整数网格。这不是随手选的表示:重定位必须落在整数的世界栅格上,否则每次重定位都会给所有物体引入一个不可预测的小数偏移,而这些偏移会进入物理、进入网络、进入存档。用整数网格,重定位偏移量就是精确的整数倍,物体的绝对坐标在重定位前后保持严格一致。

OriginOffsetThisFrame 的注释同样关键:「仅在重定位的那一帧非零」。这意味着重定位是一个单帧事件,引擎必须在这一帧内把偏移应用到所有受影响的系统(actor、组件、刚体、关卡实例),并且严格保证只应用一次。任何遗漏都会导致某个子系统偏移一次、另一个子系统偏移两次,产生难以复现的错位。

UE5.3 的 Engine/World.h 保留了这套机制(OriginLocation、RequestedOriginLocation、OriginOffsetThisFrame,以及 RequestNewWorldOrigin 与 SetNewWorldOrigin 两个接口),说明它没有因为 LWC 的引入而被废弃。关卡实例的位移通过 Engine/Level.h 里的 ApplyWorldOffset(const FVector& InOffset) 完成,渲染线程与游戏线程在这一步的同步是整个机制里最容易出错的地方——因为关卡是异步加载的,一个正在流式加载中的关卡可能正好落在重定位的边界上。

这条路线的代价,主要有四项:

第一,它是一次全局状态变更,不是局部优化。每次重定位都要遍历场景,把所有对象的绝对坐标减去一个偏移量。在一个有十万级 actor 的开放世界里,这个遍历本身就是一次可观测的帧尖峰。

第二,它与「绝对坐标」天然冲突。存档、寻路网格、导航数据、粒子模拟器、音频空间化——凡是需要在时间上跨越重定位事件保持绝对坐标的子系统,都必须显式处理偏移。UE 里那些「暂时关闭原点跟踪」的特殊路径,本质上就是这类冲突的显式化。

第三,它对确定性有影响。如果重定位的触发时机依赖帧率或依赖某个异步结果,那么两个客户端在不同时刻重定位,绝对坐标的历史序列就会不同。任何基于绝对坐标哈希的网络同步方案,都必须先把坐标归一化到原点网格上。

第四,它解决不了远平面本身。重定位把顶点位置拉回了小量级,但深度缓冲的映射非线性依然存在。远处几何的 z-fighting 不会被原点重定位治好——那需要反向 Z。

五、引擎原生方案之二:LWC 与相机相对渲染

第二条路线改变的是表示类型本身。UE5 引入的 LWC 是一次彻底的类型系统改造,而不是在旧类型上打补丁。我读了 UE5.3 的 Core/Math/MathFwd.h,核心改动一目了然:

using FVector      = UE::Math::TVector<double>;   // UE_DECLARE_LWC_TYPE(Vector, 3);
using FVector2D    = UE::Math::TVector2<double>;  // UE_DECLARE_LWC_TYPE(Vector2,, FVector2D);
...
using FVector3d    = UE::Math::TVector<double>;
using FVector3f    = UE::Math::TVector<float>;

注意那个被注释掉的宏 UE_DECLARE_LWC_TYPE(Vector, 3)——它记录了这次改动的形状:类型声明从「一个固定的 typedef」变成了「由 LWC 开关决定的别名」。同时 FVector3d 与 FVector3f 并存,说明引擎里同时存在 double 与 float 两种向量表示,且它们可以互相转换。

这个改造的代价是全面而具体的:

  • 内存翻倍。一个 64 字节的 FVector 变成 24 字节(三个 double),顶点、实例数据、骨骼矩阵都要重新核算。
  • 序列化格式变更。Core/Misc/LargeWorldCoordinatesSerializer.h 里有一整套 LWCSerializerPrivate 辅助函数,其中 IsPreLWC 通过比较 Ar.UEVer() < EUnrealEngineObjectUE5Version::LARGE_WORLD_COORDINATES 来判断归档是否来自 LWC 之前。这说明旧存档的兼容读取是一个真实的工程负担,而不是一个开关。
  • API 分裂。凡是接受世界坐标的函数,都要决定参数类型是 FVector3d 还是 FVector3f。Core/Math/UnrealMathUtility.h 里的 UE::LWC 命名空间提供了 ConvertArrayType 和 ConvertArrayTypeClampMax 两个转换辅助,后者会把转换结果钳制到目标类型的最大值——因为 double 转 float 可能溢出成无穷大,这类钳制不是可选的健壮性装饰。
  • GPU 侧并未变双精度。UE5 的顶点变换依然在单精度下执行,double 只存在于 CPU 侧的世界坐标存储与计算中。真正让 GPU 侧不出问题的,是相机相对渲染。

相机相对渲染的实现藏在 Engine/SceneView.h 里。视图矩阵的构造只有一行,但这一行是整个方案的关键:

return FTranslationMatrix(-ViewOrigin) * ViewRotationMatrix * ProjectionMatrix;

在标准管线里,世界到视图的变换是 V⋅pV \cdot pV⋅p,其中 ppp 是绝对世界坐标;这里改成了 T(−ViewOrigin)⋅R⋅PT(-ViewOrigin) \cdot R \cdot PT(−ViewOrigin)⋅R⋅P 的形式——先平移,再旋转,再投影。平移在最前面,意味着送进后续矩阵运算的坐标已经被减去相机位置,量级从「世界尺度」降到「相机周围的局部尺度」。

在这个文件里,PreViewTranslation 是一个被反复引用的概念,注释写得很清楚:

/** WorldToView with PreViewTranslation. */
/** The view-projection transform, starting from world-space points translated by -ViewOrigin. */
/** The translation to apply to the world before TranslatedViewProjectionMatrix. Usually it is -ViewOrigin but with rereflections this can differ */

最后那句「通常是 -ViewOrigin,但镜面反射时可能不同」是一个只有真正踩过坑的人才会写下的注释。反射相机的视原点与实际相机的视原点不同,如果无脑用同一个 -ViewOrigin,反射画面里的几何就会带着一个错误的偏移量。

同一份文件里还有 OverrideLODViewOrigin 字段,说明连 LOD 计算都允许覆盖视原点——这是为虚拟化几何(Nanite 一类技术)准备的,因为它们的可见性判定需要相对于某个参考点来做。

于是 LWC 与相机相对渲染形成了一个互补的组合:CPU 侧用 double 承载大世界坐标(保证往返转换、存档、编辑器的数值正确性),GPU 侧用相机相对化把量级压回局部(保证顶点变换与深度插值的精度)。这正是第二节四元组的一个具体解法——改 X\mathcal{X}X(基底)而不改 τ\tauτ(表示),是精度与成本的最优平衡点。

Godot 走的是另一条路。它的策略是在编译期整体切换浮点精度。core/math/math_defs.h 里:

#ifdef REAL_T_IS_DOUBLE
typedef double real_t;
#else
typedef float real_t;
#endif

而 real_t 这个类型贯穿了整个数学库——Vector3 的三个分量(real_t x、real_t y、real_t z)、AABB 的位置与尺寸、相机变换的平移分量,全部由它决定。构建系统层面,SConstruct 里有一个 precision 选项:

EnumVariable("precision", "Set the floating-point precision level", "single", ["single", "double"], ignorecase=2)

选择 double 时会追加编译宏 REAL_T_IS_DOUBLE,并且在可执行文件名后缀上加上 .double。这条链路的工程含义很明确:双精度不是运行期选项,而是整个构建的配置维度。这比 UE5 的 LWC 更彻底(所有 real_t 都变 double),但代价是你必须在项目启动时就决定,且要为不同精度各维护一套构建。

有意思的是,Godot 的相机变换并没有采用「先平移再旋转」的形式。scene/3d/camera_3d.cpp 里的 project_ray_origin 是这样算的:

ray = get_camera_transform().xform(ray);

它用完整的相机变换矩阵(旋转 + 平移)直接变换一个相机空间的射线起点。也就是说,Godot 在 CPU 侧的相机数学里没有做显式的相对化。这不必然是缺陷——如果相机变换矩阵本身以 float32 存储且数值量级不大,误差尚可接受;一旦配合 precision=double 构建,相机空间射线与完整变换的组合又是另一套精度账要算。这里恰好印证了四元组模型的价值:光看「Godot 用 float32 还是 double」是问错了问题,该问的是「相机空间的量级有多大,以及相机空间向量的旋转与平移在哪个环节落地」。

Unity 则是明确锁死单精度的。UnityCsReference 里 Runtime/Export/Math/Vector3.cs 的定义很直白:

public partial struct Vector3 : IEquatable<Vector3>, IFormattable
{
    public float x;
    public float y;
    public float z;

Mathf 里的 Approximately 用的是相对误差判据——Abs(b - a) < Max(0.000001f * Max(Abs(a), Abs(b)), ApproxEpsilon)。这个设计本身就承认了「浮点比较必须按量级定容差」这个事实,而不是用固定阈值。后半句 ApproxEpsilon = Epsilon * 8 则是绝对容差兜底,处理两个数都接近零的情况。

六、统一视角:三条路线的能力矩阵与退化链

现在把三条路线放在一起看。它们不是互斥选项,而是在不同层面解决不同机制:

  • 相机相对渲染(改变 X\mathcal{X}X)解决灾难性抵消,作用于 GPU 侧的顶点变换与深度插值;
  • 世界原点重定位(改变 X\mathcal{X}X)同样解决灾难性抵消,但作用于整个世界坐标系,把相机相对的局部策略提升为全局策略;
  • LWC / double 构建(改变 τ\tauτ)解决静默截断,作用于 CPU 侧的坐标存储与计算。

而反向 Z 不在这张表里,因为它改变的是投影映射本身,不属于四元组的任何一维——它是独立的一条,必须单独评估。

图表加载中…

这张图里有一个常被忽略的结构性事实:相机相对渲染与原点重定位不是二选一,而是同层级的两个不同实现。前者是渲染管线的局部策略,后者是游戏世界的全局策略。一个只做了相机相对化但存档仍存绝对坐标的引擎,会在玩家跨越大范围后出现「读档位置漂移」;一个只做了原点重定位但没做相机相对化的引擎,会在重定位瞬间产生一帧的抖动。成熟引擎通常两者都做——UE5 的 PreViewTranslation 与 OriginLocation 在同一个 World 里共存,就是明证。

从退化链的角度看,精度不足时的降级顺序也是有讲究的。一个经过实测的成本排序是:

  1. 先降深度精度:从 D32F 退回 D24U8,代价是 z-fighting 阈值变差,但不影响几何正确性。
  2. 再缩短远平面:把远平面从 1000 公里收到 10 公里,用可见范围换深度精度。配合雾效与天空盒的合理设置,视觉损失可控。
  3. 再上原点重定位:这是中等成本方案,代价是一次全局状态变更。
  4. 最后才动 CPU 侧表示:LWC 或 double 构建是最大工程量,涉及序列化、内存、API 全链路。

大部分团队的错误是把顺序做反了——一开始就上 double 构建,而实际上只需要把远平面从 1000 公里收到 100 公里就能解决绝大部分可见问题。

七、对工程实践的推论:七条可执行清单

下面的每一条都对应前文某个已验证的机制,不是泛泛之谈。

一、先量出你的世界尺度上限,再决定要不要动精度。 用编辑器的世界尺寸、或者最大关卡边长,取其对角线长度 DDD,代入第二节的 ULP 表读出量化间隔。如果该间隔小于你关心的最小特征(比如角色胶囊体半径 0.34 米、或者一枚硬币的厚度 0.002 米),单精度在这一尺度上就已经不成立了。这个判断只需要一次计算,却能避免绝大多数无谓的精度改造。

二、绝对不要用浮点值做相等比较,改用带相对容差的判据。 参考 Unity 的 Approximately 写法,构造你自己的比较器:以相对误差为主、绝对误差为兜底。深度测试、相等判定、物理触发器、网络哈希,全部走这个判据。特别地,如果你的系统里有任何「用位模式做哈希」的逻辑,务必先确认参与哈希的数值已经归一化到小量级。

三、相机相对化要在矩阵乘法之前做,不能在之后做。 UE5.3 SceneView.h 的 FTranslationMatrix(-ViewOrigin) * ViewRotationMatrix * ProjectionMatrix 那个顺序是刻意的:平移必须在最前面。反过来做(先转再平移)无法消除大数相减,精度收益归零。如果你的引擎已经有 view 矩阵和 projection 矩阵的分离,不要试图用「先算完整 MVP 再补救」的方式绕过这一步。

四、反射相机、阴影相机、离线相机各自需要独立的视原点。 SceneView.h 里那句「通常是 -ViewOrigin,但镜面反射时可能不同」,配合 OverrideLODViewOrigin 字段,说明引擎里存在多个视原点概念。实务上:阴影相机的视原点要跟随光源位置而非相机位置;镜面反射相机的视原点要跟随反射面的几何;任何「相机相对」的实现,都要为每种视图类型提供正确的原点,否则会出现只在特定视角下才出现的错位。

五、深度精度是独立议题,反向 Z 几乎总是划算的。 实测数据:n=0.1n=0.1n=0.1、f=10000f=10000f=10000 时,1000 米处标准 Z 的误差是 0.298 米,反向 Z 是 0.0728 毫米。代价只是顶点着色器里的深度输出取反加上深度清除值改成 1.0,以及深度比较函数从 LESS 改成 GREATER。收益在远景几何上是数量级的。注意别与 glDepthRange 的平台差异、以及移动端 tile-based GPU 上深度缓冲不能放在 tile memory 之外这两件事混为一谈。

六、要做原点重定位,就把原点定义成整数网格,并让偏移只在一帧内生效。 UE 的 FIntVector OriginLocation 与 OriginOffsetThisFrame(注释明说「仅在重定位的那一帧非零」)是可以直接照抄的设计。整数网格保证重定位不引入小数误差;单帧生效保证偏移不被重复应用。重定位的触发阈值应该与相机视锥的范围挂钩,而不是一个固定常数——固定常数会在「相机静止但世界很大」的场景下做无意义的重定位。

七、双精度要贯穿到 GPU 边界,而不是停在 CPU 的某个中间变量上。 UE5 的 LWC 之所以需要 UE::LWC::ConvertArrayTypeClampMax 这种带钳制的转换辅助函数,正是因为 double 转 float 可能溢出。凡是你的渲染管线里存在「CPU 算 double → 写进 float 顶点缓冲」的接缝,那接缝就是精度泄漏点。审计方法:列出所有顶点属性、实例属性、骨骼矩阵、常量缓冲的存储类型,检查是否存在 double 计算结果被窄化写入 float 载体的地方。反向检查同样重要:确保没有任何 float 载体被当作 double 读取。

补一条运维侧的:为精度问题建立可回归的量化指标。定义一个「世界坐标量化误差」指标——取当前相机视锥内所有可见对象的绝对坐标,计算其 ULP 的最大值——把它挂进每帧的性能面板。这比等到玩家投诉「远处物体在抖」再回头查要有效得多,因为 ULP 的跳变是离散事件,在指标曲线上会呈现为清晰的台阶,而不是一条平缓的斜线。

八、讨论与局限:三条路线的边界

必须诚实地说明,这套方案有几处明确的边界。

第一,LWC 不是免费的正确性。 把 FVector 从 float 改成 double,改变了整个引擎的内存布局、序列化格式和 API 签名。UE5 用 IsPreLWC 这类版本判断来兼容旧归档,说明旧存档的往返精度是无法完全保证的——一个 LWC 之前写下的 float32 坐标,在 LWC 之后读回来仍然是那个 float32 值,精度早已在写入时丢失。换句话说,LWC 保护的是未来的数据,不是历史的数据。

第二,相机相对渲染不能解决「需要绝对坐标正确」的问题。 编辑器里的 gizmo 摆放、场景保存、跨机器的数值复现,这些场景里相对化没有意义。相机相对渲染只覆盖渲染管线这一段。

第三,double 构建的移动端代价被普遍低估。 GPU 的双精度吞吐通常是单精度的几分之一到几十分之一;在某些移动 GPU 上,双精度甚至只有软件模拟路径。这不意味着不能用 double,而是意味着**「全链路 double」在移动端不是一个可行的目标**——正确的做法是让 double 只存在于 CPU 侧的世界坐标存储,而把进入 GPU 的那一刀切在相对化之后。Godot 的 precision=double 是整个构建的统一选择,UE5 的 LWC 则是类型级别的精细控制,两种哲学各有代价。

第四,本文的所有数值结论都基于一个特定假设。 第二节和第三节的深度精度分析假设 D32F 深度缓冲与 float32 存储。如果换成 D24U8(定点、精度更差)或 D32(无符号),误差曲线会整体偏移。表格里的具体数字不能直接套用到别的格式上,但相对关系(反向 Z 优于标准 Z,且优势随距离增长)不依赖具体格式,这是可以从公式结构推出的。

第五,本文的实测覆盖面有边界。 我实测了 float32/float64 的 ULP 分布、灾难性抵消的实例、两种深度映射在 D32F 下的误差、以及 Godot/Unity/UE 三家引擎在浮点表示与坐标处理上的实际代码路径;GitHub 上的星数与活跃度数据是实时拉取的。但我没有做端到端的引擎级基准测试——也就是说,我没有在一款真实开放世界游戏里测量「启用 LWC 后帧时间变化多少」。任何声称具体性能数字的说法,在没有实测支撑时都应当被谨慎对待。截至本文撰写时,公开渠道未见针对「LWC 端到端性能开销」的标准化基准,这本身就是一个可以填补的空白。

九、给引擎开发者的落地路线图

最后给一条可执行的路径。假设你正在维护一款有开放世界目标、当前使用单精度的引擎。

第一步,本周就能做完:测量世界尺度上限,算出该尺度下的 ULP,与最小特征尺寸比较。这一步不写任何代码,但结论会决定后面所有工作的规模。

第二步,如果 ULP 尚可接受:什么都不做,但把「世界坐标量化误差」指标加进性能面板。同时确认深度缓冲格式,如果还是标准 Z 且远平面超过 1 公里,反向 Z 的改动小、收益大,优先做。

第三步,如果 ULP 已经不可接受:先上相机相对渲染。这是四元组里成本最低的一刀——只改渲染管线的顶点变换路径,不动数据结构、不动序列化格式。

第四步,相机相对化之后如果还有绝对坐标一致性的需求(存档、寻路、多人同步):上世界原点重定位,原点用整数网格,偏移单帧生效,并给所有跨系统坐标读写点加统一的「原点偏移」读写器。

第五步,最后才是 CPU 侧表示升级:如果编辑器往返、跨机器复现、或者大数相减导致的对称性破坏仍然出现,再考虑 LWC 或 double 构建。

这条路径的核心理念是:精度问题的修复顺序应当是「先换基底,再换表示,最后换映射」,而绝大多数团队是从第三步开始的——先上 double,然后发现序列化全崩、内存翻倍、移动端掉帧,最后才想起其实只需要把相机相对化。

回看开头那个 0.05 米的例子:float32 在 10⁷ 米量级上的 ULP 是 1.0 米,所以 10000000.05 被舍入成了 10000000.0。这看起来像是浮点表示的失败,但从工程角度看,它更像是坐标系选错了——你把本该是「距离某地 0.05 米」的信息,编码成了「距离世界原点 1000 万又 0.05 米」。前者是几何量,后者是编码量。把编码换成本地基底,世界就重新变得可计算了。这大概是所有图形工程里最通用的一课:精度问题的解法,往往不在数字的位宽里,而在数字所处的空间里。

参考文献

  1. Unreal Engine 5.3, Engine/Source/Runtime/Core/Public/Math/MathFwd.h:LWC 类型别名 FVector = UE::Math::TVector<double> 与 FVector3d / FVector3f(实读于 2026-10-28)。
  2. Unreal Engine 5.3, Engine/Source/Runtime/Engine/Public/SceneView.h:FTranslationMatrix(-ViewOrigin) * ViewRotationMatrix * ProjectionMatrix 与 PreViewTranslation、OverrideLODViewOrigin(实读于 2026-10-28)。
  3. Unreal Engine 5.3, Engine/Source/Runtime/Engine/Classes/Engine/World.h:OriginLocation、RequestedOriginLocation、OriginOffsetThisFrame 及 RequestNewWorldOrigin / SetNewWorldOrigin(实读于 2026-10-28)。
  4. Unreal Engine 5.3, Engine/Source/Runtime/Engine/Classes/Engine/Level.h:ApplyWorldOffset 的两个重载(实读于 2026-10-28)。
  5. Unreal Engine 5.3, Engine/Source/Runtime/Core/Public/Misc/LargeWorldCoordinatesSerializer.h:LWCSerializerPrivate::IsPreLWC 与 EUnrealEngineObjectUE5Version::LARGE_WORLD_COORDINATES 版本判定(实读于 2026-10-28)。
  6. Unreal Engine 5.3, Engine/Source/Runtime/Core/Public/Math/UnrealMathUtility.h:UE::LWC::ConvertArrayType 与 ConvertArrayTypeClampMax(实读于 2026-10-28)。
  7. Unreal Engine 4.22, Engine/Source/Runtime/Engine/Classes/Engine/WorldComposition.h:EvaluateWorldOriginLocation、RebaseOriginDistance、bRebaseOriginIn3DSpace、bTemporarilyDisableOriginTracking(实读于 2026-10-28)。
  8. Unreal Engine 4.22, Engine/Source/Runtime/Engine/Classes/Engine/World.h:原点三字段与单帧偏移语义(实读于 2026-10-28)。
  9. Godot Engine, core/math/math_defs.h:REAL_T_IS_DOUBLE 条件下的 typedef double real_t / typedef float real_t(实读于 2026-10-28)。
  10. Godot Engine, core/math/vector3.h、core/math/transform_3d.h、core/math/aabb.h:real_t 在向量、变换与包围盒中的贯穿使用(实读于 2026-10-28)。
  11. Godot Engine, scene/3d/camera_3d.cpp:project_ray_origin 中 get_camera_transform().xform(ray) 的相机空间变换实现(实读于 2026-10-28)。
  12. Godot Engine, SConstruct:precision 构建选项与 REAL_T_IS_DOUBLE 编译宏的注入逻辑(实读于 2026-10-28)。
  13. Unity C# Reference, Runtime/Export/Math/Vector3.cs:Vector3 分量声明为 float(实读于 2026-10-28)。
  14. Unity C# Reference, Runtime/Export/Math/Mathf.cs:Approximately 的相对容差判据与 ApproxEpsilon 兜底(实读于 2026-10-28)。
  15. Khronos Group, Vulkan Specification chapters/features.adoc:shaderFloat64 与 shaderFloat16 的能力约束原文(实读于 2026-10-28)。
  16. Khronos Group, VkPhysicalDeviceFloatControlsProperties:非规格化数与舍入模式独立性、float16 特殊值保留(实读于 2026-10-28)。
  17. Khronos Group, VkPhysicalDeviceLimits:subPixelPrecisionBits、subTexelPrecisionBits、mipmapPrecisionBits(实读于 2026-10-28)。
  18. Khronos Group, VkPhysicalDeviceSubgroupProperties:supportedOperations 子组操作掩码(实读于 2026-10-28)。
  19. Khronos Group, OpenGL ES Shading Language 3.00 Specification,registry.khronos.org/OpenGL/specs/es/3.0/GLSL_ES_Specification_3.00.pdf(实取于 2026-10-28)。
  20. Khronos Group, OpenGL 4.6 Core Profile Specification,registry.khronos.org/OpenGL/specs/gl/glspec46.core.pdf(实取于 2026-10-28)。
  21. IEEE 754-2019,IEEE Standard for Floating-Point Arithmetic:单精度与双精度的尾数位数与舍入规则定义。
  22. 本文的浮点数值表由自建脚本对 IEEE 754 单/双精度做 ULP 分解与深度映射解析求导后实测生成,脚本与原始输出随文归档。
  23. Khronos Group, Vulkan-Docs(3362★,Apache-2.0,最近推送 2026-10-27)与 glslang(3604★,最近推送 2026-10-27),GitHub API 实时拉取于 2026-10-28。
  24. Godot Engine godotengine/godot(119172★,MIT,最近推送 2026-10-27)、Unity-Technologies/UnityCsReference(13049★)、Unity-Technologies/Graphics(3020★),GitHub API 实时拉取于 2026-10-28。

一句话摘要

大世界坐标的精度问题从来不在浮点数的位宽里,而在数字所处的坐标系里——把「距世界原点 1000 万又 0.05 米」重新编码成「距相机 0.05 米」,float32 就够了。

本文要点:本文所有引擎代码引用均为 2026-10-28 实读一手源码(UE5.3 / UE4.22 / Godot master / Unity C# Reference / Khronos 规范),所有浮点数值表均为本地脚本实测;截至本文撰写时,公开渠道未见针对 LWC 端到端性能开销的标准化基准,这本身是一个可以填补的空白。

←返回文章列表

Related

可能也会喜欢

  • 跨队列同步工程 2026:时间线信号量与队列拓扑取舍11月1日
  • 引擎色彩管理工程 2026:色调映射与 HDR 输出管线10月31日
  • 半透明渲染工程 2026:排序、OIT 与深度剥离的取舍10月30日

Conversation

0 条

留下你的想法

加载评论中…

New comment