标签:des style blog http io ar color os 使用
写这篇的目的是为了总结我长期以来的混乱。虽然题目是“法线纹理的实现细节”,但其实我想讲的是如何在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; }
但你有没有想过为什么要使用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;
// Set "Texture Type" to "Texture" fixed4 normal = tex2D(_Bump, i.uv); norm.xyz = norm.xyz * 2 - 1
Unity为了某些原因把上述过程进行了封装,也就是说上述代码在Unity里可以这么做:把法线纹理的“Texture Type”设置成“Normal Map”,在代码中使用UnpackNormal函数得到法线方向。这其中的原因,我猜想一方面是为了方便它对不同平台做优化和调整,一方面是为了解析不同格式的法线纹理。
我们现在可以来回答第一个问题:为什么需要把法线纹理的“Texture Type”设置成“Normal Map”才能正确显示。这样的设置可以让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值推导出来的。
也就是说,如果我们把“Texture Type”设置成“Normal Map”,调用UnpackNormal相当于进行了下面的操作(不考虑其他平台):
// Set "Texture Type" to "Normal Map" // fixed3 norm = UnpackNormal(tex2D(_Bump, i.uv)); // The above line is equal to this fixed4 normal = tex2D(_Bump, i.uv); fixed3 norm; norm.xy = normal.wy * 2 - 1; norm.z = sqrt(1 - saturate(dot(norm.xy, norm.xy)));
最后,我们来看下第三个问题:把“Texture Type”设置成“Normal Map”后,有一个复选框是“Create from grayscale”,这个是做什么用的。这要从法线纹理的种类说起。我们上述提到的法线纹理,也称“Tangent-Space Normal Map”。还有一种法线纹理是从“Grayscale Height Map”中生成的。后面这种纹理本身记录的是相对高度,是一张灰度图,白色表示相对更高,黑色表示相对更低。而法线纹理可以通过对这张图进行图像滤波来实现。使用方法可见官网,算法可见论坛讨论。
这个问题一开始让我很困扰。第一次接触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不可以用于可变形的物体,而另一些人说可以(我也认为使用哪种坐标系都可以在游戏里得到正确的效果,只要通过合适的坐标系转换)。下面是总结的我比较认同的优缺点。
Tangent Space的前两个优点足以让很多人放弃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下。而这些计算,我们会在下一节里面讲到具体实现的方法。
如果要自己编码实现法线映射的目的,最主要的就是要考虑最终将光照计算转换到哪个坐标系中:Model Space,World Space,View Space还是Tangent Space。通常(注意是通常!),如果使用模型自带的法线时,我们一般把所有信息转换到World Space中。这样最大的好处就是一切都很直观,符合我们的一般认识。而如果是使用法线纹理,一般是转换到Tangent Space中。这样做的原因有一定性能的考虑,因为真正的法线信息只有到了Fragment Shader阶段才会从纹理中采样得到,如果我们不使用Tangent Space,就需要逐像素处理每个法线信息,而相反,如果使用Tangent Space,我们就只需要在Vertex Shader中对光照方向等信息进行逐顶点处理。而逐顶点总是比逐像素的处理效率更优。
需要转换的信息主要包含了下面几种:
使用自带法线计算的话,那么我们需要在vert函数中把顶点法线、光照方向、视角方向全部转换到World Space中(当前其他坐标系也可以),而在frag函数中计算光照模型。
Vertex Shader如下:
v2f vert(a2v v) { v2f o; //Transform the vertex to projection space o.pos = mul(UNITY_MATRIX_MVP, v.vertex); //Get the UV coordinates o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); // If the model matrix is orthogonal (no scaling) // We can use _Object2World; // o.worldNormal = mul((float3x3)_Object2World, SCALED_NORMAL); // Or if the matrix is orthogonal // We can use transpose instead of the inverse o.worldNormal = mul(SCALED_NORMAL, (float3x3)_World2Object); o.lightDir = mul((float3x3)_Object2World, ObjSpaceLightDir(v.vertex)); o.viewDir = mul((float3x3)_Object2World, ObjSpaceViewDir(v.vertex)); // pass lighting information to pixel shader TRANSFER_VERTEX_TO_FRAGMENT(o); return o; }
对lightDir和viewDir的转换都是只需要把当前顶点对应的光照方向和视角方向乘以_Object2World即可。ObjSpaceLightDir和ObjSpaceViewDir函数是UnityCG.cginc中Unity提供的辅助函数,它们可以在ForwardBase和ForwardAdd Pass中根据顶点位置计算相应变量在Object Space中的方向。类似的函数有WorldSpaceLightDir和WorldSpaceViewDir函数,不同的是它们是转换到World Space中。需要注意的是,这些函数并没有进行向量标准化,也就是不保证向量的模为1,因此我们需要在后面自己标准化。更多内置函数和变量可以参见官网。还有一种计算World Space中光照方向的方法是使用_WorldSpaceLightPos0,但这个变量其实官方文档中没有给出说明(起码目前没有,我记得以前是有的,但现在好像修改了),我的想法是官方希望我们使用之前提到的几种函数计算,而不推荐使用_WorldSpaceLightPos0。_WorldSpaceLightPos0只适用Forward Pass中的平行光部分,而之前的函数是可以支持各种情况的。
还有一点需要说明,就是对法线的转换。可以看出,我们没有直接像处理光照方向一样,把顶点法线乘以_Object2World。这是因为法线的要求与顶点平面垂直所致,而正确的转换方法可以参见这篇博客。而结论就是:对法线的转换需要使用矩阵的逆反置矩阵。回到我们的代码上,我们希望把法线从Object Space转换到World Space,那么就需要使用_Object2World的逆反置矩阵。但Unity并没有提供这个矩阵(Unity只提供了MV矩阵的逆反置矩阵,也就是说可以把法线从Object Space中转换到View Space中,详情可见官网),但庆幸的是,在Unity里自己计算得到这个矩阵是很简单的。我们可以首先得到_Object2World逆矩阵_World2Object。反置矩阵我们可以自己手动反置,也可以像代码中的做法一样,调换mul函数中法线和矩阵的位置,这样就可以实现反转后相乘的目的:
// Or if the matrix is orthogonal // We can use transpose instead of the inverse o.worldNormal = mul(SCALED_NORMAL, (float3x3)_World2Object);
剩下的fragment函数就是简单的光照计算:
float4 frag(v2f i) : COLOR { fixed3 texColor = tex2D(_MainTex, i.uv); //Based on the ambient light fixed3 ambi = UNITY_LIGHTMODEL_AMBIENT.xyz; //Work out this distance of the light fixed atten = LIGHT_ATTENUATION(i); //Angle to the light fixed3 diff = _LightColor0.rgb * saturate (dot (normalize(i.worldNormal), normalize(i.lightDir))) * 2; fixed3 refl = reflect(-i.lightDir, i.worldNormal); fixed3 spec = _LightColor0.rgb * pow(saturate(dot(normalize(refl), normalize(i.viewDir))), _Specular) * _Gloss; //Product the final color fixed4 fragColor; fragColor.rgb = float3((ambi + (diff + spec) * atten) * texColor); fragColor.a = 1.0f; return fragColor; }
Shader "OpenGL Cookbook/UsingDefaultNormal" { Properties { _MainTex ("Base (RGB)", 2D) = "white" {} _Specular ("Specular", Range(1.0, 500.0)) = 250.0 _Gloss ("Gloss", Range(0.0, 1.0)) = 0.2 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 Pass { Tags { "LightMode" = "ForwardBase" } CGPROGRAM #pragma multi_compile_fwbase #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" #include "AutoLight.cginc" sampler2D _MainTex; float _Specular; float _Gloss; float4 _MainTex_ST; struct a2v { float4 vertex : POSITION; fixed3 normal : NORMAL; fixed4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 lightDir : TEXCOORD2; float3 viewDir : TEXCOORD3; LIGHTING_COORDS(4,5) }; v2f vert(a2v v) { v2f o; //Transform the vertex to projection space o.pos = mul(UNITY_MATRIX_MVP, v.vertex); //Get the UV coordinates o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); // If the model matrix is orthogonal (no scaling) // We can use _Object2World; // o.worldNormal = mul((float3x3)_Object2World, SCALED_NORMAL); // Or if the matrix is orthogonal // We can use transpose instead of the inverse o.worldNormal = mul(SCALED_NORMAL, (float3x3)_World2Object); o.lightDir = mul((float3x3)_Object2World, ObjSpaceLightDir(v.vertex)); o.viewDir = mul((float3x3)_Object2World, ObjSpaceViewDir(v.vertex)); // pass lighting information to pixel shader TRANSFER_VERTEX_TO_FRAGMENT(o); return o; } float4 frag(v2f i) : COLOR { fixed3 texColor = tex2D(_MainTex, i.uv); //Based on the ambient light fixed3 ambi = UNITY_LIGHTMODEL_AMBIENT.xyz; //Work out this distance of the light fixed atten = LIGHT_ATTENUATION(i); //Angle to the light fixed3 diff = _LightColor0.rgb * saturate (dot (normalize(i.worldNormal), normalize(i.lightDir))) * 2; fixed3 refl = reflect(-i.lightDir, i.worldNormal); fixed3 spec = _LightColor0.rgb * pow(saturate(dot(normalize(refl), normalize(i.viewDir))), _Specular) * _Gloss; //Product the final color fixed4 fragColor; fragColor.rgb = float3((ambi + (diff + spec) * atten) * texColor); fragColor.a = 1.0f; return fragColor; } ENDCG } } FallBack "Diffuse" }
如果能看到这里说明你很有耐心啊。。。呜呼,这是最后的部分了。如果需要使用法线纹理来采样法线信息,最大的问题同样是坐标系的转换。我们之前说过,一般的做法是把所有信息转换到Tangent Space中,我们现在就来看如何转换。
我们先来看代码:
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)); o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)); // pass lighting information to pixel shader TRANSFER_VERTEX_TO_FRAGMENT(o); return o; }
// Declares 3x3 matrix ‘rotation‘, filled with tangent space basis #define TANGENT_SPACE_ROTATION float3 binormal = cross( v.normal, v.tangent.xyz ) * v.tangent.w; float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )
Fragment Shader里就是进行简单的纹理采样,然后就和之前的光照计算一样。
整体代码如下:
Shader "OpenGL Cookbook/UsingNormalMaps" { 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 } 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; 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; fixed3 viewDir : TEXCOORD2; LIGHTING_COORDS(3,4) }; 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)); o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)); // 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)); fixed atten = LIGHT_ATTENUATION(i); fixed3 ambi = UNITY_LIGHTMODEL_AMBIENT.xyz; fixed3 diff = _LightColor0.rgb * saturate (dot (normalize(norm), normalize(i.lightDir))) * 2; fixed3 refl = reflect(-i.lightDir, norm); fixed3 spec = _LightColor0.rgb * pow(saturate(dot(normalize(refl), normalize(i.viewDir))), _Specular) * _Gloss; fixed4 fragColor; fragColor.rgb = float3((ambi + (diff + spec) * atten) * texColor); fragColor.a = 1.0f; return fragColor; } ENDCG } } FallBack "Diffuse" }
这篇文章,有点长,有点枯燥,有点复杂,但是非常基础,了解这些很重要。我也真的花了一些时间才弄清楚这些,希望看到的人可以好好看看。渲染的东西很碎,很杂,需要积累,路途漫漫啊~
【Unity Shaders】法线纹理(Normal Mapping)的实现细节
标签:des style blog http io ar color os 使用
原文地址:http://blog.csdn.net/candycat1992/article/details/41605257