1. 项目概述为什么Shader是Unity视觉的灵魂如果你在Unity里做过项目尤其是对画面有点追求的那种肯定遇到过这样的时刻内置的Standard Shader怎么调都差点意思想要一个流光溢彩的UI边框或者一个能随风摇曳、半透明的草地。这时候Unity的材质球和Inspector面板就显得有点力不从心了。你需要的是直接与GPU对话告诉它每一个像素该怎么渲染——这就是Shader编程。Shader中文常译为“着色器”听起来有点玄乎但它本质上就是一段运行在显卡上的小程序。它决定了3D模型表面的颜色、光照、透明度等所有视觉属性。你可以把它想象成给模型“化妆”的终极工具内置的Standard Shader是“日常淡妆”而自定义Shader则是“特效妆”能让你实现从赛博朋克的霓虹光带到唯美二次元卡通渲染的一切效果。这个项目就是一次从零开始的Shader实战之旅。我们不满足于在Unity编辑器里拖拽参数而是要深入理解Shader语言主要是HLSL/GLSL从最基础的顶点/片元着色器结构开始一步步搭建起实现高级视觉效果的能力。无论你是想提升项目画面表现力、优化渲染性能还是单纯对图形学感到好奇掌握Shader都将为你打开一扇新世界的大门。它不仅是技术更是一种艺术表达的工具。2. 核心概念与工具链准备在动手写代码之前我们得先把“战场”布置好理解几个核心概念和准备好趁手的工具。2.1 Shader、Material与Asset的关系这是新手最容易混淆的地方。你可以这样理解Shader是“菜谱”。它定义了渲染的算法和流程比如“先炒肉后放菜加一勺酱油”。Material材质是“按照菜谱做出来的一盘具体的菜”。它引用了某个Shader并填充了Shader所需要的具体参数比如“酱油用老抽肉用五花肉”。Mesh网格是“盛菜的盘子”。它提供了模型的形状顶点数据。 渲染的过程就是把“盘子”Mesh交给“厨师”GPU厨师按照“菜谱”Shader和给定的“食材参数”Material最终炒出“一盘菜”屏幕上的像素。在Unity中你创建一个Shader文件.shader然后在Project窗口右键创建Material并将Material的Shader属性选择为你刚写的那个。最后把这个Material拖到场景中的GameObject上效果就出来了。2.2 开发环境与关键工具Unity版本推荐使用最新的LTS长期支持版本如2022 LTS或更新版本。它们对Shader Graph和新的渲染管线支持更完善。本实战会兼顾可编程渲染管线Built-in RP传统但稳定和Shader Graph可视化适合原型。代码编辑器Visual Studio 或 VS Code并安装Shaderlab相关插件如ShaderlabVSCode它能提供语法高亮和基础提示虽然比不上C#的智能提示但比记事本强太多了。调试工具这是Shader开发的“眼睛”。Frame DebuggerUnity内置可以一步步查看每一帧的绘制调用Draw Call和渲染状态对于理解渲染顺序和性能瓶颈至关重要。RenderDoc一个强大的第三方图形调试器。它可以捕获一帧完整的GPU渲染过程让你看到每一个渲染Pass的输入输出纹理、深度缓冲甚至能一步步调试你的Shader代码。当效果出问题时RenderDoc往往是终极解决方案。注意刚开始学习时不要过度依赖Shader Graph而跳过代码学习。Shader Graph是优秀的原型和生产工具但理解底层代码逻辑顶点变换、光照模型、纹理采样是解决复杂问题和进行深度优化的基础。两者结合才是王道。3. Shader编程基础从“Hello World”到漫反射光照让我们从一个最简单的、不依赖光照的纯色Shader开始逐步增加复杂度。3.1 第一个ShaderUnlit Color创建一个新的Shader文件命名为“UnlitColor.shader”。完整的Shader代码如下Shader Custom/UnlitColor { Properties { _Color (Main Color, Color) (1,1,1,1) // 在材质面板暴露一个颜色属性 } SubShader { Tags { RenderTypeOpaque } // 标签表示这是一个不透明物体 LOD 100 // 细节级别暂时不用管 Pass // 一个渲染通道 { CGPROGRAM #pragma vertex vert // 声明顶点着色器函数名 #pragma fragment frag // 声明片元着色器函数名 #include UnityCG.cginc // 包含Unity的内置辅助函数和变量 struct appdata // 从Mesh输入的数据 { float4 vertex : POSITION; // 顶点位置模型空间 }; struct v2f // 从顶点着色器传递给片元着色器的数据 { float4 vertex : SV_POSITION; // 裁剪空间位置输出必须 }; fixed4 _Color; // 对应Properties中定义的属性 v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // 核心变换将顶点从模型空间转换到裁剪空间 return o; } fixed4 frag (v2f i) : SV_Target { return _Color; // 片元着色器直接返回我们定义的颜色 } ENDCG } } }关键点解析UnityObjectToClipPos这是Unity提供的一个宏它内部完成了模型空间-世界空间-观察空间-裁剪空间这一系列复杂的矩阵变换。对于初学者记住它是必须的即可。SV_POSITION和SV_Target这是HLSL中的语义Semantics告诉GPU这个变量的用途。SV_POSITION表示这是顶点的最终位置SV_Target表示这是输出到屏幕的颜色。将这个Shader赋给一个材质并调整_Color你会看到一个纯色的物体不受任何灯光影响。3.2 引入纹理采样纯色太单调我们加上纹理。在Properties块中添加_MainTex (Texture, 2D) white {}。同时我们需要将纹理坐标从顶点着色器传递到片元着色器。修改数据结构与着色器struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; // 第一套纹理坐标 }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; // 纹理采样器 float4 _MainTex_ST; // 纹理的缩放平移参数Scale, Translation 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); // 核心在给定的uv坐标处采样纹理颜色 return col; }现在你的材质球上会出现一个贴图槽拖入一张图片模型就会显示该纹理。3.3 实现兰伯特漫反射光照现在让我们引入最简单的光照模型——兰伯特Lambert漫反射。这需要法线信息和灯光方向。修改appdata结构加入法线float3 normal : NORMAL;。 修改v2f结构传递世界空间法线float3 worldNormal : TEXCOORD1;。在顶点着色器中计算世界空间法线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 { // 采样纹理颜色 fixed4 col tex2D(_MainTex, i.uv); // 获取世界空间下的归一化法线 float3 normal normalize(i.worldNormal); // 获取主平行光的方向世界空间已取反指向光源 float3 lightDir _WorldSpaceLightPos0.xyz; // 兰伯特漫反射公式max(0, dot(N, L)) float diff max(0, dot(normal, lightDir)); // 获取主平行光的颜色 fixed4 lightColor _LightColor0; // 最终颜色 纹理颜色 * 光照颜色 * 漫反射强度 col.rgb * lightColor.rgb * diff; return col; }为了让Unity提供_WorldSpaceLightPos0和_LightColor0这些内置光照变量我们需要在Pass开头添加一个编译指令#pragma multi_compile_fwdbase。这个指令告诉Unity为这个Pass编译包含基础前向渲染所需变量的变体。现在你的物体就有了明暗变化会随着场景中主平行光的旋转而改变受光面。这是你实现的第一个真正意义上的光照Shader实操心得在片元着色器中进行normalize操作归一化是有性能成本的因为每个像素都要计算一次。如果模型顶点数不多可以考虑在顶点着色器中计算归一化后的法线然后传递给片元着色器虽然会因线性插值导致法线长度不为1但对于平滑模型通常可以接受这是一种常见的性能取舍。4. 中级视觉效果实战透明、溶解与顶点动画掌握了基础我们可以开始实现一些更酷的、游戏里常见的视觉效果了。4.1 透明与混合渲染透明效果需要改变渲染队列和混合模式。我们创建一个新的Shader “TransparentAlpha”。Shader Custom/TransparentAlpha { Properties { _MainTex (Texture, 2D) white {} _AlphaScale (Alpha Scale, Range(0, 1)) 0.5 // 整体透明度控制 } SubShader { Tags { QueueTransparent // 关键使用透明渲染队列 RenderTypeTransparent IgnoreProjectorTrue // 通常透明物体不受投影器影响 } LOD 100 ZWrite Off // 关键关闭深度写入防止透明物体互相遮挡出错 Blend SrcAlpha OneMinusSrcAlpha // 关键使用Alpha混合模式 Pass { CGPROGRAM ... // 顶点/片元着色器声明和结构体定义与之前类似 fixed _AlphaScale; fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 最终输出的Alpha值由纹理的Alpha通道和我们的缩放系数共同决定 col.a * _AlphaScale; // 如果Alpha非常小直接丢弃该片元避免渲染浪费可选优化 // clip(col.a - 0.01); return col; } ENDCG } } }关键解析QueueTransparent确保这个物体在所有不透明物体渲染完之后再渲染。ZWrite Off关闭深度写入。如果开启先渲染的透明物体会挡住后面的破坏混合效果。Blend SrcAlpha OneMinusSrcAlpha这是最常用的混合模式。SrcAlpha指当前片元颜色的Alpha值作为源混合因子OneMinusSrcAlpha指(1 - 源Alpha)作为目标混合因子。公式为FinalColor SrcColor * SrcAlpha DstColor * (1 - SrcAlpha)。这实现了自然的透明叠加。4.2 溶解效果溶解效果常用于物体消失、被腐蚀等场景。核心思路是使用一张噪声图作为溶解依据根据一个阈值来裁剪Clip片元。Properties中添加_NoiseTex(Noise Tex, 2D) white {}和_DissolveThreshold(Dissolve Threshold, Range(0, 1.1)) 0。片元着色器fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 采样噪声图通常使用r通道即可 fixed noise tex2D(_NoiseTex, i.uv).r; // 关键裁剪函数。如果噪声值小于阈值则丢弃该片元产生“洞” clip(noise - _DissolveThreshold); // 可选为溶解边缘添加颜色如发光 float edge saturate((noise - _DissolveThreshold) * 10); // 边缘检测因子 fixed3 edgeColor lerp(fixed3(1,0.5,0), fixed3(1,1,0), edge); // 从橙色到黄色 col.rgb lerp(col.rgb, edgeColor, 1-edge); // 混合边缘色 return col; }通过动画或脚本控制_DissolveThreshold从0变化到1以上物体就会逐渐溶解消失。clip()函数是性能相对较低的操作但对于这种特效是标准做法。4.3 顶点动画模拟旗帜飘动在顶点着色器中直接修改顶点位置可以实现各种动画。这里模拟一个简单的正弦波飘动。顶点着色器v2f vert (appdata v) { v2f o; // 在模型空间下计算动画偏移 float3 pos v.vertex.xyz; // 假设旗帜沿着模型空间的X轴悬挂在Z轴方向波动 // _Time.y是自游戏开始的总时间秒用于驱动动画 // sin(频率 * 位置 时间 * 速度) * 幅度 float wave sin(_Frequency * pos.x _Time.y * _Speed) * _Amplitude; pos.z wave; // 在Z轴方向添加偏移 // 使用动画后的顶点位置进行常规变换 o.vertex UnityObjectToClipPos(float4(pos, v.vertex.w)); o.uv TRANSFORM_TEX(v.uv, _MainTex); // 注意法线也需要重新计算简单的顶点动画会破坏法线信息。 // 对于复杂动画可能需要根据相邻顶点位置在着色器中重新计算法线或使用法线贴图弥补。 o.worldNormal UnityObjectToWorldNormal(v.normal); return o; }在Properties中定义_Frequency,_Speed,_Amplitude等参数就可以在材质面板调节飘动的频率、速度和幅度了。这种方法的优点是动画完全在GPU上运行不占用CPU性能极高适合大量重复物体的动画如草地、海浪、旗帜群。5. 高级专题屏幕后处理与表面着色器初探5.1 实现一个简单的屏幕泛光效果屏幕后处理Post-processing是在整个场景渲染完成后对最终图像进行的全屏处理。Unity中通常通过OnRenderImage函数配合Graphics.Blit实现。我们创建一个C#脚本SimpleBloom和一个对应的后处理Shader。C#脚本核心using UnityEngine; [ExecuteAlways] // 在编辑模式下也执行 public class SimpleBloom : MonoBehaviour { public Shader bloomShader; private Material _material; [Range(0.0f, 5.0f)] public float threshold 1.0f; // 亮度阈值 [Range(0.0f, 10.0f)] public float intensity 1.0f; // 泛光强度 void OnRenderImage(RenderTexture src, RenderTexture dst) { if (bloomShader null) return; if (_material null) _material new Material(bloomShader); _material.hideFlags HideFlags.HideAndDontSave; // 1. 提取高亮部分 _material.SetFloat(_Threshold, threshold); RenderTexture brightTex RenderTexture.GetTemporary(src.width / 2, src.height / 2, 0); // 降采样以提升性能 Graphics.Blit(src, brightTex, _material, 0); // 使用Shader的第一个Pass提取高光 // 2. 模糊高亮部分这里简单进行两次模糊 _material.SetFloat(_Intensity, intensity); RenderTexture blurredTex RenderTexture.GetTemporary(brightTex.width, brightTex.height, 0); Graphics.Blit(brightTex, blurredTex, _material, 1); // 使用Shader的第二个Pass模糊 RenderTexture.ReleaseTemporary(brightTex); RenderTexture temp RenderTexture.GetTemporary(blurredTex.width, blurredTex.height, 0); Graphics.Blit(blurredTex, temp, _material, 1); // 再次模糊 RenderTexture.ReleaseTemporary(blurredTex); blurredTex temp; // 3. 将模糊后的高光与原图混合 _material.SetTexture(_BloomTex, blurredTex); Graphics.Blit(src, dst, _material, 2); // 使用Shader的第三个Pass混合 RenderTexture.ReleaseTemporary(blurredTex); } }后处理Shader核心多PassPass 0 (提取高光)片元着色器中采样原图颜色计算亮度Luminance如果亮度超过阈值则输出该颜色否则输出黑色。float luminance dot(col.rgb, float3(0.2126, 0.7152, 0.0722)); // 标准亮度公式 col.rgb * step(_Threshold, luminance); // step(a, b): ba则返回1否则0Pass 1 (模糊)实现一个简单的盒子模糊或高斯模糊。例如采样当前像素及其上下左右四个相邻像素的颜色取平均。fixed4 blur tex2D(_MainTex, i.uv); blur tex2D(_MainTex, i.uv float2(_MainTex_TexelSize.x, 0)); blur tex2D(_MainTex, i.uv float2(-_MainTex_TexelSize.x, 0)); blur tex2D(_MainTex, i.uv float2(0, _MainTex_TexelSize.y)); blur tex2D(_MainTex, i.uv float2(0, -_MainTex_TexelSize.y)); blur / 5.0; return blur;Pass 2 (混合)采样原图_MainTex和模糊后的高光纹理_BloomTex将两者相加Additive Blend。fixed4 sceneCol tex2D(_MainTex, i.uv); fixed4 bloomCol tex2D(_BloomTex, i.uv); return sceneCol bloomCol * _Intensity;将这个脚本挂到相机上并指定Shader就能看到场景中亮部区域产生了泛光效果。这是一个非常简化的Bloom实际生产环境会使用更高效的高斯模糊如双重滤波和更好的亮度提取公式。5.2 表面着色器Unity的快捷光照封装对于需要复杂光照如多光源、阴影、光照贴图的物体手写前向渲染Pass会很繁琐。Unity提供了表面着色器Surface Shader它是一种更高级的抽象让你专注于定义表面的物理属性反照率、法线、光滑度、金属度等而光照计算由Unity帮你生成。一个最简单的表面着色器框架如下Shader Custom/SurfaceExample { Properties { _MainTex (Albedo (RGB), 2D) white {} _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM // 关键编译指令表面着色器使用标准光照模型全向前向渲染 #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; half _Glossiness; half _Metallic; struct Input { float2 uv_MainTex; // 输入结构体自动匹配以“uv_”开头的纹理 }; void surf (Input IN, inout SurfaceOutputStandard o) { // 表面函数在这里填充表面属性 fixed4 c tex2D(_MainTex, IN.uv_MainTex); o.Albedo c.rgb; // 漫反射颜色 o.Metallic _Metallic; // 金属度 o.Smoothness _Glossiness; // 光滑度 o.Alpha c.a; // 透明度 } ENDCG } FallBack Diffuse }#pragma surface surf Standard fullforwardshadows这一行是核心。它声明了表面函数名为surf。使用Standard基于物理的渲染PBR光照模型。使用fullforwardshadows支持所有前向渲染的阴影。 在surf函数中你只需要填充SurfaceOutputStandard结构体的成员剩下的漫反射、高光、阴影计算都由Unity生成的代码完成。这对于快速实现一个符合物理规律、支持多光源和阴影的材质极其方便。注意事项表面着色器虽然方便但它生成的代码量通常比自己手写的顶点/片元着色器大且对某些极端自定义光照模型的支持不如手写灵活。它是一种“用灵活性换取开发效率”的权衡。在移动平台要特别注意其性能开销。6. 性能优化与调试实战Shader写出来容易写得好、跑得快是另一回事。性能优化是Shader开发不可或缺的一环。6.1 性能瓶颈分析与工具使用GPU瓶颈 vs CPU瓶颈使用Unity的Profiler查看GPU时间。如果Render相关的时间很长很可能是GPU瓶颈问题出在Shader复杂度过高或Draw Call过多。如果是WaitForTargetFPS或脚本逻辑耗时则是CPU瓶颈。Frame Debugger逐Draw Call查看检查是否有冗余的渲染如渲染了被遮挡的物体、是否可以进行批次合并Batch。RenderDoc当怀疑是Shader本身效率低下时用RenderDoc捕获一帧。查看每个Draw Call的GPU耗时重点关注像素着色器Fragment Shader的耗时。复杂的数学运算如sin,pow,dot在循环内、过多的纹理采样、高精度的数据类型floatvshalfvsfixed都可能是元凶。6.2 常见的Shader优化技巧精度选择在移动平台或对性能要求高的地方使用低精度数据类型。fixed低精度通常用于颜色0-1范围。在片元着色器中使用。half中精度范围更大。用于短向量、方向等。float全精度用于位置、纹理坐标等需要高精度的计算。 原则能用fixed不用half能用half不用float。减少纹理采样纹理采样是昂贵的操作。合并纹理将金属度、光滑度、环境光遮蔽AO等灰度图打包到一张纹理的RGBA通道中。使用纹理图集Atlas将多个小纹理合并成一张大纹理减少纹理切换。利用tex2Dlod或tex2Dbias进行手动mipmap级别控制高级技巧。简化数学运算避免在片元着色器中使用复杂的循环和分支if,forGPU并行架构不擅长处理这些。用mad乘加指令、lerp线性插值代替多个独立计算。将可以提前的计算移到顶点着色器即使结果在片元着色器中需要插值。利用Shader变体与关键字使用#pragma multi_compile或shader_feature来编译不同功能的Shader变体而不是用一个超级复杂的、包含所有可能分支的“全能”Shader。运行时通过Material.EnableKeyword来切换功能让每个物体只运行它需要的代码。慎用透明与深度测试透明物体QueueTransparent,ZWrite Off无法进行标准的深度排序优化且通常无法被批处理会显著增加渲染负担。非必要少用全屏透明或大量小透明物体。6.3 一个实战优化案例优化自定义水体Shader假设你写了一个包含菲涅尔效应、法线贴图扰动、高光反射的复杂水体Shader在低端手机上帧率很低。排查与优化步骤用RenderDoc定位发现主要的耗时Draw Call中片元着色器指令数极高。分析代码发现为了模拟水波在片元着色器中对法线贴图采样了3次用于不同频率的叠加。菲涅尔计算使用了pow和smoothstep。高光计算使用了完整的Blinn-Phong模型。优化方案纹理采样将三个不同缩放的法线贴图采样改为对一张法线贴图采样一次然后在代码里通过不同的UV缩放和偏移来模拟多重频率。或者使用一张RGBA纹理其中两个通道存储一套法线另外两个通道存储另一套。数学近似用fresnel 1.0 - dot(normal, viewDir);这种简单的计算代替复杂的pow运算虽然物理上不精确但视觉上差异可能很小。简化高光对于移动端可以考虑使用更简单的高光模型或者只在特定角度如接近反射方向才计算高光。精度下调将所有不涉及深度和位置的float改为half颜色计算改为fixed。变体分离创建一个简化变体shader_feature _SIMPLE_VERSION在低端设备上关闭法线扰动和高光只保留基础颜色和透明。经过这些优化片元着色器的指令数可能下降30%-50%帧率得到显著提升。优化是一个不断权衡视觉效果和性能的过程需要针对目标平台进行测试和调整。7. 从Built-in RP向URP/HDRP迁移的思考随着Unity的发展可编程渲染管线SRP包括通用渲染管线URP和高清渲染管线HDRP已成为主流。它们与传统的内置渲染管线Built-in RP在Shader编写上有显著不同。核心差异库与函数URP/HDRP使用ShaderLibrary中的函数如TransformObjectToHClip代替UnityObjectToClipPos内置变量名也变了如_MainLightPosition代替_WorldSpaceLightPos0。光照架构URP使用前向渲染器Forward Renderer资产来配置光照Shader中通过Lighting.hlsl中的GetMainLight()等函数获取光照信息而不是直接访问内置变量。它更强调单Pass、多光源的延迟渲染风格实际是前向。Shader编写模式URP鼓励使用SRPDefaultUnlit或UniversalForward等预定义的SubShader块并配合HLSLPROGRAM/ENDHLSL。表面着色器在URP中不是首选通常需要手写光照Pass或使用Lit Shader Graph。迁移建议对于新项目除非有特殊遗留需求否则强烈建议直接从URP开始。它的性能、现代特性支持和社区资源都更好。对于旧项目迁移这是一个渐进的过程。可以先将复杂的自定义Shader使用CGINCLUDE或自定义HLSL文件封装核心算法然后为URP和Built-in分别编写不同的SubShader和Pass包装。Unity也提供了一些转换工具但无法处理复杂逻辑手动调整是必须的。学习路径掌握了Built-in下的Shader原理后学习URP的核心是理解其RenderPipeline、Renderer Features和新的ShaderLibraryAPI。从将一个简单的Unlit Shader移植到URP开始逐步理解其光照和阴影系统。Shader编程是一条深入图形渲染核心的道路从最简单的颜色输出到复杂的全局光照模拟其魅力在于对最终图像像素级的控制力。这个过程充满挑战需要耐心调试和不断学习但当看到自己编写的代码将一堆顶点数据变成屏幕上惊艳的动态效果时那种成就感是无与伦比的。我个人的经验是多动手复现经典效果如Toon Shader、边缘光、雾效多使用Frame Debugger和RenderDoc“窥视”GPU的运作积累的“肌肉记忆”会比死记硬背语法更有用。最后别忘了在性能和效果之间找到属于你项目的平衡点。