[{"content":" 本文及其所属的专栏是笔者阅读学习高翔博士视觉 SLAM 十四讲的产物，看书跑代码总是匆匆而过，写点笔记加深印象。本来的打算是按书的节奏依次为每章做总结，但最后觉得这样效率太低且不够连贯，还是凭自己的思路写下去。观前提示，这一部分偏向理论，不可避免地文字会比较多\n0x00 To begin with 这篇文章将会包含以下内容：\n什么是 SLAM 问题 SLAM 问题的数学模型 For reference：\n视觉 SLAM 十四讲视频课程： 【高翔】视觉SLAM十四讲 视觉 SLAM 十四讲配套代码仓库： gaoxiang12/slambook2: edition 2 of the slambook 个人学习代码仓库： congyuxiaoyoudao/learn-slambook2: Learn Basic Knowledge on Visual SLAM: From Theory to Practice 2nd edition by Xiang Gao 0x01 何为 SLAM？ What is SLAM 认识任何一个事物或者技术总是可以从两个角度出发：它是什么以及它能做什么？\nSLAM，全称为 Simultaneous Localization and Mapping，翻译过来就是同步定位与建图。它要求携带传感器的主体（例如运动机器人），在环境信息未知的情况下，通过自身在环境中的运动同步估计运动过程并建立环境的模型。这样来看，SLAM 的核心问题有二：\n定位（Localization）：携带传感器的主体需要通过传感器感知环境信息判断自身的状态（位姿）； 建图（Mapping）：主体基于自身连续轨迹上的观测数据，逐步建立环境地图； 此外，这两个问题间还有一个实时性的要求，即 Simultaneous（同步），同步地完成定位与建图，而非先进行信息采集再将数据传送回去做全局的估计和优化（那样耗时和局限就比较大，也就没意义了）。\n在 SLAM 问题中，传感器作为运动主体感知环境的唯一途径，承担了从环境中获取观测数据的作用。传感器的不同意味着观测数据不同，也将 SLAM 问题分为不同类别，常见的如以相机为主要传感器的视觉 SLAM（Visual SLAM）和以激光雷达为主要传感器的激光 SLAM（LiDAR SLAM）等。在视觉 SLAM 的背景下，SLAM 问题就变成了从一系列连续图像中估计相机位姿，并基于图像特征等信息构建环境地图的过程。\n对人类来说，这个过程是很自然的：置身于陌生环境中时，我们可以很容易地四处观察，理解环境的构成，然后在脑中建立环境的概念模型。但对计算机来说则不然，单是提取图像中蕴含的丰富语义信息这一任务就极具挑战，尤其考虑到图像在计算机中仅仅是一系列数值矩阵。幸运的是，随着数十年来的研究进展，SLAM 技术已渐趋成熟：一方面，依靠人工智能的机器学习、深度学习中数值驱动的方法，计算机能够从图像中识别特征、分辨物体；另一方面，硬件的进步带来了更为先进的传感器，例如能够直接估计场景深度的 RGB-D 相机，和高精度的 3D 激光雷达。这些先进的传感器提供了更为丰富、精确的环境信息，为在复杂环境中的定位和建图提高了准确性和鲁棒性。\nSLAM 技术最早可以追溯到上世纪 90 年代，为机器人学的一个概念，随着 30 多年的发展，演变为机器人学和计算机视觉中的热门研究领域。如今的 SLAM 技术早已迈出大学的实验室，走向了工业界的实际应用：通过 SLAM 技术构建的环境地图，可以支撑很多的下游任务，例如三维重建、路径规划、自动驾驶、场景标注与理解、VR \u0026amp; AR 等等。下至家中的扫地机器人，上至太空中的天体探测车，背后都有 SLAM 的身影。\n0x02 问题建立 Problem Setup 高翔博士的 SLAM 十四讲是以视觉 SLAM 为前提的，因此这里的问题模型也是基于视觉 SLAM\n要让机器人能够解决上述提及的两个核心问题，它需要首先知道一些信息：\n自身的初始状态与随时间的运动过程 一些时刻一些位置的某些观测信息 前者可以通过设定的初始值和运动传感器每个时刻的读数估计，后者可以通过相机传感器的观测数据获得。现在假设机器人在一个未知环境中运动，相机在离散时刻 $t=1,\\dots,K\\in \\mathcal{T}$ 采集环境数据，用 $\\boldsymbol{x}$ 表示机器人的位姿，那么这些时刻的位姿为 $\\boldsymbol{x}_{1},\\dots,\\boldsymbol{x}_{K}$。运动传感器在这些时刻会产生输入数据 $\\boldsymbol{u}_{1},\\dots \\boldsymbol{u}_{K}$，相机传感器在这些时刻也可能观测到某些路标（视觉 SLAM 中可以理解为图像的特征点，假设一共有 $N$ 个路标 $\\boldsymbol{y}_{1},\\dots,\\boldsymbol{y}_{N}$）。为了更好地描述这两个过程，我们可以将它们抽象地建模为两个方程：\n$$ \\begin{cases} \\boldsymbol{x}_{k}=f(\\boldsymbol{x}_{k-1},\\boldsymbol{u}_{k})+\\boldsymbol{w}_{k}\u0026 k=1,\\dots,K \\\\ \\boldsymbol{z}_{k,j}=h(\\boldsymbol{y}_{j},\\boldsymbol{x}_{k})+\\boldsymbol{v}_{k,j}\u0026 (k,j)\\in \\mathcal{O} \\end{cases} \\tag{2.1} $$ 第一个方程中，$\\boldsymbol{x}_{k}$ 为 $k$ 时刻的位姿，$\\boldsymbol{u}_{k}$ 为 $k$ 时刻的运动传感器输入，$\\boldsymbol{w}_{k}$ 为 $k$ 时刻的噪声（传感器不可能完美地反馈真实的运动情况），$f$ 为描述从 $\\boldsymbol{x}_{k-1},\\boldsymbol{u}_{k},\\boldsymbol{w}_{k}$ 中得到 $\\boldsymbol{x}_{k}$ 的一般函数；第二个方程中，$\\boldsymbol{z}_{k,j}$ 表示视觉传感器在 $k$ 时刻观测到了路标点 $\\boldsymbol{y}_{j}$ 产生的观测数据，$\\mathcal{O}$ 是一个集合，描述在哪些时刻观测到了哪些数据，$\\boldsymbol{v}_{k,j},h$ 同理为该过程中的噪声和函数。至于为何不解析地表达这些函数，是因为不同的传感器产生的数据形式不同，从而有不同的参数化方式，这里使用一个抽象的表达，可以保持通用性~~（多态？）~~。\n有了这两个方程，我们可以将 SLAM 问题描述为已知运动传感器读数 $\\boldsymbol{u}$ 和观测数据 $\\boldsymbol{z}$ 时，估计 $\\boldsymbol{x}$ 和 $\\boldsymbol{y}$ 的条件分布——这是一个状态估计问题：\n$$ P(\\boldsymbol{x},\\boldsymbol{y}|\\boldsymbol{z},\\boldsymbol{u}) \\tag{2.2} $$ 它是一个后验概率，描述在当前所有可用信息下，对轨迹和地图的最佳一致性估计。直接求解后验概率是分布的，状态空间的维度太高，但利用贝叶斯法则，可以将其转化为一个正比于似然和先验概率的乘积的分布：\n$$ P(\\boldsymbol{x},\\boldsymbol{y}|\\boldsymbol{z},\\boldsymbol{u})=\\frac{P(\\boldsymbol{z},\\boldsymbol{u}|\\boldsymbol{x},\\boldsymbol{y})P(\\boldsymbol{x},\\boldsymbol{y})}{P(\\boldsymbol{z},\\boldsymbol{u})}\\propto \\underbrace{P(\\boldsymbol{z},\\boldsymbol{u}|\\boldsymbol{x},\\boldsymbol{y})}_{\\text{Likehood}}\\,\\underbrace{P(\\boldsymbol{x},\\boldsymbol{y})}_{\\text{Prior}}\\tag{2.3} $$ 其中似然描述了在给定的位姿下可能产生怎样的观测数据，先验概率描述了观测前对机器人位姿和场景的了解。分母 $P(\\boldsymbol{z},\\boldsymbol{u})$ 是归一化因子，用于保证概率分布和为 1，这里和估计变量无关，所以可以忽略。通过贝叶斯法则，我们不直接求后验分布，而是退而求其次，找一个最可能的 $\\boldsymbol{x},\\boldsymbol{y}$ 状态代表解，也就是做最大后验估计（Maximum A Posteriori Estimation，MAP）：\n$$ (\\boldsymbol{x},\\boldsymbol{y})^{\\ast}_{\\text{MAP}}=\\arg \\max P(\\boldsymbol{x},\\boldsymbol{y}|\\boldsymbol{z},\\boldsymbol{u})=\\underbrace{P(\\boldsymbol{z},\\boldsymbol{u}|\\boldsymbol{x},\\boldsymbol{y})}_{\\text{Likehood}}\\,\\underbrace{P(\\boldsymbol{x},\\boldsymbol{y})}_{\\text{Prior}} \\tag{2.4} $$ 在未知环境中，通常也没有 $\\boldsymbol{x},\\boldsymbol{y}$ 的全局先验信息，这样最后求解状态估计问题就可以转化为做最大似然估计（Maximum Likelihood Estimation，MLE）：\n$$ (\\boldsymbol{x},\\boldsymbol{y})^{\\ast}_{\\text{MLE}}=\\arg \\max P(\\boldsymbol{z},\\boldsymbol{u}|\\boldsymbol{x},\\boldsymbol{y}) \\tag{2.5} $$ 也就是 $\\boldsymbol{x},\\boldsymbol{y}$ 在怎样的状态下最容易产生现有的观测数据。\n对于这个联合分布似然，假设给定轨迹 $\\boldsymbol{x}$，运动输入 $\\boldsymbol{u}$ 独立于 $\\boldsymbol{z},\\boldsymbol{y}$，我们可以将其拆分为观测模型和运动模型：\n$$ P(\\boldsymbol{z},\\boldsymbol{u}|\\boldsymbol{x},\\boldsymbol{y})=P(\\boldsymbol{z}|\\boldsymbol{x},\\boldsymbol{y})P(\\boldsymbol{u}|\\boldsymbol{z},\\boldsymbol{x},\\boldsymbol{y})=\\underbrace{P(\\boldsymbol{z}|\\boldsymbol{x},\\boldsymbol{y})}_{\\text{Observation}}\\,\\underbrace{P(\\boldsymbol{u}|\\boldsymbol{x})}_{\\text{Action}}\\tag{2.6} $$ 进一步假设各时刻的运动输入间、各个观测间是独立的，那么可以将两个模型分解为各个观测和输入分布的乘积：\n$$ P(\\boldsymbol{z}|\\boldsymbol{x},\\boldsymbol{y})P(\\boldsymbol{u}|\\boldsymbol{x})=\\prod_{k,j}P(\\boldsymbol{z}_{k,j}|\\boldsymbol{x}_{k},\\boldsymbol{y}_{j})\\prod_{k}P(\\boldsymbol{u}_{k}|\\boldsymbol{x}_{k-1},\\boldsymbol{x}_{k})\\tag{2.7} $$ 回顾式 $(2.5)$，最大化似然就相当于最大化上式的乘积，实践中通常假设各个分布为高斯分布，然后使用最小化负对数似然（Minimize Negative Log-Likelihood）来求最大似然。\n对于某个观测数据的观测方程 $\\boldsymbol{z}_{k,j}=h(\\boldsymbol{y}_{j},\\boldsymbol{x}_{k})+\\boldsymbol{v}_{k,j}$，假设噪声项服从一个高斯分布 $\\boldsymbol{v}_{k,j}\\sim \\mathcal{N}(0,\\boldsymbol{Q}_{k,j})$，那么观测数据条件概率为：\n$$ P(\\boldsymbol{z}_{k,j}|\\boldsymbol{x}_{k},\\boldsymbol{y}_{j})=N(h(\\boldsymbol{y}_{j},\\boldsymbol{x}_{k}),\\boldsymbol{Q}_{k,j})\\tag{2.8} $$ 给定 $\\boldsymbol{x}_{k},\\boldsymbol{y}_{j}$，理想观测数据为 $h(\\boldsymbol{y}_{j},\\boldsymbol{x}_{k})$，但由于噪声存在，真实观测的条件概率也为一个以 $h(\\boldsymbol{y}_{j},\\boldsymbol{x}_{k})$ 为均值，$\\boldsymbol{Q}_{k,j}$ 为协方差的高斯分布。同理假设运动数据的噪声项 $\\boldsymbol{w}_{k}\\sim \\mathcal{N}(0,\\boldsymbol{R}_{k})$，那么运动数据也服从高斯分布，其条件概率为：\n$$ P(\\boldsymbol{u}_{k}|\\boldsymbol{x}_{k-1},\\boldsymbol{x}_{k})=N(g(\\boldsymbol{x}_{k-1},\\boldsymbol{x}_{k}),\\boldsymbol{R}_{k})\\tag{2.9} $$ 高斯分布的概率密度函数为：\n$$ P(x)=\\frac{1}{\\sqrt{ (2\\pi)^N \\det(\\Sigma)}}\\exp\\left( -\\frac{1}{2}(x-\\mu)^\\top \\Sigma^{-1}(x-\\mu) \\right)\\tag{2.10} $$ 取负对数：\n$$ -\\ln(P(x))=\\frac{1}{2}\\ln \\left((2\\pi)^N\\det(\\Sigma)\\right)+\\frac{1}{2}(x-\\mu)^\\top \\Sigma^{-1}(x-\\mu)\\tag{2.11} $$ 我们想要最小化上式，而第一项（其中 $N$ 为 $x$ 的维度）与变量无关，可以忽略，所以最终只要最小化右侧的二次型：\n$$ x^{\\ast}_{\\text{MLE}}=\\arg \\max P(x)=\\arg \\min (x-\\mu)^\\top \\Sigma^{-1}(x-\\mu)\\tag{2.11} $$ 最后代入 SLAM 的观测模型和运动模型，定义每个估计的观测数据和运动数据与真实结果（Ground Truth）的残差项：\n$$ \\begin{aligned} \\boldsymbol{e}_{\\boldsymbol{z},j,k}\u0026=\\boldsymbol{z}_{k,j}-h(\\boldsymbol{y}_{j},\\boldsymbol{x}_{k})\\\\ \\boldsymbol{e}_{\\boldsymbol{u},k}\u0026=\\boldsymbol{x}_{k}-f(\\boldsymbol{x}_{k-1},\\boldsymbol{u}_{k}) \\end{aligned}\\tag{2.12} $$ 代入式 $(2.11)$，得到一个数据的最优估计，结合式 $(2.7)$，由于对数的性质将乘积转为求和：\n$$ \\min J(\\boldsymbol{x},\\boldsymbol{y})=\\sum_{k}\\sum_{j}\\boldsymbol{e}_{\\boldsymbol{z},j,k}^\\top\\boldsymbol{Q}_{k,j}^{-1}\\boldsymbol{e}_{\\boldsymbol{z},j,k}+\\sum_{k}\\boldsymbol{e}_{\\boldsymbol{u},k}^\\top\\boldsymbol{R}_{k}^{-1}\\boldsymbol{e}_{\\boldsymbol{u},k}\\tag{2.13} $$ 可以看到，最终得到了一个最小二乘问题。该问题的目标函数是由多个误差项的加权求和组成，虽然整体状态空间的维度很高，但对于每一个残差项来说是简单的，仅与少量有限个状态变量有关，这使得求解具备了稀疏性，可以使用稀疏优化方法高效求解。\n0x03 Afterwords 本篇文章中笔者仅将 SLAM 问题使用一个抽象的数学模型描述，而没有讨论具体的一些实现框架和算法细节，本篇也旨在让读者对 SLAM 有大致的了解，至少明白它是什么、能解决什么问题、有怎样的应用领域。在之后的文章中，会逐步介绍 SLAM 主流求解框架以及它的各个模块的主要算法。\nEnjoy it!\n","permalink":"https://congyuxiaoyoudao.github.io/posts/slam-biography/problem-formulation-and-model-construction/","summary":"\u003cblockquote\u003e\n\u003cp\u003e本文及其所属的专栏是笔者阅读学习高翔博士视觉 SLAM 十四讲的产物，看书跑代码总是匆匆而过，写点笔记加深印象。本来的打算是按书的节奏依次为每章做总结，但最后觉得这样效率太低且不够连贯，还是凭自己的思路写下去。观前提示，这一部分偏向理论，不可避免地文字会比较多\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 什么是 SLAM 问题\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e SLAM 问题的数学模型\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e视觉 SLAM 十四讲视频课程：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV16t411g7FR\"\u003e【高翔】视觉SLAM十四讲\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e视觉 SLAM 十四讲配套代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/gaoxiang12/slambook2\"\u003egaoxiang12/slambook2: edition 2 of the slambook\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e个人学习代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/congyuxiaoyoudao/learn-slambook2\"\u003econgyuxiaoyoudao/learn-slambook2: Learn Basic Knowledge on Visual SLAM: From Theory to Practice 2nd edition by Xiang Gao\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-何为-slam-what-is-slam\"\u003e0x01 何为 SLAM？ What is SLAM\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003e认识任何一个事物或者技术总是可以从两个角度出发：它是什么以及它能做什么？\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eSLAM，全称为 Simultaneous Localization and Mapping，翻译过来就是同步定位与建图。它要求携带传感器的主体（例如运动机器人），在环境信息未知的情况下，通过自身在环境中的运动同步估计运动过程并建立环境的模型。这样来看，SLAM 的核心问题有二：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e定位（Localization）：携带传感器的主体需要通过传感器感知环境信息判断自身的状态（位姿）；\u003c/li\u003e\n\u003cli\u003e建图（Mapping）：主体基于自身连续轨迹上的观测数据，逐步建立环境地图；\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e此外，这两个问题间还有一个实时性的要求，即 Simultaneous（同步），同步地完成定位与建图，而非先进行信息采集再将数据传送回去做全局的估计和优化（那样耗时和局限就比较大，也就没意义了）。\u003c/p\u003e\n\u003cp\u003e在 SLAM 问题中，传感器作为运动主体感知环境的唯一途径，承担了从环境中获取观测数据的作用。传感器的不同意味着观测数据不同，也将 SLAM 问题分为不同类别，常见的如以相机为主要传感器的视觉 SLAM（Visual SLAM）和以激光雷达为主要传感器的激光 SLAM（LiDAR SLAM）等。在视觉 SLAM 的背景下，SLAM 问题就变成了从一系列连续图像中估计相机位姿，并基于图像特征等信息构建环境地图的过程。\u003c/p\u003e","title":"SLAM Biography I · Problem Formulation and Model Construction"},{"content":" 0x00 To begin with 这篇文章将会包含以下内容：\n泊松重建的一些前置数学知识 泊松重建算法原理及推导过程 For reference：\n📺 Laplace\u0026rsquo;s Equation and Poisson\u0026rsquo;s Equation 📖 如何通俗地理解拉普拉斯方程、泊松方程、亥姆霍兹方程？ 📖 泊松表面重建详解 📃 Poisson Surface Reconstruction 0x01 隐函数表达 Implicit Expression 表达一个三维曲面，例如球心位于原点、半径为 $r$ 的球面，可以使用球的标准方程：\n$$ F(x,y,z):x^{2}+y^{2}+z^{2}-r^{2}=0 $$ 该方程（形如 $F(x,y,z)=0$）就定义了球面的隐函数表达，它将所有变量都放在等式一侧，隐含了因变量关于自变量的函数关系，在描述球面的情况下，$z$ 关于 $x,y$ 的关系可以显化为： $$ z=\\sqrt{ r^{2}-x^{2}-y^{2} } $$ 这就是隐函数的一种显式表达，它描述球面在 $xOy$ 平面以上的部分。如果需要描述整个球面，还需要 $xOy$ 平面以下的部分： $$ z=-\\sqrt{ r^{2}-x^{2}-y^{2} } $$ 也就是说显化的隐函数可能无法覆盖所有满足隐函数方程的解集，因为显函数是一一映射，而隐函数可能是一对多或者多对多的映射。 在计算机图形学中，SDF 就是一种常用的可以描述表面的隐函数表达：\n$$ \\phi(\\mathbf{x})=\\pm \\text{dist}(\\mathbf{x},\\partial \\Omega) $$ SDF 以物体表面为界，空间中任意一点 $\\mathbf{x}$ 的 SDF 函数值为与物体表面的带符号距离（物体内部为负、外部为正）。自然的，物体表面可以用 SDF 值为零的水平集描述： $$ \\{\\mathbf{x}\\,|\\,\\phi(\\mathbf{x})=0\\} $$ ![使用 SDF 的零水平集描述表面，Jeong et.al](https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%205.PoissonRecon/202508201058142.png#center) 0x02 泊松方程 Poisson Equation 泊松表面重建其实不需要对泊松方程有什么很深刻的数学物理了解，笔者也非相关专业，所以就仅凭自己的想法写一点更为直观的解释\n泊松方程由法国数学家泊松在对势的拉普拉斯方程作二阶修正时提出，今天更为人熟知的一般形式如下：\n$$ \\nabla^{2}\\phi=-f,\\;\\phi:\\Omega\\to \\mathbb{R} $$ 其中： $\\phi$ 为定义在空间 $\\Omega$ 上描述标量场（如温度、电势）的未知函数 $\\nabla^{2}$ 为拉普拉斯算子（也可以表示为 $\\Delta$），表示对函数在空间 $\\Omega$ 内的二阶导数求和，该算子相当于梯度的散度 $\\nabla^{2}=\\nabla \\cdot \\nabla$ $f$ 为一个已知函数，描述场量的分布（如热源强度、电荷密度），称为源项 总体来说泊松方程描述的是有源情况下源项如何影响场量的分布（热源影响温度、电荷密度影响电势），而求解泊松方程，就是在给定源项 $f$ 的条件下找到满足等式的 $\\phi$。\n为了获得期望的解，需要给定一个边界条件 $\\phi|_{\\partial\\Omega}$，其描述了场量函数 $\\phi$ 在空间 $\\Omega$ 内由于某种条件约束而产生的边界 $\\partial \\Omega$，通常有两类边界条件：\nDirichlet （狄利克雷）边界：$\\phi|_{\\partial \\Omega}=g$，边界的场量已知，比如边界温度已知，边界满足：$\\forall p \\in \\partial \\Omega,\\;\\phi|_{\\partial \\Omega}(p)=g(p)$ Neumann （纽曼）边界：$\\frac{\\partial\\phi}{\\partial n}|_{\\partial \\Omega}=h$，边界上沿法向的净流出量已知，比如边界热通量已知，边界满足：$\\forall p \\in \\partial \\Omega,\\;\\frac{\\partial\\phi}{\\partial n}|_{\\partial \\Omega}(p)=h(p)$ 从更加数学物理的角度看，泊松方程可以对应一个能量泛函：\n$$ E[\\phi]=\\frac{1}{2} \\int_{\\Omega}|\\nabla\\phi|^{2}d\\Omega-\\int_{\\Omega}f\\phi d\\Omega $$ 求解泊松方程相当于让泛函 $E[\\phi]$ 对任意小变动 $\\delta{\\phi}$ 的一阶变分为零，计算一阶变分可以得到： $$ \\delta E=\\int_{\\Omega}(-\\Delta\\phi-f)d\\Omega $$ 所以 $-\\Delta \\phi-f=0 \\Leftrightarrow \\nabla^{2}\\phi=-f$，这就将泊松方程的解和能量泛函存在极小值联系起来，求解泊松方程就是求解有源情况下能量的局部极小稳定态。 0x03 泊松表面重建 Poisson Surface Reconstruction 泊松表面重建（Poisson Surface Reconstruction）是一种隐式表面重建方法，对于重建任务，给定待重建物体 $M$，目标是获取其边界 $\\partial M$。输入有向点云（有法向的点集）$S$，可以将 $\\forall s \\in S$ 视为在真实表面 $\\partial M$ 上的均匀采样，每个采样点必须包含 $p$（坐标）和 $\\vec{N}$（指向物体内侧的法向）两个属性。定义指示函数 $\\chi_{M}:\\mathbb{R}^{3}\\to\\{0,1\\}$，描述物体 $M$ 在整个空间中的占用情况，在物体内部指示函数值为 1，否则为 0。 只要能解出这个指示函数，获取边界 $\\partial M$ 也就近在咫尺了。\n那么如何求解这个 $\\chi_{M}$ 呢？我们注意到指示函数在物体表面法向上的不连续性，在这个前提下考虑指示函数的梯度 $\\nabla \\chi_{M}$：\n物体内部 $\\chi_{M}\\equiv1$，所以 $\\nabla \\chi_{M}=0$ 物体外部 $\\chi_{M}\\equiv 0$，所以 $\\nabla \\chi_{M}=0$ 物体表面，$\\chi_{M}$ 从 1 突变到 0，$\\nabla \\chi_{M}$ 的方向为指向物体内部的法线方向，值为无穷大 理想情况下 $\\nabla \\chi_{M}$ 只在 $\\partial M$ 上非零，并且方向与输入点云 $S$ 的法向一致，即在表面附近的采样位置 $s.p$ 上有 $\\nabla \\chi_{M}\\approx\\vec{N}$。为了让指示函数可微以使梯度有意义，我们需要对指示函数梯度场进行平滑，由高斯散度定理，平滑后的 $\\nabla\\tilde{\\chi}_{M}$ 等于平滑表面法向得到的向量场：\n$$ \\nabla(\\chi_{M}\\,*\\,\\tilde{F})(q_{0})=\\int_{\\partial M}\\tilde{F}_{p}(q_{0})\\vec{N}_{\\partial M}(p)dp $$ 其中 $\\tilde{F}$ 为平滑函数，$*$ 为卷积算符，$q$ 为梯度算子作用的求导变量，$q_{0}$ 为当前评估梯度的位置，$p$ 为积分变量。 由于采样得到的样本点的离散性，无法获得物体表面每点 $p$ 附近的法向，因此将积分转为离散求和，定义 $\\mathscr{P}_{s} \\subset \\partial M$ 为样本点 $s$ 附近的邻域表面，$|\\mathscr{P}_{s}|$ 为其面积，且 $\\mathscr{P}_{s_{i}}\\cap\\mathscr{P}_{s_{j}}=\\varnothing,\\;\\cup_{s\\in S}\\mathscr{P}_{s}=\\partial M$，也即 $\\{\\mathscr{P}_{s_{1}},\\mathscr{P}_{s_{2}},\\dots,\\mathscr{P}_{s_{n}}\\}$ 为在 $\\partial M$ 上的一个划分。离散化后的平滑操作为：\n$$ \\begin{aligned} \\nabla(\\chi_{M}*\\tilde{F})(q)\u0026= \\sum_{s \\in S}\\int_{\\mathscr{P}_{s}}\\tilde{F}_{p}(q)\\vec{N}_{\\partial M}(p)dp\\\\ \u0026\\approx \\sum_{s\\in S}|\\mathscr{P}_{s}|\\tilde{F}_{s.p}(q)s.\\vec{N}\\equiv\\vec{V}(q) \\end{aligned} $$ 向量场 $\\vec{V}$ 为平滑后的指示函数梯度，可以通过平滑表面法向得到。所以最终的问题转化为求解： $$ \\nabla \\tilde{\\chi}_{M}=\\vec{V} $$ 直接求解上式需要积分，但 $\\vec{V}$ 往往不可积（比如不一定是无旋场），所以通常不存在精确解。那么就退而求其次，使用最小二乘法求近似解，寻找一个标量场 $\\phi$，使其梯度 $\\nabla \\phi$ 为对向量场 $\\vec{V}$ 的最佳逼近： $$ E(\\phi)=\\min_{\\phi}\\int||\\nabla \\phi-\\vec{V}||^{2}d\\Omega $$ 由变分法可证，取最小值时满足泊松方程： $$ \\Delta\\tilde{\\chi}_{M}=\\nabla \\cdot \\vec{V} $$ 同样的，由于 $\\chi_{M}$ 的离散性质（只在物体内部为 1，其余为 0），在连续空间中无法解析表达，所以考虑使用一系列基函数的线性组合近似表示： $$ \\chi_{M}=\\sum_{i}^n c_{i}F_{i} $$ 为此，必须选取合适的基函数以及其所在的函数空间，一个简单的想法是使用 3D 网格，然而这种均匀的体素结构在需要高分辨率的重建任务中需要的函数维度随立方增长，但重建的表面三角形数量却只是平方增长。由于 $\\tilde{\\chi}_{M}$ 仅在表面 $\\partial M$ 附近有值，所以可以考虑一些更为稀疏的表示，自适应八叉树就是一种不错的选择，在 $\\partial M$ 附近相对于其它位置深度更深。 定义一棵自适应八叉树 $\\mathscr{O}$，其中每个节点 $o\\in \\mathscr{O}$ 都含有一个函数 $F_{o}$，用这棵八叉树和这些函数来离散化拟合 $\\vec{V},\\chi_{M}$。\n八叉树与基函数 Octree and Basis 在采样点集 $S$ 上，给定最大的八叉树深度 $D$，可以定义一棵最小八叉树，使每个采样点 $s$ 落在某个节点内。\n接下来，定义一个固定的、积分为 1 的基函数 $F$，再为每个节点 $o$ 定义经过该节点平移和缩放（即以节点中心为原点，按节点大小缩放）的节点函数 $F_{o}$：\n$$ F_{o}(q)\\equiv F\\left( \\frac{q-o.c}{o.w} \\right) \\frac{1}{{o.w}^{3}} $$ 其中 $o.c,o.w$ 分别为节点 $o$ 的中心位置和宽度。由这些节点函数张成的函数空间 $\\mathscr{F}_{\\mathscr{O},F}\\equiv \\text{Span}\\{F_{o}\\}$ 在表达上具有和传统小波的多尺度分辨率特性，在深层节点上拥有高频的函数表达，这些表达在离表面 $\\partial M$ 越近时越精确。 为了更有效率地表示向量场 $\\vec{V}$，可以把每个采样点的位置替换为其所在叶节点的中心位置，这样每个叶节点 $o$ 只对应一个固定的节点函数 $F_{o}$，每个采样点只对其所在叶节点的节点函数的系数有贡献。通过缩放定义域，可以把 $\\tilde{F}$ 定义在一个固定的参考域内（比如 $[-1,1]$），而不需要考虑实际的节点尺度。\n$$ F(q)=\\tilde{F}\\left( \\frac{q}{2^{D}} \\right) $$ 八叉树最大深度 $D$ 对应采样宽度 $2^{-D}$，平滑函数必须近似一个方差数量级为 $2^{-D}$ 的高斯函数（避免过度平滑或数值不稳定），因此 $F$ 应该近似一个单位方差的的高斯函数。论文中作者使用了一个紧支函数（support 有界）来近似： $$ F(x,y,z)\\equiv(B(x)B(y)B(z))^{*n} \\; \\text{with}\\;B(t)= \\begin{cases} 1 \u0026 |t|\u003c0.5\\\\ 0 \u0026 \\text{otherwise} \\end{cases} $$ 实际上就是盒式滤波做 $n$ 次自卷积，随着 $n$ 增大，$F$ 越来越接近高斯函数并且其 support 范围增加，下图给出了一维情况下盒式滤波函数做 $n$（1-4）次自卷积的形状： 作者最终选择了 $n=3$，此时函数 $F$ 在域 $[-1.5,1.5]^{3}$ 内有 support，对于八叉树的任意一个节点，其节点函数 $F_{o}$ 的 support 最多跨越 $x,y,z$ 三个主轴正负方向各两个邻居节点（$-1.5\\leq\\frac{q-o.c}{o.w}\\leq 1.5\\Leftrightarrow q\\in[o.c-1.5\\cdot o.w,o.c+1.5\\cdot o.w]$），即最多有 $5^{3}-1=124$ 个同深度的其它节点的 support 与之重叠，这使得求解散度和拉普拉斯具备了稀疏性。\n重写向量场 Vector Field Definition 以上提到的将采样点位置钳制到其所在叶节点的中心位置的方法，显然这样的钳制会造成误差，作者后来使用三线性插值，对与当前采样点 $s$ 最近的八个节点的节点函数值进行插值作为当前采样点的值，最终向量场 $\\vec{V}$ 可以写成所有采样点的和：\n$$ \\vec{V}(q)\\equiv \\sum_{s\\in S}\\sum_{o\\in\\text{Ngbr}_{D}(s)}\\alpha_{o,s}F_{o}(q)s.\\vec{N} $$ 其中，$\\text{Ngbr}_{D}(s)$ 为距离当前采样点位置 $s.p$ 最近的八个节点集合，$\\alpha_{o,s}$ 为节点 $o$ 的三线性插值权重。由于先前的假设基于采样点分布均匀，因此每个 $\\mathscr{P}_{s}$ 的面积 $|\\mathscr{P}_{s}|$ 可以视为常数，总体和平滑后的指示函数梯度只差了个缩放系数，不影响重建质量。 但在采样点分布非均匀的情况下，$|\\mathscr{P}_{s}|$ 不能视为常数，此时可以根据采样密度自适应的调节每个点的贡献以及核函数宽度，为此首先需要估计一个密度场，并指定 $\\hat{D}\\leq D$ 为用于估计密度场的八叉树深度：\n$$ W_{\\hat{D}}(q)\\equiv\\sum_{s\\in S}\\sum_{o\\in\\text{Ngbr}_{\\hat{D}}(s)}\\alpha_{o,s}F_{o}(q) $$ 这种情况下，使用密度场的倒数估计 $|\\mathscr{P}_{s}|$： $$ \\vec{V}(q)\\equiv \\sum_{s\\in S} \\frac{1}{W_{\\hat{D}}(s.p)}\\sum_{o\\in\\text{Ngbr}_{\\text{Depth}(s.p)}(s)}\\alpha_{o,s}F_{o}(q)s.\\vec{N} $$ 其中，$\\text{Depth}(s.p)(s)$ 代表样本点 $s$ 期望的八叉树深度，通过衡量当前位置的采样密度和平均采样密度 $W$ 并设置： $$ \\text{Depth}(s.p)=\\min(D,D+\\log_{4}(W_{\\hat{D}}(s.p)/W) $$ 当 $W_{\\hat{D}}(s.p)\u003eW$，即局部密度大于全局密度时，使用最大的八叉树深度（最高精度），保证细粒度的重建；反之，当 $W_{\\hat{D}}(s.p)","permalink":"https://congyuxiaoyoudao.github.io/posts/interludes/poisson-reconstruction/","summary":"\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 泊松重建的一些前置数学知识\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 泊松重建算法原理及推导过程\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📺 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.youtube.com/watch?v=nmvs0vrBT18\"\u003eLaplace\u0026rsquo;s Equation and Poisson\u0026rsquo;s Equation\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.zhihu.com/question/25481998\"\u003e如何通俗地理解拉普拉斯方程、泊松方程、亥姆霍兹方程？\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://zhuanlan.zhihu.com/p/107653198\"\u003e泊松表面重建详解\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📃 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://hhoppe.com/poissonrecon.pdf\"\u003ePoisson Surface Reconstruction\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-隐函数表达-implicit-expression\"\u003e0x01 隐函数表达 Implicit Expression\u003c/h2\u003e\n\u003cp\u003e表达一个三维曲面，例如球心位于原点、半径为 $r$ 的球面，可以使用球的标准方程：\u003c/p\u003e\n\u003cdiv\u003e$$\nF(x,y,z):x^{2}+y^{2}+z^{2}-r^{2}=0\n$$\u003c/div\u003e\n该方程（形如 $F(x,y,z)=0$）就定义了球面的隐函数表达，它将所有变量都放在等式一侧，隐含了因变量关于自变量的函数关系，在描述球面的情况下，$z$ 关于 $x,y$ 的关系可以显化为：\n\u003cdiv\u003e$$\nz=\\sqrt{ r^{2}-x^{2}-y^{2} }\n$$\u003c/div\u003e\n这就是隐函数的一种显式表达，它描述球面在 $xOy$ 平面以上的部分。如果需要描述整个球面，还需要 $xOy$ 平面以下的部分：\n\u003cdiv\u003e$$\nz=-\\sqrt{ r^{2}-x^{2}-y^{2} }\n$$\u003c/div\u003e\n也就是说显化的隐函数可能无法覆盖所有满足隐函数方程的解集，因为显函数是一一映射，而隐函数可能是一对多或者多对多的映射。\n\u003cp\u003e在计算机图形学中，SDF 就是一种常用的可以描述表面的隐函数表达：\u003c/p\u003e\n\u003cdiv\u003e$$\n\\phi(\\mathbf{x})=\\pm \\text{dist}(\\mathbf{x},\\partial \\Omega)\n$$\u003c/div\u003e\nSDF 以物体表面为界，空间中任意一点 $\\mathbf{x}$ 的 SDF 函数值为与物体表面的带符号距离（物体内部为负、外部为正）。自然的，物体表面可以用 SDF 值为零的水平集描述：\n\u003cdiv\u003e$$\n\\{\\mathbf{x}\\,|\\,\\phi(\\mathbf{x})=0\\}\n$$\u003c/div\u003e\n![使用 SDF 的零水平集描述表面，Jeong et.al](https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%205.PoissonRecon/202508201058142.png#center)\n\u003chr\u003e\n\u003ch2 id=\"0x02-泊松方程-poisson-equation\"\u003e0x02 泊松方程 Poisson Equation\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003e泊松表面重建其实不需要对泊松方程有什么很深刻的数学物理了解，笔者也非相关专业，所以就仅凭自己的想法写一点更为直观的解释\u003c/p\u003e","title":"Interlude 5. Poisson Reconstruction"},{"content":" 3DGS 的原论文中，每个高斯是不存法线信息的，然而后续工作中，也有研究人员将法线和 BRDF 信息进行训练，这样一来，就可以在后续的光栅化过程中输出 GBuffer，完成 relighting 的任务。生成初始法线，可以仅考虑高斯的均值，基于局部平面假设进行估计。\n0x00 To begin with 这篇文章将会包含以下内容：\n什么是 PLY 利用最小二乘法估计点云法线 使用 PCL 的实现 在 CloudCompare 中实现 For reference👇：\n📖 PLY - Polygon File Format 📖 Estimating Surface Normals in a PointCloud 📺 Normal and Curvature Calculation for a Pointcloud 📃 http://mediatum.ub.tum.de/doc/800632/941254.pdf 0x01 PLY 格式 PLY（Polygon File Format, also known as the Stanford Triangle Format），是一种用于存储多边形集合的图形对象的格式。有两种子格式：\nASCII：ASCII 编码表示，直观易读 Binary：二进制格式，存储空间小，能够快速保存和加载 PLY 文件的经典结构如下：\nHeader // description field, containing element type, property and count Vertex List // list of data for each vertex Face List // list of data for each face (List of other elements) Header 描述文件格式，评论（comment）以及每个元素的描述，并在其后指定该元素属性。例如一个以 ASCII 子格式保存的 PLY 文件，其 Header 如下：\n这里的数据不正常是因为中途改了原本二进制格式的编码，似乎导致了解析错误，后文所用的文件是正常的\n在该 PLY 文件中，存储了 24077 个顶点的信息，每个顶点由三个浮点分量 x，y，z 描述位置。在 Header 结束（end_header）后，即列出 Vertex List 的内容。\n此外，PLY 格式允许用户自定义元素内容，例如 3DGS 就专门设计了一个 PLY 格式。\n可以在一些 DCC 软件原生地（如 Blender）或借助插件导入 PLY 文件，也可以使用专业分析点云的软件（如 CloudCompare）。\n0x02 从点云估计法线 在传统的图形学中，每个顶点的法向由与该顶点邻接的面法向按某种形式加权计算得出，因此需要存储 Mesh 的文件至少提供每个顶点的位置数据以及组成各面的顶点索引数据。\n对于点云数据，由于其只是在原始表面上离散采样的一系列点，不存在表面信息，因此无法直接通过由面到点的方式求解顶点法线。然而，可以考虑如下两种思路：\n利用表面网格化技术从点云中构建表面，然后从网格中计算顶点法线 使用近似方法直接从点云中估计法线 第一种方法通过某种表面网格化技术（例如 Marching Cubes）从点云转换的 SDF 中重建表面，然后再使用传统图形学的做法计算顶点法线；第二种方法直接从点云中估计法线，其基于一个 local planar assumption：在足够小的一个局部区域内，点云的原始表面可以被一个平面（或者其它结构）近似。因此，确定表面上某一点的法线的问题就转化为估计与表面相切的平面的法线的问题。\n本篇文章中，将实现第二种方法。\n0x03 最小二乘法估计法线 在高中物理，经常使用最小二乘法用一条直线拟合实验数据，实际上，最小二乘法的思想可以扩展至更高维度，以拟合超平面或更复杂的几何结构\n要拟合一个平面，本质上是一个三元线性回归问题，相应地可以转化为最小二乘法的平面估计问题（least-square plane fitting estimation）。\n基本思路 对于给定点云的局部集合 $\\mathcal{P}^k$（$k$ 为集合中点的个数），其潜在近似描述的平面可以表示为点 $x$ 和面法向 $\\vec{n}$，对于集合中任意点 $p_{i}\\in\\mathcal{P}^k$，其到平面的距离为 $d_{i}=(p_{i}-x)\\cdot \\vec{n}$。最小二乘法的目标是求解 $x,n$，以使所有点到这个平面的距离平方和最小。令 $x$ 为集合点的质心：\n$$ x=\\frac{1}{k}\\cdot \\sum_{i=1}^kp_{i} $$ 求解 $\\vec{n}$ 的过程可以通过分析 $\\mathcal{P}^k$ 的协方差矩阵 $C\\in \\mathbb{R}^{3\\times 3}$：\n$$ C=\\frac{1}{k}\\sum_{i=1}^{k}(p_{i}-\\bar{p})(p_{i}-\\bar{p})^T,~C\\cdot \\vec{v_{j}}=\\lambda_{j}\\cdot \\vec{v_{j}},~j\\in\\{0,1,2\\} $$ 其中，$\\bar{p}=\\frac{1}{k}\\sum_{j=1}^{k}p_{ij}$ ，$\\lambda_{j}\\in \\mathbb{R}$ 为其特征值。定义去中心化的点坐标 $p_{i}'=p_{i}-\\bar{p}=(x_{i}',y_{i}',z_{i}')$，将 $C$ 展开：\n$$ C = \\frac{1}{k} \\sum_{i=1}^{k} \\begin{bmatrix} x_i'^2 \u0026 x_i' y_i' \u0026 x_i' z_i' \\\\ x_i' y_i' \u0026 y_i'^2 \u0026 y_i' z_i' \\\\ x_i' z_i' \u0026 y_i' z_i' \u0026 z_i'^2 \\end{bmatrix} =\\begin{bmatrix} \\sigma_{xx} \u0026 \\sigma_{xy} \u0026 \\sigma_{xz} \\\\ \\sigma_{yx} \u0026 \\sigma_{yy} \u0026 \\sigma_{yz} \\\\ \\sigma_{zx} \u0026 \\sigma_{zy} \u0026 \\sigma_{zz} \\\\ \\end{bmatrix} $$ 可以观察到 $C$ 是一个实对称半正定矩阵，主对角线元素代表集合 $\\mathcal{P}^k$ 在 $x,y,z$ 方向上的方差，非对角元素表示不同坐标轴之间的协方差。根据谱定理，实对称矩阵 $C$ 可被分解为：\n$$ C=Q\\Lambda Q^{T}=Q\\Lambda Q^{-1},~Q=[\\vec{v_{0}},\\vec{v_{1}},\\vec{v_{2}}],~\\Lambda=\\mathrm{diag}(\\lambda_{0}, \\lambda_{1}, \\lambda_{2}) $$ 实对称矩阵，其逆等于其转置\n可以将其视为某个二次型的矩阵，定义二次型函数 $Q(x)$：\n$$ Q(x)=x^{T}C^{-1}x $$ 对 $C$ 做特征分解：\n$$ Q(x)=x^{T}C^{-1}x=(x^T Q)\\Lambda^{-1} (Q^T x) $$ 记 $y=Q^T x$，$y$ 为切线空间下的坐标，原二次型转换为：\n假定特征向量均已经过归一化\n$$ Q(x)=y^{T}\\Lambda^{-1}y=\\sum_{j=1}^{n} \\frac{y_{j}^2}{\\lambda_{j}}=\\frac{y_{0}^{2}}{\\lambda_{0}}+\\frac{y_{1}^{2}}{\\lambda_{1}}+\\frac{y_{2}^{2}}{\\lambda_{2}}=1 $$ 其中，$y_{j}$ 为 $x$ 在协方差矩阵第 $j$ 个特征向量方向的投影。所以在几何角度上，可以把协方差矩阵 $C$ 视为定义了一个拟合点集的椭球体。\n$C$ 的特征向量 $\\vec{v_{j}}$ 构成一个三维正交基（切线空间基），代表集合 $\\mathcal{P}^k$ 的三个主成分（Principle Components）。如果 $C$ 表示的椭球接近一个平面，则集合 $\\mathcal{P}^k$ 在垂直于平面的方向上变化最小，即该方向的方差最小、特征值最小。考虑 $0\\leq \\lambda_{0}\\leq \\lambda_{1}\\leq \\lambda_{2}$，最小的特征值 $\\lambda_{0}$ 对应的特征向量 $\\vec{v_{0}}$ 即为拟合平面的法线。\nK-Neighborhood Selection 如上文所述，计算点云中某点的法线需要选取与该点距离最近的 $k$ 个点（K-Neighborhood）进行估计。因此，$k$ 值的选取对于估计法线的质量有着极大影响：\n较大的 $k$ 尺度会导致邻域点集较大，从而引入额外噪声，导致估计的点法向失真 较小的 $k$ 尺度可以捕捉局部细节，但容易受局部非均匀采样的影响，导致大范围内法线方向不稳定，并且增大开销 个人感觉是采样频率的问题，高频相对低频能够更好地捕捉细节，但也更容易受到噪声的干扰\n一般地，$k$ 值的选取可以让用户凭经验选择阈值，然而固定的阈值无法应对一些复杂（频率、空间分布不均匀）的点云，一种更好地方式是自适应地选取 $k$ 值。根据当前估计点的场景及其所需细节程度选取阈值，例如，在一些高频区域（潜在表面的转角、连接处）使用较小的 $k$，而在其它情况下可以使用较大的 $k$。判断给定点的所需细节程度可以通过分析其密度、曲率等因素。\n翻转法线 PCA 方法估计的法线方向如下图左、中所示，在同一个平面上出现了向内和向外两种方向的法线；下图右则为扩展高斯图像（Extended Gaussian Image），描述了点云所有法线方向，由于数据集是 2.5D 的（即从单一视点下采集），因此法线只应分布在一个半球范围内，而实际上它们分布在整个球上。\n为了使所有法线都以各自不同的程度朝向视点 $v_{p}$，对于点 $p_{i}$ 及其法线 $\\vec{n_{i}}$，应满足：\n$$ \\vec{n_{i}}\\cdot(v_{p}-p_{i})\u003e0 $$ 对于不符合上式的法线，将其取反，即可得到正确朝向（朝向视点）的法线。下图给出了经该方法重定向后的点云法线：\n该方法仅在已知视点 $v_{p}$，且一个视图仅在该视点下采集时成立。当不知道视图的视点或者视图存在多个采集视点，则上述方法不成立。\n综上所述，对于每个点 $p_{i}$，估计其法线的步骤如下：\n获得与其距离最近的 $k$ 个邻居 计算 $p_{i}$ 的法线 检查计算出的法线是否朝向视点，否则翻转 0x03 利用 PCL 实现 PCL（Point Cloud Library）是一个开源的点云处理库，专门用于处理三维点云数据。PCL 集成了包括点云文件的读写、特征提取、分割配准、可视化等模块，可以方便地处理、分析点云数据。此次从点云估计法线的任务可以借助 PCL 完成，我的配置信息为：\n平台：Windows 11 PCL 版本：PCL 1.15.0 IDE：VisualStudio 2022 PCL 可在 Github 官方仓库下载：\nReleases · PointCloudLibrary/pcl 配置过程可参考：\nPCL点云库安装及学习（2021.7.28） Trouble Shooting 配置过程中遇到了一些问题，这里一并提一下：\nerror: PCL requires C++14 or above error: Potential runtime error due to aligned malloc mismatch! You likely have to compile your code with AVX enabled or define EIGEN_MAX_ALIGN_BYTES=32 \\[pcl::PCDReader::read\\]Could not find file (乱码) Assertion failed: index \u0026gt; 0 \u0026amp;\u0026amp; index \u0026lt; count, file C:\\Program Files\\flann\\include\\flann/algorithms/kdtree_single_index.h, line 492 第一个问题默认的 C++ 版本太低，在项目\u0026ndash;\u0026gt;属性\u0026ndash;\u0026gt;C/C++\u0026ndash;\u0026gt;语言\u0026ndash;\u0026gt;C++语言标准中选择更高版本（17 或 20）即可。\n第二个问题，内存对齐问题，可以通过改变指令集解决，在项目\u0026ndash;\u0026gt;属性\u0026ndash;\u0026gt;C/C++\u0026ndash;\u0026gt;代码生成\u0026ndash;\u0026gt;启用增强指令集中选择高级矢量扩展 (X86/X64) (/arch:AVX) 即可。\n第三个问题，下载的 PCL 在根目录以及第三方依赖的 lib 目录下会提供 Debug 和 Release 两种版本的库，其中 PCL 的 lib 文件中 Debug 版本的库在文件名末尾会以‘d’标识，而第三方依赖的库则会以‘-gd-’标识。链接器链接动态库时把 Debug 和 Release 版本的混淆了，所以在项目\u0026ndash;\u0026gt;属性\u0026ndash;\u0026gt;链接器\u0026ndash;\u0026gt;输入\u0026ndash;\u0026gt;附加依赖项中只填入对应版本的库即可。\n第四个问题，表面上是断言 k-d tree 建树错误，然而检查了一下原始数据，发现是文件错了，数据包含了 e+23 和 e-20 这种极端值。重新下载原始文件就正常了，推测是中途改了一遍编码格式导致的。\nCode 1#include \u0026lt;pcl/io/ply_io.h\u0026gt; 2#include \u0026lt;pcl/point_types.h\u0026gt; 3#include \u0026lt;pcl/filters/filter.h\u0026gt; 4#include \u0026lt;pcl/features/normal_3d.h\u0026gt; 5#include \u0026lt;pcl/visualization/pcl_visualizer.h\u0026gt; 6 7int main() { 8 // read ply file 9 pcl::PointCloud\u0026lt;pcl::PointXYZ\u0026gt;::Ptr cloud(new pcl::PointCloud\u0026lt;pcl::PointXYZ\u0026gt;); 10 if (pcl::io::loadPLYFile\u0026lt;pcl::PointXYZ\u0026gt;(\u0026#34;Test.ply\u0026#34;, *cloud) != 0) { 11 PCL_ERROR(\u0026#34;Failed to load point cloud file!\u0026#34;); 12 return -1; 13 } 14 //[DEBUG]: check data 15 //std::cout \u0026lt;\u0026lt; \u0026#34;Loaded cloud size: \u0026#34; \u0026lt;\u0026lt; cloud-\u0026gt;size() \u0026lt;\u0026lt; std::endl; 16 //for (size_t i = 0; i \u0026lt; std::min\u0026lt;size_t\u0026gt;(10, cloud-\u0026gt;size()); ++i) { 17 // auto\u0026amp; pt = cloud-\u0026gt;points[i]; 18 // std::cout \u0026lt;\u0026lt; i \u0026lt;\u0026lt; \u0026#34;: (\u0026#34; \u0026lt;\u0026lt; pt.x \u0026lt;\u0026lt; \u0026#34;, \u0026#34; \u0026lt;\u0026lt; pt.y \u0026lt;\u0026lt; \u0026#34;, \u0026#34; \u0026lt;\u0026lt; pt.z \u0026lt;\u0026lt; \u0026#34;)\\n\u0026#34;; 19 //} 20 21 // filter invalid points 22 pcl::PointCloud\u0026lt;pcl::PointXYZ\u0026gt;::Ptr cloud_filtered(new pcl::PointCloud\u0026lt;pcl::PointXYZ\u0026gt;); 23 std::vector\u0026lt;int\u0026gt; nan_indices; 24 pcl::removeNaNFromPointCloud(*cloud, *cloud_filtered, nan_indices); 25 if (cloud-\u0026gt;empty()) { 26 PCL_ERROR(\u0026#34;Error: Point cloud is empty after removing NaNs!\u0026#34;); 27 return -1; 28 } 29 30 // estimate normal 31 pcl::NormalEstimation\u0026lt;pcl::PointXYZ, pcl::Normal\u0026gt; ne; 32 ne.setInputCloud(cloud_filtered); 33 ne.setViewPoint(100.0, 0.0, 0.0); 34 pcl::search::KdTree\u0026lt;pcl::PointXYZ\u0026gt;::Ptr tree(new pcl::search::KdTree\u0026lt;pcl::PointXYZ\u0026gt;()); 35 ne.setSearchMethod(tree); 36 ne.setKSearch(100); 37 38 pcl::PointCloud\u0026lt;pcl::Normal\u0026gt;::Ptr normals(new pcl::PointCloud\u0026lt;pcl::Normal\u0026gt;); 39 ne.compute(*normals); 40 41 // vis 42 pcl::visualization::PCLVisualizer viewer(\u0026#34;Normals\u0026#34;); 43 viewer.addPointCloud(cloud_filtered, \u0026#34;cloud\u0026#34;); 44 viewer.setPointCloudRenderingProperties( 45 pcl::visualization::PCL_VISUALIZER_COLOR, 46 0, 1, 0, 47 \u0026#34;cloud\u0026#34; 48 ); 49 viewer.addPointCloudNormals\u0026lt;pcl::PointXYZ, pcl::Normal\u0026gt;(cloud_filtered, normals, 10, 0.05, \u0026#34;normals\u0026#34;); 50 51 while (!viewer.wasStopped()) { 52 viewer.spinOnce(); 53 } 54} 运行程序，不出意外就能看到法线：\n可以观察到由于选取的点云为平面，较大的 $k$ 值得到的法线在整个平面上朝向更加均匀，且在转角处有更为平滑的过渡。\n0x04 在 CloudCompare 中实现 然后按照指示选择合适的 Local surface model、Neighbors、Orientation，即可计算法线，最后选中点云，在 Properties 栏中对法线进行可视化：\n","permalink":"https://congyuxiaoyoudao.github.io/posts/interludes/estimatenormals/","summary":"\u003cblockquote\u003e\n\u003cp\u003e3DGS 的原论文中，每个高斯是不存法线信息的，然而后续工作中，也有研究人员将法线和 BRDF 信息进行训练，这样一来，就可以在后续的光栅化过程中输出 GBuffer，完成 relighting 的任务。生成初始法线，可以仅考虑高斯的均值，基于局部平面假设进行估计。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 什么是 PLY\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 利用最小二乘法估计点云法线\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 使用 PCL 的实现\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 在 CloudCompare 中实现\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://paulbourke.net/dataformats/ply/\"\u003ePLY - Polygon File Format\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://pointclouds.org/documentation/tutorials/normal_estimation.html\"\u003eEstimating Surface Normals in a PointCloud\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📺 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.youtube.com/watch?v=lco0yFWm1QQ\"\u003eNormal and Curvature Calculation for a Pointcloud\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📃 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"http://mediatum.ub.tum.de/doc/800632/941254.pdf\"\u003ehttp://mediatum.ub.tum.de/doc/800632/941254.pdf\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-ply-格式\"\u003e0x01 PLY 格式\u003c/h2\u003e\n\u003cp\u003ePLY（Polygon File Format, also known as the Stanford Triangle Format），是一种用于存储多边形集合的图形对象的格式。有两种子格式：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eASCII：ASCII 编码表示，直观易读\u003c/li\u003e\n\u003cli\u003eBinary：二进制格式，存储空间小，能够快速保存和加载\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003ePLY 文件的经典结构如下：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eHeader  // description field, containing element type, property and count\nVertex List  // list of data for each vertex\nFace List  // list of data for each face\n(List of other elements)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eHeader 描述文件格式，评论（comment）以及每个元素的描述，并在其后指定该元素属性。例如一个以 ASCII 子格式保存的 PLY 文件，其 Header 如下：\u003c/p\u003e","title":"Interlude 4. 在点云数据中估计法线"},{"content":" 从导师那里听说了 WebGPU，做了点调查发现这个 API 还挺新的，正好之前只零零碎碎学了一点 OpenGL，相比于不适合人类学的 Vulkan，还是 WebGPU 显得友好一点。\n🚩 0x00 To begin with 这篇文章将会包含以下内容：\n什么是 WebGPU 配置 WebGPU 开发环境 第一个 WebGPU 项目：HelloTriangle！ For reference👇：\n🍪 WebGPU Samples 📖 WebGPU API Spec 📖 WebGPU Shading Language 📺 WebGPU小白入门（一）：如何零基础创建第一个WebGPU项目！ 📖 您的第一个 WebGPU 应用 📖 lil-gui 0.20.0 0x01 认识 WebGPU WebGPU 是一个由 W3C GPU 工作组制定的新的现代图形 API，用于在 Web 应用中访问 GPU 功能。\n在 WebGPU 出现之前，Web 端的图形编程应用主要采用 WebGL 和 WebGL 2，虽然它们有着跨平台能力和强大的社区支持，但架构过于古老，最新的 WebGL 2 只是封装了 2009 年 OpenGL 3.2 的实现。Khronos 也指出 WebGL 后续更新中不再引入现代 GPU 的新特性。\n近 10 年来，GPU 取得了飞速发展，也同时带来了与其交互的现代图形 API 的发展，例如 Metal、D3D 和 Vulkan。WebGPU 继承了这些发展，并将其带到了 Web 平台，可以在浏览器中高效运行 GPU 的通用计算能力。\n作为 WebGL 的继任者，WebGPU 不只是一个能画图 API，还具备强大的并行计算能力，这使得 WebGPU 可以利用 ComputeShader 承担更广泛的计算任务，例如粒子模拟、双调排序等。\n0x02 创建 WebGPU 项目 创建基本 Vite 项目 首先，确保电脑上有如下配置：\nNode.js 支持 WebGPU 的浏览器，如 Chrome 和 Edge（版本 113 以上） 代码编辑器，如 VSCode 进入项目根目录所在文件夹，在终端运行：\n1npm create vite@latest webgpu-render-lab -- --template vanilla-ts 这里的 webgpu-render-lab 是项目名，可任取\n按照指示选择开发模板和变体，这里选择 Vanilla 和 TypeScript，即原生 HTML + TypeScript 的模板。\n然后按照下方提示依次运行：\n1cd webgpu-render-lab 2npm install 3npm run dev 在浏览器访问对应的端口即可看到初始的模板网页。\n目前的项目结构如下：\n1my-webgpu-app/ 2├── node_modules # dependencies 3├── public # static assets 4├── index.html # initial html 5├── src/ 6│ └── main.ts # main TypeScript file 7├── tsconfig.json # TypeScript configure 8├── package.json # Node.js configure 9└── ...others 配置 WebGPU 项目 项目目录下，终端运行：\n1npm install -D @webgpu/types 安装 WebGPU 的 Ts 类型定义文件，然后在 tsconfig.json 的 compilerOptions 中增加一条：\n1// tsconfig.json 2\u0026#34;compilerOptions\u0026#34;: { 3\t... 4\t\u0026#34;types\u0026#34;: [\u0026#34;vite/client\u0026#34;,\u0026#34;@webgpu/types\u0026#34;] 5} 以启用 Vite 和 WebGPU 类型的智能提示和类型检查。\ntsconfig.json 中还有一个字段 include，用于告诉 TypeScript 编译器只处理/类型检查所列出路径下的 Ts 文件，以提升速度。\n1// tsconfig.json 2\t\u0026#34;include\u0026#34;: [\u0026#34;src\u0026#34;] 所以如果有 Ts 文件路径不在 src 下，需要在这里包含其所属路径。\n0x03 HelloTriangle 要画世界，先画三角形！\nWebGPU 渲染流程 在正式写程序之前，简单介绍一下 WebGPU 的架构和渲染流程。\n实际上，WebGPU 并非直接操纵硬件，而是运行在浏览器内部的一个抽象层。Web 应用的一个页面处于一个独立的渲染进程中，由 JS 的 API 负责与浏览器进行沟通，后者通过进程间通讯（IPC）将前者传递给 Native Module，再由 Native Module 根据操作系统映射到不同的原生图形 API（Metal、D3D、Vulkan），完成最终的渲染和计算任务。\nNative Module（原生模组）：指由本地系统代码（如 C/C++、Rust）编写、经过编译后供高级语言（如 JavaScript、Python）调用的模块，直接与操作系统或底层硬件交互。Chrome 中的 Dawn 就是一个 Native Module\nWebGPU 的工作流程大致可以分成：初始化、配置管线和录制命令队列三个阶段：\n初始化：获取可供 JS 操作的 GPU 逻辑实例，配置上下文 配置管线：创建管线，创建 Shader 模块，创建 GPU 变量（Buffers、BindGroups） 录制命令队列：开始 Pass，绘制顶点，提交队列 个人感觉，其实也可以把这三个过程合并起来当作一个初始化过程，毕竟在 TS 里写的只是整个渲染逻辑，真正执行绘制行为是由 Native 进程完成的。\n初始化 WebGPU 因为 WebGPU 将渲染结果绘制到 GPUCanvasContext 上，而这个 context 是通过 HTML 的 \u0026lt;canvas\u0026gt; 获取的，所以确保网页的 HTML 文件中至少有一个 canvas 元素，作为 GPU 的渲染目标。\n1\u0026lt;canvas\u0026gt;\u0026lt;/canvas\u0026gt; 首先检查一下目前的浏览器是否支持 WebGPU，可以用 navigator.gpu 判断，它是 WebGPU 提供给 JS 的入口对象，如果浏览器不支持 WebGPU，则其为 undefined，需要更换合适的浏览器或者开启某个 flag。\n1 if (!navigator.gpu) { 2 throw new Error(\u0026#34;WebGPU is not supported in this browser.\u0026#34;); 3 } 4 else{ 5 console.log(\u0026#34;Hello, WebGPU!\u0026#34;); 6 } 获得了 WebGPU 支持后，则可以调用 navigator.gpu 的 requestAdapter 获得一个 GPU 的逻辑适配器（adapter），再由 adapter 调用 requestDevice 获得一个实际使用 JS 进行交互的 GPU 设备（device）。\n1const adapter = await navigator.gpu.requestAdapter(); 2const device = await gpuAdapter.requestDevice(); adapter 和 device：GPUAdapter 封装了一个适配器，并描述其特性（features）、限制（limits）及适配器信息（Info），是浏览器对 GPU 的抽象，可以用它选择多个 GPU（集显，独显）；GPUDevice 封装了一个设备，并暴露该设备的功能（只读），代表与 GPU 的逻辑连接，可以用 device 调用 WebGPU 接口。\n正如开头所说，GPUCanvasContext 是通过 HTMLCanvasElement 实例调用 getContext 方法创建的，对于 WebGPU 创建一个 WebGPU 专用的画布上下文，传递 webgpu 作为其 contextType 参数。\n1const canvas = document.querySelector(\u0026#39;canvas\u0026#39;) as HTMLCanvasElement; 2const context = canvas.getContext(\u0026#39;webgpu\u0026#39;); canvas 和 context：类似于 adapter 和 device，canvas 只是一个 HTML 的 DOM 元素，需要通过 getContext 创建一个 WebGPU 可以控制的逻辑画布，在 context 上进行绘制。创建 context 之后，可以通过 configure 方法配置画布上下文，至少包括 device 和 format（GPUTextureFormat），后者一般为 bgra8unorm。\nrequestAdapter 和 requestDevice 都是异步方法，返回一个 Promise，需要使用 await 获取实际返回对象，而 await 必须在 async 函数内部使用，所以可以在外面包裹一个 async function：\n1async function initWebGPU() { 2 // check webgpu support ... 3 // request GPU adapter 4 const adapter = await navigator.gpu.requestAdapter(); 5 if (!adapter) { 6 throw new Error(\u0026#34;Cannot get GPU adapter.\u0026#34;); 7 } 8 9 // request a logical device 10 const device = await adapter.requestDevice(); 11 12 // get canvas and configure webgpu context 13 const canvas = document.querySelector(\u0026#39;canvas\u0026#39;) as HTMLCanvasElement; 14 const context = canvas.getContext(\u0026#39;webgpu\u0026#39;)!; 15 const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); 16 17 context.configure({ 18 device, 19 format: presentationFormat, 20 alphaMode: \u0026#39;premultiplied\u0026#39;, 21 }); 22 // other code ... 23} 至此，WebGPU 初始化完毕。\n配置管线 WebGPU 一共有两种管线：GPUComputePipeline（计算管线） 和 GPURenderPipeline（渲染管线），无论哪种管线都规定了 GPU 的任务流程，包括从 bindings 或者 buffers 获取输入，并产生输出（例如渲染纹理）的过程。\n从结构上看，管线由一系列可编程阶段（shaders）和固定功能状态（混合模式）组成，一个渲染管线一般包括顶点着色器（vertex shader）、片元着色器（fragment shader）等，计算管线则包括计算着色器（compute shader）。\n对于一个渲染管线，可以通过 createRenderPipeline 方法立即创建并返回一个对象，也可通过 createRenderPipelineAsync 方法异步创建。createRenderPipeline 方法接受一个字典对象 GPURenderPipelineDescriptor，包含如下成员：\nvertex（GPUVertexState）：描述管线顶点着色器入口及其输入缓冲区布局 primitive（GPUPrimitiveState）：描述管线中原始图元相关属性 depthStencil（GPUDepthStencilState）：可选的深度模板属性 multisample（GPUMultisampleState）：多重采样属性 fragment（GPUFragmentState）：描述管线片元着色器入口及其输出颜色 对于此次绘制三角形的任务，我们必须要提供的是 vertex，primitive 和 fragment。其中 vertex 和 fragment 都继承 GPUProgrammableStage，后者描述用户提供的 GPUShaderModule 中的入口点，即着色器的入口函数。\n顶点着色器接受来自顶点缓冲区的输入数据，所以需要先定义需要传输到 GPU 的顶点数据，然后通过 GPUBuffer 描述如何解析这些数据。例如，对于三角形的每个顶点，定义其由位置和颜色两个属性组成：\n1// data preparation 2const triangleData = new Float32Array([ 3// position color 40.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, 5-0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 1.0, 60.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0, 7]); 然后可以通过 createBuffer 方法创建一个 GPUbuffer，该方法接受一个 GPUBufferDescriptor 类型的字典对象，至少需要提供两个参数：\nsize：缓冲区以字节为单位的大小 usage：缓冲区允许的使用方式（例如 Vertex，Uniform，Storage） 可以通过上述定义的顶点数组的字节长度获取 size；为了能够拷贝给 GPU，并传递给 VertexBuffer，在 usage 中需要同时启用 VERTEX 和 COPY_DST。\n1// create and write gpu buffer 2const vertexBuffer = device.createBuffer({ 3size: triangleData.byteLength, 4usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, 5}); 6 7device.queue.writeBuffer(vertexBuffer, 0, triangleData); 定义好了顶点着色器的输入，就可以开始编写 shader 了。正如 OpenGL 有 GLSL，D3D 有 HLSL，WebGPU 的开发组也为其带来了一套新的着色器语言——WGSL。绘制一个三角形所需的最简单的 shader 如下：\n1// shader code 2const shaderCode = ` 3struct VertexOutput { 4 @builtin(position) position: vec4\u0026lt;f32\u0026gt;, 5 @location(0) color: vec4\u0026lt;f32\u0026gt;, 6}; 7 8@vertex 9fn vs_main( 10 @location(0) pos: vec3\u0026lt;f32\u0026gt;, 11 @location(1) color: vec4\u0026lt;f32\u0026gt; 12) -\u0026gt; VertexOutput { 13 var output: VertexOutput; 14 output.position = vec4\u0026lt;f32\u0026gt;(pos, 1.0); 15 output.color = color; 16 return output; 17} 18 19@fragment 20fn fs_main(input: VertexOutput) -\u0026gt; @location(0) vec4\u0026lt;f32\u0026gt; { 21 return input.color; 22} 23`; 24 25// create shader module 26const shaderModule = device.createShaderModule({ 27code: shaderCode, 28}); shader 也可以写在 .wgsl 文件中，通过 import 导入\n至此，可以调用 createRenderPipeline 方法，依次填入 GPURenderPipelineDescriptor 的各个字段：\nlayout：使用 \u0026lsquo;auto\u0026rsquo;，让管线自动推断资源绑定布局 vertex：shaderModule 及顶点着色器入口函数，顶点缓冲区布局和格式 fragment：shaderModule 及片元着色器入口函数，输出格式（必须与 context 的 format 匹配） primitive：其中的 topology 规定图元类型，由于我们是画三角形所以使用 \u0026rsquo;triangle-list\u0026rsquo; 1 // create render pipeline 2 const pipeline = device.createRenderPipeline({ 3 layout: \u0026#39;auto\u0026#39;, 4 vertex: { 5 module: shaderModule, 6 entryPoint: \u0026#39;vs_main\u0026#39;, 7 buffers: [ 8 { 9 arrayStride: 7 * 4, // position: 3 + color: 4 10 attributes: [ 11 { // position 12 shaderLocation: 0, 13 offset: 0, 14 format: \u0026#39;float32x3\u0026#39;, 15 }, 16 { // color 17 shaderLocation: 1, 18 offset: 3 * 4, 19 format: \u0026#39;float32x4\u0026#39;, 20 }, 21 ], 22 }, 23 ], 24 }, 25 fragment: { 26 module: shaderModule, 27 entryPoint: \u0026#39;fs_main\u0026#39;, 28 targets: [ 29 { 30 format: presentationFormat, 31 }, 32 ], 33 }, 34 primitive: { 35 topology: \u0026#39;triangle-list\u0026#39;, 36 }, 37 }); 至此，管线配置结束。\n录制渲染队列 熟悉 Unity 的同学可能会知道，当调用渲染命令时，GPU 并非立即执行，而是把一系列命令写入一个命令缓冲区（CommandBuffer），然后再将这个 CommandBuffer 注入到管线的特定阶段（例如不透明物体渲染后或者阴影渲染前）。当渲染循环执行到注入点时，才会依次执行缓冲区中的命令。\nWebGPU 中的 CommandEncoder 正是对应了写入渲染缓冲区的录制操作，直到 submit 前的内容都是在构建命令列表：\n1const commandEncoder = device.createCommandEncoder(); 2// record command here 3// ... 4const commandBuffer = commandEncoder.finish(); 5device.queue.submit([commandBuffer]); 如果说 GPUCommandEncoder 是一整个录制器，那么 GPURenderPassEncoder 就是其中的一个子录制器，录制一个 Pass 过程中的命令。简单来说，一个 Pass 就是从 GPU 输入数据到输出渲染目标的过程。一个 CommandEncoder 可以创建多个 Pass。\n在 WebGPU 中创建一个 Pass 可以使用 beginRenderPass 方法，接受一个 GPURenderPassDescripter 字典对象，至少需要包含 colorAttachments 字段。colorAttachments 定义了当前 Pass 输出的所有颜色附件，每个 colorAttachment 是一个字典，必须提供以下三个字段：\nview（GPUTextureView）：描述输出此 colorAttachment 的纹理子资源 loadOp（GPULoadOp）：执行 Pass 前对 view 的加载操作（一般为 clear） storeOp（GPUStoreOp）：执行 Pass 后对 view 的存储操作 创建 GPUPassEncoder 后，就可以录制渲染命令了，具体分为三个阶段：\n设置管线：设置该 Pass 使用的管线、渲染状态 绑定资源：设置顶点缓冲、索引缓冲和 BindingGroup 执行绘制：调用 draw 方法绘制顶点 上述流程完成后，便可以通过 end 方法结束 Pass 的录制。对于本次的三角形，其渲染队列的录制过程如下：\n1function frame() { 2 // cmdEncoder to record commands 3 const commandEncoder = device.createCommandEncoder(); 4 5 // get current texture view 6 const textureView = context.getCurrentTexture().createView(); 7 8 // begin render pass 9 const renderPassDescriptor: GPURenderPassDescriptor = { 10 colorAttachments: [ 11 { 12 view: textureView, 13 clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 14 loadOp: \u0026#39;clear\u0026#39;, 15 storeOp: \u0026#39;store\u0026#39;, 16 }, 17 ], 18 }; 19 20 const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); 21 22 // set pipeline and vertex buffer 23 passEncoder.setPipeline(pipeline); 24 passEncoder.setVertexBuffer(0, vertexBuffer); 25 26 // draw triangle: 3 vertices 27 passEncoder.draw(3); 28 29 // end render pass 30 passEncoder.end(); 31 32 // finish encode and submit 33 device.queue.submit([commandEncoder.finish()]); 34 35 // request next frame 36 requestAnimationFrame(frame); 37 } 38 39 // start render loop 40 requestAnimationFrame(frame); 在终端运行 npm run dev，不出意外就能看到一个五彩斑斓的三角形：\nCSS 自己写\nGUI 目前我们参数是写死在 TS 中，更方便的方式是加入交互的 GUI，可以在浏览器中实时调整参数并查看结果。\nOpenGL 的时候用的是 imgui，但不知为何下载不了其对 js 的封装，所以这里选择的是另一个轻量级的 lil-gui，在终端下载它的包：\n1npm install lil-gui 然后在 TypeScript 文件中引入：\n1import {GUI} from \u0026#39;lil-gui\u0026#39; 初始化 GUI 并添加三个 color 参数控制三角形每个顶点的颜色：\n1const gui = new GUI(); 2 3const params = { 4colorA: [1, 0, 0, 1], 5colorB: [0, 1, 0, 1], 6colorC: [0, 0, 1, 1], 7}; 8 9gui.addColor(params, \u0026#39;colorA\u0026#39;); 10gui.addColor(params, \u0026#39;colorB\u0026#39;); 11gui.addColor(params, \u0026#39;colorC\u0026#39;); 创建一个 GPUBuffer，将其设置为 UNIFORM，并绑定到 bindGroup：\n1const colorBuffer = device.createBuffer({ 2 size: 4 * 3 * 4, // 3 colors, each with 4 floats 3 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, 4}) 5 6const bindGroup = device.createBindGroup({ 7 layout: pipeline.getBindGroupLayout(0), 8 entries: [{ 9 binding: 0, 10 resource: { 11\tbuffer: colorBuffer 12 } 13}], 14}); 然后每帧更新这个 Buffer 并设置绑定组：\n1function updateColors(){ 2 device.queue.writeBuffer(colorBuffer, 0, new Float32Array([...params.colorA, ...params.colorB, ...params.colorC])); 3} 4 5function frame(){ 6\t// ... 7 updateColors(); 8 passEncoder.setBindGroup(0, bindGroup); 9} 最后可以在 VertexShader 中解析这个 uniform：\n1struct ColorUniform { 2 colorA: vec4\u0026lt;f32\u0026gt;, 3 colorB: vec4\u0026lt;f32\u0026gt;, 4 colorC: vec4\u0026lt;f32\u0026gt;, 5}; 6 7@group(0) @binding(0) var\u0026lt;uniform\u0026gt; uColors: ColorUniform; 8 9fn getColorByIndex(index: u32) -\u0026gt; vec4\u0026lt;f32\u0026gt; { 10 if (index == 0u) { 11\treturn uColors.colorA; 12 } else if (index == 1u) { 13\treturn uColors.colorB; 14 } else { 15\treturn uColors.colorC; 16 } 17} 18 19@vertex 20fn vs_main( 21 @builtin(vertex_index) vertexindex: u32, 22 @location(0) pos: vec3\u0026lt;f32\u0026gt;, 23 @location(1) color: vec4\u0026lt;f32\u0026gt; 24) -\u0026gt; VertexOutput { 25 var output: VertexOutput; 26 output.position = vec4\u0026lt;f32\u0026gt;(pos, 1.0); 27 output.color = getColorByIndex(vertexindex); 28 return output; 29} 回到浏览器，调整颜色，就可以看到变化了：\n0x0F After words 好了，你已经学会如何使用 WebGPU 绘制三角形了，可以尝试挑战一下制作一个 3DGS 的实时渲染预览器了！\n好吧不开玩笑了，WebGPU 还有极大部分内容是本文没有涵盖的，如果读者有更进一步的需求，文章开头也列出了一些更详细的文档和教程可供参考。如果有一些 OpenGL 或者 WebGL 基础，学 WebGPU 也会更轻松些吧……\n","permalink":"https://congyuxiaoyoudao.github.io/posts/interludes/webgpu-from-scratch/","summary":"\u003cblockquote\u003e\n\u003cp\u003e从导师那里听说了 WebGPU，做了点调查发现这个 API 还挺新的，正好之前只零零碎碎学了一点 OpenGL，相比于不适合人类学的 Vulkan，还是 WebGPU 显得友好一点。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x00-to-begin-with\"\u003e🚩 0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 什么是 WebGPU\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 配置 WebGPU 开发环境\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 第一个 WebGPU 项目：HelloTriangle！\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e🍪 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://webgpu.github.io/webgpu-samples/\"\u003eWebGPU Samples\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://gpuweb.github.io/gpuweb/\"\u003eWebGPU API Spec\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.w3.org/TR/WGSL/\"\u003eWebGPU Shading Language\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📺 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV1uu411B7uq/\"\u003eWebGPU小白入门（一）：如何零基础创建第一个WebGPU项目！\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://codelabs.developers.google.com/your-first-webgpu-app?hl=zh-cn\u0026amp;authuser=0#0\"\u003e您的第一个 WebGPU 应用\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://lil-gui.georgealways.com/\"\u003elil-gui 0.20.0\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-认识-webgpu\"\u003e0x01 认识 WebGPU\u003c/h2\u003e\n\u003cp\u003eWebGPU 是一个由 W3C GPU 工作组制定的新的现代图形 API，用于在 Web 应用中访问 GPU 功能。\u003c/p\u003e\n\u003cp\u003e在 WebGPU 出现之前，Web 端的图形编程应用主要采用 WebGL 和 WebGL 2，虽然它们有着跨平台能力和强大的社区支持，但架构过于古老，最新的 WebGL 2 只是封装了 2009 年 OpenGL 3.2 的实现。Khronos 也指出 WebGL 后续更新中不再引入现代 GPU 的新特性。\u003c/p\u003e","title":"Interlude 3. WebGPU From Scratch"},{"content":" 202 系列完结撒花 ✿✿ヽ(°▽°)ノ✿！\n0x00 To begin with 这篇文章将会包含以下内容：\n部分课程内容回顾 GAMES202 作业 5 For reference👇：\n📺B 站视频： GAMES202-高质量实时渲染 📦代码仓库： congyuxiaoyoudao/GAMES202_Homework at working 作业5发布公告及场景下载 📖 GAMES202-作业5：实时光线追踪降噪 master 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\n0x01 问题修复 TroubleShooting 做作业时遇到一些出现问题的地方，这里一并提一下：\n运行 build.bat 后 build 目录下没有出现可执行文件 ReadFloatImage 方法报错 mathutil.h 中 uint32_t was not declared in this scope 第一个问题纯属对 Cmake 不熟练，build.bat 提供的命令只是生成工程文件，要运行构建还需要如下命令（或者直接用 VS 等 IDE 运行）：\n1cmake --build build 这里为了简化运行操作，新增了一个 run.bat：\n1cmake --build build 2cd build 3Denoise.exe 4cd .. 第二个问题为路径错误，下载完场景后我的根目录文件结构如下：\n1assignment5/ 2├── examples/ 3|\t└── ... 4├── src/ 5| ├── main.cpp 6│ └── ... 7└── ... 所以需要在 main.cpp 中更改一下路径（或者直接把 examples 放到 src 下）：\n1filesystem::path inputDir(\u0026#34;../examples/box/input\u0026#34;); 2filesystem::path outputDir(\u0026#34;../examples/box/output\u0026#34;); 第三个问题，缺少头文件，在 mathutil.h 中包含 \u0026lt;cstdint\u0026gt; 即可。\n0x02 联合双边滤波 Joint Bilateral Filter 对于单帧图像的降噪，实际上就是低通滤波的过程，但是常见的低通滤波核（例如高斯核）会无条件地抹除输入图像中的高频部分，包括图像中不同物体的边界。而我们希望在降噪的过程中能够保留这些边缘，避免 Overblur，就需要寻找别的方法。\n双边滤波提供了一个不错的思路：双边滤波核的权重不仅考虑了像素间几何距离的差异，还考虑了图像值域上的差异：\n$$ w(i, j, k, l) = \\exp\\left( -\\frac{(i - k)^2 + (j - l)^2}{2\\sigma_d^2} - \\frac{\\|I(i, j) - I(k, l)\\|^2}{2\\sigma_r^2} \\right) $$ 当两个像素在值（颜色、深度等）上差异过大时，采样像素对中心像素的贡献就减少，这样就可以维持住边缘信息。\n所以我们可以在双边滤波的基础上，为核加入更多控制以应对多种复杂边缘情况，这就是联合双边滤波的思想。\n使用联合双边滤波对单帧图像进行降噪，作业中给出的核为：\n$$ J(\\mathbf{i}, \\mathbf{j}) = \\exp\\left( -\\frac{\\|\\mathbf{i} - \\mathbf{j}\\|^2}{2\\sigma_p^2} - \\frac{\\|\\tilde{C}[i] - \\tilde{C}[j]\\|^2}{2\\sigma_c^2} - \\frac{D_{\\text{normal}}(\\mathbf{i}, \\mathbf{j})^2}{2\\sigma_n^2} - \\frac{D_{\\text{plane}}(\\mathbf{i}, \\mathbf{j})^2}{2\\sigma_d^2} \\right) $$ 其中：\n$\\lVert \\mathbf{i} - \\mathbf{j} \\rVert$：像素坐标差值 $\\lVert \\tilde{C}[i] - \\tilde{C}[j] \\rVert$：颜色差值 $D_{\\text{normal}}(\\mathbf{i}, \\mathbf{j}) = \\arccos\\left( \\text{Normal}[\\mathbf{i}] \\cdot \\text{{Normal}}[\\mathbf{j}] \\right)$，描述面朝向差异 $D_{\\text{plane}}(\\text{i}, \\text{j}) = \\text{Normal}[\\text{i}] \\cdot \\frac{\\text{Position}[\\text{j}] - \\text{Position}[\\text{i}]}{\\lVert \\text{Position}[\\text{j}] - \\text{Position}[\\text{i}] \\rVert}$，描述距离差异（沿法向变化的距离） 一直有点疑惑为什么很多作业实现都是把第一项当作世界位置差异，试了一下效果也的确差不多\n各个 $\\sigma$ 为控制不同项的平滑程度的标准差，$\\sigma$ 越大表示对上述距离差异越不敏感，即更能容忍权重差异。\n在 denoise.cpp 中完成 Filter 函数，按照上述公式完成联合双边滤波逻辑：\n1Buffer2D\u0026lt;Float3\u0026gt; Denoiser::Filter(const FrameInfo \u0026amp;frameInfo) { 2 int height = frameInfo.m_beauty.m_height; 3 int width = frameInfo.m_beauty.m_width; 4 Buffer2D\u0026lt;Float3\u0026gt; filteredImage = CreateBuffer2D\u0026lt;Float3\u0026gt;(width, height); 5 int kernelRadius = 16; 6#pragma omp parallel for 7 for (int y = 0; y \u0026lt; height; y++) { 8 for (int x = 0; x \u0026lt; width; x++) { 9 // Joint bilateral filter 10 // get current pixel vals 11 Float3 color = frameInfo.m_beauty(x, y); 12 Float3 normal = frameInfo.m_normal(x, y); 13 Float3 pos = frameInfo.m_position(x, y); 14 15 float weight_sum = 0.f; 16 Float3 color_sum = Float3(0.f); 17 for(int i = -kernelRadius; i \u0026lt;= kernelRadius; i++){ 18 for(int j = -kernelRadius; j \u0026lt;= kernelRadius; j++){ 19 // clamp coordinate 20 int sampleCoord_x = std::min(std::max(0,x+i),width-1); 21 int sampleCoord_y = std::min(std::max(0,y+j),height-1); 22 23 // sample sampled pixel vals 24 Float3 sampledColor = frameInfo.m_beauty(sampleCoord_x,sampleCoord_y); 25 Float3 sampledNormal = frameInfo.m_normal(sampleCoord_x,sampleCoord_y); 26 Float3 sampledPos = frameInfo.m_position(sampleCoord_x,sampleCoord_y); 27 28 // calculate weight 29 float weight = 0.0; 30 float d_coord2 = i*i+j*j; 31 32 float d_color2 = SqrDistance(color,sampledColor); 33 34 float cosTheta = Dot(normal,sampledNormal); 35 float d_normal2 = SafeAcos(cosTheta)*SafeAcos(cosTheta); 36 37 float d_plane = 0.f; 38 float d_pos = Distance(pos, sampledPos); 39 if (d_pos \u0026gt; 0) 40 d_plane = Dot(normal,Normalize(sampledPos-pos)); 41 float d_plane2 = d_plane*d_plane; 42 43 float w_coord = - d_coord2/(2*m_sigmaCoord*m_sigmaCoord); 44 float w_color = - d_color2/(2*m_sigmaColor*m_sigmaColor); 45 float w_normal = - d_normal2/(2*m_sigmaNormal*m_sigmaNormal); 46 float w_plane = - d_plane2/(2*m_sigmaPlane*m_sigmaPlane); 47 48 weight = exp(w_coord+w_color+w_normal+w_plane); 49 // accumulate weight and color buffer 50 weight_sum += weight; 51 color_sum += sampledColor * weight; 52 } 53 } 54 // protect 0 weight 55 if(weight_sum == 0.0f){ 56 filteredImage(x, y) = color; 57 } 58 else{ 59 filteredImage(x, y) = color_sum / weight_sum; 60 } 61 } 62 } 63 return filteredImage; 64} 0x03 反投影 Reprojection 反投影利用当前帧的信息找到上一帧的对应像素位置，通过反投影可以在时间上复用已经滤波的上一帧信息。\n已知当前帧的像素坐标，可以在 GBuffer 上获取世界位置和各类变换矩阵，使用下式即可通过像素世界位置找到上一帧该像素的屏幕坐标：\n$$ \\text{Screen}_{i-1} = P_{i-1} V_{i-1} M_{i-1} M_i^{-1} \\, \\text{World}_i $$ 投影到屏幕坐标下会存在一些屏幕空间固有的问题，导致反投影后的像素不合法，若强行复用会导致 lagging ，对于这些情况我们要手动进行判断：\n反投影后的像素在屏幕外，即当前帧像素对应新进入屏幕的物体 反投影后的像素和当前帧像素对应物体的 id 不同 第一种情况可以通过判断反投影后的像素坐标是否合法屏幕范围内（$[0,w-1],[0,h-1]$）；第二种情况可以通过判断 GBuffer Id 是否相等。仅对上述判断后合法的像素进行时间累积，更新 m_valid 和 m_misc。\n按照上述思路完成 Reprojection 函数：\n1void Denoiser::Reprojection(const FrameInfo \u0026amp;frameInfo) { 2 int height = m_accColor.m_height; 3 int width = m_accColor.m_width; 4 Matrix4x4 preWorldToScreen = 5 m_preFrameInfo.m_matrix[m_preFrameInfo.m_matrix.size() - 1]; 6 Matrix4x4 preWorldToCamera = 7 m_preFrameInfo.m_matrix[m_preFrameInfo.m_matrix.size() - 2]; 8#pragma omp parallel for 9 for (int y = 0; y \u0026lt; height; y++) { 10 for (int x = 0; x \u0026lt; width; x++) { 11 // Reproject 12 m_valid(x, y) = false; 13 m_misc(x, y) = Float3(0.f); 14 15 float id = frameInfo.m_id(x, y); 16 // skip ivalid id 17 if(id == -1) 18 continue; 19 20 Float3 pos = frameInfo.m_position(x, y); 21 Matrix4x4 WorldToLocal = Inverse(frameInfo.m_matrix[id]); 22 Matrix4x4 preLocalToWorld = m_preFrameInfo.m_matrix[id]; 23 // P_{i-1}V_{i-1}M_{-1}M_{i}^{-1} 24 Matrix4x4 ReprojectionMatrix = preWorldToScreen*preLocalToWorld*WorldToLocal; 25 26 Float3 prePosScreen = ReprojectionMatrix(pos, Float3::EType::Point); 27 float pre_x = prePosScreen.x; 28 float pre_y = prePosScreen.y; 29 30 // is in viewport 31 if(pre_x\u0026lt;0 || pre_x\u0026gt;width-1 || pre_y\u0026lt;0 || pre_y\u0026gt;height-1) 32 continue; 33 34 float preId = m_preFrameInfo.m_id(pre_x, pre_y); 35 36 if(preId == id){ 37 m_valid(x, y) = true; 38 m_misc(x, y) = m_accColor(pre_x, pre_y); 39 } 40 } 41 } 42 std::swap(m_misc, m_accColor); 43} 0x04 时间累积 Temporal Accumulation 反投影将当前帧所有像素对应的上一帧反投影的像素颜色（若合法）存入 m_accColor，以便时间累积复用。\n如果直接使用上一帧的信息，当其颜色与当前颜色差异过大时可能导致 ghosting，为了避免这个情况，需要对上一帧信息和当前帧信息做钳制，即：\n$$ C^{(i-1)}=\\text{clamp}(C^{(i-1)}, \\mu - k\\sigma, \\mu + k\\sigma) $$ 其中，$\\mu,\\sigma$ 分别为当前帧像素某一邻域的均值和方差。钳制后即可使用下式混合颜色：\n$$ C^{(i)} = \\alpha \\bar{C}^{(i)} + (1 - \\alpha) C^{(i-1)} $$ 其中，$\\bar{C}^{(i)}$ 为当前帧已降噪像素信息，$C^{(i-1)}$ 为上一帧已降噪反投影像素信息，$\\alpha$ 通常取 0.2，表示更多地使用上一帧信息。\n在 denoise.cpp 中完成 TemporalAccumulation 函数，通过遍历当前像素的 7×7 邻域计算均值和方差，并将上一帧信息限制到 $(\\mu-k\\sigma,\\mu+k\\sigma)$ 范围内。为了避免求方差再做一遍循环，可以利用方差的另一个计算公式：\n$$ Var(X)=E(X^{2})-E(X)^{2} $$ 1void Denoiser::TemporalAccumulation(const Buffer2D\u0026lt;Float3\u0026gt; \u0026amp;curFilteredColor) { 2 int height = m_accColor.m_height; 3 int width = m_accColor.m_width; 4 int kernelRadius = 3; 5 int kernelSize = 2*kernelRadius+1; 6 int sampleNum = kernelSize * kernelSize; 7#pragma omp parallel for 8 for (int y = 0; y \u0026lt; height; y++) { 9 for (int x = 0; x \u0026lt; width; x++) { 10 // Temporal clamp 11 float alpha = 1.0f; 12 // previous frame color 13 Float3 color = m_accColor(x, y); 14 15 if(m_valid(x, y)){ 16 alpha = m_alpha; 17 // calculate mu and sigma in 7*7 block 18 Float3 mu, mu2 = Float3(0.f); 19 Float3 sigma = Float3(0.f); 20 for(int i = -kernelRadius; i \u0026lt;= kernelRadius; i++){ 21 for(int j = -kernelRadius; j \u0026lt;= kernelRadius; j++){ 22 int sampleCoord_x = std::min(std::max(0,x+i),width-1); 23 int sampleCoord_y = std::min(std::max(0,y+j),height-1); 24 25 Float3 curSampledColor = curFilteredColor(sampleCoord_x, sampleCoord_y); 26 mu+=curSampledColor; 27 mu2+=Sqr(curSampledColor); 28 } 29 } 30 mu /= sampleNum; 31 mu2 /= sampleNum; 32 sigma = SafeSqrt(mu2 - mu*mu); 33 34 color = Clamp(color, mu-sigma*m_colorBoxK, mu+sigma*m_colorBoxK); 35 } 36 37 // Exponential moving average 38 m_misc(x, y) = Lerp(color, curFilteredColor(x, y), alpha); 39 } 40 } 41 std::swap(m_misc, m_accColor); 42} 0x05 A-Torus Wavelet 加速 A-Torus Wavelet，即进行多次滤波，每次使用逐渐增加的滤波核尺寸和步长，以保证每次滤波的采样点数不变。总共进行 5 次滤波，每次滤波输入图像为上一次滤波结果，第 $i$ 次滤波的步长为 $2^{i}$，核尺寸为 $2^{i+2}+1$ 。通过 A-Torus Wavelet 的加速，可以将原本 $64^{2}$ 的采样数减少为 $125$，大幅提升单帧降噪速度。\n在 Denoise 类中新增一个 ATWFilter 函数，实现由 A-Trous Wavelet 加速的滤波，在原本的两层 for 循环外部再嵌套一层 for：\n1Buffer2D\u0026lt;Float3\u0026gt; Denoiser::ATWFilter(const FrameInfo \u0026amp;frameInfo) { 2 int height = frameInfo.m_beauty.m_height; 3 int width = frameInfo.m_beauty.m_width; 4 Buffer2D\u0026lt;Float3\u0026gt; filteredImage = CreateBuffer2D\u0026lt;Float3\u0026gt;(width, height); 5 int passes = 5; 6#pragma omp parallel for 7 // Accelerated by A-Trous Wavelet 8 for (int y = 0; y \u0026lt; height; y++) { 9 for (int x = 0; x \u0026lt; width; x++) { 10 // Joint bilateral filter 11 // get current pixel vals 12 Float3 color = frameInfo.m_beauty(x, y); 13 Float3 normal = frameInfo.m_normal(x, y); 14 Float3 pos = frameInfo.m_position(x, y); 15 16 float weight_sum = 0.f; 17 Float3 color_sum = Float3(0.f); 18 19 for(int pass = 0; pass \u0026lt; passes; pass++){ 20 int stride = pow(2, pass); 21 int kernelRadius = stride * 2; 22 for(int i = -kernelRadius; i \u0026lt;= kernelRadius; i+=stride){ 23 for(int j = -kernelRadius; j \u0026lt;= kernelRadius; j+=stride){ 24 // clamp coordinate 25 int sampleCoord_x = std::min(std::max(0,x+i),width-1); 26 int sampleCoord_y = std::min(std::max(0,y+j),height-1); 27 28 // sample sampled pixel vals 29 Float3 sampledColor = frameInfo.m_beauty(sampleCoord_x,sampleCoord_y); 30 Float3 sampledNormal = frameInfo.m_normal(sampleCoord_x,sampleCoord_y); 31 Float3 sampledPos = frameInfo.m_position(sampleCoord_x,sampleCoord_y); 32 33 // calculate weight 34 float weight = 0.0; 35 float d_coord2 = i*i+j*j; 36 37 float d_color2 = SqrDistance(color,sampledColor); 38 39 float cosTheta = Dot(normal,sampledNormal); 40 float d_normal2 = SafeAcos(cosTheta)*SafeAcos(cosTheta); 41 42 float d_plane = 0.f; 43 float d_pos = Distance(pos, sampledPos); 44 if (d_pos \u0026gt; 0) 45 d_plane = Dot(normal,Normalize(sampledPos-pos)); 46 float d_plane2 = d_plane*d_plane; 47 48 float w_coord = - d_coord2/(2*m_sigmaCoord*m_sigmaCoord); 49 float w_color = - d_color2/(2*m_sigmaColor*m_sigmaColor); 50 float w_normal = - d_normal2/(2*m_sigmaNormal*m_sigmaNormal); 51 float w_plane = - d_plane2/(2*m_sigmaPlane*m_sigmaPlane); 52 53 weight = exp(w_coord+w_color+w_normal+w_plane); 54 // accumulate weight and color buffer 55 weight_sum += weight; 56 color_sum += sampledColor * weight; 57 } 58 } 59 // protect 0 weight 60 if(weight_sum == 0.0f){ 61 filteredImage(x, y) = color; 62 } 63 else{ 64 filteredImage(x, y) = color_sum / weight_sum; 65 } 66 } 67 } 68 } 69 return filteredImage; 70} 加速效果也是十分明显的，ATWFilter 比 Filter 几乎快了两分钟，但是就单帧降噪质量来说是不如 Filter 的。\n0x06 Afterwords 所以闫大的离线渲染课什么时候开 :p\n","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/games202-homework-5/","summary":"\u003cblockquote\u003e\n\u003cp\u003e202 系列完结撒花 ✿✿ヽ(°▽°)ノ✿！\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 部分课程内容回顾\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e GAMES202 作业 5\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📺B 站视频：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV1YK4y1T7yY/?spm_id_from=333.337.top_right_bar_window_custom_collection.content.click\u0026amp;vd_source=b6584cebba3a7a1a34d2f60d63bdc868\"\u003eGAMES202-高质量实时渲染\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📦代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/congyuxiaoyoudao/GAMES202_Homework/tree/working\"\u003econgyuxiaoyoudao/GAMES202_Homework at working\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://games-cn.org/forums/topic/zuoye5fabugonggao/\"\u003e作业5发布公告及场景下载\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://zhuanlan.zhihu.com/p/607012514\"\u003eGAMES202-作业5：实时光线追踪降噪\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003emaster 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-问题修复-troubleshooting\"\u003e0x01 问题修复 TroubleShooting\u003c/h2\u003e\n\u003cp\u003e做作业时遇到一些出现问题的地方，这里一并提一下：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e运行 build.bat 后 build 目录下没有出现可执行文件\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eReadFloatImage\u003c/code\u003e 方法报错\u003c/li\u003e\n\u003cli\u003emathutil.h 中 \u003ccode\u003euint32_t\u003c/code\u003e was not declared in this scope\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e第一个问题纯属对 Cmake 不熟练，build.bat 提供的命令只是生成工程文件，要运行构建还需要如下命令（或者直接用 VS 等 IDE 运行）：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003ecmake --build build\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这里为了简化运行操作，新增了一个 run.bat：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003ecmake --build build\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003ecd\u003c/span\u003e build\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003eDenoise.exe\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003ecd\u003c/span\u003e ..\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e第二个问题为路径错误，下载完场景后我的根目录文件结构如下：\u003c/p\u003e","title":"Assignment 8. GAMES202 Homework 5"},{"content":" 好久不见！最近在忙别的工作，这几天难得有时间，赶紧把 202 系列作业做个收尾ε٩(๑\u0026gt; ₃ \u0026lt;)۶з！\n0x00 To begin with 这篇文章将会包含以下内容：\n部分课程内容回顾 GAMES202 作业 4 For reference👇：\n📺B 站视频： GAMES202-高质量实时渲染 📦代码仓库： congyuxiaoyoudao/GAMES202_Homework at working 📃 Revisiting Physically Based Shading at Imageworks master 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\n0x01 课程回顾 Recap 能量守恒的 BSDF Energy-Preserving BSDFs Cook-Torrance 提出的微表面 BRDF 只考虑光线在表面经过一次弹射后出射到观察方向的能量，然而忽略了多次弹射最终出射到观察方向的能量，这就造成了能量损失。尤其是当材质的 roughness 较高时，G 项减小，多次弹射的比例增加，能量损失越严重。\n2017 年 Kulla 和 Conty 受到 Disney Principle BRDF 的启发，提出了一种改进的方法，即通过引入一个能量补偿项，将多次弹射出射到观察方向的能量补充回来，就能近似地保持能量守恒。\n有了渲染方程，我们可以很容易地求得微表面 BRDF 在观察方向 $\\mu_{o}$ 上出射的总能量：\n$$ E(\\mu_{o})=\\int_{0}^{2\\pi}\\int_{0}^{1}f(\\mu_{o},\\mu_{i},\\phi)\\mu_{i}d\\mu_{i}d\\phi $$ 这里记 $\\mu_{i}=\\sin \\theta,\\theta=n\\cdot \\mu_{i}$，之后就可以将 $f_{r}\\cos \\theta \\sin \\theta d\\theta d\\phi$ 写为 $f_{r}\\mu_{i} d\\mu_{i}d\\phi$。然而原 PPT 里提到的是记为 $\\cos \\theta$，不过就积分结果来说都一样\n$E(\\mu_{o})$ 的范围为 0-1，对于不同的 $\\mu_{o}$，$E(\\mu_{o})$ 的值也会发生变化。微表面 BRDF 的 $E(\\mu_{o})$ 会随着 roughness 的增加而衰减。为了弥补这部分能量，Kulla 和 Conty 设计了一个新的 BRDF lobe：\n$$ f_{ms}=\\frac{(1-E(\\mu_{o}))(1-E(\\mu_{i}))}{\\pi(1-E_{avg})},E_{avg}=2\\int_{0}^1E(\\mu)\\mu d\\mu $$ 其中 $f_{ms}$ 为弥补损失的 BRDF，$E_{avg}$ 为在所有观察方向下出射能量的平均值。同样计算这个 BRDF 在观察方向 $\\mu_{o}$ 上的积分：\n$$ \\begin{aligned} E_{ms}(\\mu_{o})\u0026=\\int_{0}^{2\\pi}\\int_{0}^{1}f_{ms}(\\mu_{o},\\mu_{i},\\phi)\\mu_{i}d\\mu_{i}d\\phi \\\\ \u0026=2 \\int_{0}^1 \\frac{(1-E(\\mu_{o}))(1-E(\\mu_{i}))}{\\pi(1-E_{avg})}\\mu_{i}d\\mu_{i}\\\\ \u0026=2 \\frac{1-E(\\mu_{o})}{1-E_{avg}}\\int_{0}^1(1-E(\\mu_{i}))\\mu_{i}d\\mu_{i}\\\\ \u0026= \\frac{1-E(\\mu_{o})}{1-E_{avg}}(1-E_{avg})\\\\ \u0026= 1-E(\\mu_{o}) \\end{aligned} $$ 将其加到原来的微表面 BRDF 上即可弥补损失：\n$$ E(\\mu_{o})+E_{ms}(\\mu_{o})\\equiv 1 $$ 计算 $E(\\mu)$ 和 $E_{avg}$ 都需要积分，我们可以在离线端预先计算好这两项，将其按需要的变量打表，存储在纹理上，然后渲染时对应查询即可。PPT 中指出 32×32 的大小的纹理就足够了，作业中则使用了更大的 128×128。\n颜色表面 Colored Surfaces 在上一节中，表面将所有入射的光全部反射，即光线不存在被吸收或者透射的情况。然而，绝大多数表面是带有颜色的，即其会吸收某种成分的光，这种情况下的能量损失是符合物理的，即 $E(\\mu)\u003c1$。\nKulla 和 Conty 受到 Jakob 等人的启发，多次弹射下能量是 diffuse 的，所以可以使用余弦加权的 Fresnel 项代表平均被反射的能量：\n$$ F_{avg}=2\\int_{0}^1F(\\mu)\\mu d\\mu $$ 我们已经知道所有反射出射的能量比例为 $E_{avg}$，那么因为微表面的遮挡而没有出射的能量比例则为 $1-E_{avg}$，这部分能量（即发生了二次 bounce）中又有 $F_{avg}$ 的比例能够被反射，所以经过 $n$ 次反射出射到观察方向的能量可以归纳为：\n$$ \\begin{aligned} E_{0}\u0026=F_{avg}E_{avg}\\\\ E_{1}\u0026=F_{avg}(1-E_{avg})F_{avg}E_{avg}\\\\ \\dots\\\\ E_{n}\u0026=F_{avg}^n (1-E_{avg})^n F_{avg}E_{avg} \\end{aligned} $$ 很显然这是一个等比数列，将其求和后即可得到所有经过多次弹射最终出射到观察方向的总能量：\n$$ E=F_{avg}E_{avg}\\sum_{k=0}^{\\infty}F_{avg}^k(1-E_{avg})^k=\\frac{F_{avg}E_{avg}}{1-F_{avg}(1-E_{avg})} $$ $E_{avg}$ 可以通过预计算得到，但是 $F_{avg}$ 无法这么做。所以 Kulla 和 Conty 分别对介电质和导体列出了两种前人用到的数值近似方法：\n如此，在渲染时即可采样 $E_{avg}$ 和根据表面性质近似 $F_{avg}$ 计算出颜色表面的出射能量比例，然后再将其与上节中没有能量吸收的 $f_{ms}$ 相乘即可。\n0x02 问题修复 TroubleShooting 做作业时遇到一些出现问题的地方，这里一并提一下：\n找不到 vec.h 未定义标识符“M_PI” uniform 变量 uEavgFLut 命名错误 第一个问题是配置问题，用 vscode 在 task.json 的 args 字段加上：\n1 \u0026#34;-I\u0026#34;, 2\u0026#34;${workspaceFolder}/assignment4/lut-gen/ext\u0026#34;, 第二个问题把所有 M_PI 改成 PI。\n第三个问题在 KullaContyMaterial.js 中，调用父类构造函数时传入了命名和 Shader 中不对应的 uniform 变量，把 uEavgFLut 改成 uEavgLut 即可。\n0x03 预积分 $E(\\mu)$ 蒙特卡洛求解 Monte Carlo 在 Emu_MC.cpp 的 IntegrateBRDF 中完成对 $E(\\mu)$ 的积分，使用蒙特卡洛方法在半球上均匀采样入射光方向，求解这个积分。积分内部为 BRDF 以及 cos 项：\n$$ f_{r}(i,o)=\\frac{F(i,h)G(i,o,h)D(h)}{4(n\\cdot i)(n\\cdot o)},\\cos \\theta=n\\cdot i $$ F，G，D 的实现框架中均已给出，按需调用即可。\n1Vec3f IntegrateBRDF(Vec3f V, float roughness, float NdotV) { 2 float A = 0.0; 3 float B = 0.0; 4 float C = 0.0; 5 const int sample_count = 1024; 6 Vec3f N = Vec3f(0.0, 0.0, 1.0); 7 8 samplePoints sampleList = squareToCosineHemisphere(sample_count); 9 for (int i = 0; i \u0026lt; sample_count; i++) { 10 // calculate (fr * ni) / p_o here 11 Vec3f L = sampleList.directions[i]; 12 float pdf = sampleList.PDFs[i]; 13 14 float NdotL = dot(N,L); 15 Vec3f H = normalize(V+L); 16 17 // here we assume all lights are reflected, no absorption! 18 // deal with color later 19 float F = 1.0; 20 float NDF = DistributionGGX(N, H, roughness); 21 float G = GeometrySmith(roughness, NdotV, NdotL); 22 23 float nom = F * NDF * G; 24 float denom = std::max(4.0 * NdotL * NdotV,0.001); 25 float fr = nom / denom; 26 27 // accumulate each component 28 A = B = C +=fr*NdotL/pdf; 29 30 } 31 32 return {A / sample_count, B / sample_count, C / sample_count}; 33} 运行程序即可得到这样一张 LUT：\n可以看到随着粗糙度降低（自上而下），噪声越来越明显。这是因为低粗糙度的微表面法线 $m$ 集中在宏观法线 $n$ 附近，但通过随机采样入射光方向 $i$ 计算得到的半程向量 $h$ 则不然，因此导致低粗糙度下积分值的方差显著增加。但我们可以通过对微表面法线 $m$ 进行重要性采样来改善这个问题，即使得采样的 $m$ 更多地位于 $n$ 附近。\n重要性采样求解 Importance Sampling 最终的任务仍然是采样 $i$ 和计算 $\\frac{f_{r}(i,o,h)(i,n)}{pdf_{i}(i)}$，但现在 $i$ 并不由直接采样得到，而是根据某个微表面法线分布模型重要性采样得到的微表面法向 $m$ 间接求解。\n框架中使用的 NDF 为 GGX 分布，我们需要根据该分布生成采样的微表面法向 $m$，再由 $m$ 和 $o$ 反求入射方向 $i$。\n由微表面法线分布模型的性质，可以计算出采样 $m$ 的概率密度：\n$$ pdf_{m}(m)=D(m)(m\\cdot n) $$ 通过该概率密度，可以计算出该分布下对应的采样点：\n$$ \\begin{aligned} \\theta_{m}\u0026=\\arctan\\left( \\frac{\\alpha \\sqrt{ \\xi_{1} }}{\\sqrt{ 1-\\xi_{1} }} \\right)\\\\ \\phi_{h}\u0026=2\\pi \\xi_{2} \\end{aligned} $$ 其中，$\\alpha=roughness^{2},\\xi_{1},\\xi_{2}\\in[0,1)$。\n在 Emu_IS.cpp 的 ImportanceSampleGGX 函数中，完成重要性采样的逻辑，并返回切线空间的微表面法线 $h$。\n1Vec3f ImportanceSampleGGX(Vec2f Xi, Vec3f N, float roughness) { 2 float a = roughness * roughness; 3 4 // in spherical space - Bonus 1 5 float Xi1 = Xi.x; 6 float Xi2 = Xi.y; 7 float theta = atan(a*sqrt(Xi1)/sqrt(1-Xi1)); 8 float phi = 2.0 * PI * Xi2; 9 10 // from spherical space to cartesian space - Bonus 1 11 float x = cos(phi)*sin(theta); 12 float y = sin(phi)*sin(theta); 13 float z = cos(theta); 14 15 // tangent coordinates - Bonus 1 16 Vec3f up = abs(N.z) \u0026lt; 0.999 ? Vec3f(0.0, 0.0, 1.0) : Vec3f(1.0, 0.0, 0.0); // N is fixed 001 so not select 001 17 Vec3f tangent = normalize(cross(up,N)); 18 Vec3f bitangent = cross(N,tangent); 19 20 // transform H to tangent space - Bonus 1 21 Vec3f H = normalize(tangent*x + bitangent*y + N*z); 22 return H; 23} 接下来计算 $\\frac{f_{r}(i,o,h)(i,n)}{pdf_{i}(i)}$ 的值，需要先将采样 $m$ 的概率密度转化为采样 $i$ 的概率密度：\n$$ pdf_{i}(i)=pdf_{m}(m)\\left\\lvert \\left\\lvert \\frac{\\partial\\omega_{m}}{\\partial \\omega_{i}} \\right\\rvert \\right\\rvert =\\frac{D(m)(m\\cdot n)}{4(i\\cdot m)} $$ 所以最终需要累加的权重为：\n$$ \\begin{aligned} weight(i)\u0026=\\frac{f_{r}(i,o,h)(i,n)}{pdf_{i}(i)} \\\\ \u0026=\\frac{F(i,m)G(i,o,m)D(m)}{4(n\\cdot i)(n\\cdot o)}\\cdot(n\\cdot i)\\cdot \\frac{4(i\\cdot m)}{D(m)(n\\cdot m)} \\\\ \u0026=\\frac{(o\\cdot m)G(i,o,h)}{(o\\cdot n)(m\\cdot n)} \\end{aligned} $$ 这里暂且考虑所有能量都被反射，即 F 为 1；最后留在分子上的实际上是 $i\\cdot m$，但是 $i\\cdot m=o\\cdot m$，所以可以替换\n在 Emu_IS.cpp 的 IntegrateBRDF 函数中每次采样累加该权重即可：\n1Vec3f IntegrateBRDF(Vec3f V, float roughness) { 2 3 const int sample_count = 1024; 4 Vec3f N = Vec3f(0.0, 0.0, 1.0); 5 Vec3f Emu = Vec3f(0.0f); 6 for (int i = 0; i \u0026lt; sample_count; i++) { 7 Vec2f Xi = Hammersley(i, sample_count); 8 Vec3f H = ImportanceSampleGGX(Xi, N, roughness); 9 Vec3f L = normalize(H * 2.0f * dot(V, H) - V); 10 11 float NoL = std::max(L.z, 0.0f); 12 float NoH = std::max(H.z, 0.0f); 13 float VoH = std::max(dot(V, H), 0.0f); 14 float NoV = std::max(dot(N, V), 0.0f); 15 16 // calculate (fr * ni) / p_o here - Bonus 1 17 float w = VoH * GeometrySmith(roughness,NoV,NoL)/(NoV*NoH); 18 Emu += Vec3f(w); 19 // Split Sum - Bonus 2 20 } 21 22 return Emu / sample_count; 23} 完成重要性采样之后得到的 $E(\\mu)$ 就没有噪点了：\n0x04 预积分 $E_{avg}$ 预积分 $E_{avg}$ 的过程比较简单，甚至因为 $E(\\mu),\\mu$ 都是定值直接返回 $2E(\\mu)\\mu$ 即可。\n1Vec3f IntegrateEmu(Vec3f V, float roughness, float NdotV, Vec3f Ei) { 2 return Ei * NdotV * 2.0f; 3 Vec3f Eavg = Vec3f(0.0f); 4 const int sample_count = 1024; 5 Vec3f N = Vec3f(0.0, 0.0, 1.0); 6 7 for (int i = 0; i \u0026lt; sample_count; i++) 8 { 9 Vec2f Xi = Hammersley(i, sample_count); 10 Vec3f H = ImportanceSampleGGX(Xi, N, roughness); 11 Vec3f L = normalize(H * 2.0f * dot(V, H) - V); 12 13 float NoL = std::max(L.z, 0.0f); 14 float NoH = std::max(H.z, 0.0f); 15 float VoH = std::max(dot(V, H), 0.0f); 16 float NoV = std::max(dot(N, V), 0.0f); 17 18 // calculate Eavg here - Bonus 1 19 Eavg += Ei * NoV * 2.0f; 20 } 21 22 return Eavg / sample_count; 23} 求解 $E_{avg}$ 的过程仅依赖于采样到的 $E(\\mu)$ ，所以 Eavg_IS.cpp 和 E_avg_MC.cpp 的实现是一样的。\n运行程序可以得到这样一张 LUT：\n如果正确实现了 $E(\\mu)$ 的重要性采样，上图就会变得 Smooth 一点：\n0x05 实时渲染 Realtime Rendering PBR 材质的实现就不贴了，公式已经给出了，可以直接搬离线端的实现\n在实时端需要采样预积分的 $E(\\mu)$ 和 $E_{avg}$ 的 LUT，然后仍然是套公式计算能量守恒的 BRDF：\n$$ f_{r}=f_{micro}+f_{add}*f_{ms} $$ 在 MultiScatterBRDF 函数中，需要完成对 $f_{add},f_{ms}$ 的计算：\n$$ \\begin{aligned} f_{add}\u0026=\\frac{F_{avg}E_{avg}}{1-F_{avg}(1-E_{avg})}\\\\ f_{ms}(\\mu_{o},\\mu_{i})\u0026=\\frac{(1-E(\\mu_{o}))(1-E(\\mu_{i}))}{\\pi(1-E_{avg})} \\end{aligned} $$ 1vec3 MultiScatterBRDF(float NdotL, float NdotV) 2{ 3 vec3 albedo = pow(texture2D(uAlbedoMap, vTextureCoord).rgb, vec3(2.2)); 4 5 vec3 E_o = texture2D(uBRDFLut, vec2(NdotL, uRoughness)).xxx; 6 vec3 E_i = texture2D(uBRDFLut, vec2(NdotV, uRoughness)).xxx; 7 8 vec3 E_avg = texture2D(uEavgLut, vec2(0, uRoughness)).xxx; 9 // copper 10 vec3 edgetint = vec3(0.827, 0.792, 0.678); 11 vec3 F_avg = AverageFresnel(albedo, edgetint); 12 13 // calculate fms and missing energy here 14 vec3 f_add = F_avg * E_avg / (1.0 - F_avg * (1.0 - E_avg)); 15 vec3 f_ms = (1.0 - E_o) * (1.0 - E_i) / (PI * (1.0 - E_avg)); 16 return f_add*f_ms; 17 18} 完成后即可达到如图所示的效果：\n0x06 Split Sum 仍然是计算 $E(\\mu)$，结果保存到一张与 roughness 和曲率相关的 LUT 中，我们可以把 F 项从 BRDF 中分离出来（这个思路在 Lecture5 Real-time Environment Mapping 1h 15min 左右有提及）：\n$$ \\begin{aligned} E\u0026=\\int_{\\Omega^+}f_{r}(n\\cdot i) d\\omega_{i} \\\\ \u0026=\\int_{\\Omega^+} \\frac{f_{r}}{F}(n\\cdot i) Fd\\omega_{i}\\\\ \u0026=\\int_{\\Omega^+} \\frac{f_{r}}{F}(n\\cdot i) (F_{0}+(1-F_{0})(1-\\cos \\theta)^5)d\\omega_{i}\\\\ \u0026=\\int_{\\Omega^+} \\frac{f_{r}}{F}(n\\cdot i) (F_{0}+(1-F_{0})\\alpha)d\\omega_{i}\\\\ \u0026=\\int_{\\Omega^+} \\frac{f_{r}}{F}(n\\cdot i) (F_{0}(1-\\alpha)+\\alpha)d\\omega_{i}\\\\ \u0026=F_{0}\\int_{\\Omega^+} \\frac{f_{r}}{F}(n\\cdot i) (1-\\alpha)d\\omega_{i}+\\int_{\\Omega^+} \\frac{f_{r}}{F}(n\\cdot i) \\alpha d\\omega_{i} \\end{aligned} $$ 记 $\\alpha=(1-\\cos \\theta)^5$\n更改一下 IntegrateBRDF，将这两个 Split 的结果分别存入 LUT 的 GB 通道，R 通道仍然存原来的 $E(\\mu)$：\n然后在实时端重载一个 MultiScatterBRDF，增加一个 F0 作为参数，用两个 Split 的积分还原 $E(\\mu),E_{avg}$：\n调用这个新的函数并传入 F0 即可看到使用 Split Sum 计算出来的结果：\n","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/games202-homework-4/","summary":"\u003cblockquote\u003e\n\u003cp\u003e好久不见！最近在忙别的工作，这几天难得有时间，赶紧把 202 系列作业做个收尾ε٩(๑\u0026gt; ₃ \u0026lt;)۶з！\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 部分课程内容回顾\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e GAMES202 作业 4\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📺B 站视频：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV1YK4y1T7yY/?spm_id_from=333.337.top_right_bar_window_custom_collection.content.click\u0026amp;vd_source=b6584cebba3a7a1a34d2f60d63bdc868\"\u003eGAMES202-高质量实时渲染\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📦代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/congyuxiaoyoudao/GAMES202_Homework/tree/working\"\u003econgyuxiaoyoudao/GAMES202_Homework at working\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📃 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://blog.selfshadow.com/publications/s2017-shading-course/imageworks/s2017_pbs_imageworks_slides_v2.pdf\"\u003eRevisiting Physically Based Shading at Imageworks\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003emaster 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-课程回顾-recap\"\u003e0x01 课程回顾 Recap\u003c/h2\u003e\n\u003ch3 id=\"能量守恒的-bsdf-energy-preserving-bsdfs\"\u003e能量守恒的 BSDF Energy-Preserving BSDFs\u003c/h3\u003e\n\u003cp\u003eCook-Torrance 提出的微表面 BRDF 只考虑光线在表面经过一次弹射后出射到观察方向的能量，然而忽略了多次弹射最终出射到观察方向的能量，这就造成了能量损失。尤其是当材质的 roughness 较高时，G 项减小，多次弹射的比例增加，能量损失越严重。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"Missing Energy of Microfacet BRDF, Revisiting Physically Based Shading at Imageworks, SIGGRAPH 2017\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Assignments/Assignment%207.GAMES202HW4/202506151740381.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e2017 年 Kulla 和 Conty 受到 Disney Principle BRDF 的启发，提出了一种改进的方法，即通过引入一个能量补偿项，将多次弹射出射到观察方向的能量补充回来，就能近似地保持能量守恒。\u003c/p\u003e","title":"Assignment 7. GAMES202 Homework 4"},{"content":" HiZ 还在施工中……\n0x00 To begin with 这篇文章将会包含以下内容：\n部分课程内容回顾 GAMES202 作业 3 For reference👇：\n📺B 站视频： GAMES202-高质量实时渲染 📦代码仓库： congyuxiaoyoudao/GAMES202_Homework at working master 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\n0x01 课程回顾 Recap SSR（Screen Space Reflection）是一种基于屏幕空间的反射算法，也被应用于模拟全局光照。因为是屏幕空间的算法，不能且无需像传统全局光照算法那样需要获取场景的原始几何信息，而是只在当前相机的视野范围计算反射，所以效率相对较高，但也存在一些屏幕空间的 Artifacts。\nSSR 算法有两个假设：\n屏幕范围内的反射光，其反射源也同样来自屏幕空间的某个像素； 所有被直接光照亮的物体（在屏幕空间中则表现为像素），都可以作为次级光源参与对反射的贡献； SSR 对反射表面没有要求，即不仅能模拟镜面反射，还可以模拟各种粗糙度的反射\nSSR 算法的步骤如下：\n对每个需要计算反射的像素沿反射方向发射一条光线； 沿光线方向步进在屏幕空间中查找深度缓冲并与场景求交； 若有交点，则将交点颜色作为反射颜色添加到原始像素上； 其中最关键的步骤就是光线求交，最简单的方法就是 Linear RayMarching，光线每次行进一个固定的距离，每次行进后获取该位置的深度值再与场景深度进行比较，如果某次判断光线深度大于场景深度，则认为发生了相交。\n当然这种方法的精细度很依赖光线步长，小的步长效果更好但消耗大，反之，更大的步长则可能导致光线“穿过”场景而误判相交，造成错误计算反射的情况。\n为了在提高精度的同时节省开销，研究人员又提出了一种动态步长的方法，即 Hierarchical ray trace。允许光线根据场景信息在行进期间选取不同的步长，例图中光线一开始至少可以以 7 倍的 gap 作为步长，之后再缩减到原始的步长。\n说到实时渲染中的一些动态操作，几乎就离不开预处理。同样的，这种方法运行的前提是，光线需要知道行进的“最大安全距离”，即可以迈多大的步子而不与场景发生相交。\n这里最大安全距离是类比 SDF 的说法，或者应该说当前层级规定的步长\n一种实现它的方法是预先生成深度图的 Mip-Map，这里四个像素的最小值作为 Mip 生成的逻辑，具体原因涉及到比较的保号性：如果光线与上一级 Mip 都不相交（$D_{ray}","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/games202-homework-3/","summary":"\u003cblockquote\u003e\n\u003cp\u003eHiZ 还在施工中……\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 部分课程内容回顾\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e GAMES202 作业 3\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📺B 站视频：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV1YK4y1T7yY/?spm_id_from=333.337.top_right_bar_window_custom_collection.content.click\u0026amp;vd_source=b6584cebba3a7a1a34d2f60d63bdc868\"\u003eGAMES202-高质量实时渲染\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📦代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/congyuxiaoyoudao/GAMES202_Homework/tree/working\"\u003econgyuxiaoyoudao/GAMES202_Homework at working\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003emaster 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-课程回顾-recap\"\u003e0x01 课程回顾 Recap\u003c/h2\u003e\n\u003cp\u003eSSR（Screen Space Reflection）是一种基于屏幕空间的反射算法，也被应用于模拟全局光照。因为是屏幕空间的算法，不能且无需像传统全局光照算法那样需要获取场景的原始几何信息，而是只在当前相机的视野范围计算反射，所以效率相对较高，但也存在一些屏幕空间的 Artifacts。\u003c/p\u003e\n\u003cp\u003eSSR 算法有两个假设：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e屏幕范围内的反射光，其反射源也同样来自屏幕空间的某个像素；\u003c/li\u003e\n\u003cli\u003e所有被直接光照亮的物体（在屏幕空间中则表现为像素），都可以作为次级光源参与对反射的贡献；\u003c/li\u003e\n\u003c/ol\u003e\n\u003cblockquote\u003e\n\u003cp\u003eSSR 对反射表面没有要求，即不仅能模拟镜面反射，还可以模拟各种粗糙度的反射\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cimg alt=\"重用屏幕空间信息 GAMES202\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Assignments/Assignment%206.GAMES202HW3/202505192155513.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003eSSR 算法的步骤如下：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e对每个需要计算反射的像素沿反射方向发射一条光线；\u003c/li\u003e\n\u003cli\u003e沿光线方向步进在屏幕空间中查找深度缓冲并与场景求交；\u003c/li\u003e\n\u003cli\u003e若有交点，则将交点颜色作为反射颜色添加到原始像素上；\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e其中最关键的步骤就是光线求交，最简单的方法就是 Linear RayMarching，光线每次行进一个固定的距离，每次行进后获取该位置的深度值再与场景深度进行比较，如果某次判断光线深度大于场景深度，则认为发生了相交。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"固定步长的光线步进 GAMES202\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Assignments/Assignment%206.GAMES202HW3/202505192214607.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e当然这种方法的精细度很依赖光线步长，小的步长效果更好但消耗大，反之，更大的步长则可能导致光线“穿过”场景而误判相交，造成错误计算反射的情况。\u003c/p\u003e\n\u003cp\u003e为了在提高精度的同时节省开销，研究人员又提出了一种动态步长的方法，即 Hierarchical ray trace。允许光线根据场景信息在行进期间选取不同的步长，例图中光线一开始至少可以以 7 倍的 gap 作为步长，之后再缩减到原始的步长。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"层级光线追踪 GAMES202\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Assignments/Assignment%206.GAMES202HW3/202505192226586.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e说到实时渲染中的一些动态操作，几乎就离不开预处理。同样的，这种方法运行的前提是，光线需要知道行进的“最大安全距离”，即可以迈多大的步子而不与场景发生相交。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e这里最大安全距离是类比 SDF 的说法，或者应该说当前层级规定的步长\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e一种实现它的方法是预先生成深度图的 Mip-Map，这里四个像素的最小值作为 Mip 生成的逻辑，具体原因涉及到比较的保号性：如果光线与上一级 Mip 都不相交（$D_{ray}\u003cD_{M_{i+1}}$），则其一定与下级不相交（$D_{ray}\u003cany_{4}\\{D_{M_{i}}\\}$ ）。\u003c/p\u003e","title":"Assignment 6. GAMES202 Homework 3"},{"content":" (∩^o^)⊃━☆ﾟ.*･｡\n0x00 To begin with 这篇文章将会包含以下内容：\n部分课程内容回顾 GAMES202 作业 2 For reference👇：\n📺B 站视频： GAMES202-高质量实时渲染 📦代码仓库： congyuxiaoyoudao/GAMES202_Homework at working 📃 The rendering equation | ACM SIGGRAPH Computer Graphics 📃 Precomputed radiance transfer for real-time rendering in dynamic, low-frequency lighting environments | ACM Transactions on Graphics master 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\n0x01 前置准备 Warm Up 渲染方程 Render Equation James T. Kajiya（没错就是那个提出 Kajiya-Kay 头发着色模型的 Kajiya）在 1986 年提出了渲染方程（The Render Equation），原论文里长这样：\n$$ I(x,x')=g(x,x')\\left[ \\epsilon(x,x')+\\int_{S}\\rho(x,x',x'')I(x',x'')dx'' \\right] $$ 当然现在看得更舒服的形式长这样：\n$$ L_{o}(p,\\omega_{o})=L_{e}(p,\\omega_{o})+\\int_{\\Omega^+}L_{i}(p,\\omega_{i})f_{r}(p,\\omega_{i},\\omega_{o})\\cos\\theta_{i}\\space d\\omega_{i} $$ 其中：\n$L_{o}(p,\\omega_{o})$：要求出射方向的辐射率（Radience） $L_{e}(p,\\omega_{o})$：自发光项 $\\Omega^+$：积分域，仅对表面正半球积分 $L_{i}(p,\\omega_{i})$：Lighting，某一入射方向的辐射率 $f_{r}(p,\\omega_{i},\\omega_{o})$：BRDF，描述某一入射方向的光线在出射方向的贡献 $\\cos \\theta_{i}$：表面法向与某一入射方向形成的夹角余弦 所以渲染方程其实就描述了在当前 Shading Point 上，给定入射光与出射方向（观察方向），在出射方向上能够接收到的 Radiance，任何在表面上进行的光线传播的 Shading 都需要解这个方程。\nSplit Sum 对于环境光，通常使用 CubeMap 或者等距柱形投影将图像投射到无限远的球面上，要获得环境光的 Shading（目前暂不考虑 Shadowing），就需要求解渲染方程，核心是对于 Lighting 部分的求解。\n先抛开渲染方程，想象一下当表面是理想镜面的情况：对于某个 Shading Point，其反射行为是镜面反射，那么我们可以很容易地求出射方向对应的入射方向，根据这个方向查询环境光的颜色。\n更 General 一点，当表面比较粗糙的情况，能够反射到出射方向的入射角在镜反射方向上会在某个角度 $\\phi$ 范围内抖动分布，最终反射到出射方向的 Lighting 受到这个范围内所有采样点颜色的影响。这个过程可以类比于对原始的环境贴图进行滤波，且粗糙度越大，滤波核越大，这样就可以仍使用镜反射方向查询滤波后的图像。这些滤波后的图像当然可以预计算，形成一个模糊层级，然后渲染时根据不同粗糙度使用不同图像。\n但是目前 Lighting 项在积分内部，也就是说我们要对整个半球面上的入射方向都进行一次查询，还是比较 Costy 的，有没有办法把 Lighting 从积分里独立出来？下面有一种近似方法：\n$$ \\int_{\\Omega}f(x)g(x)dx\\approx \\frac{\\int_{\\Omega_{G}}f(x)dx}{\\int_{\\Omega_{G}}dx} \\cdot \\int_{\\Omega}g(x)dx $$ 这个近似在 $g(x)$ 贡献比较小或者比较平滑（低频）时是相对准确的。漫反射 BRDF 就很好地满足这个性质，所以可以对渲染方程应用这个近似：\n$$ L_{o}(p,\\omega_{o})\\approx\\frac{\\int_{\\Omega_{f_{r}}}L_{i}(p,\\omega_{i})d\\omega_{i}}{\\int_{\\Omega_{f_{r}}}d\\omega_{i}}\\cdot \\int_{\\Omega^+}f_{r}(p,\\omega_{i},\\omega_{o})\\cos \\theta_{i} \\space d\\omega_{i} $$ 乘号左边的部分代表 Lighting 在 BRDF 范围内的平均 Radience，可以认为是对出射方向有贡献的入射方向范围内在环境贴图上采样颜色的平均值。通过对环境贴图的预滤波，可以通过一次查询得到这个值。\n球谐函数 Spherical Harmonics 球谐函数 维基百科上球谐函数是下面这个式子在 $m,l$ 不同取值时的解：\n$$ Y^m_{l}(\\theta, \\phi)=(-1)^m\\sqrt{ \\frac{2l+1}{4\\pi}\\frac{(l-|m|)!}{(l+|m|)!} }P^m_{l}(\\cos \\theta)e^{im\\phi} $$ 实数形式下可视化出来是长这样的：\n看上去是不是很像物理/量子化学里面的电子云啥的（这俩还真有关系），但在图形学里面，我们不需要理解其物理意义，可以简单将其理解为一组定义在球面上的两两正交的基函数，有了它我们可以使用傅里叶变换的思想去拟合任何的球面函数。\n基函数（Basis Function）：一组两两之间相互独立的函数，即在彼此之上的投影为 0，可以类比线性代数的基向量。一组基函数张成的空间是其经过线性组合能够表示的所有函数的集合\n$$ \\mathcal{F} = \\left\\{ f(x) = \\sum_{i=1}^n c_{i}B_{i}(x) \\,\\middle|\\, c_{i} \\in \\mathbb{R} \\right\\} $$ 可以通过将原函数投影到某个基函数上获取该基函数前的系数 $c_{i}$：\n$$ c_{i}=\\int_{\\Omega}f(x)B_{i}(x)\\,dx $$ 实际使用时需要把积分离散化为一系列求和的形式，给定一个函数 $f(\\omega_{i})$，可以通过投影求得其在 $Y^m_{l}(\\omega_{i})$ 上的系数：\n$$ c^m_{l}\\approx\\sum_{i=1}^Nf(\\omega_{i})\\cdot Y^m_{l}(\\omega_{i})\\cdot \\omega_{i} $$ 然后就可以用类似泰勒展开的样子用基函数表示原函数：\n$$ \\begin{aligned} f(x)=\u0026\\sum^{\\infty}_{l=0}\\sum^l_{m=-l}c^m_{l}\\cdot Y^m_{l}(\\omega_{i}) \\\\ =\u0026 \\, c^0_{0}Y^0_{0}(\\omega_{i})+c^1_{-1}Y^1_{-1}(\\omega_{i})+c^1_{0}Y^1_{0}(\\omega_{i})+\\,\\dots \\end{aligned} $$ 对于低频的函数，只取前几项进行近似，就能达到 Adequate 的效果。如果 BRDF 是粗糙的，就相当于一个低通滤波器，其和 Lighting 的乘积的积分仍然是低频的。\nPRT（Precomputerd Radience Transfer） 球谐函数用一组基函数拟合渲染方程中的 Lighting 项，解决了 Shading 的问题，如果需要 Shadowing，就需要加入可见性项 $V(p,\\omega_{i})$，渲染方程变为：\n$$ L_{o}(p,\\omega_{o})=\\int_{\\Omega} \\underbrace {L_{i}(p,\\omega_{i})}_{Lighting} \\, \\underbrace {f_{r}(p,\\omega_{i},\\omega_{o})}_{BRDF} \\, \\underbrace {V(p,\\omega_{i})}_{Visibility} \\, \\max(0,\\cos \\theta_{i})\\,d\\omega_{i} $$ 暴力算法就是朝各个方向分别对 Lighting，BRDF 和 Visibility 计算，然后再逐项相乘，开销太大。Sloan 在 2002 年提出了PRT（Precomputed Radience Transfer），将积分内部分为 Lighting 和 Light Transport（光线传输）两个部分：\n$$ L_{o}(p,\\omega_{o})=\\int_{\\Omega} \\underbrace {L_{i}(p,\\omega_{i})}_{Lighting} \\, \\underbrace {f_{r}(p,\\omega_{i},\\omega_{o}) \\, V(p,\\omega_{i}) \\, \\max(0,\\cos \\theta_{i})}_{Light \\,Transport} \\, d\\omega_{i} $$ 假设 BRDF 项是 Diffuse 的，BRDF 项是一个定值 $\\frac{\\rho}{\\pi}$，将 Lighting 表示为系数和基函数的形式，然后交换积分与求和的位置，就可以将积分内部再次表示为在基函数上的投影：\n$$ \\begin{aligned} L(p,\\omega_{o})=\u0026 \\frac{\\rho}{\\pi} \\int_{\\Omega}\\sum l_{i}B_{i}(\\omega_{i})V(p,\\omega_{i})\\max(0,\\cos \\theta_{i}) \\, d\\omega_{i} \\\\ \\approx\u0026 \\frac{\\rho}{\\pi} \\sum l_{i} \\underbrace{ \\int_{\\Omega}B_{i}(\\omega_{i})V(p,\\omega_{i})\\max(0,\\cos \\theta_{i}) }_{Precomputed} \\, d\\omega_{i} \\\\ \\approx\u0026 \\frac{\\rho}{\\pi} \\sum l_{i}T_{i} \\end{aligned} $$ 注意这里是对 $\\omega_{i}$ 进行积分，对每一个基函数上的投影进行求和\n其中 $l_{i}$ 是 Lighting 项在基函数上投影的一组系数，$T_{i}$ 是 Light Transport 项在基函数上投影的系数。这样就把多个函数乘积的积分转变为一个简单的点乘。\n然而这些预计算需要一些前提：\nVisibility 是固定的，即要求场景中的物体不能动 Lighting 是“固定”的，Lighting 的成分不能动态变化，但 Lighting 可以旋转（相当于对基函数张成的线性空间进行旋转） 很合理，就像烘焙的光照信息是静态的一样。\n0x02 预计算球谐系数 Precompute SH Coef 光照项系数 Lighting 要算 SH 系数，就要把环境光照投影到球谐函数上，球谐函数可以调用 sh::EvalSH 获取，Lighting 项可以通过预存的 images 数组获取，剩下一个立体角 $d\\omega_{i}$：\n$$ d\\omega_{i}=\\sin \\theta \\, d\\theta \\, d\\phi $$ 其中 $\\theta$ 是极角，$\\phi$ 是方位角。对于每一个环境贴图上的像素，需要计算其投影到单位球面的面积，框架给了 CalcArea 这个函数计算这个面积。\n先把 texel 从 $[0,res-1]$ 映射到以 CubeMap 当前面中心为原点的 $\\left[ \\frac{1}{res}-1,1-\\frac{1}{res} \\right]$ 坐标范围内。然后通过四个角点的差值计算面积，每个点所对应的面积可以使用一个公式求解：\n$$ A(x,y)=\\arctan\\left( \\frac{xy}{\\sqrt{ x^2+y^2+1 }} \\right) $$ 所以在 PrecomputeCubemapSH 中，只需要为每个 SH 基函数上累加每个 texel 的贡献，就可以得到 Lighting 项的球谐系数。\n无阴影传输项系数 UnShadowed 传输项球谐系数的计算在 preprocess 函数中，assignmet sheet 里给的计算传输项伪代码比较长，但框架里主要关注到 shFunc 这个 lambda 表达式，该表达式返回需要投影的函数在给定方向上的函数值，后续会传入 sh::ProjectFunction 进行计算。\n对于无阴影的情况，要投影的函数就是简单的 cos 项：\n$$ M_{DU}=\\max(N_{x}\\cdot \\omega_{i},0) $$ 这里 H 后来我放 if 外面了，免得下面再写一遍\n有阴影传输项系数 Shadowed $$ M_{DU}=V(\\omega_{i})\\max(N_{x}\\cdot \\omega_{i},0) $$ 要投影的函数再乘上一个 Visibility 项即可：\n如果想看这步的结果，就把 prt.xml 里改成 shadowed，然后运行下 nori.exe。\n1\u0026lt;string name=\u0026#34;type\u0026#34; value=\u0026#34;shadowed\u0026#34; /\u0026gt; 记得改完代码 build 一下，不然 .exe 还是上一次的结果\n反射传输项系数 Inter-reflection 对于具有相互反射的传输项，其方程变为：\n$$ L_{DI}=L_{DS}+\\frac{\\rho}{\\pi}\\int_{S}\\hat{L}(x',\\omega_{i})(1-V(\\omega_{i}))\\max(N_{x}\\cdot \\omega_{i},0)\\,d\\omega_{i} $$ 我们已经计算了每个顶点的直接可见光源的 Lightning（$L_{DS}$），还需要计算间接光的 Lighting，其中：\n$\\hat{L}(x',\\omega_{i})$：从 $\\omega_{i}$ 到点 $x'$ 的 Radiance $1-V(\\omega_{i})$：被遮挡的部分 所以这个式子对每个顶点计算来自直接可见光源的 Lighting，然后遍历所有可能的 $\\omega_{i}$ 计算间接光的 Lighting，对于每个交点返回重心坐标插值后的球谐系数，然后以当前交点为原点，迭代计算传输项系数。\n因为觉得 lambda 有点好玩，就拿 lambda 写了一个递归，其实也可以另外封装一个函数，图片太长不好截，直接放代码：\n1 if (m_Type == Type::Interreflection) 2 { 3 // for each vertex cast a ray to the scene 4 // if occluded return interpolated sh coef 5 // then recursively call this function 6 for(int i = 0;i \u0026lt; mesh-\u0026gt;getVertexCount(); i++) 7 { 8 const Point3f \u0026amp;v = mesh-\u0026gt;getVertexPositions().col(i); 9 const Normal3f \u0026amp;n = mesh-\u0026gt;getVertexNormals().col(i); 10 11 // iterate lambda 12 // need to pass pos, normal, scene, original coeff and current bounce 13 std::function\u0026lt;std::unique_ptr\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt;(Eigen::MatrixXf*, const Point3f\u0026amp;, const Normal3f\u0026amp;, const Scene*, int)\u0026gt; iterateSHFunc; 14 iterateSHFunc = [\u0026amp;](Eigen::MatrixXf* TransportSHCoeffs, const Point3f \u0026amp;pos, const Normal3f \u0026amp;normal, const Scene* s, int bounce) -\u0026gt; std::unique_ptr\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; 15 { 16 // Step 1: allocate and assign 0 to coeffs 17 std::unique_ptr\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; coeffs(new std::vector\u0026lt;double\u0026gt;()); 18 coeffs-\u0026gt;assign(SHCoeffLength, 0.0); 19 20 // Step 2: judge by bounce to terminate 21 if (bounce \u0026gt; m_Bounce) 22 return coeffs; 23 24 // Step 3: generate sample_side^2 uniformly and stratified samples over the sphere 25 const int sample_side = static_cast\u0026lt;int\u0026gt;(floor(sqrt(m_SampleCount))); 26 std::random_device rd; 27 std::mt19937 gen(rd()); 28 std::uniform_real_distribution\u0026lt;\u0026gt; rng(0.0, 1.0); 29 for (int t = 0; t \u0026lt; sample_side; t++) 30 { 31 for (int p = 0; p \u0026lt; sample_side; p++) 32 { 33 double alpha = (t + rng(gen)) / sample_side; 34 double beta = (p + rng(gen)) / sample_side; 35 double phi = 2.0 * M_PI * beta; 36 double theta = acos(2.0 * alpha - 1.0); 37 38 // Step 4: for each wi cast a ray to the scene 39 Eigen::Array3d d = sh::ToVector(phi, theta); 40 const auto wi = Vector3f(d.x(), d.y(), d.z()); 41 float H = wi.dot(normal); 42 43 Ray3f ray = Ray3f(pos, wi); 44 Intersection its; 45 if(H \u0026gt; 0.0 \u0026amp;\u0026amp; s-\u0026gt;rayIntersect(ray, its)) 46 { 47 // Step 5: if occluded, get the intersected point and its normal 48 Point3f intersectPos = its.p; 49 Vector3f baryCoord = its.bary; 50 Point3f idx = its.tri_index; 51 MatrixXf normals = mesh-\u0026gt;getVertexNormals(); 52 Normal3f intersectNormal = Normal3f(normals.col(idx.x()).normalized() * baryCoord.x() + 53 normals.col(idx.y()).normalized() * baryCoord.y() + 54 normals.col(idx.z()).normalized() * baryCoord.z()).normalized(); 55 auto nextBounceCoeffs = iterateSHFunc(TransportSHCoeffs, intersectPos, intersectNormal, scene, bounce + 1); 56 57 // Step 6: accumulate the coeffs 58 for (int k = 0; k \u0026lt; SHCoeffLength; k++) 59 { 60 auto interpolateSH = (TransportSHCoeffs-\u0026gt;col(idx.x()).coeffRef(k) * baryCoord.x() + 61 TransportSHCoeffs-\u0026gt;col(idx.y()).coeffRef(k) * baryCoord.y() + 62 TransportSHCoeffs-\u0026gt;col(idx.z()).coeffRef(k) * baryCoord.z()); 63 64 (*coeffs)[k] += (interpolateSH + (*nextBounceCoeffs)[k]) * H; 65 } 66 } 67 } 68 } 69 // Step 7: return the coeffs 70 double weight = (sample_side * sample_side); 71 for (unsigned int c = 0; c \u0026lt; coeffs-\u0026gt;size(); c++) { 72 (*coeffs)[c] /= weight; 73 } 74 75 return coeffs; 76 }; 77 78 // Step 8: call the iterateSHFunc 79 auto interreflectCoeffs = iterateSHFunc(\u0026amp;m_TransportSHCoeffs, v, n, scene, 1); 80 for (int j = 0; j \u0026lt; SHCoeffLength; j++) 81 { 82 m_TransportSHCoeffs.col(i).coeffRef(j) += (*interreflectCoeffs)[j]; 83 } 84 } 85 } 这里使用 std::function 包装了一下这个 lambda，不然其内部不能调用自己；生成采样点的过程可以照着 sh::ProjectFunction 里面抄\n在调用时可以修改 m_Bounce 的值控制光线反弹次数（lambda 迭代次数），我这里仍然是默认值 1，之前改了一次 2 给我跑了一个多小时，(๑╹◡╹๑)。\n0x03 实时球谐光照 Realtime SH Lighting 运行 nori.exe 后，计算得到的球谐系数将保存在 prt/scenes/cubemap/ 对应环境贴图文件夹下，需要手工先把 light.txt 和 transport.txt 放到 homework2/assets/cubemap/ 对应的环境贴图文件夹下。\nengine.js 中的第 88-114 行已经帮我们完成了解析这些文件的工作，最终 Lighting 和 Transport 项的球谐系数被保存在 precomputeL 和 precomputeLT 两个数组中，这两个数组包含在 envmap 中所有环境纹理的球谐系数，其形状为：\n$$ \\begin{aligned} precomputeL =\u0026 EnvMapNum*3*(SHOrder+1)^{2} \\\\ precomputeLT =\u0026 EnvMapNum*VertexCount*(SHOrder+1)^{2} \\end{aligned} $$ 可以通过 guiParams.envmapId 获取当前 GUI 中激活的环境纹理索引，以从以上数组中获取对应的球谐系数。\n新建 PRT 材质 New PRT Material 在 src/material/ 下新建PRTMaterial.js，编写一个最简单的材质，传的统一变量仅为预计算光照项的各分量（RGB），attribute（aPrecomputeLT）按照 assignment sheet 里的要求传入：\n1class PRTMaterial extends Material { 2 3 constructor(vertexShader, fragmentShader) { 4 super({ 5 // PRT 6 \u0026#39;uPrecomputeL[0]\u0026#39;: { type: \u0026#39;precomputeL\u0026#39;, value: null }, 7 \u0026#39;uPrecomputeL[1]\u0026#39;: { type: \u0026#39;precomputeL\u0026#39;, value: null }, 8 \u0026#39;uPrecomputeL[2]\u0026#39;: { type: \u0026#39;precomputeL\u0026#39;, value: null }, 9 }, [\u0026#39;aPrecomputeLT\u0026#39;], vertexShader, fragmentShader, null); 10 } 11} 12 13async function buildPRTMaterial(vertexPath, fragmentPath) { 14 15 16 let vertexShader = await getShaderString(vertexPath); 17 let fragmentShader = await getShaderString(fragmentPath); 18 19 return new PRTMaterial(vertexShader, fragmentShader); 20 21} 在 Shader 中，uPrecomputeL 是一个类型为 mat3 的数组，索引对应 RGB 分量的球谐系数，需要逐一绑定\nutils/tools.js 中提供了用于将 precomputeL 分解为三个 mat3 类型的数组的函数 getMat3ValueFromRGB，每帧的 render 中需要对其进行绑定，WebGLRenderer.js 中：\n1let precomputeL_RGBMat3 = getMat3ValueFromRGB(precomputeL[guiParams.envmapId]); 2for(let j = 0; j \u0026lt; 3 ; j++){ 3\tif(k == `uPrecomputeL[${j}]`){ 4\tgl.uniformMatrix3fv(this.meshes[i].shader.program.uniforms[k],false,precomputeL_RGBMat3[j]); 5\t} 6} 之后可以先在 index.html 中引入：\n1\u0026lt;script src=\u0026#34;src/materials/PRTMaterial.js\u0026#34; defer\u0026gt;\u0026lt;/script\u0026gt; 然后在 loadOBJ.js 中增加 PRT 材质的 case：\n1case \u0026#39;PRTMaterial\u0026#39;: 2\tmaterial = buildPRTMaterial(\u0026#34;./src/shaders/PRTShader/PRTVertex.glsl\u0026#34;, \u0026#34;./src/shaders/PRTShader/PRTFragment.glsl\u0026#34;); 3\tbreak; 编写着色器 Write Shader 在 shaders/PRTShader/ 下新建 PRT 的顶点和片元着色器文件，作业中需要我们在顶点着色器中完成对 Lighting 和 Transport 的球谐系数的点乘，结果 vColor 经插值后传至片元着色器，直接输出。\n所以片元着色器很简单：\n1// prtFragment.glsl 2#ifdef GL_ES 3precision mediump float; 4#endif 5 6varying highp vec3 vColor; 7 8void main(void) { 9 gl_FragColor = vec4(vColor, 1.0); 10} 顶点着色器中需要完成对 vColor 的计算，看一下传过来的 uPrecomputeL[3] 和 aPrecomputeLT 长什么样子：\nemm，都是 mat3 没办法简单点乘，那就写一个函数，把它们都分成三个 vec3，然后点乘求和即可。\n1// prtVertex.glsl 2attribute vec3 aVertexPosition; 3attribute vec3 aNormalPosition; 4attribute mat3 aPrecomputeLT; 5 6uniform mat4 uModelMatrix; 7uniform mat4 uViewMatrix; 8uniform mat4 uProjectionMatrix; 9uniform mat3 uPrecomputeL[3]; 10 11varying highp vec3 vNormal; 12varying highp vec3 vColor; 13 14float LoT (mat3 L, mat3 LT) { 15 vec3 L0 = L[0]; 16 vec3 L1 = L[1]; 17 vec3 L2 = L[2]; 18 vec3 LT0 = LT[0]; 19 vec3 LT1 = LT[1]; 20 vec3 LT2 = LT[2]; 21 return dot(L0, LT0) + dot(L1, LT1) + dot(L2, LT2); 22} 23 24void main(void) { 25 26 vNormal = (uModelMatrix * vec4(aNormalPosition, 0.0)).xyz; 27 28 gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * 29 vec4(aVertexPosition, 1.0); 30 31 // rgb color from precomputed light transport 32 float r = LoT(uPrecomputeL[0],aPrecomputeLT); 33 float g = LoT(uPrecomputeL[1],aPrecomputeLT); 34 float b = LoT(uPrecomputeL[2],aPrecomputeLT); 35 vColor = vec3(r, g, b); 36} 结果 Outcome 最后在 engine.js 中调用 loadOBJ 加载 202 娘，顺便把还有一张没在 GUI 上的环境纹理放上去：\n1var envmap = [ 2\t\u0026#39;assets/cubemap/GraceCathedral\u0026#39;, 3\t\u0026#39;assets/cubemap/Indoor\u0026#39;, 4\t\u0026#39;assets/cubemap/Skybox\u0026#39;, 5\t// Begin TOP changes Add envmap CornellBox 6\t\u0026#39;assets/cubemap/CornellBox\u0026#39; 7\t// End TOP changes 8]; 9// ... 10 11\tloadOBJ(renderer, \u0026#39;assets/mary/\u0026#39;, \u0026#39;mary\u0026#39;, \u0026#39;PRTMaterial\u0026#39;, boxTransform); 12// ... 13 14function createGUI() { 15\tconst gui = new dat.gui.GUI(); 16\tconst panelModel = gui.addFolder(\u0026#39;Switch Environemtn Map\u0026#39;); 17\t// Begin TOP changes Add envmap CornellBox case in UI 18\tpanelModel.add(guiParams, \u0026#39;envmapId\u0026#39;, { \u0026#39;GraceGathedral\u0026#39;: 0, \u0026#39;Indoor\u0026#39;: 1, \u0026#39;Skybox\u0026#39;: 2, \u0026#39;CornellBox\u0026#39;:3}).name(\u0026#39;Envmap Name\u0026#39;); 19\t// End TOP changes 20\tpanelModel.open(); 21} 0x04 球谐旋转 SH Rotation 球谐函数具有两个很好的性质：\n旋转不变性：旋转球谐展开后的函数，相当于旋转这些球谐函数 独立的线性旋转：对于每层球谐函数上的系数，可以分别进行旋转，并且该旋转是线性的 把球谐函数和基向量类比就比较清楚了：旋转一个三维空间中的向量，相当于旋转坐标基，在这组新的坐标基下，其系数和旋转前其在原坐标系的系数相等。那么要求旋转后在原坐标系下的系数，只需要将新的坐标基使用原来的坐标基表示，然后重新整理系数即可。\n对球谐每阶 $l$，有 $2l+1$ 维的球谐系数组成的向量，假设存在 $2l + 1$ 个任意的三维法向 $n$，旋转函数 $R$，能够返回该阶球谐投影系数的函数 $P$，需要求得旋转整个球面函数的矩阵 $M$，有：\n$$ MP(n_{i})=P(R(n_{i}))\\,,\\,i \\in[-l,l] $$ LHS：对整个球面函数进行旋转；RHS：先旋转方向 $n_i$，再代入球面函数\n记矩阵 $A=[P(n_{-l}),\\dots,P(n_{l})]$，若其可逆，则等式两边同时右乘一个 $A^{-1}$，等式变为：\n$$ M=[P(R(n_{-l})),\\dots,P(R(n_{l}))]A^{-1} $$ Lighting 项有 RGB 三个分量，所以这里的 $P(R(n_{i}))$ 实际上是一个三维向量\n总结一下思路：\n对于环境纹理的旋转，可以获取其旋转矩阵 $R$； 在每阶 $l$ 选取 $2l+1$ 个单位方向向量，获取其在该阶上的球谐系数构成矩阵 $A$； 对该矩阵求逆得到 $A^{-1}$； 使用旋转矩阵依次旋转每阶的单位方向向量，获取这些旋转后的向量在该阶上的球谐系数 $S$； 解得 $M=SA^{-1}$ 主要的实现在 utils/tools.js 中，直接上代码：\n1function getRotationPrecomputeL(precompute_L, rotationMatrix){ 2 3\t// Step 1: Calc each band\u0026#39;s rotation matrix 4\t// Caution: for each dir the Rotation matrix is a inverse of the rotation matrix 5 let r = mat4Matrix2mathMatrix(rotationMatrix); 6 7 let shRotateMatrix3x3 = computeSquareMatrix_3by3(r); 8 let shRotateMatrix5x5 = computeSquareMatrix_5by5(r); 9 10\t// Step 2: Initialize rotated L // R G B 11 let result = []; // SH0 12 for(let i = 0; i \u0026lt; 9; i++){ // SH1 13 result[i] = []; // SH2 14 } // ... 15 16\t// Step 3: Calc SH coeffs for RGB component of precompute_L 17 for(let i = 0; i \u0026lt; 3; i++){ 18 let L_SH_R_3 = math.multiply([precompute_L[1][i], precompute_L[2][i], precompute_L[3][i]], shRotateMatrix3x3); 19 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); 20\t21\tlet L_SH_R_3_arr = L_SH_R_3.toArray(); 22\tlet L_SH_R_5_arr = L_SH_R_5.toArray(); 23 24 result[0][i] = precompute_L[0][i]; // Y1^{0} remains the same 25\tresult[1][i] = L_SH_R_3_arr[0]; 26 result[2][i] = L_SH_R_3_arr[1]; 27 result[3][i] = L_SH_R_3_arr[2]; 28 result[4][i] = L_SH_R_5_arr[0]; 29 result[5][i] = L_SH_R_5_arr[1]; 30 result[6][i] = L_SH_R_5_arr[2]; 31 result[7][i] = L_SH_R_5_arr[3]; 32 result[8][i] = L_SH_R_5_arr[4]; 33 } 34 35\treturn result; 36} 37 38function computeSquareMatrix_3by3(rotationMatrix){ // 计算方阵SA(-1) 3*3 39\t40\t// 1、pick ni - {ni} 41\tlet n1 = [1, 0, 0, 0]; let n2 = [0, 0, 1, 0]; let n3 = [0, 1, 0, 0]; 42 43\t// 2、{P(ni)} - A A_inverse 44\tlet pSHn1 = SHEval(n1[0], n1[1], n1[2], 3); 45\tlet pSHn2 = SHEval(n2[0], n2[1], n2[2], 3); 46\tlet pSHn3 = SHEval(n3[0], n3[1], n3[2], 3); 47\t48\tlet A = math.matrix([ 49\t[pSHn1[1], pSHn2[1], pSHn3[1]], // Y1^{-1} 50\t[pSHn1[2], pSHn2[2], pSHn3[2]], // Y1^{0} 51\t[pSHn1[3], pSHn2[3], pSHn3[3]] // Y1^{1} 52\t]); 53\tlet A_inverse = math.inv(A); 54 55\t// 3、用 R 旋转 ni - {R(ni)} 56\tlet Rn1 = math.multiply(rotationMatrix, n1); 57\tlet Rn2 = math.multiply(rotationMatrix, n2); 58\tlet Rn3 = math.multiply(rotationMatrix, n3); 59 60\t// get array from math matrix 61\tlet Rn1_arr = Rn1.toArray(); 62\tlet Rn2_arr = Rn2.toArray(); 63\tlet Rn3_arr = Rn3.toArray(); 64 65\t// 4、R(ni) SH投影 - S 66\tlet pSHRn1 = SHEval(Rn1_arr[0], Rn1_arr[1], Rn1_arr[2], 3); 67\tlet pSHRn2 = SHEval(Rn2_arr[0], Rn2_arr[1], Rn2_arr[2], 3); 68\tlet pSHRn3 = SHEval(Rn3_arr[0], Rn3_arr[1], Rn3_arr[2], 3); 69 70\tlet S = math.matrix([ 71\t[pSHRn1[1], pSHRn2[1], pSHRn3[1]], // Y1^{-1} 72\t[pSHRn1[2], pSHRn2[2], pSHRn3[2]], // Y1^{0} 73\t[pSHRn1[3], pSHRn2[3], pSHRn3[3]] // Y1^{1} 74\t]); 75 76\t// 5、S*A_inverse 77\tlet SA_inverse = math.multiply(S, A_inverse); 78\treturn SA_inverse; 79} 80 81function computeSquareMatrix_5by5(rotationMatrix){ // 计算方阵SA(-1) 5*5 82\t83\t// 1、pick ni - {ni} 84\tlet k = 1 / math.sqrt(2); 85\tlet n1 = [1, 0, 0, 0]; let n2 = [0, 0, 1, 0]; let n3 = [k, k, 0, 0]; 86\tlet n4 = [k, 0, k, 0]; let n5 = [0, k, k, 0]; 87 88\t// 2、{P(ni)} - A A_inverse 89\tlet pSHn1 = SHEval(n1[0], n1[1], n1[2], 3); 90\tlet pSHn2 = SHEval(n2[0], n2[1], n2[2], 3); 91\tlet pSHn3 = SHEval(n3[0], n3[1], n3[2], 3); 92\tlet pSHn4 = SHEval(n4[0], n4[1], n4[2], 3); 93\tlet pSHn5 = SHEval(n5[0], n5[1], n5[2], 3); 94 95\tlet A = math.matrix([ 96\t[pSHn1[4], pSHn2[4], pSHn3[4], pSHn4[4], pSHn5[4]], // Y2^{-2} 97\t[pSHn1[5], pSHn2[5], pSHn3[5], pSHn4[5], pSHn5[5]], // Y2^{-1} 98\t[pSHn1[6], pSHn2[6], pSHn3[6], pSHn4[6], pSHn5[6]], // Y2^{0} 99\t[pSHn1[7], pSHn2[7], pSHn3[7], pSHn4[7], pSHn5[7]], // Y2^{1} 100\t[pSHn1[8], pSHn2[8], pSHn3[8], pSHn4[8], pSHn5[8]] // Y2^{2} 101\t]); 102\tlet A_inverse = math.inv(A); 103 104\t// 3、用 R 旋转 ni - {R(ni)} 105\tlet Rn1 = math.multiply(rotationMatrix, n1); 106\tlet Rn2 = math.multiply(rotationMatrix, n2); 107\tlet Rn3 = math.multiply(rotationMatrix, n3); 108\tlet Rn4 = math.multiply(rotationMatrix, n4); 109\tlet Rn5 = math.multiply(rotationMatrix, n5); 110 111\t// get array from math matrix 112\tlet Rn1_arr = Rn1.toArray(); 113\tlet Rn2_arr = Rn2.toArray(); 114\tlet Rn3_arr = Rn3.toArray(); 115\tlet Rn4_arr = Rn4.toArray(); 116\tlet Rn5_arr = Rn5.toArray(); 117 118\t// 4、R(ni) SH投影 - S 119\tlet pSHRn1 = SHEval(Rn1_arr[0], Rn1_arr[1], Rn1_arr[2], 3); 120\tlet pSHRn2 = SHEval(Rn2_arr[0], Rn2_arr[1], Rn2_arr[2], 3); 121\tlet pSHRn3 = SHEval(Rn3_arr[0], Rn3_arr[1], Rn3_arr[2], 3); 122\tlet pSHRn4 = SHEval(Rn4_arr[0], Rn4_arr[1], Rn4_arr[2], 3); 123\tlet pSHRn5 = SHEval(Rn5_arr[0], Rn5_arr[1], Rn5_arr[2], 3); 124 125\tlet S = math.matrix([ 126\t[pSHRn1[4], pSHRn2[4], pSHRn3[4], pSHRn4[4], pSHRn5[4]], // Y2^{-2} 127\t[pSHRn1[5], pSHRn2[5], pSHRn3[5], pSHRn4[5], pSHRn5[5]], // Y2^{-1} 128\t[pSHRn1[6], pSHRn2[6], pSHRn3[6], pSHRn4[6], pSHRn5[6]], // Y2^{0} 129\t[pSHRn1[7], pSHRn2[7], pSHRn3[7], pSHRn4[7], pSHRn5[7]], // Y2^{1} 130\t[pSHRn1[8], pSHRn2[8], pSHRn3[8], pSHRn4[8], pSHRn5[8]] // Y2^{2} 131\t]); 132 133\t// 5、S*A_inverse 134\tlet SA_inverse = math.multiply(S, A_inverse); 135\treturn SA_inverse; 136} 有一些要注意的点，旋转天空盒的矩阵是如下代码生成并传入 Shader 的：\n1// WebGLRenderer.js 2let cameraModelMatrix = mat4.create(); 3mat4.fromRotation(cameraModelMatrix, timer, [0, 1, 0]); 4 5if (k == \u0026#39;uMoveWithCamera\u0026#39;) { // The rotation of the skybox 6\tgl.uniformMatrix4fv( 7\tthis.meshes[i].shader.program.uniforms[k], 8\tfalse, 9\tcameraModelMatrix); 10} 对整个环境纹理进行旋转，对某一个方向 $n_{i}$ 来说，旋转后的 Lighting 相当于对这个方向应用旋转矩阵的逆，即在原始天空盒的 $R^{-1}n_{i}$ 方向的 Lighting 的值，所以传入计算球谐函数的旋转矩阵是天空盒旋转矩阵的逆（由于旋转矩阵是单位阵，所以等价于转置）。\ntools.js 有一个函数 mat4Matrix2mathMatrix，按照名字应该是把 WebGL 的矩阵格式转换为 math.js 的矩阵格式：\n1function mat4Matrix2mathMatrix(rotationMatrix){ 2 3\tlet mathMatrix = []; 4\tfor(let i = 0; i \u0026lt; 4; i++){ 5\tlet r = []; 6\tfor(let j = 0; j \u0026lt; 4; j++){ 7\tr.push(rotationMatrix[i*4+j]); 8\t} 9\tmathMatrix.push(r); 10\t} 11\treturn math.matrix(mathMatrix) 12 13} 看起来好像是把东西都掏出来再塞回去，不过这俩矩阵存储方式是不一样的，WebGL 是按行优先存储，而 math.js 是按列优先，如果做下打印：\n1console.log(\u0026#34;WebGL mat4:\u0026#34;, cameraModelMatrix); 2console.log(\u0026#34;math matrix:\u0026#34;, mat4Matrix2mathMatrix(cameraModelMatrix)); 会发现这俩看起来似乎一样，但是在内存中存储的方式有区别。WebGL 在内存中以一维数组形式排布，math.js 的矩阵在内存中按行以二维数组存储：\n$$ WebGLMatrix=\\begin{bmatrix} m_{00} \u0026 m_{10} \u0026 m_{20} \u0026m_{30} \\\\ m_{01} \u0026 m_{11} \u0026 m_{21} \u0026m_{31} \\\\ m_{02} \u0026 m_{12} \u0026 m_{22} \u0026m_{32} \\\\ m_{03} \u0026 m_{13} \u0026 m_{23} \u0026m_{33} \\end{bmatrix},\\quad mathMatrix=\\begin{bmatrix} \\left[ m_{00},m_{01},m_{02},m_{03} \\right] \\\\ \\left[ m_{10},m_{11},m_{12},m_{13} \\right] \\\\ \\left[ m_{20},m_{21},m_{22},m_{23} \\right] \\\\ \\left[ m_{30},m_{31},m_{32},m_{33} \\right] \\end{bmatrix} $$ 所以 mat4Matrix2mathMatrix 这个函数实际上就是把 WebGL 矩阵转到 math.js 的矩阵，还做了一次转置，相当于直接拿到了旋转矩阵的逆，可以在 getRotationPrecomputeL 中直接调用这个函数获得天空盒旋转矩阵的逆，然后再传入 computeSquareMatrix_xbyx 计算球谐系数的旋转矩阵。另外，由于该矩阵是 math.js 封装的 matrix，调用 math.multiply 方法的返回值还是 matrix（即便是矩阵乘向量），所以不能通过下标访问，可以使用 ._data[i] 或者先用 .toArray 方法拿到数组，再按下标访问。\n最后将 WebGLRenderer.js 第 53 和第 63 行取消注释，不出意外就能看到模型的着色正确跟随天空盒旋转！\n","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/games202-homework-2/","summary":"\u003cblockquote\u003e\n\u003cp\u003e(∩^o^)⊃━☆ﾟ.*･｡\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 部分课程内容回顾\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e GAMES202 作业 2\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📺B 站视频：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV1YK4y1T7yY/?spm_id_from=333.337.top_right_bar_window_custom_collection.content.click\u0026amp;vd_source=b6584cebba3a7a1a34d2f60d63bdc868\"\u003eGAMES202-高质量实时渲染\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📦代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/congyuxiaoyoudao/GAMES202_Homework/tree/working\"\u003econgyuxiaoyoudao/GAMES202_Homework at working\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📃\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://dl.acm.org/doi/10.1145/15886.15902\"\u003eThe rendering equation | ACM SIGGRAPH Computer Graphics\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📃\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://dl.acm.org/doi/10.1145/566654.566612\"\u003ePrecomputed radiance transfer for real-time rendering in dynamic, low-frequency lighting environments | ACM Transactions on Graphics\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003emaster 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-前置准备-warm-up\"\u003e0x01 前置准备 Warm Up\u003c/h2\u003e\n\u003ch3 id=\"渲染方程-render-equation\"\u003e渲染方程 Render Equation\u003c/h3\u003e\n\u003cp\u003eJames T. Kajiya（没错就是那个提出 Kajiya-Kay 头发着色模型的 Kajiya）在 1986 年提出了渲染方程（The Render Equation），原论文里长这样：\u003c/p\u003e\n\u003cdiv\u003e$$\nI(x,x')=g(x,x')\\left[ \\epsilon(x,x')+\\int_{S}\\rho(x,x',x'')I(x',x'')dx'' \\right]\n$$\u003c/div\u003e\n\u003cp\u003e当然现在看得更舒服的形式长这样：\u003c/p\u003e","title":"Assignment 5. GAMES202 Homework 2"},{"content":" 🚩 0x00 To begin with 这篇文章将会包含以下内容：\nGAMES202 作业 1 For reference👇：\n📺 B 站视频： GAMES202-高质量实时渲染 📦 代码仓库： congyuxiaoyoudao/GAMES202_Homework at working master 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\n0x01 Shadow Map 完成两个任务点：\n第一个 Pass 以光源作为相机渲染一张 ShadowMap，需要为 Shader 传递正确的 uLightMVP 矩阵； 第二个 Pass 获取光源传递的统一变量 FBO （ShadowMap），需要比较当前 ShadingPoint 的深度值与 ShadowMap 上记录的深度值，得出可见性项与 Shading 结果相乘。 DirectionalLight.js 中，完善 CalcLightMVP 函数：\n1\t// Model transform 2 mat4.translate(modelMatrix, modelMatrix, translate); 3 mat4.scale(modelMatrix, modelMatrix, scale); 4 5 // View transform 6 mat4.lookAt(viewMatrix, this.lightPos, this.focalPoint, this.lightUp); 7 8 // Projection transform 9 var r = 100; 10 var l = -r; 11 var t = 100; 12 var b = -t; 13 var n = 0.01; 14 // caution! Depth of far plane should be a bit more larger 15 var f = 400; 16 17 mat4.ortho(projectionMatrix, l, r, b, t, n, f); 远平面需设置得稍微大一点，避免光源的视锥范围无法覆盖全部场景\nphongFragment.glsl 中，由于 ShadowMap 记录的深度值范围是 0-1，所以在比较之前需要把当前 ShadingPoint 到光源的距离转换到该区间，可以透视除法后转换至 NDC 后再映射至该范围：\n1 vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w; // NDC 2 shadowCoord.xyz = (shadowCoord.xyz + 1.0) / 2.0; // -1~1 -\u0026gt; 0-1 然后使用这个坐标传入 useShadowMap 函数进行比较：\n1float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){ 2 float shadowMapDepth = unpack(texture2D(shadowMap, shadowCoord.xy)); 3 float visibility = shadowCoord.z \u0026gt; (shadowMapDepth + EPS) ? 0.0 : 1.0; 4 return visibility; 5} 这里的 EPS（Epsilon）是框架定义的 Bias，作为阈值判断是否显著得大于记录的深度，用于修正自遮挡问题\n然后在 main 函数中调用 useShadowMap 和 phongColor 相乘即可。\n0x02 PCF 简单说下 PCF 的原理：针对当前 ShadingPoint，不仅考察其对应像素在 ShadowMap 上记录的深度，还要考察该像素周围（邻居像素）的深度，并逐一比较得出当前 ShadingPoint 在这些像素上的遮挡关系，然后将这些值求平均，得出一个平均的遮挡关系（介于 0-1 之间）。要点：\n并非对 ShadowMap 进行滤波 更像是对深度比较的结果进行滤波 框架推荐在圆盘状滤波核中随机采样，可以生成更加自然的模糊阴影，要完成的任务如下：\n调用任一随机采样函数，填充采样偏移数组（poissonDisk[NUM_SAMPLES]）; 考察每个偏移后像素记录的深度并与当前 ShadingsPoint 的深度进行比较，累加可见性项； 返回平均的可见性项。 这里使用 poissonDiskSamples 生成随机偏移：\n这段代码将会填充采样偏移数组，值形如：\n$$ \\begin{aligned} possionDisk_{i}=\u0026(\\cos\\theta,\\sin \\theta)\\cdot R \\\\ a_{0}\\leq \\theta\\leq a_{0}\u0026+2\\pi \\cdot rings \\\\ \\frac{1}{samples}^{3/4}\u0026\\leq R\\leq 1 \\end{aligned} $$ 在填充数组后，循环采样每一个偏移后的像素，比较深度，累加可见性项，最后求其均值：\n1// filter size is filter window size in pixels defined by FILTER_RADIUS / resolution 2float PCF(sampler2D shadowMap, vec4 coords, float filterSize) { 3 4 // STEP 1: uniform disk sampling generate a group of random sample points 5 poissonDiskSamples(coords.xy); 6 // Step 2: sample each point and accumulate each visibility component 7 float sum = 0.0; 8 for (int i = 0; i \u0026lt; PCF_NUM_SAMPLES; i++) { 9 vec2 sampleOffset = poissonDisk[i] * filterSize; 10 vec2 uv = coords.xy + sampleOffset; 11 float shadowMapDepth = unpack(texture2D(shadowMap, uv)); 12 float visibility = coords.z \u0026gt; (shadowMapDepth + EPS) ? 0.0 : 1.0; 13 sum += visibility; 14 } 15 // Step 3: return averaged visibility 16 return sum / float(PCF_NUM_SAMPLES); 17} 由于采样的偏移在 UV 空间中进行，所以需要除以贴图分辨率，框架给的是 2048，此外，乘一个 FILTER_RADIUS 调整偏移范围大小，该值越大，阴影越软\n同样在 main 函数中调用 PCF 和 phongColor 相乘即可。\n0x03 PCSS 观察现实的阴影，会发现在靠近投射阴影的障碍物时，阴影会更加硬，反之更软。PCSS 在 PCF 的基础上需要根据遮挡物的距离改变滤波核的大小，以调节阴影软硬，要完成的任务如下：\n获得遮挡物的平均深度，完善 findBlocker 函数； 根据相似三角形原理计算半影大小（penumbra size）； 根据半影大小调整滤波核大小，调用 PCF。 课程视频给出了一种确定可能遮挡当前 ShadingPoint 的范围，即连接 ShadingPoint 到 Light，在近平面处所截的区域。\n也是相似三角形，给定光源宽度即可计算：\n$$ blockerSearchRadius=W_{light} \\cdot \\frac{d_{PosToLight}-d_{near}}{d_{PosToLight}} $$ 然后将这个范围乘上偏移，在最终的偏移范围内随机采样，仅记录并累加为遮挡物的像素深度，最后求得平均遮挡物深度。\n1float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) { 2 float avgBlockerDepth = 0.0; 3 float blockerSum = 0.0; 4 poissonDiskSamples(uv); 5 6 // use proj on near plane to define search radius 7 float blockerSearchRadius = LIGHT_WIDTH_UV * (vPositionFromLight.z - NEAR_PLANE) / vPositionFromLight.z ; 8 9 for (int i = 0; i \u0026lt; BLOCKER_SEARCH_NUM_SAMPLES; i++) { 10 vec2 sampleOffset = poissonDisk[i] * blockerSearchRadius; 11 vec2 uvSample = uv + sampleOffset; 12 float shadowMapDepth = unpack(texture2D(shadowMap, uvSample)); 13 if (shadowMapDepth \u0026lt; zReceiver) { 14 avgBlockerDepth += shadowMapDepth; 15 blockerSum ++; 16 } 17 } 18 19 if(blockerSum == 0.0) return 1.0; // no blocker give a max depth value 20 else return avgBlockerDepth / blockerSum; 21} 之后在 PCSS 中调用之，再一次使用相似三角形获得半影大小，将其作为滤波器大小传入 PCF。\n1float PCSS(sampler2D shadowMap, vec4 coords){ 2 3 // STEP 1: avgblocker depth 4 float avgBlockerDepth = findBlocker(shadowMap, coords.xy, coords.z); 5 // STEP 2: penumbra size 6 // LIGHT_WIDTH_UV is defined by (LIGHT_WIDTH / resolution) 7 float penumbraSize = (coords.z - avgBlockerDepth) * LIGHT_WIDTH_UV / avgBlockerDepth; 8 // STEP 3: use penumbra size to define filter size 9 float visibility = PCF(shadowMap, coords, penumbraSize); 10 return visibility; 11 12} 这里相似三角形求解时直接使用屏幕空间灯宽度，可以直接将得到的半影直径作为滤波器大小传入 PCF，注意统一的量纲\n同样在 main 函数中调用 PCSS 和 phongColor 相乘即可。\n0x04 Bonus 多光源 要实现多光源，就需要为每个光源绘制一张 ShadowMap，常规的思路是为每张 ShadowMap 绑定一个 uniform，然后 Shader 里一一比较，合成最终的阴影。但是这样还需要向 Shader 传不同的光源的 ulightMVP 矩阵，随着光源数量增加，变量会异常地多，不是很优雅。\n这种思路要干的事：\n为每个光源渲染其独立的 ShadowMap 将每个光源的 ShadowMap 绑到 Material 的 uniform 中 将每个光源的 uLightPos 和 ulightMVP 传递给 Shader 在 Shader 中遍历每张 ShadowMap，累加可见性项 本来是想用这个思路的，但是框架要动的东西太多了，遂放弃\nGPUGems 中使用一张 ShadowMap 纹理：\nChapter 10. Parallel-Split Shadow Maps on Programmable GPUs \u0026hellip;we reuse a single shadow-map texture in each rendering pass\n生成 ShadowMap 后马上合成阴影，之后的光源就可以复用这个 Texture，然后只需将每个光源的贡献累加即可。\n采用花桑氏的做法，每个光源都走一次 Camera Pass，然后混合。\n因为一个 material 只能存一张 ShadowMap，所以一个材质要为每个光源都 build 一遍，也是花桑氏的做法，用一个索引区分 material 对应的光源，先从父类 Material 改起，Material.js 中：\n然后是 PhongMaterial.js 中，在构造函数和 build 函数中增加灯光索引：\nShadowMaterial.js 中，同样：\nloadOBJ.js 中，为每个光源 build 一个材质：\nengine.js 中，为了方便一点封装一个加方向光的函数：\n1function AddDirectionalLight(renderer, intensity, color, position, focalPoint, up, hasShadowMap) { 2 const directionLight = new DirectionalLight(intensity, color, position, focalPoint, up, hasShadowMap, renderer.gl); 3 renderer.addLight(directionLight); 4} 然后改一下位置和颜色，再次增加两盏方向光：\n1AddDirectionalLight(renderer, 5000, [1, 0, 1], lightPos, focalPoint, lightUp, true); 2lightPos = [0, 80, -80]; 3AddDirectionalLight(renderer, 500, [0, 1, 1], lightPos, focalPoint, lightUp, true); 4lightPos = [80, 80, 0]; 5AddDirectionalLight(renderer, 500, [1, 1, 0], lightPos, focalPoint, lightUp, true); 最后 WebGLRenderer中.js 中，只为对应光源索引的 material 进行绘制，然后把其余光源的绘制结果叠到第一个光源的绘制结果（FrameBuffer）上：\n这里按我原来写的把 mesh 的旋转放 Camera Pass 里了，所以加了多光源会出现很严重的抖动，拿到外面即可\n最后再让光源转起来：\n1let lightPos = this.lights[l].entity.lightPos; 2lightPos = vec3.rotateY(lightPos, lightPos, this.lights[l].entity.focalPoint, degreeToRadian(10) * deltaTime); 3this.lights[l].entity.lightPos = lightPos; 大功告成，看一下三体运动：\n动态物体 目前可以看到物体和光源是不支持旋转的，这里加上。不动脑子一点的办法是全局搜索 Transform，然后一个一个加上旋转就行，这里笔者尽量给出有逻辑的修改方式。\n框架中的渲染循环位于 engine.js，最后有个 setTransform 函数，在这里加上旋转变换：\n1function setTransform(t_x, t_y, t_z, r_x, r_y, r_z, s_x, s_y, s_z) { 2\treturn { 3\tmodelTransX: t_x, 4\tmodelTransY: t_y, 5\tmodelTransZ: t_z, 6\t// Begin TOP changes 7\tmodelRotateX: r_x, 8\tmodelRotateY: r_y, 9\tmodelRotateZ: r_z, 10\t// End TOP changes 11\tmodelScaleX: s_x, 12\tmodelScaleY: s_y, 13\tmodelScaleZ: s_z, 14\t}; 15} 然后在上面部分代码设置模型变换中加上旋转的参数，目前先给 0：\n1let floorTransform = setTransform(0, 0, -30, 0, 0, 0, 4, 4, 4); 2let obj1Transform = setTransform(0, 0, 0, 0, 0, 0, 20, 20, 20); 3let obj2Transform = setTransform(40, 0, -40, 0, 0, 0, 10, 10, 10); 这个变换作为 loadOBJ 函数的参数用于设置模型变换，先看看这个函数干了什么（loadOBJ.js）：\n涉及到 transform 的地方就这些，一个是 Mesh 的 Ctor，另一个是 material 的 build 函数。\n在 Mesh.js 中，更改其构造函数，增加对旋转的支持：\n1// TRSTransform Ctor 2 constructor(translate = [0, 0, 0], rotate = [0, 0, 0], scale = [1, 1, 1]) { 3 this.translate = translate; 4\tthis.rotate = rotate; 5 this.scale = scale; 6 } 7 8// ... 9 10// Mesh Ctor 11\tconst modelTranslation = [transform.modelTransX, transform.modelTransY, transform.modelTransZ]; 12\tconst modelRotation = [transform.modelRotateX, transform.modelRotateY, transform.modelRotateZ]; 13\tconst modelScale = [transform.modelScaleX, transform.modelScaleY, transform.modelScaleZ]; 14\tlet meshTrans = new TRSTransform(modelTranslation, modelRotation, modelScale); 回到 loadOBJ.js，从传入的 transform 中解析模型旋转，然后传入 material 的 build 函数：\n1// object.traverse 2let Translation = [transform.modelTransX, transform.modelTransY, transform.modelTransZ]; 3let Rotation = [transform.modelRotateX, transform.modelRotateY, transform.modelRotateZ]; 4let Scale = [transform.modelScaleX, transform.modelScaleY, transform.modelScaleZ]; 5 6material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Rotation, Scale, \u0026#34;./src/shaders/phongShader/phongVertex.glsl\u0026#34;, \u0026#34;./src/shaders/phongShader/phongFragment.glsl\u0026#34;); 7shadowMaterial = buildShadowMaterial(light, Translation, Rotation, Scale, \u0026#34;./src/shaders/shadowShader/shadowVertex.glsl\u0026#34;, \u0026#34;./src/shaders/shadowShader/shadowFragment.glsl\u0026#34;); 然后自然需要对 material 的变换进行支持，依次在 PhongMaterial.js 和 ShadowMaterial.js 中为上述创建材质的函数添加对旋转的支持：\nPhongMaterial.js 中：\n1// PhongMaterial Ctor 2 constructor(color, specular, light, translate, rotate, scale, vertexShader, fragmentShader) { 3 let lightMVP = light.CalcLightMVP(translate, rotate, scale); 4 5// material builder function 6async function buildPhongMaterial(color, specular, light, translate, rotate, scale, vertexPath, fragmentPath) { 7 8 let vertexShader = await getShaderString(vertexPath); 9 let fragmentShader = await getShaderString(fragmentPath); 10 return new PhongMaterial(color, specular, light, translate, rotate, scale, vertexShader, fragmentShader); 11} 同样，ShadowMaterial.js 中：\n1class ShadowMaterial extends Material { 2 constructor(light, translate, rotate, scale, vertexShader, fragmentShader) { 3 let lightMVP = light.CalcLightMVP(translate, rotate, scale); 4 super({ 5 \u0026#39;uLightMVP\u0026#39;: { type: \u0026#39;matrix4fv\u0026#39;, value: lightMVP } 6 }, [], vertexShader, fragmentShader, light.fbo); 7 } 8} 9 10async function buildShadowMaterial(light, translate, rotate, scale, vertexPath, fragmentPath) { 11 let vertexShader = await getShaderString(vertexPath); 12 let fragmentShader = await getShaderString(fragmentPath); 13 return new ShadowMaterial(light, translate,rotate, scale, vertexShader, fragmentShader); 14} 注意到，上面传递的 lightMVP 矩阵是通过 CalcLightMVP 计算的，所以下一步为其增加对旋转的支持，DirectionalLight.js 中：\n1 CalcLightMVP(translate, rotate, scale) { 2 let lightMVP = mat4.create(); 3 let modelMatrix = mat4.create(); 4 let viewMatrix = mat4.create(); 5 let projectionMatrix = mat4.create(); 6 7 // Model transform 8 mat4.translate(modelMatrix, modelMatrix, translate); 9 mat4.rotateX(modelMatrix, modelMatrix, rotate[0]); 10 mat4.rotateY(modelMatrix, modelMatrix, rotate[1]); 11 mat4.rotateZ(modelMatrix, modelMatrix, rotate[2]); 12 mat4.scale(modelMatrix, modelMatrix, scale); 记得把上面灯的 Mesh（cube）模型变换的旋转参数加上\n1// DirectionalLight Ctor 2this.mesh = Mesh.cube(setTransform(0, 0, 0, 0, 0, 0, 0.2, 0.2, 0.2, 0)); 最后，渲染循环每一帧绘制时会调用 WebGLRenderer 的 render 函数，后者又会为每一个实体调用其 meshRender 的 draw 函数，该函数需要调用 bindCameraParameters，这里向 Shader 传递了作为 uniform 的模型变换矩阵，在这里加上旋转（MeshRender.js 中）：\n1mat4.rotateX(modelMatrix, modelMatrix, this.mesh.transform.rotate[0]); 2mat4.rotateY(modelMatrix, modelMatrix, this.mesh.transform.rotate[1]); 3mat4.rotateZ(modelMatrix, modelMatrix, this.mesh.transform.rotate[2]); 对旋转的支持到这里就差不多了，现在我们需要让模型随着时间按 Y 轴旋转，我们可以从循环里拿到当前时间，然后与保存的时间相减得到差值，向 render 函数传入这个差值然后每帧累加一个旋转角度即可。\n最后，在 WebGLRenderer.js，render 函数中每帧需要更新光源对应的 FBO（帧缓冲区），否则 ShadowMap 的内容会每帧叠加，以及更新传入 Shader 的 uniform 变量。\n这里 updateMVP 是我自定义的一个函数：\n1 updateMVP(mesh, lightEntity){ 2 let translate = mesh.transform.translate; 3 let rotate = mesh.transform.rotate; 4 let scale = mesh.transform.scale; 5 let MVP = lightEntity.CalcLightMVP(translate, rotate, scale); 6 return MVP; 7 } 完成之后就可以转圈圈~\n","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/games202-homework-1/","summary":"\u003chr\u003e\n\u003ch2 id=\"-0x00-to-begin-with\"\u003e🚩 0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e GAMES202 作业 1\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📺 B 站视频：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV1YK4y1T7yY/?spm_id_from=333.337.top_right_bar_window_custom_collection.content.click\u0026amp;vd_source=b6584cebba3a7a1a34d2f60d63bdc868\"\u003eGAMES202-高质量实时渲染\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📦 代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/congyuxiaoyoudao/GAMES202_Homework/tree/working\"\u003econgyuxiaoyoudao/GAMES202_Homework at working\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003emaster 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-shadow-map\"\u003e0x01 Shadow Map\u003c/h2\u003e\n\u003cp\u003e完成两个任务点：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e第一个 Pass 以光源作为相机渲染一张 ShadowMap，需要为 Shader 传递正确的 \u003ccode\u003euLightMVP\u003c/code\u003e 矩阵；\u003c/li\u003e\n\u003cli\u003e第二个 Pass 获取光源传递的统一变量 FBO （ShadowMap），需要比较当前 ShadingPoint 的深度值与 ShadowMap 上记录的深度值，得出可见性项与 Shading 结果相乘。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eDirectionalLight.js 中，完善 \u003ccode\u003eCalcLightMVP\u003c/code\u003e 函数：\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Assignments/Assignment%204.GAMES202HW1/202504151505787.png#center\"\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-glsl\" data-lang=\"glsl\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\t\t\u003cspan class=\"c1\"\u003e// Model transform\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 2\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003emat4\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etranslate\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003emodelMatrix\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003emodelMatrix\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003etranslate\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 3\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003emat4\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003escale\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003emodelMatrix\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003emodelMatrix\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003escale\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 4\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 5\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"c1\"\u003e// View transform\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 6\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003emat4\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elookAt\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eviewMatrix\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003ethis\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elightPos\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003ethis\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efocalPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003ethis\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elightUp\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 7\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 8\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"c1\"\u003e// Projection transform\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 9\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003evar\u003c/span\u003e \u003cspan class=\"n\"\u003er\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e100\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e10\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003evar\u003c/span\u003e \u003cspan class=\"n\"\u003el\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e\u003cspan class=\"n\"\u003er\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e11\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003evar\u003c/span\u003e \u003cspan class=\"n\"\u003et\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e100\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e12\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003evar\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e\u003cspan class=\"n\"\u003et\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e13\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003evar\u003c/span\u003e \u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.01\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e14\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"c1\"\u003e// caution! Depth of far plane should be a bit more larger\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e15\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003evar\u003c/span\u003e \u003cspan class=\"n\"\u003ef\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e400\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e16\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e17\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003emat4\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eortho\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eprojectionMatrix\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003el\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003er\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003et\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003en\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ef\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cblockquote\u003e\n\u003cp\u003e远平面需设置得稍微大一点，避免光源的视锥范围无法覆盖全部场景\u003c/p\u003e","title":"Assignment 4. GAMES202 Homework 1"},{"content":" 开个新坑，之前囫囵吞枣地过了一遍 202，现在忘得差不多了，该捡起来。正好给枯燥的生活增添一点色彩（？），最近比较忙但还是争取一周做一个作业\n🚩 0x00 To begin with 这篇文章将会包含以下内容：\nGAMES202 作业 0 不包含作业框架分析 For reference👇：\n📺 B 站视频： GAMES202-高质量实时渲染 📦 代码仓库： congyuxiaoyoudao/GAMES202_Homework at working master 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\n⚙️ 0x01 Configuration 环境配置 选择使用 VSCode 插件搭建本地服务器，在扩展中搜索 Live Server，安装此插件。\n在编辑 Html 代码窗口按 F1（或者右键）选择 Live Server: Open with Live Server，随后会自动打开浏览器，导航至本地服务器端口，一切正常的话是下面这个效果：\n可以使用鼠标和右上角的控制面板进行一些简单的交互。\n如果遇到模型贴图加载不出来（只有 202 字样），可以选择多刷新几次。或者直接在加载脚本前预加载贴图，可以根治这个问题。\n1\u0026lt;link rel=\u0026#34;preload\u0026#34; href=\u0026#34;assets/mary/MC003_Kozakura_Mari.png\u0026#34; as=\u0026#34;image\u0026#34; type=\u0026#34;image/png\u0026#34; crossorigin/\u0026gt; 🤔 0x02 Blinn-Phong Shading Model 没什么好说的，CV 工程师\n使用 GLSL 编写 Shader 如果使用字符串的方式引入 Shader，就在 InternalShader.js 中加上：\n1// Phong Shader 2 3const PhongVertexShader = ` 4// attribute is vertex input data 5attribute vec3 aVertexPosition; 6attribute vec3 aNormalPosition; 7attribute vec2 aTextureCoord; 8 9// uniform is consistent/global data between VS/PS in a drawcall 10uniform mat4 uModelViewMatrix; 11uniform mat4 uProjectionMatrix; 12 13// varying is data need to be interpolated from VS to PS 14varying highp vec2 vTextureCoord; 15varying highp vec3 vFragPos; 16varying highp vec3 vNormal; 17 18void main(void) { 19 20 vFragPos = aVertexPosition; 21 vNormal = aNormalPosition; 22 // MVP 23 gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0); 24 25 vTextureCoord = aTextureCoord; 26} 27`; 28 29const PhongFragmentShader = ` 30#ifdef GL_ES 31precision mediump float; 32#endif 33uniform sampler2D uSampler; 34//binn 35uniform vec3 uKd; 36uniform vec3 uKs; 37uniform vec3 uLightPos; 38uniform vec3 uCameraPos; 39uniform float uLightIntensity; 40uniform int uTextureSample; 41 42varying highp vec2 vTextureCoord; 43varying highp vec3 vFragPos; 44varying highp vec3 vNormal; 45 46void main(void) { 47 vec3 color; 48 if (uTextureSample == 1) { 49\t// Gamma correction 50\tcolor = pow(texture2D(uSampler, vTextureCoord).rgb, vec3(2.2)); 51 } else { 52 // default color 53 color = uKd; 54 } 55 56 vec3 ambient = 0.05 * color; 57 58 // lambertian diffuse 59 vec3 lightDir = normalize(uLightPos- vFragPos); 60 vec3 normal = normalize(vNormal); 61 float diff = max(dot(lightDir, normal), 0.0); 62 float light_atten_coff = uLightIntensity / length(uLightPos- vFragPos); 63 vec3 diffuse = diff * light_atten_coff * color; 64 65 // Phong specular 66 vec3 viewDir = normalize(uCameraPos- vFragPos); 67 float spec = 0.0; 68 vec3 reflectDir = reflect(-lightDir, normal); 69 spec = pow (max(dot(viewDir, reflectDir), 0.0), 35.0); 70 vec3 specular = uKs * light_atten_coff * spec; 71 72 // Compose all components and do gamma reverse 73 gl_FragColor = vec4(pow((ambient + diffuse + specular), vec3(1.0/2.2)), 1.0); 74 75} 76`; 实现 PhongMaterial 类 在 src/materials/ 下新建 PhongMaterial.js，定义继承自 Material 的 PhongMaterial 类，传参，绑定 Shader。\nsuper 调用父类构造\n1class PhongMaterial extends Material { 2/** 3 * Creates an instance of PhongMaterial. 4 * @param {vec3f} color The material color 5 * @param {Texture} colorMap The texture object of the material 6 * @param {vec3f} specular The material specular coefficient 7 * @param {float} intensity The light intensity 8 * @memberof PhongMaterial 9 */ 10 constructor(color, colorMap, specular, intensity) { 11 let textureSample = 0; 12 13 if (colorMap != null) { 14 textureSample = 1; 15 super({ 16 \u0026#39;uTextureSample\u0026#39;: { type: \u0026#39;1i\u0026#39;, value: textureSample }, 17 \u0026#39;uSampler\u0026#39;: { type: \u0026#39;texture\u0026#39;, value: colorMap }, 18 \u0026#39;uKd\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: color }, 19 \u0026#39;uKs\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: specular }, 20 \u0026#39;uLightIntensity\u0026#39;: { type: \u0026#39;1f\u0026#39;, value: intensity } 21 }, [], PhongVertexShader, PhongFragmentShader); 22 } else { 23 //console.log(color); 24 super({ 25 \u0026#39;uTextureSample\u0026#39;: { type: \u0026#39;1i\u0026#39;, value: textureSample }, 26 27 \u0026#39;uKd\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: color }, 28 \u0026#39;uKs\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: specular }, 29 \u0026#39;uLightIntensity\u0026#39;: { type: \u0026#39;1f\u0026#39;, value: intensity } 30 }, [], PhongVertexShader, PhongFragmentShader); 31 } 32 33 } 34} 导入 PhongMaterial.js 在 index.html 中，在导入 Material.js 之后导入 PhongMaterial.js。\n1\u0026lt;script src=\u0026#34;src/materials/Material.js\u0026#34; defer\u0026gt;\u0026lt;/script\u0026gt; 2\u0026lt;!-- Load phong material **after** Material --\u0026gt; 3\u0026lt;script src=\u0026#34;src/materials/PhongMaterial.js\u0026#34; defer\u0026gt;\u0026lt;/script\u0026gt; 使用 PhongMaterial src/loads/loadOBJ.js 中，删除原本创建默认材质的部分，改为使用 PhongMaterial 创建实例。\n1// // MARK: You can change the myMaterial object to your own Material instance 2 3// let textureSample = 0; 4// let myMaterial; 5// if (colorMap != null) { 6// textureSample = 1; 7// myMaterial = new Material({ 8// \u0026#39;uSampler\u0026#39;: { type: \u0026#39;texture\u0026#39;, value: colorMap }, 9// \u0026#39;uTextureSample\u0026#39;: { type: \u0026#39;1i\u0026#39;, value: textureSample }, 10// \u0026#39;uKd\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: mat.color.toArray() } 11// },[],VertexShader, FragmentShader); 12// }else{ 13// myMaterial = new Material({ 14// \u0026#39;uTextureSample\u0026#39;: { type: \u0026#39;1i\u0026#39;, value: textureSample }, 15// \u0026#39;uKd\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: mat.color.toArray() } 16// },[],VertexShader, FragmentShader); 17// } 18\t19let myMaterial = new PhongMaterial( mat.color.toArray(), 20\tcolorMap, 21\tmat.specular.toArray(), 22\trenderer.lights[0].entity.mat.intensity) 🍪 Extra：从文件中加载 Shader 为了让这篇显得不那么水，补一下另一个从外部文件加载 Shader 的方法，在 src/shaders/phongShader 中已经给出了这两个着色器文件\n更改 PhongMaterial 类 因为构造函数不支持异步，就把创建 Material 的逻辑放到一个函数中，在这个函数里调用 loadShader.js 中定义的 loadShaderFile 函数加载 shader 文件，等待加载完后再调用构造函数并返回新创建的实例：\n1// Load the shader files from file 2class PhongMaterial extends Material { 3 constructor(uniforms, vertexShader, fragmentShader) { 4 super(uniforms, [], vertexShader, fragmentShader); 5 } 6 7 /** 8 * Creates an instance of PhongMaterial async. 9 * @param {vec3f} color 10 * @param {Texture} colorMap 11 * @param {vec3f} specular 12 * @param {float} intensity 13 * @returns {Promise\u0026lt;PhongMaterial\u0026gt;} 14 */ 15 static async create(color, colorMap, specular, intensity) { 16 const textureSample = colorMap ? 1 : 0; 17 18 const [vertexShader, fragmentShader] = await Promise.all([ 19 loadShaderFile(\u0026#39;src/shaders/phongShader/vertex.glsl\u0026#39;), 20 loadShaderFile(\u0026#39;src/shaders/phongShader/fragment.glsl\u0026#39;) 21 ]); 22 // console.log(\u0026#39;Vertex Shader:\u0026#39;, vertexShader.slice(0, 100)); 23 // console.log(\u0026#39;Fragment Shader:\u0026#39;, fragmentShader.slice(0, 100)); 24 const uniforms = { 25 \u0026#39;uTextureSample\u0026#39;: { type: \u0026#39;1i\u0026#39;, value: textureSample }, 26 \u0026#39;uKd\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: color }, 27 \u0026#39;uKs\u0026#39;: { type: \u0026#39;3fv\u0026#39;, value: specular }, 28 \u0026#39;uLightIntensity\u0026#39;: { type: \u0026#39;1f\u0026#39;, value: intensity } 29 }; 30 31 if (colorMap) { 32 uniforms[\u0026#39;uSampler\u0026#39;] = { type: \u0026#39;texture\u0026#39;, value: colorMap }; 33 } 34 35 return new PhongMaterial(uniforms, vertexShader, fragmentShader); 36 } 37} 更改 loadOBJ.js 用 create 方法创建一个新材质实例，完成后进行渲染。\n1PhongMaterial.create( mat.color.toArray(), 2\tcolorMap, 3\tmat.specular.toArray(), 4\trenderer.lights[0].entity.mat.intensity) 5\t.then((myMaterial) =\u0026gt; { 6\tlet meshRender = new MeshRender(renderer.gl, mesh, myMaterial); 7\trenderer.addMesh(meshRender); 8\t}) 📝 Afterwords 一切正常就是下面的效果。\n","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/games202-homework-0/","summary":"\u003cblockquote\u003e\n\u003cp\u003e开个新坑，之前囫囵吞枣地过了一遍 202，现在忘得差不多了，该捡起来。正好给枯燥的生活增添一点色彩（？），最近比较忙但还是争取一周做一个作业\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x00-to-begin-with\"\u003e🚩 0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e GAMES202 作业 0\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e \u003cstrong\u003e不\u003c/strong\u003e包含作业框架分析\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📺 B 站视频：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://www.bilibili.com/video/BV1YK4y1T7yY/?spm_id_from=333.337.top_right_bar_window_custom_collection.content.click\u0026amp;vd_source=b6584cebba3a7a1a34d2f60d63bdc868\"\u003eGAMES202-高质量实时渲染\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📦 代码仓库：\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://github.com/congyuxiaoyoudao/GAMES202_Homework/tree/working\"\u003econgyuxiaoyoudao/GAMES202_Homework at working\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003emaster 分支上是 202 全部作业汇总，working 分支用于提交代码。需要原始作业可下载 master 分支的包\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x01-configuration-环境配置\"\u003e⚙️ 0x01 Configuration 环境配置\u003c/h2\u003e\n\u003cp\u003e选择使用 VSCode 插件搭建本地服务器，在扩展中搜索 Live Server，安装此插件。\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Assignments/Assignment%203.GAMES202HW0/202504072204421.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e在编辑 Html 代码窗口按 F1（或者右键）选择 \u003cstrong\u003eLive Server: Open with Live Server\u003c/strong\u003e，随后会自动打开浏览器，导航至本地服务器端口，一切正常的话是下面这个效果：\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Assignments/Assignment%203.GAMES202HW0/202504072210368.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e可以使用鼠标和右上角的控制面板进行一些简单的交互。\u003c/p\u003e\n\u003cp\u003e如果遇到模型贴图加载不出来（只有 202 字样），可以选择多刷新几次。或者直接在加载脚本前预加载贴图，可以根治这个问题。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003elink\u003c/span\u003e \u003cspan class=\"na\"\u003erel\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;preload\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003ehref\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;assets/mary/MC003_Kozakura_Mari.png\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eas\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;image\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003etype\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;image/png\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003ecrossorigin\u003c/span\u003e\u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"-0x02-blinn-phong-shading-model\"\u003e🤔 0x02 Blinn-Phong Shading Model\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003e没什么好说的，CV 工程师\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch3 id=\"使用-glsl-编写-shader\"\u003e\u003cstrong\u003e使用 GLSL 编写 Shader\u003c/strong\u003e\u003c/h3\u003e\n\u003cp\u003e如果使用字符串的方式引入 Shader，就在 InternalShader.js 中加上：\u003c/p\u003e","title":"Assignment 3. GAMES202 Homework 0"},{"content":" 偶然用 shader 乱搓出的一个类似漫画风格的放射线效果，实际上还有很多缺陷，权且当思路分享了\n🚩 0x00 To begin with 这篇文章将会包含以下内容：\n漫画的速度线效果 基于 blender 着色器的乱糊 👀 0x01 What is Radiating Lines 漫画中经常有这种放射线的效果：\n通常是用来表达角色情绪、物体速度感或突出画面重点，动漫中还会做出动画以增加表现力。但是如果不靠画（全凭程序化）怎么做呢？\n如果不限制工具的话思路很简单，在 PS 里先画一个渐变，再用波浪扭成间隔不等的尖刺状，最后转成极坐标就完事了。\n但是如果要用 shader 写的话就有点头疼了，主要是那种不规则的尖刺效果。我一开始想的是让 V 的起点在径向随机偏移，但 UV 毕竟是重复平铺的，极坐标一扭还是会窜到中心去，甚至 V 有没有都没关系。\n所以偏移 UV 的想法是不行了，我的做法是糊出一个遮罩（也是很邪门了）。\n🕸️ 0x02 Radiating Lines 放射线效果 要做出放射的效果，首先得让 UV 位于模型的中心，以正方形平面为例，在映射中将 UV 同时减去 0.5，让 UV (0,0) 处位于模型中心。\n然后就是让 U 绕圆周环游一圈，现在的 UV 可以当成直角坐标系处理，很容易给出一个单位圆的方程：\n$$ u^2+v^2=1 $$ 然后算以 U 轴正方向为起点的角度：\n$$ \\theta=\\arctan \\frac{v}{u} $$ 可以用运算节点的反正切 2 计算这个$\\theta$，但是结果的范围是$-\\pi \\sim+\\pi$，需要映射回$0 \\sim1$：\n用这个作为 UV 采样波浪纹理就可以有放射线的效果：\n为什么可以直接连矢量，因为 V 是啥都无所谓，由于波浪纹理的性质径向都是同一个值\n但是漫画里面的放射线粗细、间隔没有那么均匀，那就拿一个噪波扰动 U：\n酷！可以拿这玩意交平面构成作业了（bushi）。\n🗯️ 0x03 Urchin-Shaped Mask 海胆状遮罩 现在有了放射线的造型，但我们只需要周边的部分，为此需要径向剔除中心部分的放射线，首先需要拿到径向的 V，直接取原始 UV 的长度即可。然后需要让 V 径向随机偏移，这里我直接拿波浪纹理的输出与 V 相加，糊出一个海胆状遮罩。\n波浪纹理之后的乘数用于控制偏移范围\n最后拿这个遮罩和原本的放射线混合一下，遮罩为 0 处取原本颜色，否则取 1（白色）。\n然后就糊出来了\u0026hellip;\u0026hellip;\n⭐ 0x04 After words 做出这个的时候还特意网上搜了一圈，有 AE 和 PS 的教程，但似乎没有用 shader 还原的（也可能没搜到），当然毕竟是糊出来的，还有很多不可控的地方：\n常规的漫画放射线不一定均匀径向，会有随机角度偏移 无法方便地控制条纹大小 无法流畅稳定地做出速度线动画 实际如果有项目要用估计也是直接画好遮罩然后做帧动画的，但还是希望我这种邪门的做法是无用之用吧。\n","permalink":"https://congyuxiaoyoudao.github.io/posts/interludes/manga-speed-lines/","summary":"\u003cblockquote\u003e\n\u003cp\u003e偶然用 shader 乱搓出的一个类似漫画风格的放射线效果，实际上还有很多缺陷，权且当思路分享了\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x00-to-begin-with\"\u003e🚩 0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 漫画的速度线效果\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 基于 blender 着色器的乱糊\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x01-what-is-radiating-lines\"\u003e👀 0x01 What is Radiating Lines\u003c/h2\u003e\n\u003cp\u003e漫画中经常有这种放射线的效果：\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%202.MangaSpeedLines/202504021841792.jpg#center\"\u003e\u003c/p\u003e\n\u003cp\u003e通常是用来表达角色情绪、物体速度感或突出画面重点，动漫中还会做出动画以增加表现力。但是如果不靠画（全凭程序化）怎么做呢？\u003c/p\u003e\n\u003cp\u003e如果不限制工具的话思路很简单，在 PS 里先画一个渐变，再用波浪扭成间隔不等的尖刺状，最后转成极坐标就完事了。\u003c/p\u003e\n\u003cp\u003e但是如果要用 shader 写的话就有点头疼了，主要是那种不规则的尖刺效果。我一开始想的是让 V 的起点在径向随机偏移，但 UV 毕竟是重复平铺的，极坐标一扭还是会窜到中心去，甚至 V 有没有都没关系。\u003c/p\u003e\n\u003cp\u003e所以偏移 UV 的想法是不行了，我的做法是糊出一个遮罩（也是很邪门了）。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x02-radiating-lines-放射线效果\"\u003e🕸️ 0x02 Radiating Lines 放射线效果\u003c/h2\u003e\n\u003cp\u003e要做出放射的效果，首先得让 UV 位于模型的中心，以正方形平面为例，在映射中将 UV 同时减去 0.5，让 UV (0,0) 处位于模型中心。\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%202.MangaSpeedLines/202503312115356.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e然后就是让 U 绕圆周环游一圈，现在的 UV 可以当成直角坐标系处理，很容易给出一个单位圆的方程：\u003c/p\u003e\n\u003cdiv\u003e$$\nu^2+v^2=1\n$$\u003c/div\u003e\n\u003cp\u003e然后算以 U 轴正方向为起点的角度：\u003c/p\u003e\n\u003cdiv\u003e$$\n\\theta=\\arctan \\frac{v}{u}\n$$\u003c/div\u003e\n\u003cp\u003e可以用运算节点的反正切 2 计算这个$\\theta$，但是结果的范围是$-\\pi \\sim+\\pi$，需要映射回$0 \\sim1$：\u003c/p\u003e","title":"Interlude 2. Manga Speed Lines"},{"content":" 本着虚心交流与接受批评的态度，把这篇用来“应付”学校作业的综述写作放上来，不吝赐教！\n0x00 To begin with 这篇文章将会包含以下内容：\n近年来水体波形模拟的主要方法分类介绍 现有文献的研究趋势分析及未来方向预测 For reference：\n📖 真实感水体渲染技术总结 📖 九、流体模拟简述 📖 游戏中的实时水体模拟技术 0x01 摘要 真实感水体模拟因其高度仿真的视觉表现与广泛的应用领域成为计算机图形学的研究热点。本文回顾了近 50 年（1978-2025）来水体模拟的方法，具体而言，包括早期基于经验与统计的方法与参数化波列合成的方法，中期基于物理的数值模拟方法和波粒子方法，以及近年转向数据驱动的深度学习方法。\n关键词：流体模拟、波形模拟、深度学习、物理数值方法\nRealistic water simulation has emerged as a prominent research focus in computer graphics due to its highly realistic visual effects and broad application domains. This paper reviews the methods of water simulation over the past 50 years (1978–2025), specifically including early approaches based on empirical and statistical models, parametric wave synthesis methods, mid-term physics-based numerical simulation techniques and wave particle methods, as well as the recent shift toward data-driven deep learning approaches.\nKeywords: Fluid simulation, Wave simulation, Deep learning, Physics-based numerical methods\n0x02 引言 流体模拟作为计算机图形学与流体动力学的重要分支，是一种利用物理定律和计算机算法对流体（如水波、烟雾和火焰）的行为进行仿真预测的技术，在计算机图形学、虚拟现实、影视行业以及游戏开发领域有着广泛应用。数十年来，研究人员已经开发了一系列基于流体动力学的数值模型。虽然这些方法在模拟效果和算法优化上各有优势，但高质量的流体仿真的计算开销往往无法满足实时渲染的要求，特别是当进行高分辨率场景（如海洋、河流和湖泊）的模拟时，模拟时间将大幅增加。如何高效、实时地进行高质量、真实的流体模拟，是学术界和工业界亟待解决的难题。\n在自然界的众多流体中，水体堪称一个典型的模拟对象，在游戏中对其进行模拟的需求出现得尤为频繁。真实感的水体模拟不仅需要兼顾波形（Wave Shape）和着色（Shading）的拟真性，还要能够准确响应来自各种实体和用户的交互操作，以及具备用于优化的建模方法、LOD 技术和高效的曲面细分算法。其中，水体的波形模拟尤为关键，因为水波的形态和运动受到包含风力、深度和表面张力等诸多因素的共同影响，反映了水体与环境的各种交互形式，很大程度上影响模拟的真实性和视觉效果。\n传统的波形模拟方法大多为基于物理的数值模拟技术，根据流体的物理性质建立对应的数学模型，再利用数值方法对其求解，从而还原流体的动态行为。近年来，随着新一轮人工智能浪潮的兴起，研究人员也开始引入基于深度学习的方法[1]，通过训练一个数据驱动的模型代理传统模拟过程中计算最密集的部分，借助神经网络的强大拟合能力与现代硬件的加速支持，在最大程度上保证模拟精度的同时，显著提升了模拟速度。本篇文章将主要关注近几十年以来关于水体波形模拟的研究进展，内容涵盖最早的基于经验和统计的参数模型方法、传统的基于物理的数值方法和最新的基于深度学习的数据驱动方法。首先，我们将按照时间顺序回顾水体波形模拟技术的发展历史[2-4]；接着我们将已有的方案按照实现的技术路径分类，介绍各类方法在拟真性、实时性以及适用场景等方面的特点和优势；最后，我们将对收集到的相关文献进行统计汇总，并通过可视化手段进行趋势分析与未来研究方向的预测。通过系统地整理和分析现有文献，本篇文章旨在为研究人员提供一个较为全面的技术路线图，同时为进一步探索高效的真实感水体的波形模拟方法提供理论和实践参考。\n0x03 水体波形模拟概述 真实感的水体模拟因其卓越的视觉表现，作为提升沉浸感与视觉真实度的重要技术手段，在各类游戏中得到了广泛应用。\n比如求生之路 2 中诡秘的林间水面：\nWater - Valve Developer Community 再到地平线西之绝境中幽深的河滩：\n最后是红色沙漠（Crimson Desert，2025）中潺潺的溪涧流水：\nDev Archives: The Engine Behind Crimson Desert | GDC 2025 要想实现这些大作中极具表现力的水体渲染效果，首要难点就是对水体的波形模拟。如何表现出拟真的水面波浪动态，一直是学术界和工业界长期攻坚的技术难题。多年来，研究人员已经提出了一系列对水体波形进行模拟的方法，按时间顺序列出如下：\n凹凸贴图（Bump Mapping）[J. Blinn，1978][5] 随机波形（Random Waves）[Schachter，1980][6] 正弦波（Sinusoids Wave）[Max N L，1981][7] 分形噪声（Fractal Noise）[Perlin Ken，1985][8] Gerstner 波（Gerstner Wave）[Fournier 和 Reeves，1986][9] 快速傅里叶变换（Fast Fourier Transform）[Mastin 等，1987][10] B-样条（Beta-Splines）[T\u0026rsquo;so 和 Barsky，1987][11] 欧拉方法（Eulerian Approaches）[Kass 和 Miller，1990][12] 拉格朗日方法（Lagrangian Approaches）[Stam 和 Fiume，1995][13] 欧拉-拉格朗日混合方法（Hybrid Approaches）[Brein 和 Hodgins,1995][14] 空间-频谱混合方法（Spatial-Spectral Approaches）[Thon 等，2000][15] 波粒子（Wave Particles）[Yuksel 等，2007][16] 流型图（Flow Map）[Vlachos，2010] 回归森林（Regression Forests）[Ladický 等，2015][25] 水面波包方法（Water Wave Packets）[Jeschke，2017][21] 变形感知神经网络（Deformation-aware Neural Networks）[Prantl 等，2017][29] 卷积神经网络（CNN）[Tompson 等，2017][26] 水面小波方法（Water Surface Wavelets）[Jeschke，2018][22] 潜在空间物理方法（Latent Space Physics）[Wiewel 等，2019][30] 深度神经网络（Deep Neural Network）[Zhang 等，2020][31] 图神经网络（Graphic Neural Network）[Sanchez-Gonzalez 等，2020][34] 按照实现技术的路径分类，可将上文总结的方法分为如下几类：\n早期的基于凹凸贴图的方法 线性波形叠加方法 基于统计频谱的参数化方法 基于物理的数值模拟方法 波粒子方法 基于深度学习的方法 下面对每种类别的方法进行详细介绍。\n0x04 基于凹凸贴图的方法 Blinn 在 1978[5]年开发的凹凸贴图映射（bump mapping）技术是对水体波形模拟的首次尝试。这种方法通过施加一个凹凸函数扰动表面法线方向模拟波纹效果，可以很好地再现水体表面波浪反射的随机性。\n可以选用不同的凹凸映射函数对表面进行扰动。例如，Schachter（1980）[6] 提出了一个随机波形（Random Wave）的模型，该模型包含一张预计算的窄带噪声（narrow-band noise）波形表，通过叠加多个窄带噪声波形向量生成水体纹理；Perlin（1985）[8] 结合 20 个放射状摆线波形，使用球形波前替代传统的线性波前，每个波源的振幅与频率满足 $1/f$ 关系，这一性质表达分形的思想，打破了纹理分布的重复性。\n尽管这些基于凹凸贴图的方法易于使用且不需要复杂的模拟过程，但通常不足以满足模拟和动画的需求。由于没有在几何层面上对网格进行任何更改，视觉上表现“凸起”的波浪缺乏足够的几何细节和动态性，难以表现真实的水面运动。\n0x05 线性波形叠加方法 线性波形叠加方法基于波动的线性叠加原理，每个波列由一组独立的参数控制，叠加后可以生成复杂的表面形态。​在水体模拟中，常用的基础波形为正弦波和 Gerstner 波。\n正弦波（Sinusoids Wave） Max（1981）[7] 首先将傅里叶分析理论引入计算机图形学，在他著名的电影 Carla\u0026rsquo;s Island 中使用一系列正弦波的叠加近似 Stokes 波，模拟水面波浪效果。通过不同频率正弦波的线性组合构建水面高度场，用三个参数计算在时间$t$上的海面高度图：\n$$ h(x,z,t)=-y_{0}+\\sum_{i=1}^{N_{\\omega}}A_{i}\\cos(k_{i_{x}}x+k_{i_{z}}z-\\omega _{i}t) $$ 其中$N_{\\omega}$是波数，$A_{i}$是第$i$个波的振幅，$\\vec{k}=(k_{i_{x}},k_{i_{z}})$是波矢量，$\\omega_{i}$是其脉冲值，$y_{0}$是自由表面的高度。\nPeachey（1986）[17]通过引入波剖面函数（wave profile）与相位函数的分离设计，能够根据深度改变波的振幅，改进了这种方法。\n目前在水体渲染领域已经很少直接使用正弦波进行波形模拟，业界往往青睐于使用它的进化版 Gerstner 波。\nGerstner 波（Gerstner Wave） Fournier 和 Reeves (1986) [9]提出了基于 Gerstner 模型的水体波形建模方法，通过参数化轨道运动实现了复杂海面动态的模拟。这种方法的基本思想是使用自由表面上的每个粒子描述其静止位置周围的圆，粒子的运动方程（仅考虑 XZ 平面）可以表示为：\n$$ \\begin{aligned} x=\u0026x_{0}+r\\times \\sin(\\kappa x_{0}-\\omega t) \\\\ z=\u0026z_{0}-r\\times \\cos(\\kappa x_{0}-\\omega t) \\end{aligned} $$ 通过调节轨道半径$r$、波数$\\kappa$、角频率$\\omega$等参数，可以生成不同形态的波形（如正弦波、摆线波等）。为了模拟真实海洋表面的变化性和随机性，作者引入了波列（wave trains），即具有相同基本特征（高度，周期和波长）和相同初始相位的波浪组，每列波浪都有自己的一组参数和可选的随机元素。\n相较于传统的正弦波模型，Gerstner 波具有更尖锐的波峰和更平缓的波谷，能够更真实地还原海洋中波浪的非对称特征，适用于表现大尺度的水体动态。并且由于计算量可控，性价比高，不少游戏仍采用其作为水体渲染的基础实现。\n0x06 基于统计频谱的参数化方法 基于统计频谱的参数化方法区别于以往的空间域的模拟方法，首次引入了频域的概念。通过使用从理论或测量数据获得的波的频谱分布来描述海洋表面，然后从频域提取幅度分量并通过逆快速傅里叶变换（IFFT）转换回空间域，构造水体表面高度场，将表面的几何特征作为每个点$X$处的高度，如下所示：\n$$ h(X,t)=\\sum_{i=1}^{N_{s}}\\tilde{h}(\\vec{k},t)e^{\\vec{ik}X} $$ 其中$N_{s}$是频谱分量数，$\\vec{k}$是波矢量，$\\tilde{h}(\\vec{k},t)$是从理论或经验波谱中获得的傅里叶分量。\n快速傅里叶变换（FFT） Mastin 等（1987）[10]提出了基于傅立叶合成与经验风海频谱模型的海洋波形模拟技术。其风海频谱模型是基于 Pierson 和 Moskowitz（1964）[18]由波浪记录构建的一个完全开发的风海频谱模型，其功率谱可以表示为：\n$$ F_{PM}(f)=\\frac{\\alpha g^2}{(2\\pi)^4f^5}e^{-\\frac{5}{4}(\\frac{f_{m}}{f})^4} $$ 其中$\\alpha=0.0081$为 Phillips 常量，$g$是引力常数。频率峰值$f_{m}$可以如下表示：\n$$ f_{m}=\\frac{0.13g}{U_{10}} $$ 该模型揭示了完全开发的一维风海频谱的可以使用风速（$U_{10}$）计算得出，Hasselmann 等（1980）[19]在此基础上结合方向传播因子（$D(f,\\theta)$）提出了一个二维频谱：\n$$ \\begin{aligned} F(f,\\theta)=\u0026F_{PM}(f)D(f,\\theta)\\\\ D(f,\\theta)=\u0026N_{p}^{-1}\\cos^{2p}(\\theta/2) \\end{aligned} $$ 其中：\n$$ \\begin{aligned} p=9.77\\left( \\frac{f}{f_{m}} \\right)^\\mu,\u0026\\quad \\mu=\\begin{cases} 4.06 \\quad \u0026if \\quad ff_{m} \\end{cases} \\\\ N_{p}=\u0026\\frac{2^{1-2p}\\pi \\Gamma(2p+1)}{\\Gamma^2(p+1)} \\end{aligned} $$ Mastin 等在此基础上，使用正向 FFT、上述滤波器和逆 FFT，从白噪声图像中以非常简单的方式合成了海洋图像 。通过对白噪声图像进行二维快速傅里叶变换，使用二维风海频谱对 FFT 幅度项进行滤波，能够生成不错的波浪外观。\n后来的研究人员在此基础上尝试使用多种频谱对水体进行模拟，如 Premoze 和 Ashikhmin（2000）[20]的 JONSWAP，Tessendorf（2001）的高斯伪随机和 Phillips 理论波谱 。这些方法在制作静止图像方面效果很好，但不适合动画。因为用于进行模拟的频谱数据不包含在时间上的变化。\n空间-频谱混合方法（Spatial-Spectral Approaches） 由于传统 FFT 方法需要将频域滤波结果转换为有限空间域高度场，导致生成的海面尺寸受限且无法实现多细节层次。Thon 等（2000）[15]改良了这种方法，在一个小矩阵中对海洋频谱进行采样（a），选取其中在振幅方面最具代表性的分量（b），并将这些分量的值（频率、振幅、方向）用于摆线（trochoid）的参数。与传统的 FFT 方法不同，这种方法结合了空间域的模拟思想，使用 Gerstner 波直接叠加摆线（e），无需使用逆 FFT，从而保留了多种细节层次。另外，这种方法是完全程序化的，即只在需要时计算表面高度，无需存储网格或高度场。\n同时，仅选取少量代表性的频率分量也带来了精度不足的问题，为了解决这个问题，Thon 等引入了 Perlin（1985）的湍流函数替换所有没有被选择的分量：\n$$ Turbulence(P,m)=\\sum_{i=0}^{n_{o}-1}\\frac{Noise(P\\cdot m^i)}{m^i} $$ 其中，$n_{o}$是要求和的频率分量总数，$P$是空间中的一个点，$m$是频率乘数。通过在空间域中添加该湍流函数，引入了真实海浪谱中不存在的低频分量（f），而这些分量会在 Pierson-Moskowitz 滤波后被削弱甚至剔除（a）。\n最终的海面模型表示为：\n$$ \\begin{aligned} h(x,y,t)=\u0026 \\sum_{i=1}^nA_{i}\\cdot v(k_{i}(x\\cdot \\cos\\theta_{i}+y\\cdot \\sin \\theta_{i})-\\omega_{i}\\cdot t+\\phi_{i}) \\\\ +\u0026A_{t}\\cdot Turblence(x_{t}(t),y_{t}(t),z_{t}(t),m) \\end{aligned} $$ 其中，$A_{t}$是是湍流的振幅，$m$是频率乘数，$x_{t}(t),y_{t}(t),z_{t}(t)$ 是位移到湍流的时间函数。\n0x07 基于物理的数值方法 基于物理的方法使用 N-S（Navier-Stokes）方程对流体建立数学模型。N-S 方程作为流体力学的基础，描述了流体速度和质量随时间的变化过程，可以用来模拟各种流体的动态（如水体、烟雾、大气等）。模拟的核心在于对 N-S 方程的求解，从不同的视角出发，可以分为两种求解方法：欧拉方法（基于网格）和拉格朗日方法（基于粒子）。\nNavier-Stokes 方程 对于海洋等大型流体，通常考虑不可压缩流体，其形式为：\n$$ \\begin{aligned} \\nabla \\vec{U}\u0026=0 \\\\ \\frac{\\partial \\vec{U}}{\\partial t}+\\vec{U}\\nabla \\vec{U} + \\frac{\\vec{\\nabla}p}{\\rho}\u0026-\\mu \\frac{ \\nabla^2\\vec{U}}{\\rho}-\\vec{g}=0 \\end{aligned} $$ 其中$\\vec{U}$为流体的速度，$\\mu$为粘滞系数，$g$为引力常数。算子$\\nabla \\vec{U}$为$\\vec{U}$的梯度，可以根据下式计算：\n$$ \\nabla \\vec{U}=\\left(\\frac{\\partial u}{\\partial t},\\frac{\\partial v}{\\partial t},\\frac{\\partial w}{\\partial t} \\right) $$ 第一个方程为质量守恒方程，对于不可压缩流体，意味着流体的密度随时间保持不变；第二个方程为动量守恒方程，流体的加速度$\\frac{\\partial \\vec{U}}{\\partial t}$等于施加于其上的力除以其密度。\n欧拉方法（Eulerian approaches） 欧拉方法考察空间中的固定点，通过监测离散网格上的速度场、压力场、密度场等物理参数的变化规律，建立全局流场演化模型。这一方法的优势在于能够很方便地计算规则网格上的梯度，适用于大规模的流体模拟。\nKass 和 Miller（1990）[12]首次提出了基于物理方法的思想，通过三个假设（水面为高度场、垂直速度可忽略、水平速度恒定）将 N-S 方程简化为线性波动方程：\n$$ \\frac{{\\partial^2h}}{\\partial t^2}=gd \\frac{{\\partial ^2h}}{\\partial x^2} $$ 其中波速与水深的平方根成正比$c \\propto \\sqrt{ gd }$。为了求解这个方程，需要构造连续偏微分方程的离散形式，Kass 和 Miller 使用有限差分技术将其离散近似为：\n$$ \\begin{aligned} \\frac{{\\partial^2h_{i}}}{\\partial t^2}=-\u0026g\\left( \\frac{{d_{i-1}+d_{i}}}{2 (\\Delta x)^2}(h_{i}-h_{i-1}) \\right) \\\\ +\u0026g\\left( \\frac{{d_{i}+d_{i+1}}}{2 (\\Delta x)^2}(h_{i+1}-h_{i}) \\right) \\end{aligned} $$ 这种在连续均匀网格上的有限差分将将水体离散表示为水面高度场（$h_{i}$）、速度场（$u_{i}$）和水底（$b_{i}$），体现了欧拉方法的思想。\n拉格朗日方法（Lagrangian approaches） 拉格朗日方法基于组成流体的粒子，跟踪并考察每个粒子的运动轨迹、速度、压强、密度等随时间的变化。这一方法的优势在于粒子间的独立性更利于 GPU 并行运算，适用于模拟流体的细节，但处理大规模流体时效率较低。\nStam 和 Fiume（1995）[13]首次引入将 SPH（Smoothed Particle Hydrodynamics，光滑粒子流体动力学）引入图形学，从拉格朗日视角描述流体。SPH 的核心思想包括将流体离散为一组粒子，这些粒子为可通过核函数影响其邻居的潜在隐式表面（也称为 blobs 或 metaballs）。利用核函数对物理量进行加权平均，从而近似求解 N-S 方程。\n在拉格朗日视角下，N-S 方程可以被简化：考虑到每个粒子都有自己的质量，它们的总和等于流体的总质量，并且随时间保持不变，可以省略 N-S 的质量守恒方程；在动量守恒方程中，由于流体由粒子本身传输，可以省略非线性平流项（$\\vec{U}\\nabla \\vec{U}$）。最后，只需要一个线性方程来描述流体的速度：\n$$ \\frac{{\\partial \\vec{U}}}{\\partial t}=\\frac{1}{\\rho}(-\\nabla p+\\mu \\nabla^2\\vec{U}+\\rho g) $$ 其中，$\\nabla p$为压力，$\\mu \\nabla^2\\vec{U}$为粘性力，$\\rho g$为外部压力。相关物理量可以如下近似：\n$$ S_{i}=\\sum_{j=1}^{N_{p}}m_{j}{\\frac{S_{j}}{\\rho_{j}}}W(r,R) $$ 对压强计算梯度可以得到压力，对速度应用拉普拉斯算子可以计算粘滞力。\nFeldman 和 Bonet（2007）[42]对基于 SPH 的框架进行改进，提出了动态粒子细化的一般方法，根据固定的细化模式获得子粒子的最佳质量分布；Winchenbach 和 Kolb（2021）[43]对细化过程进行改进，使用一个离散化的目标函数自适应优化细化模式，显著降低了因固定模式带来的误差，能够模拟高达 1:1,000,000 的自适应体积比的不可压缩低粘度湍流。\n拉格朗日-欧拉混合的方法（Hybrid approaches） 为了结合这两种方法的优势，研究人员还开发了结合这两种技术的混合方法，即使用欧拉方法模拟流体主体部分，再使用拉格朗日方法模拟小尺度细节，例如泡沫，气泡或喷雾，事实上，这种合成的方法也获得了非常逼真的结果。\nBrein 和 Hodgins（1995）[14]首次结合了这两种方法，模拟流体在撞击时产生的波浪、飞溅现象；Takahashi 等（2003）[38]在欧拉模拟中嵌入粒子系统，创建包括飞溅和泡沫在内的复杂场景；Greenwood 和 House（2004）[39]使用水平集方法在流体空腔、流体-空气边界处创建气泡，实现了视觉上更为真实的水体表现。\nThürey 等（2006）[40]提出了一种将二维浅水模拟与三维自由表面流体模拟相结合的方法，通过使用 Lattice Boltzmann 方法求解 Saint-Venant 方程，实现了 2D 与 3D 仿真的耦合；之后作者扩展了这项工作，引入 SPH 方法表示粒子间的相互作用（2007）[41]。\n0x08 波粒子方法 波粒子方法使用粒子代表水波，允许其发生反射以及和其它对象相互作用。这种方法结合波粒二象性的思想，将三维水体波形模拟转化为对二维粒子运动进行模拟。\n波粒子（Wave Particle） Yuksel 等（2007）[16]首次引入波粒子（wave particle）的概念，模拟水体与其它物体的交互。每个波浪粒子携带振幅、波长、传播方向等属性，通过叠加局部偏差函数生成水面高度场。\n$$ D_{i}(x,t)=\\frac{a_{i}}{2}\\left(\\cos{\\frac{\\pi|x-x_{i}(t)|}{2}}+1\\right)\\Pi\\left(\\frac{{|x-x_{i}(t)|}}{2r_{i}}\\right) $$ 其中$a_{i}$是波粒子的振幅，$r_{i}$是波粒子的半径，$\\Pi(x)$是矩形函数，保证波形在有限范围内不发生突变：\n$$ \\begin{equation} \\Pi(x)=\\left\\{ \\begin{aligned} 1 \u0026 , \u0026 |x| \u003c \\frac{1}{2} \\\\ \\frac{1}{2} \u0026, \u0026 |x| = \\frac{1}{2} \\\\ 0 \u0026, \u0026 |x| \\ge \\frac{1}{2} \\end{aligned} \\right. \\end{equation} $$ 为了模拟真实水波的横纵波复合效应，Yuksel 等扩展了高度场，引入水平偏差分量$\\eta_{xy}$：\n$$ \\begin{aligned} \\eta_{xy}(x,t)=\u0026\\sum_{i}D_{i}^L(x,t) \\\\ D_{i}^L(x,t)=\u0026L_{i}(\\hat{u}_{i}\\cdot(x-x_{i}))D_{i}(x,t) \\\\ L_{i}(u)=\u0026-\\sin\\left( \\frac{2\\pi u}{l_{i}} \\right)\\Pi\\left( \\frac{u}{l_{i}} \\right)\\hat{u}_{i} \\end{aligned} $$ 其中，$D_{i}^L$是纵向局部偏差函数，$L_{i}$是描述纵向波形的函数，模拟波粒子的圆周运动。\n此外，当波前扩展超过波粒子半径一半以上时，将波粒子分裂为三个子波粒子（振幅和分散角为原波粒子的三分之一，波粒子半径不变），这种细分方法可以保持波前的连续性，兼顾波形的扩展和收缩。\n水面波包方法（Water Wave Packets） Jeschke 和 Wojtan（2017）[21]在波粒子理论的基础上，提出了水面波包（water packets）方法，将水波能量封装为波包单元。与传统波粒子方法不同，每个波包包含一组波长和波列，其运动由两个速度控制：\n群速度：决定波包整体能量传播方向 相速度：决定波包内部波峰运动速度 基于 Airy 波理论，波包高度场通过如下公式近似：\n$$ \\eta(x,t)\\approx \\sum_{j=1}^N a_{j}\\phi(\\hat{x})\\cos(k_{j}\\cdot(\\hat{x}-X_{pg})) $$ 其中，$\\phi(x)$为高斯包络函数，$X_{pg}$为相速度与群速度累计位移差，$a_{j}$和$k_{j}$分别表示波包的振幅和波数。为了维持能量守恒，根据面积变化动态调整波包振幅：\n$$ a_{j}(t_{n+1})=a_{j}(t_{n})\\sqrt{ {\\frac{(\\rho g+\\sigma k_{n}^2)A_{n}}{(\\rho g+\\sigma k_{n+1}^2)A_{n+1}}} } $$ 其中，$A_{n}$为波包面积，$\\rho,\\sigma$分别为水密度和张力。\n水面小波方法（Water Surface Wavelets） Jeschke 等（2018）[22]提出的水面小波方法（water surface wavelets）在水面波包算法基础上进行了改进。通过引入小波变换，将水面高度场分解为空间-频率方向的振幅函数：\n$$ \\eta(x,t)=Re\\int_{\\mathbb{R}^2} A(x,k,t)e^{i(k\\cdot x-\\omega(k)t)}dk $$ 其中振幅函数$A(x,k,t)$包含位置$x$，波矢量$k$和时间$t$。这种变换将高频波动信息编码到振幅函数中，使其空间变化更平滑，降低了对网格分辨率的要求。\n0x09 基于深度学习的方法 近年来，深度学习技术逐渐被研究人员引入水体模拟领域，通过使用基于深度学习的方法，以较小的计算成本近似求解流体动力学方程（N-S 方程），能够提升模拟效率和逼真度。对应求解 N-S 的两种主流视角，使用深度学习的加速也分为两大类别：对欧拉或拉格朗日方法进行加速，也有研究人员在这方面做了一些总结[23-24]。\n回归森林（Regression Forest） Ladický 等（2015）[25]针对传统基于粒子的流体模拟方法（如 SPH，PBF 等）计算成本高、步长受限等问题，首创性地提出了一种数据驱动的基于机器学习的方法。他们将求解 N-S 方程的过程转化为回归问题，通过训练回归森林模型学习并预测粒子加速度。特征向量直接对应 N-S 方程中的粘度、张力、压力和不可压缩约束：\n$$ \\begin{aligned} \u0026\\Phi_{X,R}^{vise}(x_{i})=\\frac{\\mu}{\\rho_{0}}\\left( \\sum_{j\\in X} v_{j}\\Omega_{R}(x_{j}-x_{i})-v_{i}\\sum_{j\\in X} \\Omega_{R}(x_{j}-x_{i}) \\right)\\\\ \u0026\\Phi_{X,R}^{ten}(x_{i})=\\frac{\\sigma}{\\rho_{0}}\\left( \\sum_{j\\in X} x_{j}\\Omega_{R}(x_{j}-x_{i})-x_{i}\\sum_{j\\in X} \\Omega_{R}(x_{j}-x_{i}) \\right)\\\\ \u0026\\Phi_{X,R}^{pres}(x_{i})=\\frac{k}{\\rho_{0}}\\nabla \\sum_{j\\in X} \\Omega_{R}(x_{j}-x_{i})\\\\ \u0026\\Phi_{X,R}^{comp}(x_{i})=\\phi_{X,R}^O(x_{i}) \\end{aligned} $$ 该特征向量设计使得模型能够在保持物理合理性的前提下，实现比传统 SPH 方法快 10-1000 倍的实时模拟速度。\n卷积神经网络（Convolutional Neural Network） Tompson 等（2017）[26]提出了一种基于卷积神经网络加速欧拉流体模拟的方法，使用卷积神经网络替代传统压力投影求解器，结合无监督学习框架提高计算效率。\n该方法的核心在于使用 CNN（卷积神经网络）预测不可压缩流体的压力场，代替传统数值方法中对一个大型稀疏线性系统进行求解的过程。具体而言，研究人员设计了一个专门的网络结构，网络的输入为速度场的散度和边界条件，输出为对流体压力场的估计。\n实验结果表明，该方法在二维和三维模拟中都能实现实时性能，并且在保持物理准确性的同时，显著提高了计算效率。​\nXiao 等（2018）[27]在此基础上改变了网络的输入：根据离散化欧拉网格结构和中间速度作为输入，预测泊松系统的解；Zhang 等（2023）[28]通过训练 CNN 求解 ISPH（不可压缩光滑粒子流体动力学）中的 PPE（泊松压力方程），首次训练了一个拉格朗日视角的 CNN 模型。\n图神经网络（Graph Neural Network） Sanchez-Gonzalez 等（2020）[32]首次提出了基于图神经网络的求解器（GNS），将图神经网络引入流体模拟：用图中的节点表示物理系统的粒子，并通过学习的信息传递来计算流体动力学；Li 和 Farimani（2022）[34]使用 GNN 加速拉格朗日流体模拟；Zhang 等（2024）[34]使用 GNN 代替 CNN 对 ISPH 的 PPE 进行求解，增加了对非结构化数据的处理；Higaki 等（2025）[35]在 GNS 的基础上提出了逐步增强的 GNS，证明了在固定粒子/节点大小时，拉格朗日求解器的时间步长可以显著增加。\n0x0a 总结 各类模拟方法总结 本文主要介绍了近 50 年以来计算机图形学中对水体模拟的各项研究工作，研究人员提出了大量且多样的模拟方法，我们大致可以将这些方法分为六个类别。具体而言，基于凹凸贴图的方法和线性波形叠加方法是对水体模拟的早期尝试，这些方法虽然能在视觉上获得不错的模拟效果，但缺乏物理交互性，无法表现动态细节；随着研究的进展，基于统计频谱的参数化方法和基于物理的数值方法逐渐成为主流，这些方法在满足了交互性的同时，能够提供极其拟真的洋面外观，但因为计算复杂度较高，难以满足实时渲染的要求；近年来，波粒子方法和基于深度学习的技术也陆续涌现，为水体模拟带来了新的可能性。\n水体波形模拟类别 主要思想 优势 劣势 适用场景 基于凹凸贴图的方法 通过预定义的纹理（法线贴图/高度贴图）模拟水面波纹，利用光影变化产生动态效果。 计算开销极低；实时性高；实现简单 无物理交互性；缺乏动态细节变化；近景效果差 游戏或实时渲染中的远距离水面、低硬件需求场景 线性波形叠加方法 叠加多个波形（如 Gerstner 波）以形成复杂波形，通过调整参数模拟不同波浪形态。 效果可控性高；计算效率较高；基本实现动态效果 缺乏真实物理交互;高频细节不足；参数调节依赖经验 电影特效中的规则波浪、游戏中的中距离海面 基于统计频谱的参数化方法 基于海洋学统计模型（如 Phillips 频谱），通过FFT生成符合真实海浪频谱分布的波形。 高真实度；自然波形细节丰富；支持大范围海面模拟 计算复杂度较高 ；参数调节复杂 ；实时性受限 影视级海洋场景、航海模拟器、大规模开放水域 基于物理的数值方法 直接求解流体力学方程（Navier-Stokes），模拟水体与环境的物理交互。 物理精确度高 ；支持复杂交互 ；动态细节最丰富 计算成本极高;实时性差；实现复杂度高 科研仿真、高精度影视特效、流体动力学研究 波粒子方法 将波浪分解为携带能量和方向的粒子系统，模拟波的传播与交互。 支持局部细节控制；动态交互性好；适合复杂边界 大规模模拟效率低；粒子间连贯性难保持；能量守恒实现复杂 游戏中的近景波浪特效（如船只尾迹）、小型水域交互模拟 基于深度学习的方法 使用神经网络学习近似求解传统流体力学方程，可通过数据驱动的方式模拟复杂水体行为。 可生成超现实效果；数据驱动适配性强；潜在的高效推理可能 需要大量训练数据；物理一致性难保证；实时推理硬件要求高 风格化水体渲染、快速原型设计、结合传统方法的混合系统 补充说明：\n实时性排序：凹凸贴图 \u0026gt; 波粒子 ≈ 波形叠加 \u0026gt; 参数化方法 \u0026gt; 深度学习 \u0026gt; 物理数值方法 真实度排序：物理数值方法 \u0026gt; 参数化方法 \u0026gt; 深度学习 ≈ 波形叠加 \u0026gt; 波粒子 \u0026gt; 凹凸贴图 趋势：工业界常采用混合方案（如：参数化方法+波粒子），学术界则聚焦物理数值方法与深度学习的结合。 硬件依赖性：深度学习与物理数值方法对 GPU/算力要求最高，凹凸贴图方法最轻量。 可视化分析及预测 使用 Zotero 对检索到的文献进行可视化分析：\n可以在词云图中看到，除了传统的“Navier-Stokes”、“Particle methods”、“Lagrangian ISPH”等基于物理方法外，“Artificial neural networks”、“CNN”、“deep neural network”等基于神经网络方法的术语也开始更为频繁地出现。这或许是一个积极的信号，表明后续的研究中以 AI 助力的水体模拟方法将不断涌现。当然，未来也有可能会出现用于模拟不同细节层次水体表现（如波浪、涡流、湍流、泡沫、浮沫等）的新方法。同时，为了优化模拟效率，获得实时速率，有必要针对不同层次的表现采取差异化的模拟策略，使用可扩展且动态自适应的建模策略，合理分配计算资源。\n0x0b Afterwords 虽然是用于应付学校论文写作课的作业（学校也不指望你真读懂个什么主要是把写论文软件和格式摸清，所以我至少会用 Zotero 了），粗浅地浏览各路大佬和 SIGGRAPH 的文章，不敢说有多大收获，但至少对这些出色的工作心向往之。有机会的话会单独开一篇 Issue 针对这个领域做一些研究。\n感谢前人的综述，为我的作业提供了很大帮助。\n0x0c Reference [1] DU T. Deep Learning for Physics Simulation[C/OL]//ACM SIGGRAPH 2023 Courses. New York, NY, USA: Association for Computing Machinery, 2023: 1-25[2025-04-10]. https://dl.acm.org/doi/10.1145/3587423.3595518 . DOI: 10.1145/3587423.3595518 .\n[2] DARLES E, CRESPIN B, GHAZANFARPOUR D, 等. A Survey of Ocean Simulation and Rendering Techniques in Computer Graphics[J/OL]. Computer Graphics Forum, 2011, 30(1): 43-60. DOI: 10.1111/j.1467-8659.2010.01828.x .\n[3] IGLESIAS A. Computer Graphics Techniques for Realistic Modeling, Rendering, and Animation of Water. Part I: 1980-88[C]//Proceedings of the International Conference on Computational Science-Part II. Berlin, Heidelberg: Springer-Verlag, 2002: 181-190.\n[4] IGLESIAS A. Computer Graphics Techniques for Realistic Modeling, Rendering and Animation of Water. Part II: 1989–1997[C/OL]//SLOOT P M A, HOEKSTRA A G, TAN C J K, 等. Computational Science — ICCS 2002. Berlin, Heidelberg: Springer, 2002: 191-200. DOI: 10.1007/3-540-46080-2_20 .\n[5] BLINN J F. Simulation of wrinkled surfaces[J/OL]. SIGGRAPH Comput. Graph., 1978, 12(3): 286-292. DOI: 10.1145/965139.507101 .\n[6] SCHACHTER B. Long crested wave models[J/OL]. Computer Graphics and Image Processing, 1980, 12(2): 187-201. DOI: 10.1016/0146-664X(80)90011-8 .\n[7] MAX N L. Vectorized procedural models for natural terrain: Waves and islands in the sunset[C/OL]//Proceedings of the 8th annual conference on Computer graphics and interactive techniques. New York, NY, USA: Association for Computing Machinery, 1981: 317-324[2025-04-10]. https://dl.acm.org/doi/10.1145/800224.806820 . DOI: 10.1145/800224.806820 .\n[8] PERLIN K. An image synthesizer[J/OL]. SIGGRAPH Comput. Graph., 1985, 19(3): 287-296. DOI: 10.1145/325165.325247 .\n[9] FOURNIER A, REEVES W T. A simple model of ocean waves[C/OL]//Proceedings of the 13th annual conference on Computer graphics and interactive techniques. New York, NY, USA: Association for Computing Machinery, 1986: 75-84[2025-04-10]. https://dl.acm.org/doi/10.1145/15922.15894 . DOI: 10.1145/15922.15894 .\n[10] MASTIN G A, WATTERBERG P A, MAREDA J F. Fourier Synthesis of Ocean Scenes[J/OL]. IEEE Computer Graphics and Applications, 1987, 7(3): 16-23. DOI: 10.1109/MCG.1987.276961 .\n[11] TS’O P Y, BARSKY B A. Modeling and rendering waves: wave-tracing using beta-splines and reflective and refractive texture mapping.[J/OL]. ACM Trans. Graph., 1987, 6(3): 191-214. DOI: 10.1145/35068.35070 .\n[12] KASS M, MILLER G. Rapid, stable fluid dynamics for computer graphics[J/OL]. SIGGRAPH Comput. Graph., 1990, 24(4): 49-57. DOI: 10.1145/97880.97884 .\n[13] STAM J, FIUME E. Depicting fire and other gaseous phenomena using diffusion processes[C/OL]//Proceedings of the 22nd annual conference on Computer graphics and interactive techniques. New York, NY, USA: Association for Computing Machinery, 1995: 129-136[2025-04-10]. https://dl.acm.org/doi/10.1145/218380.218430 . DOI: 10.1145/218380.218430 .\n[14] O’BRIEN J F, HODGINS J K. Dynamic Simulation of Splashing Fluids[A/OL]. (2023-02-13)[2025-04-11]. http://arxiv.org/abs/2302.06087 . DOI: 10.1109/CA.1995.393532 .\n[15] THON S, DISCHLER J M, GHAZANFARPOUR D. Ocean waves synthesis using a spectrum-based turbulence function[C/OL]//Proceedings Computer Graphics International 2000. 2000: 65-72[2025-04-11]. https://ieeexplore.ieee.org/document/852321 . DOI: 10.1109/CGI.2000.852321 .\n[16] YUKSEL C, HOUSE D H, KEYSER J. Wave particles[C/OL]//ACM SIGGRAPH 2007 papers. New York, NY, USA: Association for Computing Machinery, 2007: 99-es[2025-04-10]. https://dl.acm.org/doi/10.1145/1275808.1276501 . DOI: 10.1145/1275808.1276501 .\n[17] PEACHEY D R. Modeling waves and surf[C/OL]//Proceedings of the 13th annual conference on Computer graphics and interactive techniques. New York, NY, USA: Association for Computing Machinery, 1986: 65-74[2025-04-12]. https://dl.acm.org/doi/10.1145/15922.15893 . DOI: 10.1145/15922.15893 .\n[18] PIERSON JR. W J, MOSKOWITZ L. A proposed spectral form for fully developed wind seas based on the similarity theory of S. A. Kitaigorodskii[J/OL]. Journal of Geophysical Research (1896-1977), 1964, 69(24): 5181-5190. DOI: 10.1029/JZ069i024p05181 .\n[19] HASSELMANN D E, DUNCKEL M, EWING J A. Directional Wave Spectra Observed during JONSWAP 1973[J/OL]. 1980[2025-04-12]. https://journals.ametsoc.org/view/journals/phoc/10/8/1520-0485_1980_010_1264_dwsodj_2_0_co_2.xml .\n[20] PREMO\u0026quot;E S, ASHIKHMIN M. Rendering Natural Waters[C]//Proceedings of the 8th Pacific Conference on Computer Graphics and Applications. USA: IEEE Computer Society, 2000: 23.\n[21] JESCHKE S, WOJTAN C. Water wave packets[J/OL]. ACM Trans. Graph., 2017, 36(4): 103:1-103:12. DOI: 10.1145/3072959.3073678 .\n[22] JESCHKE S, SKŘIVAN T, MÜLLER-FISCHER M, 等. Water surface wavelets[J/OL]. ACM Trans. Graph., 2018, 37(4): 94:1-94:13. DOI: 10.1145/3197517.3201336 .\n[23] SHARMA P, CHUNG W T, AKOUSH B, 等. A Review of Physics-Informed Machine Learning in Fluid Mechanics[J/OL]. Energies, 2023, 16(5): 2343. DOI: 10.3390/en16052343 .\n[24] LINO M, FOTIADIS S, BHARATH A A, 等. Current and emerging deep-learning methods for the simulation of fluid dynamics[J/OL]. Proceedings of the Royal Society A: Mathematical, Physical and Engineering Sciences, 2023, 479(2275): 20230058. DOI: 10.1098/rspa.2023.0058 .\n[25] LADICKÝ L, JEONG S, SOLENTHALER B, 等. Data-driven fluid simulations using regression forests[J/OL]. ACM Trans. Graph., 2015, 34(6): 199:1-199:9. DOI: 10.1145/2816795.2818129 .\n[26] TOMPSON J, SCHLACHTER K, SPRECHMANN P, 等. Accelerating eulerian fluid simulation with convolutional networks[C]//Proceedings of the 34th International Conference on Machine Learning - Volume 70. Sydney, NSW, Australia: JMLR.org, 2017: 3424-3433.\n[27] XIAO X, ZHOU Y, WANG H, 等. A Novel CNN-Based Poisson Solver for Fluid Simulation[J/OL]. IEEE Transactions on Visualization and Computer Graphics, 2020, 26(3): 1454-1465. DOI: 10.1109/TVCG.2018.2873375 .\n[28] ZHANG N, YAN S, MA Q, 等. A CNN-supported Lagrangian ISPH model for free surface flow[J/OL]. Applied Ocean Research, 2023, 136: 103587. DOI: 10.1016/j.apor.2023.103587 .\n[29] PRANTL L, BONEV B, THUEREY N. Generating Liquid Simulations with Deformation-aware Neural Networks[A/OL]. arXiv, 2019[2025-04-11]. http://arxiv.org/abs/1704.07854 . DOI: 10.48550/arXiv.1704.07854 .\n[30] WIEWEL S, BECHER M, THUEREY N. Latent-space Physics: Towards Learning the Temporal Evolution of Fluid Flow[A/OL]. arXiv, 2019[2025-04-11]. http://arxiv.org/abs/1802.10123 . DOI: 10.48550/arXiv.1802.10123 .\n[31] ZHANG Y, BAN X, DU F, 等. FluidsNet: End-to-end learning for Lagrangian fluid simulation[J/OL]. Expert Systems with Applications, 2020, 152: 113410. DOI: 10.1016/j.eswa.2020.113410 .\n[32] SANCHEZ-GONZALEZ A, GODWIN J, PFAFF T, 等. Learning to Simulate Complex Physics with Graph Networks[C/OL]//Proceedings of the 37th International Conference on Machine Learning. PMLR, 2020: 8459-8468[2025-04-13]. https://proceedings.mlr.press/v119/sanchez-gonzalez20a.html .\n[33] LI Z, FARIMANI A B. Graph neural network-accelerated Lagrangian fluid simulation[J/OL]. Computers \u0026amp; Graphics, 2022, 103: 201-211. DOI: 10.1016/j.cag.2022.02.004 .\n[34]ZHANG N, YAN S, MA Q, 等. A hybrid method combining ISPH with graph neural network for simulating free-surface flows[J/OL]. Computer Physics Communications, 2024, 301: 109220. DOI: 10.1016/j.cpc.2024.109220 .\n[35] HIGAKI T, TANABE Y, HASHIMOTO H, 等. Step-by-step enhancement of a graph neural network-based surrogate model for Lagrangian fluid simulations with flexible time step sizes[J/OL]. Applied Ocean Research, 2025, 154: 104424. DOI: 10.1016/j.apor.2025.104424 .\n[36] SHU D, LI Z, BARATI FARIMANI A. A physics-informed diffusion model for high-fidelity flow field reconstruction[J/OL]. Journal of Computational Physics, 2023, 478: 111972. DOI: 10.1016/j.jcp.2023.111972 .\n[37] TAKAHASHI T, FUJII H, KUNIMATSU A, 等. Realistic Animation of Fluid with Splash and Foam[J/OL]. [2025][2025-04-13]. https://onlinelibrary.wiley.com/doi/10.1111/1467-8659.00686 .\n[38] GREENWOOD S T, HOUSE D H. Better with bubbles: enhancing the visual realism of simulated fluid[C/OL]//Proceedings of the 2004 ACM SIGGRAPH/Eurographics symposium on Computer animation - SCA ’04. Grenoble, France: ACM Press, 2004: 287[2025-04-13]. http://portal.acm.org/citation.cfm?doid=1028523.1028562 . DOI: 10.1145/1028523.1028562 .\n[39] THÜREY N, RÜDE U, STAMMINGER M. Animation of Open Water Phenomena with coupled Shallow Water and Free Surface Simulations[J].\n[40] THÜREY N, SADLO F, SCHIRM S, 等. Real-time Simulations of Bubbles and Foam within a Shallow Water Framework[J].\n[41] LUO M, KHAYYER A, LIN P. Particle methods in ocean and coastal engineering[J/OL]. Applied Ocean Research, 2021, 114: 102734. DOI: 10.1016/j.apor.2021.102734 .\n[42] FELDMAN J, BONET J. Dynamic refinement and boundary contact forces in SPH with applications in fluid flow problems[J/OL]. International Journal for Numerical Methods in Engineering, 2007, 72(3): 295-324. DOI: 10.1002/nme.2010 .\n[43] WINCHENBACH R, KOLB A. Optimized Refinement for Spatially Adaptive SPH[J/OL]. ACM Trans. Graph., 2021, 40(1): 8:1-8:15. DOI: 10.1145/3363555 .\n","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/a-review-of-realistic-water-waveform-simulation/","summary":"\u003cblockquote\u003e\n\u003cp\u003e本着虚心交流与接受批评的态度，把这篇用来“应付”学校作业的综述写作放上来，不吝赐教！\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x00-to-begin-with\"\u003e0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 近年来水体波形模拟的主要方法分类介绍\u003c/li\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 现有文献的研究趋势分析及未来方向预测\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://zhuanlan.zhihu.com/p/95917609\"\u003e真实感水体渲染技术总结\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://zhuanlan.zhihu.com/p/672738842\"\u003e九、流体模拟简述\u003c/a\u003e\n\n\u003c/li\u003e\n\u003cli\u003e📖 \n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://zhuanlan.zhihu.com/p/21573239\"\u003e游戏中的实时水体模拟技术\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"0x01-摘要\"\u003e0x01 摘要\u003c/h2\u003e\n\u003cp\u003e真实感水体模拟因其高度仿真的视觉表现与广泛的应用领域成为计算机图形学的研究热点。本文回顾了近 50 年（1978-2025）来水体模拟的方法，具体而言，包括早期基于经验与统计的方法与参数化波列合成的方法，中期基于物理的数值模拟方法和波粒子方法，以及近年转向数据驱动的深度学习方法。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e关键词\u003c/strong\u003e：流体模拟、波形模拟、深度学习、物理数值方法\u003c/p\u003e\n\u003cp\u003eRealistic water simulation has emerged as a prominent research focus in computer graphics due to its highly realistic visual effects and broad application domains. This paper reviews the methods of water simulation over the past 50 years (1978–2025), specifically including early approaches based on empirical and statistical models, parametric wave synthesis methods, mid-term physics-based numerical simulation techniques and wave particle methods, as well as the recent shift toward data-driven deep learning approaches.\u003c/p\u003e","title":"Assignment 2. A Review of Realistic Water Waveform Simulation"},{"content":" 由于嵌入式模型的不透明、高维度以及大规模现代数据集的限制，对其进行解释和使用极具挑战性。Zijie J. Wang 等开发了 WizMap，一个交互式可视化工具，允许用户轻松探索和解释嵌入式大模型\n🚩 0x00 To begin with 应可视化课程要求，本篇旨在对原论文项目进行粗浅的翻译\u0026amp;介绍\u0026amp;分析，仅作学习之用 论文名：WizMap: Scalable Interactive Visualization for Exploring Large Machine Learning Embeddings 作者：Zijie J. Wang, Fred Hohman, Duen Horng Chau\n这篇文章将会包含以下内容：\nEmbedding Model 及其局限 前人的交互可视化工具 散点图 等高线图 WizMap 的目的及意义 WizMap 的实现分析 笔者的看法 For reference👇：\n📖 原论文地址： [2306.09328] WizMap: Scalable Interactive Visualization for Exploring Large Machine Learning Embeddings 📺 原演示视频： Demo Video \u0026ldquo;WizMAP: Scalable Interactive Visualization for Exploring Large ML Embeddings\u0026rdquo; 🔧 演示地址： WizMap 📖 没有思考过 Embedding，不足以谈 AI 📺 Machine Learning Crash Course: Embeddings 📺 14 Embedding Projector 🤔 0x01 What is LLM Embedding 为了让没有接触过机器学习的读者更容易理解原论文的工作，笔者加上了此节\nConcept of Embedding 嵌入的概念 在数学上，Embedding 代表一个映射关系：$f:X\\to Y$，该关系是同构的，即在单射条件下保持数学结构（加性、乘法等）；在机器学习领域，用于将数据源映射到另外一个空间，数据源可以来自于文本、图像甚至视频，这里以文本为例\n对于人而言，语言有丰富的上下文、语法规则、多义性、文化背景等特性。而对于机器而言，语言只不过是 0 和 1 。如果想让机器能够“理解”自然语言，就需要对文本信息进行抽象编码。\nEmbedding（嵌入）就提供了对自然语言的一层抽象，将离散的文本输入转换为连续的向量表示。这种向量表示能够捕捉文本的语义（上下文）信息，使其可以被 LLM 处理、存储和计算。\nSemantic Baseed Encoding 基于词义的编码 Embedding 对自然语言到多维向量空间的映射必须是一一映射，也即每个词语映射到一个唯一向量，不同词语映射的向量不同。这种操作有效降低了因为自然语言中可能存在的“多词一义”、“一词多义”而导致的复杂性，也是编码需要遵循的条件之一。\n映射到多维向量空间主要有两个理由：\n1.可以对应单词多维度特征，也即单词不同层面的属性。相较于标量，向量能够承载更多的信息，Embedding 就像把这些属性“嵌入”到向量各维度的值的过程。比如说 apple 可以表示为这样：\nWord Animal Big Red Expensive \u0026hellip; apple -0.5 0.1 0.7 0.3 \u0026hellip; 属性只是举个例子，不太恰当敬请谅解\n然而如何确定能描述全部自然语言词语的所有维度是困难的。即使是同一词语，在不同上下文的意义也可能不同，与其大费周章找到一套固定的维度，不如将词语定义在其处于的上下文中，用词与词之间的关系定义词。\nApple pie is my favorite dessert during the holidays. Discover the innovative world of Apple and shop everything iPhone\u0026hellip; 在第一句中，Apple 的含义与 Pear 接近，但在第二句中却与 Huawei 接近。\n2.可以对向量（准确而言是向量之间的关系）进行度量。这里就用到一些基本的线性代数性质：在定义了度量函数的向量空间中，可以用距离代表两个向量间的相似度。类似的，我们也可以用两个 Embedding Vector 间的距离代表其映射的词语的相似度。\n$$ Similarity(A,B)=D_{Euclidean}(A,B)=\\sqrt{ \\Sigma_{i=1}^n{(a_{i}-b_{i})^2} } \\\\ $$ 仅遵循单射条件只是完成了词语层面的抽象，机器仍然无法理解其背后的语义。为了能够用内积描述自然语言的相似度，Embedding必须让语义相似的单词在向量空间中接近（相对于语义差距大的）。比如在下面的例子中，“Sail”和“Ship”在向量空间中是接近的，但与“How”距离较远。\nEmbedding in Neural Network 神经网络中的嵌入层 许多神经网络处理的任务中，输入层往往是 One hot 编码的离散数据。在其组成的矩阵过于稀疏时，信息量低，计算成本高。嵌入层通过将这一输入映射至维数较低的向量，优化了计算过程，提高了计算效率。\n嵌入层本质上是一个可学习的权重矩阵，行数等于词汇表长度，列数等于预设的嵌入维度。通过损失函数和反向传播机制，不断调整嵌入矩阵中的数值，使得语义相近的单词在向量空间中更接近。\n嵌入的维度可以调整，具体取决于要升维还是降维。维数不够就加维度！\n在最后输出之前，产生的向量通常不会等于任何一个已知对应单词的嵌入向量，这个时候对其做一次模糊匹配，转换为各类别的概率分布，即每个单词匹配该向量的概率。最后输出概率最高的单词。\nInterpret and Use 解释与使用 神经网络可以类比为黑箱，可见的只有输入输出，而其学习、推理的过程是不透明的。就嵌入向量而言，一个单词的嵌入往往是几百维的浮点值，我们难以解释它的具体意义；另一方面，嵌入向量之间的距离也是通过不透明的学习过程决定的，我们只能猜测靠的近的两个向量具有语义上的相似性，而无法说明为什么某一对向量的距离比另一对近。这带来了解释上的困难。\n现代 LLM 通常在 TB 级别的数据上进行训练，数据集规模之庞大，导致嵌入词的规模和嵌入向量的维数巨大，带来了更为昂贵的存储、检索和计算开销。数千维的空间下，数据可视化极为困难，无法通过常规绘图手段（二维/三维坐标系）直观地展示数据。这带来了可视化的困难。\n如果为了优化模型或提升嵌入质量，需要研究人员对嵌入向量进行人为干预、调整，这个过程无疑是极为灾难的。为了应对这些困难，许多学者提出了各式各样的交互式可视化工具用来帮助用户探索嵌入空间，比如本篇介绍的 WizMap。\n📃 0x02 Previous Work 前人的工作 Scatter Plot 散点图 一个探索嵌入空间的方法是将嵌入降维后可视化在低维散点图中。Smilkov 等人在2016年提出了 Embedding Projector，允许用户使用 PCA（主成分分析）和 t-SNE（t-分布随机邻域嵌入）方法将高维嵌入投影到 2D 或 3D 空间中，从而直观地观察数据分布。\n这种基于散点图的方法可以较好地理解嵌入的局部结构，但面对大型数据集时，逐个检查嵌入数据点以理解嵌入空间的全局结构既费时又难以实现。\nContour Plot 等高线图 近年来也有学者提出基于等高线的可视化交互方法，Robertson 等人在2023年提出的 Angler 系统中将高维数据投影到二维平面，并以等高线的形式表示数据密度或相似度，使用户能够获得嵌入空间的整体概览，并通过叠加比较多个嵌入结果。\n这种基于等高线的方法可以快速获得对嵌入空间的全局概览，但限制了用户对嵌入局部结构的探索，且缺乏足够的视觉环境（Visual Context）。\nConclusion 结论 基于散点图的方法难以获取嵌入的全局概览，而基于等高线图的方法虽能展现数据密度，但缺乏对局部细节的刻画。两种方法各有局限。\nWizMap 旨在整合散点图与等高线图各自的优势，使用地图式交互设计为用户提供全面的视角，允许用户以不同粒度探索嵌入空间，“降低了解释和使用嵌入的门槛”（lowers the barrier to interpreting and using embeddings）。\n🔥0x03 Related Work 相关工作 Dimensionality Reduction 降维 如前文所述，嵌入向量的高维性为可视化带来了困难，而高维数据的可视化通常是应用降维技术将其投影至二维或三维空间，再使用常规的可视化方式进行处理。\n一些流行的降维技术包括 UMAP（均匀流形近似与投影）、t-SNE（t-分布随机邻域嵌入）和 PCA（主成分分析）。这些技术在维护嵌入的结构、随机性、参数敏感性、计算效率与复杂度方面各有利弊。针对不同结构、属性的数据权衡不同的降维方法，能够得到更优的降维效果。\n尽管各自降维技术存在诸多不同，但其输出均为低维嵌入表示，是形如(n_samples, n_components)的矩阵。在 WizMap 中，用户可以自由选择降维方式，以可视化投影后的嵌入。\nInteractive Embedding Visualization 交互式嵌入可视化 可视化工具的一些常见交互工具\u0026amp;方式包括：\n缩放、旋转和平移 2D 或 3D 投影嵌入以探索和检查数据点特征，如 Embedding Projector 专门用于大规模（数百万级）散点图的绘制，如 regl-scatterplot 潜在空间制图，帮助用户理解不同区域的数据分布及其语义特征，如 Latent Space Cartography 此外，研究人员还设计了一些用于比较不同降维方法产生的嵌入空间的可视化工具：\n未给出链接的工具截至目前（2025/3/30）没有开源实现\n可视化两个嵌入之间的局部和全局相似性，如 embComp（Heimerl 等，2022） 追踪数据点在两个嵌入中的位置变化（使用星轨 Star Rail 可视化），如 Emblaze（Sivaraman 等，2022） 突出显示在嵌入中变化最大的点周围的邻域，如 embedding-comparator （Boggust 等，2022） 这些工具可以通过比较不同模型、不同算法生成的嵌入，比较嵌入表示之间的差异，并​分析嵌入的全局和局部结构，以评估模型的表示学习能力。\n与此相反，WizMap 通过提供不同粒度（全局-局部）的视觉上下文，帮助用户导航和解释大型嵌入空间的全局和局部结构。\n📄0x04 Multi-scale Embedding Summarization 多尺度嵌入摘要 Embedding Summarization（嵌入摘要）旨在从大型嵌入空间中提取核心信息，以帮助用户理解和探索数据的结构、分布及其重要特征\n生成嵌入摘要主要具有两个挑战：\n如何在大型数据集中有效地总结数百万级的数据点 如何选择要总结的嵌入区域（用户对不同大小和粒度的区域有不同的兴趣） WizMap 的开发者提出了一种新颖的方法，以自动生成大规模数据的多尺度嵌入摘要。\nMulti-resolution Quadtree Aggregation 多分辨率四叉树聚合 首先应用降维技术，将高维嵌入向量投影到二维平面上的点。从这些点出发，构建一颗四叉树。\n这颗四叉树的每一次划分将二维空间分为四个相等的正方形区域（叶节点），每个区域仅存在一个数据点。为了得到不同粒度级别的嵌入汇总，自底向上遍历树。在每次迭代中提取每个叶节点中嵌入的摘要，然后将最低级别的叶节点与其父节点合并。这个过程递归地进行，形成级别越来越高（图中自下而上）的叶节点，直到整个树最终合并为一个根节点。\n有点 2048 的思想在，了解图形学八叉树的同学应该理解起来没压力\n整个过程是预计算的，当用户在 WIZMAP 中缩放时，系统会自动将缩放映射到合适的嵌入摘要级别，实现多粒度摘要的动态切换。\n可以联想谷歌地图自适应缩放的做法\nScalable Leaf-level Summarization 可扩展叶级别摘要 形成四叉树聚合时，研究人员可以选择任何合适的方法来从叶节点总结嵌入。对于文本嵌入，开发者提出了基于块的 TF-IDF（t-TF-IDF），它将 TF-IDF（Term Frequency-Inverse Document Frequency）应用于从叶节点提取关键词（Sparck Jones，1972）。\nt-TF-IDF 通过将文档划分为多个“块”来计算词频（TF）将长文档 $d$ 划分为多个较小的块 $B_{i}$，对每个块计算块级 TF ： $t\\text{-} TF(t,B)=\\frac{词 t 在块 B 中的出现次数}{块 B 的总词数}$\n类似于基于类的 TF-IDF（c-TF-IDF），在计算 TF-IDF 分数之前将聚类中的文档合并为一个元文档（Grootendorst，2022）。将每个叶节点中的所有文档（即四叉树分区中的一个块）合并为一个元文档，并在所有叶节点上计算 TF-IDF 分数。最后，提取具有最高 t-TF-IDF 分数的关键词来总结叶节点中的嵌入。\n$t\\text{-}TF\\text{-}IDF(t,d)=\\max\\limits_{B\\in d}(t\\text{-}TF(t,B)\\times IDF(t))$，选取该词在所有块中权重最高的值，保留局部特征\n这种方法是可扩展的，并且与四叉树聚合相辅相成。由于文档合并是分层的，只需构建一次 n-gram 计数矩阵，并在每次聚合迭代中仅通过一次矩阵乘法进行更新。\n在 MacBook Pro 上，总结跨越三个粒度级别的 180 万个文本嵌入，只需大约 55 秒。 处理非文本数据的嵌入时，可以通过找到叶节点中与嵌入中心点最近的点来总结嵌入。\n🖥️User Interface 用户界面 结合 livedemo 和演示视频进行解释\nWizMap 的 UI 大致可以分为如下 3 个部分：\nMap View 地图视图 地图视图是 WIZMAP 的主要视图。它提供了一个熟悉的地图界面，允许用户平移和缩放以探索不同大小的嵌入区域。为了帮助用户轻松地调查嵌入的全局和局部结构，地图视图集成了三层可视化：\nContour：等高线轮廓展示嵌入分布，可视化嵌入全局结构 Label：标签展示嵌入摘要，可视化嵌入摘要 Scatter：散点展示嵌入点信息，可视化嵌入局部结构 分布轮廓（Distribution Contour）。使用核密度估计（KDE）（Rosenblatt，1956）来估计二维嵌入点的分布。以标准的多变量高斯核和 Silverman 带宽作为 KDE 模型（Silverman，2018）。\n核密度估计是一种非参数方法，通过对每个数据点施加核函数（$K$，带宽 $h$ 决定其平滑程度），并汇总所有核函数的贡献，估计总体的概率密度函数。​其数学表达式为：$\\hat{f}(x)=\\frac{1}{nh}\\Sigma_{i=1}^nK\\left( \\frac{x-x_{i}}{h} \\right)$\n在一个 200×200 的二维网格上计算分布似然，该网格的大小由所有嵌入点的范围决定。最后，将网格上的似然性可视化为一个等高线图，突出显示嵌入的密度分布。研究人员可以调整网格密度，通过平衡计算时间和等高线分辨率来调整它。\n多分辨率标签（Muti-resolution Labels），用户可以通过这些预计算的上下文标签，在各个粒度级别上动态地解释嵌入。当用户悬停在某个网格上时，将这部分通过四叉树聚合生成的摘要标签叠加到等高线图和散点图上。并能根据用户的当前缩放级别调整标签的瓦片（Tile）大小。\n此外，此视图还能够自动标记高密度嵌入点区域，通过显示靠近高概率轮廓多边形（high-probability contour polygons）几何中心四叉树瓦片的摘要实现。\n散点图（Scatter Plot），\n用二维散点图可视化所有嵌入点及其位置。用户可以指定每个嵌入点的颜色来编码额外的特征，例如嵌入类的颜色。此外，用户可以将鼠标悬停在散点上以显示其原始数据。\nControl Panel 控制面板 地图视图默认显示所有三个可视化层，用户可以通过控制面板中的按钮自定义视图显示。此外，WizMap 允许用户通过在地图视图中叠加它们来比较同一嵌入空间中的多个嵌入组（Gleicher，2018）。对于包含时间的嵌入，用户可以使用控制面板右侧的滑块来观察嵌入随时间的变化。\nSearch Panel 搜索面板 搜索和筛选可以帮助用户发现有趣的嵌入模式并测试他们对嵌入结构的假设（Carter 等人，2019 年）。在 WizMap 中，用户可以使用搜索面板搜索包含指定单词的文本嵌入。面板显示搜索结果列表，地图视图高亮显示相应的嵌入点。\nScalable \u0026amp; Open-source Implementation 可扩展且开源的实现 WizMap 可扩展至数百万个嵌入点，无需后端服务器。利用现代网络技术，特别是 WebGL 通过 regl API（Lysenko，2016 年）渲染嵌入点。还使用 Web Workers 和 Streams API 来实现大嵌入点的流式传输。将嵌入与渲染并行处理。\n为了实现快速全文搜索，应用了 FlexSearch（Wilkerling，2019）的上下文索引评分算法。使用 D3（Bostock 等，2011）进行其他可视化，并使用 scikit-learn（Pedregosa 等，2011）进行核密度估计（KDE）。为了 WizMap 可以轻松集成到用户当前的流程中（Wang 等，2023），应用 NOVA（Wang 等，2022b）使 WIZMAP 在笔记本中可用。用户还可以通过唯一的 URL 与协作者共享他们的嵌入图。\nWizMap 的开发者提供详细的教程，帮助用户处理嵌入，并且将 WizMap 开源，以支持嵌入探索工具的未来研究和开发。\n🔧Usage Scenarios 使用场景 Exploring ACL Research Topic Trends 探索 ACL 研究趋势 海伦（Helen）是一位科学历史学家，她利用 ACL Anthology 数据集研究 NLP 领域的演变。她提取了 63k 篇论文的标题和摘要，并使用 MPNet 生成 768 维嵌入向量。然后，她通过 UMAP 降维，并调整参数以优化投影分布。她利用 WizMap 生成嵌入摘要、KDE 分布和流式数据的 JSON 文件，并基于年份特征分析 NLP 研究主题的时间演变。\n在可视化分析中，海伦通过缩放和平移探索嵌入结构，并使用搜索功能查找特定关键词的论文。她发现不同的研究方向形成了独立簇，例如翻译、摘要和医疗 NLP。在播放嵌入演变动画时，她观察到 NLP 研究主题的变化，如语法研究热度下降，而问答、讽刺、幽默和仇恨言论等新主题逐渐兴起。最终，她决定撰写一篇论文，探讨 NLP 研究趋势的演变。\nInvestigating Text-to-Image Model Usage 探究文生图模型的使用 Bob 是一名机器学习研究员，致力于改进文生图的模型。他使用 DiffusionDB 数据集，分析用户的文本提示（prompt）与生成图像的关系。为此，他采用 CLIP 将文本和图像编码为 768 维嵌入，并利用 UMAP 降维到 2D 空间。随后，他使用 WizMap 进行可视化，探索 360 万个嵌入的结构。\n在嵌入探索过程中，Bob 发现提示主要分为艺术和摄影两个类别，并进一步识别出摄影类别中的两个小簇：非人类物体和名人。随后，他通过比较文本嵌入与图像嵌入的分布，发现“电影”相关的文本嵌入密度较低，而“艺术肖像”在图像嵌入中密度较高，这可能表明 Stable Diffusion 在生成逼真的人脸图像方面存在一定的局限性。Bob 对这个发现感到满意，并将其用于改进训练数据。\n🛤️ Future Work and Conclusion 未来工作与结论 WizMap 集成基于四叉树的嵌入摘要技术，方便用户解释多种粒度级别的嵌入，展现出为机器学习领域提供可视化嵌入的潜力。开发者同时也反思开发过程中的不足，并提炼出了未来的研究方向：\n用户研究，探究用户眼中自适应的视图粒度变换的有效性 自动洞察，基于四叉树的方法对瓦片大小敏感，需要更稳健的嵌入摘要方法 增强比较，叠加等高线的方法在局部比较上劣于其它技术，如并列（Juxtaposition）和显式编码（Explicit Encoding） 📉 Broader Impact 更广泛的影响 不良分子可能会恶意利用从 WizMap 中获得的见解。例如，研究表明机器学习嵌入包含社会偏见（Bolukbasi 等人，2016 年）。因此，不良分子可以通过注入已知与性别和种族偏见相关的嵌入来操纵和破坏机器学习预测。\n📝 After words 个人拙见，主要描述除投影算法和开发者已反思的不足之外的个人看法。可能是一些细枝末节的角度，如有不当，还望海涵\n搜索功能允许搜索关键词并高亮相关嵌入，但没有提供更高级的查询功能，如按类别筛选、模糊匹配或复杂逻辑查询，并根据结果相关度分不同级别高亮嵌入。\n观察嵌入在的时间上的演变时，无法选取播放区域和控制动画播放速度，需要在时间上细致分析时可能显得不够灵活。\n原文中提到用户可以指定每个嵌入点的颜色来编码额外的特征，比如嵌入类（Users can specify the color of each embedding point to encode additional features, such as the class of embeddings.）。但在 live demo 中并没有为嵌入提供手动标记和分类的操作，这一步骤是否发生在可视化之前？\n总体上仍然是极为优秀的可视化工具，感谢开发者们的工作。\n","permalink":"https://congyuxiaoyoudao.github.io/posts/assignments/analysis-of-wizmap-scalable-interactive-visualization/","summary":"本文就原论文分析了交互式可视化工具 WizMap，探讨其如何通过创新的多尺度摘要技术解决大规模机器学习嵌入的可视化和解释难题，并介绍了其用户界面、应用场景及未来发展。","title":"Assignment 1. Analysis of WizMap Scalable Interactive Visualization"},{"content":" 之前的几篇文章中，我们已经完成了Toon模型一些基本的Feature。在深入定制之前，我们必须冷静下来思考一下我们需要什么\n🚩 0x00 To begin with 这篇文章将会包含以下内容：\n集成卡渲着色模型 For reference👇：\n📖 【UE5】通过深度偏移计算刘海投影 👀 0x02 ToonIntergrateBxDF 目前我们的光照模型只是已经具备了基本的明暗处理，仍然有数量众多的Feature需要去实现（Ramp、SDF、Anisotropic hair、Matcap、眼透等等）。虽然有一小部分可以用一些trick附加在基础色或其它材质槽上，但其余的就无可奈何了。而且不同于Unlit，在底层逻辑写死的情况下，能在材质编辑器中做的工作就比较有限了。\n一个比较容易想到的方法就是为需要特殊处理的材质单独新建一个着色模型，比如说头发一个、皮肤一个，然后里面各自处理需要的Feature。但着色模型数量是有限的，在不扩展FMaterialShadingModelField至uint32的情况下最多只允许16个着色模型。\n而且这么多模型逐一添加也比较繁琐，我认为不太优雅，所以想到另一个方法。既然我们已经有一个ToonBxDF，何不仿照IntegrateBxDF的写法，在里面写个大分支，分发不同Toon材质的情况呢？\n思路有了，那下一个问题就是，根据什么分发呢？IntegrateBxDF是根据ShadingModelID，我们应该也需要一个ToonShadingModelID。这个时候我想起了之前开辟但没使用过的ToonCustom引脚。现在它被存储在GBuffer的CustomData的Alpha通道中，我们可以停止放置Play，好好使用它了。\nFGBufferData中的CustomData在BasePass中经过编码来到GBufferD，最后输出到MRT[4]或MRT[5]。\n类型是float，（没错，half4竟然是个宏）。\n但是范围只有0~1，由于GBufferTexture的性质，我们在这个引脚输入的任何数值都会被硬件钳制到这个范围。没办法，写个函数转一下吧。\n1uint DecodeFloatToToonShadingModelID(float SrcFloat) 2{ 3 return SrcFloat * 255; 4} UE里面也新写一个材质函数，把正常的ID映射过去，\n最后在ToonIntergrateBxDF里随便写点东西测试一下，只有当ToonShadingModelID为0时才有光照计算。\n单独把身体的ID设成2看看效果。\n","permalink":"https://congyuxiaoyoudao.github.io/posts/interludes/toon-intergrated-bxdf/","summary":"\u003cblockquote\u003e\n\u003cp\u003e之前的几篇文章中，我们已经完成了Toon模型一些基本的Feature。在深入定制之前，我们必须冷静下来思考一下我们需要什么\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x00-to-begin-with\"\u003e🚩 0x00 To begin with\u003c/h2\u003e\n\u003cp\u003e这篇文章将会包含以下内容：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput checked=\"\" disabled=\"\" type=\"checkbox\"\u003e 集成卡渲着色模型\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eFor reference\u003c/strong\u003e👇：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📖\n\n\n\n\n\n\n\n  \n  \u003ca href=\"https://zhuanlan.zhihu.com/p/690066418\"\u003e【UE5】通过深度偏移计算刘海投影\u003c/a\u003e\n\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"-0x02--toonintergratebxdf\"\u003e👀 0x02  ToonIntergrateBxDF\u003c/h2\u003e\n\u003cp\u003e目前我们的光照模型只是已经具备了基本的明暗处理，仍然有数量众多的Feature需要去实现（Ramp、SDF、Anisotropic hair、Matcap、眼透等等）。虽然有一小部分可以用一些trick附加在基础色或其它材质槽上，但其余的就无可奈何了。而且不同于Unlit，在底层逻辑写死的情况下，能在材质编辑器中做的工作就比较有限了。\u003c/p\u003e\n\u003cp\u003e一个比较容易想到的方法就是为需要特殊处理的材质单独新建一个着色模型，比如说头发一个、皮肤一个，然后里面各自处理需要的Feature。但着色模型数量是有限的，在不扩展\u003ccode\u003eFMaterialShadingModelField\u003c/code\u003e至\u003ccode\u003euint32\u003c/code\u003e的情况下最多只允许16个着色模型。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"ShadingModelField只有16位\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503111821380.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e而且这么多模型逐一添加也比较繁琐，我认为不太优雅，所以想到另一个方法。既然我们已经有一个\u003ccode\u003eToonBxDF\u003c/code\u003e，何不仿照\u003ccode\u003eIntegrateBxDF\u003c/code\u003e的写法，在里面写个大分支，分发不同Toon材质的情况呢？\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503111821796.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e思路有了，那下一个问题就是，根据什么分发呢？\u003ccode\u003eIntegrateBxDF\u003c/code\u003e是根据\u003ccode\u003eShadingModelID\u003c/code\u003e，我们应该也需要一个\u003ccode\u003eToonShadingModelID\u003c/code\u003e。这个时候我想起了之前开辟但没使用过的ToonCustom引脚。现在它被存储在GBuffer的\u003ccode\u003eCustomData\u003c/code\u003e的Alpha通道中，我们可以停止放置Play，好好使用它了。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"CustomData的存储\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503111821429.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eFGBufferData\u003c/code\u003e中的\u003ccode\u003eCustomData\u003c/code\u003e在BasePass中经过编码来到GBufferD，最后输出到MRT[4]或MRT[5]。\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503111928061.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e类型是\u003ccode\u003efloat\u003c/code\u003e，（没错，\u003ccode\u003ehalf4\u003c/code\u003e竟然是个宏）。\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503111822563.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e但是范围只有0~1，由于GBufferTexture的性质，我们在这个引脚输入的任何数值都会被硬件钳制到这个范围。没办法，写个函数转一下吧。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"将浮点转为ToonShadingModelID\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503112000369.png#center\"\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-hlsl\" data-lang=\"hlsl\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kt\"\u003euint\u003c/span\u003e \u003cspan class=\"n\"\u003eDecodeFloatToToonShadingModelID\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003efloat\u003c/span\u003e \u003cspan class=\"n\"\u003eSrcFloat\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e  \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e  \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eSrcFloat\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e255\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e  \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eUE里面也新写一个材质函数，把正常的ID映射过去，\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"MF_EncodeToonShadingModelIDToFloat\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503112007292.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"编码后接入自定义引脚\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503112010055.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e最后在ToonIntergrateBxDF里随便写点东西测试一下，只有当\u003ccode\u003eToonShadingModelID\u003c/code\u003e为0时才有光照计算。\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503112012801.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003e单独把身体的ID设成2看看效果。\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/TA-interludes/Interlude%201.ToonIntegrateBxDF/202503112017053.png#center\"\u003e\u003c/p\u003e","title":"Interlude 1. ToonIntergratedBxDF"},{"content":"\n我喜欢仰望月亮。\n每每沐浴那宛若来自神明的清辉，流动的静谧，澄澈的涤荡，便能够宽赦种种浮躁、不安与愤懑。\n——隐忍之心。含蓄典雅。闲寂幽静。\n平日的生活倒也算称意，辰时而作，子时而息，忙碌于课业的闲暇也有时间钻研自己的兴趣。这般日子确乎不惊波澜，我也默许。\n然而心底总潜藏着对近月之仪的向往，渴望捧起其矜持之下为人所漠视的——无声的冷焰。\n","permalink":"https://congyuxiaoyoudao.github.io/about/","summary":"\u003cp\u003e\u003cimg alt=\"Sakurakouji Luna\" loading=\"lazy\" src=\"https://raw.gitmirror.com/congyuxiaoyoudao/Picgo-ImageBed/main/Wives/202505260010872.jpg\"\u003e\u003c/p\u003e\n\u003cbr\u003e\n\u003cp\u003e我喜欢仰望月亮。\u003c/p\u003e\n\u003cbr\u003e\n\u003cp\u003e每每沐浴那宛若来自神明的清辉，流动的静谧，澄澈的涤荡，便能够宽赦种种浮躁、不安与愤懑。\u003c/p\u003e\n\u003cbr\u003e\n\u003cp\u003e——隐忍之心。含蓄典雅。闲寂幽静。\u003c/p\u003e\n\u003cbr\u003e\n\u003cp\u003e平日的生活倒也算称意，辰时而作，子时而息，忙碌于课业的闲暇也有时间钻研自己的兴趣。这般日子确乎不惊波澜，我也默许。\u003c/p\u003e\n\u003cbr\u003e\n\u003cp\u003e然而心底总潜藏着对近月之仪的向往，渴望捧起其矜持之下为人所漠视的——无声的冷焰。\u003c/p\u003e","title":"About"},{"content":"","permalink":"https://congyuxiaoyoudao.github.io/nebula/","summary":"","title":"Nebula"}]