地形渲染:高度图生成,LOD技术,视锥体裁剪
说到地形渲染,我脑子里第一个蹦出来的画面,就是当年第一次在手机上跑通一个带高度图的地形时,那种「哇,终于有点3D游戏的感觉了」的兴奋感。不过很快就被帧率打脸了——一个 1024x1024 的网格,直接让手机变成了暖手宝。
所以这一章,咱们就来聊聊地形渲染里最核心的三个东西:高度图怎么生成、LOD(层次细节)怎么搞、以及视锥体裁剪怎么砍掉看不见的三角形。说白了,就是让你在手机上也能流畅地跑出一片山川河流。
高度图生成:从一张灰度图到三维地形
高度图,本质上就是一张灰度图。每个像素的灰度值(0~255)对应地形的高度。0 是海平面,255 是最高峰。你想想看,一张 256x256 的图片,就能描述 65536 个顶点的高度信息,这效率多高。
我个人习惯用 Perlin 噪声 或者 Simplex 噪声 来生成高度图。为什么不用随机数?因为纯随机出来的地形太「扎眼」,全是尖锐的毛刺,看着像刺猬。而噪声算法能生成平滑、自然的地形起伏。
核心思路:用多个不同频率和振幅的噪声叠加(这叫「分形噪声」或「Octave叠加」),低频决定地形的大致轮廓(山脉走向),高频添加细节(小土坡、碎石)。
我在项目中遇到过一个问题:直接用 CPU 生成高度图,在加载场景时会有明显的卡顿。后来我改成在 初始化阶段预生成,或者干脆把高度图存成 .raw 文件直接加载,瞬间丝滑了。
// 伪代码:生成高度图的一个像素值
float GenerateHeight(int x, int y) {
float height = 0.0f;
float amplitude = 1.0f;
float frequency = 1.0f;
for (int octave = 0; octave < 6; octave++) {
height += PerlinNoise(x * frequency, y * frequency) * amplitude;
amplitude *= 0.5f; // 振幅递减
frequency *= 2.0f; // 频率递增
}
// 归一化到 0~255
return (height + 1.0f) * 0.5f * 255.0f;
}
嗯,这里要注意:高度图生成后,最好做一次 平滑滤波,不然地形边缘可能会有锯齿感。我一般用 3x3 的高斯模糊跑一遍,效果立竿见影。
LOD(层次细节)技术:远处少画点,近处多画点
地形网格如果每个顶点都画,那 1024x1024 的网格就是 100 多万个三角形。手机 GPU 直接哭给你看。所以 LOD 的核心思想很简单:离相机近的地方,网格密一点;离相机远的地方,网格疏一点。
常见的 LOD 方案有几种:
- 几何裁剪图(Geometry Clipmaps):把地形分成一圈一圈的环,越往外环网格越粗。我比较推荐这个,实现起来不算太复杂,而且 GPU 友好。
- 四叉树 LOD(Quadtree LOD):把地形分成四叉树节点,每个节点根据距离决定是否继续细分。这个方案内存占用稍大,但灵活性高。
- ROAM(实时优化自适应网格):动态细分三角形,效果最好但性能开销也最大。手机端不太建议。
我的经验:在 Android 上做地形 LOD,我建议用 几何裁剪图 + 索引缓冲区 的方式。因为几何裁剪图可以预计算好不同层级的网格数据,运行时只需要根据相机位置更新偏移量,不需要动态修改顶点缓冲区,这对 GPU 非常友好。
你可能会问:「不同层级的网格之间,接缝处会不会出现裂缝?」会的,而且这个问题很烦人。解决办法是在相邻层级的边界上做 裙边(Skirt) 或者 顶点融合(Vertex Blending)。我个人习惯用裙边——在网格边界向下延伸一小段三角形,这样即使有缝隙,也被裙边遮住了,视觉上完全看不出来。
视锥体裁剪:看不见的三角形,一个都不画
这个其实很好理解。相机能看到的空间是一个平截头体(视锥体),地形上那些在视锥体外面的部分,画了也是白画。所以我们要做 视锥体裁剪,把不在视锥体内的地形块直接跳过。
具体怎么做?我一般用 AABB(轴对齐包围盒) 和视锥体的六个平面做相交测试。每个地形块(比如四叉树的一个节点)都有一个包围盒,如果这个包围盒完全在视锥体之外,那这个地形块就不需要渲染。
// 伪代码:判断包围盒是否在视锥体内
bool IsBoxInFrustum(const AABB& box, const Frustum& frustum) {
for (int i = 0; i < 6; i++) {
// 找到包围盒在法线方向上的最远点
float d = box.max.x * frustum.planes[i].normal.x +
box.max.y * frustum.planes[i].normal.y +
box.max.z * frustum.planes[i].normal.z +
frustum.planes[i].distance;
if (d < 0) return false; // 完全在平面外
}
return true;
}
我曾经踩过一个坑:视锥体裁剪的平面方程算错了,导致地形块被错误地裁剪掉,画面边缘出现「突然消失」的效果。排查了半天,最后发现是平面法线方向搞反了。嗯,这种低级错误,犯过一次就再也不会忘了。
注意:视锥体裁剪一定要在 世界空间 下做,不要在模型空间下做。因为模型空间的包围盒没有经过模型矩阵变换,位置是错的。我见过有人把包围盒的顶点坐标忘了乘模型矩阵,结果裁剪出来全是错的。
三者如何协同工作
这三个技术不是孤立的,它们需要配合起来。我画了一张图,帮你理清它们之间的关系:
流程其实很清晰:
- 高度图 提供地形的原始数据(每个顶点的高度)。
- LOD 根据相机距离,决定哪些区域用高密度网格,哪些用低密度网格。
- 视锥体裁剪 在 LOD 之后做,把不在视野内的地形块直接扔掉。
顺序不能乱。如果先裁剪再 LOD,可能会把一些本该细分的近处地形块给裁掉,因为裁剪时还没考虑 LOD 的细分逻辑。我刚开始做的时候顺序搞反了,结果近处的地形出现「空洞」,排查了好久才发现是顺序问题。
性能优化的几个小技巧
- 预计算高度图:不要在运行时动态生成高度图,除非你的地形是程序化生成的。预计算好存成纹理,加载时直接读取。
- 使用索引绘制:地形网格的顶点有很多是共享的,用索引缓冲区(GL_ELEMENT_ARRAY_BUFFER)能省不少内存和带宽。
- LOD 层级不要太多:我一般用 4~5 层就够了。层级太多,管理复杂,而且 GPU 的缓存命中率会下降。
- 视锥体裁剪的粒度:不要每个三角形都做裁剪,那太慢了。以地形块(比如 32x32 的 patch)为单位做裁剪,效率高得多。
一个小技巧:在做视锥体裁剪时,可以先把视锥体的六个平面转换到地形块的局部坐标系下,这样包围盒的坐标就不用每帧都乘模型矩阵了。我在一个项目里用这个优化,裁剪阶段的耗时直接降了 40%。
好了,这一章的内容就这些。高度图、LOD、视锥体裁剪,这三个东西组合起来,就是一套能在手机上跑起来的地形渲染方案。你想想看,从一张灰度图到一片连绵起伏的山脉,中间经历了多少数学和图形学的智慧——嗯,这就是 OpenGL ES 的魅力所在。
公众号:蓝海资料掘金营,微信deep3321