查看: 2748|回复: 1
打印 上一主题 下一主题

[经验分享] 法线纹理(Normal Mapping)的实现细节

[复制链接]

100

主题

3

听众

7683

积分

高级设计师

Rank: 6Rank: 6

纳金币
2378
精华
0

最佳新人 活跃会员 热心会员 灌水之王 突出贡献

跳转到指定楼层
楼主
发表于 2015-2-19 19:28:33 |只看该作者 |倒序浏览

[color=ize:24px]写在前面



写这篇的目的是为了总结我长期以来的混乱。虽然题目是“法线纹理的实现细节”,但其实我想讲的是如何在shader中编程正确使用法线进行光照计算。这里面最让人头大的就是各种矩阵运算和坐标系之间的转换,很容易因为坐标系错误而造成光照结果的错误。



我们将要讨论以下几个问题:


为什么法线纹理通常都是偏蓝色的?



在Unity里,法线纹理是需要把“Texture Type”设置成“Normal Map”才能正确显示,为什么?



把“Texture Type”设置成“Normal Map”后,有一个复选框是“Create from grayscale”,这个是做什么用的?



为什么在Unity Shaders里,对法线纹理的采样要使用UnpackNormal函数?直接采样不行么?


当然,法线纹理映射是要在shaders中实现的。为此,我们讨论以下几种情况:


在Surface Shader中,使用模型自带的法线如何计算光照;



在Surface Shader中,使用法线纹理如何计算光照;



在Vertex & Fragment Shader中,使用模型自带的法线如何计算光照;



在Vertex & Fragment Shader中,使用法线纹理如何计算光照;






要先说明的是,这篇文章有点长,有点绕,希望大家能耐心看完。我们先从最简单的地方开始。




[color=ize:24px]Surface Shader中的法线



在Surface Shader中,无论是使用模型自带的法线或者使用法线纹理都是一件比较方便的事。原因是Unity封装了很多矩阵操作。我们会在这一节回答关于法线纹理的那几个问题。


[color=ize:18px]使用模型自带的法线



法线实际上就是在光照模型中使用的,也就是Surface Shader的Lighting<Name>函数。Unity最常见的两种光照函数参数列表如下:



[color=:]

[color=:]

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.


[color=:]用于依赖视角的光照模型的计算,例如高光反射。而想要访问法线的话就是使用SurfaceOutput中的的o.Normal即可。需要注意的是,这些方便都是建立在Unity在背后为我们把normal、lightDir、viewDir转换到了同一坐标系下的基础上。这个坐标系一般是指World Space



[color=ize:18px]使用法线纹理



如果使用法线纹理的话,就需要我们在进入光照函数之前修改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根据不同平台对纹理进行压缩,通过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”中生成的。后面这种纹理本身记录的是相对高度,是一张灰度图,白色表示相对更高,黑色表示相对更低。而法线纹理可以通过对这张图进行图像滤波来实现。使用方法可见官网,算法可见论坛讨论。




[color=ize:32px]为什么要使用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下。而这些计算,我们会在下一节里面讲到具体实现的方法。




[color=ize:24px]Vertex & Fragment Shader中的Normal Map



如果要自己编码实现法线映射的目的,最主要的就是要考虑最终将光照计算转换到哪个坐标系中: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。


理解了上述内容后,我们来具体看如果编码实现。为了充分说明,下面的示例中都使用了传统的ADS模型(ambient+diffuse+specular),以用到上述所有需要转换的信息。




[color=ize:18px]使用模型自带的法线



使用自带法线计算的话,那么我们需要在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;

}

上述代码把转换到World Space中的各个信息存储在worldNormal、lightDir和viewDir中。




对lightDir和viewDir的转换都是只需要把当前顶点对应的光照方向和视角方向乘以_Object2World即可。ObjSpaceLightDir和ObjSpaceViewDir函数是UnityCG.cginc中Unity提供的辅助函数,它们可以在ForwardBase和ForwardAdd Pass中根据顶点位置计算相应变量在Object Space中的方向。类似的函数有WorldSpaceLightDir和WorldSpaceViewDir函数,不同的是它们是转换到World


分享到: QQ好友和群QQ好友和群 腾讯微博腾讯微博 腾讯朋友腾讯朋友 微信微信
转播转播0 分享淘帖0 收藏收藏0 支持支持0 反对反对0
回复

使用道具 举报

13

主题

8

听众

4583

积分

中级设计师

Rank: 5Rank: 5

纳金币
13
精华
0

最佳新人 活跃会员 热心会员 灌水之王 突出贡献

沙发
发表于 2015-2-23 10:51:06 |只看该作者
太牛了,看不懂啊,留着以后看吧
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

手机版|纳金网 ( 闽ICP备2021016425号-2/3

GMT+8, 2024-12-2 22:48 , Processed in 0.114910 second(s), 28 queries .

Powered by Discuz!-创意设计 X2.5

© 2008-2019 Narkii Inc.

回顶部