ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

渲染管线与Shader:从顶点着色器到片段着色器的分工协作

渲染管线与Shader:从顶点着色器到片段着色器的分工协作 前两篇把光照模型盘了个底朝天从Lambert讲到Blinn-Phong顺带把各种高级光照的来龙去脉也捋了一遍。但有一个问题我一直没展开聊你费劲算出来的那个颜色到底是在GPU的哪个环节里算出来的这就引出了今天这篇的核心也是图形学里最该先搞清楚的一张图——Rendering Pipeline以及在这条管线上跑的那些小程序Shader。开门见山说个结论Shdar不是孤立的一小段代码它是被安插在渲染管线某个具体位置上的“工序”。不理解管线的阶段性你写的shader就是碰运气理解了管线你看到任何一个渲染异常脑子里都能立刻定位到“哦问题出在光栅化之前”或者“这肯定是片元着色器阶段的锅”。这篇文章适合谁正在啃图形学课程、被孔令德那本《计算机图形学》的习题折磨的同学想入门shader但被各种术语劝退的引擎开发新手以及那些用Magicavoxel之类工具导模型、想在渲染端做点定制效果但不知道从何下手的实践派。看完之后你至少能回答三个问题顶点着色器和片段着色器到底谁在干活为什么法线要经过那么多变换为什么模型在屏幕上不显示的时候大家第一反应都是去查矩阵1. Rendering Pipeline的本质一条分工明确的流水线1.1 为什么一定要有“管线”这个概念先把工业化的类比摆出来。你想象一家汽车工厂原材料是钢板、玻璃、轮胎经过冲压、焊接、涂装、总装几道工序出来一辆能开的车。每道工序只干自己那点事做完交给下一道。渲染管线本质上就是这个工厂只不过原材料是三维模型的顶点数据、光源参数、纹理图片成品是屏幕上的一帧画面。图形学教材和面试题里常说的那个词——Fixed-Function Pipeline指的就是早年GPU把这条流水线上的每道工序都焊死在硬件里你只能调参数不能改流程。后来大家发现这太死板了于是有了Programmable Shader把管线里的关键工序开放出来让你自己写程序去控制。这就是Shader存在的根本原因它是你在GPU流水线上自定义的一个个小模块。这里有一点特别重要管线的执行是“分级”的。由CPU发起的叫Draw Call它告诉GPU“我要画一批三角形”这批数据会先被塞进显存里的缓冲区然后GPU按照固定的阶段依次处理。你写的顶点着色器和片段着色器只是这条流水线上的两站前后还有一堆你碰不到的固定功能单元在干活。1.2 从顶点到像素管线的七个关键阶段我把整个过程拆成七个阶段尽量用大白话描述每个阶段顺手标注它发生在什么“空间”里因为坐标系变换是理解管线的隐藏主线。顶点数据输入你的模型由一堆顶点组成每个顶点带着位置、法线、UV这些属性存放在VBO顶点缓冲对象和VAO顶点数组对象里。CPU发出一条Draw CallGPU按顺序取出这些顶点数据。这里的空间是模型自身的局部空间Model Space。顶点着色器Vertex Shader这是可编程阶段。每个顶点都会独立跑一次这个小程序干的事情主要是把顶点坐标从局部空间变换到裁剪空间顺路把法线、UV这些属性原样传递或者加工一下。空间转换靠的是MVP矩阵Model-View-Projection模型-视图-投影矩阵这也是图形学入门最绕不开的三连矩阵。图元装配与裁剪Primitive Assembly Clipping三个顶点被连成一个三角形然后GPU把视角外的部分裁掉。注意这时候还在裁剪空间所有顶点的坐标是带w分量的齐次坐标。光栅化Rasterization把连续的三角形“打碎”成屏幕上的像素网格。这一步产生了片元Fragment片元跟“像素”还不太一样等你后面看到深度测试就明白了。光栅化还会为每个片元插值出各种属性比如法线、UV、深度。片段着色器Fragment Shader可编程阶段。每个片元跑一次计算最终的RGBA颜色值。你写的绝大多数光照代码都住在这。测试与混合Tests Blending深度测试决定这个片元能不能被写进颜色缓冲模板测试和Alpha混合处理透明、遮罩等效果。能通过全部测试的片元才会最终变成你屏幕上的像素颜色。为什么要这么费劲地分阶段因为GPU是流处理器它最擅长的就是“同一套指令、海量数据”并行执行。你写一次顶点着色器GPU可能在同一时刻对几万个顶点跑同一份代码。这种架构决定了阶段拆分越细、数据流动越规整并行效率就越高。2. Shader的分工与协作谁在哪一步干什么活2.1 顶点着色器和片段着色器的“交接棒”很多人初学Shader的时候有个误解以为顶点着色器输出一个颜色片段着色器直接拿来用就行。事实上这两者之间隔着光栅化插值这一步而且这一步正是Shader灵活性的关键。顶点着色器的作用范围是“每个顶点”。它接收的是你传入的顶点属性输出的是裁剪空间坐标以及一组叫“varying”GLSL里也叫in/out的变量。这些变量在顶点之间是离散的值比如三角形三个顶点的法线各不一样。光栅化阶段会把三角形覆盖到的每个片元按照该片元在三角形内的重心坐标把这些离散值插值成连续值。然后片段着色器拿到的就是这个插值之后的版本。我用一个具体场景来说一个三角形三个顶点颜色分别是红、绿、蓝。顶点着色器把三个颜色原样传出去光栅化阶段会算出三角形内部每个片元的位置比例插值出位于红绿蓝之间的过渡色片段着色器把这个颜色直接返回。屏幕上就能看到一个平滑渐变的三色三角形。你要是把饿了么的渐变背景想压缩成三角形大概率能脑补出这个效果。重心坐标插值这件事特别重要因为它意味着顶点着色器只需要在顶点上算一次光照光栅化能帮你在中间的像素上算出平滑过渡的光照结果。但也正因为如此如果你在顶点着色器里算光照遇到光源在顶点之间急剧变化的情况画面就会呈现一块一块的马赫带Mach Band效应。所以现在的主流做法是在顶点着色器里只做坐标变换和准备属性把法线、位置、UV这些半成品数据交给片段着色器真正的Blinn-Phong光照计算全部放在片段着色器里逐像素完成。2.2 一份直白的Blinn-Phong着色器代码逐行拆解直接上段GLSL代码这是图形学所有作业与引擎Shader的“万恶之源”——Blinn-Phong逐像素光照。不是让你背而是让你看明白刚才说的数据交接和计算到底长什么样。顶点着色器#version 330 core layout (location 0) in vec3 aPos; // 顶点位置 layout (location 1) in vec3 aNormal; // 顶点法线 layout (location 2) in vec2 aUV; // 纹理坐标这次用不到但先留着 uniform mat4 uModel; // 模型矩阵模型空间 - 世界空间 uniform mat4 uView; // 视图矩阵世界空间 - 观察空间 uniform mat4 uProj; // 投影矩阵观察空间 - 裁剪空间 out vec3 vWorldPos; // 这俩将传给片段着色器 out vec3 vWorldNormal; // 注意这个法线待会还要再讲 void main() { // 先算出世界空间的世界坐标 vWorldPos vec3(uModel * vec4(aPos, 1.0)); // 法线要从模型空间转到世界空间这里只用3x3矩阵 vWorldNormal mat3(uModel) * aNormal; // 这个才是顶点着色器的正经输出裁剪空间坐标 gl_Position uProj * uView * vec4(vWorldPos, 1.0); }片段着色器#version 330 core in vec3 vWorldPos; in vec3 vWorldNormal; out vec4 FragColor; uniform vec3 uCameraPos; // 观察者在世界空间的位置 uniform vec3 uLightPos; // 光源在世界空间的位置 uniform vec3 uLightColor; // 光源颜色 void main() { // 忘掉归一化就会看到“会呼吸”的诡异光照 vec3 N normalize(vWorldNormal); vec3 L normalize(uLightPos - vWorldPos); vec3 V normalize(uCameraPos - vWorldPos); vec3 H normalize(L V); // 环境光 float ambientStrength 0.15; vec3 ambient ambientStrength * uLightColor; // 漫反射 float diff max(dot(N, L), 0.0); vec3 diffuse diff * uLightColor; // 高光 float specStrength 0.8; float shininess 64.0; float spec pow(max(dot(N, H), 0.0), shininess); vec3 specular specStrength * spec * uLightColor; // 一个偷懒的“白色物体”假设 vec3 baseColor vec3(1.0, 1.0, 1.0); vec3 result (ambient diffuse specular) * baseColor; FragColor vec4(result, 1.0); }这段代码本身就是一条浓缩的管线顶点着色器负责“把几何放到该放的位置上”顺路把世界坐标和法线打包送出去光栅化负责在三角形内部插值这些打包好的变量片段着色器在拿到插值结果后才真正干“着色”这种精细活。所有你能想到的高级效果——PBR、法线贴图、阴影、卡通渲染——本质上都是在这个骨架之上叠加算法换汤不换药。2.3 三种变量修饰符Attribute、Uniform、Varying的记忆口诀写Shader代码绕不开三种变量修饰符。很多新手一开始根本分不清我这里给个特别直接的记忆方式。Attribute在顶点着色器里是in旧GLSL版本叫attribute这是“每顶点封装的快递”。位置、法线、UV都属于这类GPU从VBO里取一个顶点就把这堆数据塞给你。它随顶点不同而不同。Uniform这是“全场通用的广播”。光源位置、摄像机位置、MVP矩阵一个Draw Call内所有顶点和所有片元看到的完全一样。它由CPU通过glUniform之类的接口传进来本质上是一次性配置。VaryingGLSL里是out/in组合这是“车间之间的半成品传送带”。顶点着色器把值写进out光栅化插值片段着色器用in接住。它变量的值在三角形内部处处不同因为每个片元的重心坐标不一样。我说个容易踩的坑很多人喜欢把材质球颜色也做成uniform这没错。但是如果你把光源方向放在varying里传然后每个片元都算一次“varying光源方向”的插值那么当光源移动时画面会出现明显的颜色滞后和插值异常因为它已经变成了逐顶点数据而不是全局配置。这种bug排查起来很花时间因为它不会报错只是画面看起来脏。3. 从“看着能做”到“真正能调”渲染管线在真实工具里的应用3.1 为什么是“写Shader”而不是“改渲染器”这个问题的答案藏在上面的管线结构里。渲染器里那些“不动的部分”——光栅化、深度测试、混合——是由GPU硬件和驱动实现的你改不了也不想改。能改的是流水线上那些可编程的插槽也就是Shader。所以“写Shader”天然成为图形开发者表达创意的主要方式。在Unity里你写一个ShaderLab文件本质上是把顶点阶段和片段阶段的GLSL或HLSL代码以及Unity帮你封装好的各种属性变量组织在一起。在Unreal里你可以用蓝图节点连一个材质导出的结果最后还是被编译成一段Shader塞进管线的这两个阶段。WebGPU里你写WGSL思路也是一样的。甚至Magicavoxel这个体素编辑器也支持用户自定义Shader来改变体素显示的颜色规则——它看起来跟游戏引擎八竿子打不着但底层逻辑依然是顶点/片元阶段的自定义。所以不要被各种引擎的语法差异吓到。你只要理解了管线这张图在Unity、UE、Godot、WebGPU之间横跳适应成本就是一两天的查文档时间。3.2 Magicavoxel的Shader是怎么回事Magicavoxel用户可能对“shader”一词有点陌生它长这样一个txt文件里写了一段GLSL风格代码被放在shader目录下启动后能用快捷键切换显示效果。这不是游戏引擎里的后处理也不是材质球而是在体素渲染器内部逐体素逐像素级别改变颜色输出的一套规则。体素模型有个特点它由大量密集的小立方体构成顶点数据极其规整每个体素的坐标天然就是纹理坐标的一部分。所以针对体素的shader更多时候是“根据位置、朝向、高度、距离光源的远近重新计算当前方块的颜色”。你在Magicavoxel里看到的陶土风格、碳素风格、半透明发光效果很多都来自这种位置驱动颜色算法。我举个简化逻辑的例子一个体素方块的世界坐标是xyz渲染器会把这个方块对应的片元数据交给自定义shadershader里可以这么干——如果y小于某个阈值就涂成深色模拟底部阴影如果坐标太偏就提高饱和度和亮度让边缘更醒目。这种“以坐标为输入、以颜色为输出”的函数式思路跟你在游戏里写普通材质是同一个脑回路只是体素模型的数据更规整算法可以更暴力、更大胆。3.3 用“孔令德图形学习题”的视角看管线提到孔令德那本图形学教材很多人的反应是“习题怎么这么多”。但你仔细看书里那些让人头疼的题考你的其实不是公式本身而是坐标系变换与管线阶段的对应关系。比如有一类经典题给你一个三维点坐标告诉你模型矩阵、视图矩阵、投影矩阵的值要你求它在屏幕上的最终坐标。这题如果死记硬背矩阵乘法顺序大概率记成“先视图后模型”然后算出个错误答案。但如果你脑子里有管线那张图就会自然地想到顶点着色器输入的是局部坐标先乘模型矩阵变成世界坐标再乘视图矩阵变成观察坐标最后乘投影矩阵变成裁剪坐标做完透视除法才得到NDC。这个顺序跟管线阶段一一对应根本不用背。再比如有一类题是问“绕任意轴旋转的旋转矩阵怎么求”看起来跟Shader无关但它在实际管线的应用是你希望物体绕着某个轴转旋转矩阵要乘在世界矩阵之前还是之后如果你的模型本身已经做了很多缩放、旋转直接乘一个旋转变换会导致奇怪的扭动因为模型空间跟世界空间的基底已经不一样了。这些问题在书本上看起来像纯数学推导实际做项目的时候它们全都变成了“矩阵乘法顺序写错导致模型旋转变形”的真实bug。所以我的建议是刷图形学习题不要满足于把答案算对每次动手推公式时多想一句“这一步对应的Shader代码在哪个阶段跑”。把题目当一张管线的地图来画收获会完全不一样。4. 调试Shader的心法先定位阶段再检查数据4.1 一个“哪哪都不对”的排查顺序写Shader遇到bug最忌讳的是一头扎进光照公式里乱调参数。我总结了一套排查顺序每次都能帮我快速缩小范围。第一步先确认顶点坐标变换对不对。方法很简单在片段着色器最后一行直接输出一个固定颜色比如return vec4(1.0, 0.0, 0.0, 1.0)。如果模型是红的说明几何和管线没断如果不红问题出在顶点阶段或更早的CPU数据传参上。这一步直接把Shader调试一分为二省掉一半工作量。第二步检查插值数据是否正常。比如你要调试法线可以在片段着色器里输出normalize(vWorldNormal)后取绝对值当作RGB显示。如果模型表面出现平滑渐变说明法线数据在管线里传得很稳如果颜色块状突变那就是顶点数据本身或者插值方式出了问题。一个常见原因是忘记把法线乘模型矩阵导致模型旋转后法线纹理方向错乱画面会呈现出“光源跟着物体动”的怪相。第三步才轮到光照参数。当你确认几何、方向都对再慢慢调整环境光强度、高光系数、粗糙度这些参数基本不会白调。4.2 几个高频渲染问题速查表我顺手整理了一份我在实际项目里反复踩过的坑做成表格对号入座能省很多时间。现象可能原因排查顺序模型完全显示不出来矩阵未正确传入/顶点坐标全是0先检查uniform值再输出固定颜色模型显示但光照“画风诡异”法线未做变换/未归一化用RGB可视化法线确认朝向模型有碎片化闪烁z-fighting深度精度不够/两个面重叠太近调整Near/Far比拉开几何距离模型透明但遮挡关系错乱半透明物体深度写入/排序没处理好先关深度写入再试混合状态高光位置明显不对视线方向或光源方向计算不对逐项打印L和V的方向分量核对正负这些坑绝大多数不是Shader里的光照公式推错了而是数据从顶点阶段到片元阶段的传递出了偏差或者空间矩阵用错了。所以排查的第一原则永远是别改代码先确认数据到了哪个环节。4.3 实用技巧把中间计算结果直接可视化这是我个人最推荐的一个调试心法把shader的中间量当成颜色输出。你想看世界坐标就把vWorldPos除以某个范围映射到0到1之间输出你想看法线就输出normalize(N)*0.50.5你想看UV就直接输出UV的xy当作RGB。这套方法在Unity、UE、以及裸写OpenGL里都适用只要加一行临时代码就行。还有一个技巧是给调试输出加上条件判断比如只让特定位置的片元变成高亮色。我之前调试一个Magicavoxel模型的着色规则时就用了类似“长度大于某个值的片元标红”的写法很快找出了边缘颜色异常的分界点。相比一遍遍改参数、重新编译、看结果这种“数据可视化式调试”慢是慢点但能让你看得清清楚楚问题在哪一目了然。5. 个人体感真正把管线图刻进脑子里之后聊完这么多写点实际的感触。我在刚开始学Shader的时候最喜欢干的事是疯狂堆特效代码霓虹边缘、扫描线、波光粼粼各种你能想到的视觉效果都往片段着色器里塞。结果就是效果一时爽后期优化火葬场——片段着色器里每条多余的高成本计算都会在几百万个片元上重复执行一个看似微不足道的pow或者sin都能把帧率砍掉一半。后来被前辈点醒回头把Rendering Pipeline那几张图重新啃了一遍才明白之前那些疑难杂症全都有章可循。比如你会发现半透明物体的渲染问题根子不在Shader里而是在混合阶段因为透明物体需要先深度排序才能让眼睛看到正确的遮挡关系。再比如你会发现GPU Instancing的本质是让一批顶点数据共享同一个Draw Call减少的是CPU和GPU的通信瓶颈跟Shader写得牛不牛没关系。我真心建议每一位在学图形学的朋友不要急着刷“百道光照习题”先花一天时间把“局部空间→世界空间→观察空间→裁剪空间→NDC→屏幕空间”这条坐标变换链画在纸上再把渲染管线的七个阶段标注在旁边。只要你把这张图真的刻进脑子里后续无论写三联矩阵、做法线贴图、还是调后处理都会顺畅很多。Shadr这种东西说穿了就一句话你知道数据从哪来、到哪去、在哪个环节被谁加工你就能驾驭它。
返回列表