法线贴图原理

114 阅读4分钟

1.法线贴图

在计算 phong 渲染模型的时候有一步是计算光照方向和法线方向的夹角,以计算光照强度,比如下面的代码。

    float DotN = dot(normal, lightDir);
    vec3 finalColor = baseColor * Dotn;

2.png

所以当法线越多且方向不同的时候(顶点和三角面越多)会渲染出更多的细节,比如左图。但是顶点过多导致内存占用大,计算量大等问题,减少顶点三角面的话又会导致失去这些细节。但是还有一种方法可以使得在使用较少的顶点三角面的同时保留这些光照细节,这个方法就是使用法线贴图,比如右图。

1.png

法线贴图的原理

法线贴图一般长下面这样,它的基本原理是使用图片的 R、G、B 值去存储模型的法线的 X、Y、Z。在计算光照的时候再根据 UV 去采样图片的 RGB 值,然后归一化成法线。

3.png

和上面的代码相比,下面的代码就是把原本顶点法线插值出来的法线值用了法线贴图采样的法线值代替了,这时候就会得到一个基本的效果了。

    Vec3 texNormal = noramlize(texture2D(noramlTexture, uv)) * 2 - 1;
    float DotN = dot(texNormal, lightDir);
    vec3 finalColor = baseColor * Dotn;

4.png

上面的结果看起来是正确的,但是如果你把平面换成正方体,仔细观察就会发现蓝色边框的阴影是正确的,但是红色边框的阴影似乎是由绿色箭头方向的光产生的。

5.png

为什么会产生这种现象呢?上面我们说过法线贴图的 RGB 存储了法线的 XYZ,在计算蓝色边框的面的时候从法线贴图采样到的法线刚好是垂直该面的,但红色边框的面采样到的值是确实平行该面,计算得到阴影也就不正确了。

6.png

当然,我们可以把模型的 UV 展开,然后在法线贴图里面准确记录每一个面的法线。另外还有一个好的解决办法就是切线空间。

切线空间

切线空间可以理解为 Z 轴永远垂直于三角面,X 和 Y 轴和三角面处于同于平面,三者互相垂直,X、Y、Z 分别对应着 T (tangent切线)、B(bitangent副切线)、N(normal法线), 比如下图

7.png

可以看到蓝色边框的面因为切线空间坐标和世界空间切线空间坐标刚好相同,所以该面的光照计算没有错误。

那么只要我们可以把世界空间坐标都转换到切线空间坐标(或者相反)那就算光照不就正确了。这时候就需要一个变换矩阵,这个矩阵就是 TBN 矩阵。

8.png

可以看到如果要求出这个矩阵就要分别求出 T、B、N。N 我们是知道,就是三角面的法线。T、B 虽然和三角面在同一个平面上,但是还是有无数的方向。由于T、B、N是互相垂直的,所以T、B只有求出其中一个,另外一个和 N 叉乘就可以得到,一般计算 T。具体的计算方法可以参考这篇[文章](https://learnopengl-cn.github.io/05%20Advanced%20Lighting/04%20Normal%20Mapping/#_1),这里不展开(主要是我自己没去算哈哈哈)。当然,切线 T 可以在三维软件导出模型的时候顺便算出 T 保存在数据中,比如在 blender 可以勾选

9.png

又或者3D引擎算出 T 比如在 three 中:

	const geometry = new THREE.BoxGeometry( 20, 20, 20 );
   	geometry.computeTangents()

计算光照

有了 TBN 变换矩阵之后就可以世界空间的坐标变换到切线空间,或者相反。那么这里就会有两种方法计算光照,一是把采样得到的切线空间的法线变换到世界空间,然后在和世界空间的光照方向计算。第二种是直接用 TBN 矩阵的逆矩阵把世界光照方向变换到切线空间中,这样计算出来的结果也是正确的。但是第二种的性能明显是会比第一种好的,第一种需要在片元着色器把每次采样出来的法向都左乘 TBN 矩阵变换到世界空间。

    //顶点着色器
	vTBN = mat3(vt, vb, normalize(TranfromNormal));

    //片元着色器
	vec3 texN = normalize(texture2D(uNormalTex, vUv).xyz * 2.0 - 1.0);;
	vec3 worldNormal = normalize( vTBN * texN );
    float nDotL = max(0.0, dot(worldNormal, vlightDirection));

但是第二种的话我们只需要在顶点着色器把世家空间的光照左乘 TBN 的逆矩阵,变换到切线空间,在把切线空间的光照用 varying 传给片元着色器即可。一般来说顶点着色器的计算会比片元着色器少得多。

    //顶点着色器
	vTBN = mat3(vt, vb, normalize(TranfromNormal));
    //因为TBN矩阵为正交矩阵,正交矩阵的转置和逆矩阵相同,同时转置的计算会比逆矩阵简单
	vTBN = transposeMat3(vTBN);
	vlightDirection = normalize( vTBN * vLightPosition);

    //片元着色器
    vec3 texN = normalize(texture2D(uNormalTex, vUv).xyz * 2.0 - 1.0);;
	float nDotL = max(0.0, dot(texN, vlightDirection));

最后看看在 three 实现的效果。这里可以找到该案例

10.png