本教程涵盖了法线贴图(法线映射)。
这是一系列关于超越二维曲面(或叫曲面的层级)的纹理技术教程的第一章。在本章中,我们从法线贴图开始,它是一项很好的已确定的技术来伪造照明的凹凸和凹痕–即使是在粗糙的多边形网格上。本章的代码是基于章节“光滑镜面高光”和章节“纹理球体”。
基于光照的形状感知
上面描绘的卡拉瓦乔的画作描述了圣托马斯的怀疑,直到他把手指伸进了耶稣基督的身体里他才相信基督的复活。这个皱着眉头的使徒不仅象征着怀疑还很明显得传达着一个共同的面部表情。但是,我们怎么知道他们的额头实际上是皱着的而不是用一些光和暗纹画上去的?毕竟,这是一幅平面画作。实际上,观察者直观地假设他们是皱眉的而不是画上去的–即使这幅画本身也有两种解释。教训是:光滑表面的凹凸通常可以通过单独光照而无需其它线索(阴影、遮挡、视差效果、立体声等)来令人信服地传达。
法线贴图
法线贴图通过根据一些虚拟的凹凸点改变表面法向量来试图在光滑的表面上传达凹凸(也就是插值法线的粗三角网格)。当光照用这些修改的法向量来计算时,观察者通常会感觉到这些虚拟的凹凸–即使一个完美的平坦三角形被渲染。这种幻觉肯定会崩溃(尤其是在轮廓处),但在许多情况下,它是非常令人信服的。
更具体地说,表示虚拟凹凸点的法向量首先是被编码到一张纹理贴图(也就是法线贴图)中的。片元着色器随后会在纹理贴图中查询这些向量,并且基于它们计算光照。就是这样了。当然,问题是纹理贴图中法向量的编码。
Unity中的法线贴图
有一个好消息是你可以从Unity的灰度图中创建法线贴图:在你喜欢的画图程序中创建灰度图,并且对于表面的规则高度使用指定的灰度,淡灰色表示凸起,深灰色表示凹部。要保证不同灰度之间的转换是平滑的,比如模糊一张图像。当你用Assets > Import New Asset把贴图导入,在Inspector Window中修改贴图类型为Normal map,并且勾选了Create from Grayscale。在点击Apply后,预览应该显示一个略带红色和绿色边缘的蓝色图像。要不然生成一张法线贴图,上面被编码的法线贴图可以被导入进来。(在这种情况下,不要忘记取消勾选Create from Grayscale)。
一个不怎么好的消息是片元着色器必须做一些计算来解码法向量。首先,纹理颜色被存储在一个两分量的纹理贴图中,也就是这里只有一个alpha分量A和一个颜色分量可以用。颜色组件可以作为红色、绿色和蓝色组件来访问–在所有情况下会返回相同值。这里,我们使用绿色分量G,因为Unity也会使用它。这两个分量,G和A作为数字存储在0到1之间;但是,它们表示介于-1和1之间的坐标和。映射关系如下:
从这两个分量开始,三维法向量的第三个分量可以被计算,因为它们被归一化到单位长度。
Only the “+” solution is necessary if we choose the {\displaystyle z} z axis along the axis of the smooth normal vector (interpolated from the normal vectors that were set in the vertex shader) since we aren’t able to render surfaces with an inwards pointing normal vector anyways. The code snippet from the fragment shader could look like this:
“+”是必须的如果我们延光滑法向量选择z轴(从顶点着色器设置的法向量进行插值),因为我们不能用指向内部的法向量来渲染表面。片段着色器的代码片段可能如下所示:
float4 encodedNormal = tex2D(_BumpMap,_BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
float3 localCoords = float3(2.0 * encodedNormal.a - 1.0,2.0 * encodedNormal.g - 1.0, 0.0);
localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
// approximation without sqrt:localCoords.z = 1.0 - 0.5 * dot(localCoords, localCoords);
The decoding for devices that use OpenGL ES is actually simpler since Unity doesn’t use a two-component texture in this case. Thus, for mobile platforms the decoding becomes:
使用OpenGL ES的设备解码实际上更简单,因为Unity并不使用两个分量的纹理贴图。于是,对于移动平台的解码代码如下:
float4 encodedNormal = tex2D(_BumpMap, _BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
float3 localCoords = 2.0 * encodedNormal.rgb - float3(1.0, 1.0, 1.0);
译者注:一个法线 (-1, 0, 1) 会被作为 RGB 编码为 (0, 0.5, 1);在片段着色器中, 我们可以把法线解码, 通过执行跟之前编码时相反的操作, 把颜色值展开为范围 -1.0 到 1.0 之间:
但是,教程的剩下部分仅限于桌面平台。
对于曲面的每个点来说,Unity使用本地表面坐标系统来指定法线贴图中的法向量。本地坐标系统的z轴由世界空间中光滑的、被插值的法向量N指定,并且如上图所示x-y平面是曲面的切平面。特别地,x轴是由Unity提供给顶点(参考章节“着色器的调试”中顶点输入参数的讨论)的切线参数T来指定的。给定x和z轴,y轴可在顶点着色器中通过叉乘计算而来,比如B = N × T。(字母B是指传统的名字“副法线”)。
注意法向量N是用逆模型矩阵的转置从对象空间变换到世界空间(因为它垂直于一个曲面;参考章节“应用矩阵变换”),同时切线向量T指定了曲面上各点的之间的方向并且用模型矩阵来变换。副法线向量B代表了第三种类型的向量,它的变换就不一样了。(如果你确实想知道:对应于“B×”的反对称矩阵会像三元二次型被转换。)于是,最好的选择就是首先把N和T变换到世界空间中,然后在世界空间中使用变换向量的叉乘来计算B。
这些计算通过顶点着色器执行,例如这样:
struct vertexInput {
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};
struct vertexOutput {
float4 pos : SV_POSITION;
float4 posWorld : TEXCOORD0;
// position of the vertex (and fragment) in world space
float4 tex : TEXCOORD1;
float3 tangentWorld : TEXCOORD2;
float3 normalWorld : TEXCOORD3;
float3 binormalWorld : TEXCOORD4;
};
vertexOutput vert(vertexInput input)
{
vertexOutput output;
float4x4 modelMatrix = _Object2World;
float4x4 modelMatrixInverse = _World2Object;
output.tangentWorld = normalize(
mul(modelMatrix, float4(input.tangent.xyz, 0.0)).xyz);
output.normalWorld = normalize(
mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
output.binormalWorld = normalize(
cross(output.normalWorld, output.tangentWorld)
* input.tangent.w); // tangent.w is specific to Unity
output.posWorld = mul(modelMatrix, input.vertex);
output.tex = input.texcoord;
output.pos = mul(UNITY_MATRIX_MVP, input.vertex);
return output;
}
在binormalWorld
的计算中因子input.tangent.w
被指定给Unity,也就是说,Unity提供了切线向量和法线贴图,这样我们就可以做这个乘法。利用世界空间中的归一化方向T,B和N,我们可以轻易地形成一个矩阵,它将法线贴图的任意归一化向量n从局部曲面坐标系映射到世界空间,因为矩阵的列向量正好是轴的向量;于是,n到世界空间映射的3x3矩阵就像这个样子:
在Cg中,转置矩阵的构造实际上比较容易,因为矩阵是按排构造的。
这个构造在片元着色器中完成,比如:
float3x3 local2WorldTranspose = float3x3(input.tangentWorld,
input.binormalWorld, input.normalWorld);
We want to transform n with the transpose of local2WorldTranspose (i.e. the not transposed original matrix); therefore, we multiply n from the left with the matrix. For example, with this line:
我们想要用local2WorldTranspose
的转置来变换n(也就是没有被转置的初始矩阵);因此,我们把n左乘这个矩阵。举例来说,就是下面的代码:
float3 normalDirection = normalize(mul(localCoords, local2WorldTranspose));
完整的着色器代码
这个着色器代码只是简单得整合了所有的小代码段,并且对于像素光照使用了标准双通道方法。它也展示了CGINCLUDE ... ENDCG
块的使用,它被所有子着色器的所有通道共享。
hader "Cg normal mapping" {
Properties {
_BumpMap ("Normal Map", 2D) = "bump" {}
_Color ("Diffuse Material Color", Color) = (1,1,1,1)
_SpecColor ("Specular Material Color", Color) = (1,1,1,1)
_Shininess ("Shininess", Float) = 10
}
CGINCLUDE // common code for all passes of all subshaders
#include "UnityCG.cginc"
uniform float4 _LightColor0;
// color of light source (from "Lighting.cginc")
// User-specified properties
uniform sampler2D _BumpMap;
uniform float4 _BumpMap_ST;
uniform float4 _Color;
uniform float4 _SpecColor;
uniform float _Shininess;
struct vertexInput {
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};
struct vertexOutput {
float4 pos : SV_POSITION;
float4 posWorld : TEXCOORD0;
// position of the vertex (and fragment) in world space
float4 tex : TEXCOORD1;
float3 tangentWorld : TEXCOORD2;
float3 normalWorld : TEXCOORD3;
float3 binormalWorld : TEXCOORD4;
};
vertexOutput vert(vertexInput input)
{
vertexOutput output;
float4x4 modelMatrix = _Object2World;
float4x4 modelMatrixInverse = _World2Object;
output.tangentWorld = normalize(
mul(modelMatrix, float4(input.tangent.xyz, 0.0)).xyz);
output.normalWorld = normalize(
mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
output.binormalWorld = normalize(
cross(output.normalWorld, output.tangentWorld)
* input.tangent.w); // tangent.w is specific to Unity
output.posWorld = mul(modelMatrix, input.vertex);
output.tex = input.texcoord;
output.pos = mul(UNITY_MATRIX_MVP, input.vertex);
return output;
}
// fragment shader with ambient lighting
float4 fragWithAmbient(vertexOutput input) : COLOR
{
// in principle we have to normalize tangentWorld,
// binormalWorld, and normalWorld again; however, the
// potential problems are small since we use this
// matrix only to compute "normalDirection",
// which we normalize anyways
float4 encodedNormal = tex2D(_BumpMap,
_BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
float3 localCoords = float3(2.0 * encodedNormal.a - 1.0,
2.0 * encodedNormal.g - 1.0, 0.0);
localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
// approximation without sqrt: localCoords.z =
// 1.0 - 0.5 * dot(localCoords, localCoords);
float3x3 local2WorldTranspose = float3x3(
input.tangentWorld,
input.binormalWorld,
input.normalWorld);
float3 normalDirection =
normalize(mul(localCoords, local2WorldTranspose));
float3 viewDirection = normalize(
_WorldSpaceCameraPos - input.posWorld.xyz);
float3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(_WorldSpaceLightPos0.xyz);
}
else // point or spot light
{
float3 vertexToLightSource =
_WorldSpaceLightPos0.xyz - input.posWorld.xyz;
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
float3 ambientLighting =
UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb;
float3 diffuseReflection =
attenuation * _LightColor0.rgb * _Color.rgb
* max(0.0, dot(normalDirection, lightDirection));
float3 specularReflection;
if (dot(normalDirection, lightDirection) < 0.0)
// light source on the wrong side?
{
specularReflection = float3(0.0, 0.0, 0.0);
// no specular reflection
}
else // light source on the right side
{
specularReflection = attenuation * _LightColor0.rgb
* _SpecColor.rgb * pow(max(0.0, dot(
reflect(-lightDirection, normalDirection),
viewDirection)), _Shininess);
}
return float4(ambientLighting + diffuseReflection
+ specularReflection, 1.0);
}
// fragment shader for pass 2 without ambient lighting
float4 fragWithoutAmbient(vertexOutput input) : COLOR
{
// in principle we have to normalize tangentWorld,
// binormalWorld, and normalWorld again; however, the
// potential problems are small since we use this
// matrix only to compute "normalDirection",
// which we normalize anyways
float4 encodedNormal = tex2D(_BumpMap,
_BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
float3 localCoords = float3(2.0 * encodedNormal.a - 1.0,
2.0 * encodedNormal.g - 1.0, 0.0);
localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
// approximation without sqrt: localCoords.z =
// 1.0 - 0.5 * dot(localCoords, localCoords);
float3x3 local2WorldTranspose = float3x3(
input.tangentWorld,
input.binormalWorld,
input.normalWorld);
float3 normalDirection =
normalize(mul(localCoords, local2WorldTranspose));
float3 viewDirection = normalize(
_WorldSpaceCameraPos - input.posWorld.xyz);
float3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(_WorldSpaceLightPos0.xyz);
}
else // point or spot light
{
float3 vertexToLightSource =
_WorldSpaceLightPos0.xyz - input.posWorld.xyz;
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
float3 diffuseReflection =
attenuation * _LightColor0.rgb * _Color.rgb
* max(0.0, dot(normalDirection, lightDirection));
float3 specularReflection;
if (dot(normalDirection, lightDirection) < 0.0)
// light source on the wrong side?
{
specularReflection = float3(0.0, 0.0, 0.0);
// no specular reflection
}
else // light source on the right side
{
specularReflection = attenuation * _LightColor0.rgb
* _SpecColor.rgb * pow(max(0.0, dot(
reflect(-lightDirection, normalDirection),
viewDirection)), _Shininess);
}
return float4(diffuseReflection + specularReflection, 1.0);
}
ENDCG
SubShader {
Pass {
Tags { "LightMode" = "ForwardBase" }
// pass for ambient light and first light source
CGPROGRAM
#pragma vertex vert
#pragma fragment fragWithAmbient
// the functions are defined in the CGINCLUDE part
ENDCG
}
Pass {
Tags { "LightMode" = "ForwardAdd" }
// pass for additional light sources
Blend One One // additive blending
CGPROGRAM
#pragma vertex vert
#pragma fragment fragWithoutAmbient
// the functions are defined in the CGINCLUDE part
ENDCG
}
}
}
注意我们使用了平铺和偏移uniform _BumpMap_ST,它在章节“纹理球体”中解释过,因为这个选项对凹凸贴图特别有用。
总结
恭喜!你完成了本章的学习!我们学到了:
- 人类对于图形的感知通常取决于光照。
- 什么是法线贴图。
- Unity如何编码法线贴图。
- 片元着色器如何解码Unity的法线贴图并且把它们使用到逐像素光照中去的。