写在前面
写这篇的目的是为了总结我长期以来的混乱。虽然题目是“法线纹理的实现细节”,但其实我想讲的是如何在shader中编程正确使用法线进行光照计算。这里面最让人头大的就是各种矩阵运算和坐标系之间的转换,很容易因为坐标系错误而造成光照结果的错误。
我们将要讨论以下几个问题:
- 为什么法线纹理通常都是偏蓝色的?
- 在Unity里,法线纹理是需要把“Texture Type”设置成“Normal Map”才能正确显示,为什么?
- 把“Texture Type”设置成“Normal Map”后,有一个复选框是“Create from grayscale”,这个是做什么用的?
- 为什么在Unity Shaders里,对法线纹理的采样要使用UnpackNormal函数?直接采样不行么?
- 在Surface Shader中,使用模型自带的法线如何计算光照;
- 在Surface Shader中,使用法线纹理如何计算光照;
- 在Vertex & Fragment Shader中,使用模型自带的法线如何计算光照;
- 在Vertex & Fragment Shader中,使用法线纹理如何计算光照;
要先说明的是,这篇文章有点长,有点绕,希望大家能耐心看完。我们先从最简单的地方开始。
Surface Shader中的法线
在Surface Shader中,无论是使用模型自带的法线或者使用法线纹理都是一件比较方便的事。原因是Unity封装了很多矩阵操作。我们会在这一节回答关于法线纹理的那几个问题。
使用模型自带的法线
法线实际上就是在光照模型中使用的,也就是Surface Shader的Lighting<Name>函数。Unity最常见的两种光照函数参数列表如下:
half4 Lighting<Name> (SurfaceOutput s, half3 lightDir, half atten);
This is used in forward rendering path for light models that are not view direction dependent (e.g. diffuse).
用于不依赖视角的光照模型计算,例如漫反射。half4 Lighting<Name> (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten);
This is used in forward rendering path for light models that are view direction dependent.
用于依赖视角的光照模型的计算,例如高光反射。
使用法线纹理
如果使用法线纹理的话,就需要我们在进入光照函数之前修改SurfaceOutput中的的o.Normal。我们会在void surf (Input IN, inout SurfaceOutput o) 函数里完成这件事。一般,代码都长下面这个样子:
void surf (Input IN, inout SurfaceOutput o)
{
//Get the normal data out of the normal map textures
//using the UnpackNormal() function.
float3 normal = UnpackNormal(tex2D(_NormalTex, IN.uv_NormalTex));
//Apply the new normals to the lighting model
o.Normal = normal;
}
代码很简单。修改了法线后就可以按照上一节中的方法进行光照模型的计算。看起来和第一种方法好像一样,只是更改了o.Normal。但其实,Unity在背后做了很多。虽然我们使用了同样的Lighting<Name>函数,但其中normal、lightDir、viewDir所在的坐标系已经被Unity转换过了。这次,它们使用的坐标系是Tangent Space。如果你不知道它,没关系我们马上就会讲这个坐标系的细节。
但你有没有想过为什么要使用UnpackNormal这个函数。这就牵扯到我们的第一个问题:为什么法线纹理通常都是偏蓝色的?它里面到底是存储的什么呢?你会说,当然是法线啦!那么它的所在坐标系是什么呢?是World Space?Object Space?还是View Space?
实际上,我们通常见到的这种偏蓝色的法线纹理中,存储的是在Tangent Space中的顶点法线方向。那么,问题又来了,什么是Tangent Space(有时也叫object local coordinate system)?看到新名词不要怕,坐标系嘛,无非就是原点+三个坐标轴决定的一个相对空间嘛,我们只要搞清楚原点和三个坐标轴是什么就可以了。在Tangent Space中,坐标原点就是顶点的位置,其中z轴是该顶点本身的法线方向(N)。这样,另外两个坐标轴就是和该点相切的两条切线。这样的切线本来有无数条,但模型一般会给定该顶点的一个tangent,这个tangent方向一般是使用和纹理坐标方向相同的那条tangent(T)。而另一个坐标轴的方向(B)就可以通过normal和tangent的叉乘得到。上述过程可以如下图所示(来源:http://www.opengl-tutorial.org/intermediate-tutorials/tutorial-13-normal-mapping/):
我们用一幅图(来源:《OpenGL 4 Sharding Language Cookbook》)来说明这样的关系:
也就是说,通常我们所见的法线纹理还是基于原法线信息构建的坐标系来构建出来的。那种偏蓝色的法线纹理其实就是存储了在每个顶点各自的Tangent Space中,法线的扰动方向。也就是说,如果一个顶点的法线方向不变,那么在它的Tangent Space中,新的normal值就是z轴方向,也就是说值为(0, 0, 1)。但这并不是法线纹理中存储的最终值,因为一个向量每个维度的取值范围在(-1, 1),而纹理每个通道的值范围在(0, 1),因此我们需要做一个映射,即pixel = (normal + 1) / 2。这样,之前的法线值(0, 0, 1)实际上对应了法线纹理中RGB的值为(0.5, 0.5, 1),而这个颜色也就是法线纹理中那大片的蓝色。这些蓝色实际上说明顶点的大部分法线是和模型本身法线一样的,不需要改变。总结一下就是,法线纹理的RGB通道存储了在每个顶点各自的Tangent Space中的法线方向的映射值。
我们现在来解决第四个问题:为什么在Unity Shaders里,对法线纹理的采样要使用UnpackNormal函数。我们先来看,在用OpenGL这种基本的着色语言时,我们是怎么做的。它的代码一般长成下面这样:
// Lookup the normal from the normal map
vec4 normal = texture( NormalMapTex, TexCoord );
normal.xyz = normal.xyz * 2 - 1;
上述代码很简单,就是将法线纹理中的颜色值重新映射回正确的法线方向值。如果我们要在Unity中完成这样的功能,可以这样做:
// Set "Texture Type" to "Texture"
fixed4 normal = tex2D(_Bump, i.uv);
norm.xyz = norm.xyz * 2 - 1
注意,这里并没有把法线纹理的“Texture Type”设置成“Normal Map”。上述方法是可以得到正确的法线方向的。
Unity为了某些原因把上述过程进行了封装,也就是说上述代码在Unity里可以这么做:把法线纹理的“Texture Type”设置成“Normal Map”,在代码中使用UnpackNormal函数得到法线方向。这其中的原因,我猜想一方面是为了方便它对不同平台做优化和调整,一方面是为了解析不同格式的法线纹理。
我们现在可以来回答第一个问题:为什么需要把法线纹理的“Texture Type”设置成“Normal Map”才能正确显示。这样的设置可以让Unity根据不同平台对纹理进行压缩,通过