美术同学丢过来一张渐变贴图,一句"照着这个做个流光效果",你打开 Unity 工程,右键 Create → Shader,屏幕上立刻铺开一百多行自己从来没写过的代码。大多数人对 Shader 的第一印象就是从这里开始的:它看起来很吓人,但又绕不开,因为只要你想改一点点画面的样子,最后都会走到这里。我刚开始学的时候也一样,把 Shader 当成某种神秘的"高级特效开关",直到自己动手把渲染管线走了一遍,才发现它其实只是一个跑在显卡上的函数——输入顶点和纹理坐标,输出一个颜色,仅此而已。这篇内容就从最基础的地方讲起:Shader 在 Unity 里到底是什么、它在渲染管线的哪一环干活、ShaderLab 文件为什么长成那样、顶点和片元这两个函数各自负责什么、光照阴影为什么是新手最容易翻车的地方,以及怎么亲手写一个能跑起来的最小 Shader。不管你是刚接触 Unity 的美术、刚转引擎的程序,还是被"unity shader 入门"卡了很久的人,这篇都能给你一条能走通的路。
1. 从渲染管线反推:Shader 究竟在哪一环动手
先把一个画面是怎么被"画"出来的过程捋一遍,Shader 的位置自然就清楚了。你在场景里放一个 Cube,CPU 端要做的事情包括:遍历所有带 Renderer 的物体、做视锥剔除和排序、准备每个物体的材质参数、最后把绘制指令提交给 GPU。这一步在 Profiler 里对应的是PlayerLoop下的Camera.Render,而在 Frame Debugger 里看到的就是一条条 Draw Call。GPU 拿到这些指令之后才真正开始干活,而 Shader 就是这段时间里运行的代码。
1.1 一次 Draw Call 在 GPU 内部的完整旅程
GPU 端的流程可以粗略分成几个阶段:顶点处理 → 图元装配 → 光栅化 → 片元处理 → 输出合并。顶点处理阶段由顶点着色器(Vertex Shader)执行,它针对每一个顶点跑一次,负责把顶点的坐标从模型空间变换到裁剪空间,同时把需要传给后续阶段的数据(UV、法线、顶点色)打包出去。图元装配把变换后的顶点连成三角形,光栅化再把三角形拆成一个个像素级的候选点,也就是"片元"。接下来片元着色器(Fragment Shader)针对每个片元跑一次,算出这个像素最终的颜色。最后输出合并阶段做深度测试、模板测试和混合,决定这个颜色到底写不写进屏幕缓冲。
关键点是:这两段你可编程的阶段,只是整条管线里的一小部分。三角形的连接方式、光栅化的采样规则、深度测试的比较函数,这些在传统管线里都是固定的,你只能通过状态开关去影响它,没法改写它的算法。所以每次有人问"Shader 能不能做某某效果",我第一反应都是先判断:这个效果是需要在顶点上做手脚,还是要在像素上做手脚?如果两个都不沾边,那多半不是 Shader 能解决的问题。
1.2 Shader 不是 Photoshop 滤镜,区别比想象中大
很多人第一次理解 Shader 时,会把它类比成一张"滤镜"。这个类比有一半是对的,但容易误导。Photoshop 的滤镜是拿一张已经渲染好的完整图片去处理,而 Shader 是在图片还没生成的时候逐顶点、逐像素地参与生成过程。举个大白话的例子:后期处理(Post Processing)确实更像滤镜,它在相机渲染完的这张图上做全屏的颜色调整;而一个普通的物体 Shader 更像是在"画"这个物体本身,它决定了这个 Cube 该是什么颜色、受什么光照、边缘怎么过渡。
这个区别带来一个很实际的影响:物体 Shader 是每个物体各跑一套,后期处理是全屏跑一次。所以你在物体 Shader 里写复杂的噪声计算,场景里有一千个物体就乘一千次;写在后期处理里就只算一遍。理解这一层,你在做性能优化的时候就不会瞎猜了。
1.3 Shader、Material、Texture 三者的关系
这三个东西几乎每个新手都会搞混,我用点菜的比喻讲一遍。Shader 是菜谱,它规定了做这道菜的步骤和需要哪些原料;Material 是一张点菜单,它指定了用哪份菜谱,并且填上具体的参数值(比如要什么颜色、贴哪张贴图、金属度调多少);Texture 是食材,作为参数的一种被塞进菜单里。一个 Shader 可以被无数个 Material 复用,每个 Material 有自己的参数组合,这就是为什么改一个材质的颜色不会影响其他用到同一个 Shader 的物体。
在工程里这一点体现得很明显:Assets目录下那些.shader文件是菜谱,.mat文件是菜单。你在代码里写的material.SetColor("_Color", Color.red)改的就是某一张菜单上的某一栏,而不是改菜谱本身。搞清楚这层关系,后面理解 Properties 块的作用就顺理成章了。
2. 拆开 Unity Shader 文件:ShaderLab 外壳套着 HLSL 内核
Unity 的 Shader 文件不是纯粹的 HLSL,它外面包了一层叫 ShaderLab 的声明式语法。这层语法管的是"这个 Shader 怎么被 Unity 使用"——包括暴露哪些参数给编辑器、支持哪些显卡能力、渲染队列是多少。真正干活的 HLSL 代码,是被包在Pass里面的。理解这个"外壳 + 内核"的双层结构,是看懂所有 Unity Shader 的前提。
2.1 Properties 块:写给美术看的接口
Properties 块唯一的作用是把参数暴露到材质面板上,它自己不参与任何计算。这一点非常关键,因为很多人以为在 Properties 里写了_MainTex就能在代码里直接用,其实能不能用取决于你在 HLSL 部分有没有重新声明一遍同名变量。
Properties { _MainTex ("主贴图", 2D) = "white" {} _Color ("颜色", Color) = (1, 1, 1, 1) _Gloss ("光泽度", Range(0, 1)) = 0.5 }你完全可以不在 Properties 里写任何东西,然后在 C# 里直接用material.SetFloat("_Gloss", 0.8f)去设置,Shader 照样能读到——只是美术在 Inspector 上看不到这个滑块,调起来很痛苦。所以 Properties 的定位是协作接口,不是功能实现。
还有一件事必须提醒:Unity 6 默认使用 URP 管线,材质面板上的参数是跟 CBUFFER 绑定的。如果你用了 SRP Batcher(默认开启),那么所有材质相关的变量必须放在CBUFFER_START(UnityPerMaterial) ... CBUFFER_END里,而且要保持内存布局一致。漏了这一步,Shader 能跑,但会在 Inspector 里提示 "SRP Batcher not compatible",几百个物体的场景下帧率会掉得很明显。我第一次遇到这个问题时找了半天,因为报错非常含蓄。
2.2 SubShader 与 Pass 的分层逻辑
Shader下面可以有多个SubShader,Unity 会从上到下挑第一个当前显卡支持的来用。这个设计的年代背景是显卡能力参差不齐,需要给老设备准备降级方案。现在开发 PC 和移动端项目,通常只需要写一个 SubShader,但移动端项目里保留一个高配、一个低配的双 SubShader 依然是常见做法,尤其是需要兼容不同 GPU 精度支持的时候。
SubShader下面是Pass,一个 Pass 就是一次完整的绘制流程。多 Pass 的意义在于同一个物体可以被画多次,每次用不同的参数,结果叠加起来。经典的例子是描边效果:第一个 Pass 把模型沿法线外扩一点,只画背面涂成描边色;第二个 Pass 正常画正面。两次绘制在屏幕上合成,就得到了描边。
内置管线下带光照的 Shader 通常会有ForwardBase、ForwardAdd、ShadowCaster这几个 Pass,分别负责主光、附加光、阴影投射。URP 下这些 Pass 依然存在,但写法上通过HLSLPROGRAM和 include 官方的Pass.hlsl来实现,这一点后面会细说。
2.3 Tags 与渲染状态:不起眼但决定成败的开关
SubShader和Pass上挂的 Tags 是给 Unity 渲染器看的元信息,不写不一定报错,但一定会出现奇怪的现象。常用的几个:
| Tag | 作用 | 不写的后果 |
|---|---|---|
RenderType | 标记 Shader 类型,供替换和后期使用 | 某些后处理效果抓不到你的物体 |
Queue | 决定渲染队列(Geometry 2000、Transparent 3000) | 透明物体和不透明物体排序错乱 |
LightMode | 标记这个 Pass 在光照流程中的角色 | 光照和阴影完全不生效 |
IgnoreProjector | 是否受投影器影响 | 一般不影响,但角色类项目要留意 |
渲染状态指令则写在 Pass 内部,比如Cull Back(背面剔除)、ZWrite On(写深度)、Blend SrcAlpha OneMinusSrcAlpha(透明混合)。透明物体最常见的翻车就是 ZWrite 没关,导致前后两个半透明物体互相遮挡,出现"缺一块"的现象。这些状态和你在渲染管线课上学到的固定功能阶段是一一对应的,理解起来并不难,难的是记住在什么场景下该动哪个开关。
3. 顶点着色器与片元着色器:两个函数撑起整个画面
真正的计算全在这两个函数里。它们长得都很朴素——一个入参结构体,一个返回结构体,中间几行数学运算。但要把它们用明白,得先搞清楚"什么数据在什么空间里"。
3.1 顶点着色器:坐标变换和打包数据
顶点着色器的核心任务有两个。第一是把顶点坐标从模型空间转换到裁剪空间,这一步在内置管线下叫UnityObjectToClipPos(v.vertex),在 URP 下叫TransformObjectToHClip(positionOS.xyz)。名字不一样,做的事完全一样。第二是把后续阶段需要的数据整理好传出去,比如把 UV 按贴图的缩放偏移做处理,把法线从模型空间转到世界空间。
为什么法线的处理比顶点坐标麻烦?因为法线是"方向"不是"位置",它不应该受到平移的影响。所以旋转和缩放要用不同的变换矩阵去处理,Unity 提供了UnityObjectToWorldNormal()这个函数帮你把这件事做对。我自己踩过的一个坑是:直接拿mul(unity_ObjectToWorld, float4(normal, 0))去算法线,模型缩放非等比的时候法线就歪了,光照看起来"发灰"。非等比缩放的法线需要用法线矩阵(逆转置矩阵)来处理,这个细节在书里往往一笔带过,但实际项目里模型缩放不均匀太常见了。
还有一个容易被忽略的点:顶点着色器的调用次数跟你以为的不一样。它针对每个顶点跑一次,但一个 Cube 只有 8 个顶点,而你看到的是一个带明暗过渡的立方体——那多出来的信息全部来自片元着色器里的插值计算。
3.2 片元着色器:逐像素决定最终颜色
片元着色器拿到的输入,是顶点着色器输出经过光栅化插值之后的结果。也就是说,三角形三个顶点各自输出了一组数据,光栅化阶段会按照像素在三角形内的位置,把这些数据线性插值出一个"这一个像素专属的值"。这就是为什么贴图能正确地铺满整个三角形,也是为什么 UV 在顶点上只需要存三个点就够。
片元着色器里该做什么?核心是算出这个像素的最终颜色。典型操作包括:采样贴图(SAMPLE_TEXTURE2D或内置管线的tex2D)、计算光照、叠加顶点色、混合雾效。这里有个新手常犯的错误,就是把本该在顶点阶段做的事放到片元阶段。比如一个周期性的扫描线效果,如果你在片元里用sin去算,每个像素都要算一次三角函数;如果放到顶点里算完再插值,开销直接降一个量级。判断标准很简单:这个值在三角形内部变化剧烈吗?不剧烈就放顶点。
3.3 语义(Semantics):为什么必须成对出现
HLSL 里每个结构体成员后面跟的那个冒号加单词,叫语义。POSITION、SV_POSITION、TEXCOORD0、SV_TARGET这些都属于语义。
它的作用是告诉编译器和 GPU:这个变量从哪个硬件寄存器读,往哪个寄存器写。所以顶点着色器的输入结构体里必须有POSITION,因为那是硬件提供的原始顶点坐标;片元着色器的返回值必须是SV_TARGET,因为那是最终输出的颜色缓冲。中间传递的 UV 用TEXCOORD0这种通用语义,编号只要不冲突就行,具体含义由你自己定义——TEXCOORD1里塞雾效因子完全没问题。
struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float fogFactor : TEXCOORD1; };这里有个坑值得单独说:TEXCOORD的可用数量是有上限的。移动端一些老设备只支持 8 个插值器,如果顶点着色器输出的数据太多,编译会报错,或者静默降级导致画面出错。这就是为什么在移动端项目里,大家会给一些次要数据用half甚至fixed精度,能省一点是一点。
4. 光照与阴影:新手最集中翻车的地方
前面讲的东西,只要你手写了几个无光照的 Shader 就能摸清。但一旦要接光照,事情就复杂了——因为光照不是一个 Pass 能搞定的,它是 Unity 渲染器和你写的 Pass 之间一套约定好的协作流程。
4.1 LightMode 决定了你的 Pass 什么时候被调用
内置管线的渲染路径有前向和延迟两种,绝大多数项目用的是前向。前向渲染中,Unity 会给每个受光照影响的物体调用若干次绘制:一次ForwardBase(主平行光和环境光),加上每次附加光一次ForwardAdd。你写的 Pass 如果不带LightMode标签,Unity 就把它当成一个普通 Pass 无条件绘制一次,光照相关的东西全都不会被正确设置。
所以写带光照的 Pass,第一件事是打上标签:
Tags { "LightMode" = "ForwardBase" }然后你才有资格去读_WorldSpaceLightPos0、_LightColor0这些内置变量。这一点在 URP 下略有不同:URP 用的是UniversalForward这个 LightMode,光照数据通过Light结构体和GetMainLight()之类的函数获取,比内置管线的裸变量更规范,但概念上是一样的——你得先声明自己在光照流程里的角色,Unity 才会把数据喂给你。
4.2 阴影是怎么来的:为什么要单独一个 Pass
阴影的实现原理其实很直观:从光源的视角渲染一遍场景,只记录深度,得到一张阴影贴图;然后正常渲染时,把每个像素的世界坐标转换到光源空间,拿它的深度去跟阴影贴图里记录的值比较,比记录的值远就说明被挡住了,这个像素就处于阴影中。
关键点在于:第一遍"从光源视角渲染"这件事,是靠物体自己的 ShadowCaster Pass 完成的。也就是说,一个物体要投射阴影,它的 Shader 里必须有一个LightMode = ShadowCaster的 Pass。内置管线下通常靠Fallback "Diffuse"兜底,因为 Diffuse 里自带 ShadowCaster;但如果你写了一个完全自造的 Shader 又没写 Fallback,结果就是"物体能显示但投不出影子",而且没有任何报错。
URP 下这件事有两种做法:一是自己写ShadowCasterPass,把官方的ShadowCasterPass.hlslinclude 进来,调用它的ShadowPassVertex和ShadowPassFragment;二是直接用UsePass把官方 Shader 里的这个 Pass 引过来。第二种更省事,但要注意引用的路径必须正确,否则会静默失败。
4.3 画面不对时的排查链路
Shader 出问题的特点就是"没报错但画面不对",所以排查要靠系统性方法,而不是改一行试一次。我整理了一份常用的对照表:
| 现象 | 最可能的原因 | 定位手段 | 修复方向 |
|---|---|---|---|
| 物体整体发紫 | Shader 编译失败,Unity 用了错误 Shader | 选中物体看材质面板提示 | 检查 HLSL 语法和 include 路径 |
| 物体能显示但没影子 | 缺 ShadowCaster Pass | Frame Debugger 看不到阴影相关绘制 | 加 Fallback 或补 ShadowCaster Pass |
| 光照忽明忽暗 | 法线没做正确变换 | 关闭缩放后对比 | 用法线矩阵处理非等比缩放 |
| 透明物体互相遮挡 | ZWrite 没关 | 调整渲染队列观察 | ZWrite Off+ 正确的 Queue |
| 移动端一片黑 | 精度不匹配,half 溢出 | 换 float 对比 | 调整精度声明 |
| 编辑器正常,打包后异常 | Shader 变体被裁剪 | 查看打包日志的变体统计 | 补充 Variant Collection |
这张表里的每一条我都实际遇到过。最值得说的是最后一条:编辑器里跑得好好的,打包到小游戏平台或者手机上就变黑。原因是 Shader 变体(Variant)在打包时被裁掉了,运行时找不到对应的分支。解决方式是把用到的 Shader 加进 Shader Variant Collection,或者在 URP 的管线设置里勾上"保留所有变体"(代价是包体变大)。这个坑在没有真机验证流程的团队里几乎是必踩的。
5. 手写第一个可用 Shader:从能跑到够用
理论说完了,落到实操。我建议的第一个练习不是做流光,而是一个最朴素的贴图着色器。原因很简单:它把整个流程里的每个环节都覆盖了一遍——Properties、CBUFFER、顶点变换、UV 采样、片元输出——但没有光照这种复杂度。把这一个写通,后面加光照只是往片元里塞公式。
5.1 一个 URP 下的最小模板
Unity 6 默认是 URP 管线,所以先给 URP 版本:
Shader "Custom/UnlitTexture" { Properties { _MainTex ("主贴图", 2D) = "white" {} _Color ("颜色", Color) = (1, 1, 1, 1) } SubShader { Tags { "RenderType" = "Opaque" "Queue" = "Geometry" } LOD 100 Pass { Name "Unlit" Tags { "LightMode" = "UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float fogFactor : TEXCOORD1; }; TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; half4 _Color; CBUFFER_END Varyings vert (Attributes IN) { Varyings OUT; OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz); OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex); OUT.fogFactor = ComputeFogFactor(OUT.positionHCS.z); return OUT; } half4 frag (Varyings IN) : SV_Target { half4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); half3 color = tex.rgb * _Color.rgb; color = MixFog(color, IN.fogFactor); return half4(color, tex.a * _Color.a); } ENDHLSL } } Fallback "Universal Render Pipeline/Unlit" }逐段解释几个容易卡住的点。TEXTURE2D和SAMPLER分开声明是 URP 的要求,内置管线的sampler2D _MainTex是两者合一的写法,URP 为了平台兼容性把它们拆开了,采样时必须成对传入SAMPLE_TEXTURE2D。_MainTex_ST里的 ST 是 Scale 和 Translate 的缩写,配合TRANSFORM_TEX使用,才能让材质面板上的 Tiling 和 Offset 生效——忘了这两行,美术调 Tiling 没反应,会被追着问。CBUFFER 的包裹不是可选项,前面说过,它关系到 SRP Batcher 能否生效。
5.2 内置管线的等价写法
如果你的项目还在用内置管线,同一个功能的写法差别不小:
CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 c = tex2D(_MainTex, i.uv) * _Color; return c; } ENDCG对比后能看出几个差异:内置管线用CGPROGRAM和UnityCG.cginc,变量直接在全局声明,没有 CBUFFER 这套东西;精度可以用fixed,URP 下更推荐统一用half。注意fixed在部分移动端 GPU 上其实就是half,不要指望它能省一半寄存器,写half更诚实一些。
5.3 移动端和小游戏平台的取舍
如果你的目标平台是小游戏或者移动端,有几个约束必须先知道,否则做出来的效果在编辑器里好看,上真机就崩。
Shader 变体的数量直接决定包体大小和首次加载耗时。multi_compile每加一个关键字,变体数量就翻倍。一个只用到一个分支的关键字,用shader_feature更好,它只在材质实际用到时才编进包。贴图采样的次数是移动端性能的大头,能用一张图解决的就别用两张,能打包成图集的就打包,因为采样器的切换会造成 GPU 状态切换。
还有一个实践中的经验:小游戏平台对 Shader 编译的容错很低,能不用分支就别用。if在 GPU 上不是像 CPU 那样跳转,两个分支往往都会被执行然后选一个,代价是常数级的浪费。对于简单的效果,用lerp或者数学运算替代if往往更快,可读性也不差。
5.4 怎么验证你写的东西真的在跑
写完不是看一眼画面就完事了。我常用的三个手段:Frame Debugger(Window → Analysis → Frame Debugger)能让你逐条查看 Draw Call,看你的 Pass 到底有没有被调用、调用了几次、用了哪个 Shader,这是排查"Pass 被忽略"的第一工具。RenderDoc可以抓一帧做深度分析,适合查渲染目标、混合状态这种更底层的细节。Shader 变体统计在打包日志里会输出,能提前发现变体爆炸。
顺便提一个我经常用的小技巧:在片元着色器里临时return half4(1, 0, 0, 1);,看看物体是不是变成纯红。如果变了,说明 Pass 确实在跑,问题出在计算逻辑;如果没变,说明 Pass 根本没被调用,问题在标签或渲染队列上。这一个动作能省掉一半的排查时间。
6. 跨管线与进阶:知道边界在哪
把基础打通之后,接下来会遇到的最现实的问题就是管线差异。内置管线、URP、HDRP 三套写法互不兼容,这不是 Unity 故意为难人,而是因为它们的设计目标不同:内置管线追求兼容性,URP 追求跨平台一致性和性能可控,HDRP 追求高保真。
| 对比项 | 内置管线 | URP | HDRP |
|---|---|---|---|
| 代码块 | CGPROGRAM/ENDCG | HLSLPROGRAM/ENDHLSL | 同 URP |
| 核心库 | UnityCG.cginc | ShaderLibrary/Core.hlsl | HDRP 专属库 |
| 坐标变换 | UnityObjectToClipPos | TransformObjectToHClip | 同 URP |
| 光照获取 | _WorldSpaceLightPos0等全局变量 | GetMainLight()等函数 | 光照循环宏 |
| 阴影 Pass | Fallback 兜底 | include 或 UsePass | 自带 Pass 库 |
跨管线移植时,最容易出问题的不是函数名,而是光照的取法。内置管线里那些_WorldSpaceLightPos0是 Unity 在渲染前帮你塞进去的全局变量,URP 里没有这些变量,必须通过GetMainLight()拿Light结构体。硬搬代码的结果是编译能过但光照全黑,这种问题调试起来很费时间。
关于学习路径,我的建议是先把手写 Unlit Shader 写熟,再去看官方 Shader 的源码。URP 的官方 Shader 在Packages/com.unity.render-pipelines.universal/Shaders/下,Unlit.shader、SimpleLit.shader这两个文件非常适合作为对照阅读的材料,它们的结构清晰,注释也够用。等你把SimpleLit读通了,再回头看《Unity Shader 入门精要》这类书里的光照公式,会发现一切都能对得上。
我自己踩过的一个认知弯路值得一提:刚学的时候总想着"我要写一个通用的 Shader 解决所有问题",结果写出来的东西又长又慢,还到处是兼容性 bug。后来才明白,Shader 的正确思路是"一个效果一个 Shader",让它只做一件事,参数暴露清楚,剩下的交给材质去配。这个思路转变之后,我写的 Shader 反而越来越短,出问题的次数也少了很多。