Contents
本教程涵盖法线贴图。
这是关于超越二维表面(或表面层)的纹理技术系列教程中的第一篇。 请注意,这些教程旨在教您这些技术是如何工作的。 如果您想在 Unity 中实际使用其中之一,您应该使用内置着色器或表面着色器。
在本教程中,我们从法线贴图开始,这是一种非常成熟的技术,可以模拟小凸起和凹痕的照明——即使是在粗糙的多边形网格上。
基于光照感知形状
左边描绘的卡拉瓦乔的画是关于圣托马斯的怀疑,他不相信基督的复活,直到他将手指放在基督的身边。 使徒们皱起的眉头不仅象征着这种怀疑,而且通过共同的面部表情清楚地表达了这一点。 然而,为什么我们知道他们的额头实际上是皱着的,而不是涂上一些明暗的线条? 毕竟,这只是一幅平面画。 事实上,观众直觉地假设这些是皱眉而不是画眉——尽管这幅画本身允许两种解释。 教训是:在没有任何其他线索(阴影、遮挡、视差效果、立体效果等)的情况下,光亮通常可以令人信服地传达光滑表面上的凹凸。
法线贴图
法线贴图试图通过改变曲面法线向量来表达光滑曲面上(即具有插值法线的粗糙三角形网格)的凹凸。当用这些修改后的法向量计算光照时,观众通常会感觉到虚拟的凹凸-即使渲染了一个完美的平面三角形。这种错觉当然可以被打破(尤其是在轮廓上),但在许多情况下,它是非常令人信服的。
更具体地说,首先在纹理图像(即法线贴图)中生成表示虚拟凹凸的法线向量。然后片段着色器在纹理图像中查找这些向量,并基于它们计算照明。当然,问题是纹理图像中法向量的生成。有不同的方法可以选择,片段着色器必须适应用于生成法线贴图的特定编码。
Unity中的法线贴图
非常好的消息是,您可以使用Unity轻松地从灰度图像创建法线贴图:在您喜爱的绘制程序中创建灰度图像,并使用特定的灰度作为曲面的规则高度,浅灰色用于凹凸,深灰色用于凹痕。确保不同灰度之间的过渡平滑,例如通过模糊图像。导入图像到Unity时,请检查“Inspector”面板中的纹理类型Texture Type更改为法线贴图(Normal map),并勾选“Create from Grayscale”从灰度创建,单击“Apply”后,预览应显示带红色和绿色边缘的蓝色图像。除了生成法线贴图之外,还可以导入编码法线贴图(在这种情况下,不要忘记取消选中“Create from Grayscale”框)。
不太好的消息是片段着色器必须进行一些计算来解码法线。首先,纹理颜色存储在双分量纹理图像中,即只有一个alpha分量
A
A
A和一个颜色分量可用。颜色组件可以作为红色、绿色或蓝色组件访问—在所有情况下都返回相同的值。这里,我们使用绿色组件
G
G
G,因为Unity也使用它。两个组件
A
A
A和
G
G
G存储0到1之间的数字;但是,它们表示-1和1之间的坐标
n
x
n_{x}
nx和
n
y
n_{y}
ny,映射为:
n
x
=
2
A
−
1
n_{x}=2A-1
nx=2A−1,
n
y
=
2
G
−
1
n_{y}=2G-1
ny=2G−1
根据这两个分量,可以计算三维法向量
n
=
(
n
x
,
n
y
,
n
z
)
n=(n{x},n{y},n{z})
n=(nx,ny,nz)的第三分量
n
z
n_{z}
nz,因为对单位长度的归一化为:
n
z
=
±
1
−
n
x
2
−
n
y
2
n_{z} = \pm\sqrt{1-n_{x}^2 - n_{y}^2}
nz=±1−nx2−ny2
如果沿平滑法向量的轴选择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);
使用 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);
但是,本教程的其余部分(以及“Projection of Bumpy Surfaces”一节)将只介绍桌面平台。
Unity为曲面的每个点使用局部曲面坐标系来指定法线贴图中的法向量。该局部坐标系的z轴由世界空间中平滑的插值法向量N给出,x-y平面是与曲面相切的平面。具体来说,x轴由Unity提供给顶点的切线参数T指定。给定x和z轴,y轴可以通过顶点着色器中的叉积计算,例如B=N× T(字母B表示此向量的传统名称“副法线”。)
注意,法线向量N是由逆模型矩阵从对象空间转换为世界空间(因为它与曲面正交);而切线向量T指定曲面上点之间的方向,因此将使用模型矩阵进行转换。副法线向量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 = unity_ObjectToWorld;
float4x4 modelMatrixInverse = unity_WorldToObject;
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 = UnityObjectToClipPos(input.vertex);
return output;
}
在计算binormalWorld
时,因子input.tangent.w
是特定于Unity的,即Unity提供了切线向量和法线映射,因此我们必须进行乘法。
利用世界空间中的归一化方向T、B和N,我们可以很容易地形成一个矩阵,将法线映射的任意法向量N从局部曲面坐标系映射到世界空间,因为这样一个矩阵的列就是轴的向量;因此,3×3将n映射到世界空间的矩阵为:
M
s
u
r
f
a
c
e
→
w
o
r
l
d
=
[
T
x
B
x
N
x
T
y
B
y
N
y
T
z
B
z
N
z
]
M_{surface \to world}= \begin{bmatrix} T_x&B_x &N_x \\ T_y&B_y &N_y\\ T_z&B_z &N_z \end{bmatrix}
Msurface→world=⎣⎡TxTyTzBxByBzNxNyNz⎦⎤
在Cg中,由于矩阵是逐行构造的,所以构造转置矩阵实际上更容易
M s u r f a c e → w o r l d T = [ T x T y T z B x B y B z N x N y N z ] M_{surface \to world}^T= \begin{bmatrix} T_x&T_y &T_z \\ B_x&B_y &B_z\\ N_x&N_y &N_z \end{bmatrix} Msurface→worldT=⎣⎡TxBxNxTyByNyTzBzNz⎦⎤
构造是在片段着色器中完成的,例如:
float3x3 local2WorldTranspose = float3x3(input.tangentWorld,
input.binormalWorld, input.normalWorld);
我们想用local2WorldTranspose的转置(即未转置的原始矩阵)来变换n;因此,我们把左边的n与矩阵相乘。例如,使用以下行:
float3 normalDirection =
normalize(mul(localCoords, local2WorldTranspose));
利用世界空间中的新法向量,我们可以计算“Smooth Specular Highlights”部分中的光照。
完整的Shader代码
此着色器代码简单地集成了所有片段,并使用我们的标准像素光双通道方法。它还演示了使用CGINCLUDE … ENDCG块,由所有Subshader的所有Pass隐式共享。
Shader "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 = unity_ObjectToWorld;
float4x4 modelMatrixInverse = unity_WorldToObject;
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 = UnityObjectToClipPos(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
}
}
}
请注意,我们使用了平铺和偏移均匀凹凸贴图,如“纹理球体”一节中所述,因为此选项通常对凹凸贴图特别有用。
总结
本节中,我们看到了:
- 人类对形状的感知往往依赖于光线。
- 什么是法线映射。
- Unity如何编码法线贴图。
- 片段着色器如何解码Unity的法线贴图,并使用它们进行逐像素照明。