本文最后更新于:2025年4月17日 下午
渐变纹理
纹理其实可以用于存储任何表面属性。一种常见的用法就是使用渐变纹理来控制漫反射光照的结果。下面我们将学习如何使用一张渐变纹理来控制漫反射光照。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 Shader "Custom/Chapter7/Chapter7-RampTexture" { Properties { _Color ("Color Tint", Color) = (1,1,1,1) _RampTex ("Ramp Tex", 2D) = "white" {} _Specular ("Specular", Color) = (1,1,1,1) _Gloss ("Gloss", Range(8.0, 256)) = 20 } SubShader { Pass { Tags { "LightMode" = "ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "Lighting.cginc" fixed4 _Color; sampler2D _RampTex; float4 _RampTex_ST; fixed4 _Specular; float _Gloss; struct a2v { float4 vertex : POSITION; float3 normal : NORMAL; float4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float3 worldPos : TEXCOORD1; float2 uv : TEXCOORD2; }; v2f vert(a2v 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.texcoord, _RampTex); return o; } fixed4 frag(v2f i) : SV_Target { fixed3 worldNormal = normalize(i.worldNormal); fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos)); fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz; //use the texture to sample the diffuse color fixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5; fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert, halfLambert)).rgb * _Color.rgb; fixed3 diffuse = _LightColor0.rgb * diffuseColor; fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos)); fixed3 halfDir = normalize(worldLightDir + viewDir); fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss); return fixed4(ambient + diffuse + specular, 1.0); } ENDCG } } FallBack "Specular" }
在上面的代码中,我们使用了半兰伯特模型,通过对法线方向和光照方向的点积做一次 0.5 倍的缩放以及一个 0.5 大小的偏移来计算半兰伯特部分 halfLambert。这样,我们得到的 halfLambert 的范围被映射到了[0, 1]之间。之后,我们使用 halfLambert 来构建一个纹理坐标,并用这个纹理坐标对渐变纹理 _RampTex 进行采样。由于 _RampTex 实际上就是一个一维纹理(它在纵轴方向上颜色不变),因此纹理坐标的 u 和 v 方向我们都使用了 halfLambert。然后,把从渐变纹理采样得到的颜色和材质颜色 _Color 相乘,得到最终的漫反射颜色。剩下的代码就是计算高光反射和环境光,并把它们的结果进行相加。
效果如下图所示:
需要注意的是,我们需要把渐变纹理的 Wrap Mode 设为 Clamp 模式,以防止对纹理进行采样时由于浮点数精度而造成的问题。下图给出了 Wrap Mode 分别为 Repeat 和 Clamp 模式的效果对比。
可以看出,使用 Repeat 模式在高光区域有一些黑点。这是由于浮点精度造成的,当我们使用 fixed2(halfLambert, halfLambert) 对渐变纹理进行采样时,虽然理论上 halfLambert 对值在[0, 1]之间,但可能会有 1.00001 这样的值出现。如果我们使用的是 Repeat 模式,此时就会舍弃整数部分,只保留小数部分,得到的值就是 0.00001,对应了渐变图中最左边的值,即黑色。因此,就会出现图中这样在高光区域反而有黑点的情况。我们只需要把渐变纹理的 Wrap Mode 设为 Clamp 模式就可以解决这种问题。
遮罩纹理
遮罩纹理 允许我们可以保护某些区域,使他们免于某些修改。例如,在之前的实现中,我们都是把高光反射应用到模型表面的所有地方,即所有的像素都使用同样大小的高光强度和高光指数。但有时,我们希望模型表面某些区域的反光强烈一些,而某些区域弱一些。为了得到更加细腻的效果,我们就可以使用一张遮罩纹理来控制光照。另一种常见的应用是在制作地形材质时需要混合多张图片,例如表现草地的纹理、表现石子的纹理、表现裸露土地的纹理等,使用遮罩纹理可以控制如何混合这些纹理。
使用遮罩纹理的流程一般是:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或某几个)通道的值(例如 texel.r)来与某种表面属性进行相乘,这样,当该通道的值为 0 时,可以保护表面不受该属性的影响。总而言之,使用遮罩纹理可以让美术人员更加精准(像素级别)地控制模型表面的各种性质。
实践
下面我们将学习如何使用一张高光遮罩纹理,逐像素地控制模型表面的高光反射强度。下图显示了只包含漫反射、未使用遮罩的高光反射和使用遮罩的高光反射的对比效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 Shader "Custom/Chapter7/Chapter7-MaskTexture" { Properties { _Color ("Color Tint", Color) = (1,1,1,1) _MainTex ("Main Tex", 2D) = "white" {} _BumpMap ("Normal Map", 2D) = "bump" {} _BumpScale ("Bump Scale", Float) = 1.0 _SpecularMask ("Specular Mask", 2D) = "white" {} _SpecularScale ("Specular Scale", Float) = 1.0 _Specular ("Specular", Color) = (1,1,1,1) _Gloss ("Gloss", Range(8.0, 256)) = 20 } SubShader { Pass { Tags { "LightMode" = "ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "Lighting.cginc" fixed4 _Color; sampler2D _MainTex; float4 _MainTex_ST; sampler2D _BumpMap; float _BumpScale; sampler2D _SpecularMask; float _SpecularScale; fixed4 _Specular; float _Gloss; struct a2v { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 lightDir : TEXCOORD1; float3 viewDir : TEXCOORD2; }; v2f vert(a2v v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw; TANGENT_SPACE_ROTATION; //Transform the light direction from object space to tangent space o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz; //Transform the view direction from object space to tangent space o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz; return o; } fixed4 frag(v2f i) : SV_Target { fixed3 tangentLightDir = normalize(i.lightDir); fixed3 tangentViewDir = normalize(i.viewDir); fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv)); tangentNormal.xy *= _BumpScale; tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb; fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo; fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir)); fixed3 halfDir = normalize(tangentLightDir + tangentViewDir); fixed specularMask = tex2D(_SpecularMask, i.uv).r * _SpecularScale; fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask; return fixed4(ambient + diffuse + specular, 1.0); } ENDCG } } FallBack "Specular" }
我们为主纹理 _MainTex、法线纹理 _BumpMap 和遮罩纹理 _SpecularMask 定义了它们共同使用的纹理属性变量 _MainTex_ST。这意味着,在材质面板中修改主纹理的平铺系数会同时影响 3 个纹理的采样。使用这种方式可以让我们节省需要存储的纹理坐标数目,如果我们为每一个纹理都使用一个单独的属性变量 TextureName_ST,那么随着使用的纹理数目的增加,我们会迅速占满顶点着色器中可以使用的插值寄存器。而很多时候,我们不需要对纹理进行平铺和位移操作,或者很多纹理可以使用同一种平铺和位移操作,此时我们就可以对这些纹理使用同一个变换后的纹理坐标进行采样。
在计算高光反射时,我们首先对遮罩纹理 _SpecularMask 进行采样。由于我们使用的遮罩纹理中每个纹素的 rgb 分量其实都是一样的,表明了该点对应的高光反射强度,在这里我们使用 r 分量来计算掩码值。然后,我们用得到的掩码值和 _SpecularScale相乘,一起来控制高光反射的强度。
需要说明的是,我们使用的这张遮罩纹理其实有很多空间被浪费了——它的 rgb 分量存储的都是同一个值。在实际的游戏制作中,我们往往会充分利用遮罩纹理中的每一个颜色通道来存储不同的表面属性。
其他遮罩纹理
在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使它们免于某些修改,而是可以存储任何我们希望逐像素控制的表面属性。通常,我们会充分利用一张纹理的 RGBA 四个通道,用于存储不同的属性。例如,我们可以把高光反射的强度存储在 R 通道,把边缘光照的强度存储在 G 通道,把高光反射的指数部分存储在 B 通道,最后把自发光强度存储在 A 通道。
参考
《Unity Shader入门精要》