3、光栅化阶段:像素填充率的秘密,Early-Z与Hi-Z的实战应用
光栅化阶段,说白了就是把顶点数据变成屏幕上的像素点。很多开发者觉得这步是GPU自动完成的,没什么好调的。嗯,我以前也这么想,直到被一个项目的帧率问题折磨了两周。
那次项目其实很简单,一个3D场景,模型面数不算多,但帧率死活上不去。我查了半天,发现GPU的像素填充率已经爆了。你想想看,一个像素可能要经过几十次甚至上百次的Overdraw才能最终显示出来,这性能能好吗?
所以今天我们就来聊聊像素填充率的秘密,以及Early-Z和Hi-Z这两个救命稻草怎么用。
3.1 像素填充率:GPU的隐形天花板
像素填充率,就是GPU每秒能处理多少个像素。单位是GPixel/s。公式很简单:
像素填充率 = 核心频率 × 像素管线数量
举个例子,Adreno 650的频率是587MHz,有4个像素管线,那它的像素填充率就是:
587 MHz × 4 = 2.348 GPixel/s
听起来挺高的对吧?但实际项目中,一个1080p的屏幕有207万像素。如果每帧有10层Overdraw,那每帧就要处理2070万个像素。60帧的话,每秒就是12.4亿个像素。嗯,已经接近极限了。
关键点:像素填充率是GPU的硬性指标,一旦超过,帧率就会断崖式下降。不是慢慢掉帧,是直接卡成PPT。
我在项目中遇到过最夸张的情况,一个场景的Overdraw达到了30层。你想想看,一个像素被画了30次,前29次都是白费的。那款手机GPU的像素填充率只有1.8 GPixel/s,1080p@60fps根本跑不动。最后只能砍特效,降低分辨率。
3.2 Early-Z:提前淘汰,减少无效绘制
Early-Z,全称是Early Depth Test。它的原理很简单:在像素着色器执行之前,先做一次深度测试。如果这个像素被挡住了,那就不用执行像素着色器了。
没有Early-Z的时候,流程是这样的:
- 顶点着色器处理顶点
- 光栅化生成像素
- 像素着色器计算颜色
- 深度测试,被挡住就丢弃
有Early-Z的时候:
- 顶点着色器处理顶点
- 光栅化生成像素
- 先做深度测试,被挡住就直接丢弃
- 只有通过测试的像素才执行像素着色器
你看,像素着色器是GPU里最耗资源的阶段。Early-Z能提前砍掉大量无效像素,节省的带宽和计算量非常可观。
实战技巧:Early-Z生效的前提是,你的绘制顺序是从近到远。如果从远到近画,那Early-Z基本没用,因为远处的像素先画,近处的像素后画,深度测试永远过不了。
我曾经在一个项目中,把绘制顺序从远到近改成了从近到远,Overdraw从15层降到了3层。帧率直接翻倍。就这么简单的一个改动,效果立竿见影。
3.3 Hi-Z:更聪明的深度剔除
Hi-Z,全称是Hierarchical Depth Buffer。它比Early-Z更聪明。Early-Z是对每个像素做深度测试,而Hi-Z是对一个像素块(通常是8×8或16×16)做测试。
Hi-Z的工作原理是这样的:
- GPU维护一个深度缓冲区的层级结构
- 每个层级存储一个像素块的最大深度和最小深度
- 绘制新像素时,先检查它所在的像素块
- 如果整个块都被挡住了,那这个块里的所有像素都不用画了
这就像你查字典,先查目录,再查具体页码。Hi-Z就是那个目录,能快速定位哪些区域完全不可见。
| 特性 | Early-Z | Hi-Z |
|---|---|---|
| 测试粒度 | 像素级别 | 像素块级别 |
| 性能开销 | 较低 | 更低 |
| 适用场景 | 一般场景 | 大块遮挡场景 |
| 硬件支持 | 几乎所有GPU | 较新GPU |
注意:Hi-Z不是万能的。如果场景中都是细碎的物体,比如树叶、栅栏,那Hi-Z的效果就不明显。因为每个像素块里可能既有可见像素又有不可见像素,Hi-Z没法做整块剔除。
3.4 实战:如何利用Early-Z和Hi-Z优化性能
光说不练假把式。我们来看看实际项目中怎么用。
3.4.1 绘制顺序优化
这是最基础也最有效的方法。我建议你这样做:
// 错误的绘制顺序:从远到近
for (int i = objects.size() - 1; i >= 0; i--) {
DrawObject(objects[i]);
}
// 正确的绘制顺序:从近到远
for (int i = 0; i < objects.size(); i++) {
DrawObject(objects[i]);
}
你可能会问,为什么不透明物体要从近到远?因为近处的物体会挡住远处的,Early-Z能提前淘汰远处的像素。透明物体则相反,要从远到近画,因为透明需要混合。
3.4.2 深度预填充
这是一个高级技巧。先画一个深度通道,把场景的深度信息写进去,然后再画颜色通道。这样Early-Z和Hi-Z就能更高效地工作。
// 第一步:只写深度,不写颜色
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
DrawSceneDepthOnly();
// 第二步:正常绘制颜色
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
DrawSceneColor();
我在一个大型开放世界项目中用过这个技巧。场景里有大量的建筑和地形,Overdraw非常严重。用了深度预填充后,像素着色器的执行次数减少了60%。代价是多了一次深度绘制,但深度绘制比颜色绘制轻量得多,整体性能还是提升了。
3.4.3 避免Alpha Test
Alpha Test会破坏Early-Z。因为Alpha Test是在像素着色器里判断透明度,如果透明度不够就丢弃像素。但这时候Early-Z已经做完了,像素着色器已经执行了。
避坑指南:我曾经在一个项目中大量使用Alpha Test来做树叶的裁剪。结果Early-Z完全失效,Overdraw飙升。后来改用Alpha Blend + 排序,性能好了很多。记住,Alpha Test是Early-Z的杀手。
3.5 总结
像素填充率是GPU的硬性指标,一旦超过就会卡顿。Early-Z和Hi-Z是减少无效像素绘制的利器。核心要点就三个:
- 绘制顺序从近到远,让Early-Z发挥最大作用
- 深度预填充,提前建立深度信息
- 避免Alpha Test,别让像素着色器白干活
嗯,这些技巧看起来简单,但实际项目中能坚持做好的团队不多。你想想看,一个场景里几十万个物体,绘制顺序稍微乱一点,性能就崩了。所以,从项目一开始就要建立规范,把绘制顺序管理好。
下一章我们会聊聊像素着色器的优化,那才是真正的性能黑洞。