第 26 章 渲染管线与深度缓冲:谁在前、谁在后,到底谁说了算
前面好几章,我们零零碎碎地聊过”画出来”这件事:第 2 章讲了一个顶点怎么从局部坐标一路搬到屏幕,第 6 章讲了光怎么给每个像素算明暗,第 8 章讲了雾怎么把远处藏起来。但你有没有一个疑问,我一直替你压着没提:
屏幕就那么一块方形的画布,成千上万个三角形,有的在前面、有的在后面,有的互相插着、有的半透明叠着——引擎到底”先画谁、后画谁”?如果两个东西重叠在同一颗像素上,最后显示的是哪一个?谁说了算?
你先猜一下。很多人第一反应是:“那肯定按顺序画呗,先画的在底下,后画的盖上去。“——听起来没错,对吧?这一章我要请你见证:正是这个看似天经地义的”按先后画”的直觉,在三维世界里会彻底翻车。而工程师用来破局的那个东西,就叫深度缓冲(Depth Buffer,也叫 Z-buffer)。它和你写后端时用的”缓存”其实是同一个家族的概念,但解决的问题特别刁钻。
这一章,咱们把”谁在前谁在后”从一句”引擎自动处理”的糊涂话,拆成你能讲清楚来龙去脉的知识。
26.1 画家算法的崩溃:按先后画,为什么会出错
先回到你刚才那个直觉。最早做三维图形的工程师,想到的也是你这个办法:既然要从远往近画才不会被盖错,那我先把远处的物体画上去,再画近处的,近的盖住远的,不就对了?
这招有个好听的名字,叫画家算法(Painter’s Algorithm)——就像画家画油画,先铺远处的天空,再画远山,再画近处的树,后画的总是盖在先画的上面,层层叠加,最后看起来就有纵深了。
名字很美,逻辑也朴素。但它有一个致命的硬伤:当物体互相交叉、互相遮挡形成循环时,“谁远谁近”这件事本身就排不出一个确定的顺序。
我给你举两个具体的坑,你品一下。
坑一:两个物体交叉咬合
想象一个红色的环和一个蓝色的环,在三维空间里十字交叉——红的从蓝的里面穿过去,蓝的也从红的里面穿过去。红环有一部分在蓝环前面,另一部分在蓝环后面。
你现在要让画家算法来排个序:红环远还是蓝环远?没法排。因为红环的 A 段比蓝环近,B 段又比蓝环远。你规定”先画红的再画蓝的”,那蓝环会整体盖住红环,红环穿到蓝环前面的那段就被错误盖掉了;你反过来”先画蓝的再画红的”,红环又整体盖住蓝环,蓝环该露在前面的那段又没了。
换句话说:当遮挡关系是”互相穿插”时,不存在一个全局的”先画谁后画谁”的排序能让每个像素都正确。 这是画家算法的数学死穴,不是你调参能调好的。
坑二:透明物体的排序噩梦
第二个坑更常见,你做游戏几乎必踩:透明物体。
画家算法依赖”后画的盖住先画的”。可透明物体(玻璃、水、半透明贴图)的本意是”后面的东西能透过来一点”。这就要求你必须按从远到近的顺序画所有透明物体,否则叠加顺序错了,透明度就对不上。
问题来了:透明物体之间、透明和不透明之间,排序本身就有成本,而且遇到上面那种交叉情况同样排不清。更要命的是,透明物体还必须”等所有不透明物体先画完”,再单独按距离排序后画——这套规则一复杂,bug 就层出不穷。
你先别急着记这些坑。我就问你一句:画家算法之所以翻车,根子是不是出在”它想用’一个全局的先后顺序’去解决’本质上每个像素各自的远近’这个问题”?一个红的 A 段和 B 段,明明离相机的距离不一样,你却非要用”整个红环”排一个名,这公平吗?
这一问,其实已经指向了正确答案的雏形:别去排”整个物体”的顺序,去管”每一个像素”谁更近。这就是深度缓冲的念头。
26.2 深度缓冲 Z-buffer 怎么破局
工程师想出来的办法,聪明在换了一个粒度。画家算法在”物体级”排序,深度缓冲在**“像素级”裁决**。
它的核心思想,我翻译成你熟悉的话:
屏幕上的每一颗像素,都配了一个”小本本”,记着”到目前为止,这颗像素上,离相机最近的那一层,离了多远”。每当有一个新的三角形要画到这颗像素上,我先比一比:这个新来的,是不是比本本上记的更近?更近,就画上去、并更新本本;不更近,就直接扔掉,别画。
这个”小本本”,就是深度缓冲(Depth Buffer),也叫 Z-buffer(Z 指的就是”沿相机前方的距离”那个方向,你在第 2 章学过”深度 = 沿相机前方的距离”,这里正式用上了)。
你看,这套逻辑彻底绕开了”先画谁后画谁”的问题。无论你以什么顺序把三角形喂给引擎,每一颗像素最后留下的,一定是”离相机最近的那个三角形在该像素处的颜色”。远的来了,发现本本上已经有更近的,它连画都不画。结果天然正确——和提交顺序无关。
这就把上面两个坑都填了:
- 红蓝环交叉:每一颗像素各自比深度,红环该在前面的像素,红的比蓝的近,红胜出;蓝环该在前面的像素,蓝的比红的近,蓝胜出。整幅图自然正确,根本不需要给”整个环”排序。
- 透明那块后面单说,但至少不透明物体的互相遮挡,彻底被深度缓冲解决,不再依赖顺序。
连回你已经学的:第 2 章我说”深度 = 沿相机前方的距离”,第 7 章(摄像机)我说”只有近裁剪面和远裁剪面之间的东西才被画”。深度缓冲正好是这两件事的”执行者”——它记录的”离相机多近”,就是第 2 章那个深度;它只在”近/远裁剪面之间”才工作,裁剪面之外的,在第 7 章讲的视锥裁剪阶段就被切掉了,根本到不了深度比较这一步。
一句话总结深度缓冲的破局点:把”物体该排第几”的问题,降维成”每个像素上谁更近”的问题。 这是本章最重要的一个反转,值得你停下来想三十秒。
26.3 深度值到底存在哪
你写后端,对”缓冲""缓存”不陌生——一块内存,按某种结构存着待处理的数据。深度缓冲就是这么一块显存(GPU 上的内存)。
具体来说:
- 深度缓冲是一张和屏幕分辨率一模一样大的”隐藏图层”。屏幕是 1920×1080,深度缓冲就是 1920×1080 个格子。
- 每个格子里存一个数——就是这个像素当前”胜出者”离相机的深度值(通常是一个归一化后的浮点数,范围在 0 到 1 之间,0 表示贴在近裁剪面,1 表示贴在远裁剪面)。
- 它不参与最终显示。你看到的画面是颜色缓冲(Color Buffer)画出来的;深度缓冲只是个”裁判”,决定哪个颜色有资格写进颜色缓冲。它自己从不被直接画到屏幕上。
类比帮你记:深度缓冲像一个大型选秀节目的”打分板”。每个像素是一个选手位置,打分板上只保留”当前最高分(最近距离)“。新选手(新片元)来挑战,分更高(更近)就替换,分低了就淘汰。观众(你)最后看到的舞台(颜色缓冲)只有胜出者,打分板本身不展示。
还有几个细节,你做引擎相关排查时会用到:
- 每个像素的深度值,是在三角形被”光栅化”成片元(fragment,你可以理解成”某个像素位置上、某个三角形的一块候选颜色”)之后,和本本比对的。所以深度测试(Depth Test)发生在”片元着色器算完颜色”之后、“写进颜色缓冲”之前。
- 深度缓冲可以和颜色缓冲一起被”清掉”——每帧开始,引擎通常把深度缓冲重置成”最远”(表示目前谁都没占),把颜色缓冲清成背景色。这就是为什么每帧画面是”重新画”,而不是往上一帧上叠。
- 深度缓冲的精度(每个格子用多少位存这个数)是个严肃参数。老式是 16 位,常见是 24 位,精确需求用 32 位浮点。位数越低,能区分的”远近层次”就越少——这直接埋下了下一节 z-fighting 的雷。
26.4 齐次坐标与透视除法的伏笔
这里我要给你埋一个和下一章(第 27 章)紧紧咬合的钩子,因为你迟早要写 shader、迟早要碰”投影矩阵”,而现在不讲,下一章你会对不上。
你想过没有:一个三维顶点(x, y, z),经过摄像机投影之后,怎么变成屏幕上的(屏幕x, 屏幕y)?“近大远小”到底是哪一步发生的?
答案藏在**齐次坐标(Homogeneous Coordinates)和透视除法(Perspective Division)**里。
我先把结论抛给你,细节第 27 章兑现:
- 顶点经过”投影矩阵”变换后,得到的不是一个普通的三维点,而是一个带四个分量的齐次坐标(x, y, z, w)。注意这个 w 不是随便加的,它是透视投影的灵魂。
- 真正的屏幕坐标,是做了透视除法之后才得到的:用
x/w、y/w得到屏幕上的横纵位置。正是这个”除以 w”,制造了近大远小——远的物体 w 大,除完之后 x、y 被缩小,于是在屏幕上显得小;近的物体 w 小,除完之后坐标被放大,显得大。 - 而深度值(Z-buffer 里那个数),同样是从这个带 w 的坐标里来的,而且它不是线性的——近处精度高、远处精度低。为什么?因为 w 和距离的关系本身就是非线性(和距离的某种倒数相关),所以”深度值的疏密”跟着 w 走,自然也是近密远疏。
你先把这句话记住,它是全章的一个枢纽:“近大远小”和”深度精度近高远的低”,是同一件事的两面,根源都在那个 w 和透视除法。 第 27 章我们会亲手把这个 w 从哪来、除法怎么除,算给你看。
这一节的伏笔先立在这儿,因为下一节要讲的 z-fighting,根子就在这个”深度精度近高远的低”上。
26.5 z-fighting(深度打架):当两面几乎贴在一起
好,现在你懂了深度缓冲怎么裁决。但工程师的世界哪有那么完美。有一个经典翻车,几乎每个做 3D 的都见过,它叫 z-fighting(深度打架 / Z 冲突)。
现象是这样的:地面上贴了一张”贴花”(decal,比如弹孔、血迹、路面的标记线),它和地面几乎在同一个高度。你从某个角度看,贴花和地面在交界处开始疯狂闪烁、互相穿插——这一帧贴花在上,下一帧地面在上,像两块玻璃在不停地争抢”谁更近”。这就是 z-fighting。
为什么会这样?我拆给你看。
原因一:深度精度分布是”近密远疏”的
连回 26.4 的伏笔。深度缓冲里存的值,近处分得细、远处分得粗。也就是说,两个物体如果都离相机很远,它们深度值的差距在”存进去的精度”里可能只差了一点点、甚至根本分不出来(被舍入成同一个数)。当两个数分不出大小,深度测试就”反复横跳”——这一帧甲胜、下一帧乙胜,于是闪烁。
几何直觉:想象一把尺子,前 1 厘米刻了 100 道线(很密),后 99 厘米只有 10 道线(很疏)。两个东西都落在后段,它们真实距离差了 2 厘米,但尺子上可能落在同一道线里——你就量不出谁远谁近了。深度缓冲的”尺子”正是前面密后面疏的。
原因二:near / far 比值过大
深度缓冲的”量程”是从近裁剪面(Near Plane)到远裁剪面(Far Plane)。这两个值之间的跨度越大,有限的精度(24 位也好 32 位也好)就被摊得越薄,远处每一”格”代表的实际距离越大,越容易分不清。
经典的作死操作:Near 设成 0.01(想看极近的东西),Far 设成 100000(想看极远的山)。中间横跨了一千万倍的距离,精度被摊得像一层纱,远处的东西全在”分不清”的区间里,z-fighting 必然爆发。
你先猜一下:如果我把 Near 从 0.01 调大到 0.1,Far 不变,远处的精度会变好还是变坏?——别急,下面揭晓,答案是”变好”,原因很反直觉,正因为深度精度近密远疏,把 Near 推远一点,等于把”密的那段”让出来给中间和远处用,整体远处精度反而提升。这是面试常考的”反直觉点”。
解决手段与代价
工程师对付 z-fighting,有几把扳手,每把都有代价,你得以”解决什么问题”来选:
- 调大 Near Plane(近裁剪面):把 Near 从 0.01 提到 0.1 甚至 0.3。代价:比 Near 更近的东西(比如第一人称里贴脸的枪管、鼻尖)会被裁掉看不见。所以这是和”近处内容”的权衡,下面 26.6 会讲第一人称怎么用。
- 缩小 Far Plane:别把 Far 设成 100000,能看 5000 就看 5000。代价:超过 Far 的远处直接不画(被裁剪),适合封闭场景。
- Polygon Offset(多边形偏移 / 深度偏移):强制让某个物体在深度比较时”假装有那么一点点更近”(或更深),把两面的深度值人为拉开。代价:它是”骗”深度测试,偏移量设太大,物体会肉眼可见地”浮”在前面或”陷”进后面,要小心调。常用于贴花、描边、阴影防痤疮。
- Logarithmic Depth Buffer(对数深度缓冲):思想是改深度值的分布公式,让精度不再”近密远疏”得那么极端,远处也能分得很细。代价:实现复杂(要在 shader 里手动算对数深度),而且和某些引擎内置的深度相关功能(如早期 Z 预通道)可能打架,不是开个开关就完事。
- 提高深度缓冲位数:24 位升 32 位浮点。代价:吃更多显存,而且很多时候瓶颈不在位数而在 near/far 比值,光加位数治标不治本。
你看,z-fighting 的每把扳手,都是在”近处可见性、远处可见性、显存、实现复杂度”之间做取舍。这和全书”没有免费午餐”的基调完全一致。
26.6 Unity 实操:Near/Far 在哪调、怎么排查 z-fighting
光讲原理不落地,你下周一上班还是不会用。我给你最直接的实操。
那几个旋钮在哪
在 Unity 里,Near/Far Plane 是摄像机(Camera)组件上的参数:
- 选中场景里的主摄像机(Main Camera)。
- Inspector 里
Camera组件下,有 Near 和 Far 两个 Clip Planes(裁剪面)数值。Near 默认 0.3,Far 默认 1000(单位是世界单位,比如米)。 - 这就是第 7 章说的”近裁剪面 / 远裁剪面”的实体旋钮。改它们,直接改变深度缓冲的量程。
什么场景该把 Near 调大
一个最典型的例子:第一人称射击游戏(FPS)里的武器。枪模型是”贴脸”拿着的,离相机可能只有 0.10.2 米。如果 Near 是默认的 0.3,枪会比 Near 还近,被裁掉,你屏幕上就看不到自己的枪了——这是 bug。所以 FPS 常把 Near 设得很小(0.010.05)才能看见枪。
但 Near 太小又容易引发远处 z-fighting(26.5 讲过)。于是 FPS 游戏有个经典 trick:用两台摄像机——一台 Near 极小(0.01)只画”枪和近处 UI 层”,另一台 Near 正常(0.3)画世界。这样枪看得见、世界又不打架。这就是”参数解决具体问题”的实战范例。
再比如:你的游戏是室内小场景(房间、地牢),Far 根本不需要 1000,设成 200 就够了,远处精度立刻厚实,z-fighting 概率大降。
一个排查清单(看到穿插闪烁 → 定位 → 治)
你遇到”两个面闪烁穿插”时,按这个顺序走:
- 确认是不是 z-fighting:移动视角,闪烁随角度变化、且发生在两面几乎共面处 → 八九不离十是它。(若闪烁和角度无关、是贴图问题,那是别的事。)
- 看两面是否真的共面:贴花和地面、两个叠在一起的平面、双面材质的正反面——真实距离差小于深度精度能区分的阈值,就是 z-fighting。
- 优先调 near/far:把 Near 调大一点、Far 调小一点,重新看是否消失。这是最干净的解法(不动几何体)。
- 不行就 Polygon Offset:给”应该在上层”的那个材质加一点深度偏移(Unity 里在材质/shader 的
Offset参数,或PolygonOffset)。 - 贴花专用技巧:让贴花”浮”在地面前方极小距离(比如 0.001~0.01 米),人为制造深度差,比依赖偏移更可控。
- 终极手段:对数深度缓冲或升深度位数,针对大尺度开放世界(如飞行模拟、星球尺度)。
自检一下:如果有人问你”我的地面和路面白线一直在闪,怎么治”,你按上面清单,第一步会让他先确认什么?第二步会去检查什么?——答案是先确认是否共面/随角度闪,第二步去调 near/far。你能把这个排查逻辑讲给同事,这章就没白学。
26.7 深度与雾 / 裁剪的协同
我带你把”远处”这一整套路数串一下,因为你前面已经学过两半了。
- 第 7 章(摄像机):讲了近裁剪面 / 远裁剪面和视锥,意思是”相机只能看到这一块范围里的东西,范围外的直接不画”。
- 第 8 章(雾化):讲了雾,用”按距离把物体颜色混进雾色”来藏住远处、省性能、做纵深。
- 本章(深度):讲了深度缓冲,用”每个像素比谁离相机更近”来决定遮挡。
你发现没有,这三样东西是同一套”处理远处的武器”,只是分工不同:
- 裁剪面负责”远处干脆不画”——硬边界,超过 Far 的一律丢弃。
- 雾负责”远处即使画了,也糊进背景色”——软过渡,遮丑、藏边界、做空气透视。
- 深度缓冲负责”画出来的东西之间,谁压谁”——裁决每一颗像素的胜负。
它们配合起来才是完整的”远处策略”:近处,深度缓冲认真裁决每个像素谁前谁后;中远处,雾开始把东西往背景色融;最远处,裁剪面一刀切掉,连画的资格都没有。工程师用这三层,既保证了遮挡正确、又省了性能、还藏住了世界尽头。
这里有个面试很喜欢问的角度:深度缓冲和雾,虽然都”和距离有关”,但干的活完全不同——深度缓冲是决定”遮挡胜负”的裁判(用深度值比大小),雾是决定”颜色被冲淡多少”的化妆师(用距离算混合比例)。一个管”谁在上”,一个管”多清楚”。别混为一谈。
26.8 收个尾,连回主线
咱们把这一章扣回全书的钥匙:图形学里很多”自动正确的事”,背后都是工程师用一个巧妙的数据结构或一套规则,把”看似要全局排序”的难题,降维成”局部逐个裁决”。
- 自然现象 / 问题:三维世界里物体互相遮挡、穿插,按”先画谁后画谁”的画家算法会在交叉和排序时崩溃。
- 工程师的方案:深度缓冲(Z-buffer)——每像素存一个”当前最近深度”,新片元更近才覆盖,裁决与提交顺序无关。
- 落进引擎的参数/设置:Camera 的 Near / Far Clip Planes(决定深度量程与精度分布)、深度缓冲位数、Polygon Offset、对数深度缓冲、深度测试开关。
- 协同:深度缓冲 + 裁剪面 + 雾,是处理”远处与遮挡”的三件套。
你带走的最核心一句话:“谁在前谁在后”不由画的顺序决定,而由每个像素的深度比较决定。 理解到这一层,你以后看到任何”两个面闪烁穿插""物体莫名被吃掉""远处精度稀烂”的现象,脑子里立刻能定位到深度缓冲这一层,而不是瞎调材质。
下一章,我们把”一个三维顶点怎么最终落到屏幕像素”这条完整的流水线,从头到尾走一遍——你会看到本章埋的”齐次坐标 / 透视除法 / w”那个钩子,是怎么在第 27 章被亲手兑现成”近大远小”的。预习一下:那条流水线有六道关卡,名字分别是 Model、View、Projection、裁剪、透视除法、Viewport。
【思考题】
- 画家算法为什么会在”两个物体互相交叉咬合”时出错?它的根子问题是”在哪一个粒度上排序”?深度缓冲是怎么换一个粒度来破局的?
- 深度缓冲(Z-buffer)存在显存的哪、每个像素存什么、它自己参不参加最终显示?用”选秀打分板”之外的一个生活类比,再描述一次它的工作原理。
- 什么是 z-fighting?它的两个主要成因分别是什么?为什么”把 Near 调大”反而能让远处的 z-fighting 变好(这个反直觉点怎么解释)?
- 假设你做一个塔防游戏,地面贴了一张”可建造区域”的半透明贴花,结果贴花和地面交界处一直闪烁穿插。请你按本章的排查清单,一步步说出你会怎么定位和治理。
【参考答案】
- 画家算法在”物体级”排序:它试图给整个物体排一个”先画后画”的全局顺序。但两个物体交叉咬合时,红环的 A 段比蓝环近、B 段又比蓝环远,“整个红环”和”整个蓝环”谁远谁近排不出确定的先后——无论怎么排,总有一段会被错误覆盖。根子问题是:它用”物体”这个太粗的粒度去解决本质上是”每个像素各自远近不同”的问题。深度缓冲破局的粒度是像素级:不再排整个物体的顺序,而是每颗像素各自比”谁离相机更近”,于是红环该在前的像素红胜出、蓝环该在前的像素蓝胜出,交叉自然正确,与提交顺序无关。
- 深度缓冲是一张和屏幕分辨率相同的隐藏图层,存在显存里;每个像素存一个”当前胜出者离相机的深度值”(归一化浮点数,0 近裁剪面、1 远裁剪面);它不参与最终显示,只当裁判决定哪个颜色能写进颜色缓冲。另一个生活类比:像停车场每个车位挂一块”当前最近离入口的车牌号”小牌。新车(新片元)进场,看门人比一比它的距离,比牌上更近就换牌并放行入位(写入颜色),否则拒之门外(丢弃)。观众(你)只看到停好的车,那块小牌从不展示。
- z-fighting 是两面几乎共面时,深度值太接近导致像素级反复翻转、闪烁穿插。成因一:深度精度近密远疏(远处的”尺子刻度”太疏,真实距离差在精度里分不出来,深度测试反复横跳);成因二:near/far 比值过大(Far 太大把有限精度摊薄,远处分辨力暴跌)。反直觉点:把 Near 调大,看似”近处更粗”,但因深度精度分布是近密远疏,把 Near 推远等于把”密的段”腾出来给中远处用,整体远处可用精度反而提升,所以远处 z-fighting 变好。前提是你没有”贴脸”的内容被 Near 裁掉(否则就要用双摄像机 trick)。
- 按本章清单:①确认是否真 z-fighting——移动视角看闪烁是否随角度变化、且发生在贴花与地面共面处;②检查两面是否真共面(贴花 z 和地面 z 差小于精度阈值);③优先调 near/far——Near 调大、Far 调小,看是否消失(最干净);④不行给贴花材质加 Polygon Offset(深度偏移)让它”假装更近”;⑤贴花专用:把贴花整体沿法线”浮”离地面极小距离(0.001~0.01 米)制造人为深度差;⑥若仍不行且是大尺度场景,考虑对数深度缓冲。塔防这种小场景,通常③或⑤就能解决。