从纹理中生成法线贴图

发表于2018-12-25
评论5 1.13w浏览

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

原文地址

知乎专栏地址

概要

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


预备知识

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


高度图或灰度图

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



先来考虑只有 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


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


计算方法


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


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

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

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


实例


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



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


有一个问题是,为什么计算出来的切线向量是 (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,如下图所示:



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


基于切线空间的法线贴图,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


结果如下图:



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


在计算切线向量的时候,是直接用高度差和 值做计算的,这其实是不合理的,因为是非常非常小的,一张 1024 * 1024 大小的图,只有 1 / 1024 = 0.00097656,但是高度差却是 0 到 1 之间某两个数的差,例如高度为 0.6 和高度为 0.2,正常来说是远大于的,这就导致了切线向量很接近 -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 时,法线贴图结果如下:




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


可以不用中心差分法吗


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


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

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


凹凸值


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


粗糙度


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


加上光照


法线是为了光照服务的,所以这里再演试一下加上一个平行光之后的漫反射的效果,并与没加法线贴图的效果做一下对比(默认法线为 -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;}


最终的结果如下图所示:




这是将光源绕 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 了,因为这个例子一切都是在世界空间下计算的,正常情况下可能在切线空间算效率会更高一些,但这并不是本篇文章的内容。最终输出的结果如下图所示:




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


最后的工作


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


如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引