一开始我们在渲染中使用纹理是为了定义一个物体的颜色,但后来发现纹理可以用于存储任何表面属性。一种常见用法就是使用渐变纹理来控制漫反射光照的结果。在计算漫反射光照时,我们都是使用表面法线和光照方向的点积结果与材质的反射率相乘得到表面的漫反射光照。有时我们需要更加灵活的控制光照结果。
有一篇论文提出了一种基于冷到暖色调的着色技术,用来得到一种插画风格的渲染结果。使用这种技术,可以保证物体的轮廓线相比于之前使用的传统漫反射光照更加明显,而且能够提供多种色调变化,现在很多卡通风格的渲染中都使用了这种技术。
我们下面学习如何使用一张渐变纹理来控制漫反射光照,效果图如下:
使用这种方式我们可以自由的控制物体的漫反射光照。不同的渐变纹理有不同的特性。
1.声明一个纹理属性存储渐变纹理,然后指明光照模式、指令、属性对应的变量等:
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;
};
2.定义顶点着色器:
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;
}
3.片元着色器
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);
}
我们使用半兰伯特模型,通过对法线方向和光照方向的点积做一次0.5倍的缩放以及一个0.5大小的偏移来计算半兰伯特部分。这样得到的半兰伯特范围被映射到了[0,1]之间。之后使用半兰伯特构建一个纹理坐标,并用这个纹理坐标对渐变纹理_RampTex进行采样。由于_RampTex实际上就是一个一维纹理(他在纵轴方向上颜色不变),因此纹理坐标的u和v方向我们都使用了halfLambert。然后把从渐变纹理采样得到的颜色和材质颜色_Color相乘,得到最终的漫反射颜色。
需要注意我们要把渐变纹理的Wrap Mode设为Clamp模式,以防止对纹理进行采样时由于浮点数精度而造成的问题(高光区域有一点黑点的问题)。
遮罩允许我们保护某些区域,使他们免于某些修改。例如之前的实现中,我们都是把高光反射应用到模型表面的所有地方,即所有像素都使用同样大小的高光强度和高光指数。但有时我们希望模型表面某些区域的反光强烈一些,某些区域弱一些。为了得到更加细腻的效果,我们就可以使用一张遮罩纹理来控制光照。另一种常见的应用是在制作地形材质时需要混合多张图片,例如表现草地的纹理、表现石子的纹理、表现裸露土地的纹理等,使用遮罩纹理可以控制如何混合这些纹理。
使用遮罩纹理的流程一般是:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或某几个)通道的值(例如texel.r)来与某种表面属性进行相乘,这样该通道的值为0时可以保护表面不受该属性的影响。总而言之,使用遮罩纹理可以让美术人员更加精准(像素级别)地控制模型表面的各种性质。
本节我们使用一张高光遮罩纹理,逐像素地控制模型表面的高光反射强度。效果图:
1. 声明属性,添加更多的变量控制高光反射:
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
}
上面的属性中_SpecularMask即是我们需要使用的高光反射遮罩纹理,_SpecularScale则是用于控制遮罩影响度的系数。
2.指明光照模式、指令、属性相匹配的变量:
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;
我们为主纹理_MainTex、法线纹理_BumpMap、遮罩纹理_SpecularMask定义了它们共同使用的纹理属性变量_MainTex_ST。这意味着在材质面板中修改主纹理的平铺系数和偏移系数会同时影响3个纹理的采样,使用这种方式可以让我们节省需要存储的纹理坐标数目,如果我们为每一个纹理都使用一个单独的属性变量TextureName_ST,那么随着使用的纹理数目的增加,我们会迅速占满顶点着色器中可以使用的插值寄存器。而很多时候,我们不需要对纹理进行平铺和位移操作,或者很多纹理可以使用同一种平铺和位移操作,此时我们就可以对这些纹理使用同一个变换后的纹理坐标(uv坐标)进行采样。
3.定义顶点着色器输入输出结构体,顶点着色器中我们对光照方向和视角方向进行了空间坐标的变换,把他们从模型空间变换到了切线空间,以便在片元着色器中和法线进行光照运算:
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;
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
return o;
}
4.使用遮罩纹理的地方是片元着色器,我们使用它来控制模型表面的高光反射强度:
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);
// Get the mask value
fixed specularMask = tex2D(_SpecularMask, i.uv).r * _SpecularScale;
// Compute specular term with the specular mask
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
在计算高光反射时,我们首先对遮罩纹理_SpecularMask进行采样。由于当前我们使用的遮罩纹理中每个纹素的rgb分量其实都是一样的,表明了该点对应的高光反射强度,在这里我们选择使用r分量来计算掩码值。然后我们用得到的掩码值和_SpecularScale相乘,一起来控制高光反射的强度。
需要说明的是,我们使用的这张遮罩纹理其实有很多空间被浪费了——它的rgb分量存储的都是同一个值,在实际有效的制作中,我们往往会充分利用遮罩纹理中的每一个颜色通道来存储不同的表面属性,下面会介绍这些内容。
在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使他们免于某些修改,而是可以存储任何我们希望逐像素控制的表面属性效果,通常我们会充分利用一张纹理的RGBA四个通道,用于存储不同的属性。例如我们可以把高光反射的强度存储在R通道,把边缘光照的强度存储在G通道,把高光反射的指数部分存储在B通道,最好把自发光强度存储在A通道。
在游戏《DOTA2》的开发中,开发人员为每个模型使用了4张纹理:一张用于定义模型颜色,一张用于定义表面法线,另外两张都是遮罩纹理。这样,两张遮罩纹理提供了共8种额外的表面属性,这使得游戏中的人物材质自由度很强,可以支持很多高级的模型属性。