几个月前排查一个渲染性能问题,画面很简单、三角形数也不高,但帧率就是上不去。Profiler一拉,GPU占用差不多满的,几个主要指标里填充负载那一格飙红——那一刻我意识到,瓶颈不是顶点,不是Shader,而是最容易被忽略的填充处理器。接着就动手把项目里那套老旧的像素填充模块重写了一遍,代号就叫Kite。这篇文想写的,就是这个名为Kite的填充处理器背后的原理、设计取舍和实测经验。
如果把渲染比作一条流水线,顶点阶段是"备料",光栅化是"裁剪布料",填充处理器就是"真正把布料缝成衣服"的那道工序。它决定了每一个像素最终以什么颜色出现在屏幕上,也经常决定了你的GPU是60帧还是30帧。这篇文章不打算讲那种"今天带你看懂GPU"的大而全,就专门围着填充处理器这个点,说清楚它的职责边界、内存带宽账单、overdraw治理、并行调度,以及拿到Profiler数据之后应该怎么判断和下手。
适合谁看?做实时渲染、写游戏引擎、被性能瓶颈折磨过的开发者都应该有用。即使你平时主要调Shader不碰底层,我也建议把填充这一层的账算明白——你会少走很多弯路。
1. 先弄清填充处理器在渲染管线里做的是哪一段
1.1 从三角形到像素颜色,中间隔着一整套"生产线"
很多人提到渲染,脑子里只有两个阶段:顶点变换和像素着色。实际从三角形变成屏幕上最终的颜色,中间隔着光栅化、插值、深度模板测试、颜色混合、写回帧缓冲这一长串工作。顶点阶段决定三角形的空间位置,光栅化把三角形离散成片元,真正负责对片元逐个"定生死"、把结果写进颜色缓冲和深度缓冲的,就是填充处理器。
Kite作为填充模块,落在管线里的位置大致是这样:顶点处理结束后,三角形交给光栅化器,生成带有一堆属性插值的片元;这些片元进入填充处理器,先做深度测试和模板测试,通过后再执行像素着色;着色结果经过混合,最终写入颜色缓冲。也就是说,在像素着色器之前和之后各有一大段逻辑,都是填充处理器在管。
这里有个新手容易绕进去的概念:片元(Fragment)和像素(Pixel)。片元是"候选像素",一个显示像素可能对应多个片元,比如半透明物体一层叠一层,或者开了多重采样。填充处理器的任务之一,就是在一大堆候选片元里选出谁有资格成为最终的像素。它的裁决依据是深度、模板、混合方式以及Shader是否丢弃片元。所以把填充处理器单纯理解成"填颜色的地方"是远远不够的。
1.2 填充处理器不只是填颜色:深度、模板、混合都是它的活
Kite内部我把工作拆成四块:深度测试、模板测试、颜色混合、结果输出。深度测试负责把被挡住的片元干在像素着色之前(能省则省);模板测试负责给"只画特定区域"这种玩法做标记,比如画阴影贴图、镜面反射的Stencil裁剪;颜色混合负责半透明、发光、后期特效里的加法混合;输出阶段处理MSAA解析、色彩压缩、写回显存。
举个例子:做一个半透明的玻璃门,玻璃前面的一个角色没法用Early-Z挡掉,因为半透明物体通常都排在不透明物体后面画。填充处理器面对这个片元时,颜色缓冲里已经有一部分背景颜色,它要做的不是简单覆盖,而是按Alpha做混合。这个过程会把颜色缓冲读出来、跟新颜色混合、再写回去。一次混合就是一次额外的显存读写,这账我在后面会展开算。
在Kite的代码里,我把这几个阶段拆成独立的处理链表,每个片元按顺序走过这些阶段。这样设计的好处是每个阶段都能单独做profile,出性能问题时能一眼看出是深度测试卡还是混合卡。
1.3 为什么单独为它起名叫"处理器"
硬件规格里经常看到像素填充率(GPixel/s)、纹理填充率(GTexel/s),这两个指标说的就是填充处理器的吞吐能力。每颗主流GPU每秒能填几十亿甚至上百亿个像素,但它不是凭空算出来的——它有自己的一级缓存、二级缓存,有专门的算术单元做深度比较和混合,有独立的调度器分配像素色块。把这套东西称作"处理器",并不夸张。
Kite在软件层面模拟的就是这个小处理器。它不负责顶点变换,也不关心几何裁剪,只盯住"从片元到像素"这一段。这样划分边界有一个很实际的好处:出问题时能快速定位。如果三角形数没变、Shader没改,只是改了屏幕分辨率帧率就掉一半,那九成是填充处理器的负载上来了;如果加了几个超大模型帧率崩了,那多半是顶点和几何阶段的问题。职责划分越清楚,性能归因越简单。
2. 每一个像素都在烧带宽:填充分级的内存账本
2.1 一张1080P画面,60帧要搬动多少字节
很多人优化时只盯着Shader里的指令数,却忘了一个更硬的约束:内存带宽。GPU和显存之间的通道就那么大,一帧画面每个像素搬几趟数据,算清楚你会吓一跳。
拿最便宜的满屏绘制来算:1920×1080约207万个像素。颜色缓冲RGBA8占4字节,深度缓冲通常也按4字节算。如果一个像素顺利通过深度测试,至少发生一次颜色写入(4字节)、一次深度读取(4字节)、一次深度写入(4字节),总共至少12字节。一帧就是207万×12≈24.9MB。按60帧算,光是这组读写就要约1.5GB/s的带宽。注意这还没算纹理采样、模板、混合、MSAA。纹理采样一次24位全彩256×256的贴图约0.2MB,采两三次就是几十MB一帧,再乘上60帧,数字很快跳到几十GB/s。
所以Kite的第一条设计准则就是:少碰内存,碰了也要尽可能批量碰。单独处理一个像素是不可能的,硬件里填充处理器的缓存和写入合并机制,本质上就是想把一堆相邻像素的访问合并成几次大的突发传输。软件模拟时,我们最该学的正是这种"局部性"思维。
2.2 Kite在内存访问上有哪几个死规矩
我在Kite里定了几条访问规矩,实测下来很管用:
- 按行和按块遍历,不要跳着访问像素。填充片元的顺序尽量保持扫描线方向,让缓存预取能命中。
- 深度缓冲和颜色缓冲分开管理,避免同一缓存行反复被污染。
- 能写在局部缓冲就不写全局缓冲。分块渲染下先写进tile内部的临时缓冲,整块处理完再一次性写回。
- 不实时做像素压缩,但会在块边界做好合并,让写入尽量整块发生。
这些规矩的本质是尊重缓存行。举个反例:如果你按三角形列表的顺序逐个处理,两个三角形在屏幕上隔得很远,那么深度缓冲和颜色缓冲的缓存行会被来回踢出,带宽开销成倍上涨。把场景按屏幕区域组织成tile之后,同一块区域里的三角形一起处理,缓存命中率明显提升。
2.3 压纹理与颜色压缩:带宽账单里能省出的大头
填充处理器的带宽消耗大头除了帧缓冲,就是纹理采样。解决方案早就有:用压缩纹理格式。无论是桌面常用的BC系列还是移动端的ASTC、ETC2,都能把贴图体积压到原来的四分之一甚至更低。但纹理压缩不是简单的"把图片存小一点"——它要求长宽满足块对齐,比如BC1是4×4的块一个64位的编码,所以贴图尺寸一般都要求4的倍数,压缩前得先把资源处理好。
颜色缓冲本身也能压缩。现代GPU普遍支持delta color compression,就是对同一块区域里相邻像素的颜色做差分编码。大面积纯色或者渐变色的天空、地面,压缩率非常可观。Kite里虽然没有硬件级的DCC,但我保留了类似的逻辑:检测到tile内颜色变化很小时,标记为"可压缩",在写回时只写差异数据,别把整块颜色都从临时缓冲里搬出去。
这个思路放到工程上很简单:给帧缓冲加一个"脏区块标记",只有真正被修改到的tile才参与输出。如果你做了全屏模糊或者渐变色背景,大部分tile内容根本没变,这个标记能直接砍掉大量无效写回。
3. 隐藏像素也在支付:overdraw与Early-Z的博弈
3.1 overdraw的三种常见来源
Overdraw,也就是同一个显示像素被填充了多次,是填充处理器最常见的超载原因。它比Shader复杂更能消耗性能,因为每次重复填充都可能触发深度读写、颜色读写。结合Kite的调试统计,overdraw主要来自三个地方。
第一是半透明粒子系统。一团火焰特效,几百个粒子叠加在同一屏幕区域,每个粒子都会对像素做颜色混合,哪怕粒子本身几乎透明,填充次数却一点不少。第二是UI和多层场景叠加。尤其手游里界面、特效、场景三层叠在一起,每层都往颜色缓冲里写一遍。第三是地形或植被的图层混合,一张地面用多张贴图按高度混合,远处看着没什么,实际每个像素被填了四五遍。
我习惯用一个具体指标衡量overdraw:帧缓冲平均填充次数。如果这个值大于2,基本可以判定填充部分有优化空间。但要注意:overdraw不等于"坏",半透明本身就需要重复填充,关键是搞清楚哪些重复是视觉必需的,哪些是可以用技术手段消掉的。
3.2 Early-Z能在填充前挡住多少无用功
Early-Z是填充处理器里最值钱的功能之一。原理很直白:在像素着色之前先做一次深度测试,深度不过的片元直接丢掉,不进入Shader,不做颜色混合,也不写颜色缓冲。
一个场景里几十万个三角形,前面画了一面墙,墙后面的地面、物品如果按"先地面后墙壁"的顺序绘制,每个被挡住的像素都得白算一整段Shader。开了Early-Z之后,这些像素在Shader前就被淘汰,负载能降一半还多。这就是为什么GPU厂商拼命宣传Early-Z,因为它对真实场景的收益实在太明显。
但Early-Z有失效场景,这点必须记住。像素Shader里带discard(比如做Alpha Test、植被边缘裁剪),GPU就不知道这个片元最终存不存在,只能等到Shader跑完再回头做深度测试,相当于Late-Z。Shader里写SV_Depth也会打断Early-Z的优化。还有渲染目标开了alpha-to-coverage等特性时,Early-Z同样可能被旁路。Kite在检测到这类Shader标记时,会主动打出警告日志,提醒开发者在能避免的场合避免它们。
3.3 绘制顺序怎么排:从底层到顶层
既然Early-Z能挡片元,那最好的策略就是先让深度缓冲变得"密不透风",挡住后面所有不透明物体。实际操作分两步。
第一步,把不透明物体按从前到后排序绘制。这听起来和直觉相反(通常从远处往近处画才对),但为了Early-Z最大化,先画最靠近相机的物体,让深度尽早写上,后面的远物体大片被挡。第二步,半透明物体最后画,而且从远到近。因为半透明需要和已有颜色做混合,必须保证颜色缓冲里已经存在内容;从不透明物体的"后表面"往"前表面"排,混合结果才正确。
Kite在提交绘制命令时会做一次排序预处理,把对象标记成"写深度但不混合"和"混合但不写深度"两类,然后生成绘制序列。实测中,一个场景如果原来overdraw在3.2,做好排序和Early-Z之后能降到1.6左右,帧时间直接砍掉三分之一。这个优化优先级极高,几乎不需要额外成本。
4. Kite的并行填充设计:从逐像素扫荡到分块调度
4.1 每像素独立是填充处理器的天赐优势
填充处理器天然适合并行,因为每个像素的计算只依赖它自己的输入、深度缓冲、纹理采样结果,像素之间几乎互不干扰。硬件里为什么把像素分组扫描?因为它可以同时处理四个甚至更多相邻像素,共享部分插值计算,一次fetch纹理喂给多个像素。
Kite在软件层做的第一件事是确认这一点:不要一次处理一个像素,而是按2×2的像素块为单位组织数据。深度、颜色、UV插值都能以向量方式一次性运算。这个2×2的布局还有一个额外价值:很多GPU在计算像素导数时需要一个2×2块内相邻像素的差值,用于纹理LOD偏导计算。把2×2作为一个基本调度单元,等于顺带把纹理滤波需要的导数信息也备齐了。
4.2 分块调度比扫描线更吃得开
一个填充处理器如果真从屏幕左上角扫到右下角,你会发现缓存命中率并不高,因为三角形的分布是局部的,扫描线顺序却会把访问范围拉得很大。Kite采用16×16像素的tile作为基本调度单位,每个tile内的三角形先被收集起来,然后统一处理这个tile里的所有片元。
这么做的好处很直接:深度缓冲和颜色缓冲同时被多个三角形访问时都在这个tile的局部缓存里,带宽需求大幅下降。移动端架构尤其依赖这种思路,整块渲染目标驻留在片上内存里,画完再统一写回显存。桌面GPU的Immediate Mode虽然不同,但同样在缓存层面践行了分块思维。
Kite的调度器持有一个tile工作队列,每个tile是一个独立的作业项。只要发现tile里有待处理的三角形,就把它放进队列,分发给多个并行线程处理。因为tile之间不存在显式的数据依赖,这个调度模型在多核CPU上接近线性扩展,我在测试机上用8线程跑4线程负载,吞吐量差不多翻倍。
4.3 Kite的接口与调度骨架
为了让你对实现有个直观认识,贴一段Kite调度器的简化骨架:
#define TILE_SIZE 16 struct TileJob { int tileX, tileY; std::vector<TrianglePrimitive*> triangles; }; void KiteFrameScheduler(Scene* scene, Framebuffer* fb) { // 把屏幕切成tile,并按三角形包围盒丢进对应的tile std::vector<TileJob> jobs; for (TrianglePrimitive* tri : scene->triangles) { AABB box = ComputeBoundingBox(tri, fb->width, fb->height); for (int ty = box.minY / TILE_SIZE; ty <= box.maxY / TILE_SIZE; ty++) { for (int tx = box.minX / TILE_SIZE; tx <= box.maxX / TILE_SIZE; tx++) { jobs[tx + ty * tileCountX].triangles.push_back(tri); } } } // 并行处理所有tile,每个tile内部再逐像素填充 ParallelFor(jobs, [&](TileJob& job) { TileBuffer tile = fb->GetTileBuffer(job.tileX, job.tileY); for (TrianglePrimitive* tri : job.triangles) { RasterizeAndFill(tile, *tri); } tile.FlushToFramebuffer(); }); }这份代码省略了三角形遍历、深度测试和混合细节,但调度骨架是完整的。它首先建立三角形与tile的映射,然后按tile独立处理。注意FlushToFramebuffer这一步,把tile局部缓冲里的最终结果一次性写回,这是带宽优化的关键。
4.4 并行度之外容易被忽视的副作用
分块调度不是没有代价。三角形跨越tile边界时,同一个三角形可能在两个tile里各被光栅化一次;如果三角形非常细长,跨越十几个tile,这个额外开销会被放大。Kite里针对这类三角形做了特殊处理:检测到包围盒远大于实际面积时,退回按扫描线填充,避免tile重复遍历。
另一个容易被忽视的点是动态状态切换。填充处理器要频繁切换混合模式、深度写入开关、纹理绑定。如果tile调度把状态变化很频繁的三角形聚在一起,缓存命中率虽然高了,但状态切换开销会吞噬掉收益。Kite在tile内部会先按材质和状态排序,减少状态切换次数。这招放到工程上叫"状态排序渲染",老团队的优化清单上都有,但它和分块调度结合时收益更明显。
5. 用数据确认填充瓶颈:拿到Profiler结果后的判断与实操
5.1 三个特征帮你锁定"填充瓶颈"
做性能优化最怕方向错了,花三天优化Shader,结果是填充瓶颈,白忙。我用三个特征快速锁定是不是填充处理器的锅。
特征一:分辨率敏感。把分辨率从1080P降到720P,帧率明显回升,大概率是填充瓶颈,因为填充负载和像素数强相关。特征二:三角形数量没怎么变,只是画面里的特效、半透明层或者MSAA倍率增加,帧率掉了,那多半填充也掉了。特征三:看Profiler里像素填充相关指标,比如GPU利用率高但几何阶段没有明显占用,Shader ALU也不是满的,唯独输出合并阶段占用接近上限。
反过来,如果降低分辨率帧率纹丝不动,改了几何数量帧率立刻反弹,那瓶颈在顶点或几何阶段,往填充方向优化就是白费力气。我在实际项目里见过有人对着一个纯几何负载高的场景猛砍overdraw,忙了一周帧率没有变化,就是因为方向错了。
5.2 常用优化清单与生效条件
这里整理一张Kite实战中反复用到的优化对照表:
| 优化手段 | 核心原理 | 适用条件 | 副作用/注意点 |
|---|---|---|---|
| 绘制顺序调整 | 尽早写深度,最大化Early-Z | 场景中不透明物体占比高 | 半透明物体需要单独排序,不能混在一起 |
| 压缩纹理 | 降低纹理采样带宽 | 贴图资源可重导出 | 需要保证贴图尺寸4的倍数,兼容目标平台格式 |
| 半分辨率渲染后上采样 | 像素数减半,填充负载减半 | 全屏后期、动态效果密集 | 画面会变糊,需要配合锐化或时间性重构 |
| 减少半透明层叠 | 降低overdraw系数 | 粒子、UI、多层场景 | 需要美术配合调整表现 |
| MSAA倍率下调 | 子样本数和像素负载直接挂钩 | 原本开高倍MSAA的场景 | 锯齿会明显,需要配TAA或FXAA补救 |
| 动态分辨率 | 按GPU负载实时调整像素数 | 端游、主机、移动端都适用 | 需要做好负载预估和帧率平滑 |
其中动态分辨率是我个人特别推荐的保命手段。它不解决填充效率问题,但给填充处理器设置了一个"熔断机制"。当帧时间超预算,渲染分辨率按比例下调,把填充负载压回安全线。
5.3 动态分辨率落地时的坑
动态分辨率的实现比很多人想象中麻烦。最粗暴的做法是GPU负载高时直接砍分辨率,但用户会看到画面突然糊一下。Kite的做法是分档位平滑过渡,每帧最多调整一档,避免视觉跳变。同时根据帧时间做预判,而不是等卡了再降。
判断依据我建议看GPU帧时间,而不是CPU帧时间。CPU瓶颈时动态分辨率没有意义;只有GPU忙于填充时,降低像素数才有效。实操中我把GPU帧时间和填充统计绑定,超过16ms就降一档,连续几帧低于12ms再升一档。阈值应该留一点滞回,防止来回震荡。
贴一段判断逻辑的伪代码:
void DynamicResolutionController::Update(float gpuFrameMs) { if (gpuFrameMs > 16.0f) { currentScale = Max(currentScale * 0.85f, minScale); // 降档 } else if (gpuFrameMs < 12.0f && currentScale < 1.0f) { currentScale = Min(currentScale / 0.85f, 1.0f); // 回升 } renderTarget->SetResolution((int)(baseWidth * currentScale), (int)(baseHeight * currentScale)); }这套逻辑还有个隐藏收益:分辨率降低后,不仅填充负载降了,纹理采样带宽也降了,而且MSAA的代价同样对应的像素数一起下降,属于一箭三雕。
5.4 MSAA的取舍:不要和填充处理器硬碰硬
关于MSAA,有件事我想专门提醒。很多人开着4×MSAA觉得自己画面更精致,却忽略了一个事实:MSAA的填充开销会乘以倍数因子。4×MSAA意味着每个显示像素对应4个子样本,深度测试和颜色写入都可能扩大四倍,等于让填充处理器干四倍的活。
如果场景的填充负载本来就紧张,我建议先关掉MSAA,用后处理抗锯齿替代。FXAA便宜但对子像素细节无能为力,TAA效果好一些但可能带来残影。更稳的做法是保持1024P或1080P渲染,开TAA,再叠加轻微锐化,视觉上比开4×MSAA还干净,性能却便宜不少。
TAA的诡异之处在于它会引入少量模糊,特别是纹理的高频细节,锐化滤镜能压住大部分。如果你对画质敏感,可以保留高分辨率渲染,但把TAA换成时域上采样方案,让填充负载维持在一个可控水平。记住:面画质的优先级,先保证帧率稳定,再谈锯齿。
5.5 性能验证:优化之后Kite自己会告诉你答案
Kite里内置了一套填充统计器,每帧记录写入了多少像素、深度测试淘汰了多少、tile写入次数、overdraw系数。优化做完,不要只盯着帧率,把这几项数据拉出来看:
- 写入像素数有没有下降。如果下降明显,说明你真正减少了填充负载。
- 深度淘汰率有没有上升。如果上升,说明Early-Z在干活,绘制顺序优化生效了。
- overdraw系数是不是降了。这个值最直观,2.0降到1.5,几乎等于清理掉一半无效填充。
有一次我把一个粒子爆炸场景的overdraw从6.4降到了2.1,帧时间却只降了17%,排查后发现剩余瓶颈变成了纹理带宽。这就是填充统计器的价值——它不给你虚妄的"优化成功感",而是告诉你下一步阻力迁到了哪里。
优化是一条链路,永远不是单个指标拉满就结束。Kite这套统计系统帮我在实战中少踩了很多坑,也让我敢在项目紧迫时快速做性能决策。
最后分享一个习惯。我现在遇到卡顿,不会一上来就重写Shader,而是先把像素层这条账算清楚:多少像素要被真正写进颜色缓冲、多少躲过了深度测试、纹理要采几次、满载时带宽够不够。数字对了,Kite这类填充处理器的工作量也就心中有数。
踩过几次坑之后,我最大的体会是:填充优化的本质不是炫技,而是对每一字节带宽和每一次无效像素写入的较真。渲染性能好不好,很多时候不取决于顶点有多复杂,而取决于你有没有让填充处理器白干那些看不见的活。希望这篇围绕Kite的经验,能帮你把这一层账理清楚。