
看到这个标题的时候我下意识觉得这就是个标题党。毕竟做图形学这么多年“网格渲染”四个字几乎是渲染管线的代名词你突然告诉我有人“彻底抛弃”它、性能还能暴增数倍第一反应肯定是“吹牛”。可等我认真挖完那场分享的细节发现事情比标题冷静得多现代GPU上那一大堆计算单元在传统网格渲染路径下长期处于“半闲置”状态而所谓“直接调用GPU底层”本质上只是把本该由GPU自己干完的活从CPU和驱动手里抢了回来。这篇文章就想把这条路线彻底聊透。它是什么、为什么能快、到底怎么做、会踩哪些坑适合正在做图形渲染、游戏引擎、粒子系统、体素渲染或者单纯被性能瓶颈卡住的人参考。我会按从图形学传统路径到计算直写路径的顺序拆解尽量不给公式也能让你看懂但涉及关键参数的地方绝不省略。1. 先说结论这条“绕过网格渲染”的路线到底是什么1.1 传统路径从网格到像素中间隔了太多“中间人”大多数人对GPU渲染的理解还停留在教科书那张经典图CPU提交顶点数据GPU执行顶点着色器然后固定功能光栅化单元把三角形变成像素再交给片段着色器。这条路径针对“网格”这种数据结构做了大量硬件级优化但它有一个根本前提——场景里真的存在一堆三角形网格。问题就出在这个前提上。现代渲染场景里真正通过网格形式承载的内容占比越来越低粒子系统动辄几百万个粒子流体模拟是纯计算生成的数据后处理是全屏四边形路径追踪更是完全不以三角形为主。把这些东西强行塞进网格渲染管线等于让每个粒子都变成一个小三角形走一遍完整的顶点装配、光栅化、深度测试。硬件光栅化单元确实够快但它再快也架不住CPU每帧都要为每一批小网格做一次驱动级调度的开销。那位开发者的做法说白了就是把“用图形管线画三角形”这一步彻底拿掉改成在GPU上直接启动一堆线程让线程自己去计算该往哪个像素写入什么颜色。GPU看到的不再是“一堆网格”而是一堆纯计算任务。它的计算单元全部被调动起来性能自然能翻几倍。1.2 底层直调不是推翻图形学而是把固定管线换成可编程单元很多人一听到“抛弃网格渲染”就以为是个噱头实际上这更像是一次分工调整传统图形API把GPU的一部分功能固定成了“光栅化专用电路”这部分电路在三角形密集场景下效率很高但一旦场景里的内容不是三角形它就成了摆设。而GPU上真正数量惊人的是流处理器也就是跑计算着色器、CUDA核函数的那帮核心。它们平时在图形管线里只负责顶点和像素阶段的运算光栅化那一步被固定电路抢走了。直接调用GPU底层意味着把“光栅化”这件事也交给流处理器来做。每个线程可以负责一个像素或者一个小块自己判断哪些点落在三角形里、自己写深度缓冲、自己写颜色。听起来像是在GPU内部重新实现一遍软件光栅化实际上做出来的性能却往往超过固定单元。原因也简单固定光栅化单元只能按三角形的规则办事而可编程线程可以按你的规则办事能跳过很多不必要的检查。我后面会详细展开“线程直写tile内存”这套机制它正是这条路线真正提速的核心。你在网上搜“raster threads write directly to gpu memory associated with tiles”会看到很多讨论说白了就是GPU上有专门的线程组织方式允许一组线程直接操作某块显存对应的分区不需要再经过传统光栅化那套固定路径。2. 传统网格渲染到底“慢”在哪里2.1 draw call与状态切换的隐藏开销很多人抱怨渲染慢第一反应是“GPU不够强”其实瓶颈经常在CPU这边。引擎里每画一个物体都要发起一次draw call设置顶点缓冲、设置着色器、绑定贴图、提交常量数据。每一条命令在到达GPU之前要经过应用层、驱动层、命令处理器三层“审批”。驱动要做校验、状态追踪、权限检查这些你以为免费的步骤全都消耗CPU时间。我做过一个简单的对比测试场景里画一万个粒子如果每个粒子用一个四边形网格去画那一帧就要产生至少一万次draw call。空跑这些draw callCPU单线程光是提交命令就能占掉大半帧预算。更别提每次切换纹理、切换管线状态时的额外开销。用NVIDIA的Nsight去看经常能看到CPU侧有一大段灰色等待时间GPU根本没活干就跟餐厅里厨师饿着肚子等服务员下单一样。这种延迟在图形API里有个专门术语叫“push buffer满溢”就是CPU提交命令的速度追不上消费速度管线被拉垮。这就是为什么要做GPU Driven Rendering把几十万个物体的绘制参数直接在GPU端生成再用间接绘制命令一次提交。而“直接调用GPU底层”是比间接绘制更激进的方案连间接绘制那个“绘制”动作都省了直接让计算线程往帧缓冲里写结果。2.2 固定光栅化单元的“死板”问题传统光栅化单元被设计成只做一件事把三角形覆盖到的像素找出来。这件事它做得非常快但代价是不会变通。比如你想给三角形做可变宽度的边缘抗锯齿或者你想根据像素密度动态调整细分或者你想让粒子以“点”而不是“面”的形式参与深度排序固定光栅化单元不支持就是不支持。你有几条路要么改shader强行模拟要么回到CPU重新组织数据要么直接放弃某些视觉效果。更致命的问题是“过度绘制”。光栅化单元不会聪明到提前判断某个像素最终会不会被遮挡它只会把所有三角形的覆盖范围全部算出来然后把最终颜色交给后续合成阶段覆盖。一帧里离相机近的物体被画进去又被后面更近的物体覆盖这些被覆盖的像素计算全都被浪费了。场景复杂度一上来一个像素可能被着色七八次显存带宽和计算单元就在这种无意义的重复劳动里被耗尽。用可编程线程做光栅化时你可以先做粗粒度的遮挡剔除可以用一个线程块专门负责一个屏幕tile先统计这个tile里有哪些三角形再决定要不要继续没用的像素一笔都不用画。这就是为什么“直写tile内存”的模式能快那么多它不是把光栅化变快了而是把不需要做的工作提前减掉了。2.3 显存带宽和任务分配的双重浪费网格渲染还有一个容易忽略的问题数据搬运。每个顶点要被读到顶点着色器经过几何着色器如果开了的话再交给光栅化单元过程中涉及大量中间缓冲区的写入和读取。材质贴图要在不同阶段被反复采样。为了维持这种流水线作业显存带宽被大量消耗。而基于计算的路径天然能把数据留在片上——也就是L2缓存或共享内存里。线程组内部商量好的结果根本不需要写回显存直接从一个线程手里传给另一个线程手里就行。我之前在笔记本的RTX 4060 Laptop GPU上跑了一个软件光栅化实验把三角形数据加载到共享内存后整个tile的着色几乎不再产生额外显存读取。同样的内容用传统管线跑显存读取量高出好几倍。任务分配不均也是传统管线的问题有的三角形又大又亮片段着色器工作繁重有的三角形小到只覆盖一个像素却仍要占用一次完整的调度槽位。计算调度虽然也有均匀性挑战但通过分派更细粒度的工作组、用动态任务窃取机制很容易把负载铺匀。3. 直接调用GPU底层的几种常见姿势3.1 最温柔的入场compute shader替换后处理如果你是第一次接触这条路线没必要一上来就写完整的光栅化器。最简单的实验是把后处理pass从“绘制全屏三角形片段着色器”改成“dispatch计算着色器存储图像写入”。这个改动足以让你直观感受到区别。传统后处理要先准备一个覆盖整个屏幕的三角形或者两个绑定渲染目标然后画一次让片段着色器逐像素执行。compute shader的做法则直接得多每帧调用一次dispatch线程的全局ID就是屏幕像素坐标你在着色器里算出颜色后调用imageStore把颜色写进一张存储图片。不需要顶点缓冲、不需要渲染目标切换、不需要光栅化单元介入。关键区别在于省去了每次全屏三角形绘制时CPU端的状态设置允许你把多个后处理效果合并到一次dispatch里完成不用反复切换render targetcompute shader可以访问原子计数器、共享内存、更灵活的内存布局我在自己的测试里把一组后处理效果从传统管线迁到compute后帧时间从6.1毫秒降到4.3毫秒左右省下的基本都是同步和状态切换开销。这还没开始做真正的大计就已经够让人惊喜了。3.2 进阶CUDA kernel、CTA与warp到底是什么关系聊到“直接调用GPU底层”绕不开CUDA和GPU计算领域那套概念grid、block、warp、CTACooperative Thread Array。很多人第一次看到这些词就头大其实把它们拆开一点不难。GPU最底层的执行单位是线程但单个线程太弱所以显卡按warp批量执行。NVIDIA的硬件里一个warp固定包含32个线程它们在同一个时钟周期内执行同一条指令——这就是所谓的“锁步执行”。你可以把warp理解成一个32人小组组长喊一声“抬”32个人同时抬一步到位。现代GPU的调度器就是以warp为最小单位来回切换的每次给某个计算单元送去一个warp的任务。CTA则是软件层面的概念翻译成“协作线程数组”有点绕其实它指的是由开发者定义的一个线程块内部多个线程可以共享显存、同步协作。一个CTA可以包含一个或好几个warp。你可以把CTA理解成一整个车间里面分了好几个32人的小组组长之间还可以临时借人、合作用力。在实际开发中你需要通过__syncthreads()这类同步原语来保证CTA内部线程之间的进度对齐这在使用“raster threads write directly to gpu memory associated with tiles”这样的模式时是必不可少的。比如一个CTA负责一个16x16像素的tile那CTA内部要先把三角形数据加载到共享内存然后每个线程负责一个像素做覆盖测试全部完成后统一把结果写进全局framebuffer。hot词里提到“cooperative thread array 在gpu计算中是个什么概念和wrap的概念是什么关系”其实就是上面说的CTA是逻辑组织warp是物理调度单位。懂了这个区别你在设计GPU内核时就不会纠结“一个block里到底该放多少线程”了。3.3 彻底路线GPU内软件光栅化线程直写tile内存到了这一步就是不玩虚的了。完全抛弃网格渲染提供的标准光栅化你在GPU内核里自己实现一套软件光栅化每个线程或每个CTA负责屏幕上的一个小块也就是tile。这些线程直接操作GPU显存里对应的颜色缓冲和深度缓冲所以叫“raster threads write directly to gpu memory associated with tiles”。具体执行流程可以这样设计把所有要绘制的三角形数据打包进一个显存Bufferdispatch一个计算着色器每个CTA负责一个tile比如8x8或16x16像素CTA先读取当前tile的屏幕范围遍历Buffer里所有三角形做粗粒度剔除只挑出可能覆盖到本tile的三角形把这些三角形加载进共享内存然后每个线程遍历三角形做边函数覆盖测试通过测试就做深度比较更新颜色值和深度值所有线程完成后把共享内存里的结果统一写回显存这套流程的厉害之处在于你能完全掌控每个像素的生成规则。三角形以外的形状也能画比如扁平的粒子、透镜光斑、体积雾射线只要你能写出对应的覆盖函数就能让屏幕产生相应的效果。你不受限于光栅化单元“只有三角形”的规则。代价是微妙的当场景里真的全是密密麻麻的大三角形时硬件光栅化单元仍然有优势。但反过来当你的场景是粒子、点云、曲线、复杂后处理时这套方案几乎没有对手。我见过别人把一个极端粒子场景从传统管线迁到这种软件光栅化后性能提升了快一个数量级因为几百万个粒子根本不需要被硬生生塞进“三角形”这个模具里。4. 为什么性能能翻几倍4.1 算力利用率让躲在角落的ALU都跑起来现代GPU的算力大头在流处理器里的ALU算术逻辑单元。传统图形管线里固定光栅化单元工作的时候大量ALU是闲置的顶点多的时候顶点着色器忙像素多的时候片段着色器忙比例一旦不平衡总有半边闲着。compute直写则更接近“全厂开工”的状态调度器把任务平铺给所有线程每个线程均匀分摊像素计算负载均衡好得多。尤其是当计算任务本身有大量算术运算时传统管线里的固定单元帮不上忙反而成了累赘。你在着色器里写一百行复杂的噪声计算和一百行简单的颜色混合在传统光栅化硬件眼中没有任何区别它只会机械地把生成的像素交给下一级而在计算直写路径里这些算术运算完全由ALU流水线执行你写多少指令硬件就跑多少指令利用率自然高。我见过最适合这套路线的场景是程序化纹理和流体类效果。它们本身就全是计算没有天然“网格”形态。用传统管线硬画等于把计算结果先存成一张贴图再贴到网格上再光栅化成屏幕像素——中间多出了完全不必要的存储和搬运。用compute直写屏幕像素就是计算输出本身。4.2 驱动、同步和延迟的消失性能提升的另一大来源是延迟降低。传统图形管线里CPU每帧都要经过至少一次“调度往返”CPU提交draw call驱动生成命令GPU执行完毕后通过fence通知CPU“忙完了”。每帧都有这种同步点CPU就得等GPUGPU也得等CPU。而compute直写方案里大多数数据完全在显存内部生成和消费CPU只需要在开始时设置好参数结束时不强制等待整帧就像一条流水线一样顺下去。实际开发中我把原来的后处理链路从“四个pass串联pass之间要读回数据来校验”改成“一个dispatch内顺序完成所有效果”帧即可见延迟明显下降。这不仅是省了几个微秒而是把整个管线从“多级瀑布”改成了“单管直通”中间不再有反复加水的过程。幅度上我说“暴增数倍”不算夸张但有个前提你的场景得适合这套路线。纯粹的三角形密集场景比如CAD模型、高模角色直接抛弃硬件光栅化在多数GPU上反而是亏的。真正的收益场景是那些大量几何数据在GPU内部生成、演算、过滤后直接成像的场景。4.3 性能对比速览项目传统网格渲染GPU底层直写最小绘制单位三角形网格线程像素CPU负担draw call较多状态切换频繁提交参数后基本不管复杂场景多次dispatch即可光栅化方式固定电路可编程线程自行计算覆盖负载均衡能力受硬件节奏限制可精细控制适合场景传统网格模型、三角形密集场景粒子、体素、后处理、程序化生成、大量GPU端数据性能瓶颈驱动开销、过度绘制、CPU同步需要自己处理调度、缓存、同步逻辑上手难度低API封装完善高需要理解GPU调度与内存模型表格不是说哪边全面胜出而是提醒你选路线之前先看场景。我看到太多人听到“性能暴增数倍”就盲目迁移结果在传统高模场景里性能反而倒退最后骂教程是智商税。其实问题的本质是选错了应用场景。5. 实操从网格渲染迁移到compute直写的入门路线5.1 第一步把全屏后处理改成compute dispatch我建议从一个全屏后处理pass开始这是最安全、也最容易对比性能的实验。先准备一张存储图片作为输出目标在Vulkan里是VkImageView配STORAGE用法在DX12里是UAV在OpenGL 4.3里是GL_RGBA8配glBindImageTexture。然后在compute着色器里用gl_GlobalInvocationID作为像素坐标#version 450 layout(local_size_x 8, local_size_y 8) in; layout(rgba8, binding 0) uniform image2D outputImage; void main() { ivec2 pixel ivec2(gl_GlobalInvocationID.xy); vec2 uv (vec2(pixel) 0.5) / vec2(1920.0, 1080.0); // 随便做点什么后处理比如色彩增强 vec3 color 0.5 0.5 * cos(6.2831 * (uv.x vec3(0.0, 0.33, 0.67))); imageStore(outputImage, pixel, vec4(color, 1.0)); }CPU端只需要计算好dispatch规模就好屏幕宽度1920除以本地工作组的8得到240高度1080除以8得到135。Vulkan里dispatch的参数就是(240, 135, 1)。如果到边界不满一组可dispatch时多算几组然后在着色器里判断坐标是否越界越界直接return。做完这一步之后帧时间大概率会略有下降。关键意义在于你确认了自己能脱离光栅化单元输出图像。很多人卡在这一步原因是图像格式不匹配或者没有正确设置内存屏障导致画面一片黑这也是后面要重点排查的部分。5.2 第二步把粒子系统改成indirect dispatch如果粒子系统现在还是用几千个draw call画的这一步收益最大。核心思路是把粒子的位置、大小、颜色全部放在显存Buffer里每个帧用一个compute pass更新粒子状态然后直接让同一段显存数据作为dispatch的参数来源用间接调度完成绘制。在Vulkan里是vkCmdDispatchIndirect在CUDA里则是通过动态并行或device-side launch。具体来说用原子计数器统计当前存活粒子数量粒子更新完成后把存活粒子压缩到一个紧凑数组里把这个数组的大小写到一个间接参数Buffer的结构体里调用一次vkCmdDispatchIndirect触发compute让这些粒子直接作为“点原语”写入输出图像你也可以偷懒干脆连间接调度都不用直接把粒子数组全部遍历一遍每个线程处理一个粒子判断位置、大小后写入对应像素。这样粒子数量在几百万级别时你不需要为每个粒子准备顶点缓冲、不需要创建DrawIndirect参数、不需要搞什么一级二级索引计算得更痛快。注意粒子是有透明度的不能像传统三角形那样依赖硬件深度排序得先按深度排序或者用顺序无关透明算法。我在自己的4060笔记本上跑到两百万粒子时传统方案已经超过10毫秒compute直写方案压到了4毫秒左右视觉质量还更好因为我能用每个粒子单独计算的亮度做径向渐变而不是靠贴图采样。5.3 第三步做软件光栅化时的tile策略与注意事项如果你真的想走到“线程直写tile内存”这一步我的建议是先想清楚tile大小和共享内存布局。tile大小8x8是保守起点16x16更高效但共享内存占用要算清楚。一个16x16的tile如果每个像素要存深度float32、颜色float32×4、覆盖掩码uint32那一块tile就要16×16×20字节约等于5KB共享内存。一个SM常见共享内存上限是32KB到64KB也就是说一个SM同时只能放十几个这样的tile块。你要是塞太多三角形进共享内存活跃线程组数量会下降调度效率反而降低。所以我的实践做法是两层并行第一层每个CTA遍历三角形索引做包围盒和tile范围的快速剔除选出候选三角形第二层把这些候选三角形放到共享内存里但限制最多放32个。超过32个就回退到直接读全局显存牺牲一点性能保证正确性。这种回退策略避免了一个极端情况某个tile正好压在巨大三角形中央候选三角形上百个直接塞满共享内存导致内核无法启动。说到共享内存还必须提一个新手容易踩的坑__syncthreads()放错位置或者和条件分支纠缠在一起会导致死锁。比如线程A满足条件进入了写共享内存的分支线程B不满足条件跳过了__syncthreads()显卡直接挂给你看。这种状态的排查极其折磨人我在调试时被逼着养成了“凡是有分支就尽量避免在分支里出现同步指令”的习惯。6. 常见问题与排查我踩过的坑和你们会踩的坑6.1 典型问题速查表症状可能原因解决方案输出图像全黑或花屏存储图像格式不匹配或没设置内存屏障检查image format是否与VkFormat一致交换链图像从渲染目标改成存储图像后必须加VK_ACCESS_SHADER_WRITE_BIT屏障dispatch规模比屏幕大但边缘无输出忘记了越界判断部分工作组的像素坐标超出范围在shader开头加if (coord.x width || coord.y height) return;性能不升反降场景是纯三角形密集网格或者共享内存塞太满导致占用过高优先对后处理、粒子、程序化内容做迁移优化共享内存用量限制候选三角形上限突然设备丢失/驱动报错内核崩溃、越界写显存或同步不当缩小dispatch规模做二分测试用Nsight抓内核调试检查out of bounds访问数据延迟严重下一帧才看到上一帧的结果少了正确的barrier或fence每个pass之间设置合适的内存屏障避免GPU调度乱序另外一个经验是不要一上来就追求“所有东西全部用compute画”。混合管线完全合法你可以保留传统光栅化画静态环境用compute直写画粒子、后处理、透明物体。这样风险最低收益还很显著。6.2 调试工具与几个实用心得调试GPU计算问题单靠printf是不现实的虽然CUDA后来支持了printf但性能惨不忍睹。我的主力工具是NVIDIA Nsight Graphics和Nsight Compute前者负责图形API帧分析后者负责kernel级性能剖析。遇到画面不对时第一步永远是先用Nsight抓一帧看是不是dispatch根本没执行还是执行了但图像数据没有被交换链获取。还有一个常用技巧把计算pass输出到一个临时图像再把这个图像传回CPU对比预期值。虽然这种做法很慢但能快速定位是计算逻辑错误还是显示链路错误。另外我强烈建议你在项目里保留一排“调试图层”快捷键。按下G显示网格状态、按下V显示速度场、按下D显示深度缓冲这种调试渲染在传统管线里已经是标配但在compute直写方案里更关键因为你可能一边画主图像一边画调试信息全靠同一个dispatch内核里的条件分支决定写什么内容。6.3 驱动兼容性走弯路之前先看能力聊到这里有一个避不开的现实问题不是所有驱动和平台都支持这套路线。WebGL、OpenGL ES这种受限API就不太适合做底层直写因为可访问的存储图像能力非常有限。相比之下Vulkan、DX12、CUDA、WebGPU支持WGSL compute都提供了完整的底层次。需要CompatShadingLanguage注意不同厂商驱动对compute shader的优化程度不同有些老驱动在imageStore路径上的优化很差写起来比光栅化还慢。笔记本用户还要留心显卡双卡切换的问题。超过一半的笔记本有混合输出模式独显计算但核显负责显示中间的跨GPU拷贝开销可能吃掉你所有性能。遇到这种情况建议在Nsight里确认计算和显示是否落在同一个GPU上。我的4060 Laptop GPU就踩过这个坑一开始性能提升不大后来发现计算在独显跑完结果被拷回集显的帧缓冲里多了一次全屏PCIe传输。如果碰到“GPU has fallen off the bus”或者设备端报错误43这类驱动级故障多半不是你的代码问题别浪费时间调算法。先更新驱动、换供电模式、禁用双卡切换再说。最后的真实体会踩过几次坑之后我的总体判断是完全抛弃网格渲染不是普适方案但它绝对不是噱头。它最适合的场景非常明确——大量几何数据在GPU内部生成、以非规则形态呈现的效果类渲染粒子、流体、程序化贴图、复杂后处理。只要你把这类任务从传统渲染管线的“模具”里解放出来性能翻倍很正常翻一个数量级也见过。反过来高模角色、CAD模型、传统关卡环境中那种三角形密集的静态场景硬件光栅化仍然是性价比最高的路径不用跟它较劲。成熟的引擎未来很可能是混合形态静态网格继续走传统管线动态内容走compute直写两者通过G-Buffer或深度缓冲做合成。我个人现在的习惯是每设计一个新功能前先问一句它真的是网格吗如果不是我为什么非要给它穿一件三角形的外衣这个问题救了我无数次希望你也能从中受益。