1. 项目概述:从理论到实践的跨越
聊了这么多期Shader的基础概念和语法,相信不少朋友已经摩拳擦掌,想看看这些理论到底能做出什么“花”来。今天,我们就来点实在的,把目光聚焦在Unity中一个非常强大且对美术友好的工具——表面着色器(Surface Shader)上,通过几个完整的、可直接复用的实用示例,来一场从零到一的实战演练。表面着色器本质上是一个代码生成框架,它帮你处理了光照模型、阴影、前向/延迟渲染路径等复杂且重复的底层工作,让你能像写一个材质属性描述函数一样,专注于物体表面的视觉表现。这对于需要快速迭代视觉效果的美术同学,或者不想在光照计算上耗费过多精力的程序同学来说,简直是福音。无论你是想实现一个带法线贴图的金属表面,还是一个半透明的卡通风格水体,通过表面着色器,你都能用相对简洁的代码达成目标。这篇文章,我就以一个从业者的角度,带你手把手实现几个经典且实用的表面着色器效果,并分享我在实际项目开发中积累的“踩坑”经验和优化技巧。
2. 核心思路与框架解析
在动手写代码之前,我们必须先理解表面着色器的“工作流”。它不是一个全新的着色语言,而是建立在Unity的CG/HLSL之上的一套高级抽象。你写的surf函数,其核心任务是:给定一个世界空间或切线空间下的输入(通过Input结构体),计算出物体表面某一点的视觉属性,并填充到SurfaceOutputStandard(或自定义的)结构体中。这个结构体里的数据,比如Albedo(漫反射颜色)、Normal(法线)、Metallic(金属度)、Smoothness(光滑度)等,就是交给Unity光照引擎进行后续计算的“原料”。
2.1 为什么选择表面着色器?
在Unity中,实现一个带完整PBR光照的材质,你有几种选择:手写顶点/片元着色器、使用Shader Graph可视化编程,或者使用表面着色器。我选择从表面着色器入手讲解实战,主要基于以下几点考量:
- 平衡效率与可控性:Shader Graph非常直观,适合美术和快速原型,但有时对复杂逻辑或性能极致优化不够灵活。手写顶点/片元着色器控制力最强,但实现一套完整的PBR光照代码量巨大,容易出错。表面着色器恰好处于中间,它用声明式的
#pragma surface指令,让Unity帮你生成复杂的光照Pass代码,而你只需关心表面属性的计算,在保持较高可控性的同时,极大提升了开发效率。 - 学习曲线平滑:对于已经理解CG语法和基础光照模型的开发者来说,表面着色器是迈向更复杂着色器编写的完美台阶。它让你能更直观地理解“表面属性”与“最终像素颜色”之间的映射关系。
- 便于调试和迭代:由于核心逻辑集中在
surf一个函数里,并且属性与Properties块直接关联,调整参数、迭代效果非常迅速。你可以像调整标准材质球一样,在Inspector里滑动滑块,实时看到效果变化。
2.2 一个标准的表面着色器骨架
任何表面着色器都离不开几个关键部分,下面这个骨架是你每次开始新Shader时都可以复用的模板:
Shader "Custom/MySurfaceShader" { Properties { // 在这里定义在材质面板中可见和可调节的属性 _MainTex ("Albedo (RGB)", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _Metallic ("Metallic", Range(0,1)) = 0.0 _Glossiness ("Smoothness", Range(0,1)) = 0.5 // ... 可以添加更多属性,如法线贴图、自发光等 } SubShader { Tags { "RenderType"="Opaque" } // 渲染队列和类型标签 LOD 200 // 细节级别 CGPROGRAM // 最关键的一行:声明这是一个表面着色器,使用Standard光照模型,使用Lambert漫反射模型等 #pragma surface surf Standard fullforwardshadows // 关联Properties中定义的属性到CG变量 sampler2D _MainTex; fixed4 _Color; half _Metallic; half _Glossiness; // Input结构体:定义了surf函数可以获取哪些额外的数据 struct Input { float2 uv_MainTex; // 自动匹配_MainTex的UV坐标 // 可以添加worldPos, viewDir, worldNormal等 }; // 核心的surf函数:计算表面属性 void surf (Input IN, inout SurfaceOutputStandard o) { // 采样纹理,结合颜色 fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; // 设置漫反射颜色 o.Metallic = _Metallic; // 设置金属度 o.Smoothness = _Glossiness; // 设置光滑度 o.Alpha = c.a; // 设置透明度(如果是透明或镂空材质) } ENDCG } FallBack "Diffuse" // 备选着色器,当主着色器不支持时使用 }这个骨架里,#pragma surface surf Standard fullforwardshadows是灵魂。surf指定函数名,Standard指定了基于物理的渲染光照模型,fullforwardshadows则告诉Unity为这个着色器生成支持完整前向渲染阴影的代码。理解并熟练运用不同的#pragma参数,是写出高效表面着色器的关键。
3. 实战示例一:基础PBR材质增强(法线贴图与细节贴图)
第一个例子,我们来实现一个增强版的基础PBR材质。标准材质已经有了颜色、金属度、光滑度,我们再为它加法线贴图和细节遮罩贴图,这是项目中提升模型质感最常用的手段。
3.1 属性与变量声明
首先在Properties块中添加我们需要的纹理和强度控制参数:
Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _NormalMap ("Normal Map", 2D) = "bump" {} // “bump”是内置的灰色法线贴图 _NormalStrength ("Normal Strength", Range(0, 2)) = 1.0 _DetailMask ("Detail Mask (R)", 2D) = "white" {} _DetailAlbedo ("Detail Albedo (RGB)", 2D) = "gray" {} _DetailIntensity ("Detail Intensity", Range(0, 1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 _Glossiness ("Smoothness", Range(0,1)) = 0.5 }这里有几个细节:
_NormalMap的默认值设为”bump”,这是一个内置的、代表平坦表面的法线贴图。_DetailMask通常是一张单通道(如R通道)的灰度图,用于控制细节贴图在模型表面的显示区域(白色显示,黑色不显示)。_DetailAlbedo是细节颜色贴图,通常使用平铺(Tiling)较高的纹理来增加表面微观细节。
3.2 Input结构体与surf函数实现
接下来,我们需要在Input结构体中声明对应的UV变量。对于第二套UV(细节UV),我们通常使用uv2前缀,或者使用float2 uv_DetailAlbedo;并依靠Unity的自动匹配。为了更清晰,我们显式声明:
struct Input { float2 uv_MainTex; float2 uv_NormalMap; float2 uv2_DetailAlbedo; // 假设细节贴图使用第二套UV float2 uv_DetailMask; };然后是最关键的surf函数实现:
void surf (Input IN, inout SurfaceOutputStandard o) { // 1. 采样主颜色贴图 fixed4 mainTex = tex2D(_MainTex, IN.uv_MainTex); fixed4 albedo = mainTex * _Color; // 2. 采样并应用细节贴图(基于遮罩) fixed4 detailTex = tex2D(_DetailAlbedo, IN.uv2_DetailAlbedo); fixed mask = tex2D(_DetailMask, IN.uv_DetailMask).r; // 取遮罩的R通道 // 常见的细节混合模式:叠加(Overlay)或线性减淡(Linear Dodge)。这里使用简单的线性插值。 // 将细节颜色(去色后与主色混合)根据遮罩强度混合到主颜色上。 fixed3 detailColor = lerp(fixed3(0.5,0.5,0.5), detailTex.rgb, _DetailIntensity); // 基础中性色混合 albedo.rgb = lerp(albedo.rgb, albedo.rgb * detailColor * 2.0, mask); // 乘以2.0用于增强对比 o.Albedo = albedo.rgb; // 3. 采样并解压法线贴图 // UnpackNormal函数会处理不同平台(如移动端)的法线贴图压缩格式 fixed3 normalMap = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap)); normalMap.xy *= _NormalStrength; // 调整法线强度 normalMap.z = sqrt(1.0 - saturate(dot(normalMap.xy, normalMap.xy))); // 重新计算Z分量,保证归一化 o.Normal = normalMap; // 4. 设置PBR参数 o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = albedo.a; }注意:细节贴图的混合算法多种多样,上面使用的是其中一种简化版。在AAA项目中,可能会使用更复杂的混合模式,如高度混合。
UnpackNormal是UnityCG.cginc中提供的工具函数,务必使用它来正确采样法线贴图,尤其是在移动平台。
3.3 实操心得与参数调节
在实际使用中,有几点心得:
- 法线强度:
_NormalStrength并非越大越好。值大于1会夸大凹凸感,可能产生不自然的“塑料感”或边缘闪烁。通常保持在0.5到1.5之间比较安全。 - 细节遮罩:制作一张好的细节遮罩纹理是关键。它应该基于模型的高模信息或手绘,确保在需要表现磨损、污渍、划痕的地方(如边缘、凹陷处)是白色,在平坦干净的区域是黑色。模糊的遮罩边缘能让混合更自然。
- 性能考量:每多采样一张纹理,就多一次纹理读取开销。在移动端,需严格控制纹理采样次数。如果
_DetailMask和_DetailAlbedo可以合并到一张纹理的RGBA通道中(例如Albedo的RGB和Mask的A),就能减少一次采样,这是常见的优化手段。
4. 实战示例二:动态积雪效果
第二个例子,我们来实现一个经典且视觉效果突出的动态积雪效果。其核心思路是:根据模型顶点在世界空间中的朝上程度(与世界空间法线Y分量相关)来混合雪材质和原本的材质。
4.1 效果原理与属性设计
积雪效果模拟的是雪自然堆积在物体上表面的物理现象。我们需要:
- 一个雪材质:包含雪的漫反射颜色、法线贴图、以及可能有的粗糙度。
- 一个控制参数:控制积雪的厚度或强度。
- 一个遮罩纹理(可选):用于手动控制哪些区域永远不积雪(如热源、垂直墙面)。
因此,属性可以这样设计:
Properties { [Header(Base Material)] _MainTex ("Albedo", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _NormalMap ("Normal Map", 2D) = "bump" {} _Metallic ("Metallic", Range(0,1)) = 0.0 _Glossiness ("Smoothness", Range(0,1)) = 0.5 [Header(Snow Material)] _SnowTex ("Snow Albedo", 2D) = "white" {} _SnowColor ("Snow Color", Color) = (0.9,0.9,0.9,1) _SnowNormalMap ("Snow Normal Map", 2D) = "bump" {} _SnowSmoothness ("Snow Smoothness", Range(0,1)) = 0.3 _SnowMetallic ("Snow Metallic", Range(0,1)) = 0.0 [Header(Snow Settings)] _SnowAmount ("Snow Amount", Range(0, 1)) = 0.5 _SnowDirection ("Snow Direction", Vector) = (0, 1, 0, 0) // 默认从上往下落 _SnowDepth ("Snow Depth", Range(0, 0.5)) = 0.1 _SnowMask ("Snow Mask (R)", 2D) = "white" {} }_SnowDirection通常就是世界空间的上方向(0,1,0),但你可以调整它来模拟斜向的风雪。_SnowDepth用于在顶点着色器中沿法线方向偏移顶点,模拟积雪的厚度堆积感。
4.2 顶点修改与表面混合
这个效果需要用到顶点着色器来修改顶点位置。我们在#pragma指令中加入vertex:vert来指定自定义的顶点函数。
CGPROGRAM #pragma surface surf Standard fullforwardshadows vertex:vert addshadow // 注意 `addshadow` 指令,它确保在顶点修改后,阴影投射也能正确工作,否则阴影会停留在原位置,出现“穿帮”。然后,定义顶点函数和新的Input结构体(需要世界法线):
struct Input { float2 uv_MainTex; float2 uv_SnowTex; float2 uv_SnowMask; float3 worldNormal; // 需要世界空间法线来计算积雪 INTERNAL_DATA // 这个宏必须加,当使用worldNormal且需要从切线空间转换时 }; void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 计算顶点在世界空间中的法线方向(近似) float3 worldNormal = UnityObjectToWorldNormal(v.normal); // 计算该顶点朝向积雪方向的程度(点积),并应用阈值和强度 float snowFactor = dot(worldNormal, _SnowDirection.xyz); snowFactor = saturate(snowFactor - (1.0 - _SnowAmount)); // 减去一个偏移,控制积雪开始的坡度 snowFactor = pow(snowFactor, 2); // 使用pow让过渡更平滑或更锐利,根据需求调整 // 根据积雪因子,沿顶点法线方向偏移顶点位置 v.vertex.xyz += v.normal * _SnowDepth * snowFactor; }最后,在surf函数中进行材质混合:
void surf (Input IN, inout SurfaceOutputStandard o) { // 1. 采样基础材质 fixed4 mainTex = tex2D(_MainTex, IN.uv_MainTex); fixed3 baseAlbedo = mainTex.rgb * _Color.rgb; fixed4 baseNormal = tex2D(_NormalMap, IN.uv_MainTex); // 2. 采样雪材质 fixed4 snowTex = tex2D(_SnowTex, IN.uv_SnowTex); fixed3 snowAlbedo = snowTex.rgb * _SnowColor.rgb; fixed4 snowNormalTex = tex2D(_SnowNormalMap, IN.uv_SnowTex); // 3. 计算动态积雪因子(同样基于世界法线,但这里用surf阶段的更精确) // WorldNormalVector是一个内置函数,用于从Input中的世界法线(INTERNAL_DATA)获取正确的向量 float3 worldNormal = WorldNormalVector(IN, float3(0,0,1)); float dynamicSnowFactor = dot(worldNormal, _SnowDirection.xyz); dynamicSnowFactor = saturate(dynamicSnowFactor - (1.0 - _SnowAmount)); // 采样遮罩贴图 fixed snowMask = tex2D(_SnowMask, IN.uv_SnowMask).r; dynamicSnowFactor *= snowMask; // 用遮罩图控制最终积雪区域 // 4. 混合Albedo和Normal o.Albedo = lerp(baseAlbedo, snowAlbedo, dynamicSnowFactor); fixed3 baseNormalUnpacked = UnpackNormal(baseNormal); fixed3 snowNormalUnpacked = UnpackNormal(snowNormalTex); o.Normal = lerp(baseNormalUnpacked, snowNormalUnpacked, dynamicSnowFactor); // 5. 混合PBR参数 o.Metallic = lerp(_Metallic, _SnowMetallic, dynamicSnowFactor); o.Smoothness = lerp(_Glossiness, _SnowSmoothness, dynamicSnowFactor); o.Alpha = mainTex.a; }4.4 常见问题与排查技巧
- 阴影撕裂或错位:如果忘记在
#pragma中加入addshadow,或者顶点偏移量_SnowDepth过大,会导致阴影投射物的顶点位置与渲染对象不匹配,产生阴影“飘离”或撕裂的现象。务必加上addshadow,并谨慎控制深度值。 - 积雪边缘锯齿或过渡生硬:
dynamicSnowFactor的计算直接使用saturate会导致硬边。可以通过smoothstep函数或者对点积结果进行pow操作来软化边缘,例如snowFactor = smoothstep(_SnowThreshold, _SnowThreshold+_SnowSoftness, dot(...))。 - 性能开销:这个着色器进行了多次纹理采样和
lerp运算,顶点着色器也有计算。在移动端,如果模型面数很高,需考虑简化。可以尝试将雪的法线贴图与基础法线贴图合并,或者使用更简化的光照模型(如Lambert代替Standard)。 - 法线方向错误:确保
_SnowDirection是单位向量。如果是(0,1,0),在vert函数中直接使用即可。如果在surf中混合后法线看起来奇怪,检查WorldNormalVector的使用是否正确,并确保INTERNAL_DATA已包含在Input结构体中。
5. 实战示例三:屏幕空间溶解与发光边缘
第三个例子,我们做一个更炫酷的、常用于角色死亡、传送或建筑破坏的效果——屏幕空间溶解。效果是物体从下至上或随机地像沙子一样消散,并且在消散边缘有发光的轮廓。
5.1 核心思路:噪声图与屏幕空间深度
溶解效果的核心是一张噪声纹理(Noise Texture)。我们为模型的每个片段(像素)采样噪声图,得到一个随机值。然后,我们用一个全局的_DissolveAmount(溶解进度,0到1)作为阈值。如果该片段的噪声值小于阈值,则丢弃该片段(clip),形成空洞。发光边缘则通过检测那些噪声值在阈值附近(即即将被溶解或刚刚被保留)的片段,为其赋予一个高亮颜色。
但是,纯模型空间的溶解在相机移动时会“粘”在模型上。为了实现更震撼的、仿佛在屏幕空间中溶解的效果,我们需要用到屏幕空间位置。这样,无论相机怎么动,溶解的“波前”在屏幕上的移动是稳定的。
5.2 着色器实现详解
首先定义属性,我们需要噪声图、溶解颜色、边缘宽度和颜色等。
Properties { _MainTex ("Albedo", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _NormalMap ("Normal Map", 2D) = "bump" {} _Metallic ("Metallic", Range(0,1)) = 0.0 _Glossiness ("Smoothness", Range(0,1)) = 0.5 [Header(Dissolve)] _NoiseTex ("Dissolve Noise", 2D) = "white" {} _DissolveAmount ("Dissolve Amount", Range(0, 1)) = 0.0 _EdgeWidth ("Edge Width", Range(0.001, 0.1)) = 0.05 _EdgeColor ("Edge Color", Color) = (1, 0.5, 0, 1) _EdgeBrightness ("Edge Brightness", Range(1, 10)) = 3.0 }由于需要屏幕空间坐标,我们要修改Input结构体和顶点着色器:
struct Input { float2 uv_MainTex; float2 uv_NoiseTex; float4 screenPos; // 屏幕空间位置 }; // 顶点着色器主要计算屏幕空间位置 void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 计算齐次裁剪空间坐标 float4 clipPos = UnityObjectToClipPos(v.vertex); // 将其转换为屏幕空间坐标(范围在0到1之间) o.screenPos = ComputeScreenPos(clipPos); }关键的surf函数实现如下:
void surf (Input IN, inout SurfaceOutputStandard o) { // 1. 采样基础材质 fixed4 mainTex = tex2D(_MainTex, IN.uv_MainTex); o.Albedo = mainTex.rgb * _Color.rgb; o.Normal = UnpackNormal(tex2D(_NormalMap, IN.uv_MainTex)); o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = mainTex.a; // 2. 获取屏幕空间UV,并采样噪声图 float2 screenUV = IN.screenPos.xy / IN.screenPos.w; // 透视除法,得到标准的(0,1)屏幕UV // 可以叠加时间或缩放屏幕UV来制作动态噪声 // screenUV.xy += _Time.y * _NoiseScrollSpeed; fixed noise = tex2D(_NoiseTex, screenUV).r; // 3. 溶解核心逻辑 float dissolveClip = noise - _DissolveAmount; // 如果噪声值小于阈值,直接丢弃该像素(产生空洞) clip(dissolveClip); // 4. 计算发光边缘 // 检查噪声值是否在阈值附近的一个小范围内([_EdgeWidth, 0]) float edgeFactor = smoothstep(0, _EdgeWidth, dissolveClip); // edgeFactor在边缘处为0,在内部为1。我们取反,让边缘为1。 edgeFactor = 1.0 - edgeFactor; if (edgeFactor > 0) { // 叠加边缘发光颜色到自发光通道(Emission) o.Emission = _EdgeColor.rgb * _EdgeBrightness * edgeFactor; // 也可以选择性地覆盖Albedo颜色,让边缘更亮 // o.Albedo = lerp(o.Albedo, _EdgeColor.rgb, edgeFactor * 0.5); } }5.3 效果优化与进阶技巧
- 噪声图的选择:使用一张无缝平铺的、对比度适中的灰度噪声图(如Perlin噪声或Voronoi噪声)。避免使用重复图案明显的噪声,否则溶解图案会显得很假。
- 动态溶解:通过修改
screenUV(例如加上_Time.y * _ScrollSpeed)可以让溶解波纹动起来,模拟能量扩散或风吹散的效果。 - 顶点动画结合:在
vert函数中,可以根据dissolveClip的值(需要从顶点传递到片元)对即将被溶解的顶点进行随机偏移或缩放,模拟碎片飞散的效果,这需要更复杂的顶点-片元数据传递。 - 性能注意:
clip操作在某些移动GPU上开销较大。如果对象像素很多,需谨慎使用。此外,屏幕空间采样意味着每个像素都要进行一次纹理读取,对带宽有压力。 - 边缘平滑:使用
smoothstep函数可以让发光边缘有一个平滑的衰减,看起来更自然。调整_EdgeWidth可以控制发光带的粗细。
6. 表面着色器开发中的常见陷阱与调试方法
即使理解了原理,在实际编写表面着色器时,依然会遇到各种诡异的问题。这里我总结几个最常遇到的“坑”和对应的排查手段。
6.1 变量未初始化或语义不匹配
这是新手最容易出错的地方。在Input结构体中声明的变量,其名称必须与使用的语义或Unity的自动匹配规则对应。
uv前缀:float2 uv_MainTex;Unity会自动用_MainTex纹理的UV坐标填充它。如果你声明了uv_MyTexture,就必须有一个名为_MyTexture的纹理属性。worldPos:世界空间位置。worldNormal:世界空间法线(需要INTERNAL_DATA配合WorldNormalVector使用)。viewDir:视角方向(切线空间)。screenPos:如示例三所示,需要自己在顶点着色器中计算。
如果变量名写错或者类型不匹配(例如float3写成了float2),该变量值将是未定义的,可能导致整个着色器表现异常或全黑/全白。
排查方法:逐行检查Input结构体。可以暂时在surf函数中用o.Emission = float3(IN.uv_MainTex, 0);这样的方式,将疑似有问题的变量输出到自发光通道,在场景中观察其颜色表现,看是否符合预期(例如,UV坐标应该在0-1范围,显示为彩色渐变)。
6.2 光照模型与渲染路径不兼容
#pragma surface指令中指定的光照模型(如Standard,Lambert,BlinnPhong)必须与渲染路径兼容。例如,Standard光照模型依赖于Unity的PBR光照计算,在延迟渲染路径下工作良好。如果你写了一个自定义光照模型函数LightingMyModel,却在前向渲染中使用,可能会因为缺少某些光照变量而出错。
排查方法:首先确认项目设置的渲染路径(Edit -> Project Settings -> Graphics)。如果是移动端项目,通常使用前向渲染。在Shader的SubShader中,可以通过Tags { "LightMode"="ForwardBase" }等来指定Pass使用的光照模式。对于表面着色器,Unity会自动生成多个Pass来适应不同渲染路径和光照类型,但如果你使用了非常规的自定义光照,可能需要手动补充一些Pass或回退(Fallback)。
6.3 透明与混合状态设置错误
如果你的表面着色器需要半透明效果(o.Alpha < 1),必须在SubShader的Tags和Pass中正确设置混合状态。一个常见的错误是,写出了透明的计算逻辑,但渲染状态仍然是不透明的("RenderType"="Opaque"),导致透明排序错误或深度写入问题。
正确设置示例:
SubShader { Tags { "Queue"="Transparent" // 渲染队列设为透明 "RenderType"="Transparent" "IgnoreProjector"="True" // 可选,忽略投影器 } LOD 200 Blend SrcAlpha OneMinusSrcAlpha // 标准Alpha混合 ZWrite Off // 关闭深度写入,避免透明物体相互遮挡问题 CGPROGRAM #pragma surface surf Standard alpha:fade // 注意这里的 `alpha:fade` ... ENDCG }#pragma中的alpha:fade(或alpha:premul)告诉Unity生成支持透明混合的代码。ZWrite Off对于标准的半透明物体通常是必须的,除非你有特殊的深度处理需求。
6.4 法线变换错误
在涉及法线贴图或世界空间计算时,法线变换出错是另一个重灾区。切线空间法线贴图采样后得到的法线是位于切线空间的,你需要将其转换到世界空间才能用于光照计算。表面着色器通过UnpackNormal和o.Normal赋值,内部通常会帮你处理这个转换。但是,如果你在surf函数中直接进行复杂的光照计算,就需要手动使用WorldNormalVector(IN, o.Normal)来获取正确世界空间法线。
经验法则:除非你在写自定义光照模型,否则尽量让表面着色器框架帮你处理法线。只需在surf中正确赋值o.Normal(来自UnpackNormal的结果),剩下的交给#pragma surface指令指定的光照模型。
6.5 使用Frame Debugger和RenderDoc
当着色器效果完全不对,靠肉眼和逻辑又查不出问题时,就必须借助工具。Unity内置的Frame Debugger是神器。你可以通过Window -> Analysis -> Frame Debugger打开它。启用后,它能让你逐帧、逐个Draw Call地查看渲染状态,包括当前使用的Shader、传递的参数、渲染目标等。你可以清晰地看到你的表面着色器是在哪个Pass被调用,传入的属性值是否正确。
对于更底层的图形API问题(如纹理采样错误、混合状态异常),RenderDoc这样的独立图形调试器是终极武器。它可以捕获一帧完整的GPU调用序列,让你看到每一个像素是如何被计算出来的,精确定位到是哪个Shader指令出了问题。
编写表面着色器是一个不断试错和积累经验的过程。从模仿开始,理解每一行代码的作用,然后大胆修改、实验,并善用调试工具,你就能越来越熟练地驾驭这个强大的工具,为你的项目创造出独一无二的视觉表现。记住,最好的学习方式就是动手实现,遇到问题,解决问题。希望这几个示例和分享的经验,能成为你Shader学习路上的有力垫脚石。