40

从纹理中生成法线贴图-腾讯游戏学院

 5 years ago
source link: http://gad.qq.com/article/detail/288865
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本篇文章未经作者本人授权,禁止任何形式的转载,谢谢!如果在第三方阅读发现公式等格式有问题,请到下列地址阅读。

原文地址

知乎专栏地址

概要

本为主要讲解生成法线贴图的基本方法,并在 unity 中进行实现和测试。


预备知识

法线贴图和基本的图形学知识,基本的向量和极限的知识。


高度图或灰度图

一张二维纹理有两个维度 u 和 v,但其实,高度(h)可以算第三个维度。有了高度,一张二维纹理就可以想象成一个三维的物体了。


?wx_fmt=png


先来考虑只有 u 方向的情况,如图所示, A 和 B 是纹理中的两个点, uv 坐标分别是 (0, 0) 和 (1, 0),上方黑线表示点对应的高度,那么显然,只要求出 u 方向上的高度函数在某一点的切线,就能求出垂直于他的法线了。同理, v 方向也是如此。也就是说,如果有纹理的高度信息,那么就能计算出纹理中每一个像素的法线了。


所以计算法线需要一张高度图,它表示纹理中每一个点对应的高度。


但其实并不需要求出每个纹理像素上 uv 方向各自的法线,只需要求出 uv 方向上高度函数的切线,再做一个叉积,即可计算出对应的法线了。


如果没有高度图,也可以用灰度图代替,灰度图就是把 rgb 三个颜色分量做一个加权平均,有很多种算法提取灰度值,这里用一个比较常用的基于人眼感知的灰度值提取公式。


color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722


这个公式是由人眼对不同颜色敏感度不同得来的,这里无需过多计较,直接把提取出来的灰度值作为高度值即可。


计算方法


当需要求一个点的函数图像切线的时候,只要求出该点的函数斜率即可,即是导数,这需要和它相临的点进行计算。显然,两个点越接近,结果越精确。所以有如下公式:

?wx_fmt=jpeg


求出切线后,就得到了两个方向上的切线向量 ?wx_fmt=jpeg和 ?wx_fmt=jpeg。之所以是这种形式的二维向量,是因为这里是按照 uoh 平面和 voh 平面分别计算的,具体的向量形式需要根据实际情况去组合。这里可以做一个优化,在求导数的时候公式里做了一个除法,因为法线最终会归一化,切线向量长度不影响叉积后的结果向量方向,所以其实可以直接把求导数时候的除法去掉,即直接将切线向量乘以 ?wx_fmt=jpeg 和 ?wx_fmt=jpeg,变为 ?wx_fmt=jpeg

 和?wx_fmt=jpeg 。如果你觉得乱,没关系,后面看具体的代码就明白了。

接下来是将两个向量做叉积,叉积的顺序会影响计算出的法线的方向,这个要根据实际情况去决定。


实例


这个例子使用 unity shader 去动态的生成一张纹理中每一个像素的法线,并当作颜色输出出来,最终在屏幕上会看到一张动态生成的法线贴图。将纹理放置成平行于屏幕的方向,如下图所示:


?wx_fmt=png


整张纹理处于世界空间 XOY 平面,并且朝向 -Z 轴(unity 使用左手坐标系,且 Z 轴朝向屏幕里)。


由于没有高度图,所以提取出灰度值来当作高度图,算法根上面描述的一样,函数名为 GetGrayColor。


float GetGrayColor(float3 color){
    return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722;}


然后可根据高度图的值来计算 uv 两个方向的高度函数切线。


float3 GetNormalByGray(float2 uv){   
    // 代码后有详细的讲解
    float2 deltaU = float2(_MainTex_TexelSize.x * _DeltaScale, 0);
    float h1_u = GetGrayColor(tex2D(_MainTex, uv - deltaU).rgb);
    float h2_u = GetGrayColor(tex2D(_MainTex, uv + deltaU).rgb);
    // float3 tangent_u = float3(1, 0, (h2_u - h1_u) / deltaU.x);
    float3 tangent_u = float3(deltaU.x, 0, (h2_u - h1_u));
    float2 deltaV = float2(0, _MainTex_TexelSize.y * _DeltaScale);
    float h1_v = GetGrayColor(tex2D(_MainTex, uv - deltaV).rgb);
    float h2_v = GetGrayColor(tex2D(_MainTex, uv + deltaV).rgb);
    // float3 tangent_v = float3(0, 1, (h2_v - h1_v) / deltaV.y);
    float3 tangent_v = float3(0, deltaV.y, (h2_v - h1_v));
    float3 normal = normalize(cross(tangent_v, tangent_u));
    return normal;}


上面代码分为 3 段,前两段为计算 uv 各自方向的高度函数切线,最后一段计算最终法线。


先看第一段,计算 u 方向的高度函数切线。首先,确定步长 ?wx_fmt=jpeg的大小。MainTexTexelSize 是 unity shader 内置的一个变量,保存着纹理大小相关的信息,是一个 float4 类型的值,具体为 (1 / width, 1 / height, width, height)。_DeltaScale 是一个控制步长缩放的变量,在这个例子中为 0.5,乘以 _DeltaScale 是用来控制法线生成的精确度的,就如之前所说, ?wx_fmt=jpeg越小,生成的法线就越精确。通常我们会向当前采样点两侧去采样,以获得更精准的结果,这个方法叫做中心差分法。然后可以根据步长分别取当前像素左右两侧的高度值(在这个例子里就是灰度值),在按照上面提到的计算方法计算切线即可。注释掉的代码是原始代码,下面没注释的是优化后的代码,这个也是上面提到的。


有一个问题是,为什么计算出来的切线向量是 (x, 0, z) 的形式,而不是其他?这是因为前面提到整张纹理是处于 XOY 平面的,而高度是第三个维度,因为 u 和 v 自然是按照 x 和 y 轴处理方便,所以高度 h 就按照 z 轴来处理了。


还有一个可能的疑问是,当 _DeltaScale 特别小的时候,取两侧的像素实际上都是单前像素,则高度差都是 0 了。但实际上这个情况只有在采样过滤方式为 point 采样时才会出现,具体采样过滤方式是如何处理的可以查阅其他资料。


同理,第二段可以计算出 v 方向的高度函数切线,两个切线向量,做叉积,再归一化,即可获得当前像素点表面的法线向量。叉积的顺序很重要,因为纹理是朝向 -z 轴的,所以一般来说会让法线也顺着表面所在的朝向,这就是为什么是 cross(tangentv, tangentu) 而不是 cross(tangentu, tangentv) 的原因。


现在将法线当作颜色输出出来看一下,当然不能直接输出,因为法线向量可能包含着负值,可能看到的都是黑色,所以需要转换一下,这个转换对于了解过法线贴图的读者应该很熟悉了。


fixed4 color = normal * 0.5 + 0.5


直接输出这个 color,如下图所示:


?wx_fmt=png


看起来跟常见的法线贴图有些不一样,常见的是偏蓝色的那种。为什么是偏蓝色的呢,因为常见的法线贴图都是切线空间的。


基于切线空间的法线贴图,z 也就是 b 通道的值都是 0.5 到 1,而 x 和 y 也就是 r 和 g 通道都是 0 到 1,所以看起来会偏蓝一些,当然不是绝对。而上面计算出来的法线贴图,由于叉积的顺序,z 分量是朝向 -z 轴的,所以 b 通道都是 0 到 0.5,不信可以用截屏工具看下颜色值。在这个例子里,想要变成切线空间下的法线贴图是非常简单的,只需要将 z 分量乘以 -1 即可,


normal.z *= -1;fixed4 color = normal * 0.5 + 0.5


结果如下图:


?wx_fmt=png


根上一张图比,确实偏蓝一些了,但是依然不够蓝。这并不是因为这张纹理特殊,而是还有一些校正的步骤没有做。


在计算切线向量的时候,是直接用高度差和 ?wx_fmt=jpeg值做计算的,这其实是不合理的,因为?wx_fmt=jpeg是非常非常小的,一张 1024 * 1024 大小的图,?wx_fmt=jpeg只有 1 / 1024 = 0.00097656,但是高度差却是 0 到 1 之间某两个数的差,例如高度为 0.6 和高度为 0.2,正常来说是远大于?wx_fmt=jpeg的,这就导致了切线向量很接近 -z 轴,计算出的法线就很接近于 xoy 平面了,这样就看起来有很多红色和绿色,因为 x 和 y 的分量更大。为了解决这个问题,需要引入一个 _HeightScale 变量,来控制高度差的比例。


float3 GetNormalByGray(float2 uv){
    ...
    float3 tangent_u = float3(deltaU.x, 0, _HeightScale * (h2_u - h1_u));
    ...
    float3 tangent_v = float3(0, deltaV.y, _HeightScale * (h2_v - h1_v));
    ...}


当这个值为 _HeightScale 值为 0.01 时,法线贴图结果如下:



?wx_fmt=png


这张法线贴图看起来正常了,而且仔细观察可以发现,每一个砖块的上侧是偏绿的,因为 y 对应于 g,右侧是偏红的,因为 x 对应于 r。


可以不用中心差分法吗


可以使用有限差分法,即不取像素两边相邻的点,而是只取一个方向上相邻的点与当前像素比较,这种方法想想也知道效果一般不如中心差分法的好。


除了高度差缩放,还有别的参数可以调节吗

有,这里简单列举两个,因为修改都很简单,而且效果不适合这里讲的例子,所以不在本文实现了。


凹凸值


图中每一个砖块,是凹进去的还是突出来的呢?要改变这个属性,只需要调整法线 xy 的正负即可,就会改变原有的凹凸方向,稍微想象一下应该就能想出来。


粗糙度


可以在原来的法线题图基础上,进一步修改法线贴图的粗糙度。其实之前的高度差缩放,也是处理粗糙度,但是当你有一张已经生成好的法线贴图时,想修改就需要做额外的处理了。也很简单,对法线的 xy 分量进行缩放,然后再重新计算 ?wx_fmt=jpeg即可。


加上光照


法线是为了光照服务的,所以这里再演试一下加上一个平行光之后的漫反射的效果,并与没加法线贴图的效果做一下对比(默认法线为 -z 轴方向)。


首先是没有法线贴图的情况。


fixed4 frag (v2f i) : SV_Target{
    float3 normal = float3(0, 0, -1);
    fixed4 texColor = tex2D(_MainTex, i.uv);
    float diffuse = saturate(dot(normal, normalize(_WorldSpaceLightPos0.xyz)));
    fixed4 color;
    color.rgb = texColor.rgb * diffuse *_LightColor0.rgb;
    return color;}


最终的结果如下图所示:



?wx_fmt=png


这是将光源绕 x 轴和 y 轴都旋转了 60 度并且使用默认法线得到的 diffuse 结果,和原来没有光照的原图比较,有了明暗的变化,但依然只是一张平坦的图。


接下来是使用了上面算法动态生成法线贴图的情况。


fixed4 frag (v2f i) : SV_Target{
    float3 normal = GetNormalByGray(i.uv);
    // normal.z *= -1;
    fixed4 color;
    fixed4 texColor = tex2D(_MainTex, i.uv);
    float diffuse = saturate(dot(normal, normalize(_WorldSpaceLightPos0.xyz)));
    color.rgb = texColor.rgb * diffuse *_LightColor0.rgb;
    return color;}


注意这里的 normal.z 不再乘以 -1 了,因为这个例子一切都是在世界空间下计算的,正常情况下可能在切线空间算效率会更高一些,但这并不是本篇文章的内容。最终输出的结果如下图所示:



?wx_fmt=png


可以看到,整张图有了明显的立体感,砖块也显得粗糙了,与之前有了极大的效果提升。再仔细观察可以发现,每个砖块左边和上边都被照亮,右边和下边都变暗了,这正符合平行光的旋转角度,所以光照结果是正确的。


最后的工作


最后的工作就是把生成的法线贴图保存到硬盘上,这一步只需要调用引擎的相关 API 把渲染出来的法线贴图保存为资源即可,也可以直接在 cpu 上操作去生成一张,但这么做就不方便用实时光照去查看效果了。



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK