LOADING

加载过慢请开启缓存 浏览器默认开启

八面体映射(Octahedral Mapping)

从法线精度问题出发,拆解八面体映射的编码与解码,理解如何将三维单位向量保存到二维数据中。

一、引言

这个坑也属于是早就想写但是一直懒得写了,源于刚开始接触延迟渲染的时候发现从Gbuffer阶段传递过来的法线精度很差,拿去做半兰伯特的话光照结果是一圈一圈的,非常难看。反复调查之后发现Unity可以开启一个高精度法线的选项,他会把用于法线的RT更换成更高精度的,并且改用八面体映射的编码方式,所以就调查并且记录一下这个算法。

二、用法和相关代码

先介绍下用法吧。它是个较高精度将三维单位向量映射为二维的方式,所以最经典的,假如你想要只用两个通道就储存法线这种向量就可以用这种方式,例如你想要在Gbuffer阶段节省一个通道之类的。 ps:UnityGbuffer阶段的法线虽然使用了八面体映射,但其实还是用float3的方式传递的,具体原因可以看这篇文章:Gbuffer传递更多种类数据的小花招 然后给一下unity的编码和解码方式吧: 编码:

八面体映射(Octahedral Mapping)示意图 1

解码:

八面体映射(Octahedral Mapping)示意图 2

写成公式是这样: 将法线映射到八面体上: \[\mathbf{p}=\frac{\mathbf{n}}{|n_x|+|n_y|+|n_z|}\] 下半球折叠 \[\mathbf{q}= \begin{cases} (p_x,p_y), & p_z\geq 0 \\[6pt] \left( (1-|p_y|)\operatorname{sign}(p_x), (1-|p_x|)\operatorname{sign}(p_y) \right), & p_z<0 \end{cases}\] 将(-1,1)的值映射到(0,1) \[\mathbf{UV}=\frac{\mathbf{q}+1}{2}\]

三、公式解读

先搬出个经典的图(箭头反过来看,从球到左边的二维正方形):

八面体映射(Octahedral Mapping)示意图 3

我们将其分为两步: 1. 将球面映射为正八面体 2. 将正八面体映射为二维的正方形

3.1、将球面映射为正八面体

像法线这种单位向量,它就是一种单位球,解析式为\(x^2\)+\(y^2\)+\(z^2\)=1,也就最右边的球状,我们首先要将其映射为中间的正八面体。正八面体的解析式为|x|+|y|+|z|=1,也就是对于原向量(x,y,z),我们想要得到新的向量( x’+y’+z’),而它满足正八面体的解析式。 对于球面到八面体的映射,我们希望球面向量的方向不变,但是又要落到正八面体上,对于方向不变的向量,我们只能给他乘一个系数,我们设原法线向量为n=(x,y,z),新的正八面体向量为n’= (x’,y’,z’) 那么 \[n'=k n\] 也就是: \[(x',y',z')=(kx,ky,kz)\]

把它代入八面体方程: \[|kx|+|ky|+|kz|=1\]

提取 (k): \[k(|x|+|y|+|z|)=1\]

所以: \[k=\frac{1}{|x|+|y|+|z|}\]

于是: \[n'= \frac{n}{|x|+|y|+|z|}\] 对应代码就是:

n /= abs(n.x) + abs(n.y) + abs(n.z);

而unity中的写法与之前的写法在数学上是等效的:

n *= rcp(max(dot(abs(n), 1.0), 1e-6));

首先是dot(abs(n), 1.0),这一步是在快速求xyz绝对值分量之和,看不懂的话自己手动算一下这段代码就懂了(也不知道为什么unity非得用这种不直观的方法,貌似也不会有GPU上的提速)。 然后就是rcp取倒数,再与n相乘,等价于n去除以后面这段,其中max是为了防止除0而取一个最小值。对GPU来说取倒数做乘法会比除法快一些。

3.2、将正八面体映射为二维的正方形

这一步就主要是为了将float3的量变成float2的量,由于正八面体有|x|+|y|+|z|=1的特性,所以我们其实根本不需要储存z值,有XY我们完全可以自己算出Z值为多少。所以我们可以看成从顶视图直接去看这个正八面体,然后忽略z轴。

八面体映射(Octahedral Mapping)示意图 4

但其实还有问题,直接忽略掉z轴的话,我们就只有上半部分的法线了,也就是这部分:

八面体映射(Octahedral Mapping)示意图 5

上下部分的xyz值是相同的,但是z的正负不一样,我们又不想单独拿一个通道存Z的正负,对此的解法是将折叠部分给翻折出来,让整个二维图像成为一个正方形:

八面体映射(Octahedral Mapping)示意图 6

如图所示,将黄色部分翻折出来变成了绿色的部分。也就是我们要解决原先的四个象限的三角形以斜边为对称轴做轴对称的新的图形的坐标位置问题。 求轴对称其实已经是个初高中问题了,这里不过多讲解,以第一象限为例,对称轴为直线x+y=1,那么对称点为1-p.yx。不过并不是所有象限的对称轴都是x+y=1这条直线,我们可以做一个取巧的做法:先给所有点做绝对值|p.yx|,这样就假装所有点都是第一象限,也就都适用这样的公式,再用sign(x,y)给送回各自象限中去。 代码:

if (n.z < 0.0)
{
    n.xy =(1.0 - abs(n.yx))* sign(n.xy);
}

Unity中的写法又整了些花活:

float t = saturate(-n.z);  
return n.xy + float2(n.x >= 0.0 ? t : -t, n.y >= 0.0 ? t : -t);

首先saturate(-n.z),也就是说当n.z>0时,t=0,下一行n.xy就不变,n.z<0的时候t=-n.z,也就是得到了n.z的模长。 根据正八面体的性质,|x|+|y|+|z|=1 所以 |z|=1-|x|-|y| 也就是t=1-|x|-|y| 我们忽略最后一行复杂的正负号判断,总之它就是n.xy+t n.x+(1-|x|-|y|)也就是1-|y| n.y+(1-|x|-|y|)也就是1-|x| 至此实际上它就是之前写的第一象限的对称公式。而后面的一长串正负号判断则是对于各个象限的还原: float2(n.x >= 0.0 ? t : -t, n.y >= 0.0 ? t : -t); 只要手动自己带入一下各种情况就会发现实际上和之前的sign是等效的了。

四、碎碎念

能帮到各位就好了…不懂的话可以问我或者问问ai,ai大人好强大啊