好久不写shader了,果然忘的很快,还是需要记录一下,这样才不会经常造车轮。
法线的真人真事
shader里面,经常会用到法线实现一些效果,法线到世界坐标系的转换也是经常的,坑也不少:
mul(IT_M, norm) => mul(norm, I_M) => {dot(norm, I_M.col0), dot(norm, I_M.col1), dot(norm, I_M.col2)}



normalize(mul(normal, (float3x3)unity_WorldToObject));
注意到输出的vector4,在数据格式上,转置与不转置实际上并没有区别,(shader中,转置实际上只对矩阵有效果)。因此,mul(V, M) 就相当于 mul( tranpose(M), V )更进一步的,对于正交矩阵,其转置矩阵等于逆矩阵,那么通过这个方法,还可以得到他的逆矩阵,这个特性我们后面还会看到。
使用法线纹理
但其实,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/):

也就是说,通常我们所见的法线纹理还是基于原法线信息构建的坐标系来构建出来的。那种偏蓝色的法线纹理其实就是存储了在每个顶点各自的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中的法线方向的映射值。
第一次接触Tangent Space会让人觉得比较难理解,而模型原始的法线其实是定义在Object Space中的,那为什么法线纹理就不能直接存储在Object Space中的新法线信息呢?实际上,这对应了两种法线纹理——Object-Space Normal Map和Tangent-Space Normal Map。它们分别对应了下面两种样子的纹理(来源:http://www.surlybird.com/tutorials/TangentSpace/):

从视觉上来说,Object-Space Normal Map五颜六色,原因是它是基于Object Space存储的,方向各异。如果我们把模型本身自带的法线映射到一张纹理上,就是一张Object-Space Normal Map;而Tangent-Space Normal Map如我们前面所说,是偏蓝色的。原因是它基于每个顶点的Tangent Space,很多顶点法线只是在原法线的基础上略微有些偏移而已。
总体来说,Object-Space Normal Map更符合我们人类的直观认识,而且法线纹理本身也很直观,容易调整,因为不同的颜色就代表了不同的颜色。那么问题来了,为什么要使用这么“蹩脚”的Tangent Space来存储法线纹理里(起码大部分都是)?而且Unity里是仅支持Tangent-Space Normal Map的法线纹理的。
实际上,法线本身存储在哪个坐标系中都是可以的,例如存储在World Space、或者Object Space、或者Tangent Space中。但问题是,我们并不是单纯的想要得到法线,后续的光照计算才是我们的目的。而选择哪个坐标系意味着我们需要把其他信息(例如viewDir和lightDir)转换到相应的坐标系中。而网上关于这两种法线纹理(World Space使用的比较少,暂时忽略)的选择各种各样,有些观点我也觉得无法理解。有的人讲应该只用Tangent Space,有的讲Object Space更快更好,有的人认为Object Space不可以用于可变形的物体,而另一些人说可以(我也认为使用哪种坐标系都可以在游戏里得到正确的效果,只要通过合适的坐标系转换)。下面是总结的我比较认同的优缺点。
- 使用Object-Space的优点
- 实现简单,更加直观。我们甚至都不需要模型原始的normal和tangent等信息,也就是说计算更少。生成它也非常简单,而如要要生成Tangent-Space Normal Map的话,由于它的tangent是和UV方向相同,因此想要效果比较好的Normal Map的话要求UV Map也是连续的。
- 在UV缝合处和尖锐的边角部分,可见的突变(缝隙)较少,可以提供平滑的边界。这是因为Object-Space Normal Map存储的是同一坐标系下的法线信息,因此在边界处通过插值得到的法线可以平缓变换。而Tangent-Space Normal Map中的法线信息则依靠UV的方向和三角化结果,可能在边缘处或尖锐的部分会造成更多可见的缝合迹象。
- 使用Tangent-Space的优点
- 自由度很高。Object-Space Normal Map记录的是绝对法线信息,仅可用于创建它时的那个模型,而应用到其他模型上效果就完全错误了。而Tangent-Space Normal Map记录的是相对法线信息,这意味着,即便把该纹理应用到一个完全不同的网格上,也可以得到一个合理的结果。
- 可进行UV动画。比如,我们可以移动一个纹理的UV坐标来实现一个凹凸移动的效果,但使用Object-Space Normal Map会得到完全错误的结果。原因同上。这种UV动画在水或者火山熔岩这种类型的物体会会用到。
- 可以重用Normal Map。比如,一个砖块,我们可以仅使用一张Normal Map就可以用到所有的六个面上。
- 可压缩。由于Tangent-Space Normal Map中法线的Z方向总是正方向的,因此我们可以仅存储XY方向,而推导得到Z方向。而Object-Space Normal Map由于每个方向都是完全可能的,因此必须存储三个方向的值,不可压缩。
Tangent Space的前两个优点足以让很多人放弃Object Space而选择它了。下面的链接里有更深入地讨论(不保证观点的正确性):
- http://www.valvetime.net/threads/tangent-vs-object-vs-world-normal-maps.81018/
- http://www.surlybird.com/tutorials/TangentSpace/(感觉里面的说法不是很准确)
- http://docs.cryengine.com/plugins/servlet/mobile#TangentSpaceNormalMapping-DrawbacksofTangentSpaceLighting
- http://docs.cryengine.com/display/SDKDOC4/Tangent+Space+Normal+Mapping
- http://gamedev.stackexchange.com/questions/31499/what-are-the-advantages-of-tangent-space-normals-over-object-space-normals
- http://www.3dkingdoms.com/tutorial.htm
- https://www.opengl.org/discussion_boards/showthread.php/173724-Tangent-or-object-space(讨论很激烈,建议好好看看)
看了这么多,总结一下为什么Tangent-Space会这么流行。“It never fails!”从上面的优点列表可以看出,Tangent-Space在很多情况下都优于Object-Space,而且可以节省很多美术人员的工作。
当然,也不是说Object-Space Normal Map完全没有用处,一些人就喜欢用Object-Space Normal Map也是可以的~
说了半天,不管使用哪个坐标系,都面临着一个选择,就是最后光照计算使用的坐标系究竟是哪个。对于Tangent-Space Normal Map,我们一般就是在Tangent Space里计算的,也就是说,我们需要把viewDir、lightDir在Vertex Shader中转换到Tangent Space中,然后在Fragment Shader对法线纹理采样后,直接进行光照计算。而对于Object-Space Normal Map,我们可以有多种选择,即可以选择最终在Object-Space下,也可以在World Space或者View Space下。而这些计算,我们会在下一节里面讲到具体实现的方法。
如果需要使用法线纹理来采样法线信息,最大的问题同样是坐标系的转换。我们之前说过,一般的做法是把所有信息转换到Tangent Space中,我们现在就来看如何转换。
如果要自己编码实现法线映射的目的,最主要的就是要考虑最终将光照计算转换到哪个坐标系中:Model Space,World Space,View Space还是Tangent Space。通常(注意是通常!),如果使用模型自带的法线时,我们一般把所有信息转换到World Space中。这样最大的好处就是一切都很直观,符合我们的一般认识。而如果是使用法线纹理,一般是转换到Tangent Space中。这样做的原因有一定性能的考虑,因为真正的法线信息只有到了Fragment Shader阶段才会从纹理中采样得到,如果我们不使用Tangent Space,就需要逐像素处理每个法线信息,而相反,如果使用Tangent Space,我们就只需要在Vertex Shader中对光照方向等信息进行逐顶点处理。而逐顶点总是比逐像素的处理效率更优。
需要转换的信息主要包含了下面几种:
- 法线(如果使用自带法线的话)
- 光源方向。如果是使用平行光(例如ForwardBase中使用的光照),那么不需要把光源方向作为顶点信息从Vertex Shader中传递给Fragment Shader;如果是使用点光源这类光源,我们需要逐顶点处理光源方向,把以每个顶点为出发点的光源信息存储在v2f中,传递给Fragment Shader。
- 视角方向。如果我们的Shader需要计算和视角方向有关的光照计算(如高光)时,就需要把视角方向在Vertex Shader中处理后传递给Fragment Shader。
// Declares 3x3 matrix 'rotation', filled with tangent space basis
#define TANGENT_SPACE_ROTATION \
float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \
float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )
TANGENT_SPACE_ROTATION;
o.wNormal = UnityObjectToWorldNormal(v.normal);
o.wViewDir = mul(rotation, ObjSpaceViewDir(v.vertex));
o.wLightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
法线格式
这样的设置可以让Unity根据不同平台对纹理进行压缩,通过UnpackNormal函数对法线纹理进行正确的采样,即“将把颜色通道变成一个适合于实时法向映射的格式”。我们首先来看UnpackNormal函数的内部实现(在UnityCG.cginc里):
inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
{
fixed3 normal;
normal.xy = packednormal.wy * 2 - 1;
#if defined(SHADER_API_FLASH)
// Flash does not have efficient saturate(), and dot() seems to require an extra register.
normal.z = sqrt(1 - normal.x*normal.x - normal.y * normal.y);
#else
normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
#endif
return normal;
}
inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if (defined(SHADER_API_GLES) || defined(SHADER_API_GLES3)) && defined(SHADER_API_MOBILE)
return packednormal.xyz * 2 - 1;
#else
return UnpackNormalDXT5nm(packednormal);
#endif
}
从代码我们可以推导出,对于移动平台上,Unity没有更改法线纹理的存储格式,仍然是RGB通道对应了XYZ方向。对于其他平台上,则使用了另一个函数UnpackNormalDXT5nm。为什么要这样差别对待呢?实际上是因为对法线纹理的压缩。按我们之前的处理方式,法线纹理被当成一个和普通纹理无异的图,但其实,它只有两个通道是真正必不可少的,因为第三个通道的值可以用另外两个推导出来(法线是单位向量)。显然,Unity采用的压缩方式是DXT5nm。这种压缩方式的原理我就不讲了(其实我也不是很懂。有兴趣的可以看这篇),但从通道的存储上,它的特点是,原先存储在R通道的值会被转移到A通道上,G通道保留,而RB通道会使用某种颜色填充(相当于被舍弃了)。因此UnpackNormalDXT5nm函数中,真正法线的xy值对应了压缩纹理的wy值,而z值是通过xy值推导出来的。
针对UNITY的2,3事
就是在Unity 5.0以前,实际上如果我们对一个模型A进行了非统一缩放,Unity内部会重新在内存中创建一个新的模型B,模型B的大小和缩放后的A是一样的,但是它的缩放系数是统一缩放,我们可以通过unity_Scale.w来得到这个统一缩放系数。换句话说,在Unity 5.0以前,实际上我们在shader中根本不需要考虑模型的非统一缩放问题,因为在shader阶段非统一缩放根本就不存在了!
不得不说,Unity这个背后的小动作真的是很奇葩。。。我认为正是这个原因,导致在Unity 4.x中内置shader的对法线的转换都大胆地直接使用了_Object2World来把法线转换到世界空间下,天真的我竟然还以为是他们不在意。Too young!!!因此,在Unity 4.x中,完全可以不考虑非统一缩放对法线变换的影响,我们可以直接使用原变换矩阵即可,不需要再使用原变换矩阵的逆转置矩阵来变换法线。评论里有一位仁兄指出了这个问题。的确,在上面最后一个例子里,我在v2f中存储从该顶点的Tangent Space转换到World Space的转换矩阵,然后在frag函数中,利用转换矩阵先把法线转换到World Space中。这里,我直接使用的就是从Tangent Space到World Space的变换矩阵来变换法线的,没有考虑逆转置矩阵,因此这位仁兄看不懂是正常的。。。
在这里我给出官方的更新说明(详情请见:http://docs.unity3d.com/Manual/UpgradeGuide5-Shaders.html):
“unity_Scale” shader variable has been removed
The “unity_Scale” shader property has been removed. In 4.x unity_Scale.w was the 1 / uniform Scale of the transform, Unity 4.x only rendered non-scaled or uniformly scaled models. Other scales were performed on the CPU, which was very expensive & had an unexpected memory overhead. In Unity 5.0 all this is done on the GPU by simply passing matrices with non-uniform scale to the shaders. Thus unity_Scale has been removed because it can not represent the full scale. In most cases where “unity_Scale” was used we recommend instead transforming to world space first. In the case of transforming normals, you always have to use normalize on the transformed normal now. In some cases this leads to slightly more expensive code in the vertex shader.
也就是说,unity_Scale在Unity 5.x中已经被抛弃了,Unity也不会在CPU里再处理非统一缩放模型。带来的问题就是,我们需要自行处理非统一缩放问题,并要在必要时进行归一化操作了。
把“Texture Type”设置成“Normal Map”后,有一个复选框是“Create from grayscale”,这个是做什么用的。这要从法线纹理的种类说起。我们上述提到的法线纹理,也称“Tangent-Space Normal Map”。还有一种法线纹理是从“Grayscale Height Map”中生成的。后面这种纹理本身记录的是相对高度,是一张灰度图,白色表示相对更高,黑色表示相对更低。而法线纹理可以通过对这张图进行图像滤波来实现。使用方法可见官网,算法可见论坛讨论。
那么,说了这么多,正确的方法是什么呢?在Unity 5.x中,如果我们需要把法线从模型空间变换到世界空间中,可以直接使用内置函数UnityObjectToWorldNormal,例如:
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal)
Shader "OpenGL Cookbook/UsingNormalMaps (Unity 5.x)" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Bump ("Bump", 2D) = "bump" {}
_Specular ("Specular", Range(1.0, 500.0)) = 250.0
_Gloss ("Gloss", Range(0.0, 1.0)) = 0.2
_Cubemap ("Cubemap", CUBE) = ""{}
_ReflAmount ("Reflection Amount", Range(0,1)) = 0.5
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
Pass {
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
sampler2D _Bump;
float _Specular;
float _Gloss;
samplerCUBE _Cubemap;
float _ReflAmount;
float4 _MainTex_ST;
struct a2v {
float4 vertex : POSITION;
fixed3 normal : NORMAL;
fixed4 texcoord : TEXCOORD0;
fixed4 tangent : TANGENT;
};
struct v2f {
float4 pos : POSITION;
fixed2 uv : TEXCOORD0;
fixed3 lightDir: TEXCOORD1;
fixed4 TtoW0 : TEXCOORD2;
fixed4 TtoW1 : TEXCOORD3;
fixed4 TtoW2 : TEXCOORD4;
LIGHTING_COORDS(5, 6)
};
v2f vert(a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = TRANSFORM_TEX (v.texcoord, _MainTex);
//Create a rotation matrix for tangent space
TANGENT_SPACE_ROTATION;
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
float3 worldPos = mul(_Object2World, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
// Case 1: The codes used by built-in shaders
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
// Case 2: The codes which I think are correct
float3x3 WtoT = mul(rotation, (float3x3)_World2Object);
o.TtoW0 = float4(WtoT[0].xyz, worldPos.x);
o.TtoW1 = float4(WtoT[1].xyz, worldPos.y);
o.TtoW2 = float4(WtoT[2].xyz, worldPos.z);
// pass lighting information to pixel shader
TRANSFER_VERTEX_TO_FRAGMENT(o);
return o;
}
fixed4 frag(v2f i) : COLOR {
fixed4 texColor = tex2D(_MainTex, i.uv);
fixed3 norm = UnpackNormal(tex2D(_Bump, i.uv));
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
// Case 1
half3 worldNormal = normalize(half3(dot(i.TtoW0.xyz, norm), dot(i.TtoW1.xyz, norm), dot(i.TtoW2.xyz, norm)));
// Case 2
worldNormal = normalize(mul(norm, float3x3(i.TtoW0.xyz, i.TtoW1.xyz, i.TtoW2.xyz)));
fixed atten = LIGHT_ATTENUATION(i);
fixed3 ambi = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 diff = _LightColor0.rgb * saturate (dot (normalize(worldNormal), normalize(lightDir)));
fixed3 lightRefl = reflect(-lightDir, worldNormal);
fixed3 spec = _LightColor0.rgb * pow(saturate(dot(normalize(lightRefl), normalize(worldViewDir))), _Specular) * _Gloss;
fixed3 worldView = fixed3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
fixed3 worldRefl = reflect (-worldViewDir, worldNormal);
fixed3 reflCol = texCUBE(_Cubemap, worldRefl).rgb * _ReflAmount;
fixed4 fragColor;
fragColor.rgb = float3((ambi + (diff + spec) * atten) * texColor) + reflCol;
fragColor.a = 1.0f;
return fragColor;
}
ENDCG
}
}
FallBack "Diffuse"
}