基于辐射场的体积渲染#
路径追踪器与NeRF的光线逻辑不同#
路径追踪光线#
在路径追踪器里,光线在场景里的行为是离散事件序列——光线要么打中一个表面(材质分类、重要性采样、算attenuation),要么打中光源(自发光计入终止),要么啥都没打中(背景纯黑,直接break)。每次hit都是一个二元判定(hit or miss),然后在hit点做完材质计算后,光线继续弹射(recursion/俄罗斯轮盘赌控制深度)。
NeRF光线#
NeRF的光线完全不弹射,就是相机发出的一条直线,笔直穿过一段空间,不会因采样改变方向。但是作为交换,NeRF认为光线路径上的每一个点都有一个连续的体积密度 𝜎(x),可以认为是光线在位置 x 处终止的微分概率。
从物理模型推导渲染方程#
我们假设光线沿方向前进时,辐射亮度 L 的变化只受两件事影响(NeRF简化模型,不考虑光线被其他方向散射进来,只考虑吸收和自发光):
- 吸收:走过一小段距离 dt,光线有一定概率消失,消失率正比于密度 σ
- 自发光:这一小段本身也会往外发光,贡献正比于密度 σ 乘上这一点的颜色 c
写成微分方程:
dtdL=−σ(t)L(t)+σ(t)c(t)求解微方方程可得:
L(t)=∫tntT(s)σ(s)c(s)ds其中:
T(t)=exp(−∫tntσ(s)ds)T(t)这一项,就是沿途累积的 attenuation 的连续版本,物理意义:光线从起点走到t这个位置,一路上没被吸收的概率(或是剩余能量比例)。
而整个积分,便是把路径上每一点的自发光 c(s) 按这一点本身多大权重发光 σ(s) 和还有多少能到达相机 T(s) 相乘,得到这条光线上累加每次的衰减得到最终颜色,只不过相较于路径追踪,这里从离散求和变成了连续积分。
积分离散化#
分层采样(stratified sampling)#
为什么不能像普通黎曼积分那样均匀切分区间?因为论文作者发现如果每次训练用的采样点位置都固定不变,网络的分辨率会被这个固定的离散网格限制死,因为其学不会空间中任意点的值。
而分层采样便是先把 [tn,tf]均匀分成 N 个小区间,但在每个小区间内部随机取一个点,公式为
ti∼U[tn+Ni−1(tf−tn),tn+Ni(tf−tn)]每次训练迭代,具体的采样位置都不一样,这就迫使网络必须学会空间里任意连续位置的值,而不是只记住几个固定网格点。这根 TAA 里面对像素划分成 N 份,每个子像素采样会进行随机抖动几乎差不多。
离散求和公式#
有了这 N 个采样点 {t1,...,tN},积分被近似成:
C^(r)=i=1∑NTi(1−exp(−σiδi))ci其中:
Ti=exp(−j=1∑i−1σjδj)δi=ti+1−ti 是相邻采样点的间距。其中每一项的物理含义:
-
Ti:透过率的离散化,也就是把从起点到第 i 个点之前,所有段的衰减连续相乘。注意这里是累乘(数学上等价于把 exp 里的求和展开),这和路径追踪器里每次 hit 更新一次
attenuation *= ...的写法在结构上几乎一模一样 -
(1−exp(−σiδi)):这一项不是直接用 σi,而是第 i 段的局部不透明度。当 σiδi 很小时(密度低或者段很短),这一项约等于 σiδi(泰勒展开一阶近似),退化回和积分公式几乎一致的形式;当 σi 很大时(比如撞上了实心物体表面),这一项趋近于 1,表示这一段几乎完全不透明,光线走到这基本完全挡住了,这其实就是比尔-朗伯定律的直接推论
-
ci:这个点网络预测出的颜色,直接加权累加进最终颜色
整个公式可以这样理解:从相机出发,沿着这条光线走过 N 个采样点,每个点对最终颜色的贡献 = 光线到该点之前还剩多少能量(Ti) × 该点本身有多不透明(1−exp(−σiδi)) × 该点颜色(ci),把这 N 个点的贡献加起来就是这条光线最终呈现的颜色。
与蒙特卡洛积分的关系#
看整个离散化公式,采样点的选取用了蒙特卡洛的分层采样策略,但一旦采样点确定了,公式内部的求和是解析、确定性的,也就是说只有一层随机。但是路径追踪里发射光线随机,每次hit后也要产生一个新随机数来决定下一项,两者有明显区别。然而该离散公式仍然具有类似蒙特卡洛方法的无偏性,只要采样点足够多足够密,会收敛到真实积分。
代码实现#
这是一个测试代码,包含离散求和公式的实现与验证
点击展开代码
1import torch2import matplotlib.pyplot as plt3
4plt.rcParams['font.sans-serif'] = ['SimHei'] # 用来正常显示中文5plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号6
7device = torch.device("cuda" if torch.cuda.is_available() else "cpu")8
9# --------- 分层采样 -------------10def stratified_sample(t_n, t_f, N, device):11 # 返回:shape(N,)的采样点t值12 bin_edges = torch.linspace(t_n, t_f, N + 1, device=device) # 等间距生成 N + 1 个点,总bin数为N,shape(N+1,)13 bin_starts = bin_edges[:-1] # 每个 bin 的左边界14 bin_ends = bin_edges[1:] # 每个 bin 的有边界15
16 rand_offsets = torch.rand(N, device=device) # shape(N,)17 t_samples = bin_starts + rand_offsets * (bin_ends - bin_starts)18 return t_samples19
20
21# 验证一下:采样点应该都落在[t_n, t_f]内,且是递增的22t_n, t_f = 2.0, 6.023N = 824t_samples = stratified_sample(t_n, t_f, N, device)25print("采样点:", t_samples)26print("是否递增:", torch.all(t_samples[1:] > t_samples[:-1]).item())27print("是否都在范围内:", (t_samples.min() >= t_n) and (t_samples.max() <= t_f))28
29# --------- 假想场景:sigma(t)、color(t),模拟一团位于t=4附近的雾 ----------30def toy_sigma(t):31 # 用高斯函数模拟密度:在t=4附近密度最高32 return 8.0 * torch.exp(-0.5 * ((t - 4.0) / 0.5) ** 2)33
34def toy_color(t):35 # 颜色暂且固定为红色36 N = t.shape[0]37 color = torch.zeros(N, 3, device=t.device)38 color[:, 0] = 1.039 return color40
41# ------------ 体渲染积分 -------------42def volume_render(t_sample, sigma, color):43 """44 t_samples: (N,) 采样点位置,沿光线递增45 sigma: (N,) 每个采样点的密度46 color: (N, 3) 每个采样点的颜色47 返回: 最终颜色 (3,), 以及每个采样点的权重 T_i*alpha_i (N,),方便调试可视化48 """49 N = t_sample.shape[0]50
51 # 计算相邻采样点间距 delta_i = t_{i+1} - t_i52 # 最后一个点没有下一个点,论文做法是给它一个很大的delta(近似当作趋于无穷远)53 delta = t_sample[1:] - t_sample[:-1]54 delta_last = torch.tensor([1e10], device=t_sample.device) # 最后一点接近无穷55 delta = torch.cat([delta, delta_last], dim=0)56
57 # alpha_i = 1 - exp(-sigma_i * delta_i)58 alpha = 1.0 - torch.exp(-delta * sigma)59
60 # T_i = exp(-sum_{j<i} sigma_j * delta_j)61 # 注意T_1永远是1(还没经过任何衰减)62 sigma_delta = sigma * delta # shape (N,)63 # torch.cumsum算累加和;我们要的是"严格小于i"的累加,所以要在前面手动插入一个0再去掉最后一项64 accumulated = torch.cumsum(sigma_delta, dim=0)65 accumulated_exclusive = torch.cat([66 torch.zeros(1, device=t_sample.device),67 accumulated[:-1]68 ]) # shape (N,),第i项变成sum_{j<i},第0项是069
70 T = torch.exp(-accumulated_exclusive)71
72 weights = T * alpha # shape (N,),这就是每个采样点对最终颜色的"权重"73
74 # 最终颜色 = sum(weights_i * color_i)75 final_color = torch.sum(weights.unsqueeze(1) * color, dim=0) # shape (3,)76 return final_color, weights77
78# -------- 运行验证结果 ------------79sigma_vals = toy_sigma(t_samples) # (N,)80color_vals = toy_color(t_samples) # (N, 3)81
82final_color, weights = volume_render(t_samples, sigma_vals, color_vals)83print("\n采样点t:", t_samples.cpu().numpy())84print("对应密度sigma:", sigma_vals.cpu().numpy())85print("每个点的权重(T_i*alpha_i):", weights.cpu().numpy())86print("权重之和(应该<=1):", weights.sum().item())87print("最终渲染颜色 (R,G,B):", final_color.cpu().numpy())88
89# ---------- 用更密集的采样点,画出sigma、T、weight随t变化的曲线,直观验证物理意义 ----------90N_dense = 20091t_dense = torch.linspace(t_n, t_f, N_dense, device=device)92sigma_dense = toy_sigma(t_dense)93color_dense = toy_color(t_dense)94_, weights_dense = volume_render(t_dense, sigma_dense, color_dense)95
96# 手动重新算一遍T用于画图(前面volume_render没有单独返回T,这里重新提取逻辑画图用)97delta_dense = t_dense[1:] - t_dense[:-1]98delta_dense = torch.cat([delta_dense, torch.tensor([1e10], device=device)])99sigma_delta_dense = sigma_dense * delta_dense100accumulated_dense = torch.cumsum(sigma_delta_dense, dim=0)101accumulated_excl_dense = torch.cat([torch.zeros(1, device=device), accumulated_dense[:-1]])102T_dense = torch.exp(-accumulated_excl_dense)103
104t_np = t_dense.cpu().numpy()105sigma_np = sigma_dense.cpu().numpy()106T_np = T_dense.cpu().numpy()107weights_np = weights_dense.cpu().numpy()108
109fig, axes = plt.subplots(1, 3, figsize=(15, 4))110axes[0].plot(t_np, sigma_np)111axes[0].set_title("sigma(t) — 密度分布(假想的雾)")112axes[0].set_xlabel("t")113
114axes[1].plot(t_np, T_np)115axes[1].set_title("T(t) — 透过率,应单调递减")116axes[1].set_xlabel("t")117
118axes[2].plot(t_np, weights_np)119axes[2].set_title("weight(t) = T*alpha — 每点对最终颜色的贡献")120axes[2].set_xlabel("t")121
122plt.tight_layout()123plt.savefig("volume_render_toy.png", dpi=120)124plt.show()125print("\n图已保存为 volume_render_toy.png")在终端你应该会看见如下输出:
1样点: tensor([2.4819, 2.7023, 3.2912, 3.8842, 4.3187, 4.6949, 5.3758, 5.9204],2 device='cuda:0')3是否递增: True4是否都在范围内: tensor(True, device='cuda:0')5
6采样点t: [2.481948 2.702264 3.2912261 3.8841789 4.318664 4.694871 5.37575157 5.920372 ]8对应密度sigma: [7.9697035e-02 2.7560255e-01 2.9291751e+00 7.7882209e+00 6.5296149e+009 3.0457594e+00 1.8160109e-01 5.0105201e-03]10每个点的权重(T_i*alpha_i): [1.7405272e-02 1.4722256e-01 6.8828648e-01 1.4209706e-01 4.5608659e-0311 3.7393314e-04 5.0630360e-06 4.8701768e-05]12权重之和(应该<=1): 0.999999940395355213最终渲染颜色 (R,G,B): [0.99999994 0. 0. ]同时得到如下实验结果图:
sigma、T、weight随t变化的曲线
NOTE右图
weight(t)应该也是一个类似钟形但比sigma更尖、峰值在sigma峰值稍靠前一点的曲线——这是因为权重是T(还没被完全吸收)和alpha(这一点本身吸收多少)的乘积,在雾团中心之前T还很高、alpha开始变大,两者相乘在峰值前达到最大,过了峰值之后虽然alpha还大,但T已经掉得很低了
网络结构#
论文里提到:尽管神经网络理论上是万能函数近似器,但让网络直接处理xyzθφ这种原始坐标,渲染出来的高频细节(颜色和几何上的高频变化)表现很差,这是因为深度网络天生偏向学习低频函数(谱偏差),如果先用高频函数把输入映射到更高维空间再喂给网络,能显著改善对高频数据的拟合能力。
分层体积采样(Hierarchical sampling)#
损失函数和训练细节#
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
