【公式推导03】光照向量
[!NOTE] 约定
本文用列向量/右手坐标系进行说明以及推导。
若需转换到行向量/左手坐标系,需要对变换矩阵做转置
法线
法线分类
当一个模型有很多凹凸细节时,模型的顶点网格会很密(数据量大),随之而来的就是性能问题。模型凹凸对应的是顶点的高度偏移,高度偏移影响的是法线方向,因而我们视觉上看到模型的凹凸(光影计算)其实只和模型法线有关,因此可以修改模型原本的法线信息,让模型看起来有凹凸,但是实际却是网格稀疏的低面几何体。
- 如下图,左侧是一个面很少,并不平滑的几何体,用原法线计算光照后会看到平面(红色线部分),右侧是将几何体原本的法线信息经过插值处理,经光照计算后,就会看起来像是平滑(凸出)过渡的曲面(浅紫色部分)。
四种法线:
- 顶点法线 (vertex normal):模型自带的信息
- 面法线 (face normal ) :很容易算,三角形两个边的向量进行叉积即可得到垂直于该面的法向量。
- 插值法线 (interpolated normal):用 vertex normal 经过插值得到的逐像素的法线
- 平均顶点法线(averaged normal):
实际工作中,模型有很多面,顶点被多个面共用,如何确定这个顶点的 vertex normal 使得后续多个面插值得到的插值法线更平滑?
运用一种被称为求顶点法线平均值 ( vertex normal averaging ) 的计算方法。此方法通过对网格中共享顶点 v 的多边形的平面法线求取平均值,从而获得网各中任意顶点 v 处的顶点法线 n。例如,图中网格中的四个多边形共用顶点 v,因此, v 处的顶点法线求法如下:
$$
\boldsymbol{n}_{\mathrm{avg}}=\frac{\boldsymbol{n}_0+\boldsymbol{n}_1+\boldsymbol{n}_2+\boldsymbol{n}_3}{\left|\boldsymbol{n}_0+\boldsymbol{n}_1+\boldsymbol{n}_2+\boldsymbol{n}_3\right|}
$$
为了得到更为精准的结果,我们还可以采用更加复杂的求平均值方法,比如说,根据多边形的面积来确定权重(如面积大的多边形的权重要大于面积小的多边形),以求取加权平均值。
法线变换
法向量的变换比较特殊,简单来说就是法向量左乘模型变换矩阵(模型空间到世界空间)的逆转置矩阵,这样可以在非统一变换中保持法线和切线垂直。
Unity shader 中的法线变换
[!NOTE] Title
总结:两种方法将法线从模型空间转换到世界空间
- 使用模型变换矩阵的逆转置矩阵对法线进行变换,首先的得到模型空间到世界空间的逆矩阵
unity_WorldToObject
,然后通过调换他在mul
函数中的位置得到和转置矩阵相同的矩阵乘法。由于法线是一个三维矢量,所以需要用(float3x3)截取。o.worldNormal = mul (v.normal, (float3x3) unity_WorldToObject);
- 直接使用 unity 内置函数
o.worldNormal = UnityObjectToWorldNormal (v.normal);
在做 blinn-phong 光照计算时,法线信息必不可少。而在 shader 中,可以得到 app 传入 vertex shader 的模型空间的的法线信息(一个顶点含有一个法向量),但是计算时往往需要将法线从模型空间转换为世界空间,同时模型顶点的坐标也需要转换到世界空间,只有坐标空间一致才能进行计算。
对比顶点与法线的空间变换会产生疑问:
1 | o.worldNormal = mul(v.normal,(float3x3)unity_WorldToObject);//为什么这个是右乘矩阵?为什么要(float3x3) |
- 问题 1 :为什么法线做变换时,需要用 (float3x3)来取得变换矩阵的前三行三列?
产生这种差别的原因是法线和切线都是方向向量(只有方向,会进行旋转和缩放变换,而不考虑平移的影响),而顶点是空间中的位置。同时注意法线用于计算最终是要 normalize 的,长度是 1。因此法线变换是需要取变换矩阵的 (float3x3),最后一列是平移变换部分,舍弃。
- **问题 2 :为什么同样是模型空间转换到世界空间,法线用的是 unity_WorldToObject 变换矩阵,且是矩阵右乘向量,而顶点用的是 unity_ObjectToWorld 矩阵,且是矩阵左乘向量?
从上述矩阵中看出,前三行列包含了缩放变换,当向量进行不成比例的缩放时,就会影响到向量的方向:法线将不会与几何体表面垂直(顶点的法向量是该顶点所在几个表面法向量的平均值)。如何才能让法线进行变换仍能保持正确的方向?
此时要对法向量进行正确的变换, 应该是对其进行相反的缩放 (比如 xy 是 1: 2 缩放, 那么法向量 xy 就要2:1缩放。
所以法向量的变换矩阵中缩放矩阵应该是原来顶点变换缩放矩阵的逆矩阵,而法向量的旋转矩阵保持不变。
由于旋转矩阵是正交矩阵,因此旋转矩阵的转置=逆矩阵。而缩放矩阵不是正交矩阵,因此无法通过转置求得逆矩阵,但缩放矩阵是对角矩阵,转置后不会发生改变。因此可以这样做:
对 unity_ObjectToWorld 求逆矩阵得到 unity_WorldToObject(同时得到旋转和缩放矩阵的逆),再做转置。这样我们就得到了旋转矩阵和缩放矩阵的逆转置,保持了旋转矩阵不变,同时得到了缩放矩阵的逆。
在 Unity shader 计算法线向量有以下两种等价写法
本质原因mul 函数有两种形式,改变了向量的行列形式:
mul(v, M)
:行向量 v 乘以矩阵 Mmul(M, v)
:矩阵乘以列向量 v
还有一个重要的数学推导:列向量 v 左乘变换矩阵 M 等于行向量 v 右乘变换矩阵 M 的转置
[!NOTE]
注:unity_ObjectToWorld 是一个点从模型空间转换到世界空间的变换矩阵,unity_WorldToObject 则相反,两者是逆变换,因此两个矩阵互为逆矩阵。
法线变换矩阵公式推导
前面是用具象的理解得到矩阵,如何更加严谨地得出法线的正确变换矩阵呢?——> 利用切线进行公式推导
那么,应该使用哪个矩阵来变换法线呢? 我们可以由数学约束条件来推出这个矩阵。我们知道**同一个顶点的切线 $T_A$ 和法线 $N_A$ 必须满足垂直条件,即 $T_A·N_A=0$**。给定变换矩阵 $M_{A\rightarrow B}$,我们已经知道 $T_B=M_{A\rightarrow B}T_A$。我们现在想要找到一个矩阵 $G$ 来变换法线 $N_A$,使得变换后的法线仍然与切线垂直。即
$$
\mathrm{T}B\cdot\mathrm{N}B=(M{A\rightarrow B}\mathrm{T}A)\cdot(G\mathrm{N}A)=0
$$
对上式进行一些推导后可得:
$$
(M{A\to B}\mathbf{T}{A})\cdot(G\mathbf{N}{A})=(M_{A\to B}\mathbf{T}{A})^{\top}(G\mathbf{N}{A})=\mathbf{T}{A}^{\top}M{A\to B}^{\top}G\mathbf{N}{A}=\mathbf{T}{A}^{\top}(M_{A\to B}^{\top}G)\mathbf{N}_{A}=0\quad
$$
为什么 $(M_{A\to B}\mathbf{T}{A})\cdot (G\mathbf{N}{A})=(M_{A\to B}\mathbf{T}{A})^{\top}(G\mathbf{N}{A})$?
因为第一个公式是矢量点乘,可以注意到中间有个黑点。然后为了推导到矩阵里面,因此把矢量运算替换成了等价的矩阵乘法,因为默认矢量可以当成是列矩阵,所以第二个公式就是把它们当成列矩阵进行矩阵乘法。转置是因为矩阵乘法有行列规则,不转置的话这个乘法是没法进行的,不符合矩阵乘法定义。你可以在纸上画一画,第二个那个转置矩阵乘法后也是一个标量,和第一个矢量点乘的结果是相同的。
例如: $\begin{pmatrix}2\3\end{pmatrix}\cdot\begin{pmatrix}1\2\end{pmatrix}=21+23=\begin{bmatrix}2\3\end{bmatrix}^T\begin{bmatrix}1\2\end{bmatrix}=8$
由于 $T_A\cdot N_A=0$,因此如果 $M_{A\to B}^{\top}G=E$,那么上式既可成立。
也就是说,如果 $G=\left(M_{A\to B}^{\top}\right)^{-1}=\left(M_{A\to B}^{-1}\right)^{\top}$,即使用原变换矩阵的逆转置矩阵来变换法线就可以得到正确的结果。
三种情况:
- 如果变换只包括旋转变换,那么变换矩阵 $M_{A\rightarrow B}$ 就是正交矩阵, 那么 $M_{A\to B}^{-1}=M_{A\to B}^{\top}$,因此 $\left(M_{A\to B}^{\top}\right)^{-1}=M_{A\to B}$ ,也就是说我们可以使用用于变换顶点的变换矩阵来直接变换法线。
- 如果变换只包含旋转和统一缩放,而不包含非统一缩放,我们利用统一缩放系数 $k$ 来得到变换矩阵 $M_{A\rightarrow B}$ 的逆转置矩阵 $\left(M_{A\rightarrow B}^{T}\right)^{-1}=\frac{1}{k}M_{A\rightarrow B}$ 。这样就可以避免计算逆矩阵的过程。
- 如果变换中包含了非统一变换,那么我们就必须要求解逆矩阵来得到变换法线的矩阵。
公式推导中提到了切线与法线不变的垂直关系,由此引申出图形学中十分重要的知识点:切线空间
切线空间
切线空间中的法线
纹理贴在物体上的坐标,与纹理空间的区别就是多了一个法线轴,数据变为了三维。
法线是向量,向量则必然需要指定坐标系/坐标空间,可以用模型空间、世界空间、切线空间来表示。模型和世界空间大家一定很熟悉,那么什么是切线空间呢?
一个坐标空间就是一个坐标系,只要有三个正交坐标轴 xyz 作为基轴就可以定义坐标空间中任意一点。切线空间是位于三角形表面之上的空间,切线空间中的 xyz 轴分别是 t 轴(切线方向)、b 轴(副切线方向)和 n(法线方向) 轴。
确定的一点是:切线空间的 z 轴(向上的轴)与法线 n 同向。那么 x 与 y 轴要如何定义呢?
我们都知道,一条曲线上某点的切线只有一条,而一个曲面上某点却有一个切平面,该平面上过该点的任意一条线都是该点的切线,所以切线有无数条。有多种不同的 x 和 y 轴组合, 每种组合生成的点法向量是不一致的, 所以需要规定一套固定的 x 和 y 轴, 大家遵守同样的规则,建模软件在计算切线的时候会选择和 UV 展开方向相同的那个方向作为切线方向。
根据 [[03 光照向量#UV 与 TB 的关系]] 中的证明,在 TBN 推导进行最后的正交化之前,T 的方向对应 u 的方向,B 对应 V 的方向。正交化之后则不一定
法线贴图 Normal Map
计算法线贴图(二维)
下图中红色线为原始的表面,蓝色曲线为使用法线贴图后的效果。,我们讨论平面上点 $p$ 的法线
- $p$ 点原来的法线朝上,即 $n(p) = (0, 1)$
- 通过求 $p$ 点的导数 $dp = c*[h(p+1) - h(p)]$ 求出两点的高度差。其中 c 为常数表示凹凸贴图的影响程度,h 为高度 p 点、p+1 点对应高度。
- 因此切线可表示为 $t(p)=(1,dp)$。
- 切线与法线垂直,因此 $n(p) = (-dp, 1)$, $n(p)$ 要归一化
计算法线贴图(三维)
三维的情况下有 u、v 两个方向的变换。
实际情况下法线方向不一定朝上,这里的例子是基于一个局部坐标系确定的。
- $p$ 点原来法线为: $n(p) = (0, 0,1)$
- 求 $p$ 点的倒数:
- $dp/du = c1 * [h(u+1) - h(u)]$
- $dp/dv = c2 * [h(v+1) - h(v)]$
- $n(p) = (-dp/du, -dp/dv, 1)$, $n(p)$ 要归一化
注意以上计算发生在模型空间。
法线贴图的存储方式
插值就可以得到平滑过渡,那么对 vertex normal 做各种各样的偏移,得到用于计算的 interpolated normal,就能得到各种凹凸起伏的光影结果。偏移是相对于原法线信息的偏移,因此以原法线方向作为 z 轴正方向,定义一个切线空间,并在切线空间中用三个坐标值就可以表示出偏移后的法线信息,用贴图存储模型空间与切线空间的法线数据,这个贴图叫做法线贴图:
[!NOTE] 模型空间和切线空间的法线贴图
![]()
左图是模型空间的法线贴图,右图是切线空间的法线贴图。使用右图需要使用 TBN 矩阵将贴图中提取出的法线从切线空间变换到计算光照使用的空间。
左图是模型空间,因法线方向各不相同,所以看起来五颜六色的;
右图是切线空间,每个法线方向所在的坐标空间是不一样的,即是表面每点各自的切线空间,这种法线纹理其实存储了每个点在各自的切线空间中的 shading 法线偏移(扰动)方向,如果一个点的法线方向不变,那么在其切线空间中,新的法线方向就是 z 轴方向。即值为(0,0,1),映射到颜色即(0.5,0.5,1)浅蓝色。蓝色说明顶点的大部分 shader 法线和模型本身法线一样,无需改变
[!tip] 模型空间与切线空间存储 shading normal 的优缺点对比
模型空间存储:
- 优点:简单直观
- 问题:当模型有顶点变化时(如骨骼动画),则面片都变化了,此时无法计算新的法线。(法线应当随着面片变化同样变化)
切线空间存储有更多优势,我们常用切线空间的法线贴图:
- 切线空间存储的是相对法线信息,因此换个网格(或者网格变换 deforming)应用该纹理,也能得到合理的结果。
- 可以进行 uv 动画,通过移动该纹理的 uv 坐标实现凹凸移动的效果
- 可以重用法线纹理,比如, 一个砖块, 我们仅使用一张法线纹理就可以用到所有的 6 个面。
- 可以压缩:[[#法线贴图的压缩]]。因为切线空间的法线 z 方向总是正方向,因此可以仅存储 xy(rg) 方向,从而推到 z (b) 方向(normal 是单位向量,用勾股定理由 xy 得出 z,取 z 为正的一个即可)。而模型空间的法线纹理方向各异,无法压缩。
利用法线贴图计算光照就可以实现法线映射 Normal Mapping,法线映射与 Phong shading 的主要差别在哪呢?
在于我们掌握信息的多少。在 Phong shading 时,用到三角形每个顶点的法线(插值获取其内像素的法线),而法线映射使用法线贴图纹理能提供相当多的法线(多到每个像素都有对应法线,而无需插值获取),显著改善渲染细节。
读取法线贴图数据
切线空间中法线的每个分量的值的范围是 $[−1,1]$,而 RGB 分量的值的范围是 $[0,1]$ 。所以,在将法线存储为 RGB 图像时,需要对每个分量做一个映射,所得的 RGB 图像呈蓝紫色:
1 | vec3 rgb_normal = (normal + 1)/ 2; |
这里要注意,将法线存储到法线贴图的过程中,需要进行上述操作。当我们从法线贴图中读取法线数据后,需要进行重映射,即上述变换的逆变换,即从$[0,1]$ 映射到 $[−1,1]$ 。
1 | normal = 2 * vec3rgb_normal - 1 |
切线空间中的法线是相对于真实法线的偏移(又称扰动):
法线贴图的压缩
法线贴图的压缩,DXT compression 压缩技术。 (unity 和 ue 会自动做此事,我们需要的是正确读取法线贴图中的数据)
- 只保留 rg 两个颜色通道
因为 normal 是归一化向量,其大小为 1;又因为切线空间的法线 z 方向总是正方向,所以可以只存 x 和 y 就可以用勾股定理计算出 z 值(因为正方向所以取正值)。所以只保存 rg 两个通道。 - 但是如果只有一个通道那向量压缩质量更好。所以只用 g 通道,并将r 通道的值存入alpha 透明通道
Z 计算方法为:${Z = sqrt(1 - (x x + yy))}$
利用法线贴图的信息计算模型光照
首先要从贴图中提取出法线信息:先映射范围,所以采样纹理后,也要从像素先计算出真正的法线值才行。如果纹理是经过了压缩的,则还要再加一步,提取出真正的 pixel normal 值。
要做基于法线的光照计算,在同样的坐标空间中点乘什么的才有意义。一般利用计算光照时,有 3 条路:
- 在世界空间中计算:将变量(顶点位置、光照方向、法线方向等)转换到世界空间运算。
- 在模型空间中计算:将同上变量转换到模型空间运算。
- 在切线空间中计算。
而从一个坐标空间变换到另一个坐标空间需要一个变换矩阵,如同 unity 中的 unity_WorldToObject 和 unity_ObjectToWorld 矩阵一样,用来在世界空间和模型空间之间变换。因此此处的变换则需要TBN 矩阵,将从贴图中提取出的pixel normal 变换到世界空间或模型空间。
TBN 矩阵
TBN 矩阵定义了一个点从切线空间变换到世界空间或者模型空间的变换矩阵:当 N 是模型空间中的法线时,计算出的 TBN 则是 TBN_Object,该矩阵对贴图中取得的 pixel normal 数据做变换后,会得到模型空间的 final shading normal 。同理如果 N 是世界空间中的法线时,计算出的 TBN 则是 TBN_World,做变换会得到世界空间中的 final shading normal 。
- 两种 TBN 矩阵的计算流程示意(下图):中间的绿色线条上部属于顶点着色器中的内容(法线、切线都是模型空间中的)、绿色线条下部属于像素着色器中的内容(法线、切线都是在顶点着色器中变换到世界空间,传入像素着色器计算)。由于使用 TBN_World 的方法在片段着色器中计算量更小,因此常常用 TBN_World 做计算(尽管顶点着色器中计算量大,但通常顶点着色器通常比片段着色器运行的少)。
注意区分:TBN 中的 N,则是指的是,模型顶点的真实法线。而 matrix(T,B,N)* n
的这个 n
,才是从法线贴图中还原出来的,通过贴图像素值计算的法线。 final shading normal =matrix(T,B,N)* n
;的这个 final shading normal 是转换到正确空间后的最终用于光照计算的法线。
- @ (final shading normal、pixel normal 、TBN_World、TBN_Obect 都是我自创的用于理解的名字而已,无特殊含义)
**在切线空间中计算:将光照方向、视角方向在 Vertex Shader 里面从模型空间变换到切线空间去计算光照,这样更省,可以不必再 Fragment Shader 里面进行更耗时的计算。从模型空间变换到切线空间的矩阵如何得到呢?
我们前面已经知道了从切线空间->模型空间的矩阵(即用模型空间中的法线计算 TBN_Object)。因此模型空间转换到切线空间的矩阵是TBN_Object 的逆矩阵,此处冯女神给出了求逆矩阵的算法:
TBN 推导
$$
\begin{bmatrix}t_x&b_x&n_x\ t_y&b_y&n_y\ t_z&b_z&n_z\end{bmatrix}\begin{bmatrix}v_x\ v_y\ v_z\end{bmatrix}=\begin{bmatrix}t_x v_x+b_x v_y+n_x v_z\ t_y v_x+b_y v_y+n_y v_z\ t_z v_x+b_z v_y+n_z v_z\end{bmatrix}
$$
我们可以根据纹理坐标和模型坐标求出 TB
这张图描述了模型中每个三角形所在的纹理坐标系与切线空间坐标系的联系。
对于模型中每一个三角形的顶点,我们有其位置坐标以及纹理坐标,这个技术就要通过这两组坐标得到 TBN 矩阵。
如图对于每个三角形在切线中的两个边向量用 $E_1$ 和 $E_2$ (对应图中为 $e_0e_1$)来表示:
$$
\begin{array}{c}E_1=\Delta U_1T+\Delta V_1B\ \ E_2=\Delta U_2T+\Delta V_2B\end{array}
$$
使用矩阵来表示:
$$
\begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\ E_{2x}&E_{2y}&E_{2z}\end{bmatrix}=\begin{bmatrix}\Delta U_1&\Delta V_1\ \Delta U_2&\Delta V_2\end{bmatrix}\begin{bmatrix}T_x&T_y&T_z\ B_x&B_y&B_z\end{bmatrix}
$$
继而推导出:
$$
\begin{bmatrix}T_x&T_y&T_z\ B_x&B_y&B_z\end{bmatrix}=\frac{1}{\Delta U_1\Delta V_2-\Delta U_2\Delta V_1}\begin{bmatrix}\Delta V_2&-\Delta V_1\ -\Delta U_2&\Delta U_1\end{bmatrix}\begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\ E_{2x}&E_{2y}&E_{2z}\end{bmatrix}
$$
以上就是在一个三角形中,通过顶点坐标与纹理坐标获得 $T, B$ 向量的过程。
注意:这里解出的 $T$ 或者 $B$ 不是最终需要的,$T$ 向量需要进一步的与 $N$ 向量正交化,所以我们只需要解出 $T$,然后通过$B = cross(N,T)$,这才是最终的 $B$。有了 $T, B$ 就可以计算得到 $N$,从而构建出 $TBN$ 矩阵。
MikktSpace
MikktsSpace 是作为一中 TBN 计算标准,用于解决软件之间切线空间计算不统一。目
关于切线空间法线贴图的一个常见误解是,这种表示在某种程度上与资产无关。然而,从高分辨率曲面采样/捕捉然后变换到切线空间的法线更像是一种编码。因此,为了反转原始捕获的法线场,用于解码的变换必须与用于编码的变换完全相反。
这带来了一个问题,因为没有切线空间生成的实现标准。每个法线贴图烘焙器都使用不同的实现,此外,对于如何使用插值帧将法线转换为切线空间,没有标准。
由于法线贴图烘焙器和用于渲染的像素着色器之间的这种不匹配而产生的数学错误会导致对接缝进行着色。这些是不需要的硬边,当模型被照亮/着色时,这些硬边会变得可见。
更糟糕的是,仅仅使用相同的切线空间生成代码是不够的。大多数实现都具有顺序依赖性,这可能导致不同的切线空间,这取决于面的顺序或顶点的顺序。如果索引列表发生更改(多个/单个/重复顶点),则其他索引列表会生成不同的结果。如果去除非法的基元,其他基元会再次产生不同的结果。
顺序依赖关系还导致镜像网格不总是获得正确的镜像切线空间。
解决方案
Morten S.Mikkelsen 的切线空间生成码克服了这些问题。具体而言,该实现旨在使切线空间的生成对从一个应用程序移动到另一个应用的3D 模型尽可能具有弹性。也就是说,即使索引列表、面/顶点的排序和/或非法的基元的移除发生变化,也会生成相同的切线空间。同时支持三角形和四边形。
这个实现是由 Morten S.Mikkelsen 在他的硕士论文开发过程中完成的,任何人都可以免费使用。它包含在两个独立的文件 mikktspace.h和 mikktspace.c 中。这使得任何人都可以轻松地将实现集成到自己的应用程序中,从而复制相同的切线空间。这也使得代码成为实现标准的完美候选者。我们希望该标准将被尽可能多的开发人员采用。
The standard is used in Blender 2.57 and is also used by default in xNormal since version 3.17.5 in the form of a built-in tangent space plugin (binary and code).
该标准在Blender 2.57中使用,自3.17.5版本以来,xNormal也默认使用该标准,形式为内置切线空间插件(二进制和代码)。
仍存在的问题
Even though mikktspace supports quads these will eventually be split into triangles before baking and pixel shading. Furthermore, though mikktspace guarantees a consistent choice of tangent frame, at the quad vertices, the interpolated tangent frame is heavily affected by which diagonal split is chosen later on. This is not a big issue when the tangent frame transitions slowly across the face. For a well-behaved low polygonal mesh this is true for most regions.
尽管mikktspace支持四边形,但在烘焙和像素着色之前,这些四边形最终会被分割成三角形。此外,尽管mikktspace保证了切线框架的一致选择,但在四边形顶点处,插值的切线框架会受到稍后选择的对角线分割的严重影响。当切线框架在面上缓慢过渡时,这不是一个大问题。对于表现良好的低多边形网格,大多数区域都是如此。
When there is a great change in the tangent frame a problem occurs when the tools pipeline (prior to pixel shading) and the baker do not choose the same diagonal split. One possible solution is to choose your split based on an order-independent strategy. For instance, split all quads by the shortest diagonal using the vertex positions. If these have the same length then split by the shortest diagonal using the texture coordinates. This will lead to an order-independent choice and works with mirrored meshes. The triangulator in xNormal has supported this since 3.17.5 and triangulation is performed after the mikktspace plugin is done. Reproducing the same splits in your own tools pipeline is trivial to do.
当切线框架发生巨大变化时,当工具管道(在像素着色之前)和面包师没有选择相同的对角线分割时,就会出现问题。一个可能的解决方案是根据订单独立策略选择拆分。例如,使用顶点位置按最短对角线分割所有四边形。如果这些具有相同的长度,则使用纹理坐标按最短对角线分割。这将导致一个与顺序无关的选择,并适用于镜像网格。xNormal中的三角测量器从3.17.5开始就支持这一点,并且三角测量是在mikktspace插件完成后执行的。在您自己的工具管道中复制相同的分割是微不足道的。
Another more trivial solution which any artist can perform on his/her own is to simply triangulate the model before baking and export which will also ensure consistent triangulation. However, this way the quad information is lost which may or may not be of any importance depending on the game engine and how the asset is to be used.
任何艺术家都可以自己完成的另一个更琐碎的解决方案是在烘焙和导出之前简单地对模型进行三角测量,这也将确保一致的三角测量。然而,通过这种方式,四元信息会丢失,根据游戏引擎和资产的使用方式,这可能具有任何重要性,也可能不具有任何重要性。
We stress that in most cases this is NOT a visible issue. But in the few cases where it is a problem this section explains why and how to deal with it.
我们强调,在大多数情况下,这不是一个明显的问题。但在少数情况下,这是一个问题,本节解释了为什么以及如何处理它。
在 unity shader 中的 TBN 矩阵?
untiy 中我们可以直接拿到 tangent,进而与 normal 叉乘得到 bitangent,轻松构建出 TBN 矩阵。
unity shader 中,模型的顶点数据(包括顶点位置、顶点法线、uv 坐标、切线矢量 tangent)通过 appdata 传给顶点着色器,在顶点结构中通过语义获得,语义 TANGENT 标示切线数据(如:float4 tangent : TANGENT,tangent 变量存储了切线矢量数据)。
TANGENT 矢量是切线矢量,是四维向量,由 xyzw 四个分量。**v.tangent. xyz 是切线方向,即 tbn 中的 t;v.tangent. w 的值为+1 或者-1,w 分量代表了什么呢?
在 Unity 中,只有切线矢量 t 存储在顶点中,而副切线 b(也称双切线或副法线)是从读取的法线值和该切线值做叉乘得出,叉乘能得到垂直于 t 与 n 的矢量 b。 前面说过平面上某点的切线有无数的方向,一般模型会用模型 uv 展开相同的方向作为切线方向,因此 uv 方向(即切线的选择)就影响到了叉乘的结果 b 的方向,因此 w 会再存储一个信息。叉乘的结果会得到一个方向,w 分量又进一步决定了取叉乘结果的正方形还是反方向。
1 | //计算副切线 |
- 世界空间下的 TBN_World 的计算:
1 | // Construct a matrix that transforms a point/vector from tangent space to world space |
注:上述代码中此处用了 unity 内置计算方向的函数UnityObjectToWorldNormal,因此兼容模型非均匀缩放的情况。如果不用内置函数,则要注意向量计算时模型非均匀缩放的问题了。
疑难解析
Tangent 轴的方向与 u 轴方向相同,Bitangent 轴的方向与 v 轴方向相同?
Tangent、Bitangent 轴只在正交化前与 UV 轴方向分别相同,因此这句话一半对,一半错。对于规范的 TBN 矩阵而言,TB 二轴与 UV 轴方向极大多数情况很可能并不相同。当顶点法线在建模软件中被修改为不垂直于该面片时,TB 平面甚至不在三角形面片上;当 uv 经过拉伸时,因 TB 二轴在正交化前与 UV 方向相同,故正交化后 TB 二轴必然与 uv 方向是有所偏差的,而对于一个多面片的模型 uv 展开,这种拉伸旋转的情况简直司空见惯。uv 二轴在三维面片上的映射向量相互垂直,对吗?
根据下图,uv 二轴在三维空间下的方向很可能不垂直。切线空间 tbn 中的 N(Normal)轴是怎么得到的,其与 uv 二轴在三维面片上的映射方向的向量是垂直吗?
切线空间的 N 轴就是顶点法线。(在此提及一点,建模软件中但凡是出现单个顶点包含多条法线(split vertex normal)的,在实际导出后,都是变为多个重合顶点,一个顶点只有一条法线,这是任何时候都不会改变的事实。) uv 二轴在三维平面的投影方向就是三角面片所在的平面,n 轴并不一定与其垂直。顶点法线可以直接根据相邻面的法线取平均得到,对吗?
这个属于常见带有误导色彩的言论,在此特意申明,顶点法线爱怎么调整怎么调整,与面法线并无关联。面法线只是垂直于面的一条向量,规定了面的正反,而顶点法线才是用于光照信息的处理。即便是建模软件中,顶点法线的最初是的默认情况也并非是面法线的平均,只有当在建模软件中对物体进行了平滑着色后,才会根据面法线平均得到顶点法线(如 blender 中为 shader smooth 命令)。这其中的逻辑不要弄混。
UV 与 TB 的关系
我们知道,一个顶点结构中包含很多信息,其中就有顶点在三维空间中的位置信息,以及顶点在展开的 uv 中的 uv 坐标,且三维空间下三角形中的每一个点都必能在 uv 上找到对应的点,反之亦然。下图是三角形的 uv 展开图以及其俯视图,我们可以很容易的在三维空间与 uv 空间下对任意一个点进行映射,因为该 uv 没有任何拉伸旋转,甚至可以说是标准完美得过分了。
若设 A(x1,y1,z1),uv 坐标为(u1,v1)、B(x2,y2,z2),uv 坐标为(u2,v2)、C(x3,y3,z3),uv 坐标为(u3,v3)。且将向量 AC 记录为 E1、向量 AB 记录为 E2,则对于这条公式而言,
在这种特殊情况下,此时的 T 显然恰好与 U 在三维空间上的映射方向相同、同理 B 也与 V 方向相同。直观上,该公式描述的数学意义是,如何将一个点从 uv 空间映射到三维空间,其中 TB 作为基矢,以 uv 空间中的 u 和 v 的增长作为控制参数。假设三角形中存在一点 P,则向量 AP=u(p)T+v(p)B,只要知道 P 点的 uv 坐标值,即可得到 P 点的三维坐标值。
如果我们在三维空间下将三角形做一点拉伸,变成如下图所示,则 UV 向量在三维空间的方向将不再垂直,但是有 Color Grid 图的帮助,我们依然可以很方便的辨认出 uv 映射的位置。
不过在这种情况下,向量 T 与 B 依然会与 UV 保持方向相同吗?答案是肯定的,将上述式子进行简单求解,可看到 T 与 B 的表达式。为了直观判断,这里笔者将该物体导入 unity,通过 geometry shader 进行可视化生成该方式计算的 TB 向量,为了连贯性,代码与步骤将放在附录 1 中提供。
代码中的 TB 向量严格遵守上述推导结果,下图将计算出来的 T 以蓝线表示,B 以切线表示,可以看到该公式计算出来的 T 与 B 确实与 uv 方向相同。
目前算出来的 TB 还不是真正的切线与副切线,需要经过正交化得到 TBN 矩阵,其中的计算规范由下图给出:
其中 n 是建模软件中规定的顶点法线,可以看到 n 在正交化过程中不会受到影响,该过程是对 TB 向量进行方向的调整以及长度的归一化。TB 在此过程后会相互垂直,此时将不再一定与 UV 方向保持相同。特别的,当调整顶点法线后,TB 平面甚至将于三维空间中的三角形平面不同,形成的切线空间实际上是不够直观的。(值得一提的是,在 npr 渲染中,法线的更改是十分常见的,没事更改顶点法线并非太闲。)
光的散射(scattering)
- ? 区分?
透射 (Transmission)
反射 (Reflection)
折射 (Refraction)
衍射 (Diffraction)
吸收 (Absorption)
散射 (Scattering)
光线由光源发射出来后,就会与一些物体相交。通常,相交的结果有两个:散射 (scattering)和吸收(absorption)
散射只改变光线的方向,但不改变光线的密度和颜色,而吸收只改变光线的密度和颜色。
光线在物体表面经过散射后,有两种方向:
- 一种将会散射到物体内部,这种现象被称为折射 (refraction) 或透射 (transmission)
- 另一种将会散射到外部,这种现象被称为反射 (reflection) 。
对于不透明物体,折射进入物体内部的光线还会继续与内部的颗粒进行相交,其中一些光线最后会重新发射出物体表面,而另一些则被物体吸收。那些从物体表面重新发射出的光线将具有和入射光线不同的方向分布和颜色。
反射
[!NOTE]
$i$:光源指向目标方向(HLSL 中reflect(i, n)
) 函数是该情况)
$$r=i-2(i\cdot n)n$$
$l$:目标指向光源方向
$$r=2(l\cdot n)n-l$$
$i$ 入射向量
$r$ 反射向量
将向量 $v$ 在界面法线 $n$ 方向上的投影记为 $v_\perp$,在界面方向上的投影记为 $v_{||}$。
根据反射定律,反射角等于入射角:$\theta_i=\theta_r$
因为
$$
\begin{array}{l}i_{\perp}=-|i_{\perp}|n=(i\cdot n)n\ i_{||}=i-i_{\perp}\end{array}
$$
然后因为 $r_{||}$ 是 $i_{||}$ 的等向量, $r_⊥$ 是 $i_⊥$ 的反向量
$$
r=r_{||}+r_{\perp}=i_{||}-i_{\perp}=i-2i_{\perp}=i-2(i\cdot n)n
$$
注意这里计算使用的 $i$ 是光源指向目标,在光照计算时我们通常使用 $i$ 相反方向的向量 $l$:从目标指向光源
此时的反射向量计算结果为:
$$r=2(l\cdot n)n-l$$
折射
[!NOTE]
$$r=\begin{cases}\frac{\eta_1}{\eta_2}i-(\frac{\eta_1}{\eta_2}(i\cdot n)+\sqrt{1-(\frac{\eta_1}{\eta_2})^2(1-(i\cdot n)^2)})n&\text{if}:1-(\frac{\eta_1}{\eta_2})^2(1-(i\cdot n)^2)\ge0\ 0&\text{if}:1-(\frac{\eta_1}{\eta_2})^2(1-(i\cdot n)^2)<0\end{cases}$$
根据斯涅尔定律(Snell’s Law,又称从折射定律),介质折射率和正弦的乘积一定相等。
$$
\eta_{1}sin\theta_{i}=\eta_{2}sin\theta_{r}
$$
因 $r_{||}$ 和 $i_{||}$ 平行且指向相同方向
$$
r_{||}=\dfrac{|r_{||}|}{|i_{||}|}i_{||}=\dfrac{sin\theta_r}{sin\theta_i}i_{||}=\dfrac{\eta_1}{\eta_2}i_{||}=\dfrac{\eta_1}{\eta_2}(i+cos\theta_i n))\qquad(1)
$$
根据勾股定理
$$
|r_\perp|=\sqrt{|r|^2-|r_||^2}=\sqrt{1-sin^2\theta_r}
$$
所以
$$
r_{\perp}=-|r_{\perp}|n=-\sqrt{1-sin^2\theta_r}n
$$
再根据斯涅尔定律
$$
sin^2\theta_r=(\dfrac{\eta_1}{\eta_2})^2sin^2\theta_i=(\dfrac{\eta_1}{\eta_2})^2(1-cos^2\theta_i)
$$
代入得
$$
r_{\perp}=-\sqrt{1-(\dfrac{\eta_1}{\eta_2})^2(1-cos^2\theta_i)}n\quad\quad\quad(2)
$$
将式子 $(1)$ 和式子 $(2)$ 代入
$$
\begin{aligned}r&=r_{||}+r_{\perp}\ &=\frac{\eta_1}{\eta_2}(i+cos\theta_in)-\sqrt{1-(\frac{\eta_1}{\eta_2})^2(1-cos^2\theta_i)}n\ &=\frac{\eta_1}{\eta_2}i+(\frac{\eta_1}{\eta_2}cos\theta_i-\sqrt{1-(\frac{\eta_1}{\eta_2})^2(1-cos^2\theta_i)})n\end{aligned}
$$
最后因为
$$
cos\theta_i=-i\cdot n
$$
得到
$$
r=\dfrac{\eta_1}{\eta_2}i-(\dfrac{\eta_1}{\eta_2}(i\cdot n)+\sqrt{1-(\dfrac{\eta_1}{\eta_2})^2(1-(i\cdot n)^2)})n
$$
需要注意的是,上述式子在根号内的值大于等于 0 时才成立。当 $\eta_1\leqslant\eta_2$ 时,根号内始终不小于 0。当 $\eta_1>\eta_2$ 时,则在特定入射角度(超过临界角)会发生全内反射的现象。此时折射分量为 0。
$$
r=\begin{cases}\frac{\eta_1}{\eta_2}i-(\frac{\eta_1}{\eta_2}(i\cdot n)+\sqrt{1-(\frac{\eta_1}{\eta_2})^2(1-(i\cdot n)^2)})n&\text{if}:1-(\frac{\eta_1}{\eta_2})^2(1-(i\cdot n)^2)\ge0\ 0&\text{if}:1-(\frac{\eta_1}{\eta_2})^2(1-(i\cdot n)^2)<0\end{cases}
$$
菲涅尔方程(Fresnel)
从Lambert模型到PBR模型6:推导镜面反射和漫反射的菲涅尔部分 - 知乎 (zhihu.com)
随着物体表面法线与视线的角度增大,物体的反射能力增大,这种现象称之为菲涅尔效应,万物皆有菲涅尔效应。
角度跟法线角度越小,越容易穿透介质,发生折射、散射、漫反射。
角度跟法线角度越大,越难穿透介质,就越容易发生镜面反射。
菲涅尔方程(Fresnel Equations),用来描述光在不同折射率的介质之间的行为的方程。
菲涅尔(Fresnel)方程很复杂,计算量很大
实时渲染中广泛采用 Schlick 的 Fresnel 近似,因为计算成本低廉,而且精度足够:
$$
F_{Schlick}(v,n) = F_0 + (1-F_0)(1- v\cdot n)^5
$$
Empricial 近似:
$$
F_{Empricial}(v,n) = \max(0,\min(1,bias+scale \times (1-v\cdot n)^{power}))
$$
$F_0$:反射系数,用于控制菲涅尔反射的强度
$v$:观察方向
$n$:表面法线
$bias、scale、power$ 为控制项
光源
平行光(定向光)
距离极远,视为光向量不变。
定向光均匀分布,并且不会像点光源那样随距离衰减
点光源
点光源的辐照度随着距离的平方而减小
假设有一个各向同性(强度在所有方向都是相同的)点光源发射 1 瓦特辐射功率(power),将该光源放在半径为 1 米的空心球体的中心。所有光的功率都落在球体内表面并均匀分布。整个表面积为 $4\pi$,所以单位面积的辐射功率密度为 $\displaystyle\frac{1}{4\pi}W/m^2$。
由此,当光源发射的功率为 $P$ 时,辐照度 $\displaystyle E=\frac{P}{4\pi}\frac{1}{r^2}=\frac{I}{r^{2}}$ ,其中 $I=\frac{P}{4\pi}$ 是光源强度。
结合兰伯特余弦定律得,$\displaystyle E=I\frac{n\cdot l}{r^{2}}=I\frac{\cos \theta}{r^{2}}$
衰减算法:
用衰减因子乘以漫反射来影响光照。
- 线性衰减:
d 为光源到物体表面的距离:$d=length(\vec {LightPos} -\vec{WorldVertexPos})$
FalloffEnd 是最大衰减距离,FalloffStart 是开始衰减距离。
$\displaystyle F_{Attenuation} =\fracNaNNaN$
- 曲线衰减:
$\displaystyle F_{Attenuation} =\frac{1}NaN$
使用三个衰减因子控制衰减
c: 常数衰减因子
l: 线性衰减因子
d: 平方衰减因子
聚光灯
范围呈圆锥体的光: 手电筒
光源位置 $Q$
目标点 $P$
定义光向量与光传播的方向相反,即光向量的方向是由点 $P$ 指向点光源 $Q$:
$$L=\frac{Q-P}{||Q-P||}$$
由图可知,当且仅当位于 $-L$ 与 $d$ 之间的夹角 $\phi$ 小于圆锥体的半顶角 $\phi_{max}$ 时,P 才位于聚光灯的圆锥体范围之中(所以它才可以被光照射到)。而且,聚光灯圆锥体范围内所有光线的强度也不尽相同。位于圆锥体中心处的光线(即 $Qd$ 这条向量上的光线)光强应该是最强的,而随着角 $\phi$ 由 0 增加至 $\phi_{max}$ ,光强会逐渐趋近于0。
衰减算法:
根据 $\phi$ 计算(光的角度是 180 度的平面光):
$\displaystyle F_{Attenuation}=max(0,\cos\phi)=max(0,-L\cdot d)^{power}$根据内角外角(可以灵活调整光的角度和衰减):
定义内角 $\phi_1$ 和外角 $\phi_2$ ,内外角的差角记作 $\phi_{distance}=\phi_2-\phi_1$
$\displaystyle F_{Attenuation}=\frac{\phi_{distance}-(\phi-\phi_{distance})}{\phi_{distance}}$