标签: CG

  • 波动光学渲染:全波参考模拟-学习笔记-4

    波动光学渲染:全波参考模拟-学习笔记-4

    今天继续来粗略看看Sig23这篇计算表面反射的全波参考模拟器paper。

    关键词:图形学入门、波动光学渲染、BEM、AIM、BRDF
    Wave Optics Render, Full Wave Reference Simulator

    原文:https://zhuanlan.zhihu.com/p/1471147574

    前言

    稍微总结一下目前的理解。

    基于波动光学的计算机图形学对图形学研究者而言无疑是巨大的挑战。它不仅要在计算上处理电磁波,还涉及一些量子力学的理论,想要打通物理学和图形学之间的隔阂属实不易。在基于波动光学的图形学中,光在介质中的传播不再只是直线,而是会因为波长不同,表现出自旋、偏折和衍射等多种特性。从肥皂泡、偏振镜、油膜光泽或糖分介质导致的旋光再到无线电辐射如何在城市建筑之间传播,都离不开波动光学理论。

    在知乎上也有很多大佬分享了非常详细的波动光学渲染理论基础教学。

    但是奈何阅读门槛都较高,且距离实际应用太远。波动光学渲染涉及的数学物理知识太多,就算是一行一行细看闫老师和他博士生的paper,也是寸步难行。

    图形学的圣杯是光线追踪,而全局波动光学渲染则是图形学圣杯中的圣杯。

    在Sig21上,Shlomi提出了将路径追踪与物理光学相结合实现电磁辐射传播的真实模拟。目前已经有不少方法计算电磁波的传输,从高精度但计算密集的波动求解器到快速但不够精确的几何光学方法。如下图所示,展示了当前电磁波传输的计算方法。左边最准确,右边最快。

    波动求解器(Wave Solvers)专注于求解麦克斯韦方程的精确解,但对于大型场景来说并不实用,一般用FDTD、BEM或FEM来做。

    PO基于高频近似的电磁波计算,但是对于可见光这个频率来说其实勉强够用了。PS:[Xia 2023]的黑狗毛就属于Physical Optics方法。而本文的全波参考应该还是属于Wave Solvers的方式,但是在BEM的基础上使用了PO的一些思想(等效电流等)。

    除了PO,还有一种方法介于Wave Solvers和Geometrical Optics之间,称为Hybrid GO-PO,我个人觉得应该叫做几何光学-物理光学混合方法。统一衍射理论(Uniform Theory of Diffraction, UTD)将衍射效应纳入几何光学来计算高频条件下的电磁波传输。个人理解,UTD通过计算绕射系数来补偿几何光学射线边界条件的不足,也就是说几何光学的射线也可以转弯了。这种操作在雷达探测天线设计领域非常实用。除了UTD,Hybrid GO-PO还涉及一种叫射线发射和反弹方法(Shooting and Bouncing Rays, SBR)的技术。这种技术模拟射线在物体表面多次反射,同样基于几何光学。

    我个人认为,把光理解为一种平面正弦波其实有些局限。例如3b1b的光学系列视频就把光的电场看成是一个平面正弦波。

    虽然可以解释绝大多数现象,并且非常适合入门学习,但是对于波动光学渲染的研究而言,把光的电场简单描述为平面正弦波没办法进一步解释例如本篇paper提及的高斯光束。

    但是我依旧强烈推荐不了解波动光学的读者先看这个系列的视频。

    视频通过右旋手性介质会使光“旋转”这个特殊波动光学现象,科普了一系列波动光学的有趣现象以及背后的原理。

    非常引人深思。

    最后甚至还讲到了如何利用物质波构建全息影像。

    在经典电磁场理论中,我们通常使用平面波展开电磁场,每个模式的光子在空间上是非定域的,具有无限的空间延展。这种展开方式下,一个光子处于一个频率和波矢明确的模式上,因此它在空间中“充满”了整个平面波的区域。对于自由空间中的电磁场模式非常常见,但这种平面波并不适合描述局域性。

    另一种展开方式是局域的波包(wave packet)。这种理解方式,允许我们在空间中对电磁场进行位置上的排布,即形成一系列具有一定宽度的波包。

    img

    把光单纯理解为一个正弦波其实违反了不确定性原理。如果光被视为正弦波,也就说明这个光是完全的单色光,但是现实中不可能有单色光。光信号作用基本是频域作用,空域到频域要用傅立叶变换。带宽-时间不确定性原理指出,带宽越窄,信号在时间上会越长。相反,带宽越宽,信号的时间长度越短。

    在经典电磁理论中,一个波包可以对应于一个能量集中的区域。

    但是注意光子也不能单纯理解为一种波包,而是理解为一种概率波。量子力学中的概率波函数描述了光子的存在几率。波包越集中,粒子性就越明显。这就是量子电动力学解释的波粒二象性。一个光子并不一定只能在一个波包中,它也可以描述为多个波包的叠加。因为光子状态本质上是量子场的激发,允许在不同位置的波包上进行叠加

    也就是说,一个光子可以“跨越”多个波包,即它的波函数可以在空间上以多个波包的形式存在,而不局限于某个特定位置。当一个波包模式上有一个光子时,这个波包可以看作是最低激发态;而如果波包上有多个光子(高阶激发态),则会体现出更高的能量。

    本文中提到的一种电磁波束高斯光束是一种特定的电磁波解,可以视为一种波包。高斯光束是一个具有稳定振幅和相位结构的单一波包,它在横截面上表现为高斯分布。不同于我们平常在经典电磁理论中常用的平面波解,因为高斯光束的波前是曲率变化的,不是无限延伸的平面。

    波的系综(ensemble of waves)指的是多个波的集合,这些波可能具有不同的频率、相位或传播方向。如果考虑一个系统中多个独立波包(例如多个脉冲激光束)相互叠加,可以将这些波包理解为一个波的系综。换句话说,如果有多个不相关的波包,尤其是随机相位的波包叠加在一起,它们在统计上可构成波的系综。

    当我们把电磁场展开为一系列波包,那么就可以将每个波包视为一个随机事件,他们的到达时间、相位都是随机变量。对于多个波包的集合,我们可以在不同时间、不同位置观察到这些波包的特征。在统计意义上,使用系综平均来分析光的能量分布和波动行为。


    $$
    \langle U(\vec{r}, t) \rangle = \lim_{T \to \infty} \frac{1}{2T} \int_{-T}^{T} U(\vec{r}, t) \, dt
    $$

    由于不是物理壬,微元写在积分式前我是不认可的(

    上面说到,光可以视为多个波包的集合。而不同波包之间可能会存在相位差和时间差,因此需要引入互相关函数互相干函数来描述两个不同位置的波包之间的相干性和相对相位,提供了对这些波包在时空上相似性的量化描述。如果说系综平均用于描述一个信号在统计意义下的平均行为,那么互相关函数是在时间平均的角度上,描述了不同位置和不同时间之间的相干关系波动关联性

    具体而言,互相关函数描述了位置 $\vec{r}_1$ 和 $\vec{r}_2$ 之间的波动相干性。如果两个波相干,那么这个Gamma值就会比较大:


    $$
    \Gamma(\vec{r}_1, \vec{r}_2, \tau) = \langle U(\vec{r}_1, t) U^*(\vec{r}_2, t + \tau) \rangle
    $$


    交叉谱密度(CSD)矩阵 $W(\vec{r}_1, \vec{r}_2, \omega)$ 是互相关函数的傅里叶变换,表示在频率域中两个位置之间的相干性

    还记得我们之前在Xia2023那篇黑狗毛中讨论的自相关函数吗?互相关函数需要两个信号来描述,而自相关函数只有一个信号。另一个角度来理解,自相关函数(ACF)是互相关函数的一种特殊情况。自相关函数描述的是信号与自身在不同时刻或不同位置的相关性,而互相关函数则是两个不同信号或同一信号在不同位置的相关性。互相关函数与交叉谱密度为一对傅里叶变换对,自相关函数与功率谱密度为一对傅里叶变换对。

    交叉谱密度(CSD)和互相干函数描述在不同位置间波动的相干性。而辐射互谱密度(Radiance Cross-Spectral Density, RCSD)是CSD的推广,描述的是光辐射(即能量密度)在不同位置和不同方向之间的相关性。可以理解为,RCSD在辐射度量的基础上提供了类似于CSD的相干性描述。公式中, $L(\vec{r}_1, \vec{r}_2, \omega)$ 表示在频率 $\omega$ 下,位置 $\vec{r}_1$ 和 $\vec{r}_2$ 之间的辐射强度相干性。

    辐射互谱密度传输方程(SDTE)与经典的光传输方程(Light Transport Equation, LTE)相似,但是更加适合波动光学。LTE描述的是从点到点的光辐射度传输,而SDTE则使用RCSD函数来描述光辐射的传播,相当于将传输视为区域间的相干传输。

    SDTE中的RCSD通过区域间的积分形式表达辐射的传播,可以理解为用RCSD矩阵和衍射算子代替了传统的反射和散射。并且注意,SDTE基于RCSD函数,而不是具体的光强值,这一点和LTE是有较大区别的。

    在进一步讨论利用边界元方法(Boundary Element Method, BEM)自适应积分法(Adaptive Integral Method, AIM)加速的全波参考模拟器之前,首先简单回顾下前文的内容。前文介绍了PO方法和SDTE/RCSD理论。这些方法用于不同的散射计算需求,但它们的基本理论和适用范围不同。本文将讨论一种通过BEM和AIM结合提供高精度的面散射模拟的方法。

    用原作者的话来总结,Wave optics在基于物理渲染里面属于很新的分支。虽然波动光学现象在生活中随处可见,但是对画面的影响并不是很大。这个方向其实还有很多可以做的地方。

    其他相关参考:


    A Full-Wave Reference Simulator for Computing Surface Reflectance

    Paper主页:

    https://blaire9989.github.io/assets/1_BEMsim3D/project.html

    ACM主页:

    https://dl.acm.org/doi/10.1145/3592414

    ACM Citations:

    Yunchen Yu, Mengqi Xia, Bruce Walter, Eric Michielssen, and Steve Marschner. 2023. A Full-Wave Reference Simulator for Computing Surface Reflectance. ACM Trans. Graph. 42, 4, Article 109 (August 2023), 17 pages. https://doi.org/10.1145/3592414

    演讲报告

    与更常用的光线追踪技术相比,波动光模拟更费时间但也更精确。

    比方说金属表面的微观划痕、毛发纤维结构,传统的光学模型渲染的,无法渲染出我们现实生活中观察到的五彩斑斓的色彩效果。

    基于波动光学的渲染是一个难题,因为解决麦克斯韦方程组需要大量复杂的计算。现有的基于波的外观模型通常采用一些近似方法。

    一种近似方法是标量场近似(scalar field approximation)。

    在波散射问题(wave scattering problem)中,电场和磁场是不同的矢量场量,具有不同极化方向(polarizations)的光由指向不同方向的场量组成。

    有些近似模型将这两个矢量场量替换为单一标量函数(single scalar function),因此可以计算光能的强度,但放弃了对极化的建模(modeling polarization)。

    另一种近似是一阶近似(first-order approximation)。假设光线仅与模型结构的每个部分发生一次反射,忽略了多次反射。然而,许多情况下这些近似都不适用。

    例如 Yu 等人与 Dr. Lawrence 团队的合作,Penn State University 制作了带有圆柱形横截面的表面,这些表面会引起多次光反射并产生结构色彩,使用近似模型无法很好地理解或预测这些现象。

    作者希望通过计算双向反射分布函数(BRDF)来尽可能精确地表征表面散射。

    现有模型都采用各种近似方法,比如基于光线、标量或一次近似的模型,在没有参考质量(reference quality)的BRDF的情况下,很难看出每种反射模型缺少了什么或适用于什么场景。

    作者的解决方案是构建一个三维四波模拟器(3D 4-way simulation),用于计算具有明确微观几何结构的表面的BRDF。

    声称不使用任何近似方法,为表面样本计算出参考质量的BRDF。

    速度方面dddd。

    作者接下来介绍他们的模拟如何工作以及它与BRDF的关系。

    首先,在左边这幅图,Input一个表面样本(定义为高度场)以及一个入射方向(在投影半球上表示)。定义了一个向表面传播的入射场,并从表面计算出一个散射场。

    中间这幅图,光束是入射场,背景中的散射场也显示在此图中。

    输出是对应给定入射方向的BRDF模式,半球每个点代表一个出射方向,颜色代表相应方向上反射光的颜色。BRDF以RGB颜色表示,这些颜色是从光谱数据转换而来。

    对于很多粗糙度不高的表面,反射模式围绕镜面方向对称并随入射光方向的移动而变化。

    接下来讲的是如何使用边界元法来解决仅在表面上的信号散射问题,从而降低了问题的维度。

    表面信号是从麦克斯韦方程解出的表面电流,在离散化后,将问题构建为一个线性系统,并求解出表面电流和散射场。

    为了使计算可行,作者将线性系统对称化,并使用一个适合对称矩阵的最小残差求解器。

    此外,使用自适应积分方法加速矩阵向量乘法,这是一种基于快速傅里叶变换的加速方法,最初用于雷达计算。

    代码大部分使用了Cuda C++包进行加速。

    接下来,展示了一些结果,说明其计算的BRDF与之前方法得出的BRDF的比较。

    [Yan 2018] 使用标量场近似的BRDF模型,这些模型只考虑一次折射。

    [Xia 2023] 这篇使用矢量场量但也只考虑一次折射。

    最精确的方法还得是咱们的。不仅使用矢量场量(vector field quantities),而且考虑了所有次序的反射(reflections of all orders)。

    上图每个入射方向对应五种BRDF,分别代表不同的计算方法。

    表面相对平滑的材料,表面上覆盖了一些各向同性的凸起(a bunch of isotropic bombs)。

    第一行显示的是法线入射(normal instance),反射模式基本上居中(reflection pattern is pretty much centered)。

    第二行由于入射光方向从某个倾斜方向来,模式向左偏移。

    由于表面不太粗糙,五种方法的结果非常相似。

    另一种材质呢,有一些棱棱角角(corner cubes)。每个corner cubes的三个面会让光多次反射,使反射光沿入射方向返回。叫做逆向反射(retroreflection)。

    咱们的模拟器也可以模拟这种现象。左边四种方法都败下阵来。

    原因在于如果只考虑一次反射,当光线击中corner cubes的一个面后,会被预测为向下进入下半球。

    最后的例子是一个表面覆盖了一些球形坑。

    由于坑边缘的高坡度(high slopes of the surface at the edges of the pits),导致多次反射的出现。

    不同的方法看到明显差异。

    可以看到预测的额外反射峰(extra lobe predicting)。(中间偏右那个部分)

    另外整体更亮了。

    这些差异源于不同次序反射间的干涉效果(interference between reflections of different orders)。

    最后,简单介绍高效计算非常多的密集采样方向的BRDF的技术。

    如果需要模拟的表面很大,速度就会很慢,并需要大量的GPU内存。

    但是计算是线性的,可以将大面积表面分解为多个较小的子区域。

    每个子区域上投射入射场,首先执行较小的子区域模拟,然后将散射场整合,得出整个大面积表面的BRDF。

    对不同子区域的散射场应用不同的复数值缩放因子(different complex value scale factors),就可以综合出对应不同入射方向的大面积表面的BRDF。

    这是因为对每个子区域的局部入射场,应用适当的相移会在表面上产生具有不同净方向的总入射场。在这张图中,入射场垂直传播。如果将五个焦点在不同空间位置的相同场叠加,得到一个空间上更宽的场,仍沿垂直方向传播。如果线性组合这些场,并对每个子区域的场应用适当的复数值缩放因子,整体场可以以略微倾斜的方向传播。

    这里解释一下为啥复数缩放因子(different complex value scale factors)可以产生不同的入射方向。

    • 这个因子可以调整波的幅度和相位。就比如往水面丢石头,两颗石头同时丢,水面的波就会更强。如果一颗稍微晚一些丢,波纹就可能会相互抵消(相消干涉)。这个因子就是控制石头投掷的时间。通过调整相位控制波的叠加方式,进而“引导”波向不同的方向传播。详细的去搜索「Beamforming」,雷达、无线通信和声纳等领域应用很广泛。

    这三张图代表光波的波前。即波峰。可以理解为光在传播中的波形截面。

    上方的图,垂直地射向表面,入射场分布集中在中心。光场集中在中心,沿垂直方向传播(即中间的黄线方向)。

    左下方,多个相同入射场的叠加效果,但叠加时相位保持一致(即没有相位差)。多个入射场相加,使得整个场在空间上分布更宽了,但传播方向还是保持垂直。

    右下方,多个入射场的叠加效果,但是在叠加时加入了相位差。相当于“偏移”了入射场的方向。呈现出一个倾斜方向。

    演示中,两个视频展示了BRDF模式的移动。(我懒得截GIF了)

    最后是大佬比心合影。

    论文

    我的第一篇文章已经粗略介绍了这项工作的内容和成果,接下来直接进入理论推导(Section3-5)。

    3 FULL-WAVE SIMULATION

    整体方法从一个表面模型开始,表面由高度场及其材料属性(如复折射率)描述,并指定一个目标点。

    为了计算给定入射方向的BRDF,定义了一个入射场,该场从特定方向传播至目标点。

    这个入射场作为输入,通过表面散射模拟进行处理,从而求解出相应的散射电磁场。

    在本节(FULL-WAVE SIMULATION)中,将重点介绍BEM在应用场景中的原理。下一节会讲解如何高效实现BEM算法,最后一节会介绍如何结合多个模拟结果,以合成在入射和散射方向上密集采样的BRDF。

    开局先来一张符号表吓一吓你。

    3.1 Boundary Element Method: The Basics

    边界元法(BEM)主要解决单一频率的散射问题,即特定频率的电磁波(包括电场和磁场)如何在不同介质的边界上反射和散射。这里的边界将空间分成了两个均匀区域,两个区域的材料特性(入射场所处的介质参数)用( $\epsilon_1, \mu_1$ )和( $\epsilon_2, \mu_2$ )表示。其中, $\epsilon$ 代表介电常数(介电率),$\mu$ 代表磁导率(磁导系数)。

    在这种方法中,我们处理的是复数值场量,这些场量既包含振幅信息,也包含相位信息(即波的传播状态)。为了简化公式,我们假设所有波都是“时间谐和”的——也就是波随时间按照特定的周期变化。在整个文中, $e^{j\omega t}$ 项被省略,以简化表述。

    3.1.1 Maxwell’s Equations and Surface Currents

    首先,麦克斯韦方程描述了电场(E)和磁场(H)是如何相互影响的,决定了光波如何在不同材料之间传播和散射。为了化简,这里用“时间谐和”的形式:

    $$ \begin{align} \nabla \times \mathbf{E} &= -\mathbf{M} – j \omega \mu \mathbf{H} \\ \nabla \times \mathbf{H} &= \mathbf{J} + j \omega \epsilon \mathbf{E} \tag{1} \end{align} $$


    等号左边描述电场和磁场在空间中的“旋转”程度。 $M$ 和 $J$ 是表面电流密度(假想的电流),分别表示磁流和电流的密度(electric and magnetic current densities)。这个公式可以理解为,电场在边界附近“旋转”时,产生磁流和磁场的变化;磁场的旋转也会产生电场和电流的变化。

    边界元法的核心思想是:在边界上引入表面电流,用这些电流来间接描述场的分布,而不需要计算每个区域中的所有点。三维问题化简为边界上的二维问题。

    3.1.2 Source-Field Relationships

    表面上的假想电流(电磁波的“源”)是如何产生散射的电磁场(“场”)的呢?

    如Fig2.所示,在区域 $R_1$ ,总场(入射场和散射场)分别表示为 $E_1$ 和 $H_1$ ;

    $$ \begin{align} &\mathbf{E}_1 = \mathbf{E}_i + \mathbf{E}_s \\ &\mathbf{H}_1 = \mathbf{H}_i + \mathbf{H}_s \tag{2} \end{align} $$


    在区域 $R_2$ ,总场表示为 $E_2$ 和 $H_2$ 。上方的散射场由上方的电磁流产生;下面的散射场由下方的电磁流产生。

    在均匀介质中,麦克斯韦方程组可以写成积分的形式,描述电场和磁场的生成方式。

    \begin{align} \mathbf{E}(\mathbf{r}) &= -j \omega \mu (\mathcal{L} \mathbf{J})(\mathbf{r}) – (\mathcal{K} \mathbf{M})(\mathbf{r}) \\ \mathbf{H}(\mathbf{r}) &= -j \omega \epsilon (\mathcal{L} \mathbf{M})(\mathbf{r}) + (\mathcal{K} \mathbf{J})(\mathbf{r}) \tag{3} \end{align}


    等号左边分别表示电磁场在 $r$ 处的电磁场强度,即描述的是场的“作用效果”。 $\mathcal{L}$ 和 $\mathcal{K}$ 是积分算子,表示场如何从表面电流和磁化强度产生。这两个算子定义为:

    $$ \begin{aligned} & (\mathcal{L} \mathbf{X})(\mathbf{r})=\left[1+\frac{1}{k^2} \nabla \nabla \cdot\right] \int_V G\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \mathbf{X}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} \\ & (\mathcal{K} \mathbf{X})(\mathbf{r})=\nabla \times \int_V G\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \mathbf{X}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} \end{aligned} \tag{4} $$


    $G(r, r{\prime})$ 是用于标量亥姆霍兹方程的三维格林函数,定义为:

    $$
    G(\mathbf{r}, \mathbf{r}^{\prime}) = \frac{e^{-jkr}}{4 \pi r} \quad \text{where } r = |\mathbf{r} – \mathbf{r}^{\prime}|
    \tag{5}
    $$


    这个函数把散射表面的源场转换为散射区域内电磁场的分布。

    本论文这里和[Xia 2023]中的公式(11)其实是一样的,但本论文将格林函数隐含在算子中,且积分域更为广泛。本质上都是描述如何从电流密度 $\mathbf{J}$ 和磁流密度 $\mathbf{M}$ 生成散射电场 $E(r)$。

    求解麦克斯韦方程时,通过格林函数来整合各处的源(如电流、电荷)对空间中电磁场的影响。假设电磁场以 $e^{j\omega t}$ 的形式随时间变化(单频),就可以得到类似于亥姆霍兹方程的形式:$(\nabla^2 + k^2) \mathbf{E} = -j \omega \mu \mathbf{J}$ ,实际上这是一种“频域”形式的麦克斯韦方程。引入格林函数建立一种源-场关系,即将电流 $\mathbf{J}$ 和电荷 $\rho$ 作为“源”来计算电磁场 $\mathbf{E}$ 和 $\mathbf{H}$ 的分布。那么格林函数是满足如下方程的:$(\nabla^2 + k^2) G(\mathbf{r}, \mathbf{r}{\prime}) = -\delta(\mathbf{r} – \mathbf{r}{\prime})$ ,$\delta$ 是狄拉克δ函数,表示“点源”在空间中产生的标准波形,描述的是在空间中由一个“点源”激发的波场。通过格林函数,可以表达任意电流分布 $\mathbf{J}$ 在空间中对场点 $\mathbf{r}$ 产生的影响了!接着,每个源点上的“电流”或“电荷”都通过格林函数扩散到整个空间,对每一个场点产生累积影响。$\mathbf{E}(\mathbf{r}) = \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{J}(\mathbf{r}{\prime}) d \mathbf{r}{\prime}$ 这公式就是电场表示为源电流的积分叠加。最后,结合麦克斯韦方程组的积分形式和格林函数的思想。比如电场的积分形式可以表示为:$\mathbf{E}(\mathbf{r}) = -j \omega \mu \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{J}(\mathbf{r}{\prime}) d \mathbf{r}{\prime} – \nabla \times \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{M}(\mathbf{r}{\prime}) d \mathbf{r}{\prime}$ ,通过卷积可以获得每个源点上的微小电流分布对场点的累积影响,这是通过格林函数的积分在空间中传播和叠加的结果。格林函数作为卷积核,将空间中各点的电流分布通过积分传播到目标场点,实现了空间中各源点电流对整个场的累积影响。

    介质区域 $R_1$ 和 $R_2$ 内的电场就分别表示为公式(6)(7),包含了两个算子。

    在区域 $R_2$ 内:

    $$ \begin{align} & \mathbf{E}_1(\mathbf{r}) = -j \omega \mu_1 (\mathcal{L}_1 \mathbf{J}_1)(\mathbf{r}) – (\mathcal{K}_1 \mathbf{M}_1)(\mathbf{r}) \\ & \mathbf{H}_1(\mathbf{r}) = -j \omega \epsilon_1 (\mathcal{L}_1 \mathbf{M}_1)(\mathbf{r}) + (\mathcal{K}_1 \mathbf{J}_1)(\mathbf{r}) \end{align} \tag{6} $$


    在区域 $R_2$ 内:

    \begin{align} \mathbf{E}_2(\mathbf{r}) &= -j \omega \mu_2 (\mathcal{L}_2 \mathbf{J}_2)(\mathbf{r}) – (\mathcal{K}_2 \mathbf{M}_2)(\mathbf{r}) \\ \mathbf{H}_2(\mathbf{r}) &= -j \omega \epsilon_2 (\mathcal{L}_2 \mathbf{M}_2)(\mathbf{r}) + (\mathcal{K}_2 \mathbf{J}_2)(\mathbf{r}) \tag{7} \end{align}


    这样就得到了在不同介质区域中电场和磁场的表现形式。

    总结一下,这一小节通过假想的表面电流产生散射电磁场,将麦克斯韦方程组转化为积分表达,格林函数将电流和电荷分布转化为电磁场的积分叠加,展示了源-场关系的具体实现方式。最后给出了区域 $R_1$ 和 $R_2$ 内电场和磁场的表达式,展示了不同介质参数对场的影响。

    3.1.3 Boundary Conditions

    当电磁波在两种不同介质的边界传播时,会发生反射和折射。此时波的能量不可能凭空消失,而是在界面上平滑过渡的。如果电场或磁场在边界上不连续,就会出现不符合实际的能量跃变(即能量突然消失或增加),这违反了能量守恒。

    具体可以搜索:「Interface conditions for electromagnetic fields」。

    因此,必须在介质边界上满足一定的边界条件,以确保电磁场的连续性和能量守恒。具体而言,当电磁波在两种不同介质的界面上传播时,电场和磁场的切向分量(tangential component)需要在边界上保持连续性:


    $$
    \begin{aligned}
    & \mathbf{n} \times (\mathbf{E}_1 – \mathbf{E}_2) = 0 \\
    & \mathbf{n} \times (\mathbf{H}_1 – \mathbf{H}_2) = 0
    \end{aligned}
    \tag{8}
    $$


    并且边界上的净电磁流密度为零,即边界两侧电磁流密度方向相反大小相等。


    $$
    \begin{aligned}
    & \mathbf{J} = \mathbf{J}_1 = -\mathbf{J}_2 \\
    & \mathbf{M} = \mathbf{M}_1 = -\mathbf{M}_2
    \end{aligned}
    \tag{9}
    $$


    这两个条件同时满足,才不会破坏物理守恒。

    3.1.4 Integral Equations

    结合上面公式 (6)、(7)、(8) 和 (9),得到关于电场和磁场的积分方程,称之为 PMCHWT(Poggio-Miller-Chang-Harrington-Wu-Tsai)方程:

    $$
    \begin{aligned}
    & {\left[j \omega \mu_1\left(\mathcal{L}1 \mathbf{J}\right)(\mathbf{r})+j \omega \mu_2\left(\mathcal{L}_2 \mathbf{J}\right)(\mathbf{r})+\left(\mathcal{K}_1 \mathbf{M}\right)(\mathbf{r})+\right.}\left.\left(\mathcal{K}_2 \mathbf{M}\right)(\mathbf{r})\right]{\tan } \\
    &=\left[\mathbf{E}i(\mathbf{r})\right]{\tan } \\
    & {\left[\left(\mathcal{K}1 \mathbf{J}\right)(\mathbf{r})+\left(\mathcal{K}_2 \mathbf{J}\right)(\mathbf{r})-j \omega \varepsilon_1\left(\mathcal{L}_1 \mathbf{M}\right)(\mathbf{r})-j \omega \varepsilon_2\left(\mathcal{L}_2 \mathbf{M}\right)(\mathbf{r})\right]{\tan } } \\
    &=-\left[\mathbf{H}i(\mathbf{r})\right]{\tan }
    \end{aligned}
    \tag{10}
    $$


    这两个方程分别还有个名字:

    • EFIE(Electric Field Integral Equation),电场积分方程。 $j \omega \mu_1 (\mathcal{L}_1 \mathbf{J})(\mathbf{r})$ 和 $j \omega \mu_2 (\mathcal{L}_2 \mathbf{J})(\mathbf{r})$ 表示在介质 1 和 介质 2 中,电流密度 $\mathbf{J}$ 对电场的贡献。 $\mathcal{K}_1 \mathbf{M}$ 和 $\mathcal{K}_2 \mathbf{M}$ 表示在介质 1 和介质 2 中,磁流密度 $\mathbf{M}$ 对电场的贡献。
    • MFIE(Magnetic Field Integral Equation),磁场积分方程。

    总的来说,这是一种边界积分方程,专门用来求介电物体引起的电磁散射问题。有了这个PMCHWT方程,就可以准确计算电磁场的分布了。

    3.1.5 Solving for Current Densities

    这一节,需要通过上面的PMCHWT方程计算物体表面上的“电流”和“磁流”分布,这些分布决定了电磁波碰到物体后会怎么“反射”或者“折射”。

    求解表面电流密度 $\mathbf{J}$ 和磁流密度 $\mathbf{M}$ 是通过将边界元离散化。对于离散单元定义一个基函数 $f_m(\mathbf{r})$ ,用基函数展开法表示电流密度和磁流密度分布。


    $$
    \mathbf{J}(\mathbf{r}) = \sum_{m=1}^{N} I_{J_m} f_m(\mathbf{r}); \quad \mathbf{M}(\mathbf{r}) = \sum_{n=1}^{N} I_{M_n} f_n(\mathbf{r})
    \tag{11}
    $$


    N 是基函数的总数;$ I_{J_m}$ 和 $I_{M_n}$ 是对应基函数的未知系数,代表了每个单元上的电流和磁流强度。

    通过这种基函数展开,连续的表面电流密度和磁流密度被分解成一系列基函数的线性组合。

    为了求解对应基函数的未知系数,将电场积分方程 (EFIE) 和磁场积分方程 (MFIE) 转化为一个线性方程组。具体使用伽辽金法(Galerkin Method)完成。这个方法基本思想是,将积分方程作用在每个基函数上,加权平均使得积分方程在每个基函数的投影方向上都成立。该方法简单的说就是离散化、找基底、算系数。一个高维的线性方程组可以用线性代数方法简化。

    这样可以将原本连续形式的PMCHWT方程的EFIE部分转换为有限个线性方程,把问题转化为解如下矩阵方程。


    $$
    \begin{bmatrix} A_{EJ} & A_{EM} \ A_{HJ} & A_{HM} \end{bmatrix} \begin{bmatrix} I_J \ I_M \end{bmatrix} = \begin{bmatrix} V_E \ V_H \end{bmatrix}
    \tag{12}
    $$


    其中


    $$
    A_{\mathrm{EJ}}^{m n} =\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[j \omega \mu_1\left(\mathcal{L}_1 \mathbf{f}_n\right)(\mathbf{r})+j \omega \mu_2\left(\mathcal{L}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{13}
    $$

    $$
    A_{\mathrm{EM}}^{m n} =\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[\left(\mathcal{K}_1 \mathbf{f}_n\right)(\mathbf{r})+\left(\mathcal{K}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{14}
    $$

    $$
    A_{\mathrm{HJ}}^{m n} =\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[\left(\mathcal{K}_1 \mathbf{f}_n\right)(\mathbf{r})+\left(\mathcal{K}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{15}
    $$

    $$
    A_{\mathrm{HM}}^{m n} =-\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[j \omega \varepsilon_1\left(\mathcal{L}_1 \mathbf{f}_n\right)(\mathbf{r})+j \omega \varepsilon_2\left(\mathcal{L}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{16}
    $$


    $$
    V_{\mathrm{E}}^m =\int_S \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{E}_i(\mathbf{r}) d \mathbf{r}\tag{17}
    $$

    $$
    V_{\mathrm{H}}^m =-\int_S \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{H}_i(\mathbf{r}) d \mathbf{r}\tag{18}
    $$

    公式(12)中,需要求出 $I_J$ 和 $I_M$ 。

    不严谨地讲,公式(13)-(16)分别表示每一小块的电流密度对产生电场的贡献、每个小块的磁流密度对产生电场的贡献、每个小块的电流密度对产生磁场的贡献和每个小块的磁流密度对产生磁场的贡献。公式(17)(18)分别表示外界入射电场对这个小块电流的“推动力”和外界入射磁场对这个小块磁流的“推动力”。强调一下矩阵里面的元素,比如 $A_{EJ}^{mn}$ ,这其实是一个二重积分。由于对源点 $\mathbf{r}{\prime}$ 的积分已经在 $\mathcal{L}_1$ 和 $\mathcal{L}_2$ 中完成了,因此导致原paper看起来是个一重积分。

    虽然原论文没有写,但是建议聪明的读者自己推导一下。尝试根据上文提到的公式(4)把公式(13)展开。我这里尝试推导了一下,有错误请指正。首先把两个算子代入,注意这里梯度算子的位置:


    $$
    \begin{aligned}
    A_{\mathrm{EJ}}^{mn} &= j \omega \mu_1 \int_S \mathbf{f}_m(\mathbf{r}) \cdot \left\{ \int_V G_1(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} + \frac{1}{k_1^2} \nabla \left[ \nabla \cdot \int_V G_1(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} \right] \right\} d\mathbf{r} \\
    &\quad + j \omega \mu_2 \int_S \mathbf{f}_m(\mathbf{r}) \cdot \left\{ \int_V G_2(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} + \frac{1}{k_2^2} \nabla \left[ \nabla \cdot \int_V G_2(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} \right] \right\} d\mathbf{r}
    \end{aligned}
    $$

    先考虑其中一个梯度项:

    $$
    \int_S \mathbf{f}_m(\mathbf{r}) \cdot \nabla \left[ \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} \right] d\mathbf{r}
    $$

    用向量分布积分展开:

    $$
    \int_S \mathbf{f}m \cdot \nabla B \, d r = -\int_S B (\nabla \cdot \mathbf{f}_m) \, dr + \int{\partial S} B (\mathbf{A} \cdot \mathbf{n}) \, dr
    $$

    其中,散度项 $B$ :

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime}
    $$

    但是很抱歉,这里是物理。在边界条件下,边界项直接简化为零,得到:

    $$
    \int_S \mathbf{f}_m \cdot \nabla B \, dS = -\int_S B (\nabla \cdot \mathbf{f}_m) \, dS
    $$

    对于散度项 $B$ ,可以直接展开散度:

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = \int_V (\nabla \cdot G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime}
    $$

    一个是标量函数,一个是矩阵函数,因此根据散度的乘积法则:

    $$
    \nabla \cdot (G \mathbf{f}_n) = (\nabla G) \cdot \mathbf{f}_n + G (\nabla \cdot \mathbf{f}_n)
    $$

    但是很抱歉,这里是物理。由于基函数满足无散度条件,因此这里直接简化:

    $$
    \nabla \cdot (G \mathbf{f}_n) = (\nabla G) \cdot \mathbf{f}_n
    $$

    同时我们注意到格林函数的对称性 $\nabla G(\mathbf{r}, \mathbf{r}{\prime}) = -\nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime})$ ,于是有:

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = -\int_V \nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime}) \cdot \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime}
    $$

    对这一项也进行分布积分:

    $$
    \int_V \nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime}) \cdot \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = \int_V \nabla{\prime} \cdot (G \mathbf{f}_n) d\mathbf{r}{\prime} – \int_V G (\nabla{\prime} \cdot \mathbf{f}_n) d\mathbf{r}{\prime}
    $$

    但是很抱歉,这里是物理。边界项再次化简:

    $$
    \int_V \nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime}) \cdot \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = -\int_V G(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime}
    $$

    最终得到:

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = \int_V G(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime}
    $$

    波数 $k_i$ 于介质参数的关系:

    $$
    k_i^2 = \omega^2 \mu_i \varepsilon_i \quad \Rightarrow \quad \frac{1}{k_i^2} = \frac{1}{\omega^2 \mu_i \varepsilon_i}
    $$

    另一个梯度项也是同理,最后得到最终的表达式 $A_{\mathrm{EJ}}^{mn}$ :

    $$
    \begin{aligned} A_{\mathrm{EJ}}^{mn} &= j \omega \mu_1 \int_S \int_{V_1} \mathbf{f}m(\mathbf{r}) \cdot \mathbf{f}n(\mathbf{r}{\prime}) G_1(\mathbf{r}, \mathbf{r}{\prime}) d\mathbf{r}{\prime} d\mathbf{r} \\ &\quad – \frac{j}{\omega \varepsilon_1} \int_S \int{V_1} (\nabla \cdot \mathbf{f}m(\mathbf{r})) G_1(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}n(\mathbf{r}{\prime})) d\mathbf{r}{\prime} d\mathbf{r} \\ &\quad + j \omega \mu_2 \int_S \int{V_2} \mathbf{f}m(\mathbf{r}) \cdot \mathbf{f}n(\mathbf{r}{\prime}) G_2(\mathbf{r}, \mathbf{r}{\prime}) d\mathbf{r}{\prime} d\mathbf{r} \\ &\quad – \frac{j}{\omega \varepsilon_2} \int_S \int{V_2} (\nabla \cdot \mathbf{f}_m(\mathbf{r})) G_2(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime} d\mathbf{r} \end{aligned}
    $$

    同样的操作,得到剩下的矩阵元素,这里直接抄论文附加材料的内容了:

    $$
    \begin{aligned} A_{\mathrm{EM}}^{m n}= & A_{\mathrm{HJ}}^{m n}=\int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot\left[\nabla G_1\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \times \mathbf{f}_n\left(\mathbf{r}^{\prime}\right)\right] d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot\left[\nabla G_2\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \times \mathbf{f}_n\left(\mathbf{r}^{\prime}\right)\right] d \mathbf{r}^{\prime} d \mathbf{r} \\ A_{\mathrm{HM}}^{m n} & =-j \omega \varepsilon_1 \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) G_1\left(\mathbf{r}, \mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\frac{j}{\omega \mu_1} \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) G_1\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -j \omega \varepsilon_2 \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) G_2\left(\mathbf{r}, \mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\frac{j}{\omega \mu_2} \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) G_2\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r}\end{aligned}
    $$

    得到矩阵的每一个元素之后,引入平移不变函数(Shift-invariant Functions)。帮助得到在不同坐标系下表示格林函数及其梯度。

    $$
    \begin{aligned}
    & g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=\frac{e^{-j k_i r}}{4 \pi r} \\
    & g_{2, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=\hat{\mathbf{x}} \cdot \nabla G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=-\left(x-x^{\prime}\right)\left(\frac{1+j k_i r}{4 \pi r^3}\right) e^{-j k_i r} \\
    & g_{3, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=\hat{\mathbf{y}} \cdot \nabla G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=-\left(y-y^{\prime}\right)\left(\frac{1+j k_i r}{4 \pi r^3}\right) e^{-j k_i r} \\
    & g_{4, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=\hat{\mathbf{z}} \cdot \nabla G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=-\left(z-z^{\prime}\right)\left(\frac{1+j k_i r}{4 \pi r^3}\right) e^{-j k_i r} \quad \text { where } r=\left|\mathbf{r}-\mathbf{r}^{\prime}\right|
    \end{aligned}
    $$

    然后将矩阵元素展开为基函数的不同分量的组合,分量作用在平移不变函数上。最终矩阵的所有元素,都有如下形式。这样的形式可以加速边界元矩阵的构造和求解。

    $$
    \int_{\mathbf{f}m} \int{\mathbf{f}_n} \psi_m(\mathbf{r}) g\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \xi_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r}
    $$

    最终得到:


    Here, $x, y, z, x^{\prime}, y^{\prime}, z^{\prime}$ are the Cartesian components of $\mathbf{r}, \mathbf{r}^{\prime}$. Now we have for $i=1,2$ :

    $$ \begin{aligned} A_{\mathrm{EJ}, i}^{m n} &= j \omega \mu_i \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_{m x}(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \mathbf{f}_{n x}\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \\ & \quad + j \omega \mu_i \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_{m y}(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \mathbf{f}_{n y}\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \\ & \quad + j \omega \mu_i \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_{m z}(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \mathbf{f}_{n z}\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \\ & \quad – \frac{j}{\omega \varepsilon_i} \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \end{aligned} \tag{S.18} $$

    where $\mathbf{f}{m x}, \mathbf{f}{m y}, \mathbf{f}_{m z}$ are the $x, y, z$ components of the vector basis function $\mathbf{f}_m$ . Similarly, we have:

    $$ \begin{aligned} & A_{\mathrm{EM}, i}^{m n}=A_{\mathrm{HJ}, i}^{m n}=\int_{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m z}(\mathbf{r}) g_{2, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n y}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m y}(\mathbf{r}) g_{2, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n z}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m x}(\mathbf{r}) g_{3, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n z}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m z}(\mathbf{r}) g_{3, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n x}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m y}(\mathbf{r}) g_{4, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n x}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m x}(\mathbf{r}) g_{4, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}_{n y}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \end{aligned} \tag{S.19} $$

    Lastly, we also have:

    $$ \begin{aligned} A_{\mathrm{HM}, i}^{m n} & =-j \omega \varepsilon_i \int_{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m x}(\mathbf{r}) g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n x}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -j \omega \varepsilon_i \int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m y}(\mathbf{r}) g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n y}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -j \omega \varepsilon_i \int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m z}(\mathbf{r}) g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n z}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\frac{j}{\omega \mu_i} \int{\mathbf{f}m} \int{\mathbf{f}n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) g{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \end{aligned} \tag{S.20} $$


    这部分代码在MVProd类中,看不懂也没问题,因为上面都是我瞎写瞎抄的,已经超出图形学的研究范畴了。

    另外作者还讨论了EFIE和MFIE的对称性。正是这种对称性使得计算效率和空间利用率更低。注意到:


    $$
    A_{EJ} = A_{EJ}^T, \quad A_{HM} = A_{HM}^T \tag{19}
    $$

    $$
    A_{EM} = A_{EM}^T, \quad A_{HJ} = A_{HJ}^T, \quad A_{EM} = A_{HJ}\tag{20}
    $$

    由于矩阵是对称的,我们不需要计算所有的矩阵元素,也不用存储所有矩阵元素。

    在解出表面电流密度后,可以再使用公式(6)来计算从散射表面向外传播的散射场。

    3.2 Rough Surface Scattering: The Specifics

    在模拟电磁波与粗糙表面之间的相互作用时,表面的不规则几何结构会对散射特性造成显著影响。作者对粗糙表面离散化,将连续的表面划分为多个单元,每个单元都可以应用PMCHWT方程做数值计算的方法。

    3.2.1 Rough Surface Samples

    将粗糙表面表示为一个二维的高度场(height field),然后将该高度场进行离散化处理,划分为多个矩形单元。

    每次模拟只考虑尺寸为 $$L_x \times L_y$$ 的表面样本。并且选择一个步进 $d$ 来定义离散化的网格。


    $$
    x_s = s \cdot d, \quad s = 0, 1, \ldots, N_x
    \
    y_t = t \cdot d, \quad t = 0, 1, \ldots, N_y
    \tag{21}
    $$


    其中,$ N_x = L_x / d$ 和 $N_y = L_y / d$ ,分别表示在 $x$ 和 $y$ 方向上被划分成的单元数。在每个离散点 $(x_s, y_t)$ 都有一个高度场 $h(x_s, y_t)$ 函数。

    作者总结了,粗糙表面的高度变化尺度非常小,通常只有几微米。也就是和可见光电磁波波长相当。

    3.2.2 Basis Elements and Functions

    每个基元有四个角,每个角有不同的高度,并且每个角对电流磁流贡献都不同。因此在每个小方块上,都定义了四个基函数,近似表示每个小方块上的情况。

    在大多数模拟中,步长 $d$ 选取为波长的 $\lambda / 16$ 左右,以确保精度。

    每个基元由两个参数 u 和 v 参数化,范围均在 [-1, 1]。

    基元的形状通过一个双线性函数 $\mathbf{r}(u, v)$ 来表示,其中 $(s, t)$ 表示当前基元的索引,且基元由四个顶点的坐标决定:

    $$ \begin{aligned} \mathbf{r}(u, v) = &\frac{(1 – u)(1 – v)}{4} \mathbf{p}_{s-1, t-1} + \\ &\frac{(1 – u)(1 + v)}{4} \mathbf{p}_{s-1, t} + \\ &\frac{(1 + u)(1 – v)}{4} \mathbf{p}_{s, t-1} + \\ &\frac{(1 + u)(1 + v)}{4} \mathbf{p}_{s, t} \end{aligned} \tag{22} $$

    其中, $\mathbf{p}{s, t} = (x_s, y_t, z{s, t})$ 是基元的四个顶点坐标。

    在每个矩形基元上定义四个基函数 $f_1(u, v), f_2(u, v), f_3(u, v), f_4(u, v)$ ,它们的形式为:


    $$
    \begin{aligned}
    & f_1(u, v) = \frac{(1 – u)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial u}, \quad f_2(u, v) = \frac{(1 + u)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial u} \\
    & f_3(u, v) = \frac{(1 – v)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial v}, \quad f_4(u, v) = \frac{(1 + v)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial v}
    \end{aligned}
    \tag{23}
    $$


    这里的 Jacobian $J(u, v)$ 表示如下:


    $$
    J(u, v) = \left| \frac{\partial \mathbf{r}(u, v)}{\partial u} \times \frac{\partial \mathbf{r}(u, v)}{\partial v} \right|
    \tag{24}
    $$


    Jacobian 的引入用于转换坐标系并确保基函数在不同的 $u, v$ 方向上具有合适的比例关系。

    3.2.3 Gaussian Beam Incidence

    由于不太了解光学,以下为个人理解。高斯光束是激光发射出去的时候,在行波场中间部分出现往内部凹陷的一种现象,换一句话说高斯光束是描述光在横截面上的能量分布。而平面波和球面波的重点是描述能量传播的方向。在传播过程中,高斯光束的波前形状近似为球面波。

    Gouy 相位(Gouy phase)是高斯光束传播中的一种相位延迟效应,光束通过焦点后相位会额外增加。另一方面,高斯光束满足麦克斯韦方程组在傍轴条件下的一个解,可以近似为非均匀的球面波。感觉目前也不用太深入研究。

    有关高斯光束的资料:

    回到原论文,高斯光束好处在于可以控制入射场的大小,进而能够控制一个稍微比照射区域大的区域的表面感应电流密度是非零数值。

    高斯光束是一种电磁波,其振幅在垂直于传播方向的平面上呈二维高斯分布[Paschotta 2008],它的能量主要集中在光束中心附近。看上方图(a),高斯光束可以用焦平面 $P$ 、中心点 $o$ 、和光束腰径(beam waist) $w$ 来描述。场强随位置的衰减关系为 $e^{-r^2 / w^2}$ ,当距离中心超过 $2.5w$ 时,场强衰减至极小,可认为几乎为零。

    虽然但是,高斯光束也具有一定的发散性。发散角 $\theta$ 近似与波长 $\lambda$ 成正比,与光束腰径 $w$ 成反比。公式:


    $$
    \theta = \frac{\lambda}{\pi \eta w} \tag{25}
    $$


    当光束斜着射入表面时,高斯光束在焦平面上有一个椭圆形的横截面。如下图所示。在两个垂直方向上有不同的腰斑尺寸(beam waist):一个平行于入射方向和表面法线的平面,另一个与之垂直。

    为了保证不同方向上的高斯光束在表面上的照射区域相同,这里引入了两个横向方向的束腰宽度:


    $$
    w_1 = w, \quad w_2 = w \cos \theta_i\tag{26}
    $$


    即使在不同入射角度下,表面上的照射面积保持一致。这对推导BRDF而言非常重要。

    4. IMPLEMENTATION AND ACCELERATION

    但是如果按上面的方法硬算,是不可能会有结果的。因此需要一些加速手段。

    4.1 The Adaptive Integral Method

    想要直接计算上面公式(12)的方程组,计算量是不可接受的。

    $$ \begin{bmatrix} A_{EJ} & A_{EM} \ A_{HJ} & A_{HM} \end{bmatrix} \begin{bmatrix} I_J \ I_M \end{bmatrix} = \begin{bmatrix} V_E \ V_H \end{bmatrix} \tag{12} $$


    按照原本的思路,用 $N$ 个基函数来表示电流磁流密度,矩阵的规模就是 $2N \times 2N$ 。如果直接求解矩阵(LU分解,Cholesky分解等),总复杂度可能在 $\mathcal{O}(N^3)$ 。就算用共轭梯度法总复杂度也在 $\mathcal{O}(N^2)$ 。在一些小规模的仿真中,基函数的规模大概在 $960*960$ ,存储需求大概就在 29.4GB 。用了Adaptive Integral Method, AIM,按8字节来算总存储需求约在 76.8 MB。究竟是什么方法这么神奇呢?小编接下来就带大家一起看看吧!

    4.1.1 Approximating Matrix Elements

    AIM最初是由Bleszynski等人[1996]提出的。AIM的核心思想是将每个基函数的作用近似为一组点源的作用,避免直接计算每一对基函数之间的精确相互作用,同时通过FFT将各基函数的影响传播开来,提升计算效率。

    AIM中矩阵元素的计算方式是对矩阵元素的某些项的线性组合来近似计算。这就是为什么在上面公式(13)-(16)后面让读者们进一步推导,推导最终的结果使其符合AIM的形式。

    $$
    \int_{f_m} \int_{f_n} \psi_m(r) g(r – r{\prime}) \xi_n(r{\prime}) \, dr{\prime} \, dr
    \tag{27}
    $$


    AIM首先会在包含电磁场和场源的空间内创建一个全局3D笛卡尔网格,如下图(6)所示。

    为了进一步化简公式(27),AIM算法会让原本的基函数近似为在这个三维笛卡尔坐标上一组网格点上的点源。说白了就是连续变离散,并且方便后续FFT。

    $$ \psi_m(r) \approx \tilde{\psi}m(r) := \sum{p \in S_m} \Lambda_{mp} \delta^3(r – p) \\ \xi_n(r{\prime}) \approx \tilde{\xi}n(r{\prime}) := \sum{q \in S_n} \Lambda{\prime}_{nq} \delta^3(r{\prime} – q) \tag{28} $$

    将公式(27)代入公式(28),换句话说,就是将双重积分的形式转化为了一个双重求和的形式。

    $$
    \sum{p \in S_m} \sum_{q \in S_n} \Lambda_{mp} g(p – q) \Lambda{\prime}_{nq}
    \tag{29}
    $$

     

    方法详细参考:

    Kai Yang and Ali E Yilmaz. 2011. Comparison of precorrected FFT/adaptive integral method matching schemes. Microwave and Optical Technology Letters 53, 6 (2011), 1368–1372.

    4.1.2 Base and Correction Matrices

    基于公式(29)定义一组基础近似(base approximation)矩阵 $B_{EJ}, B_{EM}, B_{HJ}, B_{HM}$ 作为近似,专门处理距离较远的基函数对。这些矩阵通过引入 $\Lambda$ 矩阵和卷积等操作化简计算。同时,对于距离较近($d_{near}$)的基函数对,再引入修正矩阵(Correction Matrices)来减少误差。 $C_{EJ}, C_{EM}, C_{HJ}, C_{HM}$ 是一种稀疏矩阵,定义如下:

    $$ C_{\mathrm{X}}^{m n}=\left\{\begin{array}{ll} A_{\mathrm{X}}^{m n}-B_{\mathrm{X}}^{m n} & d_{m n} \leq d_{\text {near }} \\ 0 & \text { otherwise } \end{array} \quad \mathrm{X} \in\{\mathrm{EJ}, \mathrm{EM}, \mathrm{HJ}, \mathrm{HM}\}\right. \tag{30} $$

    $A_X^{mn}$ 是原始矩阵的精确值,而 $B_X^{mn}$ 是基础矩阵的近似值。通过减去基础矩阵的近似值,得到一个较准确的修正项,用于补偿近距离基函数对的误差。

    综上所述,AIM方法中每个矩阵的最终近似形式可以写成如下关系:

    $$ \begin{aligned} A_{\mathrm{EJ}} \approx B_{\mathrm{EJ}}+C_{\mathrm{EJ}} ; & A_{\mathrm{EM}} \approx B_{\mathrm{EM}}+C_{\mathrm{EM}} ; \\ A_{\mathrm{HJ}} \approx B_{\mathrm{HJ}}+C_{\mathrm{HJ}} ; & A_{\mathrm{HM}} \approx B_{\mathrm{HM}}+C_{\mathrm{HM}} \end{aligned} $$


    换句话说,原始矩阵可以通过基础矩阵和修正矩阵的组合来近似。

    4.1.3 Fast Matrix-Vector Multiplication

    快速矩阵-向量乘法(Fast Matrix-Vector Multiplication)是AIM的核心。

    由于上面得到的修正矩阵 $C$ 是稀疏矩阵, $C$ 只在近距离的基函数上有非零值,因此矩阵 $C$ 的乘法操作是很快的。

    利用基础近似矩阵 $B$ 的卷积特性,计算了矩阵 $B$ 与向量的乘积。计算过程分为三步:


    $$
    y_1 = \Lambda_2^T x, \quad y_2 = G y_1, \quad y_3 = \Lambda_1 y_2
    \tag{32}
    $$


    第一步把向量投影到一个稀疏矩阵网格上。

    第二步也是核心步骤,把网格点的数据传播到整个网络,也就是计算每个点对其他点的影响。两个点比较接近,矩阵 $G$ 中的传播函数就大。通过FFT来做加速。


    $$
    y_2 = \mathcal{F}^{-1} { \mathcal{F}(g) \mathcal{F}(y_1) }
    \tag{33}
    $$


    第三步骤将结果映射回原来的基函数空间。

    4.2 GPU-Accelerated Iterative Solving

    在GPU上将AIM方法中的计算重点转移到快速傅里叶变换(FFT)和稀疏矩阵操作上。总结一下,目前将大型矩阵分为基础矩阵 $B$ 和修正矩阵 $C$ ,分别处理远距离和近距离的基函数对。

    • cuFFT:将基础矩阵的乘法操作转换为频域中的卷积计算
    • cuSPARSE:算稀疏矩阵加速修正矩阵 C

    并且优化计算策略:

    • 对于小规模仿真任务,只需要使用1个GPU
    • 对于大规模仿真任务,将任务分配到4个GPU
    4.2.1 Small-Scale Simulations

    对于小规模仿真任务(例如 $12 \mu m \times 12 \mu m$), 必须事先计算并存储传播函数的傅里叶变换值(即矩阵 $G$ 的傅里叶变换)。在小规模任务中稀疏修正矩阵 C 占用的显存不到5GB,一张GPU就可以搞掂。

    4.2.2 Large-Scale Simulations

    对于大规模仿真任务(例如 $24 \mu m \times 24 \mu m$),由于单个GPU的显存不足以存储所有数据,作者将计算任务分配到4个GPU上。在这个尺度的仿真上,基函数的个数会达到960 × 960个,存储所有修正矩阵的非零元素(包括行列索引和复数浮点数值)大约需要20GB显存。策略还是和小规模一样,每个GPU分配大约5GB内存来存储修正矩阵 $C$ 。

    MINRES求解器在主机CPU上执行,而矩阵-向量乘积 $y = Ax$ 的计算在GPU上完成。但是不需要担心传输时间,这个向量大概只有30MB。

    4.3 FFT-Accelerated Scattered Field Evaluation

    用FFT加速计算散射场。在远场区域上评估从表面散射的场最终求出表面BRDF。

    在求解BEM后,得到了表面的电流密度 $\mathbf{J}$ 和磁流密度 $\mathbf{M}$ 。这些密度分布定义了表面上的电磁源,可以用来计算在远场区域上的散射场。公式很简单,随着距离衰弱同时还会具有一定的相位变化:


    $$
    \mathbf{E_s}(r) \approx \mathbf{E}(\hat{r}) \frac{e^{-jkr}}{r}; \quad \mathbf{H_s}(r) \approx \mathbf{H}(\hat{r}) \frac{e^{-jkr}}{r}
    \tag{36}
    $$


    公式右边的 $\mathbf{E}(\hat{r})$ 和 $\mathbf{H}(\hat{r})$ 是在远场上特定方向 $\hat{r}$ 的振幅。在不同的方向上,散射场的强度可能不同。


    $$
    \begin{aligned}
    F_1(\hat{\mathbf{r}})=\int_V J_x\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} ; & F_2(\hat{\mathbf{r}})=\int_V J_y\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} \\
    F_3(\hat{\mathbf{r}})=\int_V J_z\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} ; & F_4(\hat{\mathbf{r}})=\int_V M_x\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} \\
    F_5(\hat{\mathbf{r}})=\int_V M_y\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} ; & F_6(\hat{\mathbf{r}})=\int_V M_z\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime}
    \end{aligned}
    \tag{37}
    $$


    为了避免直接求解这些积分,作者利用先前(4.1.2)对 $\mathbf{J}$ 和 $\mathbf{M}$ 的点源近似($\Lambda$ 矩阵),将每个积分项 $F_i(\hat{r})$ 离散化并重写为傅里叶变换的形式,如公式 (38) 所示:


    $$
    F_i(\hat{r}) = \sum_{p \in S} h_i(p) e^{jp \cdot k \hat{r}}
    \tag{38}
    $$


    将连续的场强计算转化为离散的求和,这样就可以用FFT快速计算了。并且从上式可以观察到, $F_i(\hat{r})$ 实际上是 $h_i(p)$ 在空间频率 $-k\hat{r}$ 上的傅里叶分量。

    The required spatial frequencies are not on the FFT grid but can be interpolated; we add zero padding prior to the FFT step, to ensure enough resolution in the frequency domain for the trilinear interpolation to be sufficiently accurate.

    5 HIGH RESOLUTION BRDF GENERATION

    搞了一大堆,终于回到熟悉的BRDF计算了。这里关键在于使用小尺度模拟的线性叠加来重建大尺度入射场的远场散射,而不是一口吃成大胖子。$N^2$个小尺度的高斯光束构成的网格来线性组合成近似大尺度的入射场。

    这里用到“波束引导”(beam steering)技术。这种方法不用对每个方向都进行一次模拟,从而大幅降低计算成本。

    5.1 Basic and Derived Incident Directions

    首先,沿某方向 $\mathbf{u}$ 传播的 $N^2$ 个高斯光束组成在接收平面的一个 $N \times N$ 点的网格。这些光束组合后能够生成一个大的总场。

    然后给每个高斯光束引入复数缩放因子,调整每个光束的相位,进而调整组合场的传播方向,这些方向称为desired direction。


    $$
    a_{st} = e^{j k \mathbf{p}{st} \cdot \omega_i} \tag{39}
    $$

    当目标入射方向 $-\omega_i$ 与基本方向 $\mathbf{u}$ 之间的夹角接近各高斯光束的发散角(divergence of the small beams)时,aliasing artifacts begin to appear. An example is shown in Fig. 8 (d).

    In our framework, we decide on a primary waist w and choose a collection of basic incident directions. In general, 较小的腰宽意味着更大的发散角,这样可以从每个基本方向派生出更多的入射方向,从而减少所需的基本方向数量。较大的腰宽会降低每个高斯光束的发散角,使得组合后的总场发散更小,从而产生更精确的入射方向。

    每个六边形的中心对应一个基本入射方向。整个半球的所有入射方向划分为若干territories,每个territories归属于一个基本入射方向。在半球投影中,这种比例关系与余弦因子相互抵消,因此可以使用大小相等的六边形来表示。

    对于每个基本方向,可以通过“波束引导”产生一些偏离该基本方向的派生方向。

    当光的入射角很小时(例如接近表面法线方向),基本入射方向附近的派生方向范围很小,因为小角度的光更集中,不会有很大的扩散。反之,派生方向范围会更大。总结就是,入射角越大(角度越接近水平),派生方向的覆盖范围就越大。公式作者也提到了:$1/\cos \theta_i$。

    5.2 Individual Simulations and Synthesized Results

    为了计算BRDF,我们需要知道这个大面积入射光的散射情况。然而,直接模拟这样一个大面积的光会需要很高的计算成本。因此,我们:

    使用小尺度模拟的叠加来模拟大尺度入射场。

    可以理解为,用很多个小的手电筒(高斯光束)来覆盖一个区域,而不是用一个巨大的探照灯。

    首先决定手电筒的尺寸(光束的大小,即腰宽 $w$),并在表面上均匀地分布这些手电筒。写成公式,这个手电筒的排列就是网格点${x_s}, {y_t}$,代表每个高斯光束的中心位置。网格间距一般和腰宽一致,确保光的均匀覆盖,并保持较低的发散角。

    让每个高斯光束在其中心区域产生相同的电磁场,只是在不同的位置上重复这一效果。

    接下来,想要得到大光束的总散射场,这里需要相位因子来进行“调整”和“叠加”,详细请回看公式(39)。

    最终,Combining with Eq. 39, the scattered fields in the far field region corresponding to the pair of directions $(w_i,w_o)$ are given by:

    $$ \begin{aligned} \mathbf{E}\left(\omega_i, \omega_o\right) & =\sum_{s=1}^n \sum_{t=1}^n e^{j k \mathbf{p}{s t} \cdot\left(\omega_i+\omega_o\right)} \mathbf{E}{s t}\left(\omega_o\right) \\ \mathbf{H}\left(\omega_i, \omega_o\right) & =\sum_{s=1}^n \sum_{t=1}^n e^{j k \mathbf{p}{s t} \cdot\left(\omega_i+\omega_o\right)} \mathbf{H}{s t}\left(\omega_o\right) \end{aligned} \tag{41} $$

    where $\mathbf{E}, \mathbf{H}$ refer to the far field quantities only associated with directions (without the $e^{-j k r} / r$ term).

    Lastly, we can compute the surface BRDF value as

    $$
    f_r\left(\omega_i, \omega_o\right)=\frac{\frac{1}{2}\left|\mathbf{E}\left(\omega_i, \omega_o\right) \times \mathbf{H}\left(\omega_i, \omega_o\right)^*\right|}{\Phi_i \cos \theta_r}
    \tag{42}
    $$

    where the incident power $\Phi_i$ is computed by integrating the incident irradiance over the surface:

    $$
    \Phi_i=\frac{1}{2} \int_S\left|\left[\mathbf{E}_i\left(\mathbf{r}^{\prime}\right) \times \mathbf{H}_i\left(\mathbf{r}^{\prime}\right)^*\right] \cdot \mathbf{n}\right| d \mathbf{r}^{\prime}
    \tag{43}
    $$

    where $\mathbf{n}$ is the surface normal at the macro scale ( $+\mathbf{z}$ ). Note that Eq. 42 and Eq. 43 can also be applied in single simulations, where $\Phi_i$ is computed from a single Gaussian beam.

  • 波动光学毛发渲染:相关论文汇总整理(一)-学习笔记-3

    波动光学毛发渲染:相关论文汇总整理(一)-学习笔记-3

    声明:这一篇就是纯纯的垃圾文章了,当作前两篇文章的补充。随便整理的供自己复习用。所有标题都可以跳转论文主页。专有词尽量都中英文给出了。若有错误恳请指正万分感谢。

    原文:https://zhuanlan.zhihu.com/p/830617613

    目录

    1. [Xia 2023] A Practical Wave Optics Reflection Model for Hair and Fur
    2. [Xia 2023] Iridescent Water Droplets Beyond Mie Scattering
    3. [Aakash 2023] Accelerating Hair Rendering by Learning High-Order Scattered Radiance
    4. [Kneiphof and Klein 2024] Real-Time Rendering of Glints in the Presence of Area Lights
    5. [Huang 2024] Real-time Level-of-detail Strand-based Hair Rendering
    6. [Xing 2024] A Tiny Example-Based Procedural Model for Real-Time Glinty Appearance Rendering
    7. [Zhu 2022] Practical Level-of-Detail Aggregation of Fur Appearance
    8. [Clausen 2024] Importance of multi-modal data for predictive rendering
    9. [Shlomi 2024] A Free-Space Diffraction BSDF
    10. [Kaminaka 2024] Efficient and Accurate Physically Based Rendering of Periodic Multilayer Structures with Iridescence
    11. [Yu 2023] A Full-Wave Reference Simulator for Computing Surface Reflectance
    12. [Shlomi 2022] Towards Practical Physical-Optics Rendering
    13. [Huang 2022] A Microfacet-based Hair Scattering Model
    14. [Shlomi 2021] A Generic Framework for Physical Light Transport
    15. [Shlomi 2024] A Generalized Ray Formulation For Wave-Optics Rendering
    16. [Shlomi 2021] Physical Light-Matter Interaction in Hermite-Gauss Space
    17. [GUILLÉN 2020] A general framework for pearlescent materials
    18. [Werner 2017] Scratch iridescence: Wave-optical rendering of diffractive surface structure
    19. [Fourneau 2024] Interactive Exploration of Vivid Material Iridescence using Bragg Mirrors
    20. [Chen 2020] Rendering Near-Field Speckle Statistics in Scattering Media
    21. [Kajiya and Kay 1989] Kajiya-Kay Model
    22. [Marschner 2003] Light Scattering from Human Hair Fibers
    23. [Benamira 2021] A Combined Scattering and Diffraction Model for Elliptical Hair Rendering
    24. [Zinke 2008] Dual Scattering Approximation for Fast Multiple Scattering in Hair

    [Xia 2023] A Practical Wave Optics Reflection Model for Hair and Fur

    波动光学、毛发渲染、表面电磁计算远散射场

    利用波动光学渲染毛发。计算表面电磁场得到散射场,再加入噪声模拟Glints效果。

    我发现这个系列的作者颜值都好高。(划掉)

    img

    1. 背景

    毛发渲染一直以来主要基于光线追踪技术,无法处理波动光学效应(Wave Optics),例如毛发表面强烈的前向散射和微妙的颜色变化。之前的研究【Xia et al. 2020】证明了衍射效应在纤维的颜色和散射方向上起到了关键作用。然而,这项研究并没有考虑如表面粗糙度和纤维表皮层的微观结构(例如倾斜的角质鳞片)。

    2. 动机

    为了弥补现有的光线光学模型缺少对衍射和前向散射的处理(例如Glints现象)。

    尽管全波模拟可以得到非常细致的散射数据,但是计算量仍然太高。必须通过某种方式加速或简化,以实现大规模场景中的毛发或皮毛渲染。

    想要开发一种能够高效处理各种纤维几何变化的模型。

    3. 方法

    毛发建模是基于毛发的扫描电子显微镜(SEM)图像。

    用「WAVE SIMULATION WITH 3D FIBER MICROGEOMETRY」计算粗糙纤维表面的反射和衍射。即PO。

    引入了散斑理论来分析散射模式的统计特性,用噪声来加速。

    [Xia 2023] Iridescent Water Droplets Beyond Mie Scattering

    波动光学、虹彩效应、水面水滴 Quetelet 散射模型

    结合 Mie 散射、Quetelet 散射(光干涉)以及水滴动态变化,逼真地渲染出水面上和蒸汽中彩虹般的水滴色彩效应,超越了传统的单一 Mie 散射模型。

    img

    1. 背景

    虹彩现象在自然界中很常见,尤其是在水滴、雾气和蒸汽等情况下。一般可以通过Mie散射来解释。Mie 散射描述了当光线遇到与波长相当的球形颗粒时发生的散射效应,是目前用于模拟水滴、云层、雨雾等自然现象的重要理论之一。

    然而,尽管 Mie 散射能够解释孤立水滴的光学特性, Mie 散射不能完全解释诸如水面彩虹色水滴和蒸汽中复杂的彩虹图案等现象。现象不仅依赖于单个粒子如何散射光线,还依赖表面反射、干涉效应以及颗粒大小的动态变化。

    2. 动机

    Mie 散射只能处理孤立的光散射现象,而无法解释更复杂的光学干涉效应。

    准确模拟这些自然现象能够极大提升图像渲染的真实性和观感。

    现有的计算机光学模型和渲染方法大多局限于 Mie 散射,无法解释光线在多颗粒环境下的交互现象,比如水滴之间或水滴与表面之间的光干涉和反射。

    3. 方法

    用「水面上的 Quetelet 散射模型」来解释水面漂浮水滴产生的彩虹色效果。通过建立经验模型,使用热成像技术将温度与水滴的尺寸和高度相关联。使用 Quetelet 散射相函数和 BRDF(双向反射分布函数)来渲染粒子群和水面。

    开发了一个水滴生长和蒸发模型,模拟水滴在蒸汽中的动态变化。与 Mie 散射结合,利用非均匀尺寸的水滴来模拟蒸汽中的彩虹色变化。为了提高渲染效率,采用了基于运动模糊的加速算法,相比于传统方法提升了 10 倍的计算速度。

    [Aakash 2023] Accelerating Hair Rendering by Learning High-Order Scattered Radiance

    毛发渲染、MLP、加速毛发散射

    结合了小型多层感知器(MLP,Multilayer Perceptron)的在线学习毛发高阶散射辐射(higher-order scattered radiance online)的方法(这个应该如何翻译?),在路径追踪(path tracing)框架下显著加速了毛发渲染,减少了计算时间并且只引入了少量偏差。

    img

    1. 背景

    毛发的多次散射(multiple scattering)非常复杂,特别是在路径追踪(path tracing)过程中,由于需要模拟光线在毛发之间的多次散射,导致难以收敛。

    2. 动机

    开发一种方法,在提升计算效率的同时,保持对多次散射效果的高质量模拟。

    现有技术中,一些方法对场景或光照做出简化假设。该论文希望提出一种不对场景做任何假设的通用方法。

    3. 方法

    使用一个小型多层感知器(MLP,Multilayer Perceptron)来在线学习高阶散射辐射(learning higher-order scattered radiance online),这个 MLP 网络在渲染过程中实时学习毛发的散射特性,不依赖于预先计算的表格或模拟。

    在路径追踪框架中集成了 MLP,用来推测和计算高阶散射辐射贡献。

    渲染器的偏差和速度(renderer’s bias & speedup)可以实时调节,以便在计算效率和渲染质量之间找到最优平衡。

    [Kneiphof and Klein 2024] Real-Time Rendering of Glints in the Presence of Area Lights

    加速区域光源Glints、微表面模型、实时渲染

    在区域光源(area lights)下渲染闪光(glints)效果,通过结合线性变换余弦(LTC,Linearly Transformed Cosines)和基于二项分布的微表面计数模型,实现实时渲染。

    img

    1. 背景

    许多现实材料(如金属、宝石等)具有闪闪发光(Glints)的外观,效果源于微表面的反射。然而闪光是离散现象,涉及波动光学模拟计算量太大。

    以往的研究大多集中在使用无穷小的点光源来渲染闪光效果,对于像太阳这样远距离的光源来说这是合理的简化,但现实中大多数光源本质上都是区域光源。现有技术未能有效处理区域光源下的闪光渲染。

    2. 动机

    处理区域光源下的闪光(glint rendering under area lights)。面积光源(例如通过窗口照射进房间的灯光)是常见的光源类型,如何在这种光源下高效地渲染闪光效果是一个尚未完全解决的问题。希望开发一种方法,能够在面积光源下准确渲染闪光效果,同时满足实时渲染的需求。

    希望能够轻松集成到现有的实时渲染框架中,且不对已有的面积光源着色方法带来显著额外开销。

    3. 方法

    闪光反射概率估算(estimating glint reflection probability)来计算微表面(microfacet)正确定向以反射来自光源的光线到观察者的概率。大光源通过线性变换余弦(LTC,Linearly Transformed Cosines),小光源用局部常量法(locally constant approximation)。

    基于二项分布的计数模型(binomial distribution-based counting model)来计算反射微表面的数量。

    与现有框架的集成(integration with existing frameworks)。

    [Huang 2024] Real-time Level-of-detail Strand-based Hair Rendering

    毛发渲染、LoD、基于发束、BCSDF

    提出了一种创新的实时基于发束(strand-based)毛发渲染框架,通过无缝的细节层次(LoD,Level-of-Detail)过渡,确保毛发在不同视距下保持一致的外观,并实现显著的渲染加速。

    img

    1. 背景

    在影视和游戏制作中,基于发束的毛发渲染(strand-based hair rendering)因其逼真的外观而越来越受欢迎,但它的计算成本非常高,尤其是在远视距下。

    当前 LoD 方法,从发束到卡片的过渡中容易出现明显的不连续导致的外观不一致。

    2. 动机

    解决动态和视觉不连续性(discontinuity in dynamics and appearance)。现有的从发束到毛发卡片的转化方案在外观和动画表现上有显著差异。论文的目标是实现从远到近无缝的 LoD 过渡,消除过渡时的外观变化,同时保持计算效率。

    3. 方法

    使用椭圆形厚毛发模型(elliptical thick hair model)将多个发束封装在一个椭圆形的体积内。在不同 LoD 下保持毛发簇(hair cluster)的形状和整体结构,从而在视距变化时提供一致的外观。

    椭圆双向曲线散射分布函数(elliptical Bidirectional Curve Scattering Distribution Function, BCSDF)模拟毛发簇内的单次和多次散射现象,适用于从稀疏到密集、从静态到动态的毛发分布场景。

    动态 LoD 调整与毛发宽度计算(dynamic LoD adjustment and hair width calculation)。

    [Xing 2024] A Tiny Example-Based Procedural Model for Real-Time Glinty Appearance Rendering

    Glints、材料自相似性

    一种基于小型样本微结构(tiny example microstructures)的模型,实时渲染glinty的效果,大幅减少了内存占用和计算开销,同时保持了高频反射细节的真实感。

    img

    1. 背景

    复杂微观结构产生的闪光细节能够显著提升渲染的真实感,特别是在金属、宝石等材质上。这些细节通常需要高分辨率的法线贴图(normal maps)来定义每个微观几何形状,然而这类方法对内存需求很高,不适合实时渲染应用。

    2. 动机

    降低内存和计算开销(reduce memory and computational overhead)。

    利用材料的自相似性(leveraging material self-similarity)。观察到许多材质具有独立的结构特征和自相似性,通过小型样本来隐式表示复杂微结构从而降低内存需求。

    3. 方法

    基于小型样本微结构的程序化模型(tiny example-based procedural model),基于材质的自相似性,能够通过重复利用少量样本来生成复杂的闪光细节。

    法线分布函数预计算(precomputed Normal Distribution Functions, NDFs),使用四维高斯分布(4D Gaussians)预计算并存储小型样本的法线分布函数(NDFs)。存储在多尺度 NDF 图中(multi-scale NDF maps),并在渲染时通过简单的纹理采样调用。

    基于小型样本的 NDF 评估方法(tiny example-based NDF evaluation method),通过纹理采样结合小型样本 NDF 评估方法,快速生成复杂表面的闪光外观。

    [Zhu 2022] Practical Level-of-Detail Aggregation of Fur Appearance

    毛发渲染、简化毛发数量、神经网络

    一种实用的毛发外观聚合模型,通过减少几何毛发数量并结合光线多次散射,利用神经网络实现实时动态简化,显著加速毛发渲染,同时保持逼真的视觉效果。

    img

    1. 背景

    毛发数量太多,每根毛发的光线散射和反射会大幅增加计算量,尤其是在模拟多次光线散射时。

    现有简化方法大多通过减少毛发数量来提高渲染效率,局限性(limitations of existing simplification methods)很大。这种方法会导致毛发看起来过于粗糙或干燥,光线的反射和散射效果也不真实。

    2. 动机

    减少几何复杂性(reducing geometric complexity)。

    提高渲染效率(improving rendering efficiency)。

    3. 方法

    提出一个聚合毛发外观模型(aggregated fur appearance model),用一个粗大的圆柱体来表示一组毛发簇的光学行为。通过分析单根毛发的光学属性(如光线入射角度等),该模型能够准确反映毛发簇的聚合外观。

    使用一个轻量级神经网络(lightweight neural network),将单根毛发的光学特性映射到聚合模型中的参数。

    提出了一个基于视距和光线反弹次数的动态细节层次方案(dynamic level-of-detail scheme),动态简化毛发的几何结构。

    [Clausen 2024] Importance of multi-modal data for predictive rendering

    预测性渲染、光谱渲染、微表面几何结构

    多模态数据(multi-modal data)对于预测性渲染(predictive rendering)很重要,特别是在准确建模材料反射行为方面,通过结合光谱、空间信息及微观几何细节,提升反射模型的真实性和计算效率。

    img

    1. 背景

    预测性渲染的需求(need for predictive rendering)旨在精确模拟材料的外观。

    目前的材料反射行为数据库大多局限于单一维度,通常只涵盖光谱域(spectral domain)或空间域(spatial domain),并且缺乏对微观几何细节(microgeometry)的描述。

    2. 动机

    为了弥补数据不足(addressing data limitations),通过多模态数据,不仅能够更好地模拟材料在不同光照条件下的反射,还能揭示材料表面微观几何对光散射的影响。

    多模态反射数据有助于开发更加真实且高效的反射模型。

    3. 方法

    多模态反射数据的构建(building a multi-modal reflection database),包含材料的光谱信息(spectral data)、空间分布信息(spatial distribution data)以及微观几何特征(microgeometry details)。

    对材料表面的微观几何进行微观几何模拟(simulating microgeometry)。

    光谱和空间域的综合(integrating spectral and spatial domains)。

    [Shlomi 2024] A Free-Space Diffraction BSDF

    波动光学、电磁计算、自由空间衍射、重要性采样、PT集成、

    一种基于自由空间衍射的双向散射分布函数(BSDF),通过光线追踪在不需要几何预处理的情况下,能够高效模拟复杂场景中光绕过物体边缘的衍射现象,特别适用于路径追踪技术。

    img

    1. 背景

    自由空间衍射(free-space diffraction)是一种光学现象:当光线遇到物体的边缘或角落时会发生绕射,部分能量弯曲进入阴影区。这种现象对模拟光波的传播非常重要,尤其是在长波长(如雷达、WiFi 和蜂窝信号)条件下。

    传统方法如几何衍射理论(GTD)和统一衍射理论(UTD)的局限性(limitations of traditional methods)在于需要处理尤其是在复杂几何场景中互相干涉的光线导致的极高计算复杂性。现有方法依赖于场景简化和特定几何结构,无法有效处理复杂的三维场景。

    2. 动机

    解决复杂场景中的衍射渲染问题(addressing diffraction in complex scenes)。现有的衍射模拟方法难以扩展并与路径追踪技术兼容。

    现有衍射方法往往依赖复杂的非线性干涉计算,而路径追踪采用线性渲染方程。本文希望设计一种在路径追踪框架内高效工作的自由空间衍射 BSDF,不需要对路径追踪器做大修改。

    3. 方法

    基于 Fraunhofer 衍射的边缘衍射模型(Fraunhofer diffraction edge model)。在光线与几何物体交点附近,识别相关的边缘并计算绕射效应。当光线照射到物体时,通过几何分析构建自由空间衍射的 BSDF,量化光线如何在几何物体周围传播,以及有多少能量发生衍射。

    重要性采样策略(importance sampling strategy)评估光线与物体的交互点周围的几何边缘,并对绕射光线进行采样和追踪。

    路径追踪中的无缝集成(seamless integration in path tracing)

    [Kaminaka 2024] Efficient and Accurate Physically Based Rendering of Periodic Multilayer Structures with Iridescence

    多层油膜渲染、虹彩效应、波动光学

    一种多层干涉渲染方法。表现周期性多层结构的虹彩效应,通过引入生物学中的 Huxley 方法,实现了独立于层数的高效计算.

    img

    1. 背景

    薄膜干涉(thin-film interference)是一种光波的波动特性引起光学现象,在视角或光照角度变化时会产生虹彩效果(iridescence)。通常出现在自然界中的单层或多层结构中,比如蝴蝶翅膀、甲虫壳和介电镜等。

    现有方法例如递归计算法和转移矩阵法(Transfer Matrix Method, TMM)的局限性(limitations of existing methods)在于计算复杂度随着层数的增加而显著提高。简化方法则会忽略薄膜中的多重反射。

    2. 动机

    提高多层结构的渲染效率(improving efficiency for multilayer structures)。

    应用于复杂材料的物理渲染(physical rendering of complex materials)。

    3. 方法

    提出一种基于 Huxley 方法的多层干涉模型(multilayer interference model based on Huxley’s approach)。高效计算周期性多层结构中的反射和透射系数。且支持多种材料和吸收效应(support for multiple materials and absorption effects)。

    基于 BRDF 实现(BRDF implementation)。被实现为一个 BRDF(双向反射分布函数),可以集成到传统渲染系统中,如 PBRT-v3。

    [Yu 2023] A Full-Wave Reference Simulator for Computing Surface Reflectance

    波动光学、全波模拟

    基于边界元法(BEM)的全波模拟器,可以高精度计算粗糙表面上的光散射,用于评估和改进计算机图形学中的近似反射模型,特别是在多次散射、干涉和衍射效应显著时。

    img

    1. 背景

    表面反射模型通常基于几何光学(geometric optics),假定光以光线的形式传播。对于表面特征与光波长相当的场景,几何光学模型无法准确捕捉如衍射和干涉等波动效应(wave effects)。

    基于波动光学近似,如 Beckmann-Kirchhoff 理论和 Harvey-Shack 模型,它们在多次散射和复杂几何结构下仍然会产生误差。

    2. 动机

    由于现有的反射模型在不同情况下精度各异,缺乏可靠的基准来验证其准确性。本文的目标是开发一个基于全波理论的模拟器以最大限度地减少近似并通过数值离散化实现高精度的表面反射计算,从而提供一个可以用于评估各种反射模型准确性的参考工具。

    解决多次散射和波动效应(addressing multiple scattering and wave effects)。

    3. 方法

    边界元法(Boundary Element Method, BEM),用自适应积分法(Adaptive Integral Method, AIM)来加速。

    模拟器的全波模拟(full-wave simulation)完整解决了 Maxwell 方程,能够准确模拟光的传播、干涉、散射等波动现象。

    并且能高效计算 BRDF(efficient BRDF computation)。

    [Shlomi 2022] Towards Practical Physical-Optics Rendering

    波动光学、PLT

    提出了一个高效的物理光传输(PLT)框架,利用部分相干光和波动光学的原理,通过改进的渲染算法,在复杂场景中实现了精确的干涉、衍射和极化效应的渲染,并使其性能接近经典的“物理基础”渲染方法。

    img

    1. 背景

    现有的渲染方法大多忽略了光的波动特性,特别是在复杂场景中,这导致无法渲染诸如光的干涉、衍射等物理现象,这些现象在某些材料(如彩虹色涂层、光盘等)上尤为重要。为了解决这一问题提出一种基于麦克斯韦电磁理论的渲染框架。

    虽然 PLT 提供了理论上的全波模型,能够模拟光的相干性、干涉和衍射,但现有的方法计算难度非常大。

    2. 动机

    简化物理光传输模型(simplifying the physical light transport model)。

    引入新材料(introducing new coherence-aware materials),开发能够感知光相干性的材料模型,提高 PLT 在实际场景中的可用性。

    3. 方法

    限制光的相干性形状(restricting the coherence shape of light),通过热力学推导,证明这种近似在大多数自然光源下是合理的。

    采用了扩展的斯托克斯-穆勒方法(Stokes-Mueller calculus),将光的辐射、极化和相干性属性结合在一起,作为新的渲染原语。广义的斯托克斯参数可以完整量化光的所有属性,并能精确模拟由这些属性引起的复杂光学现象,如干涉和衍射。

    波动 BSDF 和重要性采样(wave BSDF and importance sampling)。

    新相干感知材料模型(new coherence-aware material models)充分利用光的相干属性扩展了 PLT 的适用范围。

    [Huang 2022] A Microfacet-based Hair Scattering Model

    毛发渲染、散射瓣、BCSDF

    提出了首个基于微表面理论的毛发散射模型准确地描述毛发的散射行为。非可分离的散射瓣结构、椭圆形截面、高效的重要性采样和前向散射光斑(glint-like)效果。

    img

    1. 背景

    毛发渲染的复杂性(complexity of hair rendering)。大多数现有的毛发散射模型通过可分离的散射瓣(separable lobes)来简化数学计算,虽然快但并不是Ground Truth的。

    大多数毛发散射模型基于几何简化,将毛发当作光滑圆柱体处理。导致了散射行为的偏差。

    2. 动机

    引入物理合理的微表面模型(introducing a physically-plausible microfacet model)更准确地描述毛发的散射行为:表面的微观粗糙度、倾斜的鳞片结构以及非可分离的散射瓣形状。

    改善采样效率和物理精度(improving sampling efficiency and physical accuracy)。

    3. 方法

    将毛发建模结合微表面(microfacet)理论,应用 GGX 或 Beckmann 正态分布来描述表面的微观粗糙度。并且是非可分离的散射瓣(non-separable lobes)。

    毛发散射的分布函数(bidirectional curve scattering distribution function, BCSDF)描述光线在毛发表面上的复杂交互行为。

    支持椭圆截面和高效采样(support for elliptical cross-sections and efficient sampling)。支持椭圆的毛发截面。

    [Shlomi 2021] A Generic Framework for Physical Light Transport

    波动光学、PLT

    提出了首个基于麦克斯韦电磁理论能够处理部分相干光的全局光传输框架,准确模拟光的干涉和衍射效应,并将传统的基于辐射度的光传输理论扩展到波动光学领域。

    img

    1. 背景

    现有的光传输模型通常基于几何光学和辐射度(radiometry)忽略了光的波动特性,无法模拟干涉和衍射等现象。不能准确再现如彩虹色效应、光栅、薄膜干涉、光偏振等波动光学效应,也就是经典辐射度光传输模型的局限性(limitations of classical radiometric light transport models)。

    目前的模型只能处理局部处理波动效应(local treatment of wave effects),对于全局场景中光的传输和相干性搞不了。

    2. 动机

    实现全局光传输中波动效应的物理一致性(achieving global wave-optics consistency in light transport),也就是结合麦克斯韦电磁理论。

    结合波动光学与传统几何光学(integrating wave optics with classical geometric optics),处理光的波动效应,又能在短波长极限下与经典几何光学一致。

    3. 方法

    光的部分相干性建模(modelling partially-coherent light)分为两个部分。两点相干性描述(two-point coherence description)和光源模型(light source model)。与传统的辐射度不同,本文基于光的部分相干性引入了“交叉谱密度函数”(cross-spectral density function),能够捕捉光的干涉特性。自然光源的物理模型,基于量子力学中的自发辐射原理。

    光传输方程的推广(generalizing the light transport equation)。利用谱密度传输方程(spectral-density transport equation)计算光在传播过程中的干涉和衍射效应。并且本文证明了该框架在短波长极限下简化为经典的几何光学,因而可以与现有的光传输方法无缝衔接。

    衍射与传播模型(diffraction and propagation model)。

    [Shlomi 2024] A Generalized Ray Formulation For Wave-Optics Rendering

    波动光学、波动采样理论、双向路径追踪

    提出了一种广义光线(generalized ray)形式化模型,用于波动光学渲染,通过解决采样问题,使得弱局部性、线性和完备性在波动光学中同时成立。实现了复杂场景下的双向波动光学路径追踪和高效渲染。

    img

    1. 背景

    光传输的经典模型基于射线光学(ray optics),假设光作为线性传播的点查询。然而,射线光学无法捕捉光的波动特性,忽略了干涉、衍射等现象,例如虹彩效应、薄膜干涉和长波辐射的绕射等。

    虽然波动光学能够精确描述光的干涉和衍射效应,但由于其非线性行为,传统的采样和路径追踪技术难以应用。

    2. 动机

    解决波动光学中的采样问题(solving the sampling problem in wave optics)。为了在双向光传输中应用波动光学,需要解决弱局部性下的采样问题。

    开发一种新型的波动光学形式化,使其能够在逆向路径追踪和双向光传输中有效应用,同时保持线性和完备性。

    提高波动光学渲染效率(improving wave-optics rendering efficiency),使得波动光学渲染的收敛速度能够接近经典射线光学的渲染系统。

    3. 方法

    广义射线的引入(introduction of the generalized ray)。进行弱局部的线性查询。广义射线不再局限于单一位置的点查询,而是占据一个小的空间区域。能够捕捉光的干涉和衍射效应。

    弱局部性和线性化(weak locality and linearization)。在波动光学中,完美的局部性和线性化是无法同时实现的。因此放弃完全局部性。采用弱局部性确保广义射线可以线性叠加。

    逆向波动光传输模型(backward wave-optical light transport model)。

    双向路径追踪中的应用(application in bidirectional path tracing)。

    [Shlomi 2021] Physical Light-Matter Interaction in Hermite-Gauss Space

    波动光学、PLT

    提出了一种新的光-物质相互作用框架,通过将部分相干光分解到 Hermite-Gauss 空间,并将物质建模为局部平稳的随机过程,从而统一了散射和衍射的公式,并实现了对复杂光学现象的高效计算和描述。

    img

    1. 背景

    日常观察到的光通常由许多独立的电磁波组成。由于部分相干光的复杂性(complexity of partially-coherent light),部分相干光的相干特性在微观几何表面的反射、涂层材料的外观、光栅效应等现象经典的辐射度理论无法解释。

    现有工具的局限性(limitations of existing tools)只能渲染特定材料,难以推广。

    2. 动机

    构建通用的光-物质相互作用框架(building a general-purpose light-matter interaction framework),高效处理部分相干光,简化现有计算工具的复杂性。

    分解光的相干特性(decomposing light coherence properties),引入 Hermite-Gauss 空间,希望以计算可行的方式分解和表示光的相干性,从而广泛适用于各种光学现象。

    3. 方法

    Hermite-Gauss 空间中的光传输(light transport in Hermite-Gauss space)。

    局部平稳物质模型(locally-stationary matter model)。

    光-物质相互作用的分析(analysis of light-matter interaction)。

    光-物质相互作用公式的统一(unifying light-matter interaction formulae)。

    [GUILLÉN 2020] A general framework for pearlescent materials

    波动光学、干涉颜料光学、逆渲染

    模拟珍珠光材料的光学特性。为珍珠光材质的设计和逆向渲染提供了理论基础。

    img

    1. 背景

    珍珠光材料的广泛应用(Wide Applications of Pearlescent Materials),并且这些材质具有独特的光泽和变色效果,广泛应用于包装、陶瓷、印刷和化妆品等领域。

    珍珠光效应的复杂光学过程(Complex Optical Processes of Pearlescence)源于颜料片之间的多重散射和波动光学干涉。现有的模型难以全面描述这些复杂的光学行为。

    2. 动机

    建立更广泛适用的珍珠光材质模型(Building a More Comprehensive Model for Pearlescent Materials)。现有的珍珠光材质模型对颜料的复杂结构和制造过程的影响考虑不足。目标是通过引入新的光学模拟模型,扩展可表示的珍珠光外观范围。

    一个通用的珍珠光材质模型还可以用于逆向渲染中。

    3. 方法

    提出基于干涉颜料的光学模型(Optical Model Based on Interference Pigments)。将颜料片的多层结构、粒子的方向相关性、厚度变化等特性纳入考虑。

    参数空间的系统研究(Systematic Study of Parameter Space),探讨了颜料片的方向性、厚度和排列对材料外观的影响。

    逆向渲染(Inverse Rendering)帮助解读真实世界中的光散射现象。

    [Werner 2017] Scratch iridescence: Wave-optical rendering of diffractive surface structure

    波动光学、非旁轴标量衍射理论、虹彩效应、微观划痕

    基于非旁轴标量衍射理论的波动光学模型,用于模拟微观划痕表面的虹彩效应,从局部光斑到远距离下的光滑反射。

    img

    1. 背景

    划痕引起的光学效应(Optical Effects of Scratches),定向光照下(如阳光或卤素灯),这些划痕表面会显示出复杂的虹彩图案,是由划痕结构对入射光的衍射引起的。在几何光学模型中无法再现。

    虽然现有的解析模型能够重现一些微结构(如光盘)的虹彩效果,但对于局部分辨的划痕光学行为的模拟仍然是一个难题。

    2. 动机

    提供一个波动光学的划痕渲染框架(Provide a Wave-Optical Scratch Rendering Framework)。能够精确模拟划痕引起的光学效应,包括光斑、虹彩等视觉现象。

    3. 方法

    基于非旁轴标量衍射理论的波动光学模型(Wave-Optical Model Based on Non-Paraxial Scalar Diffraction Theory)。本文的方法能够在入射和反射大角度下准确模拟光在微尺度表面特征上的衍射行为。

    划痕表面结构的矢量图形表示(Vector Graphics Representation of Scratch Surfaces)。

    多尺度行为的 BRDF 模型(Multi-Scale BRDF Model)。

    物理基础渲染系统中的集成与优化(Integration and Optimization in Physically-Based Rendering Systems)。

    [Fourneau 2024] Interactive Exploration of Vivid Material Iridescence using Bragg Mirrors

    波动光学、虹彩效应、Bragg镜、光谱近似

    描述 1D 光子晶体(即 Bragg 镜)的材料虹彩效果。特定条件下简化为快速计算的单次反射BRDF。

    img

    1. 背景

    自然界中的虹彩现象(Iridescence in Nature)体现在生物、植物或宝石中。这是由特定的微观几何结构引起的,这些结构的尺寸与可见光波长相当。最显著的例子是光子晶体(photonic crystals),在一维、二维或三维结构中重复排列产生结构色。

    1D 光子晶体,即 Bragg 镜的光学特性(Optical Properties of Bragg Mirrors)。现有的工作大多使用经典的传递矩阵法(transfer matrix method)来计算多层薄膜的光学效应,但随着薄膜数量的增加,计算复杂度也显著增加。

    2. 动机

    简化 Bragg 镜反射计算(Simplifying the computation of Bragg mirror reflectance),引入一个更简洁、封闭形式的反射公式探索在 RGB 光谱渲染中的快速近似方法。

    研究粗糙 Bragg 层的效果(Investigating the effects of rough Bragg layers),探究表面粗糙度对光学表现的影响。

    3. 方法

    引入封闭形式的反射公式(Closed-form Reflectance Formula)。基于 Yeh 的公式(Yeh88 Formula),做 RGB 光谱近似(RGB Spectral Approximation)。

    分析粗糙度对光学传输的影响(Effect of Roughness on Optical Transmission)。

    利用单次反射 BRDF 模型(Single-reflection BRDF Model)高效地渲染粗糙 Bragg 层的外观。

    [Chen 2020] Rendering Near-Field Speckle Statistics in Scattering Media

    MC路径积分、重要性采样、记忆效应、散斑、生物组织成像

    在散射介质中模拟近场成像条件下的散斑统计,加速生物组织成像应用中的散斑渲染,为基于散斑的成像技术提供支持。

    img

    1. 背景

    在生物组织中进行深层成像时,由于光在组织内部的多重散射,成像变得非常困难。在相干光(如激光)照射下,组织内部会产生高频的散斑图案。散斑图案的统计学特性,特别是记忆效应(memory effect),为组织成像技术(如荧光成像和自适应光学聚焦)提供了基础。

    现有模型的局限性(Limitations of Existing Models)在于当前主要是集中在远场成像。近场条件都被忽略了。

    2. 动机

    提供物理精确且高效的近场散斑渲染模型(Developing a Physically Accurate and Efficient Model for Near-Field Speckle Rendering)。

    提升散斑模拟的计算效率(Improving Computational Efficiency of Speckle Simulations),波动方程求解器计算量太大了。

    3. 方法

    基于蒙特卡洛路径积分的渲染框架(Monte Carlo Path Integral Rendering Framework)。

    光学孔径和散射相函数的近似(Aperture and Phase Function Approximations)。

    重要性采样(Importance Sampling)。

    [Kajiya and Kay 1989] Kajiya-Kay Model

    毛发鼻祖,无需多言

    毛发简化为细长圆柱体,并通过扩展 Phong 模型模拟毛发表面的光反射行为。

    img

    1. 背景

    基于 Phong 光照模型的概念,将其扩展为适应毛发渲染的经验模型。

    2. 动机

    毛发具有非常独特的光学特性,如高光反射、次表面散射等,而这些现象的出现与毛发的几何形状和表面结构密切相关。

    3. 方法

    Kajiya-Kay 模型基于 Phong 模型的思想,扩展 Phong 模型(Extension of the Phong Model)。

    细长圆柱体假设(Cylindrical Hair Representation)。

    [Marschner 2003] Light Scattering from Human Hair Fibers

    毛发鼻祖,无需多言+1

    能够捕捉现有Kajiya-Kay模型无法描述的关键视觉效果,如多个高光和与纤维轴旋转相关的散射变化。

    img

    1. 背景

    Kajiya-Kay模型的局限性(Limitations of the Kajiya-Kay Model)仅假设毛发为不透明的圆柱体,忽略了内部反射和透射等关键现象.

    2. 动机

    毛发是介电材料,尤其是浅色头发(如金色、棕色、红色)具有显著的半透明性。因此更真实的毛发散射模型需求(Need for a More Accurate Hair Scattering Model)。

    3. 方法

    测量了单根毛发的3D全半球光散射。

    提出透明椭圆柱体模型(Transparent Elliptical Cylinder Model)。

    简化的阴影模型(Simplified Shading Model)。

    [Benamira 2021] A Combined Scattering and Diffraction Model for Elliptical Hair Rendering

    波动光学、毛发渲染、椭圆毛发、衍射散射瓣函数、免预计算

    一个结合散射和衍射的新模型,能够在无需预计算的情况下模拟具有椭圆形截面的毛发的光散射和衍射现象。

    img

    1. 背景

    依旧是波动光学为背景。当光与尺寸接近光波长的物体相互作用时,干涉和衍射效应变得显著。

    毛发渲染需要考虑其几何特性以及光的波动效应。虽然射线追踪可以模拟大部分散射现象,但对于毛发中的衍射现象,它显得不足。

    2. 动机

    解决衍射和椭圆形截面问题(Addressing Diffraction and Elliptical Cross-sections)。提出了一个结合光的波动和射线特性的模型,能够在无需预计算的情况下处理毛发的光衍射现象。支持任意椭圆形截面的毛发纤维。

    3. 方法

    射线部分(Ray Interaction with Elliptical Fibers)引入完整的光传输模型,延续了传统的射线模型,处理了大多数散射效应。

    波动部分(Wave Diffraction by Elliptical Fibers)引入了一个新的衍射散射瓣函数,捕捉光与毛发相互作用时产生的强向前散射效应。

    免预计算(Precomputation-free Approach)。

    现代离线渲染器集成(Integration with Modern Ray Tracers)。

    [Zinke 2008] Dual Scattering Approximation for Fast Multiple Scattering in Hair

    毛发渲染、多重散射

    “双重散射”模型广泛运用于实时渲染,经典模型无需多言。

    img

    1. 背景

    在浅色密集的头发中,多次散射是决定整体头发颜色的关键因素。

    现有的基于路径追踪或光子映射的方法渲染太慢,且往往忽略了头发纤维的圆形截面。

    2. 动机

    一个物理精确且高效的多次散射模型需求(Need for Physically Accurate and Efficient Multiple Scattering Model)。

    3. 方法

    双重散射模型(Dual Scattering Model),全局多重散射和局部多重散射。全局多重散射部分旨在计算穿过头发体积并到达目标点邻域的光,而局部多重散射则考虑该邻域内的散射事件。

  • 毛发渲染研究:波动光学的毛发渲染-学习笔记-2

    毛发渲染研究:波动光学的毛发渲染-学习笔记-2

    声明:这篇文章主要是关于SIG23年这篇黑狗毛论文的个人笔记。应该也没啥阅读门槛,因为我自己也没有入门图形学。公式有但是不多。公式tag跟原论文一样。所有内容都是我瞎捣鼓的,公式如果有理解错误请多多指正,感谢感谢!

    原文:https://zhuanlan.zhihu.com/p/809636731

    关键词:图形学入门、离线渲染、基于波动光学渲染、毛发渲染

    Mengqi Xia, Bruce Walter, Christophe Hery, Olivier Maury, Eric Michielssen, and Steve Marschner, “A Practical Wave Optics Reflection Model for Hair and Fur,” ACM Transactions on Graphics (TOG), vol. 42, no. 4, article 39, pp. 1-15, Jul. 2023.

    1. Related Work

    • 基于Ray的纤维模型 在人类毛发的研究中,Marschner[2003]的模型广泛应用于业界,通过分析介电圆柱和圆锥中的光线路径,将散射分为R、TT和TRT。Zinke[2009]加入了漫反射成分。Sadeghi[2010]提出了艺术家控制参数化方法。d’Eon[2014]和Huang[2022]提出了非可分离的表征方法,也就是说方位角和纵向角度之间有耦合效应,不可简单分离。Chiang[2016] 进一步优化了该模型,使其适用于生产级渲染。 除了人类毛发,Khungurn和Marschner[2017]探讨了椭圆形毛发的建模。Yan[2015, 2017]研究了带有内部髓质的动物毛发。Aliaga[2017]将模型推广至有更加复杂截面的纺织纤维。
    • 基于波动光学的纤维模型 Linder[2014]解析了具有完全圆形截面的圆柱纤维,并且探讨了散射行为。Xia[2020]通过在具有任意截面的圆柱上进行二维波动模拟,展示了与几何光学模型相比的若干重要差异。然而这个模型是规则的圆形,并没有譬如毛鳞片等微观几何结构。Benamira和Pattanaik[2021]提出了更加快速的混合模型,这个模型仅仅在前向散射使用波动光学求解,其余部份则是依赖几何光学。
    • 基于物理光学的平面模型模拟 物理光学平面模型使用物理光学近似来模拟光线在接近平面、可以表示为高度场的粗糙表面上的散射行为,包括高斯随机、周期性、预计算和划痕表面。它们结合了Kirchhoff标量衍射理论和路径追踪方法来处理散射与反射,并通过各种模型(如Beckmann-Kirchhoff和Harvey-Shack)计算光在不同粗糙表面上的衍射。尽管这些模型在平面表面上有效,但纤维几何的封闭性和复杂的光线交互需要更复杂的处理方法。
    • 计算电磁学Spickle Effect程式化噪声 上一篇文章已经提及了,这里略过。

    https://zhuanlan.zhihu.com/p/776529221

    2. Background Research

    概述

    毛发建模是基于毛发的扫描电子显微镜(SEM)图像。可以准确还原毛发的微观结构,例如毛鳞片、粗糙度。

    接下来,用「WAVE SIMULATION WITH 3D FIBER MICROGEOMETRY」计算粗糙纤维表面的反射和衍射。

    在这个基础上,引入了散斑理论来分析散射模式的统计特性。并且用噪声来描述这些散斑,大幅优化模型。

    然后通过与实际测量数据的对比,推导出合理的纤维参数(例如尺寸、表皮角度和表面粗糙度)。最后集成进渲染系统中。

    3. OVERVIEW

    3.1 Fiber scattering models

    这个我翻译成纤维散射模型。这个东西描述的是单根纤维发生相互作用。这里的关键核心是BCSDF(双向曲线散射分布函数)。有别于在表面反射和折射中常用的双向散射分布函数(BSDF),BCSDF是专门为曲线形状的纤维设计的。下面这个公式讲的是,给定某波长的光照射在一根纤维上时,它从某个方向射入后,会从另一个方向反射或透射出来。
    $$
    L_r(\omega_r, \lambda) = \int L_i(\omega_i, \lambda) S(\omega_i, \omega_r, \lambda) \cos \theta_i d\omega_i \tag{1}
    $$
    公式左边表示给定波长 $\lambda$ ,出射方向 $\omega_r$ 下的辐射亮度。$ L_i(\omega_i, \lambda)$ 是入射方向 $\omega_i$ 的辐射亮度。 $S(\omega_i, \omega_r, \lambda)$ 是双向曲线散射分布函数,描述了光线如何被纤维”打散”。$\cos \theta_i$ 是为了考虑入射角度的影响,如果光线以一个很平的角度照射到纤维上,它的影响会比垂直照射时小。

    把上面这个公式写成球坐标,并且把每一种不同的光线与毛发纤维的交互当作不同的模式,然后把不同的散射项累加起来:
    $$
    S(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) = \sum_{p=0}^{\infty} S_p(\theta_i, \theta_r, \phi_i, \phi_r, \lambda)
    \tag{2}
    $$
    第一散射项 $S_0$ 描述表面反射,即以前经常说的直接反射项 $R$ 。这一项是一般代表从光滑纤维的反射或者粗糙纤维表面反射的统计平均值。论文这里能更加精确地计算这一项。

    回想以前的渲染方法(比如Marschner[2003]),通常是将每一个散射模式 $S_p$ 分解为两个独立的函数:longitudinal function $M_p$ 和anazimuthal function $N_p$ ,这样的方法已经被批判过是不准确的,因此应该避免使用下面这种可分离的近似方法。
    $$
    S_p(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) = M_p(\theta_i, \theta_r)N_p(\theta_i, \phi_i, \phi_r, \lambda)
    \tag{3}
    $$
    因此,XIA[2023]采样了多个粗糙纤维的散射参数并且取均值,记为 $S_0,avg$ 。 $f(\theta_h, \phi_h, \lambda)$ 是噪声分量,通过两个半程向量角度和波长来表示,用来修正当前的特定纤维实例和均值的偏差。
    $$
    S_{0,\text{sim}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) \approx S_{0,\text{avg}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) f(\theta_h, \phi_h, \lambda)
    \tag{4}
    $$
    当前,完整的纤维散射模型如下。第一项表示纤维表面的反射的散射模式,结合了3D波动模拟。后面的求和项是其他高阶的散射模式之和。
    $$
    S_{\text{prac}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) = S_{0,\text{prac}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) + \sum_{p=1}^{\infty} S_p(\theta_i, \theta_r, \phi_i, \phi_r, \lambda)
    \tag{5}
    $$
    实际上,最终的散射公式更为简洁,以适应有更复杂几何结构的表面。

    3.2 Speckle theory

    散斑理论(Speckle Theory)描述光与粗糙表面相互作用时产生的随机光强分布现象。Goodman[2007]公式中的 $A$ 表示所有相位向量的叠加,即结果相位向量(Phasor):
    $$
    \mathbf{A} = \frac{1}{\sqrt{N}} \sum_{n=1}^{N} a_n = \frac{1}{\sqrt{N}} \sum_{n=1}^{N} a_n e^{i\phi_n}
    $$
    上式为散斑强度的综合表达,换而言之,用来表示散射后的光线强度。产生该现象的原因是光线会在许多微小表面之间相互反射、折射并相互干涉。光线传播发生的相位差导致明暗不均的图案。通过该公式,可以统计性地计算出光线如何在纤维表面相互干涉,得到散斑图案。

    4. WAVE SIMULATION WITH 3D FIBER MICROGEOMETRY

    在波动光学模拟中,光看作一种电磁波。由相互垂直的磁场和电场组成。光线与毛发的相互作用(如散射)可以转化为分析电磁场如何受到物体的影响。

    4.1 Wave optics

    首先需要明白,当电磁场随着时间周期变化时,称该场为时偕场(Time-Harmonic Fields)。在时谐场中,电场和磁场可以通过复数表示为相位向量(phasors)。巧妙的是,电场和磁场本身就是相互垂直。
    $$
    E_{\text{inst}} = \Re(E e^{j\omega t}), \quad H_{\text{inst}} = \Re(H e^{j\omega t})
    \tag{6}
    $$
    分别是电场和磁场,只不过是分别取了实数部分。复数部分包含了场的振幅和相位信息。

    下面这两组方程是麦克斯韦方程组在时谐场中的形式:
    $$
    \nabla \times \mathbf{E} = -\mathbf{M} – j\omega \mu \mathbf{H}
    \
    \nabla \times \mathbf{H} = \mathbf{J} + j\omega \varepsilon \mathbf{E}
    \tag{7}
    $$
    其中, $\varepsilon$ 是介电常数(影响电场), $\mu$ 是磁导率(影响磁场)。这两项描述了材料怎么影响电磁场的传播。

    当物体(如纤维)被一束入射波照射时,入射电场和磁场分别用 $\mathbf{E}_i$ 和 $\mathbf{H}_i$ 表示。但是物体的存在改变了这些场,使得我们观察到的是总场
    $$
    \mathbf{E}_1 = \mathbf{E}_i + \mathbf{E}_s, \quad \mathbf{H}_1 = \mathbf{H}_i + \mathbf{H}_s
    \tag{8}
    $$
    简而言之,总场 = 入射场 + 散射场。

    光能量随着散射场 $\mathbf{E}_s$ 和 $\mathbf{H}_s$ 向外传播,因此计算纤维的散射函数是关键。怎么算呢?

    全波模拟是最为准确的。进行全波模拟需要将物体离散化为网格,并且需要高分辨率(一般来说,每个波长至少10个网格单元),这导致需要处理百万级别的网格单元。在上一篇文章也有提及。

    也就是说,即便是模拟一个仅仅几十微米长的短纤维段,也需要处理数百万个网格单元。

    因此,采用 物理光学近似(PO)。在PO中,物体表面的电流和磁流(分别记作 $J$ 和 $M$ )可以看作是散射场的次级源头。电磁流产生了二次辐射,形成散射波。PO假设物体表面只会发生单次反射,忽略多次反射和复杂的衍射效应。得到表面的电流和磁流之后,计算他们产生的散射波。通过从表面电流和磁流中推导出这些远场波的性质。

    光一个PO还不够,还得揉个八叉树算法进去加速远场计算。

    4.2 Physical Optics Approximation

    具体地,PO做了两个简化假设:单次散射与局部平面假设。这个方法也足够通用。

    如上图,某物体表面采样了多个点,近似为平面。计算切平面电流和磁场,进而产生一个散射场。通过这个散射场,计算这些电流和磁流产生的远场散射波。与此同时,使用Octree结构划分为更小的体素。每个叶节点的计算结果会聚合至父节点,得到总的辐射贡献。

    Surface current calculation.

    表面电流、磁流的计算是PO模拟中非常关键的一步。对于每一个采样点,存储其法向量 $n(r{\prime})$ 和面积信息。接下来再每个小平面计算入射波和表面电流的相互作用。

    根据入射波的方向 $e_i$ 和表面法向 $n(r{\prime})$ ,将入射电场分解为平行极化和垂直极化分量。分解的原因是,平行极化和垂直极化在菲涅尔方程中,是完全不同的表达。

    平行极化分量:光的电场平行于入射光线的反射面。
    垂直极化分量:光的电场垂直于入射光线的反射面。

    因此,得到入射场 $E_i$ 被分解为平行极化的分量 $E_i^p$ 和垂直极化的分量 $E_i^s$ ,长这个样子: $E_i = E_i^p + E_i^s$ 。

    反射场 $E_r$ 被表示为平行极化和垂直极化的分量之和,入射场旁边的系数表示菲涅尔方程中的反射:
    $$
    E_r = E_r^p + E_r^s = F^p E_i^p + F^s E_i^s
    \tag{9-1}
    $$
    然后,总电场 $E_1$ 是入射场和反射场的总和:
    $$
    E_1 = E_i + E_r
    \tag{9-2}
    $$
    根据中学物理,我们知道电会生磁,磁也会生电。因此有如下表达:
    $$
    M = -n \times E_1,
    J = n \times H_1
    \tag{10}
    $$
    因此,根据入射场和反射场就可以计算出表面的感应电流和磁流。得到电流和磁流后,计算远场的散射波。

    在理论上,入射场可以是任意的。但是这里作者用了Gaussian-windowed平面波。这种波的振幅遵循正态分布,好算。

    总结一下,这里将入射光分解为两个分量,用菲涅尔方程算反射场。进而将反射场与入射场相加,得到总场。通过电/磁场与电/磁流的关系,计算表面的感应电/磁流。这样就可以模拟纤维的散射了。

    也就是说,计算纤维表面的电流和磁流的确可以得到光的散射行为。

    Far-field radiation in 3D

    上一节通过表面电流得到表面电流和磁流。本节用这个信息来计算远场散射。

    基于惠更斯原理(Huygens’s Principle)将原本的散射问题转化为辐射问题。

    惠更斯原理指出,每个波前的电都可以看作新的次级波源。一环扣一环的感觉。

    毛发纤维表面的电流 $J$ 和 磁流 $M$ 被视为光线的次级光源,然后重新辐射出来,得到散射场。

    这里就有点难了,涉及到电磁学中的矩量法(Method of Moments, MoM)。读者可以深入研究一下Gibson[2021]这本书,学会了一定要好好教我。但是没关系,我从闫老师2021年的paper中找了一张图,保证你能看懂。

    图中的红色小球表示的次级辐射源,各自向外辐射,类似于表面电流和磁流产生的次级辐射波。
    次级辐射源向各个方向发射等强度的波,这与本文散射公式中每个表面点对所有方向都有散射贡献的思想一致。
    图中观察了距离光源远场区域(距离为 $r$ )的一小片区域( $\delta \mathbf{r}$ )。在远场区域中,分析波在不同位置 $\mathbf{r}_1$ 和 $\mathbf{r}_2$ 的行为,分别沿着 $\hat{r}_1$ 和 $\hat{r}_2$ 两个方向观察。这与散射公式中,在远场处计算散射电场的行为非常相似。
    图中右侧的光波会在远场叠加形成复杂的干涉条纹,这类似于散射公式中的积分项,将次级辐射源的电磁波在远处叠加。

    用公式则表示为:
    $$
    E_s(\mathbf{r}) = j \omega \mu_0 \frac{e^{-jk_0 R}}{4\pi R} \hat{r} \times \int_\Gamma \left[ \hat{r} \times \mathbf{J}(r{\prime}) + \frac{1}{Z_0} \mathbf{M}(r{\prime}) \right] e^{jk_0 r{\prime} \cdot \hat{r}} d\mathbf{r{\prime}}
    \tag{11}
    $$
    结合图来理解,公式描述的是散射电场 $E_s(\mathbf{r})$ 在远处(远场)某一点 $\mathbf{r}$ 处的表现,与距离呈 $1/R$ 关系衰减。这个公式是一个二维复数平面,也就是把麦克斯韦方程组的解看作时谐电磁波的形式。

    看这个上式结构,形式上跟 $\mathbf{E}(\mathbf{r}, t) = \mathbf{E}_0 e^{j(\omega t – k \cdot \mathbf{r})}$ 很像,叫电场的常见时谐解。

    对应起来。 $ j \omega \mu_0$ 这虚数项描述磁场对电场的影响,与电磁场的频率 $\omega$ 和真空中的磁导率 $\mu_0$ 相关。光波的频率越高,电场越强。 $e^{-jk_0 R}$ 是相位因子,表示光波在传播中的相位变化。波数 $k_0 = \frac{2\pi}{\lambda}$ , $\lambda$ 是电磁波的波长。表示波传播到距离 R 的地方时,电场的相位会发生变化。 $\hat{r}$ 是从散射物体指向观察点的单位向量,表示波的传播方向。叉乘 $\times$ 操作符表示计算的是电场的方向。确保计算出的电场和波传播的方向一致。

    积分项 $ \int_\Gamma \left[ \hat{r} \times \mathbf{J}(r{\prime}) + \frac{1}{Z_0} \mathbf{M}(r{\prime}) \right] e^{jk_0 r{\prime} \cdot \hat{r}} d\mathbf{r{\prime}} $ 是该公式的核心。对物体表面 $\Gamma$ 进行积分,得到毛发表面每个点对散射电场的贡献。每个点的表面电流加表面磁流之和,再点乘相位因子。进一步的, $\mathbf{J}(r{\prime})$ 是表面电流密度, $\hat{r} \times$ 确保了电流产生的散射电场与波的传播方向 $\hat{r}$ 正交。 $Z_0$ 是自由空间阻抗(free space impedance),具体数值 $Z_0 = \sqrt{\frac{\mu_0}{\varepsilon_0}} \approx 377 \, \Omega$ ,个人理解该常数是真空中电场和磁场的比例关系,电场和磁场的抗阻不同因此需要统一尺度才能线性相加,即将磁流规范化到和电流类似的形式。指数项也是相位因子,不做过多解释。

    细心的读者可能发现,为何表面电流密度 $\mathbf{J}(r{\prime})$ 有叉乘 $\hat{r} \times \mathbf{J}(r{\prime})$ ,而表面磁流密度 $\mathbf{M}(r{\prime})$ 没有叉乘。

    根据麦克斯韦方程组,电场和磁场是互相正交的。电流 $\mathbf{J}$ 会产生磁场,变化的磁场再反过来产生电场。电场和磁场是相互耦合的。但是,我们重新看看麦克斯韦方程组的下面两条。
    $$
    \nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}
    \
    \nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t}
    $$
    磁场的生成是直接通过位移电场来实现的,方向已经和电场正交。而电场的产生是磁场的变化率,方向性需要调整。

    换一个角度来理解,积分项左边描述的是电磁场强度随距离衰减,也考虑了相位变化和方向性。积分项描述每个表面点的电磁流与相位贡献

    总结,公式(11)是一个精确的表达,描述物体表面电磁场如何在距离 $r$ 处散射。得到了电场的公式,磁场就很容易计算了。

    当散射距离 $R$ 足够大时,远场的简化表达式如下,可以直接忽略近场效应。换句话说,在远场中,传播特性已经趋于稳定,因此可以把积分项简化为与散射方向 $\hat{r}$ 相关的项,即 $E_s^{\text{far}}(\hat{r})$ 。
    $$
    E_s(r) = \frac{e^{-jk_0 R}}{R} E_s^{\text{far}}(\hat{r}), \quad H_s(r) = \frac{e^{-jk_0 R}}{R} H_s^{\text{far}}(\hat{r})
    \tag{12}
    $$
    此外,若直接计算每个点的贡献,时间复杂度为 $O(MN)$ ,即离散点数$M$ $*$ 散射方向数$N$ 。此处引入八叉树,通过空间划分减少计算表面的离散点数,时间复杂度降到了 $O(M+log(M)N)$ 。具体实现方式在4.3。

    4.3 Multilevel fast Physical Optics

    用多层快速物理光学(Multilevel Fast Physical Optics, MFPO)加速远场散射计算。

    Chew[2001]提出的多层快速多极子算法(MLFMA),用于加速求解电磁场散射问题。这个算法首先为例如毛发表面构建一个八叉树结构,每个叶节点表示一个采样点。构建完Octree后计算每个叶节点的表面电流、磁流。接着从叶节点出发逐层向上累积。从原本的 $O(MN)$ 降低到 $O(M + \log(M)N)$ 。

    接着作者介绍了八叉树加速算法的三个关键部分。首先是远场散射核(far-field scattering kernel)。
    $$
    e^{jk_0 r{\prime} \cdot \hat{r}} = e^{jk_0 (r{\prime} – c_L) \cdot \hat{r}} \prod_{i=1}^{L} e^{jk_0 (c_i – c_{i-1}) \cdot \hat{r}}
    \tag{13}
    $$
    最终目标是计算物体表面每个点的电磁波对某个远场观察区域(距离为 $R$ ,方向为 $\hat{r}$ )的散射贡献。具体是求散射电场 $E_s(r)$ 和磁场 $H_s(r)$ ,即从毛发表面上的每个点 $r{\prime}$ 发出的电磁波在远场中的贡献。公式等号左边是某表面点 $r{\prime}$ 到远场观察方向 $\hat{r}$ 的相位变化。最终的时间复杂度为 $O(MN)$ 。作者把表面分为不同的区域,每个区域指定一个参考中心点,公式中的 $c_0, c_1, …, c_L$ 是八叉树中不同层次的节点中心。

    光是这样也没办法减少计算量。因此需要将距离较近的表面点,由于它们的相位变化差别很小,通过八叉树的高层节点合并这些点的贡献。

    还是用闫老师这幅图来解释。对于远场的某个区域 $\delta \mathbf{r} $ ,首先还是会精确计算所有采样点对参考点 $\mathbf{r}$ 的贡献。但是该区域的其他点则用八叉树的父节点进行近似计算。也就是说,旁边的 $\mathbf{r}_1$ 和 $\mathbf{r}_2$ 就不再需要考虑这么多的采样点了,而是直接用八叉树得到的总贡献。

    作者定义了一个方向集,八叉树每个父节点都存储了关于不同方向集的累积贡献数据。因此,父节点不仅包含空间上的信息,还包含多个散射方向上的累积信息。最终在根节点得到完整的360度散射场分布。

    接下来,从树的第二层开始,依次向上合并。上采样具体方法是对子节点的方向集中的散射贡献做正向FFT,接着zero-padding扩充频域数据最后逆向FFT转换为空间域。最终,使得父节点可以在不同方向上得到更加精确的散射信息。

    • Performance

    八叉树加速效果很好。三层树效果最佳。越复杂的纤维优化效果越好。

    • Fiber microgeometry and scattering patterns

    毛发纤维横截面一般不是完美的圆形,而是椭圆形。通过椭圆的主半径 $r_1$ 和次半径 $r_2$ 来定义纤维的几何参数。为了模拟纤维表面的微观粗糙度,作者在椭圆柱体的表面上叠加了一个高斯随机高度场(Gaussian random height field),模拟真实的纤维表面。进一步地,加入了角质层倾斜(cuticle tilt),模拟薄片在纤维表面倾斜排列。

    通过对比传统的基于光线的毛发模型,波动光学模拟发现,除了XIA[2020]预测到的显著的向前散射(forward-scattering)现象,还观察到了复杂的波长依赖颗粒图案(wavelength-dependent granular patterns)。在转换为RGB颜色时,会生成丰富的色彩效果。

    研究指出了一些从模拟中观察到的规律:

    • 无论它们的实际位置如何,具有相同几何参数(如半径、粗糙度、角质层倾斜等)的纤维,在散射行为上会生成相似的颗粒图案
    • 如果纤维的几何参数不同,则它们会生成不同统计特性的颗粒图案,即散射模式明显不同。
    • 散射斑点的位置依赖于光线的入射角度,偏移的方向跟随half vector方向。
    • 斑点(speckle)图案的大小随光波波长的增加而增大。这一现象与Goodman[2007]结果一致。

    5. A PRACTICAL FIBER SCATTERING MODEL

    实用纤维散射模型(A PRACTICAL FIBER SCATTERING MODEL)旨在解决纤维的微观几何变化和复杂散射行为。

    以前的研究一般是用Lut存散射分布函数,这种方法空间消耗巨大,需要同时记录纵向和方位角散射分布。

    现在,作者提出了一种基于小波噪声表示的紧凑纤维散射模型,可表示的几何复杂度提高了,散射效果更好了。

    简单的说,作者想要紧凑的统计散斑现象,使其可以通过均值方差自相关函数(ACF)等统计量来表示。因此作者借助了散斑理论(Speckle Theory)来描述光随机干涉所产生的图样。

    5.1 Speckle statistics

    这里作者上来就提到完全发展的散斑 (Fully Developed Speckle)这个概念。以下是个人理解。在光线照射到一个粗糙的表面(比如纤维/毛发表面)时,表面上的每个小区域都会散射光线。由于表面的微小不规则性,散射的光线之间会相互干涉,产生一个复杂的光强分布。这种分布表现为一系列亮点和暗点,我们称之为散斑。表面的微小特征(比如粗糙度)在整个照射区域(相对于光线的波长来说)变得足够不规则,导致散射光线在各个点上的相位和强度是随机的,这就是所谓的完全发展的散斑

    这个时候,可以用Goodman[2007]的complex Gaussian distribution来描述Fully Developed Speckle。即,电磁场的实部 $\mathcal{R}$ 和虚部 $\mathcal{I}$ 在空间上服从复高斯分布(complex Gaussian distribution)。
    $$
    p_{\mathcal{R},\mathcal{I}}(\mathcal{R}, \mathcal{I}) = \frac{1}{2 \pi \sigma^2} \exp\left( – \frac{\mathcal{R}^2 + \mathcal{I}^2}{2\sigma^2} \right)
    \tag{14}
    $$
    场的实部和虚部是独立且正态分布的。均值为零,方差相同。

    电磁场光强 $I$ 和该光强分布的服从指数分布(exponential distribution)的概率密度函数:
    $$
    I = \mathcal{R}^2 + \mathcal{I}^2 \ p_I(I) = \frac{1}{2\sigma^2} \exp\left( -\frac{I}{2\sigma^2} \right)
    \tag{15}
    $$
    这些公式没啥可讲的,总之散斑场就是很随机哒!

    作者让光强遵循指数分布,确保在单个方向的统计特性。其次,通过研究两个点之间的光强统计关系,衡量两个点的ensemble average。这里用autocorrelation function (ACF)。
    $$
    C(I_{p_1}, I_{p_2}) = \frac{\overline{(I_{p_1} – \overline{I_{p_1}})(I_{p_2} – \overline{I_{p_2}})}}{\sigma(I_{p_1}) \sigma(I_{p_2})}
    \tag{16}
    $$
    自相关函数的值介于 -1 和 1 之间,当值接近 1 时,说明两个光强的散射行为非常相近。这是高效再现纤维散射场中颗粒结构的关键。

    5.2 Wavelet noise representation of the speckles

    作者引入小波噪声(Wavelet noise)来表示散斑的噪声分量 $f(\theta_h, \phi_h, \lambda)$ 。具体公式如下:
    $$
    f(\mathbf{x}) = \sum_{b=0}^{n-1} w_b(\mathbf{x}) I\left(2^b g_{\lambda}(\mathbf{x})\right)
    \tag{17}
    $$
    公式的核心思想是将散斑的光强分解为不同频率层次的噪声,并且加权组合。

    通过调整不同频率带的权重,生成自相关函数 $ C_f(\mathbf{x}_1, \mathbf{x}_2)$ 接近与目标自相关函数的最终噪声。

    根据维纳-辛钦定理(Wiener-Khinchin theorem),自相关函数可以通过傅里叶变换(Fourier Transform)来计算。具体公式如下:

    $$
    C_f(\mathbf{x}_1, \mathbf{x}2) = \mathcal{F} \left( \mathcal{F}^{-1} \left( \sum{b=0}^{n-1} w_b I_b \right)^2 \right)
    \tag{18}
    $$
    这表示我们可以通过计算小波噪声的傅里叶变换,来获得自相关函数。自相关函数和功率谱密度函数是一对傅里叶变换,太Amazing了。

    这里原论文给了一个通过频率带加权求和近似计算ACF的证明。

    省流:通过对噪声的各个频率带进行加权,可以近似得到整个噪声的自相关函数,而不用处理不同频率带之间的交互。

    通过最小二乘法找到非负权重 $v_b$ ,以使得每个频率带的自相关函数 $C_b(\mathbf{x}1, \mathbf{x}_2)$ 的加权和可以逼近目标自相关函数 $C_t(\mathbf{x}_1, \mathbf{x}_2)$ 。

    $$ C_t(\mathbf{x}_1, \mathbf{x}2) \approx \sum{b=0}^{n-1} v_b C_b(\mathbf{x}_1, \mathbf{x}_2) \tag{19} $$ 计算完权重后,要确保能量守恒。也就是调整噪声函数 $f(\mathbf{x})$ 的期望值 $\mathbb{E}[f(\mathbf{x})] = 1$ 。 $$ \begin{aligned} & \mathrm{E}\left[S{\text {avg }}\left(\theta_i, \theta_r, \phi_r, \phi_r, \lambda\right) f\left(\theta_h, \phi_h, \lambda\right)\right] \\\
    & =S_{\text {avg }}\left(\theta_i, \theta_r, \phi_r, \phi_r, \lambda\right) \mathrm{E}[f(\mathbf{x})] \
    & \approx S_{\text {avg }}\left(\theta_i, \theta_r, \phi_r, \phi_r, \lambda\right)
    \end{aligned}
    \tag{20}
    $$


    虽然效果好,但是有局限性。比如在grazing incidence angles的地方(光线几乎平行表面的情况),拟合准确性下降,导致散射准确度下降。Degeneration in the Forward Direction问题,当光线处于正前方方向(光线方向与表面法线一致)时,半向量方向会退化。

    6. Validation

    6.1 Wave simulation validation

    计算了x-y平面中的散射强度,并通过3600个方位角 $\phi_r$ 进行计算,最终将这些角度平均到360个方向上。

    首先对比Mie散射。在grazing angles不如Mie散射。物体的半径相对于波长较大时,PO近似更为精确。当物体曲率较小,就不准。

    然后对比BEM。模拟一个小椭球体的波散射。BEM用了三个小时,PO花了两秒。

    最后对比2D BEM。用一维高斯高度场(1D Gaussian height fields)包裹在圆形和椭圆形的横截面上。PO完胜。

    6.2 Measurement

    使用了波长为 633nm 的 HeNe 激光器,光束的光斑大小为 0.7mm(沿着头发的长度方向)× 3mm(垂直于头发方向)。激光通过一个小孔照射在人类头发样本的一个小区域。

    总之就是效果好!

    6.3 Noise representation validation

    一个字,好!

    7. Rendering

    整合进PBRT-v3。原本的基于光线的模型记作 $S_{\text{ray}}$ ,而衍射模型记作 $S_{\text{diffract}}$ 。

    对于衍射,这里近似为单缝衍射,而缝的宽度等于圆柱体(纤维)的直径。
    $$
    f_{\text{diffract}}(\theta_i, \phi_d, a) = a \cos \theta_i \cdot \text{sinc}^2(a \cos \theta_i \sin \phi_d)
    \tag{22}
    $$
    这种衍射模型被用来和纵向函数结合,得到一个完整的双向曲面散射分布函数(BCSDF)。将衍射因子以 $50 \times 50 \times 200$ 的表格形式进行预计算。并使用同样大小的表格进行重要性采样。

    消光截面可以简单理解为光与物体相互作用的“有效面积”。 纤维的消光截面会比它的实际几何截面大。消光截面会接近于几何截面的两倍。光在纤维上既发生反射也发生衍射,因此我们需要把总能量分配给这两种现象。根据经验,可以把一半的能量用于衍射,另一半用于反射。
    $$
    S_{\text{diffract}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = \frac{1}{2} \left[S_{\text{ray}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) + f_{\text{diffract}}(\theta_i, \phi_d, D/\lambda)\right]
    \tag{23}
    $$
    通过重要性采样,公平地考虑光的反射和衍射。

    接下来,最终的描述光散射现象的渲染公式,形式整得挺好:
    $$
    S_{0,\text{prac}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = S_{0,\text{avg}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) f(\theta_h, \phi_h, \lambda)\ \\
    = \frac{1}{2} \left[ S_{\text{ray}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) + f_{\text{diffract}}(\theta_i, \phi_d, D/\lambda) \right] f(\theta_h, \phi_h, \lambda)\
    \tag{24}
    $$
    反射+衍射+噪声。噪声函数 $f(\theta_h, \phi_h, \lambda)$ 在最后,使得散射的光线不在集中在少数方向。

    重点看这个部分:
    $$
    S_{0,\text{prac}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = S_{0,\text{avg}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) f(\theta_h, \phi_h, \lambda)
    $$
    $S_{0,\text{avg}}$ 表示毛发表面平均反射/折射和衍射的行为,这是通过预处理(BSDF表格)计算得到的。

    接下来讲讲如何从PO提取BCSDF。

    通过麦克斯韦方程得到散射的电场和磁场($E_s^{\text{far}}$ 和 $H_s^{\text{far}}$)。

    接着用坡印廷矢量(Poynting vector)计算能量流。相当于计算光的强度。


    $$
    \langle S \rangle = \frac{1}{2} \text{Re}(E \times H^) \tag{25}
    $$

    为了将模拟结果用于渲染,将散射强度与入射功率关联起来。散射强度的计算公式,其中 $R^2$ 是远场球面面积:

    $$
    I_s(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = \langle S(\mathbf{r}) \rangle \cdot \hat{n} R^2 \tag{26}
    $$

    散射功率 $P_s$ 和吸收功率 $P_a$ 分别通过积分散射光和吸收光的强度来计算。

    $$
    P_s = \int_{\Omega} I_s(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) \, d\omega \tag{27}
    $$

    对于吸收功率 $P_a$ ,通过表面上的坡印廷矢量积分计算。

    $$
    \begin{aligned}
    P_a & =\int_{\Gamma} \frac{1}{2} \operatorname{Re}\left(\mathbf{E}1 \times \mathbf{H}_1^{}\right) \cdot \hat{\mathbf{n}}_1(A) d A \ & = \\
    \int{\Gamma} \frac{1}{2} \operatorname{Re}\left(\mathbf{J}^{} \times \mathbf{M}\right) \cdot \hat{\mathbf{n}}_1(s) d s
    \end{aligned}
    \tag{28}
    $$


    最终,BCSDF搞出来了。

    $$
    S(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = \frac{I_s(\theta_i, \phi_i, \theta_r, \phi_r, \lambda)}{|P_a – P_s|}
    \tag{30}
    $$

    最后用了一个5D的表格。

    1D 维度描述波长。
    2D 描述入射光的方向。
    2D 描述出射光的方向。

    可以通过散射的记忆效应减少存储需求。

    渲染时,对于每个入射光方向,查询相邻的表格数据,应用相应的角度偏移(记忆效应中的角度偏移量)。

    最终使用的表格大小为 $25 \times 32 \times 72 \times 180 \times 360$ ,非常大,约15GB的内存需求。

    8. Result

    与XIA[2020]相比,新模型更加鲜艳。色彩光斑(colorful glints)更好。体现多种波长对毛发产生的光斑。

    9. DISCUSSION AND CONCLUSION

    第一篇能够实际应用的 3D 波动光学纤维散射模型。

    能够生成光学散射中常见的细微光斑(speckle patterns)。

    虽然 3D 模拟的精度很高,但它的内存占用和计算成本非常大。提出了噪声实用模型,通过捕捉纤维散射光斑的统计特性(如自相关函数),减少计算量。

    目前该模型主要用于第一阶反射模式的模拟。未来可以扩展到更高阶的散射模式。

    高阶模式时可能需要进一步开发新技术来预测或测量高阶模式的统计特性。

  • 毛发渲染研究:从基于光线到波动光学-学习笔记-1

    毛发渲染研究:从基于光线到波动光学-学习笔记-1

    原文:https://zhuanlan.zhihu.com/p/776529221


    毛发渲染研究历史演变

    在1989年,Kajiya 和 Kay 将Phong模型扩展到毛发绘制,提出了绘制毛发的经验模型——Kajiya-Kay 模型。该模型将毛发简化为一系列细长的圆柱体,并假设光线只在毛发表面发生简单的反射。镜面反射:高光。漫反射:模拟光线在毛发内部的散射,毛发的整体亮度。

    Marschner等人在2003年发表了论文《Light Scattering from Human Hair Fibers》,提出了一个基于物理的毛发反射模型,被称为Marschner模型。

    传统的毛发渲染模型,如Marschner模型和Kajiya-Kay模型,通常将毛发简化为单层圆柱体,忽略了毛髓质(medulla)的影响。Yan等人在2015年发表的论文《Physically-Accurate Fur Reflectance: Modeling, Measurement and Rendering》针对这一问题提出了一种更为精确和高效的毛发渲染模型,即将每根毛发建模为两个同心圆柱体。其建模结合了完整的毛发双向散射分布函数(BSDF),精确地描述光线在毛发中的多路径传播和散射行为。此外,为了确保物理真实性,进行了大量物理测量工作,包括九种不同的毛发样本,最后开放了数据库的部分反射参数供艺术家调整。为了提高渲染效率,Yan[2015]结合了[Zinke and Weber 2007]近场散射(R, TT, TRT)的考虑,对常见的散射路径进行预计算,存在Lut中,实现了单毛纤维的高效光线散射计算。对于渲染管线的优化,Yan[2015]主要集中在双圆柱模型上。

    从Marschner[2003]到d’Eon[2011]和Chiang[2016]的拓展模型,虽然不断增加毛发的参数(如d’Eon[2011]的方位角粗糙度数值积分、Chiang[2016]的近场方位角散射)使得渲染精度增加,但是其复杂的散射路径和大量的预计算限制了其实用性、实时性。Yan[2015]提出的双圆柱毛发反射模型也存在计算成本高、实用性低的问题。因此Yan[2017]提出了一个简化版本,通过解析的方式实现快速积分并且大幅减少Lobes数量。

    上面这幅图中,清晰的指出了,Marschner[2003]使用的是纵向-方位角的分解表示,将复杂的三维光散射过程简化为两个较为独立的维度。纵向散射函数(Longitudinal Scattering Function)描述光线沿着毛发纤维轴线的传播和散射。方位角散射函数(Azimuthal Scattering Function)描述光线在毛发纤维横截面(垂直于纤维轴线的平面)内的散射。这个模型考虑T、TT和TRT。在d’Eon[2011]修正了能量守恒问题。Yan[2015]的双圆柱模型(毛小皮和毛髓质)复杂了光线交互,考虑了R、TrT、TtT和TrRrT。Yan[2017]中引入了统一的折射率(IORs),化简光路传播,不再区分不同材料的折射率,即R、TT、TRT、TT^s和TRT^s(^s表示化简路径)。Yan[2017]指出统一IORs实际上并不会显著影响渲染结果,依旧能保持较高的真实性,原因是毛皮质(cortex)和毛髓质(medulla)之间的折射率非常接近

    Yan[2017]为了解决以前毛发渲染中计算复杂度较高的问题,提出了近场与远场的划分,并引入了分层渲染策略(Layered Rendering Strategy)。近场主要是光线在单根毛发上的精细散射和反射。此区域需要高精度的物理模型渲染毛发,如涉及干涉、衍射等波动光学现象。远场则是描述光线在大量毛发纤维集体作用下的整体散射效应。在此区域,单根毛发的微观结构影响可以被平均化,更加适合采用统计/近似等方法计算,优化提高渲染效率。划分标准:基于光与毛发纤维之间的距离和毛发纤维的尺寸。也就是设定一个Threshold,当光线与毛发的距离小于该阈值时,归类为近场;反之,归类为远场。分层渲染的流程首先是光线追踪,判断近场抑或是远场,近场结合预计算散射表用Mie散射理论和Fresnel方程算反射透射,远场用统计散射函数+预计算散射表+MC积分降低复杂度,最终两者叠加,并且做归一化处理。这里按照论文的三点来详细讲讲:

    • Simple Reflectance Model(简单的反射模型)Yan[2015]的模型虽然引入了毛髓质(medulla),并考虑了更多的物理细节,但仍然具有较高的计算复杂度,特别是在近场与远场的转换上。Yan[2017]提出简化的分层反射模型,保留了反射的关键物理现象,减少了不必要的复杂光散射路径。他们将反射描述为三个主要的光散射路径(R:光线在毛小皮表面的镜面反射、TT:光线透过毛小皮层,并在内部散射后从另一侧透射出来、TRT:光线进入毛发后在内部反射一次,最终透射出来)。最后结合简化的双向散射分布函数(BSDF)捕捉路径的反射,减少了计算中所需的lobes(通常用于描述不同散射方向的分布曲线)。相比于之前的模型,计算过程中的lobes从原来的9个减少到5个。
    • Improved Accuracy and Practicality(提高的精度和实用性)高精度模型(如Marschner模型、Yan[2015]等)需要复杂的数值计算和大量的预计算数据,因此难以在实时应用中实现。而低精度的经验模型则缺乏足够的物理真实性。于是在Yan[2017]中,做了很多改进。尽管模型简化了光散射路径,但通过结合物理现象(如毛小皮的多层反射结构、各向异性散射等),保持了毛发和毛皮的物理精度。通过对光的纵向和方位角散射进行合理简化,该模型在精确性上优于传统的经验模型。此外就是近场与远场的过渡处理。传统模型往往无法平滑地处理近场和远场之间的光学过渡问题。Yan等人引入了一个近场-远场的解析解决方案,在光线靠近毛发纤维时精确模拟反射,同时在远场时快速近似光线的整体反射行为。使得渲染效率可用于实时渲染。
    • Analytic Near/Far Field Solution(解析的近场/远场求解)近场(光线与单根毛发纤维之间的短距离交互,即散射行为)和远场(光线与大量毛发纤维之间的远距离集体效应)的处理存在巨大差异。为了实现近场和远场的无缝过渡,作者使用了解析积分方法,而非繁琐的数值积分。解析积分能够直接计算反射函数,不需要通过复杂的数值求解或预计算,极大地减少了计算时间。
    • Significant Speed Up(显著加速)
      • 减少用于描述散射路径的lobes数量
      • 解析积分和预计算结合
      • 采用了简化的BSDF和解析的反射计算公式,将光线追踪和反射计算在GPU上并行化,模型的渲染速度比之前的方法提升了6-8倍。

    简单总结一下,Yan[2017]提出的反射模型效果和性能都不错,通过统一皮层和髓质的IOR,使得模型只需要5个lobes就可以表示皮毛的复杂散射,利用张量近似最小化存储开销。在这个模型的基础上,提出远近场的解析积分,将模型拓展至多尺度渲染。想要在实时渲染中实现BCSDF模型是非常简单的,当前已经有不少实现方式了,并且已经应用于影视行业。[The Lion King (HD). 2017 movie] (2019 Oscar Nominee for Best Visual Effects)

    XIA[2023]提出了基于波动光学的毛发反射模型。传统的毛发渲染模型大多基于几何光学近似,这些模型在处理较大的毛发纤维时效果较好,但在细微光学现象(如毛发上的彩色光点,即glints)方面表现欠佳。这些散射效应,包括反射、透射和多次散射,难以通过简单的几何光学模型准确描述。随着毛发纤维的直径接近或小于光(可见光)波长时,波动光学效应变得越来越重要,而几何光学模型则无法捕捉这些效应。

    毛发的波动光学效应,如光的干涉、衍射等计算量非常大。波动光学模拟需要计算电磁场的传播,而不仅仅是光线的路径。毛发、皮毛具有高度不规则的微观结构,这些结构会进一步影响光的散射。基于几何光学的方法无法处理这些波动现象,而全波模拟需要高昂的计算资源。

    早在XIA[2020]就已经提出了利用波动光学准确描述光与纤维的相互作用,用边界元法(Boundary Element Method, BEM)模拟光线在任意截面的纤维散射。并且XIA[2020]指出由于衍射效应,纤维表现出极强的前向散射效应。因此波动光学效应应该让光线专注集中前向散射的方向。还指出了,小的纤维散射效应显著依赖光的波长,导致强烈的波长散射。此外波动场带来的奇异性软化现象也是决定真实焦散效果的关键。为了BEM模拟的计算量可控,纤维的形状理想为具有规则的横截面形状。但是Marschner[2003]指出毛发表面的不规则对毛发外观有重要的影响,在波动光学中这样的效应是否显著仍然是一个需要探讨和解决的问题。

    传统的几何光学方法基于光线追踪(Ray Tracing),通过模拟光线在毛发纤维上的反射和折射来预测光的传播路径。然而,这种方法在处理波长与纤维尺寸相当的光波时显得不足,无法捕捉到由于衍射产生的复杂光学效应。实际测量中,纤维散射表现出一些尖锐的光学特征,这是由于光的衍射效应造成的。包括黑色狗毛中表现的略微颜色偏移,也是由于光的干涉和衍射引起的。

    为了处理这些几何光学无法解释的现象,XIA[2023]开发了一个基于物理光学近似(Physical Optics Approximation,PO)的三维波动光学模拟器,并且利用GPU加速计算效率。通过八叉树结构来处理空间。该模拟器具有一定的通用性,能够处理任意的三维几何形状,也就是说可以处理到纤维表面的微观结构。

    但是XIA[2023]中指出,因为计算复杂度高的原因,直接将这个模拟器运用到当前主流渲染框架是不现实的。因此需要先将该模型迁移到现有的毛发散射模型中,然后加入一个基于基础衍射理论(elementary diffraction theory)的衍射lobe。最后提出了一个基于随机过程的调制方法来捕捉散斑(optical speckle)效应,尽管是程序噪声但是依然与物理模拟结果一致,视觉效果与现实相近。

    XIA[2023]将当前的毛发/纤维渲染分为两种,一种是传统的Ray-based Fiber Models(基于光线的纤维模型),另一种则是Wave-based Fiber Models(基于波动光学的纤维模型)

    Linder[2014]提出了一种解析解用于处理圆柱形纤维的散射行为,但是只适用于完美的圆形截面,无法处理复杂的毛发表面结构。XIA[2020]通过二维波动光学渲染研究了任意横截面形状的纤维散射行为,展现出衍射效应的表现,但是该论文假设前提是完美的挤出结构,即纤维表面是规则的。Bennamira&Pattanaik[2021]提出了一个混合模型,在只有前向衍射的情况下使用波动光学求解,在其他散射模式中使用传统的几何光学。但是XIA[2023]进一步考虑了光线的入射纵向角依赖性

    论文最后将程序噪声拟合到波动光学中的散斑模式,通过统计特性拟合,产生非常真实的效果。

    XIA[2023]还提到了电磁计算工具(Computational Electromagnetics Tools)在处理光线和纤维复杂交互时的重要作用,尤其是使用像是BEM等数值方法时。计算电磁学是用于研究电磁现象的计算方法,因为光是一种电磁波,光学中许多现象可以通过电磁学的工具来分析。CEM在光学中经常用于计算光与物体表面(如毛发纤维)之间的交互。CEM的常见算法有:

    • 有限差分时域法(Finite-Difference Time-Domain, FDTD):一种通过空间和时间离散化求解麦克斯韦方程的数值方法,最早由Kane Yee于1966年提出,是一种直接的时域方法Kane Yee[1966], Taflove[2005]。
    • 有限元法(Finite Element Method, FEM):通过将求解区域划分为有限个元素,用来求解复杂几何结构中的电磁场分布Jin[2015]。
    • 边界元法(Boundary Element Method, BEM):也称为矩量法(Method of Moments, MoM),这是一种通过只处理物体表面的电磁场来减少计算量的数值方法Gibson[2021], Huddleston[1986], Wu[1977]。

    虽然CEM有很多加速算法,比如Song[1997]的多层快速多极子算法(Multilevel Fast Multipole Algorithm, MLFMA),但是在毛发皮毛模拟中,提升依旧杯水车薪。

    由于全波模拟计算成本高昂,因此XIA[2023]提出物理光学近似(PO)来简化例如毛发纤维表面的反射和衍射过程。

    物理光学平面模型物理光学近似(Physical Optics, PO)的一种应用,它专门用于模拟光在平面或接近平面的表面上的散射和衍射效应。Beckmann-Kirchhoff[1987]和Harvey-Shack[1979]可以有效计算粗糙表面的散射光。He[1991], Kajiya[1985]的高斯随机表面、Stam[1999]的周期性静态表面和Werner[2017]的划痕表面都利用了物理光学近似来处理表面反射和衍射。

    对于更加复杂的衍射,Krywonos[2006], Krywonos[2011]则提出了改进粗糙表面衍射的处理方法。Holzschuch, Pacanowski[2017]提出了双尺度微表面模型,结合反射和衍射来模拟粗糙表面。最近Falster[2020]结合了Kirchhoff标量衍射理论和路径追踪处理二次反射、散射。Yan[2018]利用物理光学渲染粗糙表面的镜面微几何结构。

    和平面表面不同的是,纤维表面是闭合的曲面,毛发纤维的几何形状对光的相互作用更加复杂。除了反射和散射,还需要处理前向衍射散射和大范围阴影效应。

    XIA[2023]还讨论了散斑(speckle)效应程序噪声(procedural noise)在毛发渲染中的应用。

    Speckle是当光线和粗糙的表面相互作用时,产生的具有颗粒感结构的图像或衍射图案。其统计特性已经被广泛研究。当相干光(如激光)照射到粗糙表面或通过散射介质时,产生的一种随机的明暗斑点图案。通俗地讲,就像你用激光笔照射在一堵粗糙的墙上,原本应该是一个光滑的光点,却会看到一个颗粒状、闪烁的图案。由于光在微小的表面不平整处发生了散射,不同路径的光波相互干涉,有的加强(形成亮斑),有的相互抵消(形成暗斑),从而产生了这种斑点状的图案。

    之前的研究已经探索了如何通过蒙特卡洛方法来模拟体积散射中的散斑效应Bar[2019, 2020]。然而,这些模型主要适用于均匀介质(homogeneous media),不适用于毛发纤维等异质结构。Steinberg, Yan [2022]研究了平面粗糙表面的散斑渲染。然而作者指出,纤维表面的散斑效应与平面表面不同,表现出不同的统计特性。

    因此XIA[2023]提出了精确捕捉纤维散斑模式统计特性的渲染模型。通过研究纤维表面的特殊几何结构和散斑分布,来模拟毛发纤维的散射效应,提供更优质的散斑效果。

    需要注意的是,薄膜干涉(Thin-film interference)和散斑效应(Speckle Effect)虽然都是由光的干涉现象引起,但它们在物理机制、视觉表现以及在计算机图形学中的渲染方法上存在显著差异。薄膜干涉的蒙特卡洛方法,如随机薄膜厚度采样,可以借鉴到散斑效应的随机斑点生成中,以提高渲染的真实感。层次化的厚度采样、预计算干涉图案等近似算法也可以相互借鉴。薄膜干涉往往涉及不同尺度的光波干涉,散斑效应也涉及微观表面结构的多尺度散射。

    两者之间,薄膜干涉渲染复杂度相对较低,可以充分使用预计算规避实时计算的负担。但是散斑效应具有高度随机、统计特性,需要处理大量随机干涉的路径,尤其是模拟毛发这种异质结构。目前的研究如XIA[2023]正致力于提高其效率,但相比薄膜干涉仍有较大差距。

    XIA[2023]使用Cook, DeRose[2005]的Wavelet带限噪声控制处理毛发纤维的微观几何变化。这种噪声不同于常规的程序噪声,例如Perlin[1985], Olano[2002], Perlin, Neyret[2001]等,Wavelet噪声的一个显著优点是其统计分布可以计算和控制

    XIA[2023]的实用波动光学纤维散射模型的优势在于其逼真的彩色高光(glints)。以往的几何光学模型通常假设纤维表面是光滑的介电柱体,没有考虑到光波在表面不规则结构上的复杂交互。在实际测试中,XIA[2023]的模型在渲染时间上表现良好,能够在生产环境中应用,并且相比于传统模型生成的光学效果更加细腻和真实。

    XIA[2023]是一个重要的突破,构建了首个三维波动光学纤维散射模拟器。以往的纤维模型(包括早期的波动光学模型如Xia等人2020年)大多假设纵向和方位角方向上的散射是可分离的,这大大简化了计算。然而,作者的模拟结果显示,高光在纵向和方位角上是不可分离的,这是之前的模型无法准确处理的现象。该模拟器还预测了散斑模式(speckle patterns),这是之前所有基于几何光学和波动光学的纤维散射模型都没有捕捉到的现象。并且以往的存储和查找模拟生成的5维散射分布的方法是通过表格法(tabulation),特别消耗内存。因此用程序噪声直接取代一个五维的表格。

    XIA[2023]目前只模拟了一次反射模式中的散斑效应,更高阶的反射模式仍然在研究中。并且浅色的毛发可能需求更高的计算要求才能完美模拟。该研究的波动光学纤维散射模型可以轻松与之前的纤维模型结合

    References

    Zotero一键生成,需修正。

    [1] J. T. Kajiya and T. L. Kay, “RENDERING FUR WITIt THREE DIMENSIONAL TEXTURES,” 1989.

    [2] S. R. Marschner, H. W. Jensen, and M. Cammarano, “Light Scattering from Human Hair Fibers,” 2003.

    [3] A. Zinke and A. Weber, “Light Scattering from Filaments,” IEEE Trans. Visual. Comput. Graphics, vol. 13, no. 2, pp. 342–356, Mar. 2007.

    [4] L.-Q. Yan, C.-W. Tseng, H. W. Jensen, and R. Ramamoorthi, “Physically-accurate fur reflectance: modeling, measurement and rendering,” ACM Trans. Graph., vol. 34, no. 6, pp. 1–13, Nov. 2015.

    [5] L.-Q. Yan, H. W. Jensen, and R. Ramamoorthi, “An efficient and practical near and far field fur reflectance model,” ACM Trans. Graph., vol. 36, no. 4, pp. 1–13, Aug. 2017.

    [6] M. Xia, B. Walter, C. Hery, O. Maury, E. Michielssen, and S. Marschner, “A Practical Wave Optics Reflection Model for Hair and Fur,” ACM Trans. Graph., vol. 42, no. 4, pp. 1–15, Aug. 2023.

    Glints效果探究

    传统的基于几何光学的渲染方法,例如Yan[2014, 2016]使用双向反射分布函数(BRDF)来模拟镜面反射表面,存在一定的局限性。

    Yan[2014, 2016]指出,传统BRDF模型通常使用光滑的法线分布函数(NDF),假设微平面是无限小的。但实际上,现实中的表面往往具有明显的几何特征,例如微米级别的凹凸、金属漆中的片状物等,这些特征在强定向光源(如日光)下会引发显著的Glints效果。Yan等人通过高分辨率的法线贴图,更精确地模拟这些小尺度的表面几何特征,并提出了一种新的方法来有效渲染这些复杂的镜面高光。

    传统的均匀像素采样技术在捕捉这些小范围内的高光时方差过大,导致渲染效率低下,且无法有效处理由于光路复杂性引起的高光分布不均现象。因此Yan[2014, 2016]引入了基于法线分布的搜索和针对性采样。

    在毛发渲染中可以观察到毛发和毛皮在强定向光源照射下会显示出颜色变化的闪烁效果。

    XIA[2023]中使用光学散斑理论(Optical Speckle)来模拟高光噪声,加入了基础衍射理论的衍射瓣(diffraction lobe)处理光在毛发等纤维结构表面的衍射效应,进而渲染出彩色高光效果(colored glints)。

    XIA[2023]第八章提到,在阳光下可以很容易观察到Glints现象。这些颜色效应虽然在远距离观察时显得微妙,但在近距离观察时能够显著增强毛发的外观效果,有时还会导致纤维的色调发生轻微变化。

    在图9中,XIA[2023]的模型在浅色毛发上也能产生彩色闪光效果。相比于深色纤维,浅色毛发的闪光更加细微,因为多次散射会使颜色平均化,导致颜色对比度降低。与XIA[2020]相比,XIA[2023]不仅更好地处理了波长相关的反射,还提升了对毛鳞片角度的处理能力,能够捕捉到由毛鳞片倾斜引起的高光移动。

    References

    [1] L.-Q. Yan, M. Hašan, W. Jakob, J. Lawrence, S. Marschner, and R. Ramamoorthi, “Rendering glints on high-resolution normal-mapped specular surfaces,” ACM Trans. Graph., vol. 33, no. 4, pp. 1–9, Jul. 2014.

    [2] L.-Q. Yan, M. Hašan, S. Marschner, and R. Ramamoorthi, “Position-normal distributions for efficient rendering of specular microstructure,” ACM Trans. Graph., vol. 35, no. 4, pp. 1–9, Jul. 2016.

    全波参考模拟器

    https://dl.acm.org/doi/10.1145/3592414

    1. Intro

    这篇paper讨论的是渲染黑狗毛论文「A Practical Wave Optics Reflection Model for Hair and Fur」中用来生成高精度光散射模拟数据的物理波模拟三维波动光学纤维散射模拟器的理论基础。

    计算粗糙表面的光反射是一个重要课题。例如毛发纤维的微小特征等小尺度的几何结构,对光的反射行为有显著影响。BRDF描述的是给定入射、出射方向表面如何反射光线。几何光学的局限已经重复多次,这种将光看作直线传播的模型在微观结构与光波长相近时则无法捕捉到光的波动性。

    使用波动光学来近似衍射的理论模型有[Beckmann and Spizzichino 1987]的Beckmann-Kirchhoff theory, [Krywonos 2006]的Harvey-Shack模型。前者描述粗糙表面的光反射行为,后者则是一系列基于波动光学的模型,更加精确地描述光在复杂表面的散射行为。

    现有的模型都是针对大面积表面的平均反射行为,忽略局部的细节变化。Yan[2016, 2018]的则是能够在空间的不同区域捕捉微观结构对光反射的变化。即使是基于电磁波传播的模型,也由于计算复杂性,仍然需要进行一定的近似处理。这些方法其实并不是ground truth的。

    为了准确捕捉干涉效应,XIA[2023]目标开发一个参考模拟工具,忠实按照麦克斯韦方程组模拟光的传播。唯一的近似仅是数值离散化,最终生成传统的双向反射分布函数(BRDF)作为输出。而这个模拟器则是真正做到了ground truth

    也就是说这个模拟器能够准确模拟光的波动特性,包括干涉、衍射和多重散射等等。模拟器中运用到的近似也仅仅是网格划分和数值积分误差。

    通过高精度的全波模拟,能够生成具有高角度和空间分辨率的BRDF数据

    同时,模拟器能够处理大规模的表面区域(如60 × 60 × 10波长)。比方说使用波长约500纳米的可见光,60波长就相当于30微米。也就是说,模拟器的计算是基于光波长尺度进行的,在相同的真实物理尺寸下,不同波长的光会对应不同的离散化单元数量。波长越大(例如红光的波长比蓝光大),对于同样的物理尺寸,所需的离散化单元(如网格划分)就会相对更少,因此处理起来的计算量会相对更小,处理速度也可能更快

    具体地,将表面表示为一个高度场,每个网格点对应一个高度值,且用四边形作为基元。

    对于散射场,用边界积分公式(Boundary Integral Formulation)将电磁波的散射问题转化为仅在表面边界上求解的积分方程,关键实现方法是边界元方法(BEM)。再用基于三维快速傅里叶变换(3D FFT)的自适应积分方法(Adaptive Integral Method, AIM)加速边界积分的计算过程。

    并且利用GPU加速并行处理大规模的表面散射问题。

    并且paper中采用了一种组合小规模模拟结果来表征表面双向散射行为。

    相关工作

    基于波动光学的反射模型

    老生常谈的几何光学 vs 波动光学。本文主要对比表面散射模型。几何光学的经典模型包括:Cook-Torrance模型 [Cook and Torrance 1982]、Oren-Nayar模型[Michael 1994]。波动光学这边,主要是用物理光学近似(Physical Optics Approximations)来简化全波方程。也就是黑毛狗中的一阶近似(单次散射)来估计表面反射。经典模型包括Beckmann-Kirchoff理论Harvey-Shack模型,它们使用标量形式的近似方程来模拟波动光学效应。它们被广泛应用于各种表面类型的反射估计,比如高斯随机表面周期性表面等。但是这些方法的计算结果常常是空间上的平均结果,没办法进行高分辨率的细节反射。

    • He等人(1991)和Lanari等人(2017)的高斯随机表面模型。
    • Dhillon等人(2014)、Stam(1999)以及Toisoul和Ghosh(2017)的周期性表面模型。
    • Levin等人(2013)的多层平面表面模型。
    • Dong等人(2016)的表面数据表模型。
    • Werner等人(2017)对划痕表面的研究。

    此外,物理光学近似还被用于估计某些特殊表面的空间变化外观,例如:

    • Yan等人(2018)的表面数据表
    • Steinberg和Yan(2022)的随机表面模型。

    一些混合表面模型将物理光学模型应用于某些表面成分(例如小尺度粗糙度),而对较大尺度使用几何光学模型。这些混合模型的应用包括:

    • Falster等人(2020)和Holzschuch与Pacanowski(2017)的表面粗糙度模型。
    • Belcour和Barla(2017)的薄膜干涉模型。
    • Guillén等人(2020)的悬浮颗粒模型。

    此外,物理光学模型还被用于处理更长距离的表面间效应。例如:

    • Cuypers等人(2012)和Steinberg等人(2022)的研究探索了这些长程效应。

    基于波动光学的散射方法,如Lorenz-Mie理论T矩阵法,也被用于体积散射的计算,例如:

    • Bohren和Huffman(2008)及Mishchenko等人(2002)的理论。
    • Frisvad等人(2007)和Guo等人(2021)的体积散射应用。

    此外,Sadeghi等人(2012)和Shimada与Kawaguchi(2005)提出的复杂值光线追踪技术被应用于渲染自然现象和结构色效应。然而,表面间的长程效应体积散射等问题目前超出了本研究的范围。

    计算电磁学(CEM)的数值计算方法

    数值计算有很多方法:

    • Oskooi等人(2010)提出了基于差分求解麦克斯韦方程组的数值方法有限差分时域法(FDTD)。FDTD已经被用于预测波长尺度结构的外观(如Auzinger等人(2018),Musbach等人(2013)的研究)。但是随着模拟区域增加,开销相当大!
    • 有限元法(FEM)是一种广泛用于求解偏微分方程的数值方法,也可以用于电磁学问题中。它通过对模拟域的三维离散化来解决问题。与FDTD类似,计算量太大,更加不用说用于实时渲染。
    • Gibson(2021)详细介绍了边界元法(BEM)。BEM的主要优势在于,它通过将散射问题转化为物体表面的积分方程,降低了离散化的维度。在FDTD和FEM中,整个三维空间都需要离散化,而BEM只需要对物体的表面进行离散化,这显著降低了计算的维度和复杂性。

    论文选择了BEM。主要原因是其拓展性。有利于复杂表面结构的处理。

    加速BEM有很多方法:

    • Liu和Nishimura(2006),White和Head-Gordon(1994)提出的快速多极子法(FMM)
    • Bleszynski等人(1996)提出的基于三维快速傅里叶变换(3D FFT)的自适应积分法(AIM)
    • Liao等人(2016),Pak等人(1997)提出的稀疏矩阵规范网格法(SMCG)

    论文选择了AIM。原因是AIM适合处理一个轴向尺寸比较小的区域。

    结果

    BRDF值以半球图的形式显示。使用了标准的光谱数据到XYZ到RGB的转换,生成了彩色的BRDF图。

    即随着高度场分辨率的增加,BRDF输出逐渐趋于稳定。在高度场的每波长8个样本的分辨率已经足够产生准确的结果。

    通过与现有波动光学模型的比较,本文的模拟器精度最高,能够处理复杂的光学现象和几何结构,适合高精度需求的场景,适用于多次反射、干涉和复杂表面。然而,计算成本较高,是效率与精度间的折衷。

    • OHS和GHS模型计算简单,适合平滑表面和中等粗糙度表面,但在大入射角和复杂表面上误差较大。GHS相比OHS在大角度下精度有提升。
    • 基尔霍夫模型精度相对较高,但是只能在一阶的范围保持精度。
    • 切平面方法计算高效,适合较为简单的表面几何。复杂的就不行。
    • 本文的精度最牛,适合高精度场景。

    相干区域的比较,随着照明相干度的增加,BRDF的分辨率和细节变得更加丰富。相干度较高时,BRDF中包含更多的高分辨率细节。

    另外,论文还展示了论文中用于加速BRDF计算的光束引导技术(beam steering)。如图14,表面沿一个方向呈镜面反射,而在另一个方向上则表现出回射效应。论文计算了一系列逐渐变化的入射角的BRDF值。如下图所示。

    每个入射方向上的BRDF图像缩减为一条细线段。图15中的对比显示,Tangent Plane无法准确建模表面的二阶反射(即经过多次反射后出射的光线)。但是如果表面光滑,用Tangent Plane还是非常快速准确的。

    此外,论文将模拟器的结果与实际表面的BRDF测量进行了对比,特别是在多重反射效应显著的表面上。如图17所示,上面是实际测量,中间是论文的理论模型,下面是Tangent Plane。

    你可能会问为什么差这么多?论文中使用的是理想化的几何模型,而实验中的表面可能具有一些微小的几何偏差,这可能会影响反射的精确性。一句话概括,就是论文的模拟器可以描述更加高阶的反射了!

    未来工作

    当处理更复杂、结构化表面时,本文的模拟器能更加精确地模拟光在表面上的传播和散射行为。

    未来工作方向当然就是降低计算开销,同时保证精度。

    然后将这个BRDF的近似模型的处理面积扩大。

    同时,该论文的模拟器可以作为一个基准参考。

    References

    A Full-Wave Reference Simulator for Computing Surface Reflectance

    Petr Beckmann and Andre Spizzichino. 1987. The scattering of electromagnetic waves from rough surfaces. Artech House.

    Andrey Krywonos. 2006. Predicting Surface Scatter using a Linear Systems Formulation of Non-Paraxial Scalar Diffraction. Ph. D. Dissertation. University of Central Florida.

    Ling-Qi Yan, Miloš Hašan, Bruce Walter, Steve Marschner, and Ravi Ramamoorthi. 2018. Rendering Specular Microgeometry with Wave Optics. ACM Trans. Graph. 37, 4 (2018).

    Ling-Qi Yan, Miloš Hašan, Steve Marschner, and Ravi Ramamoorthi. 2016. Positionnormal distributions for efficient rendering of specular microstructure. ACM Transactions on Graphics (TOG) 35, 4 (2016), 1–9.

    R. L. Cook and K. E. Torrance. 1982. A Reflectance Model for Computer Graphics. ACM Trans. Graph. 1, 1 (jan 1982). https://doi.org/10.1145/357290.357293

    Michael Oren and Shree K. Nayar. 1994. Generalization of Lambert’s Reflectance Model (SIGGRAPH ’94). https://doi.org/10.1145/192161.192213

  • Games202 作业三 SSR实现

    Games202 作业三 SSR实现

    作业源代码:

    https://github.com/Remyuu/GAMES202-Homeworkgithub.com/Remyuu/GAMES202-Homework

    TODO List

    • 实现对场景直接光照的着色 (考虑阴影)。
    • 实现屏幕空间下光线的求交 (SSR)。
    • 实现对场景间接光照的着色。
    • 实现动态步长的RayMarch。
    • (还没写) Bonus 1:实现 Mipmap 优化的 Screen Space Ray Tracing。
    img

    采样数:32

    写在前面

    这一次作业的基础部分算是目前202所有作业中最简单的了,没有特别复杂的内容。但是Bonus部分不知如何下手,大佬们带带。

    框架的深度缓冲问题

    这一次作业在 macOS 上会遇到比较严重的问题。正方体贴近地面的部分会随着摄像机的距离远近变化表现出异常的裁切锯齿问题。这个现象在 windows 上没有遇到,比较奇怪。

    img

    个人感觉这与深度缓冲区的精度有关,可能是z-fighting导致的,其中两个或更多重叠的表面竞争同一像素的问题。对于这种问题一般下面几种解决方案:

    • 调整近平面和远平面:不要让近平面离摄像机太近,远平面不要太远。
    • 提高深度缓冲区的精度:采用32位或者更高的精度。
    • 多通渲染(Multi-Pass Rendering):对不同距离范围的物体采用不同的渲染方案。

    最简单的解决办法就是修改近平面的大小,定位到框架的 engine.js 的25行。

    // engine.js
    // const camera = new THREE.PerspectiveCamera(75, gl.canvas.clientWidth / gl.canvas.clientHeight, 0.0001, 1e5);
    const camera = new THREE.PerspectiveCamera(75, gl.canvas.clientWidth / gl.canvas.clientHeight, 5e-2, 1e2);

    这样就可以得到相当锐利的边界了。

    img

    增加「暂停渲染」功能

    这个部分是可选的。为了减轻电脑的压力,简单写一个暂停渲染的按钮。

    // engine.js
    let settings = {
        'Render Switch': true
    };
    function createGUI() {
        ...
        // Add the boolean switch here
        gui.add(settings, 'Render Switch');
        ...
    }
    function mainLoop(now) {
        if(settings['Render Switch']){
            cameraControls.update();
            renderer.render();
        }
        requestAnimationFrame(mainLoop);
    }
    requestAnimationFrame(mainLoop);
    img

    image-20231117191114477

    1. 实现直接光照

    实现「shaders/ssrShader/ssrFragment.glsl」中的 EvalDiffuse(vec3 wi, vec3 wo, vec2 uv) 和 EvalDirectionalLight(vec2 uv) 。

    // ssrFragment.glsl
    vec3 EvalDiffuse(vec3 wi, vec3 wo, vec2 screenUV) {
      vec3 reflectivity = GetGBufferDiffuse(screenUV);
      vec3 normal = GetGBufferNormalWorld(screenUV);
      float cosi = max(0., dot(normal, wi));
      vec3 f_r = reflectivity * cosi;
      return f_r;
    }
    vec3 EvalDirectionalLight(vec2 screenUV) {
      vec3 Li = uLightRadiance * GetGBufferuShadow(screenUV);
      return Li;
    }

    第一段代码其实就是实现了Lambertian reflection model,对应渲染方程里面的 $f_r \cdot \text{cos}(\theta_i)$ 。

    我这里是除了 $\pi$ ,但是按照作业框架给出的结果,应该是没有除的,这里随便吧。

    第二部分负责直接光照(包括阴影遮挡),相对渲染方程的 $L_i \cdot V$ 。

    Lo(p,ωo)=Le(p,ωo)+∫ΩLi(p,ωi)⋅fr(p,ωi,ωo)⋅V(p,ωi)⋅cos⁡(θi)dωi

    这里顺便复习一下Lambertian反射模型。我们注意到 EvalDiffuse 传入了wi, wo 两个方向,但我们只是用了入射光的方向 wi 。这是因为Lambertian模型与观察的方向没有关系,只和表面法线与入射光线的余弦值有关。

    最后在 main() 中设置结果。

    // ssrFragment.glsl
    void main() {
      float s = InitRand(gl_FragCoord.xy);
      vec3 L = vec3(0.0);
      vec3 wi = normalize(uLightDir);
      vec3 wo = normalize(uCameraPos - vPosWorld.xyz);
      vec2 worldPos = GetScreenCoordinate(vPosWorld.xyz);
      L = EvalDiffuse(wi, wo, worldPos) * 
          EvalDirectionalLight(worldPos);
      vec3 color = pow(clamp(L, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2));
      gl_FragColor = vec4(vec3(color.rgb), 1.0);
    }
    img

    2. 镜面SSR – 实现RayMarch

    实现 RayMarch(ori, dir, out hitPos) 函数,求出光线与物体的交点,返回光线是否与物体相交。参数 ori 和 dir 为世界坐标系中的值,分别代表光线的起点和方向,其中方向向量为单位向量。 更多资料可以参考EA在SIG15的课程报告

    作业框架的「cube1」本身就包含了地面,所以这玩意最终得到的SSR效果就不太美观。这里的“美观”是指论文中结果图的清晰度或游戏中积水反射效果的精致度。

    准确地说,在本文中我们实现的是最基础的「镜面SSR」,即Basic mirror-only SSR。

    img

    实现「镜面SSR」最简单的方法就是使用Linear Raymarch,通过一个个小步进逐步确定当前位置与gBuffer的深度位置的遮挡关系。

    img
    // ssrFragment.glsl
    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      const int totalStepTimes = 60;
      const float threshold = 0.0001;
      float step = 0.05;
      vec3 stepDir = normalize(dir) * step;
      vec3 curPos = ori;
      for(int i = 0; i < totalStepTimes; i++) {
        vec2 screenUV = GetScreenCoordinate(curPos);
        float rayDepth = GetDepth(curPos);
        float gBufferDepth = GetGBufferDepth(screenUV);
        // Check if the ray has hit an object
        if(rayDepth > gBufferDepth + threshold){
          hitPos = curPos;
          return true;
        }
        curPos += stepDir;
      }
      return false;
    }

    最后微调步进 step 的大小。最终我取到0.05。如果步进取的太大,反射的画面会“断层”。如果步进取得太小且步进次数又不够,那么可能导致本来应该反射的地方因为步进距离不够导致计算的终止。下图的最大步进数为150。

    img
    // ssrFragment.glsl
    vec3 EvalSSR(vec3 wi, vec3 wo, vec2 screenUV) {
      vec3 worldNormal = GetGBufferNormalWorld(screenUV);
      vec3 relfectDir = normalize(reflect(-wo, worldNormal));
      vec3 hitPos;
      if(RayMarch(vPosWorld.xyz, relfectDir, hitPos)){
        vec2 INV_screenUV = GetScreenCoordinate(hitPos);
        return GetGBufferDiffuse(INV_screenUV);
      }
      else{
        return vec3(0.); 
      }
    }

    写一个调用 RayMarch 的函数包装起来,方便在 main() 中使用。

    // ssrFragment.glsl
    void main() {
      float s = InitRand(gl_FragCoord.xy);
      vec3 L = vec3(0.0);
      vec3 wi = normalize(uLightDir);
      vec3 wo = normalize(uCameraPos - vPosWorld.xyz);
      vec2 screenUV = GetScreenCoordinate(vPosWorld.xyz);
      // Basic mirror-only SSR
      float reflectivity = 0.2;
      L = EvalDiffuse(wi, wo, screenUV) * EvalDirectionalLight(screenUV);
      L+= EvalSSR(wi, wo, screenUV) * reflectivity;
      vec3 color = pow(clamp(L, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2));
      gl_FragColor = vec4(vec3(color.rgb), 1.0);
    }

    如果单纯想测试SSR的效果,请在 main() 中自行调整。

    img
    img

    在2013年”Killzone Shadow Fall”发布之前,SSR技术仍然受到较大的限制,因为在实际开发中,我们通常需要模拟Glossy的物体,由于当时性能的限制,SSR技没有大规模采用。随着“Killzone Shadow Fall”的发布,标志着实时反射技术取得了重大的进展。得益于PS4的特殊硬件,使得实时渲染高质量Glossy和semi-reflective的物体成为可能。

    img

    在接下来的几年中,SSR技术发展迅速,尤其是与PBR等技术的结合。

    从Nvidia的RTX显卡开始,实时光线追踪的兴起逐渐开始在某些场景替代了SSR。但是在大多数开发场景中,传统的SSR仍然占有相当大的戏份。

    未来的发展趋势依然是传统SSR技术与光线追踪技术的混合。

    3. 间接光照

    照着伪代码写。也就是用蒙特卡洛方法求解渲染方程。与之前不同的是,这次的样本都在屏幕空间中。在采样的过程中可以使用框架提供的 SampleHemisphereUniform(inout s, ou pdf) 和 SampleHemisphereCos(inout s, out pdf) ,其中,这两个函数返回局部坐标,传入参数分别是随机数 s 和采样概率 pdf 。

    这个部分需要理解下图伪代码,然后照着完成 EvalIndirectionLight() 就好了。

    img

    首先需要知道,我们本次采样仍然是基于屏幕空间的。因此不在屏幕(gBuffer)中的内容我们就当作不存在。理解为只有一层正好面向摄像机的外壳。

    间接光照涉及上半球方向的随机采样和对应pdf计算。使用 InitRand(screenUV) 得到随机数就可以了,然后二选一, SampleHemisphereUniform(inout float s, out float pdf) 或 SampleHemisphereCos(inout float s, out float pdf) ,更新随机数同时得到对应 pdf 和单位半球上的局部坐标系的位置 dir 。

    将当前Shading Point的法线坐标传入函数 LocalBasis(n, out b1, out b2) ,随后返回 b1, b2 ,其中 n, b1, b2 这三个单位向量两两正交。通过这三个向量所构成的局部坐标系,将 dir 转换到世界坐标中。关于这个 LocalBasis() 的原理,我写在最后了。

    By the way, the matrix constructed with the vectors n (normal), b1, and b2 is commonly referred to as the TBN matrix in computer graphics.

    // ssrFragment.glsl
    #define SAMPLE_NUM 5
    vec3 EvalIndirectionLight(vec3 wi, vec3 wo, vec2 screenUV){
      vec3 L_ind = vec3(0.0);
      float s = InitRand(screenUV);
      vec3 normal = GetGBufferNormalWorld(screenUV);
      vec3 b1, b2;
      LocalBasis(normal, b1, b2);
      for(int i = 0; i < SAMPLE_NUM; i++){
        float pdf;
        vec3 direction = SampleHemisphereUniform(s, pdf);
        vec3 worldDir = normalize(mat3(b1, b2, normal) * direction);
        vec3 position_1;
        if(RayMarch(vPosWorld.xyz, worldDir, position_1)){ // 采样光线碰到了 position_1
          vec2 hitScreenUV = GetScreenCoordinate(position_1);
          vec3 bsdf_d = EvalDiffuse(worldDir, wo, screenUV); // 直接光照
          vec3 bsdf_i = EvalDiffuse(wi, worldDir, hitScreenUV); // 间接光照
          L_ind += bsdf_d / pdf * bsdf_i * EvalDirectionalLight(hitScreenUV);
        }
      }
      L_ind /= float(SAMPLE_NUM);
      return L_ind;
    }
    // ssrFragment.glsl
    // Main entry point for the shader
    void main() {
      vec3 wi = normalize(uLightDir);
      vec3 wo = normalize(uCameraPos - vPosWorld.xyz);
      vec2 screenUV = GetScreenCoordinate(vPosWorld.xyz);
      // Basic mirror-only SSR coefficient
      float ssrCoeff = 0.0;
      // Indirection Light coefficient
      float indCoeff = 0.3;
      // Direction Light
      vec3 L_d = EvalDiffuse(wi, wo, screenUV) * EvalDirectionalLight(screenUV);
      // SSR Light
      vec3 L_ssr = EvalSSR(wi, wo, screenUV) * ssrCoeff;
      // Indirection Light
      vec3 L_i = EvalIndirectionLight(wi, wo, screenUV) * IndCorff;
      vec3 result = L_d + L_ssr + L_i;
      vec3 color = pow(clamp(result, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2));
      gl_FragColor = vec4(vec3(color.rgb), 1.0);
    }

    只显示间接光照。采样数=5。

    img

    直接光照+间接光照。采样数=5。

    img

    写这个部分真是头痛啊,即使 SAMPLE_NUM 设置为1,我的电脑都汗流浃背了。Live Server一开,直接打字都有延迟了,受不了。M1pro就这么点性能了吗。而且最让我受不了的是,Safari浏览器卡就算了,为什么整个系统连带一起卡顿呢?这就是你macOS的User First策略吗?我不理解。迫不得已,我只能掏出我的游戏电脑通过局域网测试项目了(悲)。只是没想到RTX3070运行起来也有点大汗淋漓,看来我写的算法就是一坨狗屎,我的人生也是一坨狗屎啊

    4. RayMarch改进

    目前的 RayMarch() 其实是有问题的,会出现漏光的现象。

    img

    在采样数为5的情况下只有46.2帧左右。我的设备是M1pro 16GB。

    img

    这里重点说说为什么会产生漏光的现象,看下面这个图示。我们gBuffer里只有蓝色部分的深度信息,即使我们上面的算法已经判断了当前 curPos 已经比gBuffer的深度要深了,这也不能确保这个 curPos 是否就是碰撞点。因此上面的算法并没有考虑图中的情况,进而导致漏光的现象。

    img

    为了解决漏光问题,我们引入一个阈值 thresholds 解决这个问题(没错,又是一个近似),如果 curPos 和当前gBuffer记录的深度的差大于某个阈值,那就进入下图的情况。这个时候屏幕空间的信息没办法正确提供反射的信息,因此这个Shading Point的SSR结果就是 vec3(0) 。就是这么的简单粗暴!

    img

    代码的思路跟前面的差不多,每一次步进时,判断下一步位置的深度与gBuffer的深度的关系,如果下一步的位置在gBuffer的前面(nextDepth<gDepth),则可以步进。如果下一步的深度没有gBuffer的深,就判断一下深度相差多少,有没有给定的阈值大。如果比阈值大,那么就直接返回 false ,否则,这个时候就可以执行SSR了。先让当前位置步进一个step,返回给 hitPos ,然后返回真。

    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      const float EPS = 1e-2;
      const int totalStepTimes = 60;
      const float threshold = 0.1;
      float step = 0.05;
      vec3 stepDir = normalize(dir) * step;
      vec3 curPos = ori + stepDir;
      vec3 nextPos = curPos + stepDir;
      for(int i = 0; i < totalStepTimes; i++) {
        if(GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos))){
          curPos = nextPos;
          nextPos += stepDir;
        }else if(GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS > threshold){
          return false;
        }else{
          curPos += stepDir;
          vec2 screenUV = GetScreenCoordinate(curPos);
          float rayDepth = GetDepth(curPos);
          float gBufferDepth = GetGBufferDepth(screenUV);
          if(rayDepth > gBufferDepth + threshold){
            hitPos = curPos;
            return true;
          }
        }
      }
      return false;
    }

    但是帧率降到了42.6左右,但是却显著的改善了画面!至少是没有显著的漏光现象了。

    img

    但是画面还有一些瑕疵,就是在边缘的时候会有发毛的反射图样,也就是说漏光问题依旧没有解决,如下图所示:

    img

    上面的方法确实是存在问题的,在与阈值做对比的时候我们错误的使用了 curPos 来比较(即下图的Step n点),导致了代码也能进入第三个分支,返回那个错误 curPos 的 hitPos 。

    img

    再退一步,我们没有办法保证最终计算的 curPos 正好落在物体边缘与摄像机原点的线上。说白了,就是下图中蓝色的线是相当离散的。我们想要得到“恰好”在边界的 curPos ,进而将「Step n」到「“恰好”的curPos」这段距离的瑕疵(即上面的毛刺错误)处理掉,但是显然因为各种精度的原因,我们没办法获得。下图中,绿色的线代表一次step。

    img

    即使我们调整 threshold/step 的比值,使其接近1,我们也难以根除这个问题,最多只能起到缓解作用,就像下图所示。

    img

    因此我们需要再次改进刚刚的「防漏光」方法。

    换一句话说,就是让改进的思想也非常简单,既然我没办法获得“恰好”的 curPos 点,那我就把它猜出来。具体来说就是,直接来一个线性插值。插值之前再做一个近似,也就是将视线看作相互平行的,接着就像下图一样做一个相似三角形,猜出我们想要的 curPos ,然后把它当作 hitPos 。

    img

    hitPos=curPos+s1s1+s2

    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      bool result = false;
      const float EPS = 1e-3;
      const int totalStepTimes = 60;
      const float threshold = 0.1;
      float step = 0.05;
      vec3 stepDir = normalize(dir) * step;
      vec3 curPos = ori + stepDir;
      vec3 nextPos = curPos + stepDir;
      for(int i = 0; i < totalStepTimes; i++) {
        if(GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos))){
          curPos = nextPos;
          nextPos += stepDir;
          continue;
        }
        float s1 = GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS;
        float s2 = GetDepth(nextPos) - GetGBufferDepth(GetScreenCoordinate(nextPos)) + EPS;
        if(s1 < threshold && s2 < threshold){
          hitPos = curPos + stepDir * s1 / (s1 + s2);
          result = true;
        }
        break;
      }
      return result;
    }

    效果相当可以,没有鬼影和边界的瑕疵了。并且帧率也跟最开始的算法相似,在平均49.2左右。

    img

    接下来重点优化一下性能,具体而言就是:

    • 加入自适应step
    • 屏幕外忽略的判断

    屏幕外忽略的判断 非常简单。如果 curPos 的uvScreen不在0到1之间,那么直接放弃当前步进。

    详细说说自适应step。也就是在for的开头加上两行。实测帧率会稍微提高2-3帧左右。

    vec2 uvScreen = GetScreenCoordinate(curPos);
    if(any(bvec4(lessThan(uvScreen, vec2(0.0)), greaterThan(uvScreen, vec2(1.0))))) break;

    自适应step 也不难。首先为初始步进 step 设置一个较大的值,如果监测到步进之后的 curPos 不在屏幕内 或着 深度值比gBuffer的深 或者 不满足“s1 < threshold && s2 < threshold” ,那么就让step步进减半,以确保精度。

    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      const float EPS = 1e-2;
      const int totalStepTimes = 20;
      const float threshold = 0.1;
      bool result = false, firstIn = false;
      float step = 0.8;
      vec3 curPos = ori;
      vec3 nextPos;
      for(int i = 0; i < totalStepTimes; i++) {
        nextPos = curPos+dir*step;
        vec2 uvScreen = GetScreenCoordinate(curPos);
        if(any(bvec4(lessThan(uvScreen, vec2(0.0)), greaterThan(uvScreen, vec2(1.0))))) break;
        if(GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos))){
          curPos += dir * step;
          if(firstIn) step *= 0.5;
          continue;
        }
        firstIn = true;
        if(step < EPS){
          float s1 = GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS;
          float s2 = GetDepth(nextPos) - GetGBufferDepth(GetScreenCoordinate(nextPos)) + EPS;
          if(s1 < threshold && s2 < threshold){
            hitPos = curPos + 2.0 * dir * step * s1 / (s1 + s2);
            result = true;
          }
          break;
        }
        if(firstIn) step *= 0.5;
      }
      return result;
    }

    改进了之后,帧率一下子来到了100帧,几乎翻倍了。

    img

    最后整理一下代码。

    #define EPS 5e-2
    #define TOTAL_STEP_TIMES 20
    #define THRESHOLD 0.1
    #define INIT_STEP 0.8
    bool outScreen(vec3 curPos){
      vec2 uvScreen = GetScreenCoordinate(curPos);
      return any(bvec4(lessThan(uvScreen, vec2(0.0)), greaterThan(uvScreen, vec2(1.0))));
    }
    bool testDepth(vec3 nextPos){
      return GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos));
    }
    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      float step = INIT_STEP;
      bool result = false, firstIn = false;
      vec3 nextPos, curPos = ori;
      for(int i = 0; i < TOTAL_STEP_TIMES; i++) {
        nextPos = curPos + dir * step;
        if(outScreen(curPos)) break;
        if(testDepth(nextPos)){ // 可以进步
          curPos += dir * step;
          continue;
        }else{ // 过于进步了
          firstIn = true;
          if(step < EPS){
            float s1 = GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS;
            float s2 = GetDepth(nextPos) - GetGBufferDepth(GetScreenCoordinate(nextPos)) + EPS;
            if(s1 < THRESHOLD && s2 < THRESHOLD){
              hitPos = curPos + 2.0 * dir * step * s1 / (s1 + s2);
              result = true;
            }
            break;
          }
          if(firstIn) step *= 0.5;
        }
      }
      return result;
    }

    切换到洞穴场景,采样率设置为32,帧率就只有可怜的4帧了。

    img

    而且不过次级光源质量非常不错了。

    img

    然而这个算法运用在反射上就会导致新的问题了。尤其是下边这张图,走样非常严重。

    img
    img

    5. Mipmap实现

    Hierarchical-Z map based occlusion culling

    6. LocalBasis构建TBN原理

    一般来说,构建法切副(法线、切线、副切线向量)是通过叉积来实现,实现方法非常简单,先任选一个跟法线向量不平行的辅助向量,两者做一次叉积得到第一个切线向量,然后这个切线向量和法线向量又做一次叉积,得到副切线向量。具体代码是这样写:

    void CalculateTBN(const vec3 &normal, vec3 &tangent, vec3 &bitangent) {
        vec3 helperVec;
        if (abs(normal.x) < abs(normal.y))
            helperVec = vec3(1.0, 0.0, 0.0);
        else
            helperVec = vec3(0.0, 1.0, 0.0);
        tangent = normalize(cross(helperVec, normal));
        bitangent = normalize(cross(normal, tangent));
    }

    但是作业框架中的代码避免了使用叉积,非常巧妙。简单的说,就是确保向量间的点积都是0。

    • $b1⋅n=0$
    • $b2⋅n=0$
    • $b1⋅b2=0$
    void LocalBasis(vec3 n, out vec3 b1, out vec3 b2) {
      float sign_ = sign(n.z);
      if (n.z == 0.0) {
        sign_ = 1.0;
      }
      float a = -1.0 / (sign_ + n.z);
      float b = n.x * n.y * a;
      b1 = vec3(1.0 + sign_ * n.x * n.x * a, sign_ * b, -sign_ * n.x);
      b2 = vec3(b, sign_ + n.y * n.y * a, -n.y);
    }

    这个算法是一个比较启发式的,引入了一个符号函数,相当有逼格。还考虑了除0的情况,格局也是拉满。但是下面这四行,应该是作者不知道在哪一天写公式的时候随便乱拆给他拆出来的而已,这里我还原一下当时作者的拆解步骤。也就是倒推的过程。

    img

    另外说一下,代码中的符号函数可以在最后一步乘上。

    实际上,这样的公式我可以造出一百个,我也不知道这些个公式之间有啥区别,知道的小伙伴请告诉我QAQ。如果硬要说,那么就可以这样解释:

    传统的基于叉乘的方法可能会产生数值不稳定,因为叉乘结果在这种情况下接近于零向量。 本文采用的方法是一种启发式方法,它通过一系列精心设计的步骤来构造正交基。这种方法特别注意了数值稳定性,使其在处理接近极端方向的法线向量时仍然有效和稳定。

    感谢 @我是龙套小果丁 的指出,上面这个方法是有讲究的。作业框架中提供的算法是Tom Duff等人在17年通过改进Frisvad’s方法得到的,具体可以看下面两篇paper。

    https://graphics.pixar.com/library/OrthonormalB/paper.pdfgraphics.pixar.com/library/OrthonormalB/paper.pdf

    https://backend.orbit.dtu.dk/ws/portalfiles/portal/126824972/onb_frisvad_jgt2012_v2.pdfbackend.orbit.dtu.dk/ws/portalfiles/portal/126824972/onb_frisvad_jgt2012_v2.pdf

    References

    1. Games 202
    2. LearnOpenGL – Normal Mapping
  • Games202 作业二 PRT实现

    Games202 作业二 PRT实现

    img
    img
    img

    因为我也是菜鸡,所以不能确保所有都是正确的,希望大佬指正。

    知乎的公式有点丑陋,可以去:GitHub

    项目源代码:

    https://github.com/Remyuu/GAMES202-Homeworkgithub.com/Remyuu/GAMES202-Homework

    预计算球谐系数

    利用框架nori预先计算球谐函数系数。

    环境光照:计算cubemap每个像素的球谐系数

    ProjEnv::PrecomputeCubemapSH(images, width, height, channel); 使用黎曼积分的方法计算环境光球谐函数的系数。

    完整代码

    // TODO: here you need to compute light sh of each face of cubemap of each pixel
    // TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数
    Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x];
    int index = (y * width + x) * channel;
    Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
                      images[i][index + 2]);
    // 描述在球面坐标上当前的角度
    double theta = acos(dir.z());
    double phi = atan2(dir.y(), dir.x());
    // 遍历球谐函数的各个基函数
    for (int l = 0; l <= SHOrder; l++){
        for (int m = -l; m <= l; m++){
            float sh = sh::EvalSH(l, m, phi, theta);
            float delta = CalcArea((float)x, (float)y, width, height);
            SHCoeffiecents[l*(l+1)+m] += Le * sh * delta;
        }
    }
    C#

    分析

    球谐系数是球谐函数在一个球面上的投影,可以用于表示函数在球面上的分布。由于我们有RGB值有三个通道,因此我们会的球谐系数会存储为一个三维的向量。需要完善的部分:

    /// prt.cpp - PrecomputeCubemapSH()
    // TODO: here you need to compute light sh of each face of cubemap of each pixel
    // TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数
    Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x];
    int index = (y * width + x) * channel;
    Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
                      images[i][index + 2]);
    C#

    首先从六个 cubemap ( images 数组)的每个像素采样方向(一个三维向量,表示从中心到像素的方向),将方向转化为球面坐标( theta 和 phi )。

    接着将各个球面坐标传入 sh::EvalSH() 分别计算每个球谐函数(基函数)的实值 sh 。同时计算每个 cubemap 中每个像素所占据球面区域的比重 delta 。

    最后累加球谐系数,代码中我们可以对 cubemap 伤的所有像素进行累加,近似在原始计算球谐函数积分的操作。

    $$
    Ylm=∫ϕ=02π∫θ=0πf(θ,ϕ)Ylm(θ,ϕ)sin⁡(θ)dθdϕ
    $$

    其中:

    • θ 是天顶角,范围从 0 到 π; ϕ 是方位角,范围从 0 到 2pi 。
    • f(θ,ϕ) 是球面上某点的函数值。
    • Ylm 是球谐函数,它由相应的勒让德多项式 Plm 和一些三角函数组成。
    • l 是球谐函数的阶数; m 是球谐函数的序数,范围从 −l 到 l 。

    为了更加具体的让读者理解,这里写出代码中球谐函数离散形式的估计,即黎曼积分的方法来计算。

    $$
    Ylm=∑i=1Nf(θi,ϕi)Ylm(θi,ϕi)Δωi
    $$

    其中:

    • f(θi,ϕi) 是球面上某点的函数值。
    • Ylm(θi,ϕi) 是球谐函数在该点的值。
    • Δωi 是该点在球面上的微小区域或权重。
    • N 是所有离散点的总数。

    代码细节

    • 从 cubemap 获取RGB光照信息
    Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
                      images[i][index + 2]);
    C#

    channel 的值是3,对应于RGB三个通道。因此,index 就指向了某一像素的红色通道的位置,index + 1 指向绿色通道的位置,index + 2 指向蓝色通道的位置。

    • 将方向向量转换为球面坐标
    double theta = acos(dir.z());
    double phi = atan2(dir.y(), dir.x());
    C#

    theta 是正z轴到 dir 方向的夹角,而 phi 表示从正x轴到 dir 在xz平面上的投影的夹角。

    • 遍历球谐函数的各个基函数
    for (int l = 0; l <= SHOrder; l++){
        for (int m = -l; m <= l; m++){
            float sh = sh::EvalSH(l, m, phi, theta);
            float delta = CalcArea((float)x, (float)y, width, height);
            SHCoeffiecents[l*(l+1)+m] += Le * sh * delta;
        }
    }
    C#

    无阴影的漫反射项

    scene->getIntegrator()->preprocess(scene); 计算 Diffuse Unshadowed 的情况。化简渲染方程,将上节的球谐函数代入进一步计算为BRDF的球谐投影的系数。其中关键的函数是ProjectFunction。我们要为这个函数编写一个lambda表达式,用于计算传输函数项 。

    分析

    对于漫反射传输项,可以分为三种情况考虑:有阴影的无阴影的相互反射的

    首先考虑最简单的没有阴影的情况。我们有渲染方程

    其中,

    • 是入射辐射度。
    • 是几何函数,表面的微观特性和入射光的方向有关。
    • 是入射光方向。

    对于表面处处相等的漫反射表面,我们可以简化得到 Unshadowed 光照方程

    其中:

    • 是点 的漫反射出射辐射度。
    • 是表面法线。

    入射辐射度 和传输函数项 相互独立,因为前者代表场景中光源的贡献,后者表示表面如何响应入射的光线。因此将这两个部分独立处理。

    具体到运用球谐函数近似是,我们分别对这两项展开。前者的输入是光的入射方向,后者输入的是反射(或者出射方向),并且展开是两个系列的数组,因此我们使用名为查找表(Look-Up Table,简称LUT)的数据结构。

    auto shCoeff = sh::ProjectFunction(SHOrder, shFunc, m_SampleCount);
    C#

    其中,最重要的是上面这个函数 ProjectFunction 。我们要为这个函数编写一个Lambda表达式(shFunc)作为传参,表达式用于计算传输函数项 。

    ProjectFunction 函数传参:

    • 球谐阶数
    • 需要投影在基函数上的函数(我们需要编写的)
    • 采样数

    该函数会取Lambda函数返回的结果投影在基函数上得到系数,最后把各个样本系数累加并乘以权重,最后得出该顶点的最终系数。

    完整代码

    计算几何项,即传输函数项 。

    // prt.cpp
    ...
    double H = wi.normalized().dot(n.normalized()) / M_PI;
    if (m_Type == Type::Unshadowed){
        // TODO: here you need to calculate unshadowed transport term of a given direction
        // TODO: 此处你需要计算给定方向下的unshadowed传输项球谐函数值
        return (H > 0.0) ? H : 0.0;
    }
    C#

    总之最后的积分结果要记得除以 ,再传给 m_TransportSHCoeffs 。

    有阴影的漫反射项

    scene->getIntegrator()->preprocess(scene); 计算 Diffuse Shadowed 的情况。这一项多了一个可见项 。

    分析

    Visibility项()是一个非1即0的值,利用 bool rayIntersect(const Ray3f &ray) 函数,从顶点位置到采样方向反射一条射线,若击中物体,则认为被遮挡,有阴影,返回0;若射线未击中物体,则仍然返回 即可。

    完整代码

    // prt.cpp
    ...
    double H = wi.normalized().dot(n.normalized()) / M_PI;
    ...
    else{
        // TODO: here you need to calculate shadowed transport term of a given direction
        // TODO: 此处你需要计算给定方向下的shadowed传输项球谐函数值
        if (H > 0.0 && !scene->rayIntersect(Ray3f(v, wi.normalized())))
            return H;
        return 0.0;
    }
    C#

    总之最后的积分结果要记得除以 ,再传给 m_TransportSHCoeffs 。

    导出计算结果

    nori框架会生成的两个预计算结果的文件。

    添加运行参数:

    ./scenes/prt.xml

    在 prt.xml 中,需要做以下修改,就可以选择渲染的环境光cubemap。另外,模型、相机参数等也可自行修改。

    // prt.xml
    
    <!-- Render the visible surface normals -->
    <integrator type="prt">
        <string name="type" value="unshadowed" />
        <integer name="bounce" value="1" />
        <integer name="PRTSampleCount" value="100" />
    <!--        <string name="cubemap" value="cubemap/GraceCathedral" />-->
    <!--        <string name="cubemap" value="cubemap/Indoor" />-->
    <!--        <string name="cubemap" value="cubemap/Skybox" />-->
        <string name="cubemap" value="cubemap/CornellBox" />
    
    </integrator>
    C#

    其中,标签可选值:

    • type:unshadowed、shadowed、 interreflection
    • bounce:interreflection类型下的光线弹射次数(目前尚未实现)
    • PRTSampleCount:传输项每个顶点的采样数
    • cubemap:cubemap/GraceCathedral、cubemap/Indoor、cubemap/Skybox、cubemap/CornellBox
    img

    上图分别是GraceCathedral、Indoor、Skybox和CornellBox的 unshadowed 渲染结果,采样数是1。

    使用球谐系数着色

    将nori生成的文件手动拖到实时渲染框架中,并且对实时框架做一些改动。

    上一章计算完成后,将对应cubemap路径中的 light.txt 和 transport.txt 拷贝到实时渲染框架的cubemap文件夹中。

    预计算数据解析

    取消 engine.js 中88-114行的注释,这一段代码用于解析刚才添加进来的txt文件。

    // engine.js
    // file parsing
    ... // 把这块代码取消注释
    C#

    导入模型/创建并使用PRT材质Shader

    在materials文件夹下建立文件 PRTMaterial.js 。

    //PRTMaterial.js
    
    class PRTMaterial extends Material {
        constructor(vertexShader, fragmentShader) {
            super({
                'uPrecomputeL[0]': { type: 'precomputeL', value: null},
                'uPrecomputeL[1]': { type: 'precomputeL', value: null},
                'uPrecomputeL[2]': { type: 'precomputeL', value: null},
            }, 
            ['aPrecomputeLT'], 
            vertexShader, fragmentShader, null);
        }
    }
    
    async function buildPRTMaterial(vertexPath, fragmentPath) {
        let vertexShader = await getShaderString(vertexPath);
        let fragmentShader = await getShaderString(fragmentPath);
    
        return new PRTMaterial(vertexShader, fragmentShader);
    }
    C#

    然后在 index.html 里引入。

    // index.html
    <script src="src/materials/Material.js" defer></script>
    <script src="src/materials/ShadowMaterial.js" defer></script>
    <script src="src/materials/PhongMaterial.js" defer></script>
    <!-- Edit Start --><script src="src/materials/PRTMaterial.js" defer></script><!-- Edit End -->
    <script src="src/materials/SkyBoxMaterial.js" defer></script>
    C#

    在 loadOBJ.js 加载新的材质。

    // loadOBJ.js
    
    switch (objMaterial) {
        case 'PhongMaterial':
            material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
            shadowMaterial = buildShadowMaterial(light, Translation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");
            break;
        // TODO: Add your PRTmaterial here
        //Edit Start
        case 'PRTMaterial':
            material = buildPRTMaterial("./src/shaders/prtShader/prtVertex.glsl", "./src/shaders/prtShader/prtFragment.glsl");
            break;
        //Edit End
        // ...
    }
    C#

    给场景添加mary模型,设置位置与大小,并且使用刚建立的材质。

    //engine.js
    
    // Add shapes
    ...
    // Edit Start
    let maryTransform = setTransform(0, -35, 0, 20, 20, 20);
    // Edit End
    ...
    // TODO: load model - Add your Material here
    ...
    // Edit Start
    loadOBJ(renderer, 'assets/mary/', 'mary', 'PRTMaterial', maryTransform);
    // Edit End
    C#

    计算着色

    将预计算数据载入GPU中。

    在渲染循环的camera pass中给材质设置precomputeL实时的值,也就是传递预先计算的数据给shader。下面代码是每一帧中每一趟camera pass中每一个网格mesh的每一个uniforms的遍历。实时渲染框架已经解析了预计算的数据并且存储到了uniforms中。precomputeL是一个 9×3 的矩阵,代表这里分别有RGB三个通道的前三阶(9个)球谐函数(实际上我们会说这是一个 3×3 的矩阵,但是我们写代码直接写成一个长度为9的数组)。为了方便使用,通过 tool.js 的函数将 precomputeL 转换为 3×9 的矩阵。

    通过 uniformMatrix3fv 函数,我们可以将材质里存储的信息上传到GPU上。这个函数接受三个参数,具体请查阅 WebGL文档 – uniformMatrix 。其中第一个参数的作用是在我们自己创建的 PRTMaterial 中,uniforms 包含了 uPrecomputeL[0] , uPrecomputeL[1] 和 uPrecomputeL[2] 。在GPU内的工作不需要我们关注,我们只需要有在CPU上的 uniform ,就可以通过API自动访问到GPU上对应的内容。换句话说,当获取一个 uniform 或属性的位置,实际上得到的是一个在CPU端的引用,但在底层,这个引用会映射到GPU上的一个具体位置。而链接 uniform 的步骤在 Shader.js 的 this.program = this.addShaderLocations() 中完成(看看代码就能懂了,只是比较绕,在我的HW1文章中也有分析过), shader.program 有三个属性分别是:glShaderProgram, uniforms, 和 attribs。而具体声明的位置则是在 XXXshader.glsl 中,在下一步中我们就会完成它。

    总结一下,下面这段代码主要工作就是为片段着色器提供预先处理过的数据。

    // WebGLRenderer.js
    
    if (k == 'uMoveWithCamera') { // The rotation of the skybox
        gl.uniformMatrix4fv(
            this.meshes[i].shader.program.uniforms[k],
            false,
            cameraModelMatrix);
    }
    
    // Bonus - Fast Spherical Harmonic Rotation
    //let precomputeL_RGBMat3 = getRotationPrecomputeL(precomputeL[guiParams.envmapId], cameraModelMatrix);
    
    // Edit Start
    let Mat3Value = getMat3ValueFromRGB(precomputeL[guiParams.envmapId]);
    
    if (/^uPrecomputeL\[\d\]$$/.test(k)) {
        let index = parseInt(k.split('[')[1].split(']')[0]);
        if (index >= 0 && index < 3) {
            gl.uniformMatrix3fv(
                this.meshes[i].shader.program.uniforms[k],
                false,
                Mat3Value[index]
            );
        }
    }
    // Edit End
    C#

    也可以将 Mat3Value 的计算放在i循环的外面,减少计算次数。

    编写顶点着色器

    明白了上面代码的作用之后,接下来的任务就非常明了了。上一步我们将每一个球谐系数都传到了 GPU 的 uPrecomputeL[] 中,接下来在GPU上编程计算球谐系数和传输矩阵的点乘,也就是下图 light_coefficient * transport_matrix。

    实时渲染框架中已经完成了Light_Transport到对应方向的矩阵的化简,我们只需要分别对三个颜色通道的长度为9的向量做点乘就行了。值得一提的是,PrecomputeL 和 PrecomputeLT 既可以传给顶点着色器也可以传给片段着色器,若传给顶点着色器,就只需要在片段着色器中差值得到颜色,速度更快,但是真实性就稍差一些。怎么计算取决于不同的需求。

    img
    //prtVertex.glsl
    
    attribute vec3 aVertexPosition;
    attribute vec3 aNormalPosition;
    attribute mat3 aPrecomputeLT;  // Precomputed Light Transfer matrix for the vertex
    
    uniform mat4 uModelMatrix;
    uniform mat4 uViewMatrix;
    uniform mat4 uProjectionMatrix;
    uniform mat3 uPrecomputeL[3];  // Precomputed Lighting matrices
    varying highp vec3 vNormal;
    
    varying highp vec3 vColor;     // Outgoing color after the dot product calculations
    
    float L_dot_LT(const mat3 PrecomputeL, const mat3 PrecomputeLT) {
      return dot(PrecomputeL[0], PrecomputeLT[0]) 
            + dot(PrecomputeL[1], PrecomputeLT[1]) 
            + dot(PrecomputeL[2], PrecomputeLT[2]);
    }
    
    void main(void) {
      // 防止因为浏览器优化报错,无实际作用
      aNormalPosition;
    
      for(int i = 0; i < 3; i++) {
          vColor[i] = L_dot_LT(aPrecomputeLT, uPrecomputeL[i]);
      }
    
      gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertexPosition, 1.0);
    }
    C#

    另外值得一说的是,在渲染框架中为一个名为 aNormalPosition 的attribute设置了数值,如果在Shader中没有使用的话就会被WebGL优化掉,导致浏览器不停报错。

    编写片元着色器

    在顶点着色器中完成对当前顶点着色的计算之后,在片元着色器中插值计算颜色。由于在顶点着色器中为每个顶点计算的vColor值会在片元着色器中被自动插值,因此直接使用就可以了。

    // prtFragment.glsl
    
    #ifdef GL_ES
    precision mediump float;
    #endif
    
    varying highp vec3 vColor;
    
    void main(){
      gl_FragColor = vec4(vColor, 1.0);
    }
    C#

    曝光与颜色矫正

    虽然框架作者提及PRT预计算保存的结果是在线性空间中的,不需要再进行 gamma 矫正了,但是显然最终结果是有问题的。如果您没有事先在计算系数的时候除 ,那么以Skybox场景为例子,就会出现过曝的问题。如果事先除了 ,但是没有做色彩矫正,就会在实时渲染框架中出现过暗的问题。

    img

    首先在计算系数的时候除以 ,然后再做一个色彩矫正。怎么做呢?我们可以参照nori框架的导出图片过程中有一个 toSRGB() 的函数:

    // common.cpp
    Color3f Color3f::toSRGB() const {
        Color3f result;
    
        for (int i=0; i<3; ++i) {
            float value = coeff(i);
    
            if (value <= 0.0031308f)
                result[i] = 12.92f * value;
            else
                result[i] = (1.0f + 0.055f)
                    * std::pow(value, 1.0f/2.4f) -  0.055f;
        }
    
        return result;
    }
    C#

    我们可以仿照这个在片元着色其中做色彩矫正。

    //prtFragment.glsl
    
    #ifdef GL_ES
    precision mediump float;
    #endif
    
    varying highp vec3 vColor;
    
    vec3 toneMapping(vec3 color){
        vec3 result;
    
        for (int i=0; i<3; ++i) {
            if (color[i] <= 0.0031308)
                result[i] = 12.92 * color[i];
            else
                result[i] = (1.0 + 0.055) * pow(color[i], 1.0/2.4) - 0.055;
        }
    
        return result;
    }
    
    void main(){
      vec3 color = toneMapping(vColor); 
      gl_FragColor = vec4(color, 1.0);
    }
    C#

    这样就可以保证实时渲染框架渲染的结果与nori框架的截图结果一致了。

    img

    我们也可以做其他的颜色矫正,这里提供几种常见的Tone Mapping方法,用于将HDR范围转换至LDR范围。

    vec3 linearToneMapping(vec3 color) {
        return color / (color + vec3(1.0));
    }
    vec3 reinhardToneMapping(vec3 color) {
        return color / (vec3(1.0) + color);
    }
    vec3 exposureToneMapping(vec3 color, float exposure) {
        return vec3(1.0) - exp(-color * exposure);
    }
    vec3 filmicToneMapping(vec3 color) {
        color = max(vec3(0.0), color - vec3(0.004));
        color = (color * (6.2 * color + 0.5)) / (color * (6.2 * color + 1.7) + 0.06);
        return color;
    }
    C#

    到这里为止,作业的基础部分就完成了。

    添加CornellBox场景

    默认框架代码中没有CornellBox,但是资源文件里面有,这就需要我们自行添加:

    // engine.js
    
    var envmap = [
        'assets/cubemap/GraceCathedral',
        'assets/cubemap/Indoor',
        'assets/cubemap/Skybox',
        // Edit Start
        'assets/cubemap/CornellBox',
        // Edit End
    ];
    // engine.js
    
    function createGUI() {
        const gui = new dat.gui.GUI();
        const panelModel = gui.addFolder('Switch Environemtn Map');
        // Edit Start
        panelModel.add(guiParams, 'envmapId', { 'GraceGathedral': 0, 'Indoor': 1, 'Skybox': 2, 'CornellBox': 3}).name('Envmap Name');
        // Edit End
        panelModel.open();
    }
    C#
    img

    基础部分结果展示

    分别展示shadowed和unshadowed的四个场景。

    img

    考虑传输项光线多次弹射(bonus 1)

    这是提高的第一部分。 计算多次弹射的光线传输与光线追踪有相似之处,在使用球谐函数(Spherical Harmonics,SH)进行光照近似时,您可以结合光线追踪来计算这些多次反射的效果。

    完整代码

    // TODO: leave for bonus
    Eigen::MatrixXf m_IndirectCoeffs = Eigen::MatrixXf::Zero(SHCoeffLength, mesh->getVertexCount());
    int sample_side = static_cast<int>(floor(sqrt(m_SampleCount)));
    
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_real_distribution<> rng(0.0, 1.0);
    
    const double twoPi = 2.0 * M_PI;
    
    for(int bo = 0; bo < m_Bounce; bo++)
    {
        for (int i = 0; i < mesh->getVertexCount(); i++)
        {
            const Point3f &v = mesh->getVertexPositions().col(i);
            const Normal3f &n = mesh->getVertexNormals().col(i);
    
            std::vector<float> coeff(SHCoeffLength, 0.0f);
            for (int t = 0; t < sample_side; t++) {
                for (int p = 0; p < sample_side; p++) {
                    double alpha = (t + rng(gen)) / sample_side;
                    double beta = (p + rng(gen)) / sample_side;
                    double phi = twoPi * beta;
                    double theta = acos(2.0 * alpha - 1.0);
    
                    Eigen::Array3d d = sh::ToVector(phi, theta);
                    const Vector3f wi(d[0], d[1], d[2]);
    
                    double H = wi.dot(n);
                    if(H > 0.0) {
                        const auto ray = Ray3f(v, wi);
                        Intersection intersect;
                        bool is_inter = scene->rayIntersect(ray, intersect);
                        if(is_inter) {
                            for(int j = 0; j < SHCoeffLength; j++) {
                                const Vector3f coef3(
                                    m_TransportSHCoeffs.col((int)intersect.tri_index[0]).coeffRef(j),
                                    m_TransportSHCoeffs.col((int)intersect.tri_index[1]).coeffRef(j),
                                    m_TransportSHCoeffs.col((int)intersect.tri_index[2]).coeffRef(j)
                                );
                                coeff[j] += intersect.bary.dot(coef3) / m_SampleCount;
                            }
                        }
                    }
                }
            }
    
            for (int j = 0; j < SHCoeffLength; j++)
            {
                m_IndirectCoeffs.col(i).coeffRef(j) = coeff[j] - m_IndirectCoeffs.col(i).coeffRef(j);
            }
        }
        m_TransportSHCoeffs += m_IndirectCoeffs;
    }
    C#

    分析

    在计算有遮挡的阴影的基础上(直接光照),加上二次反射光(间接照明)的贡献。而二次反射的光线也可以再进行相同的步骤。对于间接光照的计算,使用球谐函数对这些反射光线的照明进行近似。如果考虑多次弹射,则使用 进行递归计算,终止条件可以是递归深度或光线强度低于某个阈值。下面就是文字的公式描述。

    简略代码与注释如下:

    // TODO: leave for bonus
    // 首先初始化球谐系数
    Eigen::MatrixXf m_IndirectCoeffs = Eigen::MatrixXf::Zero(SHCoeffLength, mesh->getVertexCount());
    // 采样侧边的大小 = 样本数量的平方根 // 这样我们在后面可以进行二维的采样
    int sample_side = static_cast<int>(floor(sqrt(m_SampleCount)));
    
    // 生成随机数,范围是 [0,1]
    ...
    std::uniform_real_distribution<> rng(0.0, 1.0);
    
    // 定义常量 2 \pi
    ...
    
    // 循环计算多次反射 (m_Bounce 次)
    for (int bo = 0; bo < m_Bounce; bo++) {
      // 对每个顶点做处理
      // 对于每个顶点,会做如下操作
      // - 获取该顶点的位置和法线 v n
      // - rng()获得随机的二维方向 alpha beta
      // - 如果wi在顶点法线的同一侧,则继续进行:
      // - 生成一条从顶点出发的射线,并检查这条射线是否与场景中的其他物体相交
      // - 如果有相交的物体,代码会使用相交处的信息和现有的球谐系数来更新该顶点的光线间接反射信息。
      for (int i = 0; i < mesh->getVertexCount(); i++) {
        const Point3f &v = mesh->getVertexPositions().col(i);
        const Normal3f &n = mesh->getVertexNormals().col(i);
        ...
        for (int t = 0; t < sample_side; t++) {
          for (int p = 0; p < sample_side; p++) {
            ...
            double H = wi.dot(n);
            if (H > 0.0) {
              // 这里就是公式中的 $$(1-V(w_i))$$ 如果不满足,这一轮循环就不累加
              bool is_inter = scene->rayIntersect(ray, intersect);
              if (is_inter) {
                for (int j = 0; j < SHCoeffLength; j++) {
                  ...
                  coeff[j] += intersect.bary.dot(coef3) / m_SampleCount;
                }
              }
            }
          }
        }
        // 对于每个顶点,会根据计算的反射信息更新其球谐系数。
        for (int j = 0; j < SHCoeffLength; j++) {
          m_IndirectCoeffs.col(i).coeffRef(j) = coeff[j] - m_IndirectCoeffs.col(i).coeffRef(j);
        }
      }
      m_TransportSHCoeffs += m_IndirectCoeffs;
    }
    C#

    在之前的步骤中,我们只是计算了每一个顶点的球谐函数,并不涉及到三角形中心的插值计算。但是在光线多次弹射的实现中,从顶点向正半球发射的光线会与顶点之外的位置相交,因此我们需要通过重心坐标插值计算获取发射光线与三角形内部的交点的信息,这就是 intersect.bary 的作用。

    结果

    观察一下,整体上没有太大差异,只是阴影的地方更加亮了。

    img

    环境光照球谐函数旋转(bonus 2)

    提高2。 低阶的球鞋光照的旋转可以使用「低阶SH快速旋转方法」。

    代码

    首先让Skybox转起来。 [0, 1, 0] 意味着绕y轴旋转。然后通过 getRotationPrecomputeL 函数计算旋转后的球谐函数。最后应用到 Mat3Value 。

    // WebGLRenderer.js
    let cameraModelMatrix = mat4.create();
    // Edit Start
    mat4.fromRotation(cameraModelMatrix, timer, [0, 1, 0]);
    // Edit End
    if (k == 'uMoveWithCamera') { // The rotation of the skybox
        gl.uniformMatrix4fv(
            this.meshes[i].shader.program.uniforms[k],
            false,
            cameraModelMatrix);
    }
    
    // Bonus - Fast Spherical Harmonic Rotation
    // Edit Start
    let precomputeL_RGBMat3 = getRotationPrecomputeL(precomputeL[guiParams.envmapId], cameraModelMatrix);
    Mat3Value = getMat3ValueFromRGB(precomputeL_RGBMat3);
    // Edit End
    C#

    接下来跳转到 tool.js ,编写 getRotationPrecomputeL 函数。

    // tools.js
    function getRotationPrecomputeL(precompute_L, rotationMatrix){
        let rotationMatrix_inverse = mat4.create()
        mat4.invert(rotationMatrix_inverse, rotationMatrix)
        let r = mat4Matrix2mathMatrix(rotationMatrix_inverse)
    
        let shRotateMatrix3x3 = computeSquareMatrix_3by3(r);
        let shRotateMatrix5x5 = computeSquareMatrix_5by5(r);
    
        let result = [];
        for(let i = 0; i < 9; i++){
            result[i] = [];
        }
        for(let i = 0; i < 3; i++){
            let L_SH_R_3 = math.multiply([precompute_L[1][i], precompute_L[2][i], precompute_L[3][i]], shRotateMatrix3x3);
            let L_SH_R_5 = math.multiply([precompute_L[4][i], precompute_L[5][i], precompute_L[6][i], precompute_L[7][i], precompute_L[8][i]], shRotateMatrix5x5);
    
            result[0][i] = precompute_L[0][i];
            result[1][i] = L_SH_R_3._data[0];
            result[2][i] = L_SH_R_3._data[1];
            result[3][i] = L_SH_R_3._data[2];
            result[4][i] = L_SH_R_5._data[0];
            result[5][i] = L_SH_R_5._data[1];
            result[6][i] = L_SH_R_5._data[2];
            result[7][i] = L_SH_R_5._data[3];
            result[8][i] = L_SH_R_5._data[4];
        }
    
        return result;
    }
    
    function computeSquareMatrix_3by3(rotationMatrix){ // 计算方阵SA(-1) 3*3 
    
        // 1、pick ni - {ni}
        let n1 = [1, 0, 0, 0]; let n2 = [0, 0, 1, 0]; let n3 = [0, 1, 0, 0];
    
        // 2、{P(ni)} - A  A_inverse
        let n1_sh = SHEval(n1[0], n1[1], n1[2], 3)
        let n2_sh = SHEval(n2[0], n2[1], n2[2], 3)
        let n3_sh = SHEval(n3[0], n3[1], n3[2], 3)
    
        let A = math.matrix(
        [
            [n1_sh[1], n2_sh[1], n3_sh[1]], 
            [n1_sh[2], n2_sh[2], n3_sh[2]], 
            [n1_sh[3], n2_sh[3], n3_sh[3]], 
        ]);
    
        let A_inverse = math.inv(A);
    
        // 3、用 R 旋转 ni - {R(ni)}
        let n1_r = math.multiply(rotationMatrix, n1);
        let n2_r = math.multiply(rotationMatrix, n2);
        let n3_r = math.multiply(rotationMatrix, n3);
    
        // 4、R(ni) SH投影 - S
        let n1_r_sh = SHEval(n1_r[0], n1_r[1], n1_r[2], 3)
        let n2_r_sh = SHEval(n2_r[0], n2_r[1], n2_r[2], 3)
        let n3_r_sh = SHEval(n3_r[0], n3_r[1], n3_r[2], 3)
    
        let S = math.matrix(
        [
            [n1_r_sh[1], n2_r_sh[1], n3_r_sh[1]], 
            [n1_r_sh[2], n2_r_sh[2], n3_r_sh[2]], 
            [n1_r_sh[3], n2_r_sh[3], n3_r_sh[3]], 
    
        ]);
    
        // 5、S*A_inverse
        return math.multiply(S, A_inverse)   
    
    }
    
    function computeSquareMatrix_5by5(rotationMatrix){ // 计算方阵SA(-1) 5*5
    
        // 1、pick ni - {ni}
        let k = 1 / math.sqrt(2);
        let n1 = [1, 0, 0, 0]; let n2 = [0, 0, 1, 0]; let n3 = [k, k, 0, 0]; 
        let n4 = [k, 0, k, 0]; let n5 = [0, k, k, 0];
    
        // 2、{P(ni)} - A  A_inverse
        let n1_sh = SHEval(n1[0], n1[1], n1[2], 3)
        let n2_sh = SHEval(n2[0], n2[1], n2[2], 3)
        let n3_sh = SHEval(n3[0], n3[1], n3[2], 3)
        let n4_sh = SHEval(n4[0], n4[1], n4[2], 3)
        let n5_sh = SHEval(n5[0], n5[1], n5[2], 3)
    
        let A = math.matrix(
        [
            [n1_sh[4], n2_sh[4], n3_sh[4], n4_sh[4], n5_sh[4]], 
            [n1_sh[5], n2_sh[5], n3_sh[5], n4_sh[5], n5_sh[5]], 
            [n1_sh[6], n2_sh[6], n3_sh[6], n4_sh[6], n5_sh[6]], 
            [n1_sh[7], n2_sh[7], n3_sh[7], n4_sh[7], n5_sh[7]], 
            [n1_sh[8], n2_sh[8], n3_sh[8], n4_sh[8], n5_sh[8]], 
        ]);
    
        let A_inverse = math.inv(A);
    
        // 3、用 R 旋转 ni - {R(ni)}
        let n1_r = math.multiply(rotationMatrix, n1);
        let n2_r = math.multiply(rotationMatrix, n2);
        let n3_r = math.multiply(rotationMatrix, n3);
        let n4_r = math.multiply(rotationMatrix, n4);
        let n5_r = math.multiply(rotationMatrix, n5);
    
        // 4、R(ni) SH投影 - S
        let n1_r_sh = SHEval(n1_r[0], n1_r[1], n1_r[2], 3)
        let n2_r_sh = SHEval(n2_r[0], n2_r[1], n2_r[2], 3)
        let n3_r_sh = SHEval(n3_r[0], n3_r[1], n3_r[2], 3)
        let n4_r_sh = SHEval(n4_r[0], n4_r[1], n4_r[2], 3)
        let n5_r_sh = SHEval(n5_r[0], n5_r[1], n5_r[2], 3)
    
        let S = math.matrix(
        [    
            [n1_r_sh[4], n2_r_sh[4], n3_r_sh[4], n4_r_sh[4], n5_r_sh[4]], 
            [n1_r_sh[5], n2_r_sh[5], n3_r_sh[5], n4_r_sh[5], n5_r_sh[5]], 
            [n1_r_sh[6], n2_r_sh[6], n3_r_sh[6], n4_r_sh[6], n5_r_sh[6]], 
            [n1_r_sh[7], n2_r_sh[7], n3_r_sh[7], n4_r_sh[7], n5_r_sh[7]], 
            [n1_r_sh[8], n2_r_sh[8], n3_r_sh[8], n4_r_sh[8], n5_r_sh[8]], 
        ]);
    
        // 5、S*A_inverse
        return math.multiply(S, A_inverse)  
    }
    
    function mat4Matrix2mathMatrix(rotationMatrix){
    
        let mathMatrix = [];
        for(let i = 0; i < 4; i++){
            let r = [];
            for(let j = 0; j < 4; j++){
                r.push(rotationMatrix[i*4+j]);
            }
            mathMatrix.push(r);
        }
        // Edit Start
        //return math.matrix(mathMatrix)
        return math.transpose(mathMatrix)
        // Edit End
    }
    function getMat3ValueFromRGB(precomputeL){
    
        let colorMat3 = [];
        for(var i = 0; i<3; i++){
            colorMat3[i] = mat3.fromValues( precomputeL[0][i], precomputeL[1][i], precomputeL[2][i],
                                            precomputeL[3][i], precomputeL[4][i], precomputeL[5][i],
                                            precomputeL[6][i], precomputeL[7][i], precomputeL[8][i] ); 
        }
        return colorMat3;
    }
    C#

    结果

    img

    动画GIF可以在此处 获取。

    原理

    两项关键性质

    首先简单说说原理,这里利用了球谐函数的两个性质。

    1. 旋转不变性

    在三维空间中旋转一个函数的坐标,并将这个旋转后的坐标代入球谐函数,那么你会得到与原始函数相同的结果。

    1. 旋转的线性性

    对于球谐函数的每一“层”或“带”(band)(也就是给定的阶数 l 的所有球谐函数),其SH系数可以被旋转,并且这个旋转是线性的。也就是说,可以通过一个矩阵乘法来旋转一个球谐函数展开的系数。

    Wigner D矩阵旋转方法概述

    球谐函数的旋转是一个深入的话题,这里直接概述,不涉及复杂的数学证明。 作业框架中给的是基于投影的方法,本文先介绍一个更精确的方法,Wigner D矩阵。 更加详细的内容请去看:球谐光照笔记(旋转篇) – 网易游戏雷火事业群的文章 – 知乎 ,反正我是没看懂QAQ。

    由于当前使用的是前三阶的球谐函数,并且 band0 只有一个投影系数,所以我们只需要处理band1, band2 两层上各自 , 的旋转矩阵 。

    球谐函数 的旋转可以表示为:

    其中, 是旋转矩阵元素,它给出了如何将球谐系数从原始方向旋转到新方向。

    假设有一个函数 ,它可以展开为球谐函数的线性组合 :

    如果想要旋转这个函数,我们不直接旋转每一个球谐函数,而是旋转它们的系数。新的展开系数 可以由原始系数 通过旋转矩阵得到 :

    接下来就到关键的一步了,如何计算旋转矩阵

    img

    在作业框架中,我们了解到,band 1需要构建一个 的矩阵,band 2需要构建 的矩阵。也就是说,对于每个阶数为 的 band,它都有 个合法的解,每个解对应当前 band 上的一个基函数,这是勒让德方程的一个特性。

    现在,我们来考虑旋转的影响。

    当我们旋转一个环境光照 ,我们不会去旋转基函数,而是“旋转”所有的系数。旋转一个特定的系数的过程涉及到使用Wigner D矩阵 。首先,当我们谈论旋转,我们通常指的是围绕某个轴的旋转,定义由欧拉角来指定。我们就为每一阶都计算一个边长是 的方阵 。

    一旦得到了每一阶对应的旋转矩阵,我们就可以轻松计算出“旋转”后的新系数:

    然而,计算Wigner D矩阵的元素可能会有些复杂,特别是对于较高的阶数。因此,作业提示中给出的是一种基于投影的方法。接下来我们看看上面两段代码是怎么实现的。

    投影的近似方法

    首先,选择 个 normal vector ,这个量的选取需要确保线性独立性,也就是尽可能均匀的覆盖球面(Fibonacci球面采样也许是个不错的选择),否则在后面会出现计算奇异的矩阵的错误,确保生成的矩阵是满秩的

    对于每一个normal vector ,在球谐函数上投影(SHEval函数),这实际上是在计算球谐函数与该方向上的点乘。从这个投影中,可以得到一个 维向量 ,它的每一个分量都是球谐函数的一个系数。

    使用上面得到的 向量,我们可以构建矩阵 和逆矩阵 。如果我们记 为 normal vector 在球谐函数上的第 个系数, 那么矩阵 可以写为:

    对于每一个normal vector , 应用旋转 , 得到 ,即(前乘):

    然后,对于这些旋转后的normal vectors, 再次进行球谐函数投影, 得到 。

    使用从旋转后的normal vectors得到的 向量, 我们可以构建矩阵S。计算旋转矩阵 : 旋转矩阵 可以告诉我们如何通过简单的矩阵乘法来旋转球谐系数。

    使用矩阵 乘以原始的球谐系数向量,我们可以得到旋转后的球谐系数。对每个 层重复: 为了得到完整的旋转后的球谐系数,我们需要对每个 层重复上述过程。

    Reference

    1. Games 202
    2. https://github.com/DrFlower/GAMES_101_202_Homework/tree/main/Homework_202/Assignment2
  • Games202 作业一 软阴影实现

    Games202 作业一 软阴影实现

    本文内容:JS和WebGL相关知识、2-pass shadow算法、BIAS缓解自遮挡、PCF算法、PCSS、物体移动。

    项目源代码:

    GitHub – Remyuu/GAMES202-Homework: GAMES202-Homework​

    上面这个图画着好玩。


    写在前面

    由于我对JS以及WebGL一窍不通,只能遇事不决 console.log() 。

    除了作业要求的内容,我在coding的时候也有一些疑问,希望大佬解答QAQ。

    1. 如何实现动态的点光源阴影效果?我们需要使用点光源阴影技术才可以实现万向阴影贴图(omnidirectional shadow maps),具体怎么做?
    2. possionDiskSamples函数并不是真正的泊松圆盘分布?

    框架修正

    在作业开始时请先对作业框架做一些修正。框架改动原文:https://games-cn.org/forums/topic/zuoyeziliao-daimakanwu/

    • 框架提供的 unpack 函数算法实现不准确,在不加 bias 时,会导致严重的 banding(地面一半白一半黑而不是典型的 z-fighting 效果),一定程度上影响作业调试。
    // homework1/src/shaders/shadowShader/shadowFragment.glsl
    vec4 pack (float depth) {
        // 使用rgba 4字节共32位来存储z值,1个字节精度为1/255
        const vec4 bitShift = vec4(1.0, 255.0, 255.0 * 255.0, 255.0 * 255.0 * 255.0);
        const vec4 bitMask = vec4(1.0/255.0, 1.0/255.0, 1.0/255.0, 0.0);
        // gl_FragCoord:片元的坐标,fract():返回数值的小数部分
        vec4 rgbaDepth = fract(depth * bitShift); //计算每个点的z值
        rgbaDepth -= rgbaDepth.gbaa * bitMask; // Cut off the value which do not fit in 8 bits
        return rgbaDepth;
    }
    
    // homework1/src/shaders/phongShader/phongFragment.glsl
    float unpack(vec4 rgbaDepth) {
        const vec4 bitShift = vec4(1.0, 1.0/255.0, 1.0/(255.0*255.0), 1.0/(255.0*255.0*255.0));
        return dot(rgbaDepth, bitShift);
    }
    • 清屏还需要添加一个glClear。
    // homework1/src/renderers/WebGLRenderer.js
    gl.clearColor(0.0, 0.0, 0.0,1.0);// Clear to black, fully opaque
    gl.clearDepth(1.0);// Clear everything
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    JS最基础的知识

    变量

    • 在JavaScript中,我们主要使用 var,let 和 const 这三个关键字来声明变量/常量。
    • var是声明变量的关键字,可以在整个函数范围内使用声明的变量(函数作用域)。
    • let行为与 var 类似,也是声明了一个变量,但是 let 的作用域限制在块中(块作用域),比如 for 循环或 if 语句中定义的块。
    • const:用于声明常量。 const 的作用域也是块级别的。
    • 推荐使用 let 和 const 而不是 var 来声明变量,因为它们遵循块级作用域,更符合大多数编程语言中的作用域规则,更易理解和预测。

    一个基本的JavaScript类的结构如下:

    class MyClass {
      constructor(parameter1, parameter2) {
        this.property1 = parameter1;
        this.property2 = parameter2;
      }
      method1() {
        // method body
      }
      static sayHello() {
        console.log('Hello!');
      }
    }

    创建实例:

    let myInstance = new MyClass('value1', 'value2');
    myInstance.method1(); // 调用类的方法

    也可以直接调用静态类(不用创建实例了):

    MyClass.sayHello();  // "Hello!"

    项目流程简述

    程序入口是engine.js,主函数 GAMES202Main 。首先初始化WebGL相关的内容,包括相机、相机交互、渲染器、光源、物体加载、用户GUI界面以及最重要的主循环main loop部分。

    物体加载过程,会调用loadOBJ.js。首先从文件中加载对应的glsl,构建Phong材质、Phong相关阴影还有阴影的材质。

    // loadOBJ.js
    case 'PhongMaterial':
        material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
        shadowMaterial = buildShadowMaterial(light, Translation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");
        break;
    }

    然后,通过MeshRender直接生成2-pass阴影Shadow Map和常规的Phong材质,具体代码如下:

    // loadOBJ.js
    material.then((data) => {
        // console.log("现在制作表面材质")
        let meshRender = new MeshRender(renderer.gl, mesh, data);
        renderer.addMeshRender(meshRender);
    });
    shadowMaterial.then((data) => {
        // console.log("现在制作阴影材质")
        let shadowMeshRender = new MeshRender(renderer.gl, mesh, data);
        renderer.addShadowMeshRender(shadowMeshRender);
    });

    注意到,MeshRender具备一定的通用性,它接受任何类型的材质作为其参数。具体是怎么区分的呢?通过判断传入的material.frameBuffer是否为空,如果是空,将加载表面材质,否则加载阴影图Shadow Map。在MeshRender.js的draw()函数中,看到如下代码:

    // MeshRender.js
    if (this.material.frameBuffer != null) {
        // Shadow map
        gl.viewport(0.0, 0.0, resolution, resolution);
    } else {
        gl.viewport(0.0, 0.0, window.screen.width, window.screen.height);
    }

    利用MeshRender生成了阴影之后,推入到renderer中,可以在 WebGLRenderer.js 中找到对应实现:

    addShadowMeshRender(mesh) { this.shadowMeshes.push(mesh); }

    最后进入mainLoop()主循环实现一帧帧的更新画面。

    项目流程详细解释

    这一章节将会从一个小问题出发,探讨片段着色器是如何构造的。这将会串联起几乎整个项目,而这也是我认为比较舒服的阅读项目流程。

    glsl是在哪里工作? — 从片段着色器的流程入手详细讲解代码流程

    在上文中我们并没有详细提及glsl文件是怎么调用的,这里我们详细说说。

    首先在loadOBJ.js中首次用过路径的方式将.glsl文件引入:

    // loadOBJ.js - function loadOBJ()
    material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
    shadowMaterial = buildShadowMaterial(light, Translation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");

    这里以phongFragment.glsl为例子,phongFragment.glsl通过位于 PhongMaterial.js 的buildPhongMaterial函数中的 getShaderString方法将glsl代码从硬盘中加载进来,与此同时将glsl代码通过构造参数的形式传入并用之构造一个PhongMaterial对象。PhongMaterial在构造的过程中会调用super()函数实现父类Material.js的构造函数,即将glsl代码传到Material.js中:

    // PhongMaterial.js
    super({...}, [], ..., fragmentShader);

    在c++中,子类可以选择是否完全继承父类的构造函数的参数。这里父类的构造函数有5个,实际只实现了4个,这也是完全没问题的。

    在Material.js中,子类通过构造函数的第四个参数#fsSrc将glsl代码传到了此处。至此,glsl代码的传送之路就走到了尽头,接下来等待他的将是一个名为compile()的函数。

    // Material.js
    this.#fsSrc = fsSrc;
    ...
    compile(gl) {
        return new Shader(..., ..., this.#fsSrc,{...});
    }

    至于这个compile函数什么时候调用呢?回到loadOBJ.js的流程中,现在我们已经完全执行完毕buildPhongMaterial()代码,接下来就到了上一小节提及到的then()部分。

    注意,loadOBJ()只是一个函数,不是对象!

    // loadOBJ.js
    material.then((data) => {
        let meshRender = new MeshRender(renderer.gl, mesh, data);
        renderer.addMeshRender(meshRender);
        renderer.ObjectID[ObjectID][0].push(renderer.meshes.length - 1);
    });

    在构造MeshRender对象时,就会调用compile():

    // MeshRender.js
    constructor(gl, mesh, material) {
    ...
        this.shader = this.material.compile(gl);
    }
    // Material.js
    compile(gl) {
        return new Shader(..., ..., this.#fsSrc,{...});
    }

    接下来,我们具体看一下shader.js的构造。Material在构造shader对象时实现了所有的四个构造参数。这里还是挑重点fsSrc看,即继续看看glsl代码接下来的命运。

    // shader.js
    constructor(gl, vsSrc, fsSrc, shaderLocations) {
        ...
        const fs = this.compileShader(fsSrc, ...);
        ...
    }

    在构造shader对象实现fs编译着色器时是通过compileShader()函数的。这个compileShader函数会创建一个全局变量shader,代码如下:

    // shader.js
    compileShader(shaderSource, shaderType) {
        const gl = this.gl;
        var shader = gl.createShader(shaderType);
        gl.shaderSource(shader, shaderSource);
        gl.compileShader(shader);
    
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error(shaderSource);
            console.error('shader compiler error:\n' + gl.getShaderInfoLog(shader));
        }
    
        return shader;
    };

    这个gl是什么呢?是在loadOBJ()通过构造MeshRender对象时以参数renderer.gl一路传到shader.js中的。而renderer则是loadOBJ()的第一个参数,在engine.js中传入。

    实际上loadOBJ.js中renderer是一个WebGLRenderer对象。而renderer.gl的gl是在engine.js中创建的:

    // engine.js
    const gl = canvas.getContext('webgl');

    而gl可以理解为从index.html中获取canvas的WebGL对象。实际上gl为开发者提供了一个接口来与WebGL API进行交互。

    <!-- index.html -->
    <canvas id="glcanvas"></canvas>

    WebGL推荐参考资料:

    1. https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API
    2. https://webglfundamentals.org
    3. https://www.w3cschool.cn/webgl/vjxu1jt0.html

    Tips:网站都有对应的中文版本,但是有能力的还是推荐阅读英文版本~ WebGL API:

    1. https://developer.mozilla.org/en-US/docs/Web/API
    2. https://webglfundamentals.org/docs/

    知道了gl是什么之后,自然也就发现了项目框架是在哪里通过什么方式与WebGL联系在一起的了。

    // Shader.js
    compileShader(shaderSource, shaderType) {
        const gl = this.gl;
        var shader = gl.createShader(shaderType);
        gl.shaderSource(shader, shaderSource);
        gl.compileShader(shader);
    
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error(shaderSource);
            console.error('shader compiler error:\n' + gl.getShaderInfoLog(shader));
        }
    
        return shader;
    };

    也就是说,所有关于gl的方法都是通过WebGL API调用的。gl.createShader就是我们接触到的第一个WebGL API。

    我们只需知道这个createShader()函数会返回 WebGLShader 着色器对象。我们在后文再详细说明,这里先关注shaderSource究竟何去何从。

    • gl.shaderSource:A string containing the GLSL source code to set.

    也就是说,我们一路追踪的GLSL源代码是通过gl.shaderSource函数解析进了WebGLShader中。

    然后通过gl.compileShader()函数编译WebGLShader,使其成为为二进制数据,然后就可以被WebGLProgram对象所使用。

    简单地说,WebGLProgram是一个包含已编译的WebGL着色器的GLSL程序,至少需要包含一个顶点着色器和一个片段着色器。在WebGL中,会创建一个或多个WebGLProgram对象,每个对象包含一组特定的渲染指令。通过使用不同的WebGLProgram,就可以实现各种画面。

    if语句是检查着色器是否成功编译的部分。如果编译失败,则执行括号内的代码。最后,返回编译后(或尝试编译后)的着色器对象shader。

    至此,我们就完成了将GLSL文件从硬盘中取出最后编译进着色器对象的工作。

    但是渲染的流程还没有结束。回到Shadow对象的构造处:

    // Shadow.js
    class Shader {
        constructor(gl, vsSrc, fsSrc, shaderLocations) {
            this.gl = gl;
            const vs = this.compileShader(vsSrc, gl.VERTEX_SHADER);
            const fs = this.compileShader(fsSrc, gl.FRAGMENT_SHADER);
    
            this.program = this.addShaderLocations({
                glShaderProgram: this.linkShader(vs, fs),
            }, shaderLocations);
        }
        ...

    虽然刚才我们只解说了片段着色器的GLSL编译流程,但是顶点着色器也是相当类似的,故此省略。


    这里我们介绍linkShader()链接着色器的流程。代码在文字的下方。

    1. 首先创建一个创建程序命名为WebGLProgram。
    2. 将编译后的顶点着色器和片段着色器vs和fs添加到程序中,这一步叫做附加着色器。具体而言是使用gl.attachShader()将他们附加到WebGLProgram上。
    3. 使用gl.linkProgram()链接WebGLProgram。这会生成一个可执行的程序,该程序结合了前面附加的着色器。这一步叫做链接程序
    4. 最后检查链接状态,返回WebGL对象。
    // Shader.js
    linkShader(vs, fs) {
        const gl = this.gl;
        var prog = gl.createProgram();
        gl.attachShader(prog, vs);
        gl.attachShader(prog, fs);
        gl.linkProgram(prog);
    
        if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
            abort('shader linker error:\n' + gl.getProgramInfoLog(prog));
        }
        return prog;
    };

    WebGLProgram可以被视为着色器的容器,它包含了将3D数据转换为屏幕上的2D像素所需的全部信息和指令。


    得到与着色器链接的程序glShaderProgram后,会与shaderLocations对象一同被载入。

    简单地说shaderLocations对象包含了两个属性

    • Attributes是”个体”的数据(比如每个顶点的信息)
    • Uniforms是”整体”的数据(比如一个灯光的信息)

    框架将载入的流程打包进了addShaderLocations()中。简单地说,经过这一步操作之后,当你需要给这些uniform和attribute赋值时,就可以直接通过已经获取到的位置进行操作,而不需要每次都去查询位置。

    addShaderLocations(result, shaderLocations) {
        const gl = this.gl;
        result.uniforms = {};
        result.attribs = {};
    
        if (shaderLocations && shaderLocations.uniforms && shaderLocations.uniforms.length) {
            for (let i = 0; i < shaderLocations.uniforms.length; ++i) {
                result.uniforms = Object.assign(result.uniforms, {
                    [shaderLocations.uniforms[i]]: gl.getUniformLocation(result.glShaderProgram, shaderLocations.uniforms[i]),
                });
            }
        }
        if (shaderLocations && shaderLocations.attribs && shaderLocations.attribs.length) {
            for (let i = 0; i < shaderLocations.attribs.length; ++i) {
                result.attribs = Object.assign(result.attribs, {
                    [shaderLocations.attribs[i]]: gl.getAttribLocation(result.glShaderProgram, shaderLocations.attribs[i]),
                });
            }
        }
    
        return result;
    }

    回顾一下目前已经完成的工作:成功构建好了一个编译后(或尝试编译后)的Shader着色器对象给MeshRender:

    // MeshRender.js - construct()
    this.shader = this.material.compile(gl);

    至此,loadOBJ的任务已经圆满完成。在engine.js中,这样的加载要做三次:

    // loadOBJ(renderer, path, name, objMaterial, transform, meshID);
    loadOBJ(renderer, 'assets/mary/', 'Marry', 'PhongMaterial', obj1Transform);
    loadOBJ(renderer, 'assets/mary/', 'Marry', 'PhongMaterial', obj2Transform);
    loadOBJ(renderer, 'assets/floor/', 'floor', 'PhongMaterial', floorTransform);

    接下来来到程式主循环mainLoop。也即,一个循环表示一帧:

    // engine.js
    loadOBJ(...);
    ...
    function mainLoop() {...}
    ...

    程序主循环 — mainLoop()

    实际上,执行mainLoop,该函数会再次调用自己,形成一个无限循环。这就是所谓的游戏循环或动画循环的基础机制。

    // engine.js
    function mainLoop() {
        cameraControls.update();
        renderer.render();
        requestAnimationFrame(mainLoop);
    };
    requestAnimationFrame(mainLoop);

    cameraControls.update();在更新相机的位置或方向,例如响应用户的输入。

    renderer.render();场景被渲染或绘制到屏幕上。具体的渲染内容和方式取决于renderer对象的实现。

    requestAnimationFrame的好处是它会尽量与屏幕的刷新率同步,这样可以提供更流畅的动画和更高的性能,因为它不会在屏幕刷新之间无谓地执行代码。

    关于requestAnimationFrame()函数的详细信息可以参考以下文章: https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame

    接下来重点关心render()函数的运作。

    render()渲染函数

    这是一个典型的光源渲染、阴影渲染和最终摄像机视角渲染的流程。此处就不详细展开了,放到后面的多光源部分。

    // WebGLRenderer.js - render()
    const gl = this.gl;
    
    gl.clearColor(0.0, 0.0, 0.0, 1.0); // shadowmap默认白色(无遮挡),解决地面边缘产生阴影的问题(因为地面外采样不到,默认值为0会认为是被遮挡)
    gl.clearDepth(1.0);// Clear everything
    gl.enable(gl.DEPTH_TEST); // Enable depth testing
    gl.depthFunc(gl.LEQUAL); // Near things obscure far things
    
    console.assert(this.lights.length != 0, "No light");
    console.assert(this.lights.length == 1, "Multiple lights");
    
    for (let l = 0; l < this.lights.length; l++) {
        gl.bindFramebuffer(gl.FRAMEBUFFER, this.lights[l].entity.fbo);
        gl.clear(gl.DEPTH_BUFFER_BIT);
        // Draw light
        // TODO: Support all kinds of transform
        this.lights[l].meshRender.mesh.transform.translate = this.lights[l].entity.lightPos;
        this.lights[l].meshRender.draw(this.camera);
    
        // Shadow pass
        if (this.lights[l].entity.hasShadowMap == true) {
            for (let i = 0; i < this.shadowMeshes.length; i++) {
                this.shadowMeshes[i].draw(this.camera);
            }
        }
    }
    // Camera pass
    for (let i = 0; i < this.meshes.length; i++) {
        this.gl.useProgram(this.meshes[i].shader.program.glShaderProgram);
        this.gl.uniform3fv(this.meshes[i].shader.program.uniforms.uLightPos, this.lights[0].entity.lightPos);
        this.meshes[i].draw(this.camera);
    }

    GLSL快速入门 — 分析片段着色器FragmentShader.glsl

    上文我们讨论了如何载入GLSL,这一章节介绍GLSL的概念与实际用法。

    在WebGL中进行渲染时,我们需要至少一个 顶点着色器(Vertex Shader) 和一个 片段着色器(Fragment Shader) 才能绘制出一幅画面。上一节我们以片段着色器为例,介绍了框架是怎么将GLSL文件从硬盘读取进renderer的。接下来我们也以Flagment Shader片段着色器为例子(即phongFragment.glsl),介绍编写GLSL的流程。

    FragmentShader.glsl有什么用?

    Fragment Shader的作用是在光栅化的时候为当前像素渲染正确的颜色。以下是一个Fragment Shader的最简单形式,其包含一个main()函数,在函数其中指定了当前像素的颜色gl_FragColor。

    void main(void){
        ...
        gl_FragColor = vec4(Color, 1.0);
    }

    Fragment Shader接受什么数据?

    Fragment Shader需要知道数据,数据是由以下三种主要方式提供的,具体的用法可以参考 附录1.6

    1. Uniforms (全局变量): 这些是在单个绘制调用中对所有顶点和片段都保持不变的值。常见的例子包括变换矩阵(平移旋转等操作)、光源参数和材质属性。由于它们在绘制调用中是恒定的,所以称为“uniform”。
    2. Textures (纹理): 纹理是图像数据数组,它们可以被片段着色器采样来为每个片段获得颜色、法线或其他类型的信息。
    3. Varyings (可变量): 这些是顶点着色器输出的值,它们在图形基元(如三角形)的顶点之间插值,并传递给片段着色器。这允许我们在顶点着色器中计算值(如变换后的位置或顶点颜色),并在片段之间进行插值,以便在片段着色器中使用。

    项目中用了Uniforms和Varyings两种。

    GLSL基本语法

    这里不会把基本的用法过一篇,因为那样太无聊了。我们直接看项目:

    // phongFragment.glsl - PCF pass
    void main(void) {
        // 声明变量
        float visibility;     // 可见性(用于阴影)
        vec3 shadingPoint;     // 从光源处的视点坐标
        vec3 phongColor;      // 计算出的Phong光照颜色
    
        // 将vPositionFromLight的坐标值归一化到[0,1]范围内
        shadingPoint = vPositionFromLight.xyz / vPositionFromLight.w;
        shadingPoint = shadingPoint * 0.5 + 0.5; // 进行坐标转换,使其在[0,1]范围内
    
        // 计算可见性(阴影)。
        visibility = PCF(uShadowMap, vec4(shadingPoint, 1.0)); // 使用PCF(Percentage Closer Filtering)技术
    
        // 使用blinnPhong()函数计算Phong光照颜色
        phongColor = blinnPhong();
    
        // 计算最终的片段颜色,将Phong光照颜色与可见性相乘,得到考虑阴影的片段颜色
        gl_FragColor = vec4(phongColor * visibility, 1.0);
    }

    和c语言一样,glsl是强类型语言,你不能这样赋值:float visibility = 1;,因为1是int类型。

    矢量或矩阵

    另外,glsl还内置了很多特别的类型,比如浮点类型向量vec2, vec3和 vec4,矩阵类型mat2, mat3 和 mat4。

    上面这些数据的访问方式也比较有意思,

    • .xyzw:通常用于表示三维或四维空间中的点或向量。
    • .rgba:当向量表示颜色时使用,其中r代表红色,g代表绿色,b代表蓝色,a代表透明度。
    • .stpq:当向量用作纹理坐标时使用。

    因此,

    • v.x 与 v[0] 与 v.r 与 v.s 都表示该向量的第一个分量。
    • v.y 与 v[1] 与 v.g 与 v.t 都表示该向量的第二个分量。
    • 对于vec3和vec4,v.z 与 v[2] 与 v.b 与 v.p 都表示该向量的第三个分量。
    • 对于vec4,v.w 与 v[3] 与 v.a 与 v.q 都表示该向量的第四个分量。

    你甚至可以使用一种叫“分量重组”或“分量选择”的方式访问这些类型的数据:

    1. 重复某个分量:
    2. v.yyyy 会得到一个新的vec4,其中每个分量都是原始v的y分量。这与vec4(v.y, v.y, v.y, v.y)的效果相同。
    3. 交换分量:
    4. v.bgra 会得到一个新的vec4,其中的分量按照b, g, r, a的顺序从v中选取。这与vec4(v.b, v.g, v.r, v.a)的效果相同。

    当构造一个矢量或矩阵时可以一次提供多个分量,例如:

    • vec4(v.rgb, 1)与vec4(v.r, v.g, v.b, 1)是等价的
    • vec4(1) 与vec(1, 1, 1, 1)也是等价的

    参考资料:GLSL语言规范 https://www.khronos.org/files/opengles_shading_language.pdf

    矩阵存储方式

    这些提示都可以在glmatrix的Doc中找到:https://glmatrix.net/docs/mat4.js.html。另外,如果看得仔细我们会发现这个组件也都是用列优先存储矩阵的,WebGL和GLSL中也是列有限存储。如下所示:

    将一个物体移动到一个新的位置,可以用mat4.translate()函数,并且这个函数接受三个参数分别是:一个4×4的输出out,传入的4×4矩阵a,一个1×3的位移矩阵v。

    最简单的矩阵乘法可以使用mat4.multiply,缩放矩阵使用mat4.scale(),调整“看向”的方向使用mat4.lookAt(),正交投影矩阵mat4.ortho()。

    实现光源相机的矩阵变换

    如果我们用透视投影操作,则是这里需要将下面Frustum放缩到一个正交视角的空间,如下图所示:

    但是如果我们使用正交投影,那么就可以保持深度值的线性,使得 Shadow Map 的精度尽可能大。

    // DirectionalLight.js - CalcLightMVP()
    let lightMVP = mat4.create();
    let modelMatrix = mat4.create();
    let viewMatrix = mat4.create();
    let projectionMatrix = mat4.create();
    
    // Model transform
    mat4.translate(modelMatrix, modelMatrix, translate);
    mat4.scale(modelMatrix, modelMatrix, scale);
    
    // View transform
    mat4.lookAt(viewMatrix, this.lightPos, this.focalPoint, this.lightUp);
    
    // Projection transform
    let left = -100.0, right = -left, bottom = -100.0, top = -bottom, 
        near = 0.1, far = 1024.0;  
        // Set these values as per your requirement
    mat4.ortho(projectionMatrix, left, right, bottom, top, near, far);
    
    
    mat4.multiply(lightMVP, projectionMatrix, viewMatrix);
    mat4.multiply(lightMVP, lightMVP, modelMatrix);
    
    return lightMVP;

    2-Pass Shadow 算法

    在实现两趟算法之前,先看看main()函数是怎么调用的。

    // phongFragment.glsl
    void main(void){  
      vec3 shadingPoint = vPositionFromLight.xyz / vPositionFromLight.w;
      shadingPoint = shadingPoint*0.5+0.5;// 归一化至 [0,1]
    
      float visibility = 1.0;
      visibility = useShadowMap(uShadowMap, vec4(shadingPoint, 1.0));
    
      vec3 phongColor = blinnPhong();
    
      gl_FragColor=vec4(phongColor * visibility,1.0);
    }

    那么问题来了,vPositionFromLight是怎么来的?是在顶点着色器中算出来的。

    统一空间坐标

    说人话就是,将场景的顶点的世界坐标转换为光相机的NDC空间对应的新坐标。目的为了渲染主相机的某个Shading Point的阴影时,可以在光源相机的空间中取出所需的深度值。

    vPositionFromLight表示从光源的视角看到的一个点的齐次坐标。这个坐标在光源的正交空间中,其范围是[-w, w]。他是由phongVertex.glsl计算出来的。phongVertex.glsl的作用是处理输入的顶点数据,通过上一章计算的MVP矩阵将一系列顶点转化为裁剪空间坐标。将vPositionFromLight转换到NDC标准空间得到shadingPoint,就可以将shadingPoint里面这些需要做阴影判断的Shading Point传入useShadowMap函数中。附上顶点转换的相关代码:

    // phongVertex.glsl - main()
    vFragPos = (uModelMatrix * vec4(aVertexPosition, 1.0)).xyz;
    vNormal = (uModelMatrix * vec4(aNormalPosition, 0.0)).xyz;
    
    gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix *
                vec4(aVertexPosition, 1.0);
    
    vTextureCoord = aTextureCoord;
    vPositionFromLight = uLightMVP * vec4(aVertexPosition, 1.0);

    phongVertex.glsl是和phongFragment.glsl一同在loadOBJ.js中被加载的。

    比对深度值

    接下来实现useShadowMap()函数。这个函数的目的是为了确定片段(像素)是否在阴影中。

    texture2D() 是一个GLSL的内置函数,用于对2D纹理进行采样。

    代码框架中的unpack()和pack()函数是为了增加数值精度而设置的。原因如下:

    • 深度信息是一个连续的浮点数,它的范围和精度可能超出了一个8位通道所能提供的。直接将这样的深度值存储在一个8位通道中会导致大量的精度丢失,从而导致阴影效果不正确。因此我们可以充分利用其他的三个通道,也就是将深度值编码到多个通道中。通过分配深度值的不同部分到R, G, B, A四个通道,我们可以用更高的精度来存储深度值。当我们需要使用深度值时,就可以从这四个通道解码出来。

    closestDepthVec是blocker的深度信息,

    最后,closestDepth与currentDepth进行比对,如果blocker(closestDepth)比主相机要渲染的片元的深度值(shadingPoint.z)大,说明当前的Shading Point没有被遮挡,visibility返回1.0。另外为了解决一些阴影痤疮和自遮挡问题,可以将blocker的位置调大一些,即加上EPS。

    // phongFragment.glsl
    float useShadowMap(sampler2D shadowMap, vec4 shadingPoint){
      // Retrieve the closest depth value from the light's perspective using the fragment's position in light space.
      float closestDepth = unpack(texture2D(shadowMap, shadingPoint.xy));
      // Compare the fragment's depth with the closest depth to determine if it's in shadow.
      return (closestDepth + EPS + getBias(.4)> shadingPoint.z) ? 1.0 : 0.0;
    }

    其实目前还是有点问题。我们目前的光源相机并不是万向的,也就是说其照射范围只有一小部分。如果模型在lightCam的范围内,那么画面是完全正确的。

    但是当模型在lightCam的范围外,就不应该参与useShadowMap的计算。但是目前我们并没有完成相关的逻辑。也就是说,如果在lightCam的MVP变换矩阵范围之外的位置在经过计算之后可能会出现意想不到的错误。再看一下灵魂示意图:

    上一节我们在定向光源脚本中定义了zFar、zNear等信息。如下代码所示:

    // DirectionalLight.js - CalcLightMVP()
    let left = -100.0, right = -left, bottom = -100.0, top = -bottom, near = 0.1, far = 1024.0;

    因此,为了解决模型在lightCam范围之外的问题,我们在useShadowMap或在useShadowMap之前的代码中,加入以下逻辑以剔除不在lightCam范围的采样点:

    // phongFragment.glsl - main()
    ...
    if(shadingPoint.x<0.||shadingPoint.x>1.||
       shadingPoint.y<0.||shadingPoint.y>1.){
      visibility=1.;// 光源看不见的地方,因此不会被阴影所覆盖
    }else{
      visibility=useShadowMap(uShadowMap,vec4(shadingPoint,1.));
    }
    ...

    效果如下图所示,左边是做了剔除逻辑的,右边是没有做剔除逻辑的。当202酱移动到lightCam的视锥体边界时,她就直接被截肢了,非常吓人:

    当然了,不完成这一步也没问题。实际上,在开发中我们会使用万向光源,即lightCam是360度全方位的,我们只需要剔除那些在zFar平面之外的点就可以了。

    添加bias改善自遮挡问题

    当我们从光源的视角渲染深度图时,由于浮点数精度的限制,可能会出现误差。因此,当我们在主渲染过程中使用深度图时,可能会看到物体自己的阴影,这称为自遮挡或阴影失真。

    在完成了2-pass渲染之后,我们会在202酱的头发等多处位置发现了这样的阴影痤疮,十分不美观。如下图所示:

    我们理论上可以通过添加bias缓解自遮挡问题。这里我提供一种动态调整bias的方法:

    // phongFragment.glsl
    // 使用bias偏移值优化自遮挡
    float getBias(float ctrl) {
      vec3 lightDir = normalize(uLightPos);
      vec3 normal = normalize(vNormal);
      float m = 200.0 / 2048.0 / 2.0; // 正交矩阵宽高/shadowmap分辨率/2
      float bias = max(m, m * (1.0 - dot(normal, lightDir))) * ctrl;
      return bias;
    }

    首先当光线和法线几乎垂直的时候,极有可能发生自遮挡现象,比如我们的202酱的后脑勺处。因此我们需要获取光线的方向与法线的方向。其中,m表示光源视图下每个像素代表的场景空间的大小。

    最后将 phongFragment.glsl 的 useShadowMap() 改为下文:

    // phongFragment.glsl
    float useShadowMap(sampler2D shadowMap, vec4 shadingPoint){
      ...
      return (closestDepth + EPS + getBias(.3)> shadingPoint.z) ? 1.0 : 0.0;
    }

    效果如下:

    需要注意,较大的bias值可能导致过度矫正带来的阴影缺失结果,较小的值又可能起不到改善痤疮的效果,因此需要多次尝试。

    PCF

    但是ShadowMap的分辨率是有限的。实际游戏中,ShadowMap的分辨率是远远小于分辨率的(原因是性能消耗太大),因此我们需要一种柔化锯齿的方法。PCF方法就是在ShadowMap上为每个像素取其周边的多个像素做平均计算出Shading Point的。

    最初人们想用这个方法软化阴影,但是做到后面发现这个方法可以做到软阴影的效果。

    在使用PCF算法估计阴影比例之前,我们需要准备一组采样点。对于PCF阴影,在移动设备我们只会采用4-8个采样点,而高质量的画面则来到16-32个。在这一节我们使用8个采样点,在这个基础上通过调整生成的样本的参数从而改进画面,减少噪点等等。

    但是,以上不同采样方式对于最终的画面影响其实不算特别大,最影响画面的其实是做PCF时候的阴影贴图大小,也就是shadow map的大小。具体来说,是代码中的textureSize,但是一般而言这一项在项目中都是固定一个值。

    所以我们接下来的思路是先实现PCF,最后再微调采样方式。

    毕竟,premature optimization是大忌。

    实现PCF

    在main()中,修改使用的阴影算法。

    // phongFragment.glsl
    void main(void){  
        ...
        visibility = PCF(uShadowMap, vec4(shadingPoint, 1.0));
        ...
    }

    shadowMap.xy 是用于在阴影贴图上采样的纹理坐标,shadowMap.z 是该像素的深度值。

    采样函数要求我们传入一个Vec2变量作为随机种子,接着会在一个半径为1的圆域内返回随机的点。

    接着将$[0, 1]^2$的uv坐标中分成textureSize份,设置好滤波窗口之后,就在当前的shadingPoint位置附近采样多次,最后统计:

    // phongFragment.glsl
    float PCF(sampler2D shadowMap,vec4 shadingPoint){
      // 采样 采样结果会返回到全局变量 - poissonDisk[]
      poissonDiskSamples(shadingPoint.xy);
    
      float textureSize=256.; // shadow map 的大小, 越大滤波的范围越小
      float filterStride=1.; // 滤波的步长
      float filterRange=1./textureSize*filterStride; // 滤波窗口的范围
      int noShadowCount=0; // 有多少点不在阴影里
      for(int i=0;i<NUM_SAMPLES;i++){
        vec2 sampleCoord=poissonDisk[i]*filterRange+shadingPoint.xy;
        vec4 closestDepthVec=texture2D(shadowMap,sampleCoord);
        float closestDepth=unpack(closestDepthVec);
        float currentDepth=shadingPoint.z;
        if(currentDepth<closestDepth+EPS){
          noShadowCount+=1;
        }
      }
      return float(noShadowCount)/float(NUM_SAMPLES);
    }

    效果如下:

    image-20230805213129275

    poissonDisk采样参数设置

    在作业框架中,我发现这个possionDiskSamples函数并不是真正的泊松圆盘分布?有点奇怪。个人感觉更像是均匀分布在螺旋线上的点。希望读者朋友可以指导一下。我首先先按照框架中的代码分析。


    框架中poissonDiskSamples的相关数学公式

    // phongFragment.glsl
    float ANGLE_STEP = PI2 * float( NUM_RINGS ) / float( NUM_SAMPLES );
    float INV_NUM_SAMPLES = 1.0 / float( NUM_SAMPLES );
    float angle = rand_2to1( randomSeed ) * PI2;
    float radius = INV_NUM_SAMPLES;
    float radiusStep = radius;

    转换极坐标为笛卡尔坐标: 更新规则: 半径变化:

    具体代码如下:

    // phongFragment.glsl
    vec2 poissonDisk[NUM_SAMPLES];
    
    void poissonDiskSamples( const in vec2 randomSeed ) {
      float ANGLE_STEP = PI2 * float( NUM_RINGS ) / float( NUM_SAMPLES );
      float INV_NUM_SAMPLES = 1.0 / float( NUM_SAMPLES );// 把样本放在了一个半径为1的圆域内
    
      float angle = rand_2to1( randomSeed ) * PI2;
      float radius = INV_NUM_SAMPLES;
      float radiusStep = radius;
    
      for( int i = 0; i < NUM_SAMPLES; i ++ ) {
        poissonDisk[i] = vec2( cos( angle ), sin( angle ) ) * pow( radius, 0.75 );
        radius += radiusStep;
        angle += ANGLE_STEP;
      }
    }

    也就是说,以下参数我们可以调整:

    • 半径变化指数的选取

    关于作业框架中为什么要用0.75这个数字,我做了一个比较形象的动画,展示了在泊松采样时每个结果坐标与圆心的距离(半径)的指数在0.2到1.1之间的变化,也就是说,当数值取到0.75以上时,基本可以认为数据重心会更偏向于取圆心的位置。下面动画的代码我放在了 附录1.2 中,读者可以自行编译调试。

    上面是一则视频,若您是PDF版本则需要前往网站查看。

    • 绕圈数NUM_RINGS

    NUM_RINGS与NUM_SAMPLES一起用来计算每个采样点之间的角度差ANGLE_STEP。

    此时可以有如下分析:

    如果NUM_RINGS等于NUM_SAMPLES,那么ANGLE_STEP将等于$2π$,这意味着每次迭代中的角度增量都是一个完整的圆,这显然没有意义。如果NUM_RINGS小于NUM_SAMPLES,那么ANGLE_STEP将小于$2π$,这意味着每次迭代中的角度增量都是一个圆的部分。如果NUM_RINGS大于NUM_SAMPLES,那么ANGLE_STEP将大于$2π$,这意味着每次迭代中的角度增量都超过了一个圆,这可能会导致覆盖和重叠。

    所以在这个代码框架中,当我们的采样数固定时(我这里是8),我们就可以采取决策让采样点更加均匀的分布。

    因此理论上,这里NUM_RINGS直接设置为1就可以了。

    上面是一则视频,若您是PDF版本则需要前往网站查看。

    当采样点分布均匀的情况下,效果还不错:

    如果采样非常不均匀,比如NUM_RINGS等于NUM_SAMPLES的情况,就会出现比较脏的画面:

    得到这些采样点之后,我们还可以对采样点进行权重分配处理。比如在202的课程上闫老师提到可以根据原始像素的距离设置不同的权重,更远的采样点可能会被赋予较低的权重,但项目中不涉及这部分的代码。

    PCSS

    首先找到Shadow Map中任意一处uv坐标的AVG Blocker Depth。

    float findBlocker(sampler2D shadowMap,vec2 uv,float z_shadingPoint){
      float count=0., depth_sum=0., depthOnShadowMap, is_block;
      vec2 nCoords;
      for(int i=0;i<BLOCKER_SEARCH_NUM_SAMPLES;i++){
        nCoords=uv+BLOKER_SIZE*poissonDisk[i];
    
        depthOnShadowMap=unpack(texture2D(shadowMap,nCoords));
        if(abs(depthOnShadowMap) < EPS)depthOnShadowMap=1.;
        // step函数用于比较两个值。
        is_block=step(depthOnShadowMap,z_shadingPoint-EPS);
        count+=is_block;
        depth_sum+=is_block*depthOnShadowMap;
      }
      if(count<EPS)
        return z_shadingPoint;
      return depth_sum/count;
    }

    三步走,这里不再赘述,跟着理论公式走都不太难。

    image-20230731142003749
    float PCSS(sampler2D shadowMap,vec4 shadingPoint){
      poissonDiskSamples(shadingPoint.xy);
      float z_shadingPoint=shadingPoint.z;
      // STEP 1: avgblocker depth
      float avgblockerdep=findBlocker(shadowMap,shadingPoint.xy,z_shadingPoint);
      if(abs(avgblockerdep - z_shadingPoint) <= EPS) // No Blocker
        return 1.;
    
      // STEP 2: penumbra size
      float dBlocker=avgblockerdep,dReceiver=z_shadingPoint-avgblockerdep;
      float wPenumbra=min(LWIDTH*dReceiver/dBlocker,MAX_PENUMBRA);
    
      // STEP 3: filtering
      float _sum=0.,depthOnShadowMap,vis;
      vec2 nCoords;
      for(int i=0;i<NUM_SAMPLES;i++){
        nCoords=shadingPoint.xy+wPenumbra*poissonDisk[i];
    
        depthOnShadowMap=unpack(texture2D(shadowMap,nCoords));
        if(abs(depthOnShadowMap)<1e-5)depthOnShadowMap=1.;
    
        vis=step(z_shadingPoint-EPS,depthOnShadowMap);
        _sum+=vis;
      }
    
      return _sum/float(NUM_SAMPLES);
    }

    框架部分解析

    这一部分属于是在我随便翻阅代码的时候写下的注释,在这里稍微整理了一下。

    loadShader.js

    虽然这个文件中两个函数都是加载glsl文件,但是后者的getShaderString(filename)函数更加简洁高级。这主要体现在前者返回的是Promise对象,后者直接返回文件内容。关于Promise的内容可以看本文的 附录1.3 – JS的Promise简单用法 ,关于async await的内容可以看本文 附录1.4 – async await介绍,关于.then()的用法可以查看 附录1.5 – 关于.then

    专业一点的说法就是,这两个函数提供了不同级别的抽象。前者提供了直接加载文件的原子级别能力,拥有更细粒度的控制,而后者更加简洁与方便。

    添加物体平移效果

    控制器添加到GUI上

    每一帧都要计算阴影的消耗是很大的,这里我手动创建光源控制器,手动调节是否需要每一帧都计算一次阴影。此外,当Light Moveable取消勾选的时候禁止用户改变光源位置:

    勾选上Light Moveable后,出现lightPos选项框:

    具体代码实现:

    // engine.js
    // Add lights
    // light - is open shadow map == true
    let lightPos = [0, 80, 80];
    let focalPoint = [0, 0, 0]; // 定向光聚焦方向(起点是lightPos)
    let lightUp = [0, 1, 0]
    const lightGUI = {// 光源移动控制器,如果不勾选,则不会重新计算阴影。
        LightMoveable: false,
        lightPos: lightPos
    };
    ...
    function createGUI() {
        const gui = new dat.gui.GUI();
        const panelModel = gui.addFolder('Light properties');
        const panelCamera = gui.addFolder("OBJ properties");
        const lightMoveableController = panelModel.add(lightGUI, 'LightMoveable').name("Light Moveable");
        const arrayFolder = panelModel.addFolder('lightPos');
        arrayFolder.add(lightGUI.lightPos, '0').min(-10).max( 10).step(1).name("light Pos X");
        arrayFolder.add(lightGUI.lightPos, '1').min( 70).max( 90).step(1).name("light Pos Y");
        arrayFolder.add(lightGUI.lightPos, '2').min( 70).max( 90).step(1).name("light Pos Z");
        arrayFolder.domElement.style.display = lightGUI.LightMoveable ? '' : 'none';
        lightMoveableController.onChange(function(value) {
            arrayFolder.domElement.style.display = value ? '' : 'none';
        });
    }

    附录1.1

    import numpy as np
    import matplotlib.pyplot as plt
    
    def simulate_poisson_disk_samples(random_seed, num_samples=100, num_rings=2):
        PI2 = 2 * np.pi
        ANGLE_STEP = PI2 * num_rings / num_samples
        INV_NUM_SAMPLES = 1.0 / num_samples
    
        # Initial angle and radius
        angle = random_seed * PI2
        radius = INV_NUM_SAMPLES
        radius_step = radius
    
        x_vals = []
        y_vals = []
    
        for _ in range(num_samples):
            x = np.cos(angle) * pow(radius, 0.1)
            y = np.sin(angle) * pow(radius, 0.1)
    
            x_vals.append(x)
            y_vals.append(y)
    
            radius += radius_step
            angle += ANGLE_STEP
    
        return x_vals, y_vals
    
    plt.figure(figsize=(8, 8))
    
    # Generate and plot the spiral 5 times with different random seeds
    for _ in range(50):
        random_seed = np.random.rand()
        x_vals, y_vals = simulate_poisson_disk_samples(random_seed)
        plt.plot(x_vals, y_vals, '-o', markersize=5, linewidth=2)
    
    plt.title("Poisson Disk Samples")
    plt.axis('on')
    plt.gca().set_aspect('equal', adjustable='box')
    plt.show()

    附录1.2 – 泊松采样点后处理动画代码

    说明:附录1.2 的代码直接基于 附录1.1 修改而成。

    import numpy as np
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation
    
    def simulate_poisson_disk_samples_with_exponent(random_seed, exponent, num_samples=100, num_rings=2):
        PI2 = 2 * np.pi
        ANGLE_STEP = PI2 * num_rings / num_samples
        INV_NUM_SAMPLES = 1.0 / num_samples
    
        angle = random_seed * PI2
        radius = INV_NUM_SAMPLES
        radius_step = radius
    
        x_vals = []
        y_vals = []
    
        for _ in range(num_samples):
            x = np.cos(angle) * pow(radius, exponent)
            y = np.sin(angle) * pow(radius, exponent)
            x_vals.append(x)
            y_vals.append(y)
            radius += radius_step
            angle += ANGLE_STEP
    
        return x_vals, y_vals
    
    fig, ax = plt.subplots(figsize=(8, 8))
    ax.axis('on')
    ax.set_xlim(-1, 1)
    ax.set_ylim(-1, 1)
    ax.set_aspect('equal', adjustable='box')
    
    lines = [ax.plot([], [], '-o', markersize=5, linewidth=2)[0] for _ in range(50)]
    exponent = 0.2
    
    def init():
        for line in lines:
            line.set_data([], [])
        return lines
    
    def update(frame):
        global exponent
        exponent += 0.005  # Increment to adjust the exponent
        for line in lines:
            random_seed = np.random.rand()
            x_vals, y_vals = simulate_poisson_disk_samples_with_exponent(random_seed, exponent)
            # plt.title(exponent +"Poisson Disk Samples")
            line.set_data(x_vals, y_vals)
        plt.title(f"{exponent:.3f} Poisson Disk Samples")
        return lines
    
    ani = FuncAnimation(fig, update, frames=180, init_func=init, blit=False)
    
    ani.save('animation.mp4', writer='ffmpeg', fps=12)
    
    # plt.show()

    附录1.3 – JS的Promise简单用法

    关于Promise的用法这里给出一个例子:

    function delay(milliseconds) {
        return new Promise(function(resolve, reject) {
            if (milliseconds < 0) {
                reject('Delay time cannot be negative!');
            } else {
                setTimeout(function() {
                    resolve('Waited for ' + milliseconds + ' milliseconds!');
                }, milliseconds);
            }
        });
    }
    
    // 使用示例
    delay(2000).then(function(message) {
        console.log(message);  // 两秒后输出:"Waited for 2000 milliseconds!"
    }).catch(function(error) {
        console.log('Error: ' + error);
    });
    
    // 错误示例
    delay(-1000).then(function(message) {
        console.log(message);
    }).catch(function(error) {
        console.log('Error: ' + error);  // 立即输出:"Error: Delay time cannot be negative!"
    });

    使用Promise的固定操作是写一个Promise构造函数,这个函数有两个参数(参数也是一个函数):resolve和reject。这样可以构建错误处理的分支,比如在这个案例中,输入的内容不满足需求,则可以调用reject进入拒绝Promise分支。

    比方说现在进入reject分支,reject(XXX)中的XXX就传到了下面then(function(XXX))的XXX中。

    总结一下,Promise是JS中的一个对象,核心价值在于它提供了一种非常优雅统一的方式处理异步操作与链式操作,另外还提供了错误处理的功能。

    1. 通过Promise的.then()方法,你可以确保一个异步操作完成后再执行另一个异步操作。
    2. 通过 .catch() 方法可以处理错误,不需要为每个异步回调设置错误处理。

    附录1.4 – async/await

    async/await是ES8引入的feature,旨在化简使用Promise的步骤。

    直接看例子:

    async function asyncFunction() {
        return "Hello from async function!";
    }
    
    asyncFunction().then(result => console.log(result));  // 输出:Hello from async function!

    函数加上了async之后,会隐式的返回一个Promise对象。

    await 关键字只能在 async 函数内部使用。它会“暂停”函数的执行,直到 Promise 完成(解决或拒绝)。另外你也可以用try/catch捕获reject。

    async function handleAsyncOperation() {
        try {
            const result = await maybeFails();// 
            console.log(result);// 如果 Promise 被解决,这里将输出 "Success!"
        } catch (error) {
            console.error('An error occurred:', error);// 如果 Promise 被拒绝,这里将输出 "An error occurred: Failure!"
        }
    }

    这里的”暂停”指的是暂停了该特定的异步函数,而不是整个应用或JavaScript的事件循环。

    以下是关于await如何工作的简化说明:

    1. 当执行到await关键字时,该异步函数的执行暂停。
    2. 控制权返回给事件循环,允许其他代码(如其他的函数、事件回调等)在当前异步函数之后立即运行。
    3. 一旦await后面的Promise解决(fulfilled)或拒绝(rejected),原先暂停的异步函数继续执行,从暂停的位置恢复,并处理Promise的结果。

    也就是说,虽然你的特定的async函数在逻辑上”暂停”了,JavaScript的主线程并没有被阻塞。其他的事件和函数仍然可以在后台执行。

    举一个例子:

    console.log('Start');
    
    async function demo() {
        console.log('Before await');
        await new Promise(resolve => setTimeout(resolve, 2000));
        console.log('After await');
    }
    
    demo();
    
    console.log('End');

    输出将是:

    Start Before await End (wait for 2 seconds) After await

    希望以上解释可以帮助你理解JS的异步机制。欢迎在评论区讨论,我会尽可能立即回复您。

    附录1.5 关于.then()

    .then() 是在 Promise 对象上定义的,用于处理 Promise 的结果。当你调用 .then(),它不会立即执行,而是在 Promise 解决 (fulfilled) 或拒绝 (rejected) 后执行。

    .then() 的关键点:

    1. 非阻塞:当你调用 .then() 时,代码不会暂停等待 Promise 完成。相反,它会立即返回,并在 Promise 完成时执行 then 里的回调。
    2. 返回新的 Promise:.then() 总是返回一个新的 Promise。这允许你进行链式调用,即一系列的 .then() 调用,每个调用处理前一个 Promise 的结果。
    3. 异步回调:当原始 Promise 解决或拒绝时,.then() 里的回调函数是异步执行的。这意味着它们在事件循环的微任务队列中排队,而不是立即执行。

    举个例子:

    console.log('Start');
    
    const promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('Promise resolved');
        }, 2000);
    });
    
    promise.then(result => {
        console.log(result);
    });
    
    console.log('End');

    输出会是:

    Start End (wait for 2 seconds) Promise resolved

    附录1.6 – 片段着色器:Uniforms/Textures

    https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-fundamentals.html

    Uniforms 全局变量

    全局变量在一次绘制过程中传递给着色器的值都一样,在下面的一个简单的例子中, 用全局变量给顶点着色器添加了一个偏移量:

    attribute vec4 a_position;uniform vec4 u_offset; void main() {   gl_Position = a_position + u_offset;}

    现在可以把所有顶点偏移一个固定值,首先在初始化时找到全局变量的地址

    var offsetLoc = gl.getUniformLocation(someProgram, "u_offset");

    然后在绘制前设置全局变量

    gl.uniform4fv(offsetLoc, [1, 0, 0, 0]);  // 向右偏移一半屏幕宽度

    要注意的是全局变量属于单个着色程序,如果多个着色程序有同名全局变量,需要找到每个全局变量并设置自己的值。

    Textures 纹理

    在着色器中获取纹理信息,可以先创建一个sampler2D类型全局变量,然后用GLSL方法texture2D 从纹理中提取信息。

    precision mediump float; 
    uniform sampler2D u_texture; 
    void main() {   
        vec2 texcoord = vec2(0.5, 0.5);  // 获取纹理中心的值   
        gl_FragColor = texture2D(u_texture, texcoord);
    }

    从纹理中获取的数据取决于很多设置。 至少要创建并给纹理填充数据,例如

    var tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    var level = 0;
    var width = 2;
    var height = 1;
    var data = new Uint8Array([
       255, 0, 0, 255,   // 一个红色的像素
       0, 255, 0, 255,   // 一个绿色的像素
    ]);
    gl.texImage2D(gl.TEXTURE_2D, level, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

    在初始化时找到全局变量的地址

    var someSamplerLoc = gl.getUniformLocation(someProgram, "u_texture");

    在渲染的时候WebGL要求纹理必须绑定到一个纹理单元上

    var unit = 5;  // 挑选一个纹理单元
    gl.activeTexture(gl.TEXTURE0 + unit);
    gl.bindTexture(gl.TEXTURE_2D, tex);

    然后告诉着色器你要使用的纹理在那个纹理单元

    gl.uniform1i(someSamplerLoc, unit);

    References

    1. GAMES202
    2. Real-Time Rendering 4th Edition
    3. https://webglfundamentals.org/webgl/lessons/webgl-shaders-and-glsl.html