ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity Shader从入门到进阶:Built-in与URP光照模型实战指南

Unity Shader从入门到进阶:Built-in与URP光照模型实战指南 Shader 学习最怕的一件事就是照着教程敲完代码画面出来了但脑子里全是问号——为什么这行要这么写为什么换个管线就报错为什么同样的光照模型别人跑出来是金属质感我跑出来像塑料我当初从《Unity Shader 入门精要》第 5 章啃到第 6 章正好卡在这个坎上第 5 章讲的是基础光照模型第 6 章开始进入更贴近真实渲染的进阶光照。而现在的 Unity 生态里Built-in 管线和 URP 管线并存HLSL 写法又和书里的 CG 语法有出入很多人学到一半就迷失了方向。这篇内容就是把我从 5.2 节一路推到 6.0 节过程中踩过的坑、想通的原理、以及实际项目里验证过的写法完整地摊开来讲。不管你是刚学完基础渲染管线、准备啃光照模型的新手还是从 Built-in 迁移到 URP 时被 Shader 报错折磨过的老手都能从里面找到能直接用的东西。1. 从 5.2 到 6.0 到底跨过了什么1.1 第 5 章和第 6 章的分界线在哪里《Unity Shader 入门精要》第 5 章的核心是基础光照它从标准光照模型讲起把环境光、漫反射、高光反射这三块拆开用逐顶点和逐像素两种方式分别实现。5.2 节通常对应的是漫反射光照模型的实现也就是 Lambert 模型那套东西。而第 6 章进入的是高级光照开始处理更复杂的光照效果比如半兰伯特、卡通渲染、以及基于物理的渲染思路。这条分界线看起来只是章节编号的变化实际上代表的是思维方式的转变。第 5 章你还在用经验公式拼凑光照第 6 章开始要求你理解光与材质交互的物理本质。我当初学 5.2 的时候觉得漫反射不就是法线和光线方向点乘一下嘛有什么难的。结果到了第 6 章发现同样的漫反射要考虑能量守恒、要考虑 BRDF、要考虑不同管线下的光照方向语义差异一下子就懵了。所以从 5.2 到 6.0真正跨过的不是几个公式而是从照着公式写代码到理解公式为什么长这样的认知升级。1.2 Built-in 与 URP 下同一段光照代码的命运差异这是我最想先讲清楚的一件事因为它直接决定了你书上的代码能不能跑起来。书里的示例基本是基于 Built-in 渲染管线写的用的是 CG 语法光照相关的变量比如_WorldSpaceLightPos0、_LightColor0都能直接用。但如果你在 URP 项目里照抄大概率会遇到两个结果要么编译报错要么画面全黑。原因在于 URP 把光照相关的内置变量做了封装你需要引入Lighting.hlsl这个库文件并且光照方向、颜色这些数据的获取方式变了。在 Built-in 里_WorldSpaceLightPos0直接就是光源方向对于平行光而言但在 URP 里你需要通过GetMainLight()函数拿到一个Light结构体再从里面取direction和color。我实测下来最稳妥的做法是先确定你的项目用的是哪条管线然后选择对应的写法。如果你只是想学原理用 Built-in 跟着书走最省心如果你要做实际项目尤其是移动端或者需要后续升级的直接上 URP但要做好把书里代码翻译一遍的准备。这个翻译过程不是简单的替换变量名而是要理解每个变量背后的语义。1.3 HLSL 语法在 Unity 里的实际地位现在热搜词里频繁出现 HLSL这不是偶然。Unity 官方这几年一直在推 HLSL 作为 Shader 编写的主力语言URP 和 HDRP 的 Shader 库基本都是用 HLSL 写的。CG 语法虽然还能用但本质上是被翻译成 HLSL 的而且在新管线里支持得越来越差。HLSL 和 CG 在语法层面差异不算大都是类 C 的语言但有几个关键区别你得知道。第一HLSL 没有 CG 里的fixed类型你得用half或者float代替。第二HLSL 的库函数命名和参数顺序有些微调比如tex2D在 HLSL 里更推荐用SAMPLE_TEXTURE2D宏。第三HLSL 对语义semantic的要求更严格比如顶点着色器的输入输出语义必须明确写清楚。我个人的建议是如果你是新学直接学 HLSL 写法别在 CG 上浪费时间。书里的 CG 代码你可以看懂就行实际写的时候用 HLSL 的规范来。这样你写出来的 Shader 在 URP 和 Built-in 下都能有更好的兼容性后续迁移也省事。2. 漫反射光照模型的逐像素实现细节2.1 为什么逐像素比逐顶点更值得掌握书里在讲漫反射的时候会先给一个逐顶点的版本再给一个逐像素的版本。逐顶点的做法是在顶点着色器里算好光照然后插值到片元。逐像素是把法线和光线方向传到片元着色器在片元里算光照。两者的画面差异在低模上特别明显逐顶点会出现明显的块状感因为光照是在顶点之间线性插值的而逐像素能得到平滑的光照过渡。我当初偷懒觉得逐顶点性能好就先用了逐顶点。结果在一个角色模型上脸部的高光区域出现了明显的多边形棱角特别难看。换成逐像素之后虽然性能开销大了一点但画面质量提升是肉眼可见的。现在的 GPU 性能下除非你是做极低端的移动设备否则逐像素光照基本是标配。这里有个细节要注意逐像素计算时法线要从顶点着色器传到片元着色器这个传递过程会经过插值。如果你的模型法线没有归一化插值后的法线长度可能不是 1直接拿去做点乘会导致光照结果偏暗或者偏亮。所以片元着色器里拿到法线后第一件事就是normalize一下。这个坑我踩过当时调了半天光照强度最后发现是法线没归一化。2.2 法线从模型空间到世界空间的转换陷阱漫反射计算需要在同一个空间里进行通常是世界空间。法线从模型空间转到世界空间不能直接用模型到世界的变换矩阵因为法线是方向向量不是位置点。如果模型有非均匀缩放直接用UnityObjectToWorldNormal之外的矩阵变换法线方向会歪掉。Unity 提供了UnityObjectToWorldNormal这个函数它内部处理了非均匀缩放的情况用的是逆转置矩阵。我建议你直接用这个函数别自己拼矩阵。书里有时候会写mul(v.normal, (float3x3)unity_WorldToObject)这种写法那是老版本的写法现在用UnityObjectToWorldNormal更安全。还有一个容易忽略的点光照方向的空间。在 Built-in 里_WorldSpaceLightPos0对于平行光来说就是世界空间的光照方向但它的方向定义是从光源指向物体还是从物体指向光源这个容易搞混。实际上_WorldSpaceLightPos0对于平行光它的 xyz 分量是光源方向的反方向也就是从物体指向光源的方向。所以你在做点乘的时候直接用_WorldSpaceLightPos0.xyz和法线点乘得到的就是正确的漫反射系数。这个语义我当初理解错了导致光照方向反了物体背光面反而亮。2.3 一个可直接复用的逐像素漫反射 Shader 骨架下面这个骨架是我在实际项目里反复用过的基于 Built-in 管线HLSL 风格写法你可以直接拿去改。Shader Custom/PixelDiffuse { Properties { _MainTex (Main Texture, 2D) white {} _DiffuseColor (Diffuse Color, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque LightModeForwardBase } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float2 uv : TEXCOORD1; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _DiffuseColor; 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 { float3 normal normalize(i.worldNormal); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float diff max(0, dot(normal, lightDir)); float3 diffuse _LightColor0.rgb * _DiffuseColor.rgb * diff; float3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb; fixed4 texColor tex2D(_MainTex, i.uv); return fixed4((diffuse ambient) * texColor.rgb, 1.0); } ENDCG } } }这个骨架里LightMode设为ForwardBase是关键它告诉 Unity 这是前向渲染的基础 Pass会处理主平行光。_LightColor0和_WorldSpaceLightPos0都是在这个 Pass 下才有效的内置变量。如果你把LightMode去掉或者设错光照就会失效。2.4 环境光与自发光在漫反射里的角色很多人写漫反射的时候只算主光结果物体背光面全黑看起来特别假。真实世界里背光面也有光来自环境反射。Unity 里用UNITY_LIGHTMODEL_AMBIENT来获取环境光它是一个全局变量在 Built-in 里可以直接用。但要注意环境光在 URP 里的获取方式完全不同URP 用的是SH球谐函数来存储环境光你需要通过SampleSH或者unity_AmbientSky之类的变量来取。这也是为什么 URP 下照抄 Built-in 代码会出问题的原因之一。自发光Emission在漫反射阶段通常不参与光照计算它是直接叠加到最终颜色上的。如果你想让物体有自发光效果可以在片元着色器最后加上_EmissionColor。但自发光不影响其他物体的光照它只是让物体自己看起来亮。如果你要做真正的发光效果影响周围那需要用到全局光照或者点光源。3. 从漫反射到高光6.0 章节的进阶光照思路3.1 高光反射的物理直觉与 Blinn-Phong 模型第 6 章开始讲高光反射书里会先讲 Phong 模型再讲 Blinn-Phong 模型。Phong 模型的核心是反射向量和视线方向的点乘Blinn-Phong 是半角向量和法线的点乘。两者的区别在于Blinn-Phong 在视线和反射方向夹角很大时高光会更柔和而且计算量更小因为半角向量只需要算一次。我实测下来Blinn-Phong 在大多数场景下视觉效果更自然尤其是金属材质。Phong 模型的高光在边缘处容易出现过亮或者断裂。所以如果你没有特殊需求直接用 Blinn-Phong 就行。半角向量的计算是normalize(lightDir viewDir)然后和法线点乘再取pow的指数。这个指数就是高光范围值越大高光越集中。书里通常会给一个_Gloss参数来控制范围从 8 到 256 不等。我一般用 20 到 50 之间具体看材质。3.2 半兰伯特模型解决背光面死黑的问题半兰伯特是第 6 章的一个重点它解决的是传统兰伯特模型在背光面完全黑掉的问题。传统兰伯特用max(0, dot(normal, lightDir))背光面点乘为负直接截断成 0。半兰伯特把点乘结果从 [-1,1] 映射到 [0,1]公式是dot(normal, lightDir) * 0.5 0.5。这样一来背光面也有一定的亮度画面更柔和。这个技巧在卡通渲染和角色渲染里特别常用。我做过一个卡通风格的角色用半兰伯特之后脸部的暗部不会死黑看起来更舒服。但半兰伯特也有个问题它会让光照失去物理正确性暗部亮度偏高整体对比度降低。所以如果你做的是写实风格慎用半兰伯特或者只在小范围内用。3.3 高光与漫反射的叠加顺序与能量守恒在片元着色器里漫反射和高光通常是分开算最后相加。但相加的时候要注意能量守恒漫反射和高光的总能量不应该超过入射光能量。实际写的时候很多人会把高光叠得很亮导致物体看起来像涂了油。我的做法是先算漫反射再算高光高光乘以一个系数控制强度最后加上环境光。如果要做物理正确的渲染那就要用 BRDF 模型把漫反射和高光统一到一个公式里。但那是第 6 章之后的内容了入门阶段先把 Blinn-Phong 用熟。这里有个经验高光的颜色通常取光源颜色而不是材质颜色。金属的高光颜色会带一点材质的色调但非金属的高光基本就是光源颜色。如果你把高光颜色设成材质颜色金属感会更强但非金属会显得很怪。4. URP 下重写光照 Shader 的完整路径4.1 URP 的 Shader 结构与传统 Built-in 的根本差异URP 的 Shader 结构和 Built-in 差异很大。Built-in 用CGPROGRAM和ENDCGURP 用HLSLPROGRAM和ENDHLSL。Built-in 的 Pass 里用LightMode标签URP 也有类似的标签但光照相关的库文件完全不同。URP 的核心库是UniversalRenderPipeline.hlsl光照相关的在Lighting.hlsl里。你需要引入这些库才能用GetMainLight、GetAdditionalLight这些函数。而且 URP 的顶点输入输出结构体通常用Attributes和Varyings来命名和 Built-in 的appdata、v2f对应。我刚开始迁移的时候最不习惯的就是 URP 把很多东西封装成了函数和结构体不像 Built-in 那样直接暴露变量。但用久了会发现这种封装其实更清晰尤其是处理多光源的时候。4.2 用 GetMainLight 获取主光源的正确姿势在 URP 里获取主光源标准写法是Light mainLight GetMainLight(); float3 lightDir mainLight.direction; float3 lightColor mainLight.color;GetMainLight返回的Light结构体里direction是从物体指向光源的方向和 Built-in 的_WorldSpaceLightPos0语义一致。color是光源颜色已经乘上了强度。如果你需要阴影还可以用GetMainLight(shadowCoord)的重载版本。这里有个坑URP 里的GetMainLight默认不包含阴影衰减你需要自己处理阴影。如果你直接拿mainLight.color去算光照阴影是不会显示的。要显示阴影得用mainLight.shadowAttenuation乘上去。这个我当初调了很久一直以为阴影没开后来发现是没乘衰减。4.3 URP 下逐像素漫反射的完整实现下面这个是我在 URP 下写的逐像素漫反射 Shader可以直接用Shader Custom/URP/PixelDiffuse { Properties { _BaseMap (Base Map, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Tags { LightModeUniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float3 normalWS : TEXCOORD0; float2 uv : TEXCOORD1; }; TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; CBUFFER_END Varyings vert (Attributes input) { Varyings output; VertexPositionInputs posInputs GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normInputs GetVertexNormalInputs(input.normalOS); output.positionCS posInputs.positionCS; output.normalWS normInputs.normalWS; output.uv TRANSFORM_TEX(input.uv, _BaseMap); return output; } half4 frag (Varyings input) : SV_Target { half3 normalWS normalize(input.normalWS); Light mainLight GetMainLight(); half3 lightDir normalize(mainLight.direction); half diff max(0, dot(normalWS, lightDir)); half3 diffuse mainLight.color * _BaseColor.rgb * diff; half3 ambient SampleSH(normalWS); half4 texColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); return half4((diffuse ambient) * texColor.rgb * _BaseColor.rgb, 1.0); } ENDHLSL } } }这个 Shader 里GetVertexPositionInputs和GetVertexNormalInputs是 URP 提供的工具函数帮你处理空间变换。SampleSH是采样球谐环境光比 Built-in 的UNITY_LIGHTMODEL_AMBIENT更准确。CBUFFER_START和CBUFFER_END是 URP 要求的常量缓冲区声明SRP Batcher 需要它来合批。4.4 SRP Batcher 对 Shader 写法的约束SRP Batcher 是 URP 的一个性能优化特性它能大幅减少 Draw Call 的开销。但它对 Shader 写法有要求所有材质属性必须放在CBUFFER_START(UnityPerMaterial)和CBUFFER_END之间。如果你把属性声明在外面SRP Batcher 就不会生效性能会下降。我当初不知道这个写了个 Shader 发现 Draw Call 特别高后来查了半天才发现是没加 CBUFFER。加上之后Draw Call 直接降了一半。这个细节书里没讲但实际项目里很重要。另外SRP Batcher 还要求 Shader 的变体尽量少所以你要控制#pragma multi_compile的使用。能用shader_feature的地方就别用multi_compile因为shader_feature只编译实际用到的变体而multi_compile会编译所有组合。5. 光照调试中那些让人抓狂的瞬间5.1 画面全黑或全白的排查链路Shader 写完之后画面全黑这是最常见的问题。我总结了一个排查顺序按这个顺序走基本能定位到问题。第一步检查LightMode标签。Built-in 下前向渲染基础 Pass 必须是ForwardBaseURP 下必须是UniversalForward。标签错了光照变量就是空的画面自然黑。第二步检查光照方向。把lightDir直接输出成颜色看看方向对不对。如果输出是纯色说明方向向量有问题。常见原因是法线没转到世界空间或者光照方向没归一化。第三步检查点乘结果。把dot(normal, lightDir)输出成颜色正常应该是从黑到白的渐变。如果全是黑说明法线和光线方向夹角始终大于 90 度可能是法线方向反了。第四步检查环境光。如果漫反射正常但整体偏暗可能是环境光没加上。Built-in 下用UNITY_LIGHTMODEL_AMBIENTURP 下用SampleSH。第五步检查纹理采样。如果颜色不对但光照正常可能是 UV 或者纹理没设置对。把纹理采样去掉直接输出纯色看看光照是否正常。这个排查链路我用了很多次基本上五分钟内能定位到问题。5.2 法线贴图与切线空间的方向混乱法线贴图是进阶光照里绕不开的东西但它带来的方向混乱也是最多的。法线贴图存储的是切线空间下的法线你需要把切线空间的法线转到世界空间才能参与光照计算。这个转换需要切线、副切线和法线构成的 TBN 矩阵。Unity 里可以用UnpackNormal来解包法线贴图然后手动构建 TBN 矩阵。但要注意不同平台的法线贴图格式可能不同有的平台是 DXT5nm 压缩有的平台是 RGB。UnpackNormal会处理这些差异但如果你自己写解包逻辑就要小心平台兼容性。我踩过的坑是切线空间的手性handedness在不同模型上可能不一致导致法线贴图看起来是反的。解决办法是在导入模型时勾选 Calculate MikkTSpace 或者手动调整切线。这个坑很隐蔽因为法线贴图反了之后光照看起来只是有点怪不一定会明显报错。5.3 多光源场景下的 Pass 叠加问题Built-in 的前向渲染里多光源是通过多个 Pass 叠加实现的。主光源在ForwardBasePass 里处理额外光源在ForwardAddPass 里处理。如果你只写了ForwardBase那场景里只有主光源能影响物体其他光源都没效果。ForwardAddPass 需要设置Blend One One因为它是叠加到已有颜色上的。而且ForwardAdd里不能用UNITY_LIGHTMODEL_AMBIENT因为环境光已经在ForwardBase里加过了再加就重复了。URP 下多光源的处理方式不同URP 默认只支持有限数量的额外光源超过数量的光源会用球谐函数近似。如果你需要更多实时光源得在 URP 设置里调整Additional Lights的数量。这个设置我当初找了很久因为它不在 Shader 里而在管线的 Asset 设置里。6. 从入门到能用的几个关键习惯6.1 用 Frame Debugger 看每个 Pass 的实际输出Frame Debugger 是 Unity 里调试 Shader 的神器但很多人不知道用。它能让你看到每一帧里每个 Draw Call 的实际渲染结果包括每个 Pass 的输出。如果你怀疑某个 Pass 没生效用 Frame Debugger 一看就知道。我调试多光源的时候就是用 Frame Debugger 发现ForwardAddPass 根本没执行。原因是LightMode标签写错了改过来之后额外光源就正常了。这个工具比在 Shader 里加return调试高效得多。6.2 把光照计算拆成可单独验证的模块写光照 Shader 的时候不要一次性把所有东西都写进去。先把漫反射单独调通确认光照方向、法线、颜色都对了再加高光。加高光的时候先把高光单独输出确认高光位置和范围对了再和漫反射叠加。这种模块化的调试方式能让你在出问题的时候快速定位到是哪一块的问题。我见过很多人写 Shader 是一大坨出了问题是全黑然后就开始瞎改改了半天也不知道哪里错了。拆开写虽然前期麻烦一点但后期省时间。6.3 版本管理Shader 代码也要进 GitShader 代码是纯文本完全可以进 Git。我建议每个 Shader 都单独一个文件不要嵌在材质里。这样你可以追踪每次修改出问题了也能回滚。另外Shader 的变体很多编译一次很慢。如果你在团队里协作建议把 Shader 的编译缓存也管理起来或者用 Shader Variant Collection 来预编译常用变体。这个在大型项目里能省很多时间。6.4 从 Built-in 迁移到 URP 的检查清单如果你手头有 Built-in 的 Shader 要迁移到 URP可以按这个清单来检查检查项Built-in 写法URP 写法程序块CGPROGRAMHLSLPROGRAM库文件UnityCG.cgincCore.hlsl Lighting.hlsl光照方向_WorldSpaceLightPos0GetMainLight().direction光源颜色_LightColor0GetMainLight().color环境光UNITY_LIGHTMODEL_AMBIENTSampleSH(normalWS)纹理采样tex2DSAMPLE_TEXTURE2D常量缓冲无要求CBUFFER_START(UnityPerMaterial)Pass 标签ForwardBaseUniversalForward阴影SHADOW_COORDS通过 GetMainLight 的 shadowAttenuation按这个清单逐项替换基本能完成迁移。但要注意URP 的光照模型和 Built-in 不完全一致迁移后可能需要微调参数才能达到相同的视觉效果。6.5 性能优化的几个实际抓手光照 Shader 的性能优化我实际用下来最有效的几个手段第一能用 half 就别用 float移动端 half 的运算速度快很多。第二避免在片元着色器里做复杂的数学运算能移到顶点着色器的就移过去。第三控制光照计算的次数多光源场景下能用球谐近似的就用球谐别每个光源都算一遍。还有一个容易被忽略的点normalize是有开销的如果法线在顶点着色器里已经归一化了片元里可以不再归一化除非经过了插值。插值后的法线长度会变所以片元里通常还是要归一化一次。但如果你能保证插值后长度变化不大可以省掉这次归一化。7. 写在最后的一些个人体会从 5.2 到 6.0 这段路我走了大概两个月中间反复回看了很多遍书也在实际项目里试错了很多次。最大的体会是Shader 入门难不是因为数学有多复杂而是因为信息太分散。书上的写法、官方文档的写法、实际项目的写法三者之间往往有差异你得自己把这些差异拼起来。我的建议是先把一个最简单的漫反射 Shader 在 Built-in 和 URP 下各写一遍确保都能跑通。然后在这个基础上逐步加高光、加法线贴图、加多光源。每加一个功能就用 Frame Debugger 看一遍确认输出符合预期。这样走下来你对光照模型的理解会比只看书深得多。另外别怕看官方源码。URP 的Lighting.hlsl里有很多注释解释了每个函数的用途和参数。你不需要全部看懂但遇到问题的时候去源码里搜一下相关函数往往能找到答案。我很多疑惑都是在读源码的时候解开的。最后说一个实际项目里的经验光照效果好不好参数调优占一半。同样的 Shader_Gloss从 20 调到 50金属感完全不同。所以写完 Shader 只是开始花时间调参数才是让画面出效果的关键。别指望一个默认参数就能出好效果多试几组值对比着看慢慢就有感觉了。
返回列表