1. 项目概述从“黑盒”到“可控”的渲染之旅如果你刚开始接触Unity或者已经能用C#脚本让角色跑跳、让UI动起来但一看到材质球上那些复杂的参数和神秘的Shader代码就感到头疼那么这篇笔记可能就是为你准备的。我最初学Unity时也经历过这个阶段模型导进来了灯光打好了但总觉得画面“差了点意思”——要么是塑料感太重要么是特效不够炫想调整一下颜色或质感却发现除了拖拽那几个内置的滑块完全无从下手。Shader这个决定屏幕上每一个像素最终颜色的程序就像是一个封装好的“黑盒”我们知其然却不知其所以然。《Unity Shader入门精要》这本书正是为了打破这个“黑盒”而写的。它不满足于教你如何使用Surface Shader模板快速实现一个效果而是试图带你深入GPU渲染的流水线理解从顶点数据到最终片元像素的完整过程。我的学习笔记将围绕“编写入门”这个核心记录下如何从一个只会使用Standard Shader的“用户”转变为能够动手编写简单Shader、理解其背后原理的“创造者”。这个过程的核心价值在于“可控性”——当你理解了Shader如何工作你就能精确地控制光影、颜色、纹理混合甚至创造出独一无二的视觉风格这对于技术美术、图形程序员或任何希望提升游戏视觉表现力的开发者而言都是至关重要的能力提升。2. 核心概念与渲染管线拆解理解Shader的舞台在动手写代码之前我们必须先搭建起正确的认知框架。Shader不是孤立存在的魔法它是GPU渲染管线Rendering Pipeline中几个特定阶段的可编程单元。你可以把渲染管线想象成一条工厂流水线模型数据顶点从一端进入经过一系列加工变换、裁剪、着色最终成为屏幕上的像素从另一端出来。2.1 可编程管线的关键阶段对于初学者我们主要关注其中两个可编程阶段顶点着色器Vertex Shader和片元着色器Fragment Shader在DirectX中常称为Pixel Shader。顶点着色器是流水线的第一个可编程环节。它的输入是每个顶点的模型空间坐标、法线、纹理坐标等属性。它的核心任务通常只有一个顶点变换。即将顶点从模型空间Model Space依次转换到世界空间World Space、观察空间View Space最后到齐次裁剪空间Homogeneous Clip Space。这个空间下的坐标会被用于后续的裁剪和屏幕映射。虽然它叫“着色器”但在这里几乎不做任何与颜色相关的计算它的输出是变换后的顶点位置以及其他需要传递给片元着色器的数据如纹理坐标、法线等。注意很多初学者会困惑为什么要在顶点着色器里算法线。这是因为光照计算通常在世界空间或观察空间进行而模型自带的法线是在模型空间的。我们需要将法线随顶点一起乘以模型矩阵的逆转置矩阵这是一个关键但易错点用于处理非均匀缩放将其转换到正确的空间再传递给片元着色器进行逐像素光照计算。片元着色器是决定最终像素颜色的地方。它接收来自顶点着色器经过光栅化插值后的数据例如插值后的纹理坐标、法线、视角方向等。在这里我们会进行大量的计算采样纹理、计算光照漫反射、高光反射、处理透明混合、应用屏幕后处理效果等。片元着色器的输出是一个颜色值RGBA这个值将被送入后续的混合Blending和测试Testing阶段最终决定是否以及如何更新帧缓冲区Frame Buffer中的像素。2.2 Unity Shader的三种形态Unity为了简化开发封装了三种主要的Shader编写形式理解它们的区别是入门的关键表面着色器Surface Shader这是Unity最高级别的抽象。你不需要直接编写顶点/片元着色器而是编写一个“表面函数”surf在其中描述表面的漫反射颜色、法线、高光等属性。Unity会自动为你生成完整的顶点-片元着色器代码包括前向渲染路径下的多光源支持。优点是写起来快适合实现基于物理的材质。缺点是生成的代码庞大可控性低难以进行非常定制化的顶点变换或复杂的光照模型。顶点/片元着色器Vertex/Fragment Shader这是最灵活、最基础的形式。你需要完整地编写顶点着色器函数和片元着色器函数对渲染管线有完全的控制权。本书和本笔记的重点正是这种形式。它让你能清晰地看到数据流动的每一个环节是实现自定义效果如扭曲、顶点动画、特殊溶解的必经之路。固定函数着色器Fixed Function Shader一种较老的、通过配置一系列渲染状态如设置纹理、混合模式而非编写完整程序来实现效果的方式。在现代可编程管线中已不常用主要用于支持非常老旧的硬件新手可以暂时忽略。选择建议对于入门学习强烈建议从顶点/片元着色器开始。虽然初期会比使用Surface Shader麻烦但这是真正理解Shader原理的唯一途径。掌握了它你再回头看Surface Shader会发现它只不过是一个帮你处理了多光源和阴影的“语法糖”其内核原理你已了然于胸。3. 第一个Shader从“Hello World”到漫反射光照理论说得再多不如动手写一个。我们的第一个目标不是炫酷的效果而是建立一个正确的、可工作的Shader框架并实现最基础的漫反射光照。3.1 ShaderLab的基本结构Unity中的Shader文件使用一种名为ShaderLab的声明式语言来组织。一个最基本的Shader文件结构如下Shader Custom/MyFirstShader // 在材质下拉菜单中的路径 { Properties // 属性块定义在材质面板中可调节的参数 { _MainTex (Texture, 2D) white {} // 定义一张主纹理 _Color (Color, Color) (1,1,1,1) // 定义一个颜色 } SubShader // 子着色器块可以包含多个以适应不同显卡 { Tags { RenderTypeOpaque } // 标签告诉渲染引擎这是什么类型的物体 LOD 100 // 细节级别 Pass // 一个Pass代表一次渲染绘制 { CGPROGRAM // 开始CG/HLSL代码段 #pragma vertex vert // 声明顶点着色器函数名为 vert #pragma fragment frag // 声明片元着色器函数名为 frag // 包含Unity内置的常用函数和变量 #include UnityCG.cginc // 从Properties块传递变量到CG代码 sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放偏移值命名必须为“纹理名_ST” fixed4 _Color; // 定义从模型到顶点着色器的输入结构 struct appdata { float4 vertex : POSITION; // 顶点位置模型空间 float2 uv : TEXCOORD0; // 第一套纹理坐标 float3 normal : NORMAL; // 顶点法线模型空间 }; // 定义从顶点着色器到片元着色器的传递结构v2f struct v2f { float2 uv : TEXCOORD0; // 传递纹理坐标 float4 vertex : SV_POSITION; // 裁剪空间下的顶点位置必须 float3 worldNormal : TEXCOORD1; // 传递世界空间法线 }; // 顶点着色器函数 v2f vert (appdata v) { v2f o; // 将顶点从模型空间变换到裁剪空间核心操作 o.vertex UnityObjectToClipPos(v.vertex); // 应用纹理的缩放和偏移Tiling和Offset o.uv TRANSFORM_TEX(v.uv, _MainTex); // 将法线从模型空间变换到世界空间 o.worldNormal UnityObjectToWorldNormal(v.normal); return o; } // 片元着色器函数 fixed4 frag (v2f i) : SV_Target { // 采样纹理并与颜色属性相乘 fixed4 col tex2D(_MainTex, i.uv) * _Color; // 简化版漫反射计算法线与世界空间光照方向的点积 // _WorldSpaceLightPos0是第一个平行光的方向位置光则为向量 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float3 worldNormal normalize(i.worldNormal); float diff max(0, dot(worldNormal, lightDir)); // 将漫反射系数乘到颜色上 col.rgb * diff * _LightColor0.rgb; // _LightColor0是第一个平行光的颜色 return col; } ENDCG // 结束CG代码段 } } FallBack Diffuse // 如果所有SubShader都不支持则回退到内置的Diffuse Shader }3.2 关键代码解析与实操要点顶点变换UnityObjectToClipPos这是最关键的一行。它等价于mul(UNITY_MATRIX_MVP, v.vertex)旧版内部完成了模型-世界-观察-裁剪空间的连续矩阵乘法。永远使用这个内置函数不要自己拼矩阵乘法除非你有特殊需求。法线变换UnityObjectToWorldNormal同样使用这个内置函数。它内部处理了模型矩阵的逆转置确保法线在模型有非均匀缩放时也能正确变换。自己计算很容易出错。纹理变换TRANSFORM_TEX这是一个宏用于将原始的UV坐标v.uv与材质面板上设置的纹理缩放Tiling和偏移Offset应用起来。_MainTex_ST中的ST代表 Scale 和 Translation。光照计算我们实现了一个最简单的兰伯特Lambert漫反射模型。核心是计算表面法线worldNormal与光照方向lightDir的点积dot函数并用max(0, ...)确保结果不为负。_WorldSpaceLightPos0和_LightColor0是Unity为当前渲染的物体自动提供的最主要平行光的数据。实操心得第一次写Shader最容易卡住的地方往往是变量名拼写错误、语义Semantic声明遗漏如: POSITION或者CGPROGRAM/ENDCG块写错。建议严格按照上面的结构抄写确保能编译通过并看到一个受光照影响的物体。成功后尝试调整_Color属性或在片元着色器中直接返回fixed4(i.worldNormal * 0.5 0.5, 1.0)来可视化法线方向这是调试Shader的常用手段。4. 深入核心矩阵、空间与数据传递当第一个Shader运行起来后我们需要深入理解那些“黑盒”函数背后发生了什么以及数据是如何在不同着色器阶段间传递的。4.1 空间变换矩阵详解空间变换是顶点着色器的灵魂。Unity提供了一系列内置矩阵来帮助我们UNITY_MATRIX_M(或unity_ObjectToWorld):模型矩阵将顶点/法线从模型空间变换到世界空间。UNITY_MATRIX_V:观察矩阵将顶点从世界空间变换到摄像机观察空间。UNITY_MATRIX_P:投影矩阵将顶点从观察空间变换到齐次裁剪空间。UNITY_MATRIX_VP:观察投影矩阵是 V 和 P 的乘积。UNITY_MATRIX_MVP:模型观察投影矩阵是 M, V, P 的乘积即mul(UNITY_MATRIX_P, mul(UNITY_MATRIX_V, mul(UNITY_MATRIX_M, pos)))。UnityObjectToClipPos内部就是用它。为什么法线变换要用逆转置矩阵假设模型在X轴放大2倍。一个顶点坐标(1,0,0)会变成(2,0,0)。但一个法线(1,0,0)如果简单乘以同样的缩放矩阵会变成(2,0,0)其长度变成了2不再是单位向量且方向可能不垂直于拉伸后的表面。为了保持法线与表面的垂直关系需要使用原变换矩阵的逆转置矩阵进行变换。幸运的是对于刚体变换仅旋转和平移逆转置矩阵就是原矩阵本身所以只有缩放特别是非均匀缩放才需要特别注意。UnityObjectToWorldNormal帮我们处理了这一切。4.2 数据结构与插值原理从appdata到v2f再到片元着色器的输入i数据是如何流动的appdata: 定义了从3D模型Mesh中读取哪些数据。POSITION,NORMAL,TEXCOORD0这些是语义Semantics告诉GPU从哪个数据流中获取信息。模型必须包含这些数据流否则会出错或得到零值。顶点着色器它对每个顶点执行一次。输入是一个appdata结构体实例输出是一个v2f结构体实例。在这个阶段我们进行坐标变换和初步计算。光栅化RasterizationGPU将三角形转换为屏幕上的像素片段即片元的过程。在这个过程中三角形三个顶点输出的v2f数据如uv、worldNormal会根据片元在三角形内的位置进行线性插值。片元着色器它对每个片元可粗略理解为候选像素执行一次。输入i是插值后的v2f数据。这里有一个关键点在顶点着色器中归一化normalize的法线经过插值后长度很可能不再是1。因此在片元着色器中必须重新归一化才能用于正确的点积计算。这就是为什么我们在上面的示例中在片元着色器里又做了一次normalize(i.worldNormal)。注意事项传递的数据越多、越复杂插值的开销就越大。应尽量避免在v2f中传递不必要的向量或矩阵。例如如果光照计算在视角空间进行那么最好在顶点着色器中将法线和视线方向都变换到视角空间再传递而不是传递世界空间法线和世界空间顶点位置再到片元着色器中计算视线方向。5. 实现进阶效果纹理、透明与简单特效掌握了基础框架和原理后我们可以尝试实现一些更实用的效果。5.1 多纹理混合与法线贴图单一纹理往往不够。比如我们想实现一个地面有基础颜色纹理还有一个控制石块、草地分布的遮罩纹理Splatmap以及对应的法线贴图。思路在Properties中定义多个纹理和遮罩纹理。在片元着色器中采样遮罩纹理的R、G、B通道分别对应不同区域如R通道控制草地纹理的强度。然后将不同纹理采样的颜色按遮罩权重进行混合。对于法线贴图需要从切线空间变换到世界空间。// 在v2f结构中增加切线空间到世界空间的变换矩阵或切线/副切线向量 struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float2 uv_normal : TEXCOORD1; float3 tSpace0 : TEXCOORD2; // 世界空间切线方向 float3 tSpace1 : TEXCOORD3; // 世界空间副切线方向 float3 tSpace2 : TEXCOORD4; // 世界空间法线方向 }; // 在顶点着色器中构建这个矩阵 v2f vert (appdata_tan v) { // 使用带切线的appdata结构 v2f o; // ... 计算pos和uv ... float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldTangent UnityObjectToWorldDir(v.tangent.xyz); float tangentSign v.tangent.w * unity_WorldTransformParams.w; float3 worldBinormal cross(worldNormal, worldTangent) * tangentSign; o.tSpace0 float3(worldTangent.x, worldBinormal.x, worldNormal.x); o.tSpace1 float3(worldTangent.y, worldBinormal.y, worldNormal.y); o.tSpace2 float3(worldTangent.z, worldBinormal.z, worldNormal.z); return o; } // 在片元着色器中变换法线 fixed4 frag (v2f i) : SV_Target { // 采样法线贴图切线空间 fixed3 tangentNormal UnpackNormal(tex2D(_BumpMap, i.uv_normal)); // 变换到世界空间 float3 worldNormal; worldNormal.x dot(i.tSpace0, tangentNormal); worldNormal.y dot(i.tSpace1, tangentNormal); worldNormal.z dot(i.tSpace2, tangentNormal); worldNormal normalize(worldNormal); // 使用worldNormal进行光照计算... }UnpackNormal函数用于解压被压缩过的法线贴图通常是DXT5nm格式。构建TBN矩阵Tangent, Bitangent, Normal是使用法线贴图的标准流程虽然略显繁琐但Unity有内置的宏可以简化如TANGENT_SPACE_ROTATION。5.2 透明与混合效果默认的渲染状态是“不透明”Opaque会进行深度测试ZTest并覆盖之前的像素。要实现透明效果需要修改渲染状态。SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } LOD 100 // 关闭深度写入防止透明物体相互遮挡时出现排序问题 ZWrite Off // 设置混合模式源颜色当前片元颜色*源Alpha 目标颜色帧缓冲颜色*(1-源Alpha) Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM // ... 着色器代码 ... // 片元着色器返回的颜色需要包含Alpha通道 fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; // 可能根据纹理的Alpha或某个阈值来丢弃片元实现裁剪效果 // clip(col.a - _Cutoff); // 如果Alpha小于_Cutoff则丢弃该片元 return col; } ENDCG } }关键参数解析QueueTransparent将物体的渲染队列设为透明队列Unity会大致按从后到前的顺序渲染透明物体以减少混合错误。ZWrite Off关闭深度写入。透明物体通常不写入深度缓冲否则它后面的其他透明物体就无法被渲染。Blend SrcAlpha OneMinusSrcAlpha最常用的Alpha混合模式实现自然的透明叠加效果。5.3 简单的顶点动画与溶解效果通过修改顶点着色器中的顶点位置可以实现简单的动画。顶点波动正弦波v2f vert (appdata v) { v2f o; // 在模型空间Y轴方向添加一个基于世界XZ位置和时间的正弦波偏移 float4 worldPos mul(unity_ObjectToWorld, v.vertex); float wave sin(_Time.y * _WaveSpeed worldPos.x * _WaveFrequency) * _WaveAmplitude; v.vertex.y wave; o.vertex UnityObjectToClipPos(v.vertex); // ... 其他计算 ... return o; }_Time是Unity内置的时间变量_Time.y是自场景加载以来的秒数。通过将时间与顶点位置结合可以创造出流动的波浪效果。溶解效果通常利用一张噪声纹理在片元着色器中根据噪声值裁剪clip片元。fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 采样溶解噪声图 fixed noise tex2D(_DissolveNoise, i.uv * _NoiseScale).r; // 根据一个可控的阈值进行裁剪 clip(noise - _DissolveThreshold); // 可选在边缘处添加发光色 if (noise - _DissolveThreshold _EdgeWidth) { col.rgb lerp(_EdgeColor, col.rgb, (noise - _DissolveThreshold) / _EdgeWidth); } return col; }通过动画控制_DissolveThreshold从0到1物体就会逐渐“溶解”消失。clip()函数会丢弃小于0的片元。6. 性能考量、调试与常见问题写出能用的Shader只是第一步写出高效、稳定的Shader才是进阶目标。6.1 性能优化要点精度选择CG/HLSL中有float(32位),half(16位),fixed(低精度通常11位) 三种精度。在片元着色器中对于颜色和单位向量尽可能使用fixed或half。float用于世界坐标、纹理坐标等需要高精度的计算。fixed4 fragColor; // 颜色用fixed足够 half3 normal; // 法线half通常足够 float4 worldPos; // 世界坐标用float减少计算量将计算移到顶点着色器如果某些值在三角形表面变化平滑如漫反射系数可以在顶点着色器计算然后让GPU插值到片元。这比在片元着色器对每个像素计算一次要快。避免分支if语句GPU是并行处理器分支会导致性能下降。尽量用step(),lerp(),saturate()等函数来替代简单的条件判断。减少纹理采样纹理采样是昂贵的操作。合并纹理如将金属度、光滑度、环境光遮蔽打包到一张纹理的RGB通道或使用纹理图集。合理使用LOD和多重编译SubShader的LOD指令可以控制在不同硬件上使用不同复杂度的Shader。使用#pragma multi_compile或#pragma shader_feature来编译不同功能变体避免将不用的功能代码打包进最终构建。6.2 Shader调试技巧Shader调试不像普通代码可以单步执行主要依靠“可视化输出”。颜色调试法将你想查看的中间变量映射到颜色输出。例如怀疑法线有问题就在片元着色器中return fixed4(worldNormal * 0.5 0.5, 1.0);。怀疑UV有问题就return fixed4(i.uv, 0, 1);。Unity Frame Debugger这是最强大的工具。Window - Analysis - Frame Debugger。它可以让你暂停游戏一步步查看每一个Draw Call查看该次绘制使用的Shader、属性、渲染状态甚至可以看到传递给GPU的顶点数据和着色器常量。对于排查“为什么这个物体没显示”或“为什么颜色不对”的问题至关重要。检查编译错误和警告Unity控制台会显示Shader的编译错误和警告。警告有时也很重要比如提示精度转换可能丢失。6.3 常见问题排查表问题现象可能原因排查步骤物体纯黑/纯白光照计算错误、法线错误、纹理采样失败1. 返回固定颜色如fixed4(1,0,0,1)看是否显示红色。2. 可视化法线。3. 检查纹理属性名、UV坐标、纹理导入设置是否为sRGB。物体不显示深度测试失败、Alpha裁剪、渲染队列错误1. 使用Frame Debugger查看该物体的Draw Call是否存在状态如何。2. 检查ZWrite、ZTest、RenderType和Queue标签。3. 检查clip函数条件。纹理扭曲/拉伸UV坐标计算错误、顶点数据问题1. 可视化UVreturn fixed4(i.uv, 0, 1)。2. 检查模型导入设置中的“生成光照UV”选项。3. 检查顶点着色器中TRANSFORM_TEX宏使用是否正确。透明排序错乱透明物体渲染顺序问题1. 确保透明物体的Shader设置了QueueTransparent和ZWrite Off。2. 尝试调整物体的渲染顺序或使用Camera的Depth Texture进行深度排序。移动端效果异常精度问题、不支持的特性1. 将float改为half或fixed。2. 检查是否使用了ES2.0不支持的函数如fwidth用于边缘光。3. 使用SHADER_TARGET宏进行平台差异化编译。7. 从入门到实践构建一个简单的卡通着色器作为学习成果的检验我们来实践一个简化版的卡通着色Cel Shading效果。核心思想是使用一张一维的渐变纹理Ramp Texture来对漫反射系数进行重新映射将连续的光照变化离散为几个色阶。实现步骤准备属性除了常规的颜色、纹理我们需要一张一维渐变纹理_RampTex以及一个控制色阶边缘硬度的_RampSmooth。Properties { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) _RampTex (Ramp Texture, 2D) white {} _RampSmooth (Ramp Smoothness, Range(0, 0.5)) 0.1 _OutlineColor (Outline Color, Color) (0,0,0,1) _OutlineWidth (Outline Width, Range(0, 0.1)) 0.03 }计算光照并采样Ramp纹理在片元着色器中照常计算兰伯特漫反射系数diff。然后使用这个系数作为UV的x坐标去采样一维的Ramp纹理。Ramp纹理通常是一张水平渐变的窄图x轴从0暗到1亮。fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; float3 worldNormal normalize(i.worldNormal); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float diff dot(worldNormal, lightDir); diff diff * 0.5 0.5; // 从[-1,1]映射到[0,1]以匹配UV // 添加一点平滑过渡避免色阶之间过于生硬 diff smoothstep(0, _RampSmooth, diff); fixed3 rampColor tex2D(_RampTex, float2(diff, 0.5)).rgb; col.rgb * rampColor * _LightColor0.rgb; return col; }smoothstep函数在这里用于在色阶边缘创建一个很小的平滑过渡区让卡通感不那么生硬。实现描边可选卡通风格常伴随描边。一种常见且简单的方法是使用法线外扩。需要两个Pass第一个Pass渲染背面并沿法线方向挤出顶点输出纯色作为描边第二个Pass正常渲染正面。// Pass 1: 渲染描边 Cull Front // 剔除正面只渲染背面 ZWrite On CGPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline float _OutlineWidth; float4 _OutlineColor; v2f_simple vert_outline (appdata_base v) { v2f_simple o; // 将顶点沿法线方向挤出 float4 pos UnityObjectToClipPos(v.vertex); float3 normal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 变换法线到观察空间 float2 offset TransformViewToProjection(normal.xy); // 在观察空间的XY平面偏移 pos.xy offset * _OutlineWidth; // 应用宽度 o.vertex pos; return o; } fixed4 frag_outline (v2f_simple i) : SV_Target { return _OutlineColor; } ENDCG // Pass 2: 正常渲染正面即上面写的卡通着色Pass Cull Back // ... 原有代码 ...这个描边方法简单但在模型有复杂凹凸或镜头拉近时可能不准确。更高级的方法有基于屏幕空间的后处理描边但复杂度也更高。通过这个完整的例子你将一个基础的光照模型、纹理采样、自定义渲染效果卡通化以及多Pass渲染结合了起来。从理解原理到实现功能再到考虑性能和优化这正是Shader学习从入门到精通的实践路径。记住多看、多写、多调试把每一个效果拆解成最基本的数学运算和管线步骤图形编程的大门就会向你彻底敞开。