ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

火星地形渲染实战:四叉树LOD与瓦片调度全解析

火星地形渲染实战:四叉树LOD与瓦片调度全解析

最近把火星全球地形渲染这个练手项目重新收拾了一遍,从四叉树细分到LOD切换,从球面投影到瓦片调度,前前后后踩了不少坑。之所以选火星当试验场,是因为它有一套完整的全球高程数据,不像地球那样被各种投影分带折腾得头疼,拿来做地形引擎的“小白鼠”再合适不过。这篇笔记把整个方案拆开讲:四叉树怎么设计、LOD怎么卡阈值、火星全球地形数据怎么做成金字塔、渲染时又该怎么处理裂缝和精度问题。内容比较偏向实际操作,适合正在做地形渲染、三维地球、或者想入门大规模场景组织的朋友参考。

1. 先想清楚方案:四叉树、LOD、火星数据三者怎么配合

1.1 为什么是四叉树而不是八叉树

很多人一上来就问:三维场景不是应该用八叉树吗?这里要分清概念。火星全球地形本质上是一个“球面二维曲面”,每个节点代表地表上的一块区域,本质上仍然是二维平面上的空间划分。四叉树每次把一个区域等分成四块,天然匹配经纬度网格或纹理瓦片的组织方式,递归清晰、索引简单,GPU 渲染时也能高效地按节点粒度批次合批。

八叉树当然也能用,但它的优势体现在真三维数据上,比如点云、体素、空间遮挡剔除。对地形来说额外增加一个高度维度进去,并不会带来查找效率的质变,反而会让瓦片遍历、显存管理、父子节点同步复杂好几倍。项目里没必要给自己加戏,二维问题就用二维结构,这是我一直坚持的原则。

1.2 LOD 到底在优化什么

LOD 全称 Level of Detail,核心就一句话:远处少画,近处多画。对地形渲染来说,G 缓冲区带宽、顶点处理量和纹理采样带宽是三大瓶颈。假如整个火星表面都以最高精度铺网格,每个像素的三角形规模会直接让 GPU 崩溃;而且火星直径约 6800 公里,即使只渲染视野内一小块区域,顶点数量也极其恐怖。

LOD 的优化本质是“用距离换精度”,在渲染质量和渲染代价之间找一个动态平衡点。我建议你把它理解成“按需分配”,把精细网格资源集中在相机附近,远处用粗糙网格撑住轮廓,过渡处再把相邻层级的差异抹平。只要这个逻辑跑通了,全球尺度漫游的性能问题就解决了一大半。

1.3 火星数据特征对方案选型的影响

火星全球高程数据通常是一个覆盖整个星球的经纬度网格,分辨率大概在几百米量级。这个分辨率相比地球商业地形数据要低不少,好处是数据量可控,坏处是贴地飞行时细节不足,必须配合颜色影像或程序化细节纹理来补充视觉信息。

因为数据本身是等距经纬网格,我直接采用了等距圆柱投影的方式切瓦片。把整个火星映射成一个长宽固定的大图,再按金字塔层级切块,每个四叉树节点对应一块瓦片。这么做的最大好处是和数据格式天然对齐,切片、采样、纹理坐标换算都不带额外步骤。如果做的是游戏场景或者追求极点附近更好的视觉表现,可以换成六面体投影,但这里我为了省事,选择了最直接的路线。

2. 数据预处理:把火星全球地形变成可以喂给四叉树的瓦片

2.1 数据源与精度认知

做火星项目前,我先把全球高程数据拿全,格式是标准的经纬度网格。火星全球地形一般以 CSV 或 GeoTIFF 形式发布,覆盖范围是经度 -180 到 180、纬度 -90 到 90,每个采样点记录海拔高度。

这里有个关键认知:全球低分辨率数据只能保证“从轨道上看得像火星”,一旦把相机压到距离地面几十千米,会发现地形细节仍不够丰富。实际项目中我从两个方向补充,一是引入更高分辨率的局部区域数据做混合,二是对高度场做平滑插值,并且用噪声扰动微地形,让近地面镜头不至于一片光秃。数据源的最终形态我整理成了一张大高度图,后续所有瓦片都从这张图上重采样。

2.2 全球金字塔切片的参数逻辑

金字塔是四叉树的基础。我定的参数如下:

  • 第 0 层:全球 1 张瓦片,覆盖 360 度经度、180 度纬度。
  • 第 1 层:2 x 2 张瓦片,每块覆盖 180 度经度、90 度纬度。
  • 第 n 层:2^n x 2^n 张瓦片,每块覆盖 360/2^n 度经度、180/2^n 度纬度。

每张瓦片内部固定为 256 x 256 个采样点。这样一个像素对应的实际尺度可以估算:赤道上 1 度经度约为 59 公里,第 8 层每块覆盖约 1.4 度经度,256 个像素去采样,单像素大约 0.005 度,也就是三百米左右,已经接近激光测高数据的原始分辨率。继续细分下去,数据都是插值出来的,视觉提升有限,但渲染压力会指数上升,所以最大层级我设到了 14,再深基本没有意义。

切片时我用的是通用栅格处理工具(GDAL 这类)把全球高度图裁剪成各级瓦片,输出为 16 位灰度 PNG。选择 16 位是因为火星海拔范围很大,从低于基准面数千米到最高点两万多米都有,8 位会完全丢失细节。每张瓦片在保存时保留一定的边界重叠像素,可以有效避免后续采样时相邻瓦片出现沟槽。

2.3 高度图转成高度场的注意点

拿到瓦片后,GPU 侧不能直接采样 PNG,需要把高度范围归一化到 0 到 1,精度尽量保留。我在 CPU 侧建了一张查找表,把实际海拔米数映射到 0 到 1 区间,传入着色器的 Uniform,运行时再用反向映射还原海拔。

实际项目中遇到一个坑:直接用 PNG 像素值除以 65535 得到的是相对高度,但不同瓦片之间的基准面必须统一。火星全球地形大家通常以某个参考椭球面为海拔基准,我处理时统一减掉基准面半径再归一化,避免了相邻瓦片在接缝处高度不一致的问题。同时每个瓦片在加载时做一遍 min/max 统计,存进节点对象,LOD 切换时能用这个值做更准确的包围球和误差预判。

3. 四叉树节点与 LOD 判定:这是整套系统的心脏

3.1 节点对象里到底该存什么

四叉树节点不是一个简单的“格子”,它必须承载渲染和调度所需的所有信息。我项目里的节点结构大概是这样的:

struct QuadNode { int depth; int tileX, tileY; int status; // Unloaded / Loading / Ready / Eliminated float minHeight, maxHeight; double centerLat, centerLon; BoundingSphere sphere; QuadNode* children[4]; Mesh* mesh; Texture* heightTex; Texture* colorTex; float lastAccessTime; };

depth、tileX、tileY 三件套确定了节点对应哪一层哪块瓦片,status 是状态机的基础,minHeight / maxHeight 用于误差裁剪和包围球计算。sphere 用于视锥剔除应该不用多说,但要注意它的半径必须把最大海拔也算进去,否则相机贴近高山时节点还没进入视野就被剔除了,偏远山体边缘会出现“突然出现一堵墙”的问题。

children 指针的维护是整个四叉树实现最容易乱的地方。我的经验是:创建子节点时必须保证父节点状态为 Ready,否则子节点加载完却没有网格可用,逻辑会乱。统一的状态流是父节点 Ready 后才能继续拆分,拆分和合并都只针对叶子节点操作。

3.2 屏幕误差模型与细分阈值

LOD 判定不是简单看距离,而是看“这个节点在屏幕上占多少像素”。如果节点在屏幕上面积已经很小,又何必继续细分?我用的屏幕误差估算公式是:

screenSize = (worldSize / distance) * (viewportHeight / (2.0f * tan(fov / 2.0f)));

worldSize 可以是节点覆盖的球面大圆弧长,distance 是相机到节点包围球中心的距离。算出 screenSize 之后,如果大于某个阈值,就继续细分;否则保留当前节点。

阈值的选择直接影响画质和性能。我把视图高度调成 1080p、FOV 设为 60 度后,阈值取 8 到 12 像素表现最好。小于 8 像素时,远处山体细分过于积极,每帧请求大量瓦片,卡顿明显;大于 12 像素时,远处细节丢失太严重,山脊边缘会出现明显的多边形感。

这个模型有一个简化假设,就是相机正对节点。斜视情况下,远处地形在屏幕上会被压缩,误差会比估算值小,但实际跑起来问题不大,再加上一节要说的迟滞逻辑,视觉上不会出现大面积抖动。

3.3 请求调度与内存回收:不让加载拖垮渲染

瓦片加载是异步的,网络、磁盘 IO 都可能在几毫秒到几十毫秒之间波动。直接无脑把所有满足 LOD 条件的节点全部入队,会让纹理上传和网格重建在某一帧集中爆发,卡到没法看。我引入了优先级队列:

  • 相机视野内的节点优先级最高,越靠近视野中心、屏幕误差越大,排得越前。
  • 每帧最多处理 4 到 8 个请求,加载太少会跑到哪哪儿秃,加载太多会吃掉主线程时间。
  • 已经加载完成但暂时不在视野内的节点,标记为 Cache,不立刻释放,留给相邻区域快速使用。

内存回收我用了一种类似 LRU 的策略。每帧记录节点访问时间,当总内存超过上限(比如 512 MB)时,从最久未访问的叶子节点开始回收。这种做法对原地旋转、快速平移这类操作很友好:刚去过的地方缓存在内存里,再移回去时瞬间就绪。

我特别强调一点:不要每帧把所有节点重新遍历一遍并重建网格索引。节点的细分状态没变时,索引缓存必须复用,只更新高度数据,这样才能把每帧 CPU 开销控制住。这一点做到位,帧率就能稳定不少。

4. 渲染流程与核心实现:从坐标换算到裂缝处理

4.1 球面坐标换算与顶点生成

把经纬度瓦片放到球面上,标准的换算是:

lat = (tileY + v / size) * latSpan - 90; lon = (tileX + u / size) * lonSpan - 180; x = (R + h) * cos(lat) * cos(lon); y = (R + h) * sin(lat); z = (R + h) * cos(lat) * sin(lon);

R 是火星参考半径,我取约 3390 公里,h 是该点海拔。从瓦片高度图采样出 h 之后,用 double 计算 x、y、z,存成局部坐标。这里必须提醒:火星全球坐标的数值范围很大,用 float 直接存会出现肉眼可见的抖动,尤其相机贴近地面时,地形像被风吹的纸片一样飘。我最后是 CPU 侧用 double 存世界坐标,传 GPU 之前减去相机位置,把坐标转换到相机附近的局部坐标系再转 float,完美解决精度问题。

等距圆柱投影在极区会有网格收敛问题,纬度接近 90 度时同一经度跨度对应的空间距离越来越小。好在我这个项目主要在空中视角巡游,极少贴着极点飞行,所以没有进一步处理。如果你要做极地穿越场景,建议换六面体投影方案,把球面分成六个面片,每个面单独跑四叉树,极区畸变会小得多。

4.2 索引生成与 T 型裂缝根治

地形裂缝是四叉树渲染最让人头疼的问题。它的来源很简单:相邻两个节点的细分层级不同,边缘顶点数量不一致。高层级节点边缘有 33 个顶点,低层级节点边缘只有 9 个顶点,两者的三角形顶点对不齐,接缝处就会出现缝隙。

我的方案分两步。第一步,限制相邻节点的层级差不能超过 1。这是四叉树裁剪的经典做法,任何节点细分前都要检查四邻域节点的层级,如果差超过 1,先强制细分邻居,再细分子节点。这一步能避免大跨度的 T 型裂缝。

第二步,在节点边缘做裙边。即使层级差不超过 1,相邻节点边缘顶点位置仍不同,单纯用索引把边缘连起来也会产生拉伸和共面闪烁。我用的裙边做法是:在网格的最外圈,把所有边缘顶点垂直向下压一段距离,额外生成一圈三角形带。由于裙边嵌在星球内部,正常角度看过去看不见,接缝处的高度差就被这圈下陷三角形带遮住了。加上开启深度偏移,双管齐下之后,裂缝问题基本从视觉上消失。

索引生成本身不复杂,每个节点生成一个 (N-1) x (N-1) 的索引网格,N 取 33,三角形绕序固定为逆时针。注意不要每帧重新生成,节点创建时生成一次,随后反复使用。

4.3 法线重建与纹理贴合

高度图本身不直接提供法线。最简单的做法是用高度差分求梯度:

hL = sampleHeight(u - 1, v); hR = sampleHeight(u + 1, v); hB = sampleHeight(u, v - 1); hF = sampleHeight(u, v + 1); normal = normalize(vec3(hL - hR, 2.0 * heightScale, hB - hF));

中间那个 2.0 是一个经验权重,用来调整法线强度。权重越大,光照下地形立体感越强;太小则地形显得扁平。你可以把这个值做成参数,在“地形平滑”和“山体棱角感”之间切换。

法线算出来是切线空间下的向量,还需要变换到世界空间。这一步很多人忽略,导致火星表面光照方向完全不对。使用 TBN 矩阵转换即可,T 沿经度方向,B 沿纬度方向,N 由球面坐标推出。

颜色纹理方面,火星全球影像的原始分辨率并不高,放大到近地面时会看到明显马赛克。我采用两套纹理混合:近景时,在高分辨率局部影像瓦片上叠加一层噪声细节,用高度图作为混合因子;远处则只上低分辨率全球影像,节省显存。火星地表那种红褐色氧化铁肌理,靠这层噪声补一补,视觉上还是相当能骗过眼睛的。

4.4 相机移动时的动态更新流程

一帧内完整的调度流程我是这样组织的:

  1. 更新相机参数,计算世界矩阵和视锥平面。
  2. 从根节点开始递归遍历四叉树:先做视锥剔除,再做屏幕误差判定。
  3. 不满足细分条件的节点加入渲染列表;满足细分条件的节点检查子节点状态,如果子节点已 Ready 则递归下去,否则先处理加载请求。
  4. 所有请求按优先级入队,之后统一派发异步 IO。
  5. 渲染排序完成之后,按节点透明的顺序提交绘制调用。
  6. 每帧结束后按 LUR 策略淘汰多余的缓存节点。

这套流程里最需要小心的是异步加载导致的“父节点先渲染、子节点后补上”。我遇到过一种情况:父节点已经细分,某个子节点还在加载,渲染循环里既看不到父节点、又没有子节点可画,结果相机移动时前方地形突然出现一个大黑洞。解决办法是让状态机保证“子节点全部 Ready 才替换父节点”,也就是说父节点在当前帧仍保留渲染权,直到全部子节点加载完成的下一帧,才把父节点从渲染列表里移出。

5. 问题排查与参数调优:把常见的坑都翻出来

5.1 常见问题速查表

现象可能原因处理方式
瓦片接缝处地形撕裂、有缝隙相邻节点层级差过大、边缘顶点数量不一致启用裙边、限制邻居层级差不超过 1、深度偏移
相机靠近地表时地形抖动、飘动float 精度不足double 存全局坐标,渲染时转换到相机局部坐标
部分地形黑洞,加载完才有模型父节点已细分但子节点未 Ready子节点全部 Ready 后才能移出父节点渲染
地形表面光照方向混乱法线仍在切线空间,未转换到世界空间用 TBN 矩阵变换
帧率突然下降十几帧每帧加载请求过多、网格索引重复生成请求限流、索引缓存复用
远处细节直接消失,像“面团”屏幕误差阈值设得太大调小 thresholdPx,比如从 12 降到 8
对同一区域反复细化和合并,画面抖动LOD 判定没有迟滞细分和合并使用一档迟滞区间,比如细分 10px、合并 14px
VRAM 爆炸,几分钟后卡死纹理加载不释放或 LRU 失效设纹理内存上限,淘汰时同步释放 GPU 纹理

5.2 我踩过的几个坑

第一个坑是瓦片边界像素不重叠。我最初切瓦片时采用严格不重叠的 256 像素,采样高度图时直接按局部坐标索引,结果相邻瓦片边缘的高度采样点不连续,接缝处出现一条条高度沟槽。后来改成瓦片四周多留一圈像素,或者干脆每次从瓦片对应的全局经纬度重新采样高度,不再依赖瓦片边缘,问题立刻消失。

第二个坑是层级差限制和异步加载顺序发生冲突。我一开始只做“邻居层级差不超过 1”的静态限制,但没考虑加载中的节点状态。结果高细节节点快速加载完成,低细节邻居还没就绪,画面里出现半边有地形半边形似悬崖的尴尬场景。后来把状态机改成“先临时用父节点网格过渡,等兄弟节点全部就绪再一起切换”,才算根治。

第三个坑和相机近地面移动有关。当相机贴地高度几百米、视角倾斜到接近水平时,屏幕误差估算值对误差非常敏感,节点会反复进入细分和合并状态,画面看起来像深呼吸一样一呼一吸。我加了迟滞逻辑:细分阈值用 10px,合并阈值用 14px,中间用一段缓冲区间,不再临界反复。

5.3 一套可以直接抄的保守参数起点

如果你的视口是 1080p、FOV 60 度,我建议从这套参数起步:

  • 瓦片大小:256 x 256
  • 节点网格密度:33 x 33 顶点
  • 最大层级:14(依据数据精度可下调到 12)
  • 屏幕误差阈值:10 px,合并阈值 14 px
  • 每帧最多请求数:4 到 8 个
  • 内存上限:高度图和纹理合计 512 MB
  • 纹理过滤:三线性 + 8 倍各向异性
  • 高度图格式:16 位 PNG
  • 深度偏移:开启,偏移量根据近远裁剪面动态调整

这套参数跑下来,全球视角流畅,飞到距地表几十公里高度时依然能保持较好的细节。真正进入低空漫游前,把最大层级调高、提高请求频率,效果会进一步改善,但这也要求数据源本身有足够高的局部分辨率,否则只是把插值放大,视觉收益有限。

6. 最后再分享一点实际体会

这套四叉树加 LOD 的方案,折腾完给我最大的感受是:火星作为测试场真的省心。不需要处理乱七八糟的投影分带,也不用担心数据版权问题,全流程从数据切片到渲染调度可以完整走通。而这个四叉树加 LOD 的骨架,换到地球、月球或者任何一个球体上都是通用的,差别只在于数据精度和瓦片组织方式。我个人的经验是,项目推进顺序不要乱:先让四叉树在静态网格上跑通,再做 LOD 判定,之后才叠加颜色纹理和异步调度。一上来就啃调度优化,通常只会被各种加载竞态问题劝退。希望这篇笔记能让你少走点弯路,把更多精力放在真正有趣的地形细节上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进