☰
深度缓冲Z-Buffer原理、透视矫正插值与隐藏面剔除实现详解
2026/10/17 5:42:36 网站建设 项目流程

关于深度缓冲,我想从实际的问题入手来展开。上一篇文章里,我们已经能把三角形画到屏幕上,但那时候所有的三角形都是简单叠加上去的,谁后画谁就盖住之前的内容,这显然不真实。只要你稍微把一个三角形转到另一个三角形后面,画面就穿帮了。所以这次的核心主题是隐藏面剔除,而z-buffer是最直接、最优雅的解决方案。

简单说,z-buffer是一张和屏幕一样大的“深度表”,每个像素都记录这个位置上最靠近观察者的那个点的深度值。画三角形的时候,逐个像素判断:如果当前这个点比表里存的更近,就更新颜色和深度;否则就丢弃。这样不管三角形以什么顺序提交,最后留在屏幕上的都是正确的前后遮挡关系。我们不需要对三角形排序,不需要对几何体做任何预处理,只需要每个像素做一次比较和一次写入。

这篇文章会讲清楚三件事:深度值怎么算、怎么存、精度问题怎么处理。还会给出一份可以直接跑起来的实现代码,以及我在实际调试中踩过的几个坑。

1. 隐藏面问题剖析:为什么不能简单地“按远近排序”

1.1 画家算法的局限

没有接触过图形学的人,第一反应往往是排序:把三角形按到相机的距离排个序,远的先画,近的后画,后画的自然盖住先画的。这思路源自一个很古老的技法,叫画家算法,就跟真实画家画油画一样,先涂远景,再画中景,最后画近景。在三角形数量少、彼此深度关系简单的情况下,这招确实能work。

但问题很快暴露。两个三角形互相穿插的时候,比如一面墙和一块斜插进来的木板,你没办法找到一个全局的绘画顺序:木板的一部分应该在墙前面,另一部分应该在墙后面,这时候画家算法的“排序”就失效了。更麻烦的是循环遮挡,A在B前面,B在C前面,C又在A前面,这种几何结构会让任何排序算法都陷入困境。还有更极端的案例:单个三角形自己折叠的时候,同一个三角形内部的深度变化就没法用“整体排序”来解决了。

我在实现粒子系统的时候也踩过类似的坑。粒子数量一多,如果靠排序决定绘制顺序,每一帧都要重排几千甚至上万个粒子的深度,排序的消耗还算小事,更头疼的是两个粒子几乎贴着的时候,排序会抖动,画面就会一帧前一帧后地闪烁。所以我后来坚定的方向是:不要在几何层级解决遮挡,要走到像素层级去解决。

1.2 逐像素决策的必然性

z-buffer的思路,本质上把问题的颗粒度从“三角形”细化到了“像素”。我们不关心三角形之间的整体顺序,只看屏幕上每一个具体的像素点:假设我往这个像素的位置看过去,视线会依次穿过哪些三角形表面?其中哪个点离观察者最近?把最近的那个点的颜色写进帧缓冲,这就是答案。

这个策略有一个很朴素却很漂亮的特性:每个像素的计算完全独立,不管三角形来了多少个、以什么顺序来,最终结果都一样。这叫做顺序无关性。我可以随便打乱三角形提交的先后顺序,渲染结果不会有一点点差异。这一点在实际工程里极其重要,因为引擎里的绘制顺序经常是随机的,比如透明物、半透明物、各种特效混在一起,你不可能总能保证一个严格有序的提交顺序。

当然这个方法也有代价,就是要多开一块和屏幕一样大的内存,每个像素存一个浮点数(或者一个整数)。今天来说这几乎是零成本,但在上世纪七十年代,内存以KB计算的时候,这确实是一个奢侈的决定。所以每一代硬件和软件架构,都在z-buffer的基础上做各种优化,但核心思想从1974年提出到现在几乎没变过。

2. 深度值怎么来:从三维坐标到屏幕上的z

2.1 屏幕坐标和深度值的分离

要把遮挡关系算对,首先得有一个度量“远近”的东西。很多初学者会误以为:画在屏幕上越往上的点就越远,或者物体越小就越远。这些都是间接的信号,没法作为精确判据。真正靠得住的是相机空间里的z坐标——也就是顶点在相机坐标系下的深度值。

这里有个关键点:我们不能用透视除法之前的原始z值直接去插值。透视投影下,屏幕上两点的深度变化和它们在三维空间里的真实深度变化不是线性关系。如果你直接在屏幕空间做线性深度插值,结果在大部分情况下是错的,近处像素可能被错误地判断成远处的点,然后整个遮挡关系就乱了。

我在项目里遇到过很诡异的图像扭曲现象,三角形的颜色倒是的,位置也对,但两个三角形交叉的地方阴影错误,排查了很久才发现是深度插值方式不对。正确的做法是把每个顶点的三维坐标先经过透视变换,然后用透视矫正插值(perspective-correct interpolation)方法。具体来说,屏幕上某个像素的深度值,不是简单地把两个顶点的z值按屏幕距离线性插值,而是要把1/z、或者说经过透视矩阵变换后的w分量当作线性量来插值。

听起来有点绕,我打个比方。想象一条笔直的铁轨,从你的脚下一直延伸到远方,铁轨上的枕木在现实世界中是等距排列的,但在照片上,近处两条枕木之间隔了好几毫米,远处却密集得几乎挤在一起。如果你在照片上按像素距离等分去插值,插出来的就不是真实空间里的等距点。深度值也是这样,必须用真实空间里的线性关系来处理,不能直接在屏幕空间里做线性插值。

2.2 重心坐标插值法

具体的插值工具是重心坐标。上一篇文章里我们已经用重心坐标来填充三角形内的颜色了,如果用的是vertex color,那么color = v0_color * alpha + v1_color * beta + v2_color * gamma,其中alpha是顶点0的权重,beta是顶点1的权重,gamma是顶点2的权重。

深度值的处理方式同理,但要注意:因为透视投影会造成非线性,所以我们不能直接把三个顶点的原始相机空间z值拿来做重心坐标插值。正确的方式是:

  1. 计算三个顶点在裁剪空间里的坐标(经过模型、视图、投影矩阵变换后的坐标)。
  2. 对这些裁剪空间坐标做透视除法,得到NDC坐标(规范化设备坐标)。
  3. 在NDC空间里,z值在[-1, 1]区间,但这个z和相机空间z不是线性的。
  4. 真正做插值的时候,用裁剪空间的w值做矫正:对每个像素,先对1/w做线性插值,再对z/w做线性插值,最后用(z/w) / (1/w)恢复出正确的z。

这套流程在图形学经典教材里叫perspective-correct interpolation,是学习光栅化绕不过去的坎。好消息是,这个过程虽然原理复杂,但代码实现并不复杂,核心就是多存几个中间值,多做一次除法。

我实际写代码的时候,为了先让功能work起来,用了简化的策略:暂时不纠结透视矫正,先假设投影是正交投影,或者把插值深度值直接设为NDC z。在相机离物体比较远、深度跨度比较小的情况下,这种简化在视觉上不会太明显。等到基础功能稳定后,再切换回透视矫正版本。分步调试的好处是问题容易定位:如果z-buffer效果不对,先别急着怀疑矫正逻辑,先确认基础深度比较流程没问题。

2.3 深度值的存储格式和精度

z-buffer精度是另一个大坑。精度不够的直接后果是深度冲突,就是两个距离几乎相同的表面在屏幕上交替闪烁。我的建议是,软件渲染器阶段直接用float存深度,不要为省内存去压缩成16位或8位整数。开发调试阶段,float是死亡最简单的方案,精度充足,调试方便。

但要知道为什么硬件上会有那么多花活:24位定点、反向z、对数深度等等。原因是GPU上z-buffer的带宽消耗很大,需要用更少字节降低带宽压力,同时又要尽量保持精度。反向z是个很经典的技巧:深度值不是越小越近,而是把映射反过来,让近处的精度更高。因为透视投影下,近处平面(near plane)附近的深度变化对精度非常敏感,如果使用标准映射,大部分浮点精度都浪费在远处的深度值上,近处反而精度不足。反向z把这个分布倒过来,让近处的深度值获得尽可能多的精度。

自己做软件渲染器,实现一个反向z其实也不难,但我建议第一版先不做,因为调试语义容易搞混。先用最直观的“越近z越小”的约定,把所有流程弄清楚以后,再考虑优化方向。

3. 核心代码实现:一步步搭出深度缓冲

3.1 初始化与清屏

代码层面,z-buffer通常就是一块和帧缓冲同样大小的内存,每个元素存一个float。初始化时,把每个像素的值设为正无穷大(或某个足够大的数),表示“这个位置还什么都没有”。每帧渲染前,也要把z-buffer重新清一遍,否则上一帧的深度信息会残留,画面就会出怪异的闪烁。

下面是我项目里的一小段示意代码:

class DepthBuffer { public: int width, height; std::vector<float> depthData; DepthBuffer(int w, int h) : width(w), height(h), depthData(w * h) { clear(); } void clear() { std::fill(depthData.begin(), depthData.end(), std::numeric_limits<float>::max()); } bool testAndWrite(int x, int y, float depth) { if (x < 0 || x >= width || y < 0 || y >= height) return false; size_t index = y * width + x; if (depth < depthData[index]) { depthData[index] = depth; return true; } return false; } };

注意testAndWrite返回值的含义:返回true表示通过了深度测试,并且已经更新了深度值,调用方随后应该更新颜色缓冲;返回false表示这个像素被遮挡了,颜色不用改。把深度测试和写入封装成一个函数,很大程度上减少了光栅化循环里出错误的概率。

开发阶段我会额外加一个调试面板:把深度缓冲本身可视化出来。就是把深度值映射成灰度图渲染到屏幕上。这样能一眼看出深度分布是否正确:近处亮、远处暗,平滑过渡。如果某个三角形出现突变、条纹,多半是插值方向反了或者深度数值空间搞错了。

3.2 光栅化循环中的深度读写

接下来是在三角形光栅化的主体循环里接入深度测试。基本的流程是:

  • 对三角形的包围盒内的每个像素,判断是否在三角形内。
  • 如果不在,跳过。
  • 如果在,用重心坐标插值出该像素的深度值。
  • 调用testAndWrite,通过的话,再插值颜色并写入颜色缓冲。

有一个容易出错的细节:重心坐标在屏幕空间和三维空间并不完全等价。屏幕空间重心坐标用于判断像素是否在三角形内,没问题,但用于插值深度时,直接使用屏幕空间的权重会造成透视偏差。严谨的做法是使用透视矫正后的权重。我第一版实现里偷懒用了屏幕空间权重,结果旋转相机时,三角形边缘交叉的地方总在很窄的角度范围内出现错误的像素闪烁。后来才补上了矫正。

下面是一个带透视矫正的深度插值实现,这部分是可以直接抄作业的核心代码:

// 顶点在屏幕空间的位置和裁剪空间w值 struct ScreenVertex { float x, y; // 屏幕坐标 float w; // 裁剪空间的w,用来做透视矫正 float depth; // 这个值通常是裁剪空间里的 z/w 或者 NDC z }; void drawTriangleWithDepth(const ScreenVertex& v0, const ScreenVertex& v1, const ScreenVertex& v2, const Color& c0, const Color& c1, const Color& c2, FrameBuffer& fb, DepthBuffer& db) { // 1. 计算目标和边界 float minX = std::floor(std::min({v0.x, v1.x, v2.x})); float maxX = std::ceil(std::max({v0.x, v1.x, v2.x})); float minY = std::floor(std::min({v0.y, v1.y, v2.y})); float maxY = std::ceil(std::max({v0.y, v1.y, v2.y})); minX = std::max(0.0f, minX); maxY = std::max(0.0f, maxY); maxX = std::min((float)fb.width - 1, maxX); maxY = std::min((float)fb.height - 1, maxY); // 2. 预处理每个顶点的“矫正后深度”和“矫正后权重倒数” float invW0 = 1.0f / v0.w; float invW1 = 1.0f / v1.w; float invW2 = 1.0f / v2.w; float depth0 = v0.depth * invW0; float depth1 = v1.depth * invW1; float depth2 = v2.depth * invW2; // 3. 对包围盒内每个像素做光栅化判断 for (int py = (int)minY; py <= (int)maxY; py++) { for (int px = (int)minX; px <= (int)maxX; px++) { // 像素中心坐标 float cx = px + 0.5f; float cy = py + 0.5f; // 计算屏幕空间重心坐标(省略具体函数,思路是通过叉积或斜量求权重) float alpha, beta, gamma; if (!computeBarycentric(cx, cy, v0.x, v0.y, v1.x, v1.y, v2.x, v2.y, alpha, beta, gamma)) { continue; // 在三角形外 } // 4. 透视矫正:先插值 1/w,再插值 depth/w float interpInvW = alpha * invW0 + beta * invW1 + gamma * invW2; if (interpInvW == 0.0f) continue; // 恢复真实深度 float interpDepth = alpha * depth0 + beta * depth1 + gamma * depth2; float finalDepth = interpDepth / interpInvW; // 5. 深度测试 if (db.testAndWrite(px, py, finalDepth)) { // 通过测试,插值颜色并写入颜色缓冲 float interpInvW = interpInvW; // 该值已在上方计算 float r = (alpha * c0.r * invW0 + beta * c1.r * invW1 + gamma * c2.r * invW2) / interpInvW; float g = (alpha * c0.g * invW0 + beta * c1.g * invW1 + gamma * c2.g * invW2) / interpInvW; float b = (alpha * c0.b * invW0 + beta * c1.b * invW1 + gamma * c2.b * invW2) / interpInvW; fb.setPixel(px, py, Color(r, g, b)); } } } }

这段代码里,有一个比较容易忽略的点:颜色插值也必须做透视矫正。如果只矫正深度、不矫正颜色,三角形的颜色会在贴图模式下一块一块地扭曲,虽然位置正确、遮挡正确,但颜色过渡看上去就是错的。我曾经在这个问题上浪费了一整个晚上,最后把颜色矫正加上去以后,画面立刻清爽了。

3.3 关键取舍:什么时候用深度测试

上面循环里用的深度比较条件是depth < depthData[index],也就是“更近的通过”。这个约定配合“清屏时设为正无穷大”正好合拍:任何几何表面都比正无穷大更近,都能通过第一次写入。

有时候你可能碰到条件是depth <= depthData[index],这个<和<=的差异看起来微小,但会影响脏标记的触发频率。如果两个表面完全重合(比如同一个三角形的正反面都参与了渲染),使用<=会导致一模一样深度的像素不断触发深度更新,后面的绘制把前面的覆盖,可能在边缘产生微小的闪烁。一般情况下,<更安全。

还有关于反面的处理:在目前的代码里,我们没有真正进行背面剔除,也就是说背对相机的三角形也会进入光栅化和深度测试流程。z-buffer会在深度测试时把它们全部挡住吗?不会。因为如果从背面看到的三角形,它的表面法线指向屏幕内部,但它的深度值依然是一个合法深度,除非另外做判断。大多数情况下,一个封闭模型从外面看,内部背面的三角形会被前面的几何完全覆盖,z-buffer帮我们挡住了,但代价是浪费了很多计算量在根本看不见的三角形上。这个问题我放在了本章后续优化小节里,会给出一个简单的法线判定方案。

4. 实测验证与问题排查:为什么我的画面还是不对

4.1 测试环境:绕一个立方体旋转

光有代码不算数,真正检验z-buffer是否做对,最好的方式是一个有遮挡关系的三维场景。我建议不要用太简单的单三角形,最起码用一个立方体(6个面、12个三角形),或者一个由多个交叠平面组成的模型。

我自己的测试方法是写一个简易的相机环绕逻辑:让相机围绕场景中心旋转,观察者在屏幕上转圈。如果z-buffer是错的,旋转过程中后表面时不时会穿透前表面,出现虚假的拼接痕迹。如果z-buffer是对的,不管相机转到哪个角度,物体的前后关系都稳定如一。

还需要测试一个常见的边缘案例:仅一个三角形的表面与第二个三角形表面严格共面(比如两面墙紧密贴合)。这时两个三角形的深度几乎相同,由于浮点误差,某些像素可能第一个通过测试,某些像素可能第二个通过测试,画面就会像棋盘格一样闪。解决方式是引入一个“深度偏移”的小量,让原始深度值稍微偏移一点点,把这种竞争强制分出一个赢家。硬件上这叫polygon offset,软件渲染器里我直接用一个极小常量加在深度值上。

4.2 深度冲突的实战案例

我在项目中第一次看到深度冲突,是渲染一个地面平面,然后在上面再渲染一些半透明的指示标记。地面本身是一张大平面,颜色统一,深度也统一。标记是浮在地面上方的薄片,高度差只有0.001单位。这种情况下,深度冲突几乎必然发生:标记和地面在某个像素上的深度差仍在浮点误差范围内,导致两种颜色疯狂抖动。

肉眼看起来,不是那种平滑的遮挡,而是地面上出现了噪点一样的闪烁,尤其在标记边缘。排查时我的第一反应是没开z-buffer?实际上开了,只是差值是太小。后来我把标记往下按,差值从0.001调到0.5,闪烁立刻消失。

纯软件渲染里,最稳妥的规避方法就是:在设计场景时,尽量拉开深度差;实在拉不开,就给深度测试加一个偏移。偏移量不能太大,否则物体会看起来“浮”在正确位置前面一大截,也不能太小,否则等于没加。我通常先用场景总深度范围的千分之一作为起步值,然后根据实际闪烁情况微调。

4.3 反锯齿等后续问题

z-buffer本身不解决锯齿问题。当你开启z-buffer后,三角形边缘还是会和之前一样出现阶梯状的锯齿,因为z-buffer只关心遮挡,不关心覆盖率。

如果你的渲染器接下去要做超采样抗锯齿,那z-buffer也需要注意:每个子采样点都需要单独的深度值,而不是每个像素只存一个深度。我在项目里给MSAA做的方案是:每个子采样点存一个深度值,颜色缓冲存一个累积色,最后再做resolve。这里先不展开,但希望读者知道,深度缓冲并不排斥抗锯齿,只是两者需要协同设计。

5. 优化方向:从能用走向好用

5.1 提早丢弃和背面剔除

z-buffer实现正确后,下一步自然而然要考虑效率。最简单有效的优化是背面剔除(backface culling):在光栅化之前,判断三角形法线朝向,如果它背对相机,直接丢弃,不再进入光栅化阶段。

判断方法是:顶点按顺时针或逆时针顺序排列时,计算三角形在屏幕空间的有向面积。如果面积符号不对,说明这个三角形现在背对着观察者。对于封闭网格来说,背面的面片反正会被前面的挡住,早丢弃它们没有画质损失,只是节省了几千个像素的循环。

代码里这一步很轻量:

float signedArea = (v1.x - v0.x) * (v2.y - v0.y) - (v2.x - v0.x) * (v1.y - v0.y); if (signedArea <= 0.0f) { return; // 背面,丢弃 }

注意这个假设依赖于顶点绕序固定。如果你模型里有些三角形是顺时针、有些是逆时针,背面剔除就会错误地丢出正面的三角形。所以在加载模型时,要统一顶点绕序。这个我在处理一个OBJ文件时踩过坑:大部分面是逆时针,偶尔有几个面是顺时针,结果画面上出现了几个透明的洞,当时以为是深度测试的问题,排查了好久才发现只是绕序不一致。

5.2 包围盒提前退出与分块渲染

高效的渲染器还有一个优化方向:先在屏幕空间算每个三角形的包围盒,只扫描包围盒区域内的像素;如果包围盒和屏幕没有交集,直接跳过。这个我在前面代码里已经写入了。

进一步还能做分块渲染(tiled rendering):把屏幕划分成若干个小方块,比如64x64,依次渲染每个方块。z-buffer可以按块进行“早退”测试,如果深度测试在某个块内全部失败,就能直接跳过该块内的所有像素写入。这个思路是GPU上的标准做法,但在软件渲染器里实现会比较麻烦,我建议先把基础版本跑顺,再考虑要不要做分块。

5.3 下一步预告:Shader化和半透明渲染

有了z-buffer后,这个渲染器已经可以渲染实心不透明的三角网格了。下一步我会开始接入更完整的着色模型,例如Blinn-Phong光照、纹理采样。注意,半透明物体需要单独处理:不透明物体用z-buffer决定遮挡,半透明物体则需要排序并开启混合,不能用同一个深度测试逻辑。这方面的坑,我在后续写到混合(blending)时再单独展开。

我的看法是,z-buffer是整个软渲染器正确性的地基,如果这里没有做对,之后再精致的光照也只能作用在错误遮挡关系的表面上。写到这里,我已经改了几十遍代码,从最初的完全不正确到慢慢符合预期,最大的感触始终是:图形学里的很多概念用眼睛看文章觉得理解了,真要代码实现才会发现自己哪里没想透。画一个三角形只需要处理好坐标变换,但画十个互相遮挡的三角形,那一刻才算摸到了一点三维渲染的真实门槛。

如果读者在看自己的渲染结果时出现了遮挡顺序混乱的情况,我的建议是优先检查深度值是否正确输出了,而不是先怀疑颜色插值或变换矩阵。把深度可视化,一遍就能定位问题所在。这个排查思路,算是我在整个z-buffer实现过程中最想分享的经验之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询