图形学杂记

\[ \newcommand{\E}{\mathbb{E}} \]

简介

这是一个图形学杂记,从光栅化到RT的诸多东西都乱记在里面,大多数只在这里记录了基本思想(有些暂时理解不深的也简记在这里),具体的一些东西可能会另开一贴来记录。

投影

“投影”直观上容易被理解为将三维空间物体变换到二维屏幕的过程(可以有这样一种降维的线性变换),但如果直接用矩阵来做这样一件事,不方便处理深度信息(遮挡效果)。

故实际上我们说的投影矩阵,是将三维空间中的一个长方体或平头截体(观察区域)映射到标准立方体(Canonical Cube,\([-1, 1]^3\))的过程,这个空间也叫裁剪空间。映射到这里的好处是方便进行后续操作。

正交投影

将一个长方体映射到 \([-1,1]^3\),比较简单

很多时候我们会在正交投影时把 \(z\) 倒过来,这样摄像机朝 \(-z\) 方向,而深度缓冲中的 \(z\) 越小表示离摄像机越近。

透视投影

透视投影的观察区域是一个平头截体,我们希望也使用一种(四维矩阵可表示的)线性变换将它映射到 \([-1,1]^3\)

思路:首先进行的是一个“压扁”的缩放过程,令远处的平面缩放幅度更大,近平面不变,使得平头截体变成一个长方体,随后进行正交投影。

最简单的想法:让一条观察射线(朝-z方向)上所有 \(x,y\) 坐标缩放后都相同,先假定 \(z\) 轴不变。

通过相似三角形容易计算出新坐标应该为 \((n/z\times x,n/z\times y,z)\)

编出一个变换矩阵,其中参数 \(n\) 为近平面的 \(z\) 坐标(负值!) \[ \begin{bmatrix} n&0&0&0\\ 0&n&0&0\\ ?&?&?&?\\ 0&0&1&0\\ \end{bmatrix} \times \begin{bmatrix} x\\ y\\ z\\ 1\\ \end{bmatrix} = \begin{bmatrix} n/z\times x\\ n/z\times y\\ z\\ 1\\ \end{bmatrix} \rightarrow \begin{bmatrix} nx\\ ny\\ z^2\\ z\\ \end{bmatrix} \]

我们发现1,2,4行的参数都很容易确定,但是第三行似乎没法搞?

问题出现了,这样的变换似乎不是一个线性变换(即使在四维下)

那么只能放弃追求 \(z\) 轴不变了。

实际的投影矩阵要求只有:近平面坐标不变,以及远平面的z轴(\(f\))不变

\[ \begin{bmatrix} n&0&0&0\\ 0&n&0&0\\ 0&0&A&B\\ 0&0&1&0\\ \end{bmatrix} \times \begin{bmatrix} x\\ y\\ n\\ 1\\ \end{bmatrix} = \begin{bmatrix} nx\\ ny\\ n^2\\ n\\ \end{bmatrix} \]

\[ \begin{bmatrix} n&0&0&0\\ 0&n&0&0\\ 0&0&A&B\\ 0&0&1&0\\ \end{bmatrix} \times \begin{bmatrix} x\\ y\\ f\\ 1\\ \end{bmatrix} = \begin{bmatrix} nx\\ ny\\ f^2\\ f\\ \end{bmatrix} \] \[ \begin{gather*} An+B =n^2\\ Af+B =f^2\\ A = n + f\\ B = -nf \end{gather*} \]

由此得到了这个压缩矩阵: \[ \begin{bmatrix} n&0&0&0\\ 0&n&0&0\\ 0&0&n+f&-nf\\ 0&0&1&0\\ \end{bmatrix} \]

现在得到需要的矩阵了,它满足变换后近平面不变,远平面 \(z\) 不变,且中间的 \(z\) 坐标依旧保持顺序的性质,变换到 \([-1,1]^3\) 之后大概长这样:

projection

(图源网络且魔改)

可以看到,虽然它让中间部分保持了顺序,深度测试不会出错,但是不均匀地拉伸会让三角形内部的插值有很大问题,需要进行下一节所说的透视插值矫正。

但这样的不均匀也不是没有好处:它将深度测试中我们所说的“非均匀精度分配”直接实现了!

LearnOpenGL:

可以看到,深度值很大一部分是由很小的z值所决定的,这给了近处的物体很大的深度精度。这个(从观察者的视角)变换z值的方程是嵌入在投影矩阵中的,所以当我们想将一个顶点坐标从观察空间至裁剪空间的时候这个非线性方程就被应用了。

再次体会到投影矩阵的强大

手动推导一下这个深度变换式子:

“压扁”后:\(z_1 = n+f-nf/z\)

标准坐标系内:\(z_2=-1+2\times (n-z_1)/(n-f)\)

如果深度值范围是0-1:\(z_3 = (z_2+1)/2 = (n-z_1) / (n - f) = \frac{(1/z-1/n)}{(1/f-1/n)}\)

和LearnOpenGL上给出的式子完全一致!简洁优雅

透视插值矫正

图不想画了,二维情况下的计算不难,推广到三维也很合理,直接给出结论:

设屏幕空间下,某点的重心坐标为 \(a,b,c\),则该点的实际深度值 \(w_0\) \[ \frac{1}{w_0}=\frac{a}{w_1}+\frac{b}{w_2}+\frac{c}{w_3} \] 插值结果(假设对 \(p\) 这个属性插值): \[ p_0 = w_0\times(\frac{ap_1}{w_1}+\frac{bp_2}{w_2}+\frac{cp_3}{w_3}) \] 推导链接

注意:这里用 \(w\) 表示顶点在透视投影变换前\(z\) 坐标,为顶点的实际深度,要和变换到标准设备坐标下之后的 \(z\) 坐标作区分。

这个 \(w_{123}\) 是怎么留下来的呢?还记得投影矩阵变换后的 \(w\) 分量吗,它恰恰等于原先的 \(z\)。我们通常会在齐次除法中保留它不变(因为后面也不会用到线性变换了,留着也没关系(视口变换可以直接操作)。

最后,深度测试使用的深度值并不是 \(w\)(如果用 \(w\) 就做不到非均匀分配精度了),而是NDC下的 \(z\) 坐标。我们可以用三个顶点的 \(z\) 坐标按照上述方式插值到单点的 \(z\) 坐标,它早在投影时便完成了精度的分配。

反走样

可以采用先模糊,再采样的方法。

理解走样的来源是:采样频率跟不上信号变化的速率,两个采样点之间信号可能发生了很多未被采集到的变化。

模糊操作本身是对信号做了一个均值处理,此时一个像素上包含了其附近像素的信息,故模糊后采样能非常有效地缓解锯齿。

另一个角度:模糊操作相当于一个低通滤波,除去了高频部分。而锯齿现象本质是源于对高频信号采样时的信息缺失。

从这两个角度都可以理解:为什么先采样,后模糊不行。

模糊的一种办法是对连续图像取平均得到离散结果。具体到三角形上,可以在每个像素处根据覆盖三角形的面积来改变颜色,再具体的,可以在每个像素中设多个采样点(4个,9个..),看几个在三角形内来粗略计算面积(MSAA方法)。

着色

  • Gouraud Shading,逐顶点着色,在顶点处计算出颜色,将颜色/光强插值到各个片元。
    • 双线性光强插值指的也是这个
  • Flat Shading,逐三角形着色,平直着色,每个三角形的法线都完全一样,适棱角分明的几何。
  • Phong Shading,逐像素着色,平滑着色,将顶点法线插值到像素上后逐像素计算颜色。效果最平滑,也比较常用。
    • 双线性法向插值指的也是这个

通常Mesh存储的顶点法线是周围几个平面法线的均值,适合用于Phong Shading,而Flat Shading的法线(面法线)容易直接计算出来。

具体插值方法

重心坐标

重心坐标(Barycentric Coordinates)定理:即三角形中任意一点 \(X\),都可以表示成 \(X=aA+bB+cC\),其中 \(ABC\) 为三个顶点的坐标向量,\(abc\) 为系数且满足 \(a+b+c=1\)

一个顶点的重心坐标可以用这样的 \((a,b,c)\) 来表示,实际上,可以根据顶点对面的三角形面积占比来快速计算 \(abc\)(具体图去百度找一下)

重心坐标本身就代表了三个顶点在此点所占的权重,故可以轻松得到插值比例。

双线性插值

具体思路:先插值一次得到每条线上的值,再插值得到每个位置上的值。

可以利用扫描线算法,进行增量插值。

图像放大方法

高分辨率对象上应用低分辨率图像时常用算法:

  • Nearest:取最近像素
  • Linear:线性插值,按距离分配权重,取周边像素均值
    • BiLinear:对于图像而言的双线性插值,按水平和垂直线性插值两趟
  • Bicubic:双三次插值,效果更好,原理涉及信号系统(暂略)

图像缩小方法

低分辨率对象上应用高分辨率图像(渲染远处物体上常见这种情况),一个屏幕像素对应了多个纹理像素,采样时理应取均值。

但按原本的采样方式,会仅取到中心处的纹理像素,它显然无法代表这一片区域的像素值。也就是说我们需要进行区间查询操作,实际进行的是单点查询。

于是有了mipmap(多级渐远纹理),其本质是预处理的思路,有点类似线段树。

Mipmap

即对于一张纹理,事先将其缩放为一半、1/4、1/8...(缩放时计算了区间均值),将这些缩小的纹理全都存放起来。这些全部存放的空间仅为原先的 \(4/3\) 倍。

具体渲染时,对于一个即将渲染的像素(已经知道了其uv),可根据它与相邻像素uv的差值,估算出它覆盖了多大的纹理(pixel footprint)。

注意这是一种粗略的近似,像素实际覆盖的纹理区域不一定是矩形,而mipmap方法根本上是对一个矩形区域求了均值,故这里并不完全准确。

假设现在这个像素需要覆盖一个宽为 \(L\) (个纹理像素)的正方形,那么实际如何利用多级渐远纹理取样?

  • Trilinear:三线性插值,计算 \(D=log_2 L\),即要在第 \(D\) 层纹理上取样比较合理,\(D\) 为浮点数时就,在 \(\lfloor D\rfloor\)\(\lfloor D\rfloor +1\) 层分别进行双线性插值,然后再依据 \(D\) 插值一次得到实际颜色。

Ray differential

上述计算footprint的方式只在光栅化框架中可行,在路径追踪中,我们需要追踪纹理uv对屏幕像素xy的偏导,如 \(\frac{\partial u}{\partial x}\),即屏幕像素偏移1单位时,uv偏移了多少,它可以用于估计pixel footprint。

具体而言,我们可以记录当前光线对屏幕像素的偏导 \(\frac{\partial \bold R}{ \partial xy}\),其中 \(\bold R = \vec O + t\vec D\),在光线传播过程中分别维护 \(\frac{\partial \vec O}{\partial x}\)\(\frac{\partial \vec D}{\partial x}\)....等等信息。

具体更新方式可查原论文,在大多渲染器框架中此功能都已经实现。

各向异性过滤

Mipmap只能求正方形区域的均值,故在覆盖斜着的、长的纹理时会出现overblur(过度模糊)的现象。

各向异性过滤额外预处理了一些不等比缩放的纹理(具体图百度),空间翻到了3倍,能很好地处理覆盖区域为长方形的情况,但对于斜长的情况还是会overblur。

EWA filtering是一种更复杂的方法,具体略过。

高级Shader

曲面细分

曲面细分着色器要对原低模几何进行动态细分,有两种目标:

  1. 仅增加顶点数,但保持原几何形状不变,这是为了给位移贴图等操作提供更多顶点;
  2. 增加顶点数,同时加权计算新顶点的位置,以得到更平滑的几何;

针对2,又有很多种细分方式,Loop细分,Catmull-Clark细分等。

流程:

  • 顶点着色器阶段,不再处理顶点,而是处理控制点(例如,贝塞尔曲线控制点)
  • 图元装配阶段(IA)输出由若干控制点表示的面片(Patch)
  • 曲面细分着色器,通过hull shader指定细分规则,然后在tessellation stage,由硬件进行细分

曲面细分可以根据相机远近等信息,动态条件细分程度,实现LOD

几何着色器

在顶点着色器、曲面细分着色器后,对完整图元进行变形或后期加工。

输入一个图元(包含它的每个顶点),在几何着色器中,我们可以任意操作这些顶点,变换位置或者增加新的顶点。

输出也是图元,我们可以自定义哪些顶点属于一个图元。可以输出多个图元,后续再对每个图元进行光栅化和片段着色

gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0);    // 1:左下  
EmitVertex();   
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0);    // 2:右下
EmitVertex();
gl_Position = position + vec4(-0.2,  0.2, 0.0, 0.0);    // 3:左上
EmitVertex();
gl_Position = position + vec4( 0.2,  0.2, 0.0, 0.0);    // 4:右上
EmitVertex();
gl_Position = position + vec4( 0.0,  0.4, 0.0, 0.0);    // 5:顶部
EmitVertex();
EndPrimitive();

一些常见的通过几何着色器做的事情:

  • 法线显示:在每个面上额外画了一条法线的线完成
  • 草地、毛发:在每个面上画一个垂直的三角形来完成

曲面细分和几何着色器都能用来产生新顶点,修改几何;

曲面细分只能用几种预设方案做细分,效率更高、硬件优化更好,自由度更低,输出一般仍然是三角形/四边形面片;

几何着色器更自由,可以输出任意数量、任意形状的图元,但效率更低。

纹理应用

环境贴图

可以用来做天空盒、环境光等全景贴图

一种方式是使用六个张纹理做立方体贴图,另一种方式是使用球面的展开图。

两种方式都能用三维向量来采样纹理,球面展开方式是将球面坐标作为展开图的xy坐标。

通常,将矩形贴图转换到球面坐标的环绕方式是这样的:

1

法线贴图

字面意思很好理解,用纹理来直接指定每一个像素上的法线,能在平面上做到丰富的光照细节。

但直接在模型空间下指定法线,不是太好。例如当一个立方体的六个面都使用相同的纹理时,我们却不得不为它分配六张不同的法线贴图。

一个更好的坐标系是切线空间,在切线空间下表示法线,可以只关注平面,而不关心其方向。

切线空间
1

对于一个表面而言,法线是唯一的,而切线可能有很多种,通常会将uv展开的方向定义为切线。

具体如图所示,绿色为空间中任一个三角形,灰色部分为将这个三角形的纹理(uv)直接贴上去的样子,我们定义沿纹理 \(x\) 轴方向的单位向量为切线 \(T\)(Tangent),同时沿纹理 \(y\) 轴方向的单位向量为 \(B\)(bitangent),三角形的法线朝向 \(+z\) 方向,这样的空间叫做切线空间。

切线 \(T\) 通常作为顶点数据的一部分,会在模型中给定,如果希望自己算,也可以通过上图中三角形的另外两点的顶点坐标和uv列式计算得到。

具体式子可以来这边找到 https://learnopengl.com/Advanced-Lighting/Normal-Mapping

使用切线空间来应用法线贴图的流程大概是这样的:

  • 将 Normal、Tangent 以及MVP这类的矩阵传入顶点着色器,也可以预先将法线矩阵(3x3的逆的转置的那个)计算好传入。
  • 叉乘得到 Bitangent,将三个轴都变换到世界坐标系下(同法线变换方法),得到 TBN 矩阵
  • 把 TBN 矩阵传入片段着色器
  • 在片段着色器中,从 Normal map 中采样法线,然后让这个法线左乘 TBN 矩阵即可得到世界坐标系下的法线。
    • TBN是一个仅有旋转的3x3矩阵,故不需要再进行法线变换。

如果在观察坐标系下计算光照,也是同理。

我们通常看到的法线贴图偏蓝色,正是因为切线空间下法线大多朝向 \(+z\) 方向(0, 0, 1),仅对一部分细节有所扰动。

在多个三角面共享顶点时,我们用类似法线的处理思路:如果希望有平滑的效果,就平均一下,如果要锐利就把公共顶点拆开。

细节

normal map会导致某些角度的入射/出射光,在宏观表面的上方,但却在normal所示的表面下方(反之一样),这种情况下进行光照计算会有歧义。

参照mitsuba中的法线贴图实现,若光源方向遇到了这种情况,直接舍弃;而若观察方向遇到这种情况,不舍弃,按照normal所示的新表面计算(可能由反射变成透射)

这个地方究竟应该如何舍弃,目前我还没找到什么逻辑严谨的定论,暂且按照mitsuba来。

upd: 最新发现,这个特性通常在几何项中被处理(包括mitsuba中),也即两个方向都会产生这样的遮挡。

视差贴图

视差映射 [Kaneko 2001]:仅移动一步,移动直到达到当前深度

Offset limit:还是移动一步,步长为当前深度

Steep Parallax Mapping:移动多步

Parallax Occlusion Mapping:移动多步,最后一步线性近似交点

Relief Mapping:在POM的基础上,最后一步改为二分找交点

Secant Method:在POM的基础上,最后一步改为迭代法(多轮线性近似),更快

位移贴图

位移贴图(displacement map)是一张高度图,和视差映射不同,它要真正的移动顶点,修改网格,因此它不只是材质层级的操作,还会影响到光线传输;

实现上,在光栅化管线中有两种做法:

  • 在顶点着色阶段,根据位移贴图对顶点进行位移,并改变法线,这种做法需要原始模型面数足够多,但本质上位移贴图就是为了给低模增加细节,因此该方法不太实用。
  • 在曲面细分着色器中,对图元(三角形)进行细分并修改顶点位置、改变法线。

在光线追踪管线中,做法又不一样:

  • 离线细分,将位移贴图烘焙到Mesh上,再构建BVH;这样就不支持动态的位移贴图了,非常有局限性。
  • 修改图元的求交测试,
    • 首先需要对每个带有位移贴图的图元的AABB做扩展,一般是根据最大位移做一个保守扩展(参考Falcor实现);
    • 生成height map的各个LOD层级
    • 执行带displacement的图元(三角形)求交,首先计算三角形范围内的最大位移和最小位移(在合适的LOD层级,一次采样即可得到)
    • 将三角形三个顶点向法线方向位移(最大距离,最小距离),生成一个三棱柱Shell,该Shell两端是三角形,侧面是非共面四边形(Bilinear Patch),首先光线和Shell求交,得到一个交点的范围;再在该范围内做光线步进,和高度场求交。
    • Bilinear Patch求交可以使用这个方法

阴影贴图

实现阴影的经典办法,但是有非常多弊端。

思路:首先在光源位置观察场景,走一遍光栅化流程(但不着色),渲染出一张阴影贴图,每个像素上记录深度。

然后再正常渲染场景,对于每个像素,我们再将这个像素的世界坐标Reproject到光源的观察坐标系下,找出这个片段在阴影贴图上对应的位置。然后对比深度值,以判断这个像素是否能被光源看到。

上述是最简单的阴影贴图逻辑,它只能处理平行光,只考虑了单光源,只能产生硬阴影,且依赖阴影贴图的分别率,容易产生锯齿。

  • 问题:由于阴影贴图的离散化,深度会有一些误差,我们做比较时必须保留一个容差;但这个容差过大又会导致一些阴影丢失;
    • 一种解决方案:渲染阴影贴图时,保留最小和次小深度,然后取中点作为阴影贴图;
      • 保留最小和次小会引入比较大的常数;因此没什么人用
多光源

本身我们在着色时,多光源就是分别着色后叠加的。因此阴影只对每个光源分开考虑就好。

软阴影

面光源可以用点光源近似,然后做软阴影

PCSS(Percentage Closer Soft Shadows)在采样shadow map时,同时采样一定范围内的像素(例如,7x7),计算着色点被遮挡的比例,从而获得软阴影。

通常来说,着色点距离遮挡物越近,阴影越硬,PCSS还要在Shadow map上计算一定范围内的平均Blocker depth(着色点到遮挡物的距离)来控制软阴影的强度。

总的来说,PCSS有两步范围查询,一步查询平均Blocker depth,一步查询范围内被遮挡的比例(区域Rank查询)。

VSSM

区域Rank查询要用数据结构维护这个是很麻烦的;

VSSM提出将区域深度近似为某个分布,用均值和方差描述;而均值很容易做范围查询,方差也可以变成均值:\(V(x) = E(x^2) - E^2(x)\)

求Rank约等于求该分布的CDF,VSSM并不假设分布的形式,它直接用切比雪夫不等式近似: \[ P(x > t) \le \frac{\sigma^2}{\sigma^2 +(t - \mu)^2} \] 这既是一个上界,同样也可以作为一个估计;该估计在 \(t > u\) 时比较有效,但大伙一般都随便用。

均值范围查询:可以用MIPMAP或SAT,MIPMAP的优势是硬件生成很快,SAT查询较快但构建很慢。

对于Blocker depth的范围查询,实际上要查询的是遮挡物的平均深度 \(z_{occ}\),有以下关系: \[ \frac{N_1}{N}z_{unocc} + \frac{N_2}{N} z_{occ} = z_{avg} \] 我们已经可以查询范围 \(z_{avg}\) 以及Rank \(N_1\),再进一步将 \(z_{unocc}\) 近似为着色点的深度,就可以得到 \(z_{occ}\) 了。记得Blocker depth仅仅只是一个控制软阴影强弱的一个因子,因此不需要太过准确。

Moment shadow mapping

这里的Moment是矩的意思,VSSM可以视为是用了二阶矩+切比雪夫的近似,它可以简单理解成均值和方差;而事实上,这种范式可以进一步扩展到更高阶的矩,且阶数越高,近似的分布越准确。

这个计算过程是非常复杂的,目前在软阴影上很少有用到更高阶矩,仅作为一个理论来提一下。

实时全局光照GI

这里主要讲传统的实时GI思路,我是先接触的路径追踪,再看传统GI会觉得它非常局限,很多情况都处理不了。实时GI需要追求的是大部分情况下看起来没问题,而不是无偏。

因此,我们需要先知道以下几点实时GI的关键假设:

  • 实时GI通常仅考虑一次间接光照,二次及以上的间接光不重要(可以简单用环境光近似)
  • 相机直接看到的材质很重要,但反射后看到的材质不重要(基本都当成diffuse处理,实时GI基本不会考虑镜子多重反射之类的事)
  • 一次间接光照也通常是低频的;

RSM

Shadow map可以看做是光源直接照到的区域,是光源注入到场景中的光子;

RSM则是对每个着色点,在Shadow map上采样,然后连线(不考虑间接光的visibility),贡献间接光;

VXGI

用体素+SH表达光源在场景中“注入”的光,然后对每个着色点,采样多个方向进行cone tracing,获得间接光;相比RSM能考虑间接光的visibility;

这里可以看到,处理间接光时,连几何都不再重要,将几何近似为一个个带透明的体素,大部分情况下没问题;

VXGI首先构建finest level的voxel,然后生成mipmap,根据相机位置分配mip等级;

细节先不管;

SSGI

在屏幕空间做GI;对每个着色点,采样一个一次反射的间接光方向,然后在屏幕空间追踪该间接光会打到哪;

我们渲染完成时能获得一个深度贴图,屏幕可以视为一个depth field,我们就是要将反射光线在这个depth field上做步进,找到交点后,直接复用屏幕空间颜色。

显而易见,这种方法没办法得到来自屏幕外的间接光照;

骨骼动画

以UE为例,骨骼动画通常包含以下几个要素:

  • 骨架(Skeletal),定义了各个骨骼的父子关系和相对Transform,注意每根骨骼实际上是一个关节点(Joint)
  • 骨骼网格(Skeletal Mesh),即骨架+蒙皮,定义了Mesh上每个点在各个骨骼上的权重,每个骨架可以用于多个骨骼网格,实现最简单的动画复用
  • 动画序列(Animation Sequence),定义一个骨架的一段运动动画,通常是关键帧动画
  • 动画混合(Blend Space),设定一些参数,由这些参数控制动画之间的插值和混合(例如,用速度控制角色的行走和奔跑动画)
  • 动画蓝图(Animation Blueprint),UE的动画逻辑中心,包括动画状态机,事件图表等,定义了动画如何播放和切换;动画蓝图使用动画序列(以及Bland Space)作为资产,其输入为状态(从游戏中获得),输出为Pose,指定当前帧的骨骼位姿
  • 物理资源(Physics Asset),定义骨骼的刚体、约束、碰撞等,它可以用来做头发、布料等组件的模拟。物理资源是定义在骨骼上的,它需要加入动画蓝图中,让部分骨骼通过模拟决定位姿(而非动画)
    • 衣服、头发等附件的模拟有两种方式,一种是将它们绑定到“外附魂骨“上,让外附骨骼进行物理模拟;另一种是直接作为额外的资产进行模拟,与骨骼无关,此时物理资产仅定义人物的碰撞箱

骨架结构和IK

  • 两脚兽的骨骼通常如图所示,从与地面接触的根骨(Root)开始,向上连接盆骨(Pelvis),再由盆骨连接四肢;除此之外,还有从Root出发直接连向四肢末端的IK骨。
    • Root是角色的中心,控制角色的整体移动,其余骨骼控制动画
    • Root到盆骨、IK骨的位移是可缩放的,而其余身体骨骼连接是(一般情况下)是不可缩放的,蒙皮一般也绑定在这些身体骨骼上
    • IK骨起到辅助作用,因为它的位置很自由,UE动画中通常是先将IK骨移动到目标位置,再执行IK解算,目标为将四肢末端移动到对应IK骨处。因此IK骨总是与末端骨位置重合。
  • IK绑定(IK Rig),这个资产在骨骼上定义了IK Goal,它表示一个末端目标,在动画蓝图中我们可以输入末端位姿,通过逆向动力学控制骨骼位姿。
    • IK Rig中还可以定义骨骼链(Chain)并分配Goal,它用于IK重定向
    • IK Rig中可以定义解算器,每个Goal可以使用不同的解算器
  • IK重定向,我们可以通过IK重定向将已有骨骼动画复用到一副尺寸、比例不同的新骨骼上,这需要两副骨骼有对应的IK Rig,并且骨骼链能一一匹配。如果骨骼链不设置IK Goal,则默认用FK来重定向,加了IK Goal才会使用IK。
    • 无论用FK还是IK,都不要求骨骼链的节数匹配,UE会自动在中间插值;
    • FK重定向是简单将链上所有Rotation复制过来
    • IK重定向是先设定IK Goal,再解算链上Rotation(IK Goal的匹配目标是?我也不知道,UE文档表示IK重定向一般不需要IK Goal)
    • 可以离线进行动画资源的重定向,也可以进行运行时的位姿重定向(Retarget pose from mesh,很快)

HDR与色调映射

HDR High Dynamic Range 高动态范围,指使用不限制的范围(超过 \([0,1.0]\))来表达场景亮度,在需要显示时(显示器只能显示 \([0,1.0]\) 之间的亮度)再转换到低动态范围 LDR。转换的方式叫色调映射(Tonemapping),一般不是简单的线性转换,而是通过特殊手段尽可能保留场景细节。

Reinhard色调映射

\(f(x) = \frac{1}{x+1}\)

简单好用,偏向亮色

Gamma矫正

物理亮度是正比于能量(光子数量)的,人眼看到的感知亮度实际为 \(物理亮度^{1/gamma}\)

如果我们不进行任何处理,图片直接按物理亮度存储,显示器按物理亮度发射光线,我们人眼看到的颜色也是对的(和拍摄时的颜色相同);但这样,图片的存储密度对于物理亮度而言是均匀的,但对于感知亮度就不均匀了

对于人眼而言,信息利用率没有做到最好,直观的感受就是,调颜色会发现颜色的变化不均匀。

因此,现在绝大多数电脑图片都存储在sRGB空间下,也即按感知亮度存储。

对于大多数使用者,不需要关注到gamma矫正的存在,因为他们始终在感知亮度空间下工作。而我们在进行光照运算时,必须转换到物理空间下进行。

通常说的线性工作流,指的就是在物理空间下进行计算,线性空间通常指物理亮度空间

常用到的一些知识:

  • 大多数图片( jpg, png 等)都存储在sRGB空间,线性工作流中应将他们变换到物理空间进行运算;exr 文件存储在物理空间,不需要转换。
  • 在线性空间下计算的渲染结果若要保存为 jpg, png 等,应将输出颜色变换到感知空间。我们只需负责按sRGB格式保存正确的图像,显示器会自动完成sRGB的显示工作(变换到物理空间去决定发射多少光子)。
  • 美术工具通常都是工作在sRGB上的,各种调色板调的通常都是sRGB。
  • tev中查看的是线性空间下的数值(RGB),qq截图得到的是sRGB空间下的数值,因此同一个颜色qq截出来的数值比tev大
  • 将exr另存为png时,一般会保证看起来不变,会将线性数值1/2.2次幂(放大)后再存为sRGB值。
  • OpenCV 在读取图像时并不会进行处理,很多CV工作都是在LDR、感知空间下计算Loss的,这也合理,这样计算的Loss更符合人眼感受。需要注意的是如果我们要使用别人预训练的网络,最好也先将HDR值截断,再将图像转换为sRGB输入。
    • 这里我们说到截断。事实上在科研中Tone mapping大多数时候是不用的,它更多是一个游戏或者显示器中的功能,用于提升画面细节。我们也不希望读取图像时还要进行反向Tone mapping。
    • 最近发现做CVCG科研的人好像都认为Gamma矫正也是一种Tone mapping,这个在CG的线性工作流中就是必须的了。

Sobol序列

Sobol序列是一种低差异序列,支持生成大量的 \(n\) 维点集。我们用 \(x_{i,j}\) 表示第 \(i\) 个样本的第 \(j\) 维。

其每一个维度 \(j\) 需要一个二进制生成矩阵 \(C_j\)(有了生成矩阵后,Sobol可以通过index \(i\) 计算出第 \(i\) 个数,计算过程很多地方都有,此处不赘述)

为了方便,通常会使用32x32或64x64的生成矩阵,这样可以将一行/列视为一个二进制表示,用一个整数存储。因此生成矩阵通常显示为一串整数。以32x32的生成矩阵为例,它可以支持 \(2^{32}\) 个样本。当然,这并不是Sobol的理论上限,理论上只要我们愿意去实现更高维的生成矩阵,它支持的样本数量是无限的。

Sobol官网 给出了一些文件,这里重点讲一下这个文件的用法:

d       s       a       m_i     
2       1       0       1 
3       2       1       1 3 
4       3       1       1 3 1 
5       3       2       1 1 1 
6       4       1       1 1 3 3 
7       4       4       1 3 5 13 
....
21201   18      131059  1 1 7 11 15 7 37 239 337 245 1557 3681 7357 9639 27367 26869 114603 86317

这里的 \(d\) 指维度,可以看到这个文件支持生成最高21201维的点集,每行描述了一个维度的生成矩阵。但并没有直接给出,而是只给出了矩阵的前 \(s\) 个数,后续需要我们根据公式自己推出来(具体就不细谈了,可以直接套官网的代码)。

最后再说一下Sobol在渲染中的应用。

例如,一次路径追踪中,要产生8个随机数,那么我们会使用一个8维样本来表达这一整个随机过程,而非使用8个一维样本。

光场

Plenoptic Function(全光函数):\(P(\theta, \phi, \lambda,t,x,y,z)\),指任一个位置,任一时刻向任一方向看到的某波长的光强。一个抽象概念,可以描述整个世界。

Light Field(光场):全光函数的一个子集,对于一个物体,考虑其包围盒,光场表达从任一位置,向任一方向发出的光(4D,位置和方向都可以用球面坐标表示)

nerf即使用神经网络来拟合这样一个光场(辐射场),来实现重建。

球面谐波函数

基函数:使用一组函数 \(b_i(x)\),他们的线性组合可以近似任一其他函数:\(f(x)\approx \sum_i^n A_ib_i(x)\)

我倾向于将基函数理解成“无限维度的向量”,即每个 \(x\) 都是一个维度。

类似于基向量,因为维度是无限的,故希望不是近似而是完全匹配任一函数的话,需要无限个基函数:\(f(x)=\sum_i^{\infty} A_ib_i(x)\)

函数的乘积积分(Product integral) 类似于向量点乘,故基函数的正交性类似于:\(\int f(x)g(x)d_x=0\) 对其中任两个基函数都成立。

回到球面谐波函数上来,它是一组定义在球面上的基函数 \(r=b_i(\theta, \phi)\),它是正交的。

如图所示,球谐函数可以使用若干阶,越高阶频率越高,越能表现函数细节,通常使用前3阶即可比较好的近似。

近似 \(f(w)\) 时,对于每个基函数前的系数,可以这样计算:\(c_i=\int_\Omega f(w)b_i(w)d_w\)

这称为“投影”,从上述基向量的角度来看非常显然。

应用

取低阶的SH可以拟合一个球面光照(如环境光),类似一种低通filter

对于漫反射材质,其反射的主要是低频光照信息,仅需使用3阶SH拟合环境光,就能取得极其近似的效果。

球面高斯函数

定义: \[ G(\vec n,\vec v, a, \lambda)=ae^{\lambda(\vec n \cdot \vec v - 1)} \] 高斯分布,即正态分布,距离轴线越远函数值越小。球面高斯函数(SG)将这种分布迁移到了三维球面上,用以表示一个波瓣。以 \(\vec n\) 定义波瓣的中心方向,\(\lambda\) 系数定义波瓣的”胖瘦“,\(a\) 对波瓣进行整体缩放。这些从上式中不难看出。

SG的特点:

  • 它的积分是封闭形式的
  • 两个SG的乘积仍然是SG,因此两个SG的点积也是封闭形式
  • ...

我们也可以定义一组SG基函数,通过调整他们的参数和系数来混合成新的球面函数。但SG有两个问题:它是各项同性的,且随意的一组SGs很难正交。

一组SH基函数能够快速拟合一个任意函数(求出系数组),利用的是对正交基的“投影”。SGs没了正交性,这个过程的复杂度将不可接受。故SGs基函数的数量不能很多。

ASG

Xu 2009

各向异性的SG,定义为: \[ G(\vec v,[\vec x, \vec y, \vec z], [\lambda, u], c)=c\cdot max(v\cdot z, 0) \cdot e^{-\lambda(v\cdot x)-u(v\cdot y)} \] 定义也比较直观,波峰在 \(\vec v=\vec z\) 处,其中 \(\vec x,\vec y, \vec z\) 是一个三维空间下的正交基。

原SGs也可以拼出各向异性的球面函数,但需要非常多的SG,而使用ASGs则可以使用一组数量较少的基函数。

颜色

Spectral Power Distributions (SPD):各个波长上分布的光强,多个光叠加时,SPD也可以叠加(线性性质)。

同色异谱:不同分布的光谱,人看起来可能是一样的(三种视锥细胞各自感应的结果)

烘焙

烘焙操作,通常就是在贴图上保存irradiance信息。

烘焙需要先进行一波展UV,得到UV2,这个UV2它保证不重复,而原来的UV就不一定了。

烘焙,逐物体操作,每个mesh丢入管线,顶点着色器输出的裁剪坐标直接为UV2,而计算仍然在世界空间下进行,方便地得到纹理空间的烘焙结果。

Control variates

记随机变量 \(X\) ,我们要估计其均值,最简单的方法就是从中抽取一个样本,用 \(<>\) 表示估计值,即: \[ <\E(X)> = x \] 然而,我们如果有一个和 \(X\) 相关的随机变量 \(Y\),则有办法进一步控制该估计的方差: \[ <\E(X)> = x + \alpha (y - \E(Y)) \] 因为新增部分期望为0,因此显然还是无偏的。系数 \(\alpha\) 的最优选择为: \[ \begin{gather*} \alpha = \frac{\mathrm{Cov}(X, Y)}{\mathrm{Var}(Y)} \\ \end{gather*} \] 如果我们不知道 \(\E(Y)\),则也可以改为: \[ \begin{gather*} <\E(X)> = x + \alpha (y - y_0) \\ \alpha = \frac{\mathrm{Cov}(x, y - y_0)}{\mathrm{Var}(y - y_0)} \\ \end{gather*} \] 其中 \(y_0, y\) 是相互独立的随机样本,这样可以看做将 \(y - y_0\) 作为了新的控制变量,其期望本身就为0。

注意,\(y, x\) 的相关性决定了降低方差的力度,而 \(y\)\(y_0\) 可能是不相关的,是由不同的采样过程得到。

Re-rendering

很常见的一个需求,我们将渲染任务抽象成 \(F(\theta)\),其中 \(\theta\) 为场景中的可控参数,包括几何材质等等,\(F\) 为相应的渲染结果。在很多情况下,相邻帧的 \(\theta\) 变动是很小的,可以认为相邻的两帧渲染结果极大相关。

渲染结果通常是一个无偏的蒙特卡洛估计\(<F>\),注意蒙特卡洛估计的积分本身就是一个随机变量,有 \(\E(<F>) = F\),在渲染中循环利用CV,就有了recursive control variate: \[ \begin{gather*} <F_n>_{CV} = <F_n>_n + \alpha_n (<F_{n-1}>_{CV} - <F_{n-1}>_n) \\ \alpha = \frac{\mathrm{Cov}(<F_n>_n, <F_{n-1}>_n)}{\mathrm{Var}(<F_{n-1}>_{CV} - <F_{n-1}>_n)} \\ \end{gather*} \] 其中,\(<F_i>_j\) 为第 \(i\) 帧,采用第 \(j\) 套样本的渲染结果。

\(<F_n>_n\)\(<F_{n-1}>_n\) 当然是高度相关的,这里还利用到了 \(<F>_{CV}\) 避免了每帧渲染两遍。

论文提到 \(<F_{n}>_n\)\(<F_{n}>_{CV}\) 是无关的?这一点有些无法理解,但只要这个差值的期望为0就不影响上面公式

当然,上述 \(\alpha\) 只是一个最优选择,无需精确,这个参数可以直观地认为是上一帧的影响力度,\(<F>_{CV}\) 则是累计值,场景不发生大变动的情况下,其方差逐渐减小,\(\alpha\) 逐渐增大,有点像TAA,不过是无偏的。可以直接取 \(\alpha_n = \frac{n}{n+1}\)

头发与布料

  • 都有基于Curve的方法,把每根线都建模出来,用BCSDF表达,他们需要处理多次散射与aggregation

    • Curve LOD:减少数量,变成更粗的cylinder,更远时可以直接切换到Mesh hair
    • 多次散射:近似(Dual scattering,还有用BSSRDF近似的)
    • 输入的curve通常是在DCC软件中完成,由artist制作(形式通常是引导曲线,以及插值得到的发丝)、
    • curve根会绑定到具体的mesh点上,这在UE中可以通过投影完成
  • 头发传统上有基于Mesh hair(发片)的方法,用BSDF表达,要有一个大致的表达头发轮廓的基模,主要通过opacity通道,形成镂空的立体感,BSDF方面主要关注各项异性。(Kajiya-Kay model)

  • 布料绒毛似乎没有看到类似的基于surface的做法,原因是它会向外突出?而头发则更多是平行surface的朝向

Box-Muller

利用 CDF\(^{-1}\) 采取符合某概率分布的样本是个基本方法,但常见的正态分布(或高斯分布)并没有CDF的解析解,怎么办呢?

以标准正态分布 \(X \sim N(0, 1)\) 为例,\(p(x) = \frac{1}{\sqrt{2\pi}} \exp(-\frac{x^2}{2})\)

再加上一个独立同分布的标准正态分布 \(Y\),联合概率密度为 \(p(x, y) = \frac{1}{2\pi} \exp(-\frac{x^2+y^2}{2}) = \frac{1}{2\pi} \exp(-\frac{r^2}{2})\)

设想这是 \(X-Y\) 平面上一点的概率,采样到半径 \(r\) 的概率为 \(p(r) = \exp(-\frac{r^2}{2})\),采样到特定角度的概率为 \(\frac{1}{2\pi}\)

我们发现 \(p(s = \frac{r^2}{2})\) 就是 \(\lambda = 1\) 的指数分布密度,因此,可以先根据指数分布进行 \(s\) 的采样,转换成 \(r\) 就是 \(r = \sqrt{-2ln(1-u_1)}\),再在角度上均匀采样,然后一次性得到两个符合正态分布的样本(并且它们是独立的)

问题:一维正态分布看起来也是一个缩放的指数分布形式,为什么不能直接通过它获得一维样本?


图形学杂记
http://www.lxtyin.ac.cn/2023/09/22/笔记/cg/图形学杂记/
作者
lx_tyin
发布于
2023年9月22日
许可协议