资讯动态

Unity Shader入门:从渲染管线到光照模型的实战指南

发布时间:2026/10/14 16:35:46 来源:尧图企业网站定制
1. 先从“渲染管线的黑箱”说起我最早接触Unity Shader时最大的困惑不是语法难记而是根本不知道它在我看不到的地方做了什么。很多人上来就盯着一堆Properties、SubShader、Pass背结果写了半天Shader在屏幕上要么全黑、要么全白连为什么错都说不出来。所以我不打算先灌公式而是先帮你把Unity的渲染流程在脑子里搭一个“可以运行的模型”。场景里有一个立方体、一盏平行光。你要这个立方体显示出来CPU这端要做的事是决定哪些物体该画、用哪个材质、按什么顺序画这叫渲染管线的应用阶段Application Stage。接着把这些信息打包交给GPU。GPU收到之后第一步是把顶点数据位置、法线、UV、切线等从模型空间变换到屏幕空间这叫几何阶段Geometry Stage其中最关键的是顶点着色器。第二步是把变换后的三角形拆成像素逐个像素决定它该显示成什么颜色这叫光栅化阶段Rasterization Stage片元着色器就在这里干活。Shader对你写的代码来说只是这个流程中“可编程”的那几小段顶点着色器Vertex Shader和片元着色器Fragment Shader。至于裁剪、背面剔除、深度测试这些环节默认是固定功能的你可以在Blend、Cull、ZWrite这些指令里做配置但代码逻辑本身不在你的掌控范围内。理解这件事比记住任何一句语法都重要。我见过很多项目里出现“物体被吃掉了”的情况后来排查下来发现是顶点着色器里把o.pos UnityObjectToClipPos(v.vertex)这一行写漏了。这一步本质上是做了一次从模型空间到裁剪空间的四维矩阵变换。模型空间里物体的中心不在原点也没关系因为_Object2World这类矩阵会把整个物体的局部坐标一起挪过去。但如果你在这之后又手动改了o.pos.x效果往往是物体在屏幕上被拉伸、翻转甚至消失因为裁剪空间里的w分量承担了透视除法的角色你随意改x、y就是在破坏整个NCRNormalized Clip Coordinates的约束。所以在所有实操之前我强烈建议你先画一幅图从顶点数据输入到模型空间、世界空间、观察空间、裁剪空间、屏幕空间。不要求一步画对但脑子里必须有这个链条。之后你在看Unity官方文档、看别人的Shader代码、甚至自己在写的时候都能快速定位“我现在是卡在坐标变换还是卡在颜色输出”。2. 核心概念拆解从坐标系到光照模型2.1 五坐标空间Shader的“位移地图”渲染一个模型本质上是记录它从出生建模软件里的坐标到上屏窗口里的像素坐标这一路上经历了多少次坐标系转换。我把这五个坐标空间列成一个表后面所有代码里遇到POSITION、TEXCOORD0、SV_POSITION你都要能立刻反应出它当前处于哪个坐标系。空间名称关键矩阵常见用途典型误区模型空间无或单位矩阵存储顶点原始位置、法线、UV误以为物体中心必须在原点世界空间unity_ObjectToWorld计算光照方向、视线方向忘用法线矩阵处理非统一缩放观察空间UNITY_MATRIX_V做反射、雾效计算把观察空间和裁剪空间混为一谈裁剪空间UNITY_MATRIX_VP输出给SV_POSITION直接修改xyz导致w被破坏屏幕空间视口变换UV采样、后处理混淆像素坐标和归一化坐标实际项目里最常见的往返是把顶点从模型空间变到世界空间算世界坐标下的法线方向和视线方向再变到裁剪空间作为片元着色器的输入。别看网上很多教程把这一步叫“法线变换”它背后藏着一个容易让人翻车的地方——非统一缩放。如果某个物体的Scale是(1, 2, 3)直接用unity_ObjectToWorld去转换法线得到的法线方向是错的。正确做法是用unity_ObjectToWorld的逆转置矩阵即模型空间法线要乘的矩阵是(unity_ObjectToWorld)^{-T}。Unity封装了UnityObjectToWorldNormal内部就是做了这件事。很多初级开发者在这里偷懒直接乘unity_ObjectToWorld结果花大量时间调光照方向最后发现是本该一秒能定位的问题。实操中我常用的一个验证方式在顶点着色器里输出三套坐标用Debug.Log或者直接输出到颜色通道分别显示模型空间、世界空间、裁剪空间的数值。比如把o.color float4(v.vertex.xyz, 1);当作调试颜色看物体在场景里旋转时颜色是否变化。变化了就说明你给的确实是模型空间坐标没变化才可能是世界空间。这个方法看起来很土但排查坐标空间问题极其高效。2.2 顶点着色器和片元着色器各司其职很多Shader新手会犯一个共性错误把纹理采样、光照计算、甚至TOON效果全塞在顶点着色器里理由是“顶点着色器运行次数少性能更好”。这个思路在特定情况下没错但如果你不理解两者的差异很容易做出画面闪烁、高光抖动这类奇怪效果。顶点着色器的输入单位是顶点输出单位也是顶点。它要干的事情是坐标变换、顶点色传递、UV传递、逐顶点光照计算。优点是运行次数少三角形数量远小于像素数量缺点是插值带来的“精度损耗”。举个例子如果一棵树的叶片之间有大片缝隙用顶点着色器计算光照高光会在三角形内部被插值得很平滑但一旦三角形跨越了高光的边界高光中心就会跑到三角形内部看起来像“裂缝里发光”。片元着色器的输入单位是片元可以理解成屏幕上的候选像素输出的是最终颜色。它逐像素计算可以拿到UV坐标、法线方向、世界坐标这些插值后的数据。缺点是运行次数极其巨大尤其是全屏后处理所以性能要精打细算。我的建议是能用顶点着色器做的线性运算尽量放顶点着色器但凡是涉及非线性运算比如normalize、pow、光照衰减尽量放片元着色器。这个原则不是拍脑袋定的是因为GPU在顶点到片元之间做的是线性插值你把一个方向向量插值后再normalize和插值前normalize再插值结果完全不一样。很多高光不亮、边缘发硬的问题追本溯源都是在顶点着色器里做了normalize。在片元着色器里做光照计算虽然慢一点但正确性是第一位。一个经验值如果你在做一个需要大量动态光源的移动端项目可以尝试把主光源放在片元着色器把次要光源放在顶点着色器。你会发现画面依然可以接受性能却能提升30%到50%。具体调法后面再说。2.3 坐标空间变换的常见坑法线矩阵和裁剪空间法线矩阵的问题前面提了一嘴这里展开说透。法线不是一个普通的方向向量它在模型空间里和三角形的切线方向垂直。当你对整个模型做缩放时如果我们直接用法线的原始方向参与变换当缩放不是等比缩放时法线和切线的垂直关系就被破坏了。这就好比你把一张正方形的纸沿一个方向拉长原本垂直的两条边会变成锐角和钝角。旋转矩阵不会破坏垂直关系等比缩放也不会但非统一缩放一定会。所以标准的做法是用法线矩阵(unity_ObjectToWorld)^{-T}把法线从模型空间变换到世界空间。UnityObjectToWorldNormal在Unity内部其实就是先取unity_ObjectToWorld再做一次逆转置。有的朋友为了性能故意跳过这一步只对使用统一缩放的物体生效。一旦美术改了Scale高光就会扭曲。这个坑不致命但特别的磨人。裁剪空间的问题则更隐蔽。顶点着色器的输出必须是裁剪空间坐标SV_POSITION在Unity里要求的就是这个。如果你在片元着色器里想通过i.pos.xy / i.pos.w还原屏幕坐标做后处理一定要记着透视除法这一步。很多人直接用i.pos.xy当成屏幕像素坐标得到的结果要么是边缘有拉扯感要么是UV对不上。透视除法的本质是投影变换后的坐标在w分量里包含了深度信息我们需要把x、y、z分量同时除以w才能把这个“四维裁剪坐标”映射回NDC归一化设备坐标NDC的x、y范围一般是[-1,1]再经过视口变换才是屏幕像素坐标。我之前做描边效果时就在这里卡了很久。描边需要把顶点沿法线方向向外扩张但如果在模型空间做扩张到了裁剪空间就会被各种矩阵拉伸描边的宽度会变得很不均匀。后来我改成在裁剪空间里扩张先把法线变换到裁剪空间再把法线向量的x、y除以w做扩张边缘宽度就稳定了。这个技巧很多商业描边方案都在用记住这个思路比抄代码更重要。2.4 光照模型入门从漫反射到高光光照模型是Shader里最像“调色”的部分。我在这里不打算列一堆公式而是用最直白的方式建立直觉。Lambert漫反射的直觉是物体表面越“正对”光源看起来越亮。正对的程度用NdotL衡量N是法线方向L是光源方向。如果N和L完全同向亮度是1如果N和L垂直亮度是0。在实际代码里通常还要对NdotL做saturate处理防止出现负值导致物体背面也被照亮还呈现奇怪的黑色。代码里你经常会看到float NdotL saturate(dot(worldNormal, lightDir)); float3 diffuse albedo * _LightColor0.rgb * NdotL;这个看似简单的计算包含了两个关键细节_LightColor0是Unity内置变量代表当前Pass使用的光源颜色和强度albedo则是物体的基础颜色一般来自纹理采样。Blinn-Phong高光则是对“视线方向”敏感。它引入了H向量也就是LightDir和ViewDir的半程向量normalize后的LV。之所以用半程向量而不是直接计算反射向量R是为了减少一次反射运算。计算NdotH再用saturate(NdotH)配合一个_Gloss指数做幂运算就能得到一块塑形的高光。这里有个容易踩的坑ViewDir的计算方向到底是摄像机位置 - 顶点位置还是反过来常常让人困惑。如果高光位置反射在表面内侧多半是把方向搞反了。这里想强调一个观点漫反射和绝大多数高光模型本质都在调“方向点积”。你不用死记公式只需要清楚自己手里的向量是否归一化、是否在同一个坐标系下。很多初学的朋友写完光照模型一亮一暗第一反应是调参数其实往往只是某个向量忘记normalize了。3. 写好一个Shader的“骨架”逐行解读与经验技巧3.1 ShaderLab结构Properties、SubShader、FallbackShaderLab是Unity自己的一套声明语言它包裹的是真正给GPU执行的HLSL/Cg代码。为了不让结构吓到新手我先给一个旅行箱的比喻Properties是箱子外面的标签SubShader是内部隔层Fallback是备用的空箱子。Shader Custom/BasicDiffuse { Properties { _MainTex (Albedo (RGB), 2D) white {} _Gloss (Smoothness, Range(0, 1)) 0.5 } SubShader { Tags { RenderTypeOpaque } LOD 200 Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc sampler2D _MainTex; float4 _MainTex_ST; float _Gloss; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD1; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed3 albedo tex2D(_MainTex, i.uv).rgb; fixed3 worldNormal normalize(i.worldNormal); fixed3 worldLightDir normalize(_WorldSpaceLightPos0.xyz); fixed NdotL saturate(dot(worldNormal, worldLightDir)); fixed3 diffuse albedo * _LightColor0.rgb * NdotL; return fixed4(diffuse, 1); } ENDCG } } Fallback Legacy Shaders/Diffuse }Properties里的_MainTex是暴露在材质面板上的纹理槽_Gloss是0到1之间的滑条。你可能会发现Unity在Properties里写了纹理默认是white {}这个{}是Unity对纹理属性的特殊标记表示一个空的纹理属性对象如果你在代码里用tex2D采样但又没有在Inspector上拖任何纹理系统就会用一张纯白纹理替代。这也是为什么很多Shader如果不给纹理物体依然是白色的原因。SubShader里最常见的Tag是RenderType和LightMode。RenderType主要用来被替换比如描边替换、深度相机替换LightMode则决定了这个Pass和Unity光照系统的关系。ForwardBase意味着这个Pass会处理第一个平行光、环境光和阴影。Fallback是一个很实用但又容易忽略的设计。它表示如果硬件不满足当前SubShader就回退到另一个更简单的Shader。很多移动端优化方案会把复杂的PBR Shader在低端机型上回退到简单的Lambert既保住画面基本可看也保住性能下限。3.2 最简单的顶点/片元Shader从零到能上屏如果你完全没写过Shader不要一上来就去碰Surface Shader因为Surface Shader虽然写起来简单但它会隐藏太多细节让你在真正想调效果时无从下手。建议先把下面这个模板写一遍理解每一行的作用。Shader Custom/MinimalUnlit { SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { return fixed4(1, 0, 0, 1); } ENDCG } } }你能看到这个Shader不采样纹理、不计算光照只把一个物体渲染成红色。但它已经把最核心的元素整合出来了POSITION语义告诉GPU这个变量是顶点位置SV_POSITION语义告诉GPU输出的是最终的裁剪空间坐标SV_Target语义表示片元着色器输出的颜色会直接写到渲染目标。我这里重点说一下#pragma指令。#pragma vertex vert是把Cg函数vert标记为顶点着色器入口#pragma fragment frag类似。如果拼错了编译通不过而且报错信息往往比较难懂。另一个常见的#pragma target 3.0是设置着色器模型版本版本越高能用的内置函数和指令越多但兼容性越差。一般移动端项目用#pragma target 3.0就够。我经常用这个红色Shader做“最朴素的验证”如果网格变成了红色说明你的模型和基本渲染管线没问题后面加光照、纹理每一步都能在这个基础上迭代。如果你一上来就写PBR一旦画面效果不对你根本分不清是坐标问题、贴图问题还是光照参数问题。3.3 属性块和GPU实例化为什么Properties很关键很多新手会忽视Properties的语义以为只是给美术同事看的滑条。但实际上Properties是Shader和外部C#脚本通信的桥梁。你可以通过Material.SetFloat(_Gloss, 0.8f)、Material.SetTexture(_MainTex, tex)来在运行时修改材质参数。这也是做换装、材质变体、动态特效的基础。GPU Instancing则是另一个性能关键点。默认情况下Unity会为每个材质实例提交一个绘制批次如果场景里有100个相同模型、只是颜色不同就会产生100个批次。但如果你在Properties里声明了_Color并且在顶点着色器/片元着色器里用UNITY_INSTANCING_BUFFER_START/END的宏包裹这些属性就可以让100个实例共享一个材质只是每份实例数据各自不同。这个改动在移动端项目的草、树、粒子系统里能节省大量CPU时间。UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float4, _Color) UNITY_INSTANCING_BUFFER_END(Props)有了这个声明你在片元着色器里访问_Color时要写成UNITY_ACCESS_INSTANCED_PROP(Props, _Color)。这个宏在GPU实例化开启时会从实例数据缓冲中取如果不开启就会回退到普通的全局Shader属性。我见过有人写完这个宏忘了在材质上启用“Enable GPU Instancing”结果实例化没生效还误以为是代码错了。所以记得在材质的Inspector面板把GPU Instancing勾上。3.4 C#侧如何动态修改Shader参数Shader不是只在材质面板上被动使用的它经常需要和C#配合动态变化。最简单的场景是技能特效逐渐变透明或者物体被击中后闪白。实现方式很简单material.SetFloat(_DissolveAmount, value);但你背后的逻辑要注意频繁修改材质参数会破坏Unity的合批。如果你有100个物体共用同一个材质但你每帧改其中一个物体的_DissolveAmount它会强制Unity创建一份新的材质实例合批就断了。更好的做法是把“需要动态改的物体”从大合批中拆出来单独管理或者使用MaterialPropertyBlock。MaterialPropertyBlock是一个绕开材质实例、让多个渲染器共享同一材质但又能各自修改属性的机制。它的底层实现是“参数块”不产生新材质对象。我在做大量小兵的受击闪白时就是给每个小兵的Renderer添加一个MaterialPropertyBlock设置_FlashAmount性能比直接改材质高出一大截。4. 给新手的实操路径从零做出一个可交互的彩色光照小球4.1 搭建测试环境不要一上来就在一个复杂场景里做Shader调试。我的习惯是新建场景只放一个球体默认Sphere再加一盏平行光把方向调到45度左右。球体的位置放在原点摄像机对准它。这样你所有的视觉反馈都来自这颗球方便观察。材质方面新建一个Standard材质把Shader换成我们自己写的Custom/BasicDiffuse。材质面板上的_Gloss先调到0.5看看默认效果再调大调小感受高光变化。我建议你用“Material版Shader变体”的方式一步步迭代而不是把所有功能都堆在一个文件里。你可以复制三个文件第一个是漫反射高光第二个是纹理采样法线贴图第三个是带阴影和雾效果的完整版。每完成一个都回头回顾一下上一步在哪里加了几行代码。这个过程比单纯看教程要扎实得多。4.2 代码实操如何把漫反射和高光组合在一起我先给一个带高光的完整Shader写法。漫反射部分上面已经有了高光部分我加一个Blinn-Phong。Shader Custom/BasicBlinnPhong { Properties { _MainTex (Albedo (RGB), 2D) white {} _Color (Tint, Color) (1, 1, 1, 1) _Gloss (Smoothness, Range(0, 1)) 0.5 _SpecColor (Specular Color, Color) (1, 1, 1, 1) } SubShader { Tags { RenderTypeOpaque } LOD 200 Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Gloss; fixed4 _SpecColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed3 albedo tex2D(_MainTex, i.uv).rgb * _Color.rgb; float3 worldNormal normalize(i.worldNormal); float3 worldViewDir normalize(UnityWorldSpaceViewDir(i.worldPos)); float3 worldLightDir normalize(UnityWorldSpaceLightDir(i.worldPos)); // 半程向量 float3 halfDir normalize(worldLightDir worldViewDir); // 漫反射 float NdotL saturate(dot(worldNormal, worldLightDir)); float3 diffuse albedo * _LightColor0.rgb * NdotL; // 高光 float NdotH saturate(dot(worldNormal, halfDir)); float spec pow(NdotH, _Gloss * 128.0); float3 specular _SpecColor.rgb * _LightColor0.rgb * spec; float3 finalColor diffuse specular; return fixed4(finalColor, 1); } ENDCG } } Fallback Legacy Shaders/Diffuse }这里有两个关键的“为什么”。第一为什么高光的指数是_Gloss * 128.0而不是直接用_Gloss因为_Gloss是0到1的滑条直接作为pow的指数会让高光范围变化太弱。乘以128以后高光的锐利程度会随着滑条有更明显的差异。你可以在“旧式Blinn-Phong”的Unlit Shader参考里看到类似的做法。128这个常数并非Unity规定而是为了让参数在0到1的范围内“手感更好”。第二为什么我们用UnityWorldSpaceLightDir而不是从_WorldSpaceLightPos0自己推导因为这些辅助函数内部会处理方向光源和点光源的差异。方向光的_WorldSpaceLightPos0直接是方向向量点光源则是位置需要normalize(光源位置 - 顶点位置)。用辅助函数你哪怕换灯源类型代码也能通用。但这个“偷懒”也有代价它默认你处在ForwardBase渲染路径多光源的情况下需要另行处理。4.3 让小球动起来和C#交互的实战演示写完Shader以后可以做一个直观的验证让一个C#脚本在运行时修改_Gloss观察小球高光的变化。这个操作虽然简单但却能帮新手把“材质参数修改”和“渲染结果变化”这两个概念在脑子里打通。using UnityEngine; public class GlossAnimator : MonoBehaviour { public Material targetMaterial; public float speed 1f; void Update() { float value (Mathf.Sin(Time.time * speed) 1f) * 0.5f; targetMaterial.SetFloat(_Gloss, value); } }这里有一个容易被忽略的细节targetMaterial不要直接从场景里拖一个“共享材质”又被多个物体使用否则设置参数会同时影响所有使用该材质的物体。为了调试最好让目标物体使用一份自己的材质实例比如GetComponentRenderer().material它内部会创建一个新实例。这个操作虽然会破坏批次但调试时无所谓。把高光的_Gloss从0调到1你会看到高光从大面积柔光变成一小块锐利的亮点。这个视觉反馈能帮你后续调参时理解“指数和光斑范围”的关系。5. 想进阶的看这里Shader变体、多Pass与性能优化5.1 Shader变体为什么场景里有那么多看不见的Shader在Unity项目里Shader文件本身不是只有一个二进制体它可能被编译成很多个“变体”。比如上面那个Shader里有#pragma multi_compile_fwdbaseUnity会根据不同光源类型、是否开启阴影、是否开启光照贴图生成不同的编译组合。你在打包时如果不做变体剔除一个复杂的PBR Shader可能有几千个变体包体大小和加载时间都会变差。这听起来很底层但和实际开发密切相关。你会在C#里用ShaderVariantCollection把用不到的变体剔除掉或者在Shader代码里用#pragma skip_variants排除不需要的宏。遇到内存暴涨时先别看贴图多半是Shader变体太多。5.2 多个Pass不是所有东西都只跑一遍一个Shader可以包含多个PassGPU按顺序执行。最常见的场景是描边第一个Pass把物体背面稍微放大一点并渲染成黑色第二个Pass渲染物体正面。听起来很简单但你需要把第一个Pass的Cull Front设置好因为我们要扩的是背面而不是正面。多Pass也能用来实现“轮廓光”“双面渲染”“深度预pass”等效果。多Pass的代价是性能翻倍。如果你给场景里所有角色都套了两层Pass每个角色就相当于要画两遍。所以要克制能用单Pass配合数学方法解决的效果不要轻易多层。一个典型的双Pass描边结构// Pass 1: 渲染背面放大模型黑色描边 Pass { Cull Front // 顶点向外扩张... } // Pass 2: 正常渲染正面 Pass { Cull Back // 正常光照... }我在实际项目里发现描边宽度效果和视角有关所以经常在顶点着色器里把顶点沿法线方向在裁剪空间里扩张。后来为了兼容移动端我还配合了_OutlineWidth参数并做了屏幕空间宽度的缩放效果比固定宽度自然得多。5.3 移动端优化从指令数到内存占用移动端GPU和桌面GPU在架构上有很大差异。桌面端可以任性堆指令数移动端每一行代码都要付费。几个我反复使用的优化原则减少纹理采样次数。能用一个通道存好的数据不要用两个通道各存一份。法线贴图里的a通道往往可以存金属度或粗糙度。避免在片元着色器里做循环。尤其是在移动端for循环可能会被编译器展开导致指令数爆炸。尽量使用half精度替代float特别是在移动端。half能省带宽和功率但要注意取值范围避免出现精度不足导致画面闪烁。关闭不必要的Shadow。实时阴影很贵如果物体只是远景或者小道具可以在Render设置里让它不要接收阴影。我见过一个非常典型的性能坑在片元着色器里对整张贴图做pow运算而且没有限制循环次数。这种写法在PC上跑得好好的到了手机上直接卡成PPT。优化办法是先采样一次再做几次简单算术而不是每像素都做复杂的函数运算。6. 几个高频报错与画面异常的排查实录6.1 物体全黑或全白物体全白通常是只做了return fixed4(1,1,1,1)没有任何光照计算。如果你写了Lambert但还是全白检查_LightColor0是否被正确引入。在ForwardBase中如果没有LightModeForwardBase就无法访问到主光信息。物体全黑最常见的原因是不是把法线和光照方向放在不同坐标系。比如法线在世界空间而光源方向在模型空间点积结果可能为负或者0。另一个常见原因是忘记UNITY_LIGHTMODEL_AMBIENT也就是环境光没加。真实场景里有环境光物体不会纯黑但如果你把UNITY_LIGHTMODEL_AMBIENT去掉背光面就真的全黑。6.2 高光位置不对或闪烁高光位置不对十有八九是ViewDir的方向搞反。我们想要的是“从顶点指向摄像机”如果你写成了“从摄像机指向顶点”高光会跑到背面。闪烁则多半是法线没归一化或者精度不够。在移动端half3的精度很有限如果法线数值范围很大归一化之后依然会带来抖动。6.3 纹理拉伸或UV错乱UV错乱大多数是因为TEXCOORD0和TEXCOORD1搞混。一个模型可以有多套UV第一套通常用于主纹理第二套用于光照贴图或细节贴图。你必须在顶点结构里写对语义。TRANSFORM_TEX宏会结合_MainTex_ST做Tiling和Offset如果你忘写_MainTex_ST的定义编译器会报错但如果你直接tex2D(_MainTex, v.uv)则没有任何Tiling和Offset。这是新手容易忽视的地方。6.4 动态修改材质参数不生效如果你在C#里设置了_Gloss但画面不变先检查你是不是通过material实例还是sharedMaterial。sharedMaterial是共享材质改它的属性会影响所有使用该材质的物体material会生成实例。如果是在多个场景物体上共享同一个材质并动态改但动画没生效很可能是你改的是实例但渲染时读取的是共享材质。为了方便排查我经常在Shader里临时加一个可视化的Debug通道把某个值直接映射到颜色的red通道比如return fixed4(NdotL, 0, 0, 1);。这样你一眼就能看出这个值的变化趋势比对着参数空想快很多。7. 从Unity Shader到技术美术一个持续迭代的方向上面把Shader基础概念和实操串了一遍但我想在最后多说一句不要只看Shader教程更要学会“读渲染结果”。我见过不少开发者代码写得很流畅但遇到画面不对完全靠猜去改参数效率很低。更好的方式是建立一个反馈回路先确定是坐标系问题、是光照方向问题、还是纹理UV问题然后再动手改。在实际项目中Shader往往不是一个人写完就完了。你需要和美术同事对齐“参数范围”比如金属度贴图、光滑度贴图分别存在哪个通道法线贴图是否已经翻转了绿色通道这些约定不写清楚后期联调会非常痛苦。在项目早期就定义一个简单的Shader参数规范文档哪怕只有几行字也能帮团队省下大量沟通成本。我个人在实践中的体会是Shader的学习曲线确实陡但一旦你把“坐标空间变换”和“光照模型是调方向点积”这两件事焊死在脑子里基本就能看懂市面上70%的Shader代码。剩下30%可能需要深入BRDF、延迟渲染、光线追踪这些更重的领域但基础原理不会变。最后再分享一个小技巧调试光照时先不看纹理把albedo固定成灰色这样能排除贴图因素专心看光和阴影的关系。等光照调好了再贴纹理你会觉得整个思路清晰很多。这就是我每次从零搭一个Shader材质时的固定流程看起来慢实际上比直接堆贴图和参数高效得多希望也能给你的开发工作带来一点启发。

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

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

免费获取报价 →
↑