NRC

光线追踪与神经渲染技术:从零基础到前沿实践

本教材面向零基础读者,系统讲解光线追踪、全局光照及神经渲染技术的原理与实践。


第一章:光线追踪与全局光照基础概念

1.1 什么是光线追踪?

光线追踪(Ray Tracing)是一种基于物理原理的渲染技术,通过模拟光线在场景中的传播行为来生成逼真的图像。

1.1.1 光线追踪的基本思想

在现实世界中,光线从光源发出,经过物体的反射、折射、散射等过程,最终进入人眼或相机。光线追踪正是模拟这一过程,但为了计算效率,我们通常采用逆向追踪(Backward Tracing):从相机发出光线,逆向追踪其与场景中物体的交互,直到到达光源。

1
2
3
4
5
光源 ──────────────────────────────────────> 人眼
(物理世界中光线传播方向)

相机 ──────────────────────────────────────> 光源
(光线追踪中的逆向追踪)

1.1.2 核心光线类型

光线类型 英文名称 作用描述
主光线 Primary Ray / Camera Ray 从相机发出,穿过像素进入场景的第一条光线
阴影光线 Shadow Ray 从着色点射向光源,判断该点是否被遮挡
反射光线 Reflection Ray 光线击中光滑表面后按反射定律继续传播
折射光线 Refraction Ray 光线穿过透明物体时改变方向

1.1.3 光线与物体的相交测试

光线追踪的核心操作是判断光线与几何体的相交。对于一条光线,可以用参数方程表示:

\(\mathbf{r}(t) = \mathbf{o} + t\mathbf{d}\),其中 \(t \geq 0\)

其中:

  • \(\mathbf{o}\) 是光线起点(origin)
  • \(\mathbf{d}\) 是光线方向(direction),通常为单位向量
  • \(t\) 是参数,表示光线上的点与起点的距离

光线与球体相交测试示例:

设球心为 \(\mathbf{c}\),半径为 \(r\),光线上的点 \(\mathbf{r}(t)\) 到球心的距离等于半径时相交:

\[\mathbf{o} + t\mathbf{d} - \mathbf{c} = r\]

两边平方并展开:

\[(\mathbf{o} - \mathbf{c} + t\mathbf{d}) \cdot (\mathbf{o} - \mathbf{c} + t\mathbf{d}) = r^2\]

整理得到关于 \(t\) 的一元二次方程:

\[t^2(\mathbf{d} \cdot \mathbf{d}) + 2t\mathbf{d} \cdot (\mathbf{o} - \mathbf{c}) + (\mathbf{o} - \mathbf{c}) \cdot (\mathbf{o} - \mathbf{c}) - r^2 = 0\]

由于 \(\mathbf{d}\) 是单位向量,\(\mathbf{d} \cdot \mathbf{d} = 1\),简化为:

\[t^2 + 2bt + c = 0\]

其中:

  • \(b = \mathbf{d} \cdot (\mathbf{o} - \mathbf{c})\)
  • \(c = \mathbf{o} - \mathbf{c}^2 - r^2\)

判别式 \(\Delta = b^2 - c\)

  • \(\Delta < 0\):不相交
  • \(\Delta = 0\):相切
  • \(\Delta > 0\):相交于两点

1.2 渲染的基本物理量

理解光线追踪需要先掌握光度学的基本概念。

1.2.1 辐射通量(Radiant Flux, \(\Phi\)

定义: 单位时间内发射、传播或接收的辐射能量。

\[\Phi = \frac{dQ}{dt} \quad [W = J/s]\]

单位是瓦特(Watt, W),表示光源的总功率。

1.2.2 辐射强度(Radiant Intensity, \(I\)

定义: 点光源在单位立体角内发出的辐射通量。

\[I = \frac{d\Phi}{d\omega} \quad [W/sr]\]

单位是瓦特每球面度(W/sr)。

立体角(Solid Angle) 是三维空间中角度的推广。一个球面上的面积 \(A\) 对球心的立体角为:

\[\omega = \frac{A}{r^2} \quad [sr]\]

完整球面的立体角为 \(4\pi\) sr。

1.2.3 辐照度(Irradiance, \(E\)

定义: 单位面积上接收的辐射通量。

\[E = \frac{d\Phi}{dA} \quad [W/m^2]\]

辐照度描述了入射到表面的光能密度。

重要性质:Lambert余弦定律

当光线以角度 \(\theta\) 倾斜入射时,有效接收面积增大,辐照度减小:

\[E = E_0 \cos\theta = \frac{\Phi \cos\theta}{A}\]

这就是为什么在北半球,南坡比北坡更温暖——阳光入射角更小,接收的能量更多。

1.2.4 辐射率(Radiance, \(L\)

定义: 单位投影面积、单位立体角内的辐射通量。

\[L = \frac{d^2\Phi}{dA_{\perp} d\omega} = \frac{d^2\Phi}{dA \cos\theta d\omega} \quad [W/(m^2 \cdot sr)]\]

辐射率是渲染中最核心的物理量,因为:

  1. 感知相关性: 人眼和相机传感器感知的是辐射率
  2. 传播不变性: 在真空中沿光线传播时,辐射率保持不变(忽略衰减)
  3. 可加性: 多个光源的辐射率可以线性叠加

1.2.5 各物理量之间的关系

1
2
3
4
5
6
7
Radiant Flux (Φ) [W]

├──> Intensity (I = dΦ/dω) [W/sr] ← 点光源特性

└──> Irradiance (E = dΦ/dA) [W/m²] ← 表面接收量

└──> Radiance (L = dE/dω⊥) [W/(m²·sr)] ← 最精细的描述

1.3 双向反射分布函数(BRDF)

1.3.1 BRDF的定义

当光线照射到物体表面时,一部分被吸收,一部分被反射。双向反射分布函数(Bidirectional Reflectance Distribution Function, BRDF)描述了入射光如何被反射到不同方向。

定义: 出射辐射率与入射辐照度的比值。

\[f_r(\omega_i \rightarrow \omega_o) = \frac{dL_o(\omega_o)}{dE_i(\omega_i)} = \frac{dL_o(\omega_o)}{L_i(\omega_i) \cos\theta_i d\omega_i}\]

其中:

  • \(\omega_i\):入射方向
  • \(\omega_o\):出射方向
  • \(L_o\):出射辐射率
  • \(L_i\):入射辐射率
  • \(\theta_i\):入射角

单位:\([sr^{-1}]\)

1.3.2 BRDF的性质

1. 非负性(Positivity)

\[f_r(\omega_i \rightarrow \omega_o) \geq 0\]

物理上,反射光不可能为负。

2. 互易性(Reciprocity, Helmholtz原理)

\[f_r(\omega_i \rightarrow \omega_o) = f_r(\omega_o \rightarrow \omega_i)\]

入射和出射方向交换,BRDF值不变。这一性质在双向路径追踪中非常重要。

3. 能量守恒(Energy Conservation)

\[\int_{\Omega} f_r(\omega_i \rightarrow \omega_o) \cos\theta_o d\omega_o \leq 1\]

反射的总能量不超过入射能量。

1.3.3 常见BRDF模型

1. Lambertian BRDF(理想漫反射)

\[f_r = \frac{\rho}{\pi}\]

其中 \(\rho\) 是反照率(albedo),取值范围 \([0, 1]\)

Lambert模型假设光线均匀地向所有方向反射,因此BRDF是常数。粗糙表面如纸张、粉笔可用此模型近似。

2. Phong反射模型

Phong模型包含三个分量:

\[L_o = k_a L_a + k_d (L \cdot N) + k_s (R \cdot V)^n\]

  • 环境光(Ambient):\(k_a L_a\),简化间接光照
  • 漫反射(Diffuse):\(k_d (L \cdot N)\)
  • 镜面反射(Specular):\(k_s (R \cdot V)^n\)

其中:

  • \(L\):指向光源的方向
  • \(N\):表面法线
  • \(R\):反射方向
  • \(V\):指向观察者的方向
  • \(n\):高光指数,值越大高光越锐利

3. Blinn-Phong模型

用半程向量(Half Vector)替代反射方向:

\[H = \frac{L + V}{L + V}\]

镜面反射项变为:

\[k_s (N \cdot H)^n\]

计算更高效,且在某些情况下效果更好。

4. 微表面模型(Microfacet Model)

更物理准确的模型,假设表面由无数微小镜面组成:

\[f_r = \frac{F(\omega_i, \omega_o) \cdot G(\omega_i, \omega_o) \cdot D(h)}{4(\omega_i \cdot n)(\omega_o \cdot n)}\]

  • \(F\):Fresnel项,描述不同角度的反射率
  • \(G\):几何遮蔽项,考虑微表面间的遮挡
  • \(D\):法线分布函数(NDF),描述微表面法线的分布
  • \(h\):半程向量

常用的NDF包括GGX(Trowbridge-Reitz):

\[D_{GGX}(h) = \frac{\alpha^2}{\pi((n \cdot h)^2(\alpha^2 - 1) + 1)^2}\]

其中 \(\alpha\) 是粗糙度参数。


1.4 渲染方程(The Rendering Equation)

1.4.1 渲染方程的推导

渲染方程是全局光照的核心,描述了光能在场景中的平衡状态。由James Kajiya于1986年提出。

推导思路:

考虑表面一点 \(x\),其向方向 \(\omega_o\) 发出的出射辐射率 \(L_o\) 由两部分组成:

  1. 自发光(Emission): 物体自身发出的光 \(L_e(x, \omega_o)\)
  2. 反射光(Reflection): 来自各方向的入射光被BRDF反射的部分

对于反射部分,根据BRDF定义:

\[L_r(x, \omega_o) = \int_{\Omega} f_r(x, \omega_i \rightarrow \omega_o) L_i(x, \omega_i) \cos\theta_i d\omega_i\]

其中积分域 \(\Omega\) 是以表面法线为中心的上半球面。

入射辐射率 \(L_i(x, \omega_i)\) 来自场景中另一点 \(x'\)

\[L_i(x, \omega_i) = L_o(x', -\omega_i)\]

这里 \(x'\) 是沿方向 \(\omega_i\)\(x\) 出发与场景的第一个交点。

1.4.2 渲染方程的标准形式

\[L_o(x, \omega_o) = L_e(x, \omega_o) + \int_{\Omega} f_r(x, \omega_i \rightarrow \omega_o) L_i(x, \omega_i) (\omega_i \cdot n_x) d\omega_i\]

或者使用更紧凑的符号:

\[L(x \rightarrow \omega_o) = L_e(x \rightarrow \omega_o) + \int_{S^2} f_r(x, \omega_i \leftrightarrow \omega_o) L(r(x, \omega_i) \rightarrow -\omega_i) |\cos\theta_i| d\omega_i\]

其中 \(r(x, \omega_i)\) 是射线投射函数,返回从 \(x\) 沿方向 \(\omega_i\) 的第一个交点。

1.4.3 渲染方程的递归性质

渲染方程是一个积分方程,具有递归结构:

  • 要计算 \(L_o(x)\),需要知道 \(L_i(x)\)
  • \(L_i(x)\) 来自其他点的 \(L_o\)
  • 形成无限递归

这种递归结构反映了光能在场景中的多次反弹,是全局光照复杂性的根源。

1.4.4 渲染方程的算子形式

将渲染方程写成算子形式有助于理解和求解:

\[L = L_e + K L\]

其中 \(K\) 是积分算子:

\[(KL)(x, \omega_o) = \int_{\Omega} f_r(x, \omega_i \rightarrow \omega_o) L(x, \omega_i) \cos\theta_i d\omega_i\]

移项得到:

\[L - KL = L_e\]

\[(I - K)L = L_e\]

形式解为 Neumann 级数:

\[L = (I - K)^{-1} L_e = \sum_{n=0}^{\infty} K^n L_e\]

展开后:

\[L = L_e + KL_e + K^2L_e + K^3L_e + \cdots\]

每一项对应不同的光照反弹次数:

  • \(L_e\):直接看到的自发光
  • \(KL_e\):一次反弹光照
  • \(K^2L_e\):两次反弹光照
  • ...

1.5 全局光照(Global Illumination)

1.5.1 什么是全局光照?

全局光照(Global Illumination, GI)是指在渲染中模拟光线在场景中的多次反弹,包括直接光照和间接光照。

直接光照(Direct Illumination): 光线直接从光源到达物体表面。

间接光照(Indirect Illumination): 光线经过其他物体的反射或散射后到达物体表面,包括:

  • 颜色溢出(Color Bleeding)
  • 焦散(Caustics)
  • 次表面散射(Subsurface Scattering)
  • 体积散射(Volume Scattering)

1.5.2 为什么全局光照重要?

GI示意图

全局光照能显著提升渲染的真实感:

  1. 间接照明: 阴影区域不是纯黑,而是被环境光照亮
  2. 颜色溢出: 红色地毯附近的白色墙面会带有红色调
  3. 真实感: 符合人眼对真实世界的认知

上图展示了全局光照的效果,可以明显看到间接光照对场景整体亮度和色彩的影响。

1.5.3 渲染方程中的全局光照

从渲染方程的角度:

\[L_o = L_e + \underbrace{\int_{\Omega} f_r L_{direct} \cos\theta d\omega}*{\text{直接光照}} + \underbrace{\int*{\Omega} f_r L_{indirect} \cos\theta d\omega}_{\text{间接光照}}\]

直接光照可以从光源直接计算,而间接光照需要递归求解渲染方程。

1.5.4 GI公式与半球积分

渲染方程中的积分是在半球面上进行的:

半球积分示意

半球积分的几何意义:

  • 对于表面一点,所有可能入射光来自以法线为中心的上半球
  • 每个入射方向的贡献 = 入射辐射率 × BRDF × 余弦因子
  • 余弦因子 \(\cos\theta = \omega_i \cdot n\) 表示光线倾斜时的能量衰减

球坐标系下的积分:

\[\int_{\Omega} f(\omega) d\omega = \int_0^{2\pi} \int_0^{\pi/2} f(\theta, \phi) \sin\theta d\theta d\phi\]


1.6 路径追踪(Path Tracing)

1.6.1 路径追踪的基本原理

路径追踪(Path Tracing)是求解渲染方程的蒙特卡洛方法,由James Kajiya在同一年提出。

核心思想:

  1. 从相机发射主光线
  2. 在每个交点处,随机选择一个方向继续追踪
  3. 重复直到命中光源或超过最大反弹次数
  4. 对多条路径取平均

1.6.2 路径追踪算法伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function TracePath(ray):
throughput = 1.0
radiance = 0.0

for bounce = 0 to maxBounces:
hit = IntersectScene(ray)
if not hit:
break

// 直接光照
radiance += throughput * DirectLighting(hit)

// 采样BRDF得到新方向
(newDir, pdf, brdf) = SampleBRDF(hit)

// 更新throughput
throughput *= brdf * dot(newDir, hit.normal) / pdf

// 俄罗斯轮盘赌终止
if bounce > 3:
p = min(throughput.maxComponent(), 0.95)
if random() > p:
break
throughput /= p

ray = Ray(hit.position, newDir)

return radiance

1.6.3 路径追踪的问题

路径追踪虽然准确,但计算开销很大:

Path Trace问题

问题1:收敛慢

对于间接光照,需要大量样本才能收敛。特别是:

  • 小面积光源
  • 窄高光(高粗糙度参数)
  • 焦散效果

问题2:噪点

低采样数时会产生明显噪点:

噪��示意

问题3:计算开销

每条路径可能需要多次反弹,每次反弹需要:

  • 射线求交测试
  • BRDF采样和计算
  • 直接光照计算

计算开销示意

1.6.4 路径追踪的改进技术

1. 直接光照采样

将渲染方程拆分:

\[L_o = L_e + \underbrace{\int_{\Omega} f_r L_{direct} \cos\theta d\omega}*{\text{直接光照,采样光源}} + \underbrace{\int*{\Omega} f_r L_{indirect} \cos\theta d\omega}_{\text{间接光照,采样BRDF}}\]

对直接光照,直接采样光源可以大幅降低方差。

2. 双向路径追踪(BDPT)

同时从相机和光源生成路径,在中间连接。适合处理小光源和焦散。

3. Metropolis光传输(MLT)

使用马尔可夫链蒙特卡洛方法,在路径空间中进行局部探索。一旦找到一条贡献大的路径,就在附近继续搜索。

4. 光子映射(Photon Mapping)

两遍算法:

  1. 从光源发射光子,存储在场景中
  2. 从相机渲染,使用存储的光子估计间接光照

1.7 小结

本章介绍了光线追踪和全局光照的基础概念:

  1. 光线追踪通过模拟光线传播来渲染图像,核心是光线-几何体相交测试
  2. 辐射度学量(辐照度、辐射率等)是量化描述光照的基础
  3. BRDF描述了材质如何反射光线
  4. 渲染方程是全局光照的核心,其递归结构导致了计算的复杂性
  5. 路径追踪是求解渲染方程的蒙特卡洛方法,但收敛慢、计算开销大

下一章将深入讲解渲染方程的数学基础和求解方法。


第二章:渲染方程数学基础

2.1 数学预备知识

2.1.1 球坐标与立体角

球坐标系

在三维空间中,球坐标 \((r, \theta, \phi)\) 与直角坐标 \((x, y, z)\) 的关系:

\[\begin{cases} x = r \sin\theta \cos\phi y = r \sin\theta \sin\phi z = r \cos\theta \end{cases}\]

其中:

  • \(r\):到原点的距离
  • \(\theta\):极角(与z轴夹角),范围 \([0, \pi]\)
  • \(\phi\):方位角(在xy平面的投影与x轴夹角),范围 \([0, 2\pi)\)

立体角的定义

立体角是三维空间中"角度"概念的推广。正如平面角是弧长除以半径:

\[\theta = \frac{s}{r} \quad [rad]\]

立体角是球面上的面积除以半径平方:

\[\omega = \frac{A}{r^2} \quad [sr]\]

单位是球面度(steradian, sr)。

微分立体角

在球坐标系中,微分立体角为:

\[d\omega = \frac{dA}{r^2} = \frac{r \sin\theta d\phi \cdot r d\theta}{r^2} = \sin\theta d\theta d\phi\]

半球面上积分时:

\[\int_{\Omega} f(\omega) d\omega = \int_0^{2\pi} \int_0^{\pi/2} f(\theta, \phi) \sin\theta d\theta d\phi\]

立体角与方向向量

设方向向量为 \(\omega = (x, y, z)\),则:

\[\cos\theta = z = \omega \cdot \mathbf{n}\]

其中 \(\mathbf{n} = (0, 0, 1)\) 是半球顶点的法线。

2.1.2 积分变换

面积积分到立体角积分的转换

设光源面积为 \(A\),从着色点到光源的方向为 \(\omega\),距离为 \(r\)

\[L_i d\omega = L_o \frac{dA \cos\theta'}{r^2}\]

其中 \(\theta'\) 是光源表面法线与出射方向的夹角。

这给出了一个重要转换:

\[d\omega = \frac{\cos\theta' dA}{r^2}\]

这个关系在从面积光源采样时非常重要。

2.2 渲染方程的详细推导

2.2.1 光能平衡方程

考虑表面一点 \(x\),分析其出射辐射率 \(L_o\) 的来源:

能量守恒原理: 出射光能 = 自发光 + 反射光 + 透射光

对于不透明材质,忽略透射:

\[L_o(x, \omega_o) = L_e(x, \omega_o) + L_r(x, \omega_o)\]

反射光来自四面八方的入射光:

\[L_r(x, \omega_o) = \int_{\Omega} f_r(x, \omega_i \rightarrow \omega_o) L_i(x, \omega_i) \cos\theta_i d\omega_i\]

2.2.2 各项符号的物理意义

符号 含义 单位
\(L_o(x, \omega_o)\) \(x\) 向方向 \(\omega_o\) 的出射辐射率 \(W/(m^2 \cdot sr)\)
\(L_e(x, \omega_o)\) \(x\) 向方向 \(\omega_o\) 的自发光辐射率 \(W/(m^2 \cdot sr)\)
\(L_i(x, \omega_i)\) 从方向 \(\omega_i\) 入射到点 \(x\) 的辐射率 \(W/(m^2 \cdot sr)\)
\(f_r(x, \omega_i \rightarrow \omega_o)\) BRDF,描述反射特性 \(sr^{-1}\)
\(\cos\theta_i\) 入射角余弦,\(\omega_i \cdot n_x\) 无量纲
\(d\omega_i\) 微分立体角 \(sr\)

2.2.3 完整渲染方程

\[L_o(x, \omega_o) = L_e(x, \omega_o) + \int_{\Omega^+} f_r(x, \omega_i \rightarrow \omega_o) L_i(x, \omega_i) (\omega_i \cdot n_x) d\omega_i\]

其中 \(\Omega^+\) 表示朝向法线 \(n_x\) 的正半球。

紧凑形式:

\[L = L_e + K_e L_e\]

或者使用光传输算子:

\[L = L_e + T L\]

其中 \(T\) 是光传输算子,包含BRDF和几何项。

2.3 蒙特卡洛积分

2.3.1 为什么需要蒙特卡洛积分?

渲染方程中的半球积分没有解析解(除了极少数简单场景)。数值积分方法:

  1. 均匀采样: 使用固定网格,高维时效率低(维度灾难)
  2. 蒙特卡洛: 随机采样,收敛速度 \(O(1/\sqrt{N})\),与维度无关

2.3.2 蒙特卡洛积分原理

基本形式:

设要估计积分 \(I = \int_a^b f(x) dx\)

随机采样 \(x_1, x_2, \ldots, x_N \sim p(x)\),则:

\[\hat{I} = \frac{1}{N} \sum_{i=1}^{N} \frac{f(x_i)}{p(x_i)}\]

是积分的无偏估计。

证明(简要):

\[E[\hat{I}] = E\left[\frac{1}{N} \sum_{i=1}^{N} \frac{f(x_i)}{p(x_i)}\right] = \frac{1}{N} \sum_{i=1}^{N} E\left[\frac{f(X)}{p(X)}\right]\]

\[= E\left[\frac{f(X)}{p(X)}\right] = \int_a^b \frac{f(x)}{p(x)} p(x) dx = \int_a^b f(x) dx = I\]

2.3.3 方差分析

估计量的方差:

\[Var[\hat{I}] = \frac{1}{N} Var\left[\frac{f(X)}{p(X)}\right] = \frac{\sigma^2}{N}\]

标准差(误差):

\[\sigma_{\hat{I}} = \frac{\sigma}{\sqrt{N}}\]

这就是著名的 \(O(1/\sqrt{N})\) 收敛率。

2.3.4 重要性采样

核心思想: 在函数值大的地方多采样。

选择采样分布 \(p(x)\)\(f(x)\) 相似时,方差最小。

理论最优:\(p(x) \propto |f(x)|\)

应用到渲染方程:

\[L_r = \int_{\Omega} f_r(\omega_i) L_i(\omega_i) \cos\theta_i d\omega_i\]

采样策略:

  • 采样BRDF:\(p(\omega_i) \propto f_r(\omega_i) \cos\theta_i\)
  • 采样光源:\(p(\omega_i) \propto L_i(\omega_i)\)
  • 多重重要性采样(MIS):结合多种采样策略

2.4 多重重要性采样(MIS)

2.4.1 问题背景

渲染积分中通常有多个因子:

\[f(\omega) = f_r(\omega) \cdot L_i(\omega) \cdot \cos\theta\]

  • BRDF采样:适合BRDF主导(如高光表面)
  • 光源采样:适合光源主导(如小面积光源)

单一采样策略可能在某些情况下失效。

2.4.2 MIS公式

设从分布 \(p_1, p_2, \ldots, p_n\) 中采样,MIS估计为:

\[\hat{I} = \sum_{i=1}^{n} \frac{1}{n_i} \sum_{j=1}^{n_i} w_i(x_{i,j}) \frac{f(x_{i,j})}{p_i(x_{i,j})}\]

权重 \(w_i(x)\) 满足 \(\sum_i w_i(x) = 1\)

平衡启发式(Balance Heuristic):

\[w_i(x) = \frac{n_i p_i(x)}{\sum_k n_k p_k(x)}\]

幂次启发式(Power Heuristic):

\[w_i(x) = \frac{(n_i p_i(x))^\beta}{\sum_k (n_k p_k(x))^\beta}\]

通常取 \(\beta = 2\)

2.5 渲染方程的求解方法

2.5.1 直接光照计算

直接光照可以高效计算:

\[L_{direct} = \sum_{k=1}^{N_l} L_{e,k} \cdot f_r \cdot \cos\theta \cdot V\]

其中 \(V\) 是可见性项(阴影测试)。

解析解: 对于点光源,直接光照有解析解。

采样光源: 对面积光源采样,使用MIS。

2.5.2 间接光照的挑战

间接光照涉及递归积分:

\[L_{indirect} = \int_{\Omega} f_r(\omega_i) L_o(x', -\omega_i) \cos\theta_i d\omega_i\]

其中 \(L_o(x', -\omega_i)\) 本身也是一个积分,形成无限递归。

解决方案:

  1. 路径追踪: 蒙特卡洛估计每条路径
  2. 辐射度方法: 假设漫反射,将问题离散化
  3. 光子映射: 预计算光子分布
  4. 辐射缓存: 缓存并插值间接光照

2.5.3 无偏估计与有偏估计

无偏(Unbiased): 期望值等于真实值

\[E[\hat{I}] = I\]

路径追踪、双向路径追踪是无偏的。

一致(Consistent): 样本量趋于无穷时收敛到真实值

\[\lim_{N \to \infty} \hat{I} = I\]

光子映射是一致但有偏的。

有偏但一致: 实际中常用,噪点少但可能有系统性误差。

2.6 辐射度方法(Radiosity)

2.6.1 假设与限制

辐射度方法基于以下假设:

  • 所有表面都是Lambertian(完全漫反射)
  • 场景离散化为面片

对于Lambert表面,BRDF是常数 \(f_r = \rho/\pi\),渲染方程简化为:

\[B_i = E_i + \rho_i \sum_j F_{ij} B_j\]

其中:

  • \(B_i\):面片 \(i\) 的辐射度(出射功率密度)
  • \(E_i\):面片 \(i\) 的自发光
  • \(\rho_i\):面片 \(i\) 的反射率
  • \(F_{ij}\):从面片 \(i\) 到面片 \(j\) 的形状因子

2.6.2 形状因子

形状因子 \(F_{ij}\) 表示从面片 \(i\) 发出的辐射中到达面片 \(j\) 的比例:

\[F_{ij} = \frac{1}{A_i} \int_{A_i} \int_{A_j} \frac{\cos\theta_i \cos\theta_j}{\pi r^2} V(x_i, x_j) dA_j dA_i\]

性质:

  • 互易性:\(A_i F_{ij} = A_j F_{ji}\)
  • 守恒性:\(\sum_j F_{ij} = 1\)

2.6.3 矩阵形式

\(N\) 个面片,写成矩阵形式:

\[\mathbf{B} = \mathbf{E} + \mathbf{R} \mathbf{F} \mathbf{B}\]

\[\mathbf{B} = (\mathbf{I} - \mathbf{R}\mathbf{F})^{-1} \mathbf{E}\]

可以通过迭代方法求解:

  • Jacobi迭代
  • Gauss-Seidel迭代
  • Southwell迭代

2.6.4 辐射度方法的局限

  1. 只适用于漫反射材质: 无法处理镜面反射、折射
  2. 计算复杂度: \(O(N^2)\) 的形状因子计算
  3. 内存需求: 存储 \(N^2\) 的形状因子矩阵
  4. 预计算需求: 场景变化需要重新计算

2.7 球面调和函数(Spherical Harmonics)

2.7.1 基函数概念

球面调和函数是定义在球面上的正交基函数,用于表示球面上的函数。

类比:傅里叶级数将周期函数分解为正弦余弦函数的和。

球面调和函数将球面函数分解为特定基函数的组合:

\[f(\theta, \phi) = \sum_{l=0}^{\infty} \sum_{m=-l}^{l} c_l^m Y_l^m(\theta, \phi)\]

2.7.2 球面调和函数的定义

\[Y_l^m(\theta, \phi) = K_l^m P_l^{|m|}(\cos\theta) e^{im\phi}\]

其中:

  • \(P_l^m\):关联勒让德多项式
  • \(K_l^m\):归一化常数
  • \(l\):阶数(degree),决定频率
  • \(m\):序数(order),\(-l \leq m \leq l\)

前几阶球面调和函数:

\(l\) \(m\) 实数形式 描述
0 0 \(Y_0^0 = \frac{1}{2}\sqrt{\frac{1}{\pi}}\) 常数项
1 -1 \(Y_1^{-1} = \sqrt{\frac{3}{4\pi}} y\) 线性项
1 0 \(Y_1^0 = \sqrt{\frac{3}{4\pi}} z\) 线性项
1 1 \(Y_1^1 = \sqrt{\frac{3}{4\pi}} x\) 线性项
2 ... ... 二次项

其中 \((x, y, z)\) 是单位方向向量的分量。

2.7.3 在渲染中的应用

环境光表示:

环境光照 \(L_i(\omega)\) 可以投影到SH基:

\[L_i(\omega) \approx \sum_{l=0}^{n} \sum_{m=-l}^{l} c_l^m Y_l^m(\omega)\]

辐照度计算:

对于Lambert表面,辐照度积分:

\[E(n) = \int_{\Omega} L_i(\omega) \max(0, \omega \cdot n) d\omega\]

由于卷积性质,只需存储低阶SH系数(通常3阶):

\[E(n) \approx \sum_{l=0}^{2} \sum_{m=-l}^{l} c_l^m Y_l^m(n)\]

旋转不变性:

SH的一个重要性质:低阶SH系数的旋转可以表示为线性变换。

这允许快速旋转预计算的环境光。

2.7.4 SH光照贴图

预计算辐射率传输(PRT):

\[L_{out}(n) = \sum_{l,m} \sum_{l',m'} c_l^m T_{l,m}^{l',m'} Y_{l'}^{m'}(n)\]

其中传输矩阵 \(T\) 预先计算,运行时只需矩阵乘法。

2.8 小结

本章深入讲解了渲染方程的数学基础:

  1. 立体角与球坐标:量化描述方向和角度
  2. 蒙特卡洛积分:数值求解积分的核心方法
  3. 重要性采样与MIS:降低方差的关键技术
  4. 辐射度方法:离散化求解,适用于漫反射场景
  5. 球面调和函数:高效表示和计算环境光照

下一章将介绍传统GI解决方案及其实现原理。


第三章:传统GI解决方案详解

3.1 概述

在实时渲染中,完全的路径追踪计算代价太高。游戏和实时应用需要各种近似技术来高效计算全局光照。本章介绍游戏中常用的传统GI解决方案。

GI方案概览

3.2 直接光照与间接光照分离

3.2.1 渲染方程的拆分

渲染方程可以拆分为直接光照和间接光照两部分:

\[L_o = L_e + L_{direct} + L_{indirect}\]

直接光照:

\[L_{direct} = \sum_{k} \int_{A_k} f_r(\omega_i \rightarrow \omega_o) L_e(x_k, \omega_k) \frac{\cos\theta_i \cos\theta_k}{r^2} V(x, x_k) dA_k\]

直接光照可以从光源直接采样计算,通常使用阴影贴图(Shadow Map)或阴影光线。

间接光照:

\[L_{indirect} = \int_{\Omega} f_r(\omega_i \rightarrow \omega_o) L_{indirect,in}(\omega_i) \cos\theta_i d\omega_i\]

间接光照没有明确的解析解,需要近似方法。

3.2.2 为什么间接光照难以计算?

  1. 递归性: 间接光照来自其他表面,而这些表面本身也在接收间接光照
  2. 全局依赖: 场景中任何物体的变化都可能影响所有其他物体的间接光照
  3. 高频信息: 焦散、阴影等高频效果需要大量采样
  4. 动态场景: 物体移动或光源变化时需要重新计算

3.3 环境光遮蔽(Ambient Occlusion, AO)

3.3.1 基本原理

环境光遮蔽是最简单的间接光照近似,假设环境光均匀分布:

\[AO(x) = \frac{1}{\pi} \int_{\Omega} V(x, \omega) \cos\theta d\omega\]

其中 \(V(x, \omega)\) 是可见性函数:

  • \(V = 1\):方向 \(\omega\) 可见(未被遮挡)
  • \(V = 0\):方向 \(\omega\) 被遮挡

最终光照近似:

\[L_o \approx L_{ambient} \cdot AO \cdot \rho\]

3.3.2 屏幕空间环境光遮蔽(SSAO)

SSAO在屏幕空间计算AO,无需预先烘焙:

算法步骤:

  1. 对每个像素,在以像素位置为中心的半球内随机采样若干点
  2. 检查每个采样点是否被几何体遮挡
  3. 根据遮挡比例计算AO值

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
float SSAO(vec3 position, vec3 normal) {
float occlusion = 0.0;

for (int i = 0; i < KERNEL_SIZE; i++) {
// 在法线方向的半球内随机采样
vec3 sampleDir = normalize(kernel[i]);
vec3 samplePos = position + sampleDir * radius * random();

// 将采样点投影到屏幕空间
vec4 offset = projection * vec4(samplePos, 1.0);
offset.xy /= offset.w;

// 获取该位置的深度
float sampleDepth = texture(depthBuffer, offset.xy).r;

// 检查是否被遮挡
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(position.z - sampleDepth));
occlusion += (sampleDepth >= samplePos.z ? 1.0 : 0.0) * rangeCheck;
}

return 1.0 - (occlusion / KERNEL_SIZE);
}

优点:

  • 实时计算,支持动态场景
  • 实现简单,性能开销小
  • 广泛应用于游戏中

缺点:

  • 只考虑局部遮挡
  • 缺少颜色溢出(Color Bleeding)
  • 屏幕空间信息丢失导致伪影

3.3.3 水平环境光遮蔽(Horizon-Based Ambient Occlusion, HBAO)

HBAO是SSAO的改进版本,考虑地平线角度:

\[AO = \frac{1}{2\pi} \int_{0}^{2\pi} \left(1 - \cos(h(\phi))\right) d\phi\]

其中 \(h(\phi)\) 是方位角 \(\phi\) 方向的地平线角度。

HBAO提供更物理准确的结果,但计算量更大。

3.4 光照贴图(Light Map)

3.4.1 基本原理

光照贴图是一种预计算技术:

  1. 预计算阶段(烘焙): 离线计算场景中每个点的间接光照
  2. 存储: 将结果存储在纹理贴图中
  3. 运行时: 通过纹理采样获取预计算的光照

Light Map示意

3.4.2 光照贴图的UV映射

为了存储光照信息,需要将3D场景展开到2D纹理空间:

流程:

  1. 为场景中的静态物体生成第二套UV坐标(光照UV)
  2. 确保UV展开时没有重叠
  3. 将场景离散化为光照贴图纹素
  4. 每个纹素存储该位置的光照信息

打包策略:

  • 自动UV展开:使用chart生成算法
  • 纹素密度控制:根据几何复杂度和重要性调整
  • 填充边界:避免纹素间泄漏

3.4.3 光照贴图烘焙

烘焙过程通常使用离线路径追踪:

1
2
3
4
5
6
7
8
9
for each texel in lightmap:
position = getWorldPosition(texel)
normal = getWorldNormal(texel)

// 路径追踪计算间接光照
radiance = PathTrace(position, normal)

// 存储结果
lightmap[texel] = radiance

采样策略:

  1. 辐照度(Irradiance): 存储半球积分结果 \[E(n) = \int_{\Omega} L_i(\omega) \cos\theta d\omega\]
  2. 辐射率(Radiance): 存储方向分布
  • 使用球面调和函数(SH)
    • 使用环境贴图

3.4.4 光照贴图的优缺点

优点:

  • 运行时开销极低(一次纹理采样)
  • 高质量的间接光照
  • 支持复杂的光传输效果

缺点:

  • 只适用于静态场景
  • 烘焙时间长
  • 存储空间需求大
  • 分辨率受限,可能模糊

适用场景:

  • 静态场景的游戏
  • 室内环境
  • 对实时性要求不高的预渲染

3.5 光照探针(Light Probe)

3.5.1 基本原理

光照探针是一种点采样技术,在场景中离散位置采样光照信息:

Light Probe示意

每个探针存储其位置的光照信息,运行时通过插值获取任意点的光照。

3.5.2 探针的组织方式

1. 点云探针

在场景中手动或自动放置探针点:

1
2
3
4
Probe Positions:
- P1: (x1, y1, z1)
- P2: (x2, y2, z2)
- ...

运行时使用最近邻或三角插值。

2. 体素网格探针(Volumetric Probe)

将空间划分为规则的3D网格,每个网格单元存储光照信息:

1
2
3
4
5
6
struct ProbeVolume {
vec3 origin; // 网格原点
vec3 cellSize; // 每个单元的大小
ivec3 dimensions; // 网格维度
Probe[] probes; // 探针数据
};

3. 八叉树探针

根据场景复杂度自适应放置探针:

  • 几何复杂区域:高密度探针
  • 空旷区域:低密度探针

3.5.3 探针存储的内容

探针可以存储不同粒度的光照信息:

辐照度探针:

存储辐照度的球面调和函数系数:

1
2
3
struct IrradianceProbe {
vec3 shCoeffs[9]; // L0, L1, L2阶SH系数,共9个
};

重建辐照度:

\[E(n) \approx \sum_{i=0}^{8} c_i \cdot Y_i(n)\]

辐射率探针:

存储完整的方向辐射率分布,通常使用:

  • 立方体贴图(Cubemap)
  • 八面体映射(Octahedral Map)

3.5.4 探针插值

给定任意位置 \(p\),从周围探针插值获取光照:

三线性插值:

对于体素网格探针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vec3 sampleIrradiance(vec3 position, vec3 normal) {
// 找到包围该位置的8个探针
ivec3 cell = floor((position - volume.origin) / volume.cellSize);

// 三线性插值权重
vec3 frac = fract((position - volume.origin) / volume.cellSize);

// 插值
vec3 result = vec3(0.0);
for (int i = 0; i < 8; i++) {
ivec3 offset = ivec3(i & 1, (i >> 1) & 1, (i >> 2) & 1);
vec3 weight = mix(1.0 - frac, frac, offset);
result += probes[cell + offset].eval(normal) * weight.x * weight.y * weight.z;
}

return result;
}

重心坐标插值:

对于四面体网格:

1
2
3
4
5
6
7
8
9
10
vec3 interpolateTetrahedra(vec3 position, vec3 normal) {
Tetrahedron tet = findContainingTetrahedron(position);
vec4 barycentric = computeBarycentric(position, tet);

vec3 result = vec3(0.0);
for (int i = 0; i < 4; i++) {
result += probes[tet.vertices[i]].eval(normal) * barycentric[i];
}
return result;
}

3.5.5 探针的生成方式

预烘焙探针:

  1. 离线阶段:使用路径追踪计算每个探针位置的光照
  2. 存储:保存SH系数或辐射率贴图
  3. 运行时:插值获取

实时探针更新:

对于动态场景,探针需要实时更新:

  1. 时间分片更新: 每帧更新部分探针
  2. 重要性驱动更新: 优先更新变化大的区域
  3. 渐进式更新: 使用累积采样提高质量

3.6 体素全局光照(Voxel GI)

3.6.1 体素圆锥追踪(Voxel Cone Tracing, VCT)

VCT是一种实时全局光照技术:

核心思想:

  1. 将场景体素化,存储辐射率
  2. 使用圆锥追踪近似半球积分

体素化:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Voxel {
vec3 radiance; // 存储的辐射率
vec3 normal; // 平均法线
float occupancy; // 占用率
};

// 体素化场景
void voxelizeScene() {
for each triangle in scene:
determine voxel coverage
accumulate radiance and normal
clip to cascade boundaries
}

圆锥追踪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vec3 coneTrace(vec3 origin, vec3 direction, float coneAngle) {
vec3 radiance = vec3(0.0);
float distance = 0.0;

while (distance < maxDistance) {
// 计算圆锥半径
float coneRadius = distance * tan(coneAngle);

// 计算所需的体素层级
float lod = log2(coneRadius / voxelSize);

// 采样体素
vec3 samplePos = origin + direction * distance;
vec3 voxelRadiance = sampleVoxelVolume(samplePos, lod);

// 累积
radiance += voxelRadiance * occlusion;

distance += step;
}

return radiance;
}

间接光照计算:

1
2
3
4
5
6
7
8
9
10
11
vec3 computeIndirect(vec3 position, vec3 normal) {
vec3 result = vec3(0.0);

// 发射多个圆锥覆盖半球
for (int i = 0; i < NUM_CONES; i++) {
vec3 coneDir = getHemisphereCone(i, normal);
result += coneTrace(position, coneDir, coneAngle);
}

return result / NUM_CONES;
}

3.6.2 VCT的优缺点

优点:

  • 支持动态场景
  • 实时更新
  • 可产生软阴影和光泽反射效果

缺点:

  • 体素化开销大
  • 内存占用高
  • 需要高分辨率体素网格才能获得高质量结果

3.7 光照传播体(Light Propagation Volumes, LPV)

3.7.1 基本原理

LPV将光照传播建模为网格中的辐射率扩散过程:

  1. 注入阶段: 将直接光照注入到体素网格
  2. 传播阶段: 迭代传播辐射率
  3. 渲染阶段: 从网格采样间接光照

3.7.2 辐射率表示

每个体素单元存储辐射率的SH系数:

1
2
3
4
5
struct LPVCell {
vec3 shCoeffsR[4]; // R通道SH系数
vec3 shCoeffsG[4]; // G通道SH系数
vec3 shCoeffsB[4]; // B通道SH系数
};

3.7.3 传播过程

辐射率从一个单元传播到相邻单元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void propagateStep() {
LPVCell newGrid[GRID_SIZE];

for each cell in grid:
for each direction in 6 face directions:
// 获取邻单元的辐射率
LPVCell neighbor = getNeighbor(cell, direction);

// 传播到当前单元
vec3 incident = neighbor.getFlux(direction);
cell.shCoeffs += projectToSH(incident, direction);

grid = newGrid;
}

通常需要3-4次传播迭代。

3.7.4 LPV的问题

  • 光泄漏: 辐射率穿过薄墙传播
  • 自发光: 错误的自阴影
  • 分辨率限制: 低分辨率导致光照模糊

3.8 屏幕空间全局光照(SSGI)

3.8.1 屏幕空间方法的优势

屏幕空间方法只需要当前帧的G-buffer信息:

  • 深度缓冲
  • 法线缓冲
  • 颜色缓冲

无需预计算,完全实时。

3.8.2 屏幕空间环境光遮蔽改进(SSAO → SSDO)

屏幕空间方向遮挡(SSDO)扩展AO以计算间接光照:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
vec3 SSDO(vec2 screenPos, vec3 position, vec3 normal) {
vec3 indirect = vec3(0.0);

for (int i = 0; i < NUM_SAMPLES; i++) {
// 在屏幕空间采样
vec2 sampleOffset = samplingPattern[i];
vec2 sampleScreenPos = screenPos + sampleOffset * radius;

// 重建世界位置
vec3 samplePos = reconstructPosition(sampleScreenPos);

// 检查是否形成遮挡
vec3 toSample = samplePos - position;
if (dot(normalize(toSample), normal) < 0) {
// 从采样位置获取颜色(作为间接光)
vec3 sampleColor = texture(colorBuffer, sampleScreenPos).rgb;

// 根据距离和角度加权
float weight = max(0, dot(normal, normalize(toSample)));
indirect += sampleColor * weight;
}
}

return indirect / NUM_SAMPLES;
}

3.8.3 屏幕空间反射(SSR)

SSR在屏幕空间追踪反射光线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
vec3 screenSpaceReflection(vec3 position, vec3 viewDir, vec3 normal) {
// 计算反射方向
vec3 reflectDir = reflect(-viewDir, normal);

// 转换到屏幕空间
vec3 rayPos = position;
vec3 rayDir = reflectDir;

// 光线行进
for (int step = 0; step < MAX_STEPS; step++) {
// 前进
rayPos += rayDir * stepSize;

// 转换到屏幕坐标
vec2 screenPos = projectToScreen(rayPos);

// 检查是否在屏幕内
if (!inScreen(screenPos)) break;

// 深度比较
float depth = texture(depthBuffer, screenPos).r;
float rayDepth = rayPos.z;

if (rayDepth > depth) {
// 找到交点,返回颜色
return texture(colorBuffer, screenPos).rgb;
}
}

// 未找到交点,返回环境光
return texture(envMap, reflectDir).rgb;
}

3.8.4 屏幕空间方法的局限

致命缺陷:

  • 屏幕外信息丢失: 屏幕外的物体无法影响当前像素
  • 背面信息丢失: 被遮挡的几何体不可见

解决方案:

  • 结合其他方法(如探针)补充屏幕外信息
  • 使用时间累积减少噪点
  • 混合多分辨率采样

3.9 传统方法总结

方法 预计算 动态场景 质量 性能
Light Map 运行时极快
Light Probe 可选 部分支持
SSAO
SSGI 中-低 中等
VCT 中等
LPV 低-中

传统方法对比

3.10 小结

本章介绍了游戏和实时渲染中常用的传统GI解决方案:

  1. AO方法:简单有效,但只有遮蔽效果
  2. 光照贴图:高质量,但只适用于静态场景
  3. 光照探针:灵活,可部分支持动态
  4. 体素方法(VCT/LPV):实时支持动态,但质量和性能有折衷
  5. 屏幕空间方法:完全实时,但信息不完整

下一章将介绍DDGI(Dynamic Diffuse Global Illumination)技术,这是传统探针方法的现代化改进,也是神经渲染技术的基础。


第四章:DDGI(Dynamic Diffuse Global Illumination)原理与实现

4.1 DDGI概述

4.1.1 什么是DDGI?

DDGI(Dynamic Diffuse Global Illumination)是由NVIDIA提出的实时全局光照技术,用于计算场景中的漫反射间接光照。它是传统光照探针方法的现代化改进,结合了光线追踪和探针更新机制。

DDGI示意图

4.1.2 DDGI的核心思想

DDGI的核心思想是:

  1. 空间划分: 在场景中放置规则网格的探针(Probe)
  2. 光线追踪更新: 使用光线追踪从每个探针位置投射光线,收集光照信息
  3. 辐照度存储: 每个探针存储其位置周围的辐照度信息
  4. 实时插值: 运行时从探针插值获取任意位置的间接光照

4.1.3 DDGI与传统方法的对比

特性 传统Light Probe DDGI
更新方式 离线烘焙 实时光追更新
动态支持 有限 完全支持
存储方式 SH系数 辐照度+深度Atlas
漏光问题 严重 有解决方案
硬件要求 需要RTX或软件光追

4.2 Probe的基本原理

4.2.1 Probe的物理意义

每个探针本质上存储的是其所在位置的球面光照分布

Probe示意图

对于点 \(p\) 处的探针,我们关心的是从各个方向 \(\omega\) 入射的辐射率 \(L_i(p, \omega)\)

4.2.2 辐照度与辐射率

辐照度(Irradiance) 是辐射率在半球面上的积分:

\[E(p, n) = \int_{\Omega} L_i(p, \omega) (\omega \cdot n) d\omega\]

其中 \(n\) 是表面法线。

关键洞察: 对于漫反射材质,我们只需要辐照度,不需要完整的方向辐射率分布。

4.2.3 Probe存储的内容

DDGI中每个探针存储两类信息:

1. 辐照度(Irradiance)Atlas

将每个探针的辐照度信息存储为一个小型纹理,通常使用球面映射:

1
2
3
4
5
6
Probe Atlas结构:
┌────────────────────────────────────┐
│ Probe 0 │ Probe 1 │ Probe 2 │ ... │ ← 辐照度信息
├────────────────────────────────────┤
│ Probe N │ ... │ ... │ ... │
└────────────────────────────────────┘

每个探针存储其球面上的辐照度分布,通常使用:

  • 八面体映射(Octahedral Mapping):将球面映射到正方形
  • 等距柱状投影(Equirectangular):类似世界地图

2. 深度(Depth)Atlas

存储每个探针方向上到最近表面的距离:

深度Atlas

深度信息的作用:

  • 防止漏光(Light Leaking)
  • 提高探针插值的准确性
  • 实现更精确的遮挡计算

4.3 八面体映射(Octahedral Mapping)

4.3.1 为什么需要映射?

将球面上的方向映射到2D纹理坐标,方便存储和采样。理想的映射应该:

  • 双射(一对一)
  • 面积保持(或近似)
  • 计算简单

4.3.2 八面体映射原理

将单位球面上的点投影到正八面体,再展开为正方形:

方向到纹理坐标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vec2 directionToOctahedral(vec3 dir) {
// 归一化确保在单位球面上
dir = normalize(dir);

// 投影到八面体
vec2 octCoord = dir.xy / (abs(dir.x) + abs(dir.y) + abs(dir.z));

// 处理下半球
if (dir.z < 0.0) {
octCoord = vec2(
(1.0 - abs(octCoord.y)) * sign(octCoord.x),
(1.0 - abs(octCoord.x)) * sign(octCoord.y)
);
}

// 映射到[0, 1]范围
return octCoord * 0.5 + 0.5;
}

纹理坐标到方向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vec3 octahedralToDirection(vec2 octCoord) {
// 映射到[-1, 1]范围
vec2 p = octCoord * 2.0 - 1.0;

// 重建z分量
float z = 1.0 - abs(p.x) - abs(p.y);

// 处理下半球
if (z < 0.0) {
p = vec2(
(1.0 - abs(p.y)) * sign(p.x),
(1.0 - abs(p.x)) * sign(p.y)
);
}

return normalize(vec3(p.x, p.y, z));
}

4.3.3 八面体映射的优点

  1. 方向均匀分布: 比等距柱状投影更均匀
  2. 高效计算: 只需少量算术运算
  3. 无缝采样: 边界处理简单
  4. 低失真: 在球面上均匀采样

4.4 DDGI Probe更新算法

4.4.1 更新流程概览

DDGI的Probe更新包含以下步骤:

1
2
3
4
5
6
7
8
9
10
每帧更新:
┌─────────────────────────────────────────────────────┐
│ 1. 从每个探针发射光线(Ray Tracing) │
│ ↓ │
│ 2. 计算光线交点处的直接光照 │
│ ↓ │
│ 3. 累积更新辐照度和深度Atlas │
│ ↓ │
│ 4. 时间滤波减少噪点 │
└─────────────────────────────────────────────────────┘

4.4.2 光线追踪更新

从每个探针位置发射光线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void updateProbeRadiance(int probeIndex, vec3 probePosition) {
// 获取探针的Atlas坐标
ivec2 atlasCoord = getProbeAtlasCoord(probeIndex);

// 对每个方向进行光线追踪
for (int rayIndex = 0; rayIndex < RAYS_PER_PROBE; rayIndex++) {
// 获取光线方向(使用预计算的低差异序列)
vec3 rayDir = getRayDirection(rayIndex);

// 光线追踪
Ray ray = Ray(probePosition, rayDir);
RayHit hit = traceRay(ray);

if (hit.valid) {
// 计算交点处的直接光照
vec3 directLight = computeDirectLight(hit.position, hit.normal);

// 获取交点处的间接光照(从上一帧的Probe插值)
vec3 indirectLight = interpolateFromProbes(hit.position, hit.normal);

// 总光照
vec3 totalLight = directLight + indirectLight;

// 存储辐照度
ivec2 texelCoord = atlasCoord + directionToTexel(rayDir);
radianceAtlas[texelCoord] = totalLight;

// 存储深度
depthAtlas[texelCoord] = hit.distance;
} else {
// 未击中,使用环境光或天空光
vec3 skyLight = sampleSkyLight(rayDir);
radianceAtlas[texelCoord] = skyLight;
depthAtlas[texelCoord] = MAX_DISTANCE;
}
}
}

4.4.3 辐照度累积

为了减少噪点,DDGI使用时间累积:

1
2
3
4
5
6
7
8
9
10
11
12
// 当前帧的辐照度
vec3 currentIrradiance = computeIrradianceFromRays();

// 上一帧的辐照度
vec3 previousIrradiance = loadPreviousIrradiance(probeIndex);

// 指数移动平均
float alpha = 0.05; // 历史权重
vec3 blendedIrradiance = lerp(currentIrradiance, previousIrradiance, alpha);

// 存储结果
storeIrradiance(probeIndex, blendedIrradiance);

4.4.4 深度的作用

深度信息用于解决漏光问题:

深度防漏光

问题: 当探针位于墙壁外侧时,可能错误地照亮墙内侧。

解决方案: 使用深度信息检测墙壁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vec3 sampleProbeWithDepth(vec3 position, vec3 normal, Probe probe) {
// 计算到探针的方向
vec3 toProbe = probe.position - position;
float distToProbe = length(toProbe);
vec3 dirToProbe = toProbe / distToProbe;

// 获取探针该方向的深度
float probeDepth = sampleDepthAtlas(probe, -dirToProbe);

// 如果到探针的距离大于探针记录的深度,说明有遮挡
if (distToProbe > probeDepth + threshold) {
// 降低该探针的贡献
return vec3(0.0);
}

// 正常采样
return sampleIrradianceAtlas(probe, normal);
}

4.5 探针插值与渲染

4.5.1 三线性插值

对于规则网格探针,使用三线性插值获取任意位置的光照:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
vec3 interpolateProbes(vec3 position, vec3 normal) {
// 找到包围该位置的8个探针
vec3 localPos = (position - probeVolume.origin) / probeVolume.spacing;
ivec3 lower = ivec3(floor(localPos));
ivec3 upper = lower + ivec3(1);

// 三线性插值权重
vec3 t = fract(localPos);

vec3 result = vec3(0.0);
float totalWeight = 0.0;

// 遍历8个相邻探针
for (int i = 0; i < 8; i++) {
ivec3 offset = ivec3(i & 1, (i >> 1) & 1, (i >> 2) & 1);
ivec3 probeCoord = lower + offset;

// 获取探针位置
vec3 probePos = probeVolume.origin + probeCoord * probeVolume.spacing;

// 深度检测
vec3 toProbe = probePos - position;
float dist = length(toProbe);
vec3 dir = toProbe / dist;

float probeDepth = sampleDepthAtlas(probeCoord, -dir);

// 如果探针与当前位置之间有障碍物,降低权重
float depthWeight = (dist < probeDepth + threshold) ? 1.0 : 0.0;

// 法线权重(背面探测点贡献降低)
float normalWeight = max(0.0, dot(normal, dir));

// 三线性权重
vec3 blendWeight = mix(1.0 - t, t, offset);
float trilinearWeight = blendWeight.x * blendWeight.y * blendWeight.z;

// 综合权重
float weight = trilinearWeight * depthWeight * normalWeight;

// 采样辐照度
vec3 irradiance = sampleIrradianceAtlas(probeCoord, normal);

result += irradiance * weight;
totalWeight += weight;
}

return result / max(totalWeight, 0.0001);
}

4.5.2 双边滤波

为了进一步减少噪点,可以使用空间滤波:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
vec3 bilateralFilter(ivec2 centerCoord, vec3 centerNormal, float centerDepth) {
vec3 result = vec3(0.0);
float totalWeight = 0.0;

for (int y = -FILTER_RADIUS; y <= FILTER_RADIUS; y++) {
for (int x = -FILTER_RADIUS; x <= FILTER_RADIUS; x++) {
ivec2 coord = centerCoord + ivec2(x, y);

vec3 sampleIrradiance = texelFetch(irradianceTex, coord, 0).rgb;
vec3 sampleNormal = texelFetch(normalTex, coord, 0).rgb;
float sampleDepth = texelFetch(depthTex, coord, 0).r;

// 法线相似度权重
float normalWeight = pow(max(0.0, dot(centerNormal, sampleNormal)), normalSigma);

// 深度相似度权重
float depthWeight = exp(-abs(centerDepth - sampleDepth) / depthSigma);

// 空间距离权重
float spatialWeight = exp(-(x*x + y*y) / spatialSigma);

float weight = normalWeight * depthWeight * spatialWeight;

result += sampleIrradiance * weight;
totalWeight += weight;
}
}

return result / totalWeight;
}

4.6 DDGI实现细节

4.6.1 Probe Grid配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct DDGIVolume {
vec3 origin; // 网格原点
vec3 extents; // 网格范围
ivec3 probeCounts; // 每个维度的探针数量
float probeRadius; // 探针影响半径

// Atlas参数
ivec2 atlasResolution; // Atlas分辨率
int probesPerRow; // 每行探针数

// 更新参数
int raysPerProbe; // 每个探针的光线数
float historyBlendWeight; // 历史混合权重
};

4.6.2 光线方向生成

使用低差异序列生成均匀分布的光线方向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 使用黄金角螺旋生成均匀球面采样
vec3 generateRayDirection(int rayIndex, int totalRays) {
float phi = (1.0 + sqrt(5.0)) * 0.5; // 黄金比例
float theta = 2.0 * PI * rayIndex / phi;
float z = 1.0 - 2.0 * (rayIndex + 0.5) / totalRays;
float radius = sqrt(1.0 - z * z);

return vec3(
radius * cos(theta),
radius * sin(theta),
z
);
}

// 添加抖动避免模式化
vec3 generateJitteredRayDirection(int rayIndex, int totalRays, vec2 randomSeed) {
vec3 baseDir = generateRayDirection(rayIndex, totalRays);

// 添加小量随机扰动
vec3 jitter = vec3(
rand(randomSeed) - 0.5,
rand(randomSeed + vec2(1.0)) - 0.5,
rand(randomSeed + vec2(2.0)) - 0.5
);

return normalize(baseDir + jitter * 0.1);
}

4.6.3 探针分类

为了优化性能,可以对探针进行分类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum ProbeState {
INACTIVE, // 不在场景内
ACTIVE, // 正常工作
DIRTY // 需要更新
};

void classifyProbes() {
for each probe in probeVolume:
vec3 probePos = getProbePosition(probe);

// 检查是否在几何体内
if (isInsideGeometry(probePos)) {
probe.state = INACTIVE;
} else {
probe.state = ACTIVE;
}
}

4.7 DDGI的优化技术

4.7.1 时间分片更新

不需要每帧更新所有探针:

1
2
3
4
5
6
7
8
9
10
// 每帧只更新部分探针
void updateProbesTemporal(int frameIndex) {
int probesPerFrame = totalProbes / UPDATE_SPAN;
int startProbe = (frameIndex % UPDATE_SPAN) * probesPerFrame;

for (int i = 0; i < probesPerFrame; i++) {
int probeIndex = startProbe + i;
updateProbeRadiance(probeIndex);
}
}

4.7.2 自适应采样

根据光照变化动态调整采样密度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void adaptiveUpdate() {
// 计算每个探针的光照变化率
for each probe:
vec3 currentIrradiance = computeCurrentIrradiance();
vec3 previousIrradiance = probe.history;
float change = length(currentIrradiance - previousIrradiance);

probe.priority = change;

// 按优先级排序,优先更新变化大的探针
sortProbesByPriority();

// 在预算内更新高优先级探针
for (int i = 0; i < UPDATE_BUDGET; i++) {
updateProbeRadiance(sortedProbes[i]);
}
}

4.7.3 多分辨率探针

在大型场景中使用多级探针网格:

1
2
3
Level 0 (高分辨率): 小物体、室内场景
Level 1 (中分辨率): 中等区域
Level 2 (低分辨率): 远距离、室外大场景
1
2
3
4
5
6
7
8
9
10
11
vec3 sampleMultiResolutionProbes(vec3 position, vec3 normal) {
// 确定使用哪一级探针
int level = selectProbeLevel(position);

// 从相邻级别采样并混合
vec3 result0 = sampleProbeLevel(position, normal, level);
vec3 result1 = sampleProbeLevel(position, normal, level + 1);

float blendFactor = computeBlendFactor(position, level);
return mix(result0, result1, blendFactor);
}

4.8 DDGI与UE5 Lumen

4.8.1 Lumen概述

Lumen是虚幻引擎5的全动态全局光照系统,其核心组件包括:

  1. 软件光线追踪: 使用距离场和网格距离场
  2. 硬件光线追踪: 在支持的GPU上使用
  3. 辐射率缓存: 类似DDGI的探针系统

Lumen架构

4.8.2 Lumen中的辐射率缓存

Lumen使用Screen Space Radiance Cache和World Space Radiance Cache的组合:

Screen Space Radiance Cache:

  • 在屏幕空间缓存辐射率
  • 高分辨率,更新频繁
  • 受屏幕空间限制

World Space Radiance Cache:

  • 世界空间探针网格
  • 补充屏幕外信息
  • 使用类似DDGI的更新机制

4.8.3 Lumen与DDGI的对比

特性 DDGI Lumen
更新方式 固定光线数 自适应
存储方式 Atlas 纹理 + 体素
漫反射GI 支持 支持
镜面反射 有限支持 支持
硬件要求 RTX或软件光追 多种后端

4.9 DDGI的局限与改进方向

4.9.1 当前局限

  1. 漏光问题: 虽然深度可以缓解,但无法完全消除
  2. 分辨率限制: 探针密度有限,难以捕捉高频细节
  3. 动态物体: 快速移动物体的GI更新有延迟
  4. 镜面反射: 主要针对漫反射,镜面反射效果有限

4.9.2 改进方向

  1. 自适应探针放置: 根据场景复杂度动态调整探针密度
  2. 神经网络加速: 使用神经网络预测光照分布
  3. 混合方法: 结合屏幕空间方法和探针方法

4.10 小结

本章详细介绍了DDGI技术:

  1. Probe原理: 存储空间点的球面光照分布
  2. 八面体映射: 高效的球面-平面映射方法
  3. 光线追踪更新: 实时更新探针的光照信息
  4. 深度防漏光: 使用深度信息解决墙壁漏光问题
  5. 插值与滤波: 从探针获取任意位置的光照

DDGI是连接传统GI方法和神经渲染技术的桥梁,下一章将介绍如何使用神经网络进一步改进光照缓存。


第五章:Neural Radiance Caching(NRC)详解

5.1 NRC概述

5.1.1 什么是Neural Radiance Caching?

Neural Radiance Caching(NRC,神经辐射缓存)是一种使用神经网络来近似场景中辐射率分布的技术。它最初由Müller等人在2021年提出,旨在加速路径追踪渲染。

NRC概念图

5.1.2 核心思想

传统路径追踪中,每条路径都需要递归计算多次反弹的光照,计算量巨大。NRC的核心思想是:

  1. 用神经网络学习场景的辐射率分布
  2. 缓存中间结果,避免重复计算
  3. 通过查询网络获取间接光照估计

5.1.3 NRC与传统Radiance Cache的区别

特性 传统Radiance Cache Neural Radiance Cache
存储方式 离散点/探针 神经网络权重
插值方式 线性/三次插值 神经网络推断
内存占用 与分辨率成正比 固定(网络大小)
泛化能力 局限于采样点 可泛化到未采样区域
更新成本 需要更新大量缓存 增量学习

5.2 神经网络基础

5.2.1 多层感知机(MLP)

NRC使用简单的多层感知机作为核心网络结构:

1
2
3
输入层 → 隐藏层1 → 隐藏层2 → 隐藏层3 → 输出层

位置编码 + 方向编码 → MLP → 辐射率输出

推荐配置:

  • 隐藏层数:3层
  • 每层宽度:64个神经元
  • 激活函数:Leaky ReLU

5.2.2 激活函数

Leaky ReLU:

\[f(x) = \begin{cases} x & \text{if } x > 0 \alpha x & \text{if } x \leq 0 \end{cases}\]

其中 \(\alpha\) 通常取0.01。

优点:

  • 解决ReLU的"死亡神经元"问题
  • 计算简单高效
  • 保持稀疏激活特性
1
2
def leaky_relu(x, alpha=0.01):
return np.where(x > 0, x, alpha * x)

5.2.3 位置编码(Positional Encoding)

直接使用坐标作为输入会导致网络难以学习高频信息。位置编码将输入映射到高维空间:

\[\gamma(p) = [\sin(2^0 \pi p), \cos(2^0 \pi p), \sin(2^1 \pi p), \cos(2^1 \pi p), \ldots]\]

原理:

  • 低频基函数捕捉平滑变化
  • 高频基函数捕捉细节

5.3 Hash Grid Encoding

5.3.1 为什么需要Hash Grid?

传统位置编码有两个问题:

  1. 固定分辨率: 无法自适应场景复杂度
  2. 内存开销大: 高分辨率需要大量存储

Hash Grid Encoding(哈希网格编码)解决了这些问题:

  • 可变分辨率,适应场景复杂度
  • 内存占用固定
  • 支持无界场景

5.3.2 Hash Grid原理

核心思想: 使用空间哈希将3D位置映射到特征向量。

1
位置(x, y, z) → 多分辨率体素网格 → 哈希查找 → 特征插值 → 输出特征向量

算法步骤:

  1. 多分辨率网格: 创建 \(L\) 个不同分辨率的网格

\[N_l = \lfloor N_{min} \cdot b^l \rfloor, \quad l = 0, 1, \ldots, L-1\]

其中 \(b = \exp\left(\frac{\ln N_{max} - \ln N_{min}}{L-1}\right)\)

  1. 哈希函数: 将体素角点映射到特征表

\[h(x, y, z) = ((x \oplus y \oplus z) \mod T)\]

其中 \(T\) 是特征表大小,\(\oplus\) 是按位异或。

  1. 特征插值: 在体素内三线性插值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 计算在分辨率l下的体素坐标
vec3 scaledPos = pos * resolutions[l];
ivec3 voxelCoord = ivec3(floor(scaledPos));
vec3 frac = fract(scaledPos);

// 获取8个角点的特征
vec4 features[8];
for (int i = 0; i < 8; i++) {
ivec3 offset = ivec3(i & 1, (i >> 1) & 1, (i >> 2) & 1);
ivec3 corner = voxelCoord + offset;
uint hash = hashFunction(corner);
features[i] = featureTable[hash % TABLE_SIZE];
}

// 三线性插值
vec4 result = trilinearInterpolate(features, frac);

5.3.3 Unbounded Hash-Grid Encoding

对于开放世界场景,使用无界哈希网格编码:

关键改进:

  1. 对数空间映射: 将无限空间压缩到有限范围

\[\text{scaled}(x) = \text{sign}(x) \cdot \frac{\log(1 + |x|)}{\log(1 + R)}\]

其中 \(R\) 是参考距离。

  1. 分辨率层级: 推荐配置
参数 推荐值 说明
Level (L) 6 分辨率层数
Feature Dimension (F) 2 每个特征向量的维度
Table Size (T) \(2^{19}\) 哈希表大小

5.4 NRC的网络架构

5.4.1 输入编码

NRC对不同的输入使用不同的编码策略:

位置编码(Position Encoding):

推荐使用Hash Grid Encoding:

  • Levels: 6
  • Feature Dimension: 2
  • Base Resolution: 16
  • Finest Resolution: 2048

视角方向编码(View Direction Encoding):

推荐使用恒等编码(Identity Encoding):

\[\gamma_{dir}(\omega) = \omega\]

直接使用方向向量 \((\theta, \phi)\)\((x, y, z)\) 作为输入。

法线编码(Normal Encoding):

同样推荐恒等编码:

\[\gamma_n(n) = n\]

5.4.2 完整网络结构

1
2
3
4
5
6
7
8
9
10
11
12
输入层:
├── 位置 (x, y, z) → Hash Grid Encoding → 12维特征 (6 levels × 2 dim)
├── 方向 (θ, φ) → 恒等编码 → 2维
└── 法线 (nx, ny, nz) → 恒等编码 → 3维

合并: [17维特征向量]

MLP:
├── 隐藏层1: 17 → 64, Leaky ReLU
├── 隐藏层2: 64 → 64, Leaky ReLU
├── 隐藏层3: 64 → 64, Leaky ReLU
└── 输出层: 64 → 3 (RGB辐射率)

NRC架构图

5.4.3 网络参数量估算

1
2
3
4
5
6
7
8
9
10
11
输入编码参数:
- Hash Table: 2^19 × 2 × 4 bytes = 4 MB

MLP参数:
- Layer 1: 17 × 64 + 64 = 1152
- Layer 2: 64 × 64 + 64 = 4160
- Layer 3: 64 × 64 + 64 = 4160
- Output: 64 × 3 + 3 = 195
- Total: ~10K parameters

总参数量: ~4 MB (主要是Hash Table)

5.5 NRC的训练

5.5.1 在线训练

NRC在渲染过程中实时训练,而非预训练:

1
2
3
4
5
6
每帧:
1. 从屏幕空间采样6%的像素作为训练射线
2. 对每条训练射线执行路径追踪
3. 收集路径上的采样点作为训练数据
4. 更新神经网络参数
5. 对其余像素使用网络查询替代部分路径追踪

5.5.2 训练数据收集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct TrainingSample {
vec3 position; // 采样点位置
vec3 direction; // 入射方向
vec3 normal; // 表面法线
vec3 radiance; // 真实辐射率(通过路径追踪计算)
float weight; // 样本权重
};

void collectTrainingSamples(Ray ray, std::vector<TrainingSample>& samples) {
vec3 throughput = vec3(1.0);

for (int bounce = 0; bounce < MAX_BOUNCES; bounce++) {
RayHit hit = traceRay(ray);

if (!hit.valid) break;

// 收集样本
TrainingSample sample;
sample.position = hit.position;
sample.direction = -ray.direction;
sample.normal = hit.normal;

// 计算真实辐射率
vec3 directLight = computeDirectLight(hit);
vec3 indirectEstimate = queryNetwork(hit.position, randomDirection, hit.normal);
sample.radiance = directLight + indirectEstimate;
sample.weight = throughput.luminance();

samples.push_back(sample);

// 更��throughput
vec3 newDir = sampleBRDF(hit);
throughput *= evaluateBRDF(hit, newDir) * dot(newDir, hit.normal);

// 俄罗斯轮盘赌
if (bounce > 3) {
float p = min(throughput.luminance(), 0.95);
if (random() > p) break;
throughput /= p;
}

ray = Ray(hit.position, newDir);
}
}

5.5.3 损失函数

NRC使用相对L2损失函数:

\[\mathcal{L} = \sum_i w_i \left( \frac{L_{pred,i} - L_{target,i}}{L_{target,i} + \epsilon} \right)^2\]

损失函数

相对损失的原因:

  1. HDR范围: 辐射率值跨越多个数量级
  2. 重要性平衡: 避免高亮度区域主导损失
  3. 数值稳定: 添加 \(\epsilon\) 防止除零
1
2
3
4
5
6
7
8
def relative_l2_loss(pred, target, epsilon=0.01):
"""
相对L2损失函数
pred: 预测的辐射率 (N, 3)
target: 目标辐射率 (N, 3)
"""
relative_error = (pred - target) / (target + epsilon)
return torch.mean(relative_error ** 2)

5.5.4 优化器

推荐使用Adam优化器,参数配置:

参数 说明
Learning Rate \(10^{-3}\) 初始学习率
\(\beta_1\) 0.9 一阶矩估计衰减率
\(\beta_2\) 0.99 二阶矩估计衰减率
\(\epsilon\) \(10^{-15}\) 数值稳定性常数
1
2
3
4
optimizer = torch.optim.Adam([
{'params': hash_grid.parameters(), 'lr': 1e-3},
{'params': mlp.parameters(), 'lr': 1e-3}
], betas=(0.9, 0.99), eps=1e-15)

5.6 NRC渲染流程

5.6.1 完整渲染管线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────┐
│ NRC渲染管线 │
├─────────────────────────────────────────────────────────┤
│ 1. G-Buffer生成 │
│ - 位置、法线、材质信息 │
├─────────────────────────────────────────────────────────┤
│ 2. 训练射线采样 (屏幕空间的6%) │
│ - 执行完整路径追踪 │
│ - 收集训练样本 │
│ - 更新神经网络 │
├─────────────────────────────────────────────────────────┤
│ 3. 直接光照计算 │
│ - 使用传统方法计算直接光照 │
├─────────────────────────────────────────────────────────┤
│ 4. 间接光照查询 │
│ - 使用NRC网络查询间接光照 │
│ - 对剩余94%像素 │
├─────────────────────────────────────────────────────────┤
│ 5. 合成最终图像 │
│ - 直接光照 + 间接光照 │
└─────────────────────────────────────────────────────────┘

5.6.2 推断代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
vec3 queryNRC(vec3 position, vec3 direction, vec3 normal) {
// 编码输入
vec3 posEncoded = hashGridEncode(position);
vec3 dirEncoded = direction; // 恒等编码
vec3 normalEncoded = normal; // 恒等编码

// 合并特征
float features[17];
// ... 填充特征 ...

// MLP前向传播
float hidden1[64], hidden2[64], hidden3[64];
float output[3];

// Layer 1
for (int i = 0; i < 64; i++) {
hidden1[i] = 0.0;
for (int j = 0; j < 17; j++) {
hidden1[i] += features[j] * weights1[j][i];
}
hidden1[i] = leakyReLU(hidden1[i] + bias1[i]);
}

// Layer 2, 3 类似...

// 输出
for (int i = 0; i < 3; i++) {
output[i] = hidden3[i]; // 可选:使用ReLU确保非负
}

return vec3(output[0], output[1], output[2]);
}

5.7 NRC的高级技术

5.7.1 处理HDR辐射率

问题: MLP输出范围有限,难以直接表示HDR值。

解决方案:

  1. 对数空间训练:

\[L_{train} = \log(1 + L)\]

1
2
3
4
5
def encode_hdr(radiance):
return torch.log(1.0 + radiance)

def decode_hdr(encoded):
return torch.exp(encoded) - 1.0
  1. 色调映射:

\[L_{mapped} = \frac{L}{1 + L}\]

1
2
3
4
5
def tonemap(radiance):
return radiance / (1.0 + radiance)

def inverse_tonemap(mapped):
return mapped / (1.0 - mapped + 1e-6)

5.7.2 时间一致性

使用时间累积提高稳定性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 累积历史参数
void accumulateParameters(float* currentParams, float* historyParams, float alpha) {
for (int i = 0; i < PARAM_COUNT; i++) {
historyParams[i] = alpha * historyParams[i] + (1 - alpha) * currentParams[i];
}
}

// 使用历史参数进行推断
vec3 queryStable(vec3 pos, vec3 dir, vec3 normal) {
vec3 current = queryNRC(pos, dir, normal, currentParams);
vec3 history = queryNRC(pos, dir, normal, historyParams);

// 验证历史值是否合理
float diff = length(current - history);
if (diff > threshold) {
return current; // 历史值不可靠
}

return mix(current, history, 0.5);
}

5.7.3 多分辨率采样

使用重要性采样优化训练:

1
2
3
4
5
6
7
8
9
10
11
// 根据屏幕空间误差确定采样密度
int getSampleCount(vec2 pixel) {
float error = estimateError(pixel);

if (error > HIGH_ERROR_THRESHOLD) {
return 4; // 高误差区域多采样
} else if (error > MEDIUM_ERROR_THRESHOLD) {
return 2;
}
return 1;
}

5.8 NRC与DDGI的结合

5.8.1 动机

NRC最初为路径追踪设计,但其思想可以应用到DDGI:

  1. 用NRC替代DDGI的部分光线追踪: 减少每个探针需要的光线数
  2. 用NRC提供更好的初始猜测: 加速收敛
  3. 用NRC泛化到未采样区域: 提高空间覆盖率

5.8.2 结合方案

1
2
3
4
5
传统DDGI:
探针 → 光线追踪 → 辐照度

NRC增强DDGI:
探针 → 少量光线追踪 + NRC查询 → 辐照度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vec3 updateProbeWithNRC(Probe probe, int reducedRayCount) {
vec3 irradiance = vec3(0.0);

// 部分方向使用光线追踪
for (int i = 0; i < reducedRayCount; i++) {
vec3 dir = getRayDirection(i);
vec3 traced = traceRayForIrradiance(probe.position, dir);

// NRC辅助估计
vec3 nrcEstimate = queryNRC(probe.position, dir, dir);

// 混合
irradiance += mix(traced, nrcEstimate, NRC_WEIGHT);
}

// 其余方向仅使用NRC
for (int i = reducedRayCount; i < FULL_RAY_COUNT; i++) {
vec3 dir = getRayDirection(i);
irradiance += queryNRC(probe.position, dir, dir);
}

return irradiance / FULL_RAY_COUNT;
}

5.9 NRC的局限与展望

5.9.1 当前局限

  1. 训练开销: 在线训练需要计算资源
  2. 泛化边界: 对训练数据中未出现的场景泛化有限
  3. 高频细节: 神经网络对高频光照细节的表达有限
  4. 动态场景: 场景剧烈变化时需要重新适应

5.9.2 未来方向

  1. 更好的编码: 探索更高效的位置编码方法
  2. 混合架构: 结合显式存储和神经网络
  3. 预训练模型: 利用大规模数据集预训练
  4. 多任务学习: 同时学习多个相关任务

5.10 小结

本章详细介绍了Neural Radiance Caching技术:

  1. 基本原理: 使用神经网络近似场景的辐射率分布
  2. 网络架构: Hash Grid Encoding + 简单MLP
  3. 训练方法: 在线训练,相对L2损失
  4. HDR处理: 对数空间或色调映射
  5. 与DDGI结合: 可以减少光线追踪开销

下一章将介绍Neural Irradiance Volume,这是一种直接学习探针辐照度的技术。


第六章:Neural Irradiance Volume(NIV)

6.1 NIV概述

6.1.1 什么是Neural Irradiance Volume?

Neural Irradiance Volume(NIV,神经辐照度体)是一种直接学习场景中辐照度分布的神经渲染技术。与NRC不同,NIV直接学习每个探针位置的辐照度,而非辐射率。

NIV概念图

6.1.2 NIV与NRC的区别

特性 NRC NIV
学习目标 辐射率(Radiance) 辐照度(Irradiance)
输入 位置 + 方向 + 法线 位置 + 法线
输出 方向辐射率 半球积分辐照度
计算量 每方向需查询 一次查询即得结果
适用场景 路径追踪加速 探针辐照度学习

6.1.3 为什么选择学习辐照度?

辐照度的优势:

  1. 维度更低: 辐照度只依赖于位置和法线,无需方向参数
  2. 查询更简单: 一次查询替代半球积分
  3. 存储更紧凑: 只需存储标量场而非方向场

数学基础:

\[E(p, n) = \int_{\Omega} L_i(p, \omega) (\omega \cdot n) d\omega\]

对于漫反射材质,辐照度完全决定了出射辐射率:

\[L_o = \frac{\rho}{\pi} E\]

6.2 NIV的数学模型

6.2.1 辐照度作为函数

将辐照度视为场景的函数:

\[E: \mathbb{R}^3 \times S^2 \rightarrow \mathbb{R}^3\]

\[E(p, n) = \text{位置 } p \text{ 处法线为 } n \text{ 的辐照度}\]

关键性质:

  1. 空间连续性: 辐照度在空间中通常是连续的
  2. 法线依赖: 相同位置不同法线会有不同辐照度
  3. 局部相似性: 相邻位置的辐照度相似

6.2.2 球面调和函数表示

使用球面调和函数(SH)压缩辐照度的方向依赖:

\[E(p, n) \approx \sum_{l=0}^{L} \sum_{m=-l}^{l} c_l^m(p) Y_l^m(n)\]

其中 \(c_l^m(p)\) 是位置相关的SH系数。

对于Lambertian辐照度:

只用前3阶SH就能很好地近似(共9个系数):

1
2
3
4
5
struct SHCoefficients {
vec3 L0; // L=0, m=0 (1个系数)
vec3 L1[3]; // L=1, m=-1,0,1 (3个系数)
vec2 L2[5]; // L=2, m=-2,-1,0,1,2 (5个系数)
};

6.3 NIV的网络架构

6.3.1 输入编码

位置编码:

推荐使用Hash Grid Encoding,与NRC相同:

  • Levels: 6
  • Feature Dimension: 2
  • Base Resolution: 16
  • Finest Resolution: 2048

法线编码:

两种选择:

  1. 恒等编码: 直接使用法线向量 \[\gamma(n) = n = (n_x, n_y, n_z)\]
  2. SH基函数: 预计算法线的SH基值 \[\gamma(n) = [Y_0^0(n), Y_1^{-1}(n), Y_1^0(n), Y_1^1(n), \ldots]\]

6.3.2 完整网络结构

方案A:直接预测辐照度

1
2
3
4
5
输入: position (Hash Grid) + normal (恒等编码)

MLP (3层,每层64神经元)

输出: irradiance (RGB)

方案B:预测SH系数

1
2
3
4
5
6
7
8
9
输入: position (Hash Grid)

MLP (3层,每层64神经元)

输出: SH coefficients (9个RGB系数 = 27维)

与法线SH基点乘

输出: irradiance (RGB)

NIV架构

6.3.3 网络代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import torch
import torch.nn as nn

class NeuralIrradianceVolume(nn.Module):
def __init__(self, num_levels=6, feature_dim=2, hidden_dim=64):
super().__init__()

# Hash Grid Encoding
self.hash_grid = HashGridEncoding(
num_levels=num_levels,
feature_dim=feature_dim,
log2_hashmap_size=19,
base_resolution=16,
finest_resolution=2048
)

# 输入维度: Hash特征 + 法线
input_dim = num_levels * feature_dim + 3

# MLP
self.mlp = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.LeakyReLU(0.01),
nn.Linear(hidden_dim, hidden_dim),
nn.LeakyReLU(0.01),
nn.Linear(hidden_dim, hidden_dim),
nn.LeakyReLU(0.01),
nn.Linear(hidden_dim, 3) # RGB辐照度
)

def forward(self, position, normal):
# 编码位置
pos_encoded = self.hash_grid(position)

# 合并输入
features = torch.cat([pos_encoded, normal], dim=-1)

# MLP推断
irradiance = self.mlp(features)

return irradiance

6.4 NIV的训练

6.4.1 训练数据生成

NIV需要预先烘焙或实时收集辐照度数据:

离线烘焙方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct IrradianceSample {
vec3 position;
vec3 normal;
vec3 irradiance;
};

std::vector<IrradianceSample> bakeIrradianceSamples(Scene& scene) {
std::vector<IrradianceSample> samples;

// 在场景中均匀采样点
for (int i = 0; i < NUM_POSITIONS; i++) {
vec3 pos = sampleRandomPosition(scene.boundingBox);

// 检查是否在有效区域内
if (!isValidPosition(pos)) continue;

// 对多个法线方向计算辐照度
for (int j = 0; j < NUM_NORMALS; j++) {
vec3 normal = sampleHemisphereDirection(j);

// 使用路径追踪计算辐照度
vec3 irradiance = computeIrradiance(pos, normal);

samples.push_back({pos, normal, irradiance});
}
}

return samples;
}

实时收集方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 从DDGI探针收集训练数据
void collectFromDDGIProbes(DDGIVolume& ddgi, std::vector<IrradianceSample>& samples) {
for (int i = 0; i < ddgi.probeCount; i++) {
Probe& probe = ddgi.probes[i];
vec3 probePos = probe.position;

// 从探针的辐照度Atlas提取不同法线的辐照度
for (int n = 0; n < NUM_NORMALS; n++) {
vec3 normal = getSampleNormal(n);

// 从探针Atlas计算辐照度
vec3 irradiance = computeIrradianceFromAtlas(probe, normal);

samples.push_back({probePos, normal, irradiance});
}
}
}

6.4.2 损失函数

NIV使用相对L2损失,与NRC类似:

\[\mathcal{L} = \frac{1}{N} \sum_{i=1}^{N} \left \frac{E_{pred,i} - E_{target,i}}{E_{target,i} + \epsilon} \right^2\]

损失函数示意

1
2
3
4
5
6
def irradiance_loss(pred, target, epsilon=0.01):
"""
NIV的损失函数
"""
relative_error = (pred - target) / (target + epsilon)
return torch.mean(relative_error ** 2)

6.4.3 训练流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def train_niv(model, train_loader, num_epochs=1000):
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(num_epochs):
total_loss = 0

for batch in train_loader:
position = batch['position']
normal = batch['normal']
target_irradiance = batch['irradiance']

# 前向传播
pred_irradiance = model(position, normal)

# 计算损失
loss = irradiance_loss(pred_irradiance, target_irradiance)

# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()

total_loss += loss.item()

if epoch % 100 == 0:
print(f"Epoch {epoch}, Loss: {total_loss / len(train_loader)}")

6.5 NIV的应用场景

6.5.1 初始化DDGI探针

使用NIV为DDGI探针提供初始辐照度猜测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void initializeDDGIWithNIV(DDGIVolume& ddgi, NeuralIrradianceVolume& niv) {
for (int i = 0; i < ddgi.probeCount; i++) {
vec3 probePos = ddgi.probes[i].position;

// 对每个法线方向
for (int dir = 0; dir < NUM_DIRECTIONS; dir++) {
vec3 normal = getDirectionNormal(dir);

// 使用NIV预测辐照度
vec3 predictedIrradiance = niv.query(probePos, normal);

// 初始化探针Atlas
ddgi.probes[i].setIrradiance(dir, predictedIrradiance);
}
}
}

6.5.2 加速辐照度收敛

在动态场景中,NIV可以加速DDGI的收敛:

1
2
3
4
5
6
7
8
9
10
11
12
vec3 updateProbeWithNIVPrior(Probe& probe, NeuralIrradianceVolume& niv) {
vec3 newIrradiance = traceRaysForIrradiance(probe);

// 使用NIV预测作为先验
vec3 nivPrior = niv.query(probe.position, probe.normal);

// 贝叶斯更新
float confidence = computeConfidence(probe.rayCount);
vec3 result = mix(nivPrior, newIrradiance, confidence);

return result;
}

6.5.3 补充未采样区域

对于探针稀疏的区域,NIV可以提供补充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vec3 sampleIrradianceHybrid(vec3 position, vec3 normal, DDGIVolume& ddgi, NeuralIrradianceVolume& niv) {
// 从DDGI插值
vec3 ddgiIrradiance = ddgi.interpolate(position, normal);
float ddgiConfidence = ddgi.computeInterpolationConfidence(position);

// 从NIV查询
vec3 nivIrradiance = niv.query(position, normal);

// 根据探针密度混合
if (ddgiConfidence < LOW_CONFIDENCE_THRESHOLD) {
// 探针覆盖不足,更多依赖NIV
return mix(ddgiIrradiance, nivIrradiance, 0.7);
} else {
// 探针覆盖充足,更多依赖DDGI
return mix(ddgiIrradiance, nivIrradiance, 0.1);
}
}

6.6 NIV的高级技术

6.6.1 泛化能力提升

问题: NIV在训练数据未覆盖的区域泛化有限。

解决方案:

  1. 数据增强:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def augment_training_data(samples):
augmented = []

for sample in samples:
augmented.append(sample)

# 添加噪声扰动
for _ in range(3):
noisy_pos = sample.position + random_noise() * 0.1
noisy_normal = normalize(sample.normal + random_noise() * 0.05)
augmented.append({
'position': noisy_pos,
'normal': noisy_normal,
'irradiance': sample.irradiance
})

return augmented
  1. 正则化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def smoothness_regularization(model, positions):
"""
鼓励网络在空间中平滑
"""
loss = 0
for pos in positions:
# 计算梯度惩罚
pos.requires_grad = True
normal = torch.tensor([0, 1, 0], dtype=torch.float32)
output = model(pos, normal)

grad = torch.autograd.grad(output.sum(), pos, create_graph=True)[0]
loss += torch.mean(grad ** 2)

return loss

6.6.2 实时学习

支持动态场景的实时学习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void realTimeLearning(NeuralIrradianceVolume& niv, FrameData& frame) {
// 从当前帧收集样本
std::vector<IrradianceSample> newSamples;

// 从可见表面采样
for (auto& pixel : frame.visiblePixels) {
vec3 pos = pixel.worldPosition;
vec3 normal = pixel.normal;
vec3 irradiance = computeGroundTruthIrradiance(pixel);

newSamples.push_back({pos, normal, irradiance});
}

// 在线更新网络
niv.onlineUpdate(newSamples, learningRate=0.001, numSteps=10);
}

6.6.3 多尺度表示

使用多分辨率Hash Grid捕捉不同尺度的细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class MultiScaleNIV(nn.Module):
def __init__(self):
super().__init__()

# 粗尺度:捕捉大范围光照变化
self.coarse_grid = HashGridEncoding(
num_levels=4,
base_resolution=8,
finest_resolution=128
)

# 细尺度:捕捉局部细节
self.fine_grid = HashGridEncoding(
num_levels=4,
base_resolution=128,
finest_resolution=2048
)

# 分别的MLP
self.coarse_mlp = MLP(input_dim=8, hidden_dim=32, output_dim=3)
self.fine_mlp = MLP(input_dim=8, hidden_dim=32, output_dim=3)

def forward(self, position, normal):
# 粗尺度
coarse_features = self.coarse_grid(position)
coarse_irradiance = self.coarse_mlp(
torch.cat([coarse_features, normal], dim=-1)
)

# 细尺度
fine_features = self.fine_grid(position)
fine_detail = self.fine_mlp(
torch.cat([fine_features, normal], dim=-1)
)

# 合并
return coarse_irradiance + fine_detail

6.7 NIV与预烘焙探针的结合

6.7.1 从烘焙探针初始化NIV

如果游戏已有烘焙的Light Probe,可以用于初始化NIV:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void initializeNIVFromBakedProbes(NeuralIrradianceVolume& niv, LightProbeGrid& bakedProbes) {
std::vector<IrradianceSample> trainingData;

// 从烘焙探针提取训练数据
for (auto& probe : bakedProbes.probes) {
// 对于每个SH系数,重建不同法线的辐照度
for (int n = 0; n < NUM_NORMALS; n++) {
vec3 normal = getSampleNormal(n);
vec3 irradiance = evaluateSH(probe.shCoefficients, normal);

trainingData.push_back({
probe.position,
normal,
irradiance
});
}
}

// 训练NIV
niv.train(trainingData, numEpochs=10000);
}

6.7.2 NIV泛化到未烘焙区域

1
2
3
4
5
6
7
8
9
10
11
12
vec3 queryLighting(vec3 position, vec3 normal, LightProbeGrid& baked, NeuralIrradianceVolume& niv) {
// 检查是否有烘焙数据
bool hasBakedData = baked.isValidPosition(position);

if (hasBakedData) {
// 使用烘焙数据
return baked.interpolate(position, normal);
} else {
// 使用NIV泛化
return niv.query(position, normal);
}
}

6.8 NIV的局限与解决方案

6.8.1 光环境变化

问题: NIV学习的辐照度对应特定光环境,光环境变化时需要更新。

解决方案:

  1. 条件输入: 将光环境参数作为网络输入
1
2
3
4
5
6
7
8
9
class ConditionalNIV(nn.Module):
def forward(self, position, normal, light_params):
# light_params: 光源位置、强度、颜色等
features = torch.cat([
self.encode_position(position),
normal,
light_params
], dim=-1)
return self.mlp(features)
  1. 增量学习: 光环境变化时增量更新
1
2
3
4
5
6
7
void adaptToLightChange(NeuralIrradianceVolume& niv, LightChange& change) {
// 计算光照变化的影响
std::vector<IrradianceSample> newSamples = recomputeAffectedSamples(change);

// 增量更新网络
niv.incrementalUpdate(newSamples);
}

6.8.2 高频细节缺失

问题: 神经网络倾向于平滑,可能丢失高频光照细节。

解决方案:

  1. 残差学习: 学习相对于低频基的残差
1
2
3
4
5
6
7
8
9
class ResidualNIV(nn.Module):
def forward(self, position, normal):
# 低频基(从SH或其他方法)
base_irradiance = self.computeLowFrequencyBase(position, normal)

# 网络预测残差
residual = self.mlp(self.encode(position), normal)

return base_irradiance + residual
  1. 细节增强: 使用单独的细节网络

6.9 NIV实践建议

6.9.1 参数配置推荐

参数 推荐值 说明
Hash Grid Levels 6 平衡精度和内存
Feature Dimension 2 每级特征维度
MLP隐藏层数 3 足够的表达能力
MLP隐藏层宽度 64 平衡性能和质量
训练样本数 100K-1M 根据场景复杂度
学习率 \(10^{-3}\) 初始学习率
ε(损失函数) 0.01 相对损失的稳定项

6.9.2 性能优化

  1. 量化: 使用FP16或INT8推理
  2. 剪枝: 移除不重要的网络连接
  3. 蒸馏: 训练更小的学生网络

6.10 小结

本章详细介绍了Neural Irradiance Volume技术:

  1. 基本原理: 直接学习场景的辐照度分布
  2. 网络架构: Hash Grid Encoding + MLP
  3. 与NRC的区别: 辐照度vs辐射率,更低维度
  4. 应用场景: 初始化DDGI探针、加速收敛、补充未采样区域
  5. 局限性: 光环境变化适应、高频细节表达

下一章将探讨神经渲染的前沿方向,包括用神经网络替代传统光线追踪组件的探索。


第七章:神经渲染前沿方向

7.1 概述

前面章节介绍的NRC和NIV主要用于加速光照计算,但仍依赖传统的光线追踪架构(BVH、射线-三角形相交测试等)。本章探讨更激进的方向:使用神经网络替代光线追踪的核心组件。

神经渲染前沿

7.2 传统光线追踪的性能瓶颈

7.2.1 加速结构回顾

现代光线追踪使用层次化加速结构:

Bottom-Level Acceleration Structure (BLAS):

  • 存储单个物体的几何信息
  • 通常使用BVH(Bounding Volume Hierarchy)
  • 每个叶子节点包含三角形

Top-Level Acceleration Structure (TLAS):

  • 组织场景中的所有BLAS实例
  • 支持动态物体的快速更新

7.2.2 性能分析

光线追踪的计算开销分解:

阶段 开销占比 说明
BVH遍历 30-40% 树结构遍历,访存密集
射线-三角形相交 20-30% 数值计算密集
着色计算 30-50% 材质、光照计算

BLAS的问题:

  1. 构建开销: 复杂模型需要大量时间构建BVH
  2. 内存占用: 高精度模型需要存储大量三角形
  3. 更新代价: 动态变形物体需要重新构建或更新

BVH结构

7.3 神经网络替代方案

7.3.1 核心思想

能否用神经网络学习场景的几何和外观,替代传统的:

  • BVH加速结构
  • 射线-几何体相交测试
  • 材质和光照计算

理想目标:

1
2
3
4
5
传统管线:
光线 → BVH遍历 → 相交测试 → 着色 → 颜色

神经渲染管线:
光线 → 神经网络查询 → 颜色

7.3.2 可行性分析

神经网络的优势:

  • 紧凑表示:用网络权重替代大量几何数据
  • 连续表示:可以表示任意精度的几何
  • 隐式编码:自然处理复杂拓扑

挑战:

  • 训练数据需求
  • 推断速度
  • 精度保证

7.4 NeRF(Neural Radiance Fields)

7.4.1 NeRF基本原理

NeRF用多层感知机表示场景的辐射率场:

\[F_\theta: (x, y, z, \theta, \phi) \rightarrow (r, g, b, \sigma)\]

其中:

  • \((x, y, z)\) 是3D位置
  • \((\theta, \phi)\) 是视角方向
  • \((r, g, b)\) 是颜色
  • \(\sigma\) 是体密度(不透明度)

7.4.2 体渲染积分

NeRF使用体渲染方程从辐射率场合成图像:

\[C(\mathbf{r}) = \int_{t_n}^{t_f} T(t) \sigma(\mathbf{r}(t)) \mathbf{c}(\mathbf{r}(t), \mathbf{d}) dt\]

其中: \[T(t) = \exp\left(-\int_{t_n}^{t} \sigma(\mathbf{r}(s)) ds\right)\]

是累积透射率。

离散化近似:

\[\hat{C}(\mathbf{r}) = \sum_{i=1}^{N} T_i \alpha_i \mathbf{c}_i\]

其中: \[\alpha_i = 1 - \exp(-\sigma_i \delta_i)\]

7.4.3 NeRF渲染管线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def render_ray(nerf_model, ray_origin, ray_direction, near, far, num_samples):
"""
NeRF单条光线渲染
"""
# 在光线上采样点
t_values = torch.linspace(near, far, num_samples)
points = ray_origin + ray_direction.unsqueeze(-1) * t_values

# 查询NeRF
rgbs = []
densities = []
for point in points:
rgb, density = nerf_model(point, ray_direction)
rgbs.append(rgb)
densities.append(density)

# 体渲染积分
rgbs = torch.stack(rgbs)
densities = torch.stack(densities)

# 计算alpha
deltas = t_values[1:] - t_values[:-1]
alphas = 1 - torch.exp(-densities[:-1] * deltas)

# 计算透射率
transmittance = torch.cumprod(1 - alphas + 1e-10, dim=0)
transmittance = torch.cat([torch.ones(1), transmittance[:-1]])

# 最终颜色
weights = transmittance * alphas
color = (weights.unsqueeze(-1) * rgbs[:-1]).sum(dim=0)

return color

7.4.4 NeRF与光线追踪的对比

特性 传统光线追踪 NeRF
几何表示 显式三角形 隐式网络
相交测试 BVH遍历 体采样
存储需求 与复杂度成正比 固定(网络大小)
渲染速度 快(硬件加速) 慢(多次网络查询)
质量 取决于模型精度 高(视图合成)
适用场景 通用渲染 特定场景重建

7.4.5 NeRF的改进方向

加速技术:

  1. Instant-NGP: 使用Hash Grid Encoding大幅加速训练和推断
  2. Plenoxels: 用稀疏体素网格替代MLP
  3. KiloNeRF: 使用数千个小MLP替代大网络
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class InstantNGPNeRF(nn.Module):
"""
使用Hash Grid Encoding的快速NeRF
"""
def __init__(self):
super().__init__()

# Hash Grid Encoding
self.hash_grid = HashGridEncoding(
num_levels=16,
feature_dim=2,
log2_hashmap_size=19
)

# 密度网络(小)
self.density_net = nn.Sequential(
nn.Linear(32, 16),
nn.ReLU(),
nn.Linear(16, 1)
)

# 颜色网络(小)
self.color_net = nn.Sequential(
nn.Linear(32 + 3, 16), # +3 for view direction
nn.ReLU(),
nn.Linear(16, 3),
nn.Sigmoid()
)

def forward(self, position, view_dir):
features = self.hash_grid(position)
density = self.density_net(features)
color = self.color_net(torch.cat([features, view_dir], dim=-1))
return color, density

7.5 3D Gaussian Splatting

7.5.1 基本原理

3D Gaussian Splatting是另一种神经表示方法,使用显式的3D高斯球集合表示场景:

每个高斯球由以下参数定义:

  • 位置 \(\mu \in \mathbb{R}^3\)
  • 协方差矩阵 \(\Sigma \in \mathbb{R}^{3\times3}\)
  • 不透明度 \(\alpha \in [0, 1]\)
  • 球谐系数 \(SH\)(用于视图相关的颜色)

7.5.2 高斯球的光栅化

与传统光线追踪不同,3DGS使用可微分光栅化:

1
2
3
4
对于每个像素:
1. 找到投影到该像素的高斯球
2. 按深度排序
3. Alpha混合计算最终颜色
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def rasterize_gaussians(gaussians, camera, image_size):
"""
高斯球光栅化
"""
image = torch.zeros(image_size[0], image_size[1], 3)

# 将高斯球投影到屏幕空间
projected = project_gaussians(gaussians, camera)

# 按深度排序
sorted_indices = torch.argsort(projected.depths, descending=True)

# 对每个像素
for y in range(image_size[0]):
for x in range(image_size[1]):
# 找到覆盖该像素的高斯球
overlapping = find_overlapping_gaussians(
projected, x, y, sorted_indices
)

# Alpha混合
alpha_acc = 0.0
color = torch.zeros(3)

for idx in overlapping:
gaussian = projected[idx]
alpha = gaussian.opacity * evaluate_2d_gaussian(
gaussian, x, y
)
color += (1 - alpha_acc) * alpha * gaussian.color
alpha_acc += (1 - alpha_acc) * alpha

if alpha_acc > 0.99:
break

image[y, x] = color

return image

7.5.3 与光线追踪的关系

3DGS可以与光线追踪结合:

  1. 混合渲染: 使用3DGS表示场景几何,用光线追踪计算阴影/反射
  2. 神经网络查询替代: 高斯球可作为加速结构的一部分

7.6 神经隐式表面表示

7.6.1 有符号距离场(SDF)

SDF是另一种隐式几何表示:

\[SDF(p) = \begin{cases} d(p, \partial\Omega) & p \notin \Omega -d(p, \partial\Omega) & p \in \Omega \end{cases}\]

其中 \(\Omega\) 是物体内部,\(\partial\Omega\) 是表面。

使用神经网络学习SDF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class NeuralSDF(nn.Module):
def __init__(self, hidden_dim=256):
super().__init__()
self.net = nn.Sequential(
nn.Linear(3, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, 1)
)

def forward(self, points):
return self.net(points)

7.6.2 从SDF到光线追踪

球体追踪(Sphere Tracing):

使用SDF进行光线追踪的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
vec3 sphereTrace(vec3 origin, vec3 direction, NeuralSDF& sdf) {
float t = 0.0;
const float MAX_DIST = 100.0;
const float EPSILON = 0.001;

for (int i = 0; i < MAX_STEPS; i++) {
vec3 p = origin + t * direction;

// 查询SDF
float d = sdf.query(p);

if (d < EPSILON) {
// 找到表面
return p;
}

if (t > MAX_DIST) {
// 超出最大距离
break;
}

// 沿光线前进
t += d;
}

return vec3(INFINITY);
}

7.6.3 神经SDF的优势

  1. 紧凑表示: 复杂几何用小网络表示
  2. 无限细节: 网络输出连续值,理论上无限分辨率
  3. 拓扑灵活: 无需处理网格拓扑

7.7 神经相交测试

7.7.1 学习射线-几何相交

更激进的方案是直接用神经网络学习相交测试:

\[f_{intersect}: (\mathbf{o}, \mathbf{d}) \rightarrow (t, \mathbf{n}, \text{material})\]

其中:

  • \((\mathbf{o}, \mathbf{d})\):光线起点和方向
  • \(t\):相交距离
  • \(\mathbf{n}\):交点法线
  • \(\text{material}\):材质ID或属性

7.7.2 实现方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class NeuralIntersection(nn.Module):
def __init__(self):
super().__init__()

# 编码光线
self.ray_encoder = nn.Sequential(
nn.Linear(6, 128), # origin(3) + direction(3)
nn.ReLU(),
nn.Linear(128, 256),
nn.ReLU()
)

# 预测相交参数
self.intersection_head = nn.Linear(256, 1) # t
self.normal_head = nn.Linear(256, 3) # normal
self.material_head = nn.Linear(256, 32) # material features

def forward(self, origin, direction):
ray_features = self.ray_encoder(
torch.cat([origin, direction], dim=-1)
)

t = self.intersection_head(ray_features)
normal = torch.tanh(self.normal_head(ray_features))
normal = F.normalize(normal, dim=-1)
material = self.material_head(ray_features)

return t, normal, material

7.7.3 挑战

  1. 精度问题: 神经网络难以保证精确的相交判断
  2. 边界情况: 擦边、切线相交等边界情况难处理
  3. 泛化性: 训练数据外的几何难以处理

7.8 混合方案

7.8.1 神经网络辅助BVH

保守但实用的方案是用神经网络辅助而非替代:

加速结构改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct HybridBVH {
// 传统BVH结构
BVHNode* nodes;
Triangle* triangles;

// 神经网络加速组件
NeuralSDF coarseGeometry; // 粗糙几何用于早期剔除
NeuralCache intersectionCache; // 相交结果缓存
};

bool hybridIntersect(HybridBVH& bvh, Ray ray, Hit& hit) {
// 使用神经SDF快速排除
float sdfDist = bvh.coarseGeometry.query(ray.origin);
if (sdfDist > ray.tmax) {
return false; // 确定不相交
}

// 检查缓存
CacheKey key = computeCacheKey(ray);
if (bvh.intersectionCache.lookup(key, hit)) {
return true;
}

// 传统BVH遍历
bool found = bvh.traverse(ray, hit);

// 更新缓存
if (found) {
bvh.intersectionCache.store(key, hit);
}

return found;
}

7.8.2 自适应表示

根据场景复杂度和性能需求选择表示方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
enum class GeometryRepresentation {
MESH, // 传统网格 + BVH
SDF, // 有符号距离场
NEURAL_SDF, // 神经SDF
GAUSSIAN, // 高斯球
NERF // NeRF
};

GeometryRepresentation selectRepresentation(
const Object& obj,
float performanceBudget
) {
// 根据物体特性选择最佳表示
if (obj.triangleCount > HIGH_POLY_THRESHOLD &&
performanceBudget < LOW_PERFORMANCE_THRESHOLD) {
return GeometryRepresentation::NEURAL_SDF;
}

if (obj.isComplexTopology) {
return GeometryRepresentation::GAUSSIAN;
}

return GeometryRepresentation::MESH;
}

7.9 实时神经渲染的挑战

7.9.1 推断速度

神经网络的推断速度是主要瓶颈:

方法 每像素查询次数 相对速度
传统光线追踪 1-10
NeRF 64-256
3D Gaussian Splatting 10-100 中等
Neural SDF 50-200 中等

优化策略:

  1. 网络压缩: 蒸馏、量化、剪枝
  2. 硬件加速: Tensor Core、专用硬件
  3. 缓存和近似: 空间时间缓存

7.9.2 训练数据需求

神经渲染方法通常需要大量训练数据:

方法 训练数据 训练时间
NeRF 100+视角照片 数小时
3DGS 100+视角照片 10-30分钟
Neural SDF 点云/网格 数小时

解决方案:

  • 预训练模型
  • 迁移学习
  • 合成数据训练

7.10 未来展望

7.10.1 研究方向

  1. 更快的推断: 研究更高效的网络架构
  2. 更好的泛化: 减少对训练数据的依赖
  3. 物理一致性: 确保满足物理约束
  4. 混合渲染: 结合传统方法和神经方法

7.10.2 工业应用前景

应用场景 适用技术 时间线
电影特效 NeRF/3DGS 已应用
虚拟现实 混合方法 1-3年
实时游戏 辅助神经组件 3-5年
全神经渲染 研究阶段 5-10年+

7.11 小结

本章探讨了用神经网络替代传统光线追踪组件的前沿方向:

  1. NeRF: 隐式辐射率场,适合场景重建
  2. 3D Gaussian Splatting: 显式高斯球,渲染效率较高
  3. Neural SDF: 隐式几何表示,可用于光线追踪
  4. 神经相交测试: 更激进但尚不成熟
  5. 混合方案: 实用性强,是近期的主要方向

这些技术仍在快速发展中,是未来图形学的重要研究方向。

下一章将提供实践指南和代码示例,帮助读者开始实际开发。


第八章:实践指南与代码示例

8.1 开发环境搭建

8.1.1 硬件要求

配置项 最低要求 推荐配置
GPU RTX 2060 或同等 RTX 3080 或更高
显存 6 GB 12 GB+
内存 16 GB 32 GB+
存储 50 GB SSD 500 GB NVMe SSD

为什么需要RTX显卡?

  • 硬件光线追踪加速(RT Cores)
  • Tensor Core用于神经网络推断
  • CUDA支持GPU计算

8.1.2 软件环境

基础开发环境:

1
2
3
4
5
6
7
8
9
10
11
12
# CUDA安装(建议11.7+)
# 从NVIDIA官网下载安装

# Python环境
conda create -n raytracing python=3.10
conda activate raytracing

# 核心依赖
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu117
pip install numpy scipy matplotlib
pip install glfw PyOpenGL
pip install tensorboard

图形API开发:

1
2
3
4
5
# Vulkan SDK(用于高性能渲染)
# 从 https://vulkan.lunarg.com/ 下载安装

# 或使用DirectX 12(Windows)
# 需要Windows 10 SDK

8.1.3 推荐的开发框架

框架 用途 链接
PyTorch3D 神经渲染 facebookresearch/pytorch3d
tiny-cuda-nn 快速神经网络 NVlabs/tiny-cuda-nn
Vulkan-Hpp 光线追踪API KhronosGroup/Vulkan-Hpp
OptiX NVIDIA光线追踪 NVIDIA OptiX SDK
Falcor 渲染研究框架 NVIDIA/Falcor

8.2 基础光线追踪器实现

8.2.1 最小化光线追踪器

以下是一个简化的Python光线追踪器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import numpy as np
from dataclasses import dataclass
from typing import Optional, Tuple

@dataclass
class Ray:
origin: np.ndarray
direction: np.ndarray

def at(self, t: float) -> np.ndarray:
return self.origin + t * self.direction

@dataclass
class HitRecord:
t: float
position: np.ndarray
normal: np.ndarray
material_id: int

class Sphere:
"""球体几何"""
def __init__(self, center: np.ndarray, radius: float, material_id: int = 0):
self.center = center
self.radius = radius
self.material_id = material_id

def intersect(self, ray: Ray, t_min: float = 0.001, t_max: float = float('inf')) -> Optional[HitRecord]:
"""光线-球体相交测试"""
oc = ray.origin - self.center
a = np.dot(ray.direction, ray.direction)
half_b = np.dot(oc, ray.direction)
c = np.dot(oc, oc) - self.radius * self.radius

discriminant = half_b * half_b - a * c
if discriminant < 0:
return None

sqrtd = np.sqrt(discriminant)

# 找最近的交点
root = (-half_b - sqrtd) / a
if root < t_min or root > t_max:
root = (-half_b + sqrtd) / a
if root < t_min or root > t_max:
return None

position = ray.at(root)
normal = (position - self.center) / self.radius

return HitRecord(
t=root,
position=position,
normal=normal,
material_id=self.material_id
)

class Scene:
"""简单场景"""
def __init__(self):
self.objects = []
self.materials = {}

def add_object(self, obj):
self.objects.append(obj)

def intersect(self, ray: Ray, t_min: float = 0.001, t_max: float = float('inf')) -> Optional[HitRecord]:
"""遍历所有物体找最近交点"""
closest_hit = None
closest_t = t_max

for obj in self.objects:
hit = obj.intersect(ray, t_min, closest_t)
if hit is not None:
closest_hit = hit
closest_t = hit.t

return closest_hit

def random_in_unit_sphere() -> np.ndarray:
"""在单位球内随机采样"""
while True:
p = np.random.uniform(-1, 1, 3)
if np.dot(p, p) < 1:
return p

def lambertian_scatter(normal: np.ndarray) -> np.ndarray:
"""Lambertian漫反射采样"""
return normal + random_in_unit_sphere()

def ray_color(ray: Ray, scene: Scene, depth: int, max_depth: int = 50) -> np.ndarray:
"""递归路径追踪"""
if depth <= 0:
return np.zeros(3)

hit = scene.intersect(ray)

if hit is None:
# 天空背景
t = 0.5 * (ray.direction[1] + 1.0)
return (1.0 - t) * np.ones(3) + t * np.array([0.5, 0.7, 1.0])

# 简单漫反射
scatter_dir = lambertian_scatter(hit.normal)
scattered = Ray(hit.position, scatter_dir)

# 递归追踪
return 0.5 * ray_color(scattered, scene, depth - 1, max_depth)

def render(scene: Scene, width: int, height: int, samples_per_pixel: int = 100) -> np.ndarray:
"""渲染图像"""
image = np.zeros((height, width, 3))

# 相机参数
aspect_ratio = width / height
viewport_height = 2.0
viewport_width = aspect_ratio * viewport_height
focal_length = 1.0

origin = np.zeros(3)
horizontal = np.array([viewport_width, 0, 0])
vertical = np.array([0, viewport_height, 0])
lower_left = origin - horizontal / 2 - vertical / 2 - np.array([0, 0, focal_length])

for j in range(height):
print(f"Rendering row {j+1}/{height}")
for i in range(width):
color = np.zeros(3)

for s in range(samples_per_pixel):
u = (i + np.random.random()) / (width - 1)
v = (j + np.random.random()) / (height - 1)

ray = Ray(
origin=origin,
direction=lower_left + u * horizontal + v * vertical - origin
)

color += ray_color(ray, scene, max_depth=50)

image[height - 1 - j, i] = np.sqrt(color / samples_per_pixel) # gamma校正

return image

# 使用示例
if __name__ == "__main__":
import matplotlib.pyplot as plt

# 创建场景
scene = Scene()
scene.add_object(Sphere(np.array([0, 0, -1]), 0.5)) # 主球
scene.add_object(Sphere(np.array([0, -100.5, -1]), 100)) # 地面

# 渲染
image = render(scene, width=400, height=300, samples_per_pixel=50)

# 显示
plt.imshow(image)
plt.axis('off')
plt.savefig('output.png')
plt.show()

8.3 DDGI实现框架

8.3.1 探针网格管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ddgi_volume.h
#pragma once

#include <vector>
#include <glm/glm.hpp>

struct DDGIProbe {
glm::vec3 position;
std::vector<glm::vec3> irradiance; // 八面体映射存储
std::vector<float> depth; // 深度信息
bool active;
};

class DDGIVolume {
public:
DDGIVolume(const glm::vec3& origin,
const glm::vec3& extents,
const glm::ivec3& probeCounts);

// 获取探针索引
int getProbeIndex(const glm::ivec3& coord) const;
glm::ivec3 getProbeCoord(int index) const;

// 采样辐照度
glm::vec3 sampleIrradiance(const glm::vec3& position,
const glm::vec3& normal) const;

// 更新探针
void updateProbe(int probeIndex,
const std::vector<glm::vec3>& rayDirections,
const std::vector<glm::vec3>& rayRadiance,
const std::vector<float>& rayDepths);

// 获取参数
const glm::vec3& getOrigin() const { return m_origin; }
const glm::vec3& getSpacing() const { return m_spacing; }
int getTotalProbeCount() const { return m_totalProbeCount; }

private:
glm::vec3 m_origin;
glm::vec3 m_extents;
glm::vec3 m_spacing;
glm::ivec3 m_probeCounts;
int m_totalProbeCount;

std::vector<DDGIProbe> m_probes;

// 八面体映射
glm::vec2 directionToOctahedral(const glm::vec3& dir) const;
glm::vec3 octahedralToDirection(const glm::vec2& oct) const;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// ddgi_volume.cpp
#include "ddgi_volume.h"
#include <cmath>
#include <algorithm>

DDGIVolume::DDGIVolume(const glm::vec3& origin,
const glm::vec3& extents,
const glm::ivec3& probeCounts)
: m_origin(origin)
, m_extents(extents)
, m_probeCounts(probeCounts)
{
m_spacing = extents / glm::vec3(probeCounts - 1);
m_totalProbeCount = probeCounts.x * probeCounts.y * probeCounts.z;

// 初始化探针
m_probes.resize(m_totalProbeCount);

for (int z = 0; z < probeCounts.z; z++) {
for (int y = 0; y < probeCounts.y; y++) {
for (int x = 0; x < probeCounts.x; x++) {
int index = getProbeIndex(glm::ivec3(x, y, z));

m_probes[index].position = origin + glm::vec3(x, y, z) * m_spacing;
m_probes[index].irradiance.resize(64, glm::vec3(0.0f)); // 8x8 atlas
m_probes[index].depth.resize(64, 1000.0f);
m_probes[index].active = true;
}
}
}
}

int DDGIVolume::getProbeIndex(const glm::ivec3& coord) const {
return coord.z * m_probeCounts.x * m_probeCounts.y +
coord.y * m_probeCounts.x +
coord.x;
}

glm::ivec3 DDGIVolume::getProbeCoord(int index) const {
int z = index / (m_probeCounts.x * m_probeCounts.y);
int remainder = index % (m_probeCounts.x * m_probeCounts.y);
int y = remainder / m_probeCounts.x;
int x = remainder % m_probeCounts.x;
return glm::ivec3(x, y, z);
}

glm::vec2 DDGIVolume::directionToOctahedral(const glm::vec3& dir) const {
glm::vec3 d = glm::normalize(dir);
glm::vec2 oct(d.x / (fabs(d.x) + fabs(d.y) + fabs(d.z)),
d.y / (fabs(d.x) + fabs(d.y) + fabs(d.z)));

if (d.z < 0.0f) {
oct = glm::vec2(
(1.0f - fabs(oct.y)) * (oct.x >= 0.0f ? 1.0f : -1.0f),
(1.0f - fabs(oct.x)) * (oct.y >= 0.0f ? 1.0f : -1.0f)
);
}

return oct * 0.5f + 0.5f;
}

glm::vec3 DDGIVolume::sampleIrradiance(const glm::vec3& position,
const glm::vec3& normal) const {
// 计算在网格中的位置
glm::vec3 localPos = (position - m_origin) / m_spacing;
glm::ivec3 lowerCoord = glm::ivec3(glm::floor(localPos));

// 三线性插值
glm::vec3 t = localPos - glm::vec3(lowerCoord);

glm::vec3 result(0.0f);
float totalWeight = 0.0f;

for (int i = 0; i < 8; i++) {
glm::ivec3 offset(i & 1, (i >> 1) & 1, (i >> 2) & 1);
glm::ivec3 coord = lowerCoord + offset;

// 边界检查
if (coord.x < 0 || coord.x >= m_probeCounts.x ||
coord.y < 0 || coord.y >= m_probeCounts.y ||
coord.z < 0 || coord.z >= m_probeCounts.z) {
continue;
}

int probeIndex = getProbeIndex(coord);
const DDGIProbe& probe = m_probes[probeIndex];

if (!probe.active) continue;

// 采样辐照度
glm::vec2 octCoord = directionToOctahedral(normal);
int texelIndex = int(octCoord.x * 7.99f) + int(octCoord.y * 7.99f) * 8;
glm::vec3 irradiance = probe.irradiance[texelIndex];

// 计算权重
glm::vec3 blendWeight = glm::mix(1.0f - t, t, glm::vec3(offset));
float trilinearWeight = blendWeight.x * blendWeight.y * blendWeight.z;

// 深度权重(防止漏光)
glm::vec3 toProbe = probe.position - position;
float distToProbe = glm::length(toProbe);
glm::vec3 dirToProbe = toProbe / distToProbe;

glm::vec2 probeOct = directionToOctahedral(-dirToProbe);
int depthIndex = int(probeOct.x * 7.99f) + int(probeOct.y * 7.99f) * 8;
float probeDepth = probe.depth[depthIndex];

float depthWeight = (distToProbe < probeDepth + 0.1f) ? 1.0f : 0.0f;

float weight = trilinearWeight * depthWeight;
result += irradiance * weight;
totalWeight += weight;
}

return totalWeight > 0.0f ? result / totalWeight : glm::vec3(0.0f);
}

8.4 神经辐射缓存PyTorch实现

8.4.1 Hash Grid Encoding实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
import torch
import torch.nn as nn
import numpy as np

class HashGridEncoding(nn.Module):
"""
多分辨率哈希网格编码
基于Instant-NGP论文实现
"""
def __init__(
self,
num_levels: int = 16,
feature_dim: int = 2,
log2_hashmap_size: int = 19,
base_resolution: int = 16,
finest_resolution: int = 2048,
):
super().__init__()

self.num_levels = num_levels
self.feature_dim = feature_dim
self.log2_hashmap_size = log2_hashmap_size
self.hashmap_size = 2 ** log2_hashmap_size

# 计算每层的分辨率
b = np.exp((np.log(finest_resolution) - np.log(base_resolution)) / (num_levels - 1))
self.resolutions = [int(base_resolution * (b ** i)) for i in range(num_levels)]

# 初始化哈希表
self.embeddings = nn.ModuleList([
nn.Embedding(self.hashmap_size, feature_dim)
for _ in range(num_levels)
])

# 初始化权重
for emb in self.embeddings:
nn.init.uniform_(emb.weight, -1e-4, 1e-4)

# 哈希参数
self.primes = torch.tensor([1, 2654435761, 805459861], dtype=torch.int64)

def hash_function(self, coords: torch.Tensor) -> torch.Tensor:
"""
空间哈希函数
coords: (N, 3) int64
"""
result = coords[:, 0] * self.primes[0]
result ^= coords[:, 1] * self.primes[1]
result ^= coords[:, 2] * self.primes[2]
return result % self.hashmap_size

def forward(self, positions: torch.Tensor) -> torch.Tensor:
"""
positions: (N, 3) float32, 在[0, 1]范围内
返回: (N, num_levels * feature_dim)
"""
batch_size = positions.shape[0]
device = positions.device

# 移动primes到正确设备
primes = self.primes.to(device)

features = []

for level in range(self.num_levels):
resolution = self.resolutions[level]

# 计算体素坐标
scaled_pos = positions * resolution
voxel_coords = torch.floor(scaled_pos).long()
frac = scaled_pos - voxel_coords.float()

# 获取8个角点的哈希值
corner_coords = []
for i in range(8):
offset = torch.tensor([
i & 1,
(i >> 1) & 1,
(i >> 2) & 1
], device=device)
corner_coords.append(voxel_coords + offset)

# 计算哈希值
hash_indices = []
for coords in corner_coords:
hash_val = coords[:, 0] * primes[0]
hash_val = hash_val ^ (coords[:, 1] * primes[1])
hash_val = hash_val ^ (coords[:, 2] * primes[2])
hash_val = hash_val % self.hashmap_size
hash_indices.append(hash_val)

# 查询嵌入
corner_features = []
for hash_idx in hash_indices:
feat = self.embeddings[level](hash_idx)
corner_features.append(feat)

# 三线性插值
c000, c001, c010, c011, c100, c101, c110, c111 = corner_features

# 沿x插值
c00 = c000 * (1 - frac[:, 0:1]) + c100 * frac[:, 0:1]
c01 = c001 * (1 - frac[:, 0:1]) + c101 * frac[:, 0:1]
c10 = c010 * (1 - frac[:, 0:1]) + c110 * frac[:, 0:1]
c11 = c011 * (1 - frac[:, 0:1]) + c111 * frac[:, 0:1]

# 沿y插值
c0 = c00 * (1 - frac[:, 1:2]) + c10 * frac[:, 1:2]
c1 = c01 * (1 - frac[:, 1:2]) + c11 * frac[:, 1:2]

# 沿z插值
c = c0 * (1 - frac[:, 2:3]) + c1 * frac[:, 2:3]

features.append(c)

return torch.cat(features, dim=-1)


class NeuralRadianceCache(nn.Module):
"""
神经辐射缓存网络
"""
def __init__(
self,
num_levels: int = 6,
feature_dim: int = 2,
hidden_dim: int = 64,
):
super().__init__()

# 位置编码
self.position_encoder = HashGridEncoding(
num_levels=num_levels,
feature_dim=feature_dim,
)

# 输入维度: Hash特征 + 方向(3) + 法线(3)
input_dim = num_levels * feature_dim + 6

# MLP
self.mlp = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.LeakyReLU(0.01),
nn.Linear(hidden_dim, hidden_dim),
nn.LeakyReLU(0.01),
nn.Linear(hidden_dim, hidden_dim),
nn.LeakyReLU(0.01),
nn.Linear(hidden_dim, 3), # RGB辐射率
)

def forward(
self,
position: torch.Tensor,
direction: torch.Tensor,
normal: torch.Tensor,
) -> torch.Tensor:
"""
position: (N, 3) 归一化到[0, 1]
direction: (N, 3) 单位向量
normal: (N, 3) 单位向量
返回: (N, 3) RGB辐射率
"""
# 编码位置
pos_encoded = self.position_encoder(position)

# 合并输入
features = torch.cat([pos_encoded, direction, normal], dim=-1)

# MLP推断
radiance = self.mlp(features)

# ReLU确保非负
radiance = torch.relu(radiance)

return radiance


class RelativeL2Loss(nn.Module):
"""相对L2损失函数"""
def __init__(self, epsilon: float = 0.01):
super().__init__()
self.epsilon = epsilon

def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
relative_error = (pred - target) / (target + self.epsilon)
return torch.mean(relative_error ** 2)


# 训练示例
def train_nrc():
# 创建模型
model = NeuralRadianceCache()
loss_fn = RelativeL2Loss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

# 模拟训练数据
num_samples = 10000
positions = torch.rand(num_samples, 3) # 归一化位置
directions = torch.randn(num_samples, 3)
directions = directions / directions.norm(dim=-1, keepdim=True)
normals = torch.randn(num_samples, 3)
normals = normals / normals.norm(dim=-1, keepdim=True)

# 目标辐射率(模拟)
target_radiance = torch.rand(num_samples, 3) * 2.0 # HDR范围

# 训练循环
for epoch in range(100):
optimizer.zero_grad()

pred_radiance = model(positions, directions, normals)
loss = loss_fn(pred_radiance, target_radiance)

loss.backward()
optimizer.step()

if epoch % 10 == 0:
print(f"Epoch {epoch}, Loss: {loss.item():.6f}")

return model


if __name__ == "__main__":
model = train_nrc()
print("训练完成!")

8.5 学习资源推荐

8.5.1 经典论文

光线追踪基础:

  • Kajiya, J. T. (1986). "The Rendering Equation"
  • Whitted, T. (1980). "An Improved Illumination Model for Shaded Display"

全局光照:

  • Jensen, H. W. (1996). "Global Illumination using Photon Maps"
  • Veach, E. (1997). "Robust Monte Carlo Methods for Light Transport Simulation"

神经渲染:

  • Mildenhall et al. (2020). "NeRF: Representing Scenes as Neural Radiance Fields"
  • Müller et al. (2021). "Instant-NGP: Instant Neural Graphics Primitives"
  • Müller et al. (2021). "Neural Radiance Caching"

DDGI:

  • Majercik et al. (2019). "Dynamic Diffuse Global Illumination with Ray-Traced Irradiance Fields"

8.5.2 开源项目

项目 说明 链接
tinyrenderer 软件渲染器教学 ssloy/tinyrenderer
Ray Tracing in One Weekend 经典教程 RayTracing/raytracing.github.io
instant-ngp NVIDIA NeRF实现 NVlabs/instant-ngp
PyTorch3D 神经渲染框架 facebookresearch/pytorch3d
Falcor 渲染研究框架 NVIDIA/Falcor

8.5.3 进阶书籍

  1. Physically Based Rendering (PBRT) - Matt Pharr, Wenzel Jakob, Greg Humphreys
  2. Real-Time Rendering (4th Edition) - Tomas Akenine-Möller et al.
  3. Ray Tracing Gems I & II - NVIDIA

8.6 研究方向建议

8.6.1 初学者建议

阶段一:基础(1-3个月)

  • 实现简单的光线追踪器
  • 理解渲染方程和蒙特卡洛积分
  • 学习基本的BRDF模型

阶段二:进阶(3-6个月)

  • 实现路径追踪
  • 学习重要性采样和MIS
  • 了解传统GI方法

阶段三:前沿(6-12个月)

  • 研究DDGI实现
  • 学习神经渲染基础
  • 阅读最新论文

8.6.2 可能的研究课题

  1. DDGI优化:
  • 自适应探针放置
  • 更好的深度防漏光算法
  • 与神经网络的结合
  1. 神经渲染:
  • 实时NRC推断优化
  • NIV的光环境适应
  • 混合渲染管线设计
  1. 前沿探索:
  • 神经网络替代BVH
  • 3D Gaussian Splatting与光线追踪结合
  • 神经SDF渲染

8.7 小结

本章提供了实践开发的起点:

  1. 开发环境: 硬件要求、软件配置、推荐框架
  2. 基础实现: 简化的光线追踪器Python代码
  3. DDGI框架: C++核心结构示例
  4. 神经渲染: PyTorch实现的Hash Grid和NRC
  5. 学习资源: 论文、项目、书籍推荐
  6. 研究方向: 循序渐进的学习路径

希望这份教材能帮助你进入光线追踪和神经渲染的精彩世界!


附录:术语表

术语 英文 解释
光线追踪 Ray Tracing 通过模拟光线传播进行渲染的技术
全局光照 Global Illumination (GI) 考虑光线多次反弹的完整光照计算
辐射率 Radiance 单位投影面积单位立体角的辐射通量
辐照度 Irradiance 单位面积接收的辐射通量
BRDF Bidirectional Reflectance Distribution Function 双向反射分布函数,描述材质反射特性
渲染方程 Rendering Equation 描述光能平衡的积分方程
路径追踪 Path Tracing 使用蒙特卡洛方法求解渲染方程
探针 Probe 存储空间点光照信息的数据结构
DDGI Dynamic Diffuse Global Illumination 动态漫反射全局光照技术
NRC Neural Radiance Caching 神经辐射缓存
NIV Neural Irradiance Volume 神经辐照度体
NeRF Neural Radiance Fields 神经辐射场
SDF Signed Distance Field 有符号距离场
BVH Bounding Volume Hierarchy 层次包围盒加速结构
SH Spherical Harmonics 球面调和函数