ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Shader是什么?从渲染管线到光照模型的Unity入门指南

Shader是什么?从渲染管线到光照模型的Unity入门指南

写这篇文章的时候,我一直在想怎么把“Shader是什么”这个问题讲透。因为如果你是Unity的老用户,一定有过这样的时刻:C#代码写得飞起,一到Shader就发怵,网上搜了一堆资料,全是矩阵、向量、法线贴图,每个字都认识,连起来就是看不懂。这篇内容不打算给你塞一堆高深的理论,而是从渲染管线的角度,把Shader放在它真正的位置上,讲清楚它到底在GPU里做了什么、为什么程序员需要换一种思维来写它、以及你最需要先掌握的是哪几个数学和光照概念。

如果你是个刚开始接触Unity图形学的开发者,或者你写了几年业务代码但一直没敢碰Shader,这篇内容应该是为你准备的。我会尽量用大白话+实际场景,把Shader这个被神话太多的概念,拆成能动手理解的东西。

1. 先搞清楚Shader在渲染管线里站哪个岗

很多人一上来就去背Shader的语法、研究Surface Shader和ShaderLab的写法区别,结果越学越乱。我建议你先别碰代码,先理解一件事:Shader只是渲染管线里的一个环节,它不是独立存在的。

1.1 渲染管线的“工厂流水线”类比

把GPU渲染一张画面想象成一条工厂流水线。你从场景里扔进去一堆数据——模型顶点、贴图、灯光信息、相机参数——流水线末端吐出来一张带颜色的像素图,也就是你在Game视图里看到的那帧画面。这条流水线分成几个固定的工位:顶点处理、图元装配、光栅化、片元处理、逐片元测试与混合。Shader就分别站在“顶点处理”和“片元处理”这两个工位上。

我想强调这个类比的原因是:初学者最常见的误区,是以为Shader是“改渲染效果”的魔法代码,改一行就全变了。实际上Shader只是规定了每一个顶点、每一个像素应该怎么被处理。它跟C#脚本的本质区别在于:C#脚本是在CPU上“一次性”地对游戏逻辑做操作,而Shader是在GPU上“同时且重复地”对成千上万个顶点、像素做同样的操作。

顶点Shader的职责很简单:把模型空间里的顶点坐标,经过一层层变换,最终变成裁剪空间里的坐标。片元Shader的职责更简单粗暴:给定一个像素位置,结合光照、贴图、法线等数据,算出这个像素最终显示什么颜色。这两者中间的光栅化阶段,是硬件自动完成的,你不能写代码干预。

1.2 为什么你必须先理解坐标变换

在写第一个Shader之前,我建议你先理解一个最核心的动作:模型顶点是怎么一步步变成屏幕像素的。这个流程叫顶点变换,它决定了你看物体的角度、远近、大小,甚至透视效果。

具体来说,一个顶点大概要走这几步:模型空间(Model Space)到世界空间(World Space),再到观察空间(View Space),再到裁剪空间(Clip Space),最后经过透视除法变成归一化设备坐标(NDC),再映射到屏幕坐标。你去翻Unity内置Shader源码,看到UNITY_MATRIX_MVP这个矩阵的时候,它其实就是把前几步打包在了一起,一个矩阵完成从模型到裁剪空间的变换。

这个流程特别重要,因为你在Shader里写的每个顶点位置、法线方向,都要记得它们当前在哪个坐标系里。很多人写出的Shader出现“物体转起来光照却不跟着转”的怪问题,十有八九是坐标系搞混了:比如把世界空间的法线拿去跟观察空间的向量做计算,结果自然是错的。

1.3 光栅化:GPU看不见的“翻译官”

这里有个概念值得单独拿出来讲清楚——光栅化。顶点Shader处理完所有顶点后,GPU会把这些顶点连成三角形,然后判断哪些像素被这个三角形覆盖。这个过程叫光栅化,是硬件固化的,不需要你写代码。

但光栅化有个副产品,叫插值。三角形三个顶点的数据,比如法线、UV坐标、颜色,会被硬件线性插值,填满三角形内部的每一个像素点。这个插值结果会作为输入传给片元Shader。这意味着你在顶点Shader里算的东西,到了片元Shader里不一定还保持原样——它可能已经被插值成了渐变值。

举一个实际例子:如果你在顶点Shader里算了一个高光反射,因为顶点之间的方向变了,那么三角形内部像素上看到的高光,是GPU把三个顶点的高光插值出来的结果。很多时候你会发现高光有棱角、不圆滑,这就是顶点级计算插值精度不够导致的。想更精细,就把计算放到片元Shader里做。这个点后面我还会细说。

2. GPU的思维模式和CPU完全不同:写Shader先换脑子

理解了Shader在管线里的位置,下一步要解决的是编程思维的转变。我见过太多从C#转过来的开发者,把Shader当成C#来写,写出来的代码要么性能极差,要么根本跑不对。这里面的核心差异,是CPU和GPU的执行模型完全不同。

2.1 “一个顶点一个像素,GPU不搞循环”

CPU是强控制流处理器:一条指令执行完再执行下一条,分支可以跳转,循环可以嵌套,逻辑非常灵活。GPU不一样。GPU的设计目标是高吞吐量的并行计算,它把任务拆成成千上万个独立的线程,同时跑。你要记住一句口诀:Shader里没有“循环遍历所有顶点”的逻辑,因为Shader本身就是被GPU循环调用的。

你写的顶点Shader,不是一段“处理所有顶点”的代码,而是“处理单个顶点”的代码。GPU会同时把它跑在成百上千万个顶点上。片元Shader同理:它是“处理单个像素”的代码。所以你在写Shader的时候,脑子里不许想着“foreach”,要想的是“我现在就是这个顶点/这个像素,我要对它做什么”。

这个思维转换一旦完成,你对Shader的很多困惑就会消失。比如你会明白,为什么Shader里的全局变量(比如时间_Time)是“每帧都一样的值”,但每个顶点拿到的uv、normal、position却各自不同——因为每个顶点在执行同一份代码时,用的是自己那一份输入数据。

2.2 分支与循环在GPU里的真实代价

C#里写if很自然,性能代价低。但Shader里写if,GPU会在所有并行线程之间做同步,可能让所有线程都走最慢的那条路径,这叫“分支发散”。极端情况下,一个像素进if分支,另一个像素跳else分支,GPU为了保持同步,两个分支都得算一遍。

这不是说Shader里完全不能写if,现代GPU对分支有了很多优化,但作为入门者,我建议你保持一个基本原则:能用数学表达式解决的,就不要用分支解决。比如判断一个值是否为负数然后取绝对值的逻辑,直接abs()就行,别写if (x < 0) x = -x;。能通过step、saturate、lerp这类内置函数实现的效果,就不要引入分支。

同样的逻辑也适用于循环。虽然Shader里允许for循环,但循环次数最好是个编译期常量,不要依赖运行期变化的变量。循环展开的代价在GPU上可能远超你的预期,尤其是在移动端GPU上,一个动态循环可能让帧率直接腰斩。

2.3 数据流:从CPU到Shader的通道

Shader不是封闭的,它需要跟外部通信。通信通道有三个:第一个是Properties,你在Shader里声明的属性,比如颜色、贴图、数值滑条,这部分的初衷是给美术在Inspector面板上调参用的;第二个是CG/HLSL里的uniform变量,可以在C#脚本里通过Material.SetFloat、SetColor、SetTexture等方式传进去;第三个是顶点输入数据,由MeshRenderer组件提供,包括顶点位置、法线、UV、切线等。

我遇到不少初学者在写Shader时会把前两个搞混:在Properties里声明了颜色,却在CG代码里不知道怎么取,或者反过来在CG里定义了一个变量,面板上却找不到。记住一个规律:Properties块里的声明负责“暴露给Inspector”,CG代码里的同名变量声明负责“真正供代码使用”,两者通过名字绑定。如果不想暴露给Inspector,也可以只在CG代码里声明,这样外部就调不到,完全由C#脚本控制。

3. 写Shader必须懂的那点数学:其实比你想的少得多

很多非科班出身的开发者一听到“图形学数学”就头大,觉得要回去重学线性代数。实际上,Unity Shader入门需要你熟练掌握的数学内容很少:向量加减、点积、叉积、矩阵的基本变换常识、以及理解坐标空间。这节我不会给你推公式,而是用实际Shader里的用途来反推每个数学概念的作用。

3.1 向量:颜色也能当向量用

向量在Shader里无处不在。最基础的用法是表示方向:光照方向、视线方向、法线方向。但稍微进阶一点,你需要习惯“向量的分量可以代表任何东西”。一个float3可以是位置,可以是方向,也可以是RGB颜色——因为它们底层就是三四个浮点数。

我举个例子:片元Shader里最常见的操作是“取贴图颜色然后调亮”,代码写出来大概是float3 color = tex2D(_MainTex, i.uv).rgb;,然后color *= 2.0;。这个“乘以2”发生在RGB三个通道上,和三通道向量放大完全是一个数学操作。你不需要给“颜色”单独特设一套运算规则,向量的分量运算天然适用于它。

另一个常见操作是lerp颜色混合,比如col.rgb = lerp(col.rgb, _FogColor.rgb, fogFactor);,这就是在三通道向量之间做线性插值,等价于col.r = lerp(col.r, _FogColor.r, fogFactor)对每个通道做一遍。这让你在处理颜色渐变、雾效、淡入淡出时,可以直接把整个向量丢进lerp,省很多事,也更容易理解。

3.2 点积:光照计算的第一块基石

点积(Dot Product)可能是Shader里用得最多的运算。它的几何含义很简单:两个单位向量的点积等于它们夹角的余弦值。也就是说,两个方向越接近,点积越接近1;方向完全相反,点积等于-1;方向垂直,点积等于0。

这个性质用在漫反射光照里非常直观。计算一个表面像素的亮度,看的是“光方向”和“法线方向”的接近程度。float diffuse = saturate(dot(normalDir, lightDir));——法线朝向光源,亮度就高;法线背向光源,亮度接近0,这就是Lambert漫反射模型的全部核心。加上一个max(0, ...)或saturate,是为了把所有负值压到0,避免背面出现负亮度这种无意义结果。

点积还能用于判断前后关系、边界过渡、边缘光等。比如模型边缘检测,常见思路是用“视线方向”和“法线方向”的点积值,边缘处法线几乎垂直于视线,点积接近0,于是可以用1 - dot(n, v)作为边缘光的强度。这个概念在很多Shader效果里反复出现,你掌握了点积的这层含义,看大部分特效Shader的代码都不会太懵。

3.3 叉积:求法线、求切线的利器

叉积(Cross Product)的概念比点积稍抽象,但在Shader里它的用途极其明确:给定两个不平行的向量,叉积能产生一个同时垂直于这两个向量的新向量。也就是说,它天然适合“求法线”。

最经典的场景是:你有一张高度图,想根据相邻像素的高度差计算法线方向,你会先算出沿着U方向的切线,再算出沿着V方向的切线,然后用叉积得到法线。还有,很多程序化生成Mesh的场景里,顶点顺序确定后,法线也是靠叉积算出来的。

还有个小技巧值得记一下:叉积结果的指向遵循右手定则,所以叉积的顺序会影响新向量的方向。cross(a, b)和cross(b, a)结果方向正好相反。如果你发现算出来的法线方向是反的,别慌,交换一下参数顺序就行。在Shader里因为坐标系习惯不同,这个坑很常见。

3.4 矩阵与坐标空间:别背公式,记住“空间”概念

矩阵变换的内容,我建议入门阶段不用深究每个矩阵怎么算出来的,而是要建立起“坐标空间”这个思维模型。一个顶点在世界空间里的坐标是(1, 2, 3),在相机空间里可能就是另一个数,因为坐标系原点不同、轴向不同。

Unity内置了常用变换矩阵,你在Shader里经常会看到这样几行:

  • UnityObjectToClipPos(v.vertex):模型顶点到裁剪空间,顶点Shader里必用。
  • UnityObjectToWorldNormal(v.normal):模型法线到世界空间。
  • UnityWorldSpaceViewDir(worldPos):从世界坐标指向相机的方向。
  • UnityWorldSpaceLightDir(worldPos):从世界坐标指向光源的方向。

这组内置函数帮你省掉了手动乘矩阵的麻烦。入门阶段,你只要搞清楚“我现在这个向量在哪个空间里”“我要把它弄到哪个空间里去”就够了。很多时候Shader效果不对,不是数学错了,而是你把两个不同空间的向量放在一起做了运算,这在几何上是没有意义的。

4. 从Lambert到Blinn-Phong:第一个光照模型的完整拆解

理论铺垫差不多了,我们来点实际的。这一节我带你拆解最经典的光照模型,从只有一个漫反射的Lambert开始,逐步加上高光,变成Blinn-Phong。这是你会在无数项目里遇到的基础光照框架,所有PBR、卡通渲染、风格化效果,都是在这些基础模型上演变出来的。

4.1 一个最简Shader的骨架拆读

我先给你看一个最简的漫反射Shader的代码结构,然后逐段解释每行在干嘛:

Shader "Custom/DiffuseSimple" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 200 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); return col; } ENDCG } } }

这个Shader本身不做光照,只是把贴图原样显示出来。但它的代码结构是你未来写所有Shader的模板。appdata是顶点输入数据声明,v2f是顶点Shader传给片元Shader的数据结构,vert做坐标变换和UV变换,frag采样贴图输出颜色。你注意看,顶点Shader的输出变量带上了TEXCOORD0和SV_POSITION这些语义标签,这些标签告诉GPU这些数据最终会被用在哪个阶段。SV_POSITION是顶点Shader必须输出的,它代表裁剪空间坐标,GPU要靠它做光栅化。

4.2 加入Lambert漫反射:理解光源和法线的作用

现在给这个Shader加上最简单的漫反射。你需要在顶点Shader和片元Shader之间传递法线方向,然后在片元Shader里计算光照。

v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag (v2f i) : SV_Target { float3 normalDir = normalize(i.worldNormal); float3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos)); float diff = saturate(dot(normalDir, lightDir)); fixed4 col = tex2D(_MainTex, i.uv); col.rgb *= diff; return col; }

这里有个非常重要的工程细节:为什么法线要在顶点Shader里算好再传到片元Shader,而不是在片元Shader里访问原始法线?因为v.normal只在顶点Shader阶段可用,进了光栅化之后GPU只认你已经输出的数据,你要不懂把这个数据传给片元Shader,片元阶段就拿不到它了。

另外还有一件事我要多说一句:这个漫反射计算是在片元Shader里做的,片元数量远多于顶点数量,所以计算量比在顶点Shader里大得多。但好处是每个像素都有自己插值后的法线,效果更平滑。如果你的目标平台是高端PC,这不是问题;如果你在写移动端Shader,就要认真评估是否需要把光照计算放到顶点Shader里。一个折中思路是:对环境要求不高的地方用顶点光照,主角、重要特效再用片元光照。

4.3 从Lambert到Blinn-Phong:高光到底在算什么

漫反射之外,光照模型还要解决另一个现象:高光。金属、塑料、水面这些表面,在特定角度会有一个很亮的斑点,这就是镜面反射,也就是高光。Blinn-Phong模型用一个非常巧妙的近似来模拟它:它不直接算反射向量,而是算“半角向量”。

半角向量(Half Vector)是光照方向和视线方向的中间方向。如果这个半角向量跟法线方向越接近,说明光线经过表面反射后越有可能进入你的眼睛,高光就越强。代码上看是这样的:

float3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos)); float3 halfDir = normalize(lightDir + viewDir); float spec = pow(saturate(dot(normalDir, halfDir)), _Shininess);

_Shininess是高光指数。指数越大,高光斑块越小越锐利,材质显得越“亮越硬”;指数越小,高光范围越大越柔和,材质显得更“哑光”。这个pow操作是Shader里非常常见的用法,它负责控制高光的衰减速度。

加上高光后的最终颜色就是:

col.rgb *= (diff + spec * _SpecColor.rgb);

这里diff控制漫反射亮度,spec控制高光强度。

4.4 为什么现代渲染都在说“PBR也会用到这些基础模型”

如果你已经接触过Unity的URP、HDRP的Lit Shader,应该会看到一堆复杂的参数——金属度、粗糙度、AO、遮蔽贴图。很多人会觉得自己学的Blinn-Phong过时了。但实际上,PBR的微表面模型里,Cook-Torrance BRDF这个高光项(包含法线分布函数、几何遮蔽函数、菲涅尔项)在概念上跟Blinn-Phong是一脉相承的:都在描述微表面法线的统计分布,只是表达方式更物理化。

我的建议是:先把Blinn-Phong吃透,它给你一个“光照亮度是怎么被算出来的”直觉模型,这个直觉会在你理解PBR参数时帮大忙。比如PBR里的法线分布函数,本质上也是在高光计算里做“法线和半角方向接近程度的度量”,只是用了更复杂的公式来模拟不同粗糙度下的反射表现。你如果直接去啃PBR代码,很容易被一堆三角函数吓退;但你要是带着Blinn-Phong的基础去读,代码里每一个项是干嘛的,会清晰很多。

5. 坐标系、法线变换和常见姿势:最容易踩的坑盘点

有了理论基础,也看了一段代码,这一节专门做“踩坑预防”。Shader新手最容易栽的坑,基本都是坐标系和法线变换相关的。这些坑一旦踩过,解决起来非常费时间,因为你可能根本想不到问题出在“空间不匹配”这么基础的原因上。

5.1 坑一:法线直接拿模型变换矩阵乘,结果偏了

有一个现象特别常见:模型旋转了,光照却不跟着旋转,或者物体变形了,高光斑块位置错乱。原因往往是你在Shader里手动做了法线变换,但方法不对。

模型空间的法线不能直接套用顶点位置的变换矩阵。如果模型发生了非均匀缩放(比如X轴拉长、Y轴缩短),变换后的法线不再垂直于表面。正确做法是使用“逆转置矩阵”变换法线。当然,Unity已经帮你封装好了,你用UnityObjectToWorldNormal(v.normal)而不是自己手写矩阵乘法,就能避开这个坑,因为Unity内部已经处理了逆转置。

我见过不少人觉得“反正我项目里没有非均匀缩放,直接乘就行”,然后某天美术在场景里随手拉伸了一个物体,效果就开始出问题。认真做渲染的话,别自己碰那个矩阵,直接走Unity封装,省心。

5.2 坑二:忘了归一化

向量归一化,也就是normalize,是Shader代码里极其常见的操作。但很多人写了光照,发现高光位置飘忽不定、边缘过渡不自然,就是因为在做点积之前没有对向量做归一化。

点积的几何意义建立在单位向量上。如果dot(normalDir, lightDir)里的两个向量长度不是1,算出来的“夹角余弦值”其实混合了向量长度的缩放信息。虽然最后saturate能压到[0,1]范围,但过渡形状就不对了。在顶点Shader里算完的向量经过插值之后,长度也会被干扰:两个单位向量插值之后,长度大概率不是1。所以在片元Shader里使用之前,尽量normalize一次。

这个坑在GLSL/HLSL里都没有自动纠错,代码不会报错,颜色也会显示,但效果就是“差那么一点”。排查起来特别隐蔽。

5.3 坑三:UV坐标和屏幕坐标分不清

再一个常见问题:拍屏变颜色不对、贴图错位、UV动画方向反了。UV坐标(TEXCOORD)是模型顶点的属性,值在[0,1]范围(或者超出做Tiling),它表示采样贴图时的横纵位置。屏幕坐标(VPOS/ScreenPos)是片元在屏幕上的像素位置,它跟相机、分辨率都有关。这两个概念在新手期极其容易混。

最典型的场景是做“屏幕后处理效果”:你想根据像素在屏幕上的位置做渐变,却用UV坐标去算,结果物体转起来效果跟着转。屏幕后处理需要的是屏幕坐标,你可以在片元Shader里通过VPOS语义拿,也可以通过ComputeScreenPos把裁剪坐标转成屏幕坐标。这个区别你得心里有数,别出现“UV动画误以为是屏幕空间动画”的乌龙。

5.4 坑四:在不对的平台上假设了精度

Shader里浮点精度也是个容易被忽略的坑。PC平台用float(32位)没问题,但移动端GPU对高精度浮点的支持差异很大。很多移动端Shader为了性能,默认用half(16位)或fixed(11位)。如果你在移动端用fixed存一个范围很大的坐标值(比如世界坐标),精度可能不足,导致画面抖动、贴图闪烁。

这算是工程层面的建议:Shader里的代码,能明确指定精度的就把精度写明白,float3用于坐标、方向这类空间数据,fixed4/half4用于颜色、法线这类颜色相关中间量。不要太较真,但至少要有这个意识。特别是URP环境下,大家都在推移动端渲染,精度问题出现的频率比我以前写内置渲染管线时高很多。

6. 工具链推荐:没有这些工具,Debug Shader等于盲人摸象

写Shader有一个很无奈的事情:它不像C#那样有断点调试。你没法在GPU的某个线程里暂停,看看某个变量的值是多少。所以“可视化Debug”就成了Shader开发里必须掌握的技能。我分享几个我日常开发中最常用的手段,每一个都能帮你在遇到疑难时定位问题。

6.1 用颜色输出调试数据

最原始的Debug方式:把你想看的任意数值,直接当成颜色输出去渲染。比如你想知道顶点法线方向对不对,直接在片元Shader里写:

return fixed4(normalDir, 1.0);

法线的分量范围是[-1, 1],颜色通道范围是[0, 1],所以输出之前最好乘0.5加0.5。这样负值也能被映射到可见范围。你想要红色通道看到法线的X分量、绿色看到Y分量、蓝色看到Z分量,这个映射会让你直观看到法线方向的大致分布。

这个方法虽然土,但排查“空间变换错误”极其有效。我每次写新Shader,第一步都是先把中间变量可视化出来:UV、法线、世界坐标、视线方向、光照方向,轮流丢到颜色里看一眼。先确认管线里的每一步数据都符合预期了,再算最终颜色。没有这步,直接写完一跑,效果不对的时候,你根本不知道是哪个环节出了问题。

6.2 活用Unity的Frame Debugger

Unity编辑器里有个内置的Frame Debugger,可以在Window > Analysis里找到。它能一帧一帧地看渲染的每个Draw Call,并且能看到每个Draw Call用了哪个Shader、传了哪些参数、绑定了哪些贴图。

看起来它是个“性能分析工具”,但我在写Shader的时候经常用它做正确性检查。比如我写了一个多Pass的Shader,第二Pass的效果没出来,我就可以通过Frame Debugger看这个Pass有没有被真正执行,绑定出现了什么状态。原理上它就是在告诉你:你写的Shader代码,最终被GPU执行时的完整状态。这比你在碎片化的代码脑补里猜要可靠得多。

6.3 学会用Shader Property在面板上显示中间值

另一个我好用的调试姿势是:在Shader的Properties块里暴露一个开关或滑块,用来切换调试模式。

Properties { [Toggle(_DEBUG_NORMAL)] _DebugNormal ("Debug Normal", Float) = 0 [Toggle(_DEBUG_UV)] _DebugUV ("Debug UV", Float) = 0 }

然后在片元Shader里:

#ifdef _DEBUG_NORMAL return fixed4(normalDir * 0.5 + 0.5, 1.0); #endif #ifdef _DEBUG_UV return fixed4(i.uv, 0, 1.0); #endif

这样你不用反复改代码重编译,直接在Inspector面板上拨动开关就能快速切换显示模式。团队协作时也很有用:给美术这样一套调试开关,他们自己就能看是UV问题还是法线问题,不用每次改一点都来麻烦你。我做个项目时,几乎都会预留几个这样的调试开关放在Shader的底部区域,省了非常多沟通成本。

6.4 关于Shadertoy这类外部工具的坑

网上很流行用Shadertoy这类在线平台展示Shader效果,你可以看到很多炫酷的图形学Demo。但我想提醒你,那里的Shader和Unity里的Shader不是一个物种。Shadertoy是GLSL写的,而且它的模型是“全屏像素着色”,没有顶点变换、没有网格数据、没有相机矩阵,它直接在片元阶段用屏幕坐标模拟一切。

很多人会犯的错是:在Shadertoy上看中一个效果,想把它搬到Unity里,结果发现完全不知道怎么搭。因为Unity里你要考虑Mesh、UV、法线、相机矩阵、光照这些基础管线输入,而Shadertoy全都帮你省略了。我不是说Shadertoy不能学,上面的数学和特效思路非常有启发,但要记住:它提供的是一套独立的约定,和Unity的标准Shader之间需要一个“翻译”的过程。初学者最好先用Unity内置的Shader模板跑通整个管线,再去看Shadertoy的花活。

7. 一套适合入门的实操练手顺序(含目标与自检)

我知道很多人看完理论手痒,想马上动手写点东西。这一节我按门槛从低到高,给你一条实操练手路线。每个阶段我都写清楚目标、核心练习内容,以及“做出来什么效果就算过关”。

7.1 第一阶段:跑通一个纯色Shader

目标:理解Shader文件的完整骨架、Pass结构、顶点Shader与片元Shader的分工。

练习内容:新建一个Shader,输出一个纯红色物体,不需要贴图不需要光照,就是fixed4(1, 0, 0, 1)。然后再改成输出绿色、蓝色,改成按UV渐变:

fixed4 frag (v2f i) : SV_Target { return fixed4(i.uv.x, i.uv.y, 0, 1); }

如果物体上出现了一个左下角黑色、向右变红、向上变绿的渐变矩形,并且渐变方向不随相机旋转而改变(因为UV是模型属性),你就算入门了。

做这个练习的时候,你最好多建几个形状——立方体、球体、胶囊体——看一看同一个UV渐变在模型上怎么分布的。这能帮你建立“贴图UV跟模型表面形状”之间的直观联系,这个感觉比背一百行理论都有用。

7.2 第二阶段:给物体加一个可调颜色属性

目标:理解Properties→CG变量→Inspector面板的数据流。

练习内容:在Properties里声明一个颜色属性_Color,在片元Shader里用它乘以贴图颜色或直接作为输出色。然后在Inspector面板里拖动色环,观察物体颜色变化。

自检标准:如果你能通过C#脚本在运行时修改这个颜色,并看到物体实时响应,你就掌握了Material.SetColor这一类通信方式。这一步非常关键,因为后续所有动态Shader效果,比如受击闪白、蓄力变色、选中高亮,都是靠这种数据驱动的思路实现的。

7.3 第三阶段:实现完整的Blinn-Phong光照

目标:把前面讲的光照数学串起来,写出漫反射+高光的完整Shader。

练习内容:不依赖Surface Shader,用顶点/片元Shader自己实现一个带主纹理、漫反射、高光的材质。你需要把模型法线、世界坐标从顶点Shader传到片元Shader,并正常使用主方向光。

自检标准:在场景里放一个球体,加上一个平行光,旋转灯光角度,观察球体亮度变化是否自然;再切换相机角度,观察高光位置是否始终位于“光源方向与相机方向的半角方向”上。如果高光会跟着相机正确移动,说明你对空间变换、半角向量这些概念的掌握已经过关了。

7.4 第四阶段:给Shader加动画参数

目标:学会通过_Time变量驱动简单的Shader动画。

练习内容:在片元Shader里用_Time.y对UV做偏移,实现一个滚动纹理;再用sin(_Time.y)控制整体亮度脉动。

自检标准:当你看到纹理均匀移动、物体呼吸般变亮变暗时,你就已经走完Unity基础Shader的闭环了:外部参数传入(Properties/C#)→顶点/片元处理→随时间变化的动态输出。这个闭环能力,足够你应付日常游戏开发中70%的Shader需要——因为你遇到的很多“效果”,本质就是“某个变量随时间变化、按某种数学函数驱动颜色输出”。

8. 调试Shader时的几个反直觉经验(我从实战里总结的)

Shader开发只有理论是不够的,你一定会碰上各种各样“看起来完全没道理”的问题。我把自己实际写Shader遇到的高频问题按现象、原因、解决思路整理成了一张表,以后你遇到类似情况可以直接对着查:

现象常见原因排查思路
物体显示全黑或全白光照方向或法线方向计算错误、点积被完全饱和先写死一个中间颜色输出,确认顶点/片元数据是否正常
物体呈半透明状或渲染顺序错乱没有设置正确的渲染队列/RenderType,或混合模式错误检查Tags里的Queue设置,确认Blend是否开得合理
高光位置不随相机动视线方向算错了,可能是用了世界坐标和模型坐标混算统一所有向量在同一空间,再试一次
UV贴图方向不对模型导入设置里的UV方向或贴图Tiling/Offset值不对先输出纯UV颜色看看分布,再调整Tiling和Offset
画面闪烁、抖动浮点精度问题检查是否用fixed存了大范围坐标值,改为half或float
移动端比PC端暗/亮精度和光照模型的差异确认Shader有没有针对移动端做半精度优化或降级逻辑

这里面有三条我想再展开说说,因为它们对不同项目的影响往往是“隐性但致命”的。

第一条,关于“全黑或全白”。新手看到全黑的第一反应总是怀疑贴图,但我建议永远先怀疑“数据链路”——节点Shader输出的颜色是不是理想的?法线方向是不是对的?光照方向是不是朝向物体?我用一个“彩色渐变测试法”快速定位:先让物体显示基于UV的彩色渐变,如果正常,说明顶点数据链路OK;再输出法线颜色,如果正常,说明空间变换OK;这样逐层排查,一般5分钟内能锁定问题环节。

第二条,关于“半透明渲染顺序错乱”。这件事印象很深:有一次我写一个两层的透明特效,连续调了两个小时渲染顺序都不对。后来发现透明物体要按渲染队列写Queue=Transparent,同时要关闭深度写入(ZWrite Off),还要考虑多个透明物体之间的由远到近排序。透明Shader涉及的状态管理比不透明Shader复杂得多,不是简单把alpha调小就能用的。你初学阶段,先把不透明渲染跑明白,透明相关的状态组合,建议放到有需求时再研究。

第三条,关于“闪烁”。这个问题的隐蔽性极高,因为它不是每次都发生,而是“相机距离不同时出现不同的闪烁程度”。这是典型的深度冲突或精度不足。如果你是新手,看到画面有规律性条纹闪动,先别怀疑光照,去看是不是Shader里某个值被存成了低精度,把精度调高百分之八十能解决。

9. 最后一个很实在的问题:我应该先学内置渲染管线还是URP

这个问题版本的Unity环境里特别尖锐,因为Unity这几年把重心完全放到了URP和HDRP上,很多新项目一开始就直接用URP模板。那么学Shader的时候,该用哪个?我的答案很简单也很务实:直接学URP下的Shader写法,但底层的数学和光照模型知识完全通用。

如果你用的是URP,写Shader时你会看到HLSL文件、#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"、Varyings结构体这些新东西。表面上看跟内置管线的写法差很多,但本质还是那套:顶点变换、法线变换、光照向量计算、片元颜色输出。URP自己有封装好的光照函数,比如UniversalFragmentPBR、GetMainLight()这些,它们帮你把复杂的PBR计算包起来了,你要做的是学会怎么调用它们。

我之前遇到过一个人用内置渲染管线的老教程学了一两个月,结果进公司项目一看是URP环境,Shader代码完全用不上,整个人是崩溃的。这个例子看着惨,但我真心觉得如果能在初学阶段就直接用URP,你可以少走至少一半弯路。当然,网上很多老资源都是内置管线的,我也不是说完全不能看——那些讲UnityObjectToClipPos、讲点积叉积原理的,永远不过时。但凡是涉及具体Shader代码的,建议你优先搜“URP”版本,避免学了一堆和当前工程对不上的API。

另外提醒一个URP相关的坑:URP下很多Shader需要挂在对应Renderer上才会正常处理,而且LightMode标签跟内置管线不一样。你从网上抄一个内置管线的Shader放进URP项目里,很可能会得到“透明了但没光照”或者“完全不显示”的奇怪结果。遇到这种情况,别怀疑自己,去确认下Shader的Pass里有没有加"LightMode" = "UniversalForward",这个标签在URP里很重要。

10. 关于“看懂Shader源码”这件事:一个实用方法论

到了最后这一节,我想跟你聊聊“怎么有效地看别人的Shader代码”。这是很多自学者最迷茫的地方:网上教程一大把,源码也开源,但自己看的时候总感觉像是在看天书,每行字都认识,串起来就不知道在干嘛。

我自己的方法论是“五步定位法”。第一步,先弄清楚这个Shader用在什么材质上、产生什么视觉特征,也就是只看效果不看代码;第二步,打开Shader文件,先看Properties,了解哪些参数可调,这是快速理解“这个Shader能干嘛”的最短路径;第三步,看SubShader和Pass的标签,了解它的渲染队列、光照模式、合批策略等状态设置;第四步,看顶点Shader的输出结构,明确它从模型拿了什么数据、给片元传了什么数据——这一步能帮你快速摸清数据流;第五步,也是最后一步,才去看片元Shader里的具体计算逻辑。

这套顺序的核心逻辑是:从外部表现到内部状态,从数据结构到计算逻辑,一层一层剥开,而不是直接一头扎进最底层的数学公式里。很多初学者习惯打开一个复杂的Shader直接从头读到尾,这就像拿到一本悬疑小说先看最后一页,信息高度压缩、上下文缺失,自然看不懂。

我还建议你手边常备一个“我的Shader抄写本”。我会把自己写过的、改过的效果满意的Shader都存下来,按效果类型分类:边缘光、溶解、扭曲、描边、菲涅尔、程序化纹理……下次做新效果时,先翻自己的本子,从已有基础上改,而不是总从零开始。你写多了会发现,90%的新效果都是旧效果的组合和参数调整,真正从零发明的场景极少。

说句实话,Shader学习最难的阶段就是前两周——概念太多、坐标系太抽象、调试手段太少。但只要扛过这个阶段,你能看懂的代码会越来越多,之后的学习速度会骤然加快。有一天你会发现自己能直接把脑海里的一个视觉想象,翻译成一串数学函数和坐标变换的组合,那种感觉是很爽的。我希望这篇内容能帮你少走一点弯路,早一点到达那个阶段。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进