资讯动态

填充处理器与渲染性能优化:从带宽账本到Kite分块调度实践

发布时间:2026/10/18 8:20:01 来源:尧图企业网站定制
几个月前排查一个渲染性能问题画面很简单、三角形数也不高但帧率就是上不去。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::vectorTrianglePrimitive* triangles; }; void KiteFrameScheduler(Scene* scene, Framebuffer* fb) { // 把屏幕切成tile并按三角形包围盒丢进对应的tile std::vectorTileJob 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的经验能帮你把这一层账理清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑