资讯动态

Unity Shader从材质到渲染管线:顶点/片元着色器与URP选型

发布时间:2026/9/30 19:52:39 来源:尧图企业网站定制
从材质的那个小球说起Shader在Unity里到底占什么位置你把Unity装好新建一个场景Hierarchy里躺着Directional Light和Main CameraProject面板右键建一个Material拖到球体上球体就亮了。这一步太顺了顺到很多人做了半年项目都没打开过材质属性里那个Shader下拉框。但真正决定那个球长什么样的不是模型也不是那个白球贴图而是挂在材质上的一份Shader。Unity Shader这件事说难不难但它卡人的地方很特别——你可能写过几十个C#脚本能熟练拖拽Transform、处理碰撞、做摄像机跟随逻辑写得清清楚楚可一旦要写Shader突然发现所有东西都变成了矩阵、语义、通道和一堆看不懂的宏。这不是你笨是因为Shader运行的地方在GPU上它的思考方式和CPU完全不同CPU是按你的代码一行一行顺序执行GPU是几万个线程同时干同一件事谁先谁后你管不着。这篇内容我想把Unity Shader这件事从头捋一遍重点不是给你一个漂亮的公式而是让你明白它为什么长成现在这个样子。看完之后你应该能做到这几件事知道顶点着色器和片元着色器各自负责什么阶段知道Unity里那么多Shader写法该挑哪种能自己写出第一个能跑、能改、能调试的Shader碰到阴影不对、透明穿帮、移动端掉帧的时候知道往哪个方向查。适合刚学Unity、对渲染层完全没概念的人也适合那些已经会用Shader Graph连线、但说不清背后在干什么的人。1. Shader到底是什么把渲染管线拆开看1.1 显卡里那条流水线在干的活先把渲染两个字翻译成人话把一堆三维数据压成屏幕上的一张二维图像。这个过程由GPU接管走的是一条固定流程行业里叫渲染管线。它的顺序大致是CPU先把模型顶点、索引、贴图、各种变换矩阵打包好通过Draw Call提交给GPUGPU拿到这批数据后先做顶点变换把每个顶点从模型空间挪到裁剪空间接着把点连成三角形做图元装配然后光栅化把三角形拆成一堆小小的片元你可以粗略理解为候选像素最后每个片元算颜色通过深度和混合测试写进帧缓冲。这套流程里绝大部分环节是硬件写死的你改不了光栅化怎么拆三角形是固定的深度测试的规则也是固定的。但有几个环节是开放的硬件允许你塞一段程序进去替它决定顶点该挪到哪和这个片元该是什么颜色。你塞进去的这段程序就是Shader。所以在Unity里一份Shader往往包含两个核心部分一个顶点函数负责算坐标和传递数据一个片元函数负责算最终颜色。中间那些插值、光栅化、测试硬件帮你做了你控制不了也不需要控制。理解这个分工是后面一切的前提。1.2 顶点着色器管位置和传参顶点着色器是逐顶点运行的。你有一个一万人顶点的模型它就跑一万次而且这一万次是并行跑的互相之间不知道对方在干什么。这一点特别关键因为很多人写Shader时会本能地想我遍历一下所有顶点找最高的那个这在顶点着色器里根本做不到——你只能看到当前这一个顶点。它主要干两件事。第一件是把顶点坐标做变换通常是从模型空间乘上模型矩阵到世界空间再乘视图矩阵到观察空间最后乘投影矩阵到裁剪空间输出一个裁剪空间坐标给硬件。手写的时候你会看到类似mul(UNITY_MATRIX_MVP, v.vertex)这样的写法本质就是这三个矩阵合并后的结果。在内置管线里这个宏可以用但在URP或HDRP里它已经被替换掉了得换成TransformObjectToHClip这类函数这个坑后面会细说。第二件事是把顶点上的数据往下传比如法线、切线、UV、顶点色。你在顶点函数里算好用带TEXCOORDn语义的变量输出硬件会在三角形内部按重心坐标做插值到片元阶段时每个像素拿到的就是一个平滑过渡的值。UV能正确贴图、法线能平滑过渡靠的就是这个插值机制。1.3 片元着色器一个像素一个像素地算颜色片元着色器是逐片元运行的这个数量级比顶点大得多。一个1080P全屏的模型片元可能上百万所以片元着色器里的每一行代码都会被放大几百万倍性能敏感度极高。它拿到的是插值后的数据插值后的UV、插值后的法线、插值后的世界坐标。然后你要根据这些数据算出这个片元该显示什么颜色返回一个RGBA值。光照、纹理采样、菲涅尔边缘光、溶解、噪声扰动全都在这里做。有一个概念要澄清片元不等于像素。光栅化生成的一些片元可能还没进帧缓冲就被深度测试裁掉了所以片元数一般大于等于最终写入屏幕的像素数。这也解释了为什么有些场景看着不复杂但显卡很吃力——被裁掉的片元照样跑完了整个片元着色器只是最后没写进屏幕而已。提示顶点着色器里不要做逐顶点的光照计算指望它好看。老式的顶点光照在低模上会出现明显的块状高光现在基本都是逐像素光照或者干脆预计算好光照贴图。1.4 为什么Unity要自己造一套Shader语言如果你去看原生的图形API写一个着色器要处理大量平台差异不同图形接口的着色器语法、坐标系差异、矩阵约定都不一样。Unity做了一件很讨巧的事它发明了ShaderLab——一层包裹语言用来描述我有哪些属性、我有几个Pass、渲染状态怎么设、走哪个LOD、回退到哪个Shader而具体的着色计算可以写在里面的代码块里。这么做的好处是同一份Shader能编译到好几个目标平台。坏处是初学者容易分不清哪些是ShaderLab语法、哪些是真正的着色语言语法。一个典型的错觉是把Tags、Cull、ZWrite当成着色语言的一部分其实它们是ShaderLab的渲染状态指令压根不参与GPU那边的计算它们编译后变成的是GPU的状态设置。还有一层Unity很多年前用的是Cg语言后来逐步转成HLSL。现在你在网上搜到老教程看到CGPROGRAM和ENDCG那是老写法新的应该用HLSLPROGRAM和ENDHLSL。两者大量语法互通但在SRP可编程渲染管线下CG那套基本不维护了。1.5 除了顶点和片元还有别的着色器吗有而且值得知道它们的存在。几何着色器可以在图元级别增删顶点比如把三角形变成三个新三角形做毛发曲面细分着色器可以把低模细分出更多顶点做地形或者动态细分计算着色器最特殊它不参与渲染管线是拿GPU当并行计算器用适合粒子物理、后处理卷积、图像模糊这类吞吐密集的活。不过在Unity的实际项目里新手九成九的时间都花在顶点片元着色器上。计算着色器在移动端支持度参差不齐微信小游戏这类WebGL环境对它的支持非常有限如果你打算往小游戏方向走前期别把重心放这儿。2. Unity里那么多Shader写法到底该挑哪个2.1 内置管线顶点片元着色器和表面着色器如果你用的是内置渲染管线会碰到两种主要写法。第一种是顶点片元着色器你自己写vert和frag函数光照、阴影、雾效全都得自己处理。好处是完全可控坏处是要自己处理一堆光照循环、阴影采样、光照探针代码量不低。第二种是表面着色器写一个surf函数用SurfaceOutputStandard之类的结构描述表面属性反照率、金属度、光滑度、法线Unity在编译期帮你把这些展开成一整套顶点片元代码顺便处理光照和阴影。它写起来非常快几十行就能做出接受光照的材质。表面着色器的代价是编译慢、变体多、不透明。它生成出来的代码你看得见但改不动一旦要做出格的效果就束手束脚。而且它是内置管线专属URP和HDRP里根本不支持。现在新项目基本都往SRP走所以我个人建议表面着色器了解即可重点学顶点片元。2.2 URP和HDRPShader Graph与手写HLSLURP和HDRP属于可编程渲染管线。这套体系下Unity推出了Shader Graph节点连线式编辑所见即所得URP下开箱即用。对美术、TA或者只想快速做效果的开发者来说Shader Graph的效率确实高改一个参数立刻能看到结果不用编译等半天。但Shader Graph不是万能的。它生成的代码你不好插手某些需要精细控制指令、想做极致移动端优化的场景手写HLSL更合适。还有一个现实问题Shader Graph生成的Shader变体数量容易失控如果不加控制一堆材质会让打包体积和编译时间都很难看。我自己的做法是混合使用常规的、需要频繁调参的效果用Shader Graph追求性能或者需要特殊平台适配的用HLSL手写。这两种不冲突同一个项目里可以并存。2.3 一张表说清选型逻辑写法适用管线上手难度可控性典型场景表面着色器仅内置管线低低快速做标准光照材质顶点片元HLSL内置/URP/HDRP中高自定义特效、性能敏感Shader GraphURP/HDRP低中美术友好、快速迭代计算着色器内置/URP/HDRP高高粒子、后处理卷积、GPU计算注意选型之前先确认项目用的是哪条管线。在内置管线里写HLSLPROGRAM然后发现TransformObjectToHClip找不到或者在URP里用UNITY_MATRIX_MVP编译报错都是选型没对齐导致的这类错误占了新手报错的很大一部分。2.4 为什么我劝你先手写一遍再上Shader Graph这有点像学开车先学手动挡。Shader Graph把光照、坐标变换、平台差异都封装成节点了用起来爽但一旦效果不对你连从哪查都不知道。手写过一遍之后你会知道顶点变换发生了什么光照方向从哪来UV的平铺偏移是怎么算的阴影采样需要哪些宏。再回去用Shader Graph你就能看懂它背后在干什么出问题也能定位。3. 动手写第一个Shader从骨架到能跑3.1 文件骨架逐行拆解先看一份能在内置管线跑起来的最小Shader我加了注释每一块都说明白。Shader Custom/BasicUnlit { Properties { _MainTex (主贴图, 2D) white {} _Color (叠加颜色, Color) (1,1,1,1) _Intensity (亮度, Range(0, 4)) 1 } SubShader { Tags { RenderTypeOpaque QueueGeometry } LOD 100 Pass { Cull Back ZWrite On ZTest LEqual HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; UNITY_FOG_COORDS(1) }; sampler2D _MainTex; float4 _MainTex_ST; float4 _Color; float _Intensity; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o, o.pos); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color * _Intensity; UNITY_APPLY_FOG(i.fogCoord, col); return col; } ENDHLSL } } FallBack Diffuse }Properties块里声明的是暴露在材质面板上的参数注意这里只是声明真正能用的变量必须在下面再声明一次而且类型要能对上。这个声明两遍的设计新手最容易漏漏了之后材质面板上参数照样能改但Shader里读到的一直是默认值表现就是改了没反应。SubShader是一组Pass的集合Unity会从上往下挑第一个当前硬件能跑的SubShader。Pass里就是一次完整的绘制。LOD用来做质量分级数字越小越简单。3.2 顶点函数里到底算了几件事顶点函数里我做了三件事逐个说。第一件UnityObjectToClipPos(v.vertex)把模型空间的顶点坐标一次性转到裁剪空间。这个函数内部帮你乘了模型、视图、投影三个矩阵内置管线里可以直接用。要注意它的输入是模型空间坐标如果你手上已经是世界空间坐标了得换成UnityWorldToClipPos。第二件TRANSFORM_TEX处理UV的平铺和偏移。材质面板上每个贴图参数旁边都有Tiling和Offset两个小格子你在材质上改它们值其实是通过_MainTex_ST这个自动生成的变量传进来的。TRANSFORM_TEX做的就是uv * _MainTex_ST.xy _MainTex_ST.zw。手动写一遍你就明白为什么要在材质上改平铺而不是改UV坐标了。第三件雾效宏。如果你的场景里开了雾不做这一步物体在后处理阶段会显得和周围不搭尤其是大场景远处的物体。multi_compile_fog这个编译指令会为不同的雾模式生成变体代价是变体数增加。3.3 片元函数与语义的作用片元函数返回fixed4后面跟SV_Target语义意思是这个值输出到第0号渲染目标也就是屏幕。如果你在做多重渲染目标比如延迟渲染的G-Buffer或者你自己写后处理同时输出多张图就会看到SV_Target0、SV_Target1这样的写法。appdata里的POSITION和TEXCOORD0是输入语义告诉GPU这个字段从顶点的哪个通道取数据。v2f里的SV_POSITION是必须的硬件靠它决定这个顶点最终落在屏幕的哪个位置。其他字段你随便用TEXCOORD0到TEXCOORD7它们本质上只是编号的数据通道不一定非得装UV你完全可以拿TEXCOORD1传世界坐标。提示SV_POSITION语义的字段在片元阶段的值不是单纯的插值结果它是经过透视校正的屏幕空间坐标。想从它反推屏幕UV得除以屏幕尺寸或者直接用ComputeScreenPos。3.4 在URP里怎么改这份代码URP下直接用上面这份会报错因为UnityCG.cginc和UnityObjectToClipPos都不在了。要改成引入Core.hlsl用TransformObjectToHClip另外Pass里得加Tags { LightModeUniversalForward }不然URP不认识这个Pass物体直接消失这个现象特别迷惑人——Console不报错物体就是看不见。Tags { LightModeUniversalForward } ... #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl o.pos TransformObjectToHClip(v.vertex.xyz);另外URP里fixed类型虽然还能用但不推荐了统一用half或float。这个改动看起来小但迁移老项目的时候是高频报错点值得记一笔。3.5 从单色到有光照加一个简单的高光想让它接受主光方向可以在片元里自己算一个简单的Lambert加高光float3 N normalize(i.worldNormal); float3 L normalize(_WorldSpaceLightPos0.xyz); float3 V normalize(_WorldSpaceCameraPos - i.worldPos); float3 H normalize(L V); float ndl saturate(dot(N, L)); float spec pow(saturate(dot(N, H)), _Shininess); float3 color baseColor * ndl spec * _SpecColor.rgb;这里_WorldSpaceLightPos0是Unity自动传进来的主光方向_WorldSpaceCameraPos是相机位置都是内置变量。但注意这套写法只处理了主方向光多盏灯、点光源、聚光灯都不会有反应真要做好得写完整的ForwardBase和ForwardAdd Pass或者直接用Shader Graph。这也说明了为什么很多人最终还是回到现成方案——从零造光照的轮子成本很高。4. 渲染状态和那几个最容易翻车的配置4.1 Cull、ZWrite、ZTest、Blend怎么配Cull Back剔除背面是默认值也是性能最优的选项。做树叶、布片、旗帜这类单面模型时经常要Cull Off但要注意关掉之后三角形数量翻倍移动端要谨慎。另外关掉剔除不会自动修正法线方向光照会算错通常要在片元里根据是否正面朝向手动翻转法线用VFACE语义拿到正面标记再乘上去。ZWrite On表示写入深度不透明物体必须开否则后面的物体会盖在前面物体上出现经典的前后穿帮。透明物体一般要关掉深度写入靠Queue排序来保证渲染顺序但排序是按物体中心点排的大物体和互相穿插的透明物体一定会出问题这是图形学的固有限制不是Unity的锅。Blend决定新颜色怎么和帧缓冲里已有颜色混合。常规半透明用Blend SrcAlpha OneMinusSrcAlpha发光叠加用Blend One One。混合模式写错会导致颜色发白、发灰或者完全看不见这是排查透明问题时首先要看的。状态常用值什么时候需要改CullBack / Off单面模型、需要看到内部ZWriteOn / Off不透明On透明OffZTestLEqual / AlwaysUI、描边叠加常用AlwaysBlendSrcAlpha OneMinusSrcAlpha半透明QueueGeometry / Transparent控制渲染顺序4.2 阴影不生效先查这四件事阴影问题是问得最多的。物体投影不出来按顺序查第一Shader里有没有ShadowCaster这个Pass没有的话它压根不参与阴影贴图的绘制。用内置管线时只要写了FallBackUnity会把FallBack里的阴影Pass补上自己写URP Shader时经常忘了加结果就是只有别人能投到它身上它自己投不出去。第二Shader的变体有没有被裁掉。打包时如果用了变体裁剪可能会把需要的变体剔了表现是编辑器里正常真机或者打包后没阴影。第三阴影距离和级联设置。质量设置里的阴影距离如果比物体距离主相机的距离还小阴影自然不出现。级联数量调低会让远处阴影精度骤降看起来像消失。第四偏移参数。阴影贴图有分辨率限制斜面上容易出现条纹状的自我遮挡行业叫阴影痤疮。调ShadowBias和NormalBias能缓解但调太大又会导致阴影和物体分离看起来像漂浮这叫彼得潘效应。这两个参数本质上是拿精度换瑕疵没有完美解只能调到观感能接受。4.3 顶点动画一定要改包围盒这是我从项目里踩出来的只要你在顶点着色器里动了顶点的位置Unity的视锥剔除就会按原始网格的包围盒来判断。也就是说顶点被位移到包围盒外面的部分会在相机还看不到网格本体时被整块剔除表现为物体在屏幕边缘突然整体消失或者突然弹出。解决办法是手动扩大Renderer的包围盒或者直接改材质属性里的_BoundsMin、_BoundsMax让剔除系统按更大的范围判断。也可以在脚本里定期更新Mesh.bounds。这个问题和Renderer的包围盒机制直接相关做草叶摆动、旗帜飘动、布料模拟的时候几乎必然遇到。4.4 移动端和小游戏平台的额外约束移动端GPU的带宽和精度都紧张。片元着色器里常见的优化手段把float降成half甚至fixed内置管线能省不少寄存器和带宽避免在片元里写动态分支GPU对分支的处理方式是两条路都算完再选一条分支省不了多少反而可能更慢避免在透明的UI和粒子上做无意义的大面积叠加过度绘制是移动端掉帧的头号原因尤其是全屏半透明的特效。微信小游戏这类WebGL环境还有额外限制计算着色器支持很有限一些高精度纹理格式不可用纹理压缩格式也得按平台挑。这个平台对Shader变体数量尤其敏感因为变体多了会显著影响下载体积和运行内存。做小游戏项目时能减少关键字就减少关键字shader_feature比multi_compile更省因为前者只会编译实际用到的组合。还有一个隐蔽的点限定数据块大小和常量缓冲区的对齐。不同平台对常量缓冲区的对齐要求不一样一个float3后面跟一个float可能被硬件按float4对齐导致内存布局和你以为的不一样。跨平台项目里能合并成float4就合并别留悬空的小类型。5. 调试与排查实录把黑盒打开5.1 效果不对时的排查顺序我自己的习惯是从外往内查。第一步先确认Shader有没有编译错误Console里的红字要一条条看很多时候报错信息里的行号和实际位置差几行因为宏展开会打乱行号。第二步用一个最简Shader纯红输出替换看物体是否可见确认是Shader问题还是模型、材质、剔除的问题。第三步逐步加功能UV输出、法线输出、光照输出一步一步缩小范围。这个方法看着笨但比对着代码发呆快多了。很多效果不对其实不是Shader逻辑错而是材质上贴图没给、法线反了、模型没UV、渲染队列设置错了。5.2 常见问题速查表现象常见原因排查方向物体完全不可见LightMode标签不匹配、被剔除、坐标算成NaN检查Pass标签、Cull、矩阵计算材质参数改了没反应Properties声明了但没在代码里再声明变量补齐变量声明类型要一致贴图上下颠倒平台图形接口的UV原点差异使用Unity的宏处理别硬编码翻转半透明有黑边贴图预乘问题、混合模式不对检查Blend检查贴图alpha通道顶点位移后物体闪没包围盒没跟着扩大手动更新Renderer的bounds阴影只有部分区域有级联和阴影距离设置调整质量设置里的阴影参数移动端特别卡过度绘制、高精度运算、变体过多降精度、减少透明叠层、裁剪变体属性面板出现奇怪数值常量缓冲区对齐问题合并成float4对齐5.3 我常用的几个调试手段第一招是假彩色输出。把UV直接当颜色返回float4(uv,0,1)画面会变成红绿渐变一眼就能看出UV有没有问题、有没有超出0到1范围。把法线当颜色返回float4(normal*0.50.5, 1)画面会变成彩色球能立刻看出法线方向对不对。这两招比任何工具都快。第二招是帧调试器。它能一步步展示每个Draw Call看到当前用了哪个Shader、哪些渲染状态、渲染目标长什么样。排查这个物体为什么画成这样非常有效尤其是后处理和半透明的叠加问题。第三招是抓帧工具。遇到严重像素级的问题抓一帧下来逐个管线阶段看能精确到哪个Pass的哪个输出出了问题。这类工具上手有门槛但一旦用熟排查效率提升非常明显。配套的还有Shader调试变体可以把中间变量可视化到屏幕上适合排查数值范围溢出、负值、NaN这类问题。第四招是Shader的编译错误面板。有些错误不会在Console里完整显示需要打开Shader文件看顶部的报错提示行。还有一种情况是Shader编译成功但效果异常那多半是平台差异或者精度问题这时候在前面加#pragma target 3.0之类的指令往往能让差异暴露出来。5.4 关于代码和Shader配合的几个实际心得有个效果我做过很多次物体逐渐溶解消失。实现方式是在片元里采样一张噪声贴图用一个_Progress参数去比较低于阈值的直接裁掉。这个_Progress就是C#脚本每帧传进来的配合Mathf.PerlinNoise生成的自然噪声边缘可以做出不规则的感觉比纯随机数好看很多。这也说明Shader不是孤立存在的脚本控制参数、Shader负责表现是常规的组合方式。还有一点别在Shader里做本该用代码做的事。比如位置的动画、缩放、旋转用Transform做比在顶点着色器里算要简单得多除非你要做顶点级的特殊形变。分清哪些工作属于CPU、哪些属于GPU是提升效率的关键。我也想提醒一句Shader的维度很多管线内置还是URP、平台PC还是移动还是小游戏、精度全精度还是半精度、变体数量、渲染队列任何一个维度没对齐都可能出现编辑器正常、真机不对的经典问题。我的习惯是做效果时先在目标平台上跑一遍最小验证别等做完了整体迁移那时候排查成本会翻好几倍。最后分享一个我踩过的坑有一次写了个溶解效果编辑器里完美打包到移动端之后边缘变成硬邦邦的锯齿怎么调参数都没用。后来发现是片元里用了对屏幕坐标求导的函数移动端部分GPU对这种跨像素运算的支持行为不一致值在边缘跳变导致的。改成在顶点阶段把需要的值算好传下来做插值问题就没了。这类事情看文档看不出来只能靠真机跑出来所以任何涉及跨像素运算、导数、屏幕空间采样的效果都建议在目标设备上单独验证一遍别只信编辑器。后面如果还要往下走可以试着从这三条线扩展一条是把光照模型往PBR方向做深理解能量守恒和BRDF为什么长那样一条是往性能方向钻研究变体管理、指令优化、移动端带宽还有一条是往工具链方向走把Shader和编辑器扩展结合起来做美术能直接用的参数化效果。这三条路都挺长但每一条走到一定深度你对渲染的理解都会和现在完全不一样。

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

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

免费获取报价 →
↑