4、GPU渲染管线(下):纹理映射、深度测试、混合、帧缓冲,以及现代GPU的可编程着色器架构

好,咱们接着聊渲染管线的后半段。上一章我们把顶点处理完了,图元也装配好了,光栅化也把像素点给算出来了。接下来这些像素要经历什么?说白了,就是「怎么贴图」、「谁在前面谁在后面」、「颜色怎么混在一起」,最后「存到哪里去」。这几个环节,每一个都是坑,我当年踩过不少。

4.1 纹理映射:给模型穿上衣服

光栅化之后,每个像素都拿到了自己的屏幕坐标。但光有坐标没用,你得告诉它「这个像素该是什么颜色」。纹理映射就是干这个的。

纹理映射的核心,是把纹理坐标(UV坐标)映射到纹理图像上,采样出颜色值。你想想看,一个三角形有三个顶点,每个顶点都有一个UV值。光栅化的时候,像素的UV是通过顶点UV插值算出来的。嗯,这里要注意:插值是在屏幕空间做的,不是世界空间。透视校正插值,这个细节很多教材一笔带过,但实际做硬件的时候,这个除法器占的面积可不小。

关键点:纹理采样需要处理Mipmap层级。为什么要用Mipmap?因为一个像素可能对应纹理上的多个纹素,直接采样会产生锯齿。我建议你记住这个原则:像素覆盖的纹素面积越大,用的Mipmap层级就越高。

我在项目中遇到过一个问题:纹理采样时,边界上的纹素会「溢出」,导致出现奇怪的条纹。后来发现是纹理寻址模式没配好。常见的模式有:

  • 重复(Repeat):UV超出[0,1]范围时,取小数部分循环
  • 镜像(Mirror):像镜子一样来回反射
  • 钳位(Clamp):超出范围就取边界值

还有一个容易被忽略的点:各向异性过滤。各向异性过滤不是所有场景都需要,但如果你在做地形渲染,斜着看地面的时候,普通Mipmap会糊成一片。各向异性过滤能根据视角方向,在多个方向上采样,效果明显好很多。当然,硬件开销也大——我记得当年做芯片面积评估时,各向异性过滤单元占了纹理单元将近30%的面积。

4.2 深度测试:谁在前面谁说了算

纹理映射完了,像素有了颜色。但场景里可能有多个物体,一个像素位置可能对应多个片元。谁该显示出来?深度测试就是解决这个问题的。

深度测试的原理很简单:每个片元都有一个深度值(Z值),跟深度缓冲区里存的值比一比。如果当前片元更靠近相机,就通过测试,同时更新深度缓冲区。否则就丢弃。

但这里有个坑:深度值的精度问题。深度缓冲区通常是24位或32位浮点数,但投影变换后的深度值不是线性的。靠近近裁剪面的深度变化大,远处的变化小。这就导致远处物体的深度测试容易出问题——Z-fighting现象。

避坑指南:我曾经在一个项目中,场景远处的地面一直在闪烁。排查了半天,发现是深度缓冲区精度不够。解决方案有两个:一是使用反向Z(Reversed-Z),把近远裁剪面反过来,让精度分布更合理;二是用32位浮点深度缓冲区。我个人习惯用反向Z,效果立竿见影。

深度测试还有一个优化技巧:Early-Z。传统管线是先做像素着色,再做深度测试。但很多片元其实根本通不过深度测试,白做了像素着色。Early-Z把深度测试提前到像素着色之前,能省掉大量无效计算。不过要注意:如果像素着色器里修改了深度值,Early-Z就不能用了。硬件上会有一个标志位来控制这个行为。

4.3 混合:透明物体的艺术

深度测试处理不了透明物体。为什么?因为透明物体需要跟后面的物体颜色「混在一起」。这就是混合(Blending)要做的事。

混合操作发生在像素着色之后、写入帧缓冲之前。它的公式是:

最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子

源颜色是当前片元的颜色,目标颜色是帧缓冲里已有的颜色。源因子和目标因子可以配置,常见的组合有:

混合模式 源因子 目标因子 效果
普通透明 源Alpha 1 - 源Alpha 标准透明度混合
叠加 1 1 颜色叠加变亮
相乘 目标颜色 0 颜色相乘变暗

混合有一个大坑:顺序依赖。透明物体必须从远到近渲染,否则混合结果不对。为什么?因为混合公式假设目标颜色已经是后面所有物体的混合结果。如果先画前面的透明物体,再画后面的,后面的颜色会盖住前面的——这就错了。

我记得有一次做UI渲染,半透明窗口总是显示不对。查了半天,发现是渲染顺序反了。后来我养成了一个习惯:所有透明物体单独排序,从远到近渲染。不透明物体先画,深度测试正常开;透明物体后画,深度测试只读不写。

4.4 帧缓冲:最终的目的地

所有像素处理完了,总得有个地方存起来。帧缓冲(Framebuffer)就是干这个的。它通常包含颜色缓冲、深度缓冲、模板缓冲。

颜色缓冲存的是最终要显示的颜色。深度缓冲我们刚聊过。模板缓冲用来做更精细的遮罩控制——比如在某个区域内才允许绘制。

现代GPU支持多个颜色缓冲,这叫MRT(Multiple Render Targets)。像素着色器可以同时输出多个颜色值,分别写入不同的颜色缓冲。这个技术在做延迟渲染(Deferred Rendering)时特别有用:一次渲染把位置、法线、颜色等信息都存下来,后面再做光照计算。

个人经验:帧缓冲的带宽消耗非常大。一个1920x1080的帧缓冲,32位颜色,60帧每秒,光颜色缓冲就需要约500MB/s的带宽。再加上深度缓冲、模板缓冲,轻松上GB/s。所以硬件设计时,帧缓冲通常用Tile-based架构,把屏幕分成小块,每块在片上缓存里处理完再写回显存。这样可以大幅降低带宽需求。

还有一个概念:交换链(Swap Chain)。显示器的刷新是连续的,但GPU渲染是一帧一帧的。为了避免画面撕裂,通常用双缓冲或三缓冲。前缓冲显示,后缓冲渲染,渲染完交换。三缓冲多了一个缓冲,可以减少等待时间,但会增加延迟。

4.5 现代GPU的可编程着色器架构

聊完了固定功能管线,咱们看看现代GPU是怎么做的。说白了,现代GPU把渲染管线的很多环节都「可编程化」了。顶点着色器、像素着色器、几何着色器、计算着色器……这些都是可编程的。

可编程着色器的核心是SIMT(Single Instruction, Multiple Threads)架构。什么意思?就是一条指令,多个线程同时执行。每个线程处理一个顶点或一个像素。但线程之间可能有分支,导致执行效率下降。嗯,这里要注意:分支越少,性能越好。

我建议你理解一下着色器核心的组成:

  • ALU(算术逻辑单元):做加减乘除、三角函数等
  • SFU(特殊函数单元):处理指数、对数、平方根等复杂运算
  • 纹理单元:负责纹理采样、过滤
  • 寄存器文件:存储线程的临时变量
  • 共享内存:同一线程组内的线程可以共享数据

现代GPU的架构演进有几个趋势:

  1. 统一着色器架构:顶点着色器和像素着色器用同一套计算单元,动态分配资源。这样不会出现顶点太多像素空闲,或者反过来。
  2. Tile-based渲染:把屏幕分成小块,每块在片上缓存里处理完再写回。带宽省了,但需要额外的几何处理阶段。
  3. 计算着色器:不局限于图形渲染,可以做通用计算(GPGPU)。深度学习、物理模拟都能跑在GPU上。

我曾经参与过一个GPU架构设计项目,当时在纠结要不要用统一着色器。老架构是分离的,顶点和像素各有固定数量的单元。结果发现,在复杂场景下顶点单元不够用,简单场景下像素单元闲置。后来统一了,资源利用率从60%提升到了85%以上。这个数据我记得很清楚。

最后说一个容易被忽略的点:着色器编译。GPU不直接执行高级语言(如GLSL、HLSL),而是先编译成中间表示,再转成机器码。驱动里有一个JIT编译器,负责这个工作。如果着色器编译优化不好,性能会大打折扣。我建议你在写着色器时,尽量少用动态分支,多用常量折叠和内联函数。这些优化,编译器不一定能帮你做好。

好了,渲染管线的核心内容就这些。从顶点到像素,从纹理到深度,从混合到帧缓冲,每一步都有它的设计哲学和工程陷阱。下一章我们会聊更底层的GPU微架构,包括线程调度、内存层次、缓存一致性等。到时候再细说。