资讯动态

NeRF神经辐射场论文精读:从体渲染原理到三维重建复现实战

发布时间:2026/10/20 17:22:49 来源:尧图企业网站定制
1. 为什么NeRF值得每一个做三维视觉的人花时间啃第一次接触NeRFNeural Radiance Fields神经辐射场是在一个三维重建的课题里当时手头有一组多视角拍摄的照片传统SfM加MVS的流程跑出来的点云在纹理稀疏区域和反光表面上惨不忍睹空洞、噪点、边缘毛刺全来了。后来有人丢了一篇NeRF的论文过来说“你试试这个”我花了一个周末把论文啃完、代码跑通看到渲染出来的新视角图像时确实被震住了——那种对光照、反射、半透明材质的还原能力传统管线根本做不到。这篇内容就是围绕NeRF这篇论文做一次系统性的解读。我会从论文的核心思想讲起把位置编码、体渲染、分层采样、优化策略这些关键设计逐一拆开再结合我自己复现和调参过程中踩过的坑给出可直接参考的实操建议。适合已经有一定深度学习基础、想做三维重建或新视角合成方向的人阅读也适合刚入门NeRF、看论文时被公式绕晕的朋友拿来对照理解。NeRF解决的核心问题其实很朴素给定一组围绕某个场景拍摄的多视角图像能不能合成出任意新视角下的图像传统方法要么依赖显式的三维几何表示点云、网格、体素要么依赖图像域的插值前者在细节和材质表现上受限后者在视角变化大时直接崩掉。NeRF的思路是换一条路——不显式重建几何而是用一个多层感知机MLP把整个场景“记”在网络权重里通过体渲染的方式从任意视角查询颜色和密度最终合成图像。这个思路之所以work关键在于它把“场景表示”和“渲染”统一到了一个可微的框架里端到端优化。下面我按论文的逻辑顺序把每个关键模块拆开讲。2. 论文核心思路拆解用MLP记住一个场景2.1 从“重建几何”到“学习辐射场”的范式转换传统三维重建的套路是先从图像里估计相机位姿然后三角化出稀疏点云再做稠密重建得到网格或体素最后贴纹理。这条链路每一步都有误差累积而且显式几何表示的分辨率是固定的——体素网格再细也有离散化的极限网格面数再多也拟合不了体积雾、毛发边缘这类非刚性结构。NeRF的范式转换在于它不重建任何显式几何而是学习一个连续函数。这个函数的输入是一个三维空间坐标x (x, y, z)和一个观察方向d (θ, φ)输出是该点的体密度σ和该方向上的颜色c (r, g, b)。用数学语言写出来就是F_Θ: (x, d) → (c, σ)其中 Θ 是MLP的参数。体密度 σ 只跟位置 x 有关颜色 c 跟位置和方向都有关。这个设计很关键——密度是几何属性不应该随观察方向变化颜色是外观属性镜面反射、高光这些效果必须依赖观察方向才能表现。我第一次看到这个设计时觉得挺巧妙但也没觉得多惊艳直到跑通代码看到渲染结果才意识到这个MLP实际上是在用网络权重“压缩”整个场景的信息。一个场景可能有几十万到几百万个有效采样点全部编码进一个几层深的MLP里参数量也就几MB这种隐式表示的紧凑程度是显式方法比不了的。2.2 体渲染把MLP的输出变成像素有了辐射场函数怎么从它渲染出一张图像这里用的是经典体渲染公式。对于一条从相机原点 o 出发、方向为 d 的射线 r(t) o t·d像素颜色 C(r) 是沿射线积分得到的C(r) ∫[t_n, t_f] T(t) · σ(r(t)) · c(r(t), d) dt其中 T(t) 是从 t_n 到 t 的累积透射率T(t) exp(-∫[t_n, t] σ(r(s)) ds)这个公式的物理含义很直观沿射线走每个位置都有一个密度 σ密度越大越可能“撞到”东西透射率 T 就是光线走到 t 处还没被遮挡的概率。最终颜色是所有采样点颜色的加权和权重是“走到这里还没被挡住、且在这里被挡住”的概率。实际实现时不可能做连续积分论文用的是分层采样加离散求和。把 [t_n, t_f] 分成 N 个区间在每个区间内均匀采样一个点然后Ĉ(r) Σ_i T_i · (1 - exp(-σ_i · δ_i)) · c_i其中 δ_i 是相邻采样点的间距T_i exp(-Σ_{ji} σ_j · δ_j)。这个离散化形式在代码里就是一个累加循环非常好实现。注意这里的 α_i 1 - exp(-σ_i · δ_i) 就是alpha合成里的alpha值整个体渲染本质上就是沿射线的alpha合成。理解这一点之后代码里那些累乘、累加的写法就很好懂了。2.3 位置编码让MLP能拟合高频细节如果直接把 (x, y, z) 坐标喂给MLP会发现渲染出来的图像非常模糊高频细节全丢了。论文里专门讨论了这个问题神经网络天然倾向于学习低频函数这是谱偏置spectral bias导致的。而三维场景的几何和纹理细节恰恰是高频信号。解决方案是位置编码positional encoding把每个坐标值映射到一组正弦函数上γ(p) (sin(2^0 π p), cos(2^0 π p), sin(2^1 π p), cos(2^1 π p), ..., sin(2^{L-1} π p), cos(2^{L-1} π p))对于位置 x 用 L10对于方向 d 用 L4。这样每个标量被映射成 2L 维向量位置输入从3维变成60维方向从2维变成24维。这个操作把低频输入“抬”到高维空间让MLP在高维空间里更容易拟合高频变化。我自己的理解是这相当于给MLP提供了一组不同频率的“基函数”网络只需要学习这些基函数的组合系数而不是从零开始拟合高频曲线。实测下来去掉位置编码PSNR直接掉好几个dB图像糊得没法看。2.4 分层采样把采样点用在刀刃上均匀采样有个问题场景里大部分空间是空的均匀撒点会浪费大量采样在无意义的区域。论文提出了分层采样策略同时训练两个网络一个“粗”网络用 N_c 个均匀采样点一个“细”网络用 N_f 个采样点但细网络的采样点不是均匀分布的而是根据粗网络输出的权重分布来采样。具体做法是把粗网络输出的权重 w_i 归一化成概率分布然后在这个分布下做逆变换采样把细网络的采样点集中在密度高的区域。这样既保证了覆盖又把计算资源集中在真正有物体的地方。这个设计在实现上有个细节粗网络和细网络可以是同一个MLP只是输入采样点不同也可以是两个独立的MLP。论文用的是后者参数量翻倍但效果更好。实际复现时如果显存吃紧可以先用共享权重的版本跑通再换独立版本。3. 网络结构与训练细节论文里没写透的那些事3.1 MLP的具体架构与维度设计论文里的MLP结构不算复杂但有几个设计选择值得说清楚。位置编码后的输入是60维位置 24维方向但方向不是一开始就拼进去的。网络先处理位置输入经过8层全连接每层256维ReLU激活在第5层输出一个256维特征向量同时接一个分支输出密度 σ经过ReLU保证非负。然后把第5层的特征和方向编码拼接再经过一层128维全连接输出RGB颜色经过Sigmoid保证在[0,1]。这个“先位置后方向”的设计是有讲究的密度只依赖位置所以应该在方向信息加入之前就输出颜色依赖方向所以要在方向信息加入之后输出。如果反过来密度就会受到方向影响违反物理直觉训练也会不稳定。我在复现时试过把方向和位置一开始就拼接结果训练loss震荡明显收敛慢了很多。后来改回论文的结构训练曲线就平滑了。这个细节论文里只是一笔带过但实际影响不小。3.2 损失函数与优化配置损失函数很直接就是渲染颜色和真实颜色的均方误差L Σ_r ||Ĉ_c(r) - C(r)||² ||Ĉ_f(r) - C(r)||²粗网络和细网络的输出都参与loss计算这样粗网络的权重分布才能被有效监督。优化器用Adam学习率从5e-4开始指数衰减到5e-5。论文里还提到对 σ 加了一点噪声来正则化但我在复现时发现这个噪声对最终效果影响不大可以省略。Batch size方面论文用的是4096条射线每条射线粗采样64个点、细采样128个点。这个配置在单卡上跑起来显存占用大概8-10GB如果显存不够可以减半但收敛会慢一些。实操心得学习率衰减的步数设置很关键。论文里是在500k次迭代内衰减但实际训练时如果场景简单200k次就收敛了如果场景复杂可能需要800k次以上。建议先用小分辨率比如1/4分辨率快速试跑确认收敛趋势后再上全分辨率。3.3 相机位姿与坐标系约定NeRF假设相机位姿是已知的通常用COLMAP或类似工具做SfM得到。这里有个容易踩的坑不同工具输出的坐标系约定不一样有的用OpenGL约定相机看向-Z有的用OpenCV约定相机看向Z。如果坐标系搞反了训练出来的结果会完全错乱。我的做法是先用一个简单场景比如一个立方体验证坐标系渲染一张已知视角的图像看和真实图像是否对齐。对齐了再上复杂场景。这个验证步骤花不了多少时间但能省掉后面几小时的无效训练。另外NeRF的射线生成是在归一化坐标系里做的通常把场景缩放到一个单位球内。这个归一化参数near和far需要根据场景的实际尺度调整设得太小会截断物体设得太大采样效率低。4. 从零复现完整流程与关键代码解析4.1 数据准备与相机参数处理假设你已经用SfM工具得到了一组图像和对应的相机内外参。数据准备阶段需要做几件事第一把所有图像缩放到统一分辨率。论文用的是800x800但实际可以根据显存调整。我一般先用400x400跑通流程再上800x800。第二解析相机参数。每张图像对应一个相机到世界的变换矩阵extrinsics和焦距、主点intrinsics。把这些参数整理成一个列表每条记录包含图像路径、变换矩阵、焦距。第三计算场景的归一化参数。把所有相机中心的位置取平均作为场景中心计算相机中心到场景中心的最大距离用这个距离来缩放场景使得所有相机都在单位球附近。import numpy as np def normalize_scene(c2w_list): # c2w_list: list of 4x4 camera-to-world matrices centers np.array([c2w[:3, 3] for c2w in c2w_list]) scene_center centers.mean(axis0) max_dist np.linalg.norm(centers - scene_center, axis1).max() scale 1.0 / max_dist return scene_center, scale这个归一化步骤很重要它保证了不同场景的尺度一致网络不需要额外学习尺度变化。4.2 射线生成与采样实现射线生成的核心是对于每个像素计算它在相机坐标系下的方向然后变换到世界坐标系。代码大概长这样def get_rays(H, W, focal, c2w): i, j np.meshgrid(np.arange(W), np.arange(H), indexingxy) dirs np.stack([(i - W * 0.5) / focal, -(j - H * 0.5) / focal, -np.ones_like(i)], axis-1) rays_d np.sum(dirs[..., None, :] * c2w[:3, :3], axis-1) rays_o np.broadcast_to(c2w[:3, 3], rays_d.shape) return rays_o, rays_d注意这里的-np.ones_like(i)对应的是OpenGL约定相机看向-Z。如果你用的SfM工具输出的是OpenCV约定这里要改成np.ones_like(i)。采样部分粗采样是在 [near, far] 区间内均匀取 N_c 个点然后加上一点随机扰动训练时加推理时不加。细采样是根据粗网络的权重做逆变换采样def sample_pdf(bins, weights, N_samples): weights weights 1e-5 pdf weights / weights.sum(-1, keepdimsTrue) cdf np.cumsum(pdf, axis-1) cdf np.concatenate([np.zeros_like(cdf[..., :1]), cdf], axis-1) u np.random.uniform(sizelist(cdf.shape[:-1]) [N_samples]) inds np.searchsorted(cdf, u, sideright) below np.maximum(0, inds - 1) above np.minimum(cdf.shape[-1] - 1, inds) cdf_g0 np.take_along_axis(cdf, below, axis-1) cdf_g1 np.take_along_axis(cdf, above, axis-1) bins_g0 np.take_along_axis(bins, below, axis-1) bins_g1 np.take_along_axis(bins, above, axis-1) denom cdf_g1 - cdf_g0 denom np.where(denom 1e-5, np.ones_like(denom), denom) t (u - cdf_g0) / denom samples bins_g0 t * (bins_g1 - bins_g0) return samples这段逆变换采样的代码是NeRF实现里最容易写错的地方建议直接参考官方实现不要自己从头写。4.3 体渲染累加与损失计算体渲染的累加过程在代码里就是一个循环def render_rays(rays_o, rays_d, near, far, network, N_c64, N_f128): # coarse sampling t_c np.linspace(near, far, N_c) t_c t_c np.random.uniform(sizet_c.shape) * (far - near) / N_c pts_c rays_o[..., None, :] rays_d[..., None, :] * t_c[..., :, None] sigma_c, rgb_c network(pts_c, rays_d) dists_c t_c[..., 1:] - t_c[..., :-1] dists_c np.concatenate([dists_c, 1e9 * np.ones_like(dists_c[..., :1])], axis-1) alpha_c 1.0 - np.exp(-sigma_c * dists_c) weights_c alpha_c * np.cumprod(1.0 - alpha_c 1e-10, axis-1) # fine sampling t_f sample_pdf(t_c, weights_c[..., :-1], N_f) pts_f rays_o[..., None, :] rays_d[..., None, :] * t_f[..., :, None] sigma_f, rgb_f network(pts_f, rays_d) # ... same accumulation for fine return rgb_c, rgb_f这里有个细节np.cumprod(1.0 - alpha_c 1e-10)里的1e-10是防止数值下溢。如果不加这个当alpha接近1时累乘会变成0梯度就断了。损失就是((rgb_c - gt)**2).mean() ((rgb_f - gt)**2).mean()反向传播更新网络参数。5. 常见问题与排查技巧实录5.1 训练loss不下降或震荡这是最常见的问题原因通常有几个学习率太大、位置编码的L值设置不当、相机位姿有误。我的排查顺序是先把学习率降到1e-4试试如果loss开始下降说明是学习率问题如果还不行检查位置编码的L值位置用10、方向用4是论文的配置但有些场景需要调整最后检查相机位姿用一张已知视角渲染对比看是否对齐。5.2 渲染结果模糊、细节丢失如果训练loss降下去了但图像还是糊大概率是位置编码没生效或者采样点太少。检查位置编码是否真的把输入映射到了高维检查粗采样和细采样的点数是否足够。另外如果场景里有大量高频纹理比如草地、毛发可能需要增加网络层数或每层的维度。5.3 训练速度太慢NeRF的训练确实慢单卡跑一个场景通常要几小时到十几小时。加速的方法有降低分辨率、减少采样点数、用混合精度训练、用多卡并行。我实测下来混合精度能提速30%左右显存占用也降了但要注意数值稳定性有些操作在fp16下会溢出。5.4 新视角合成出现伪影伪影通常出现在视角变化较大的区域原因是训练视角覆盖不够。如果训练图像只覆盖了场景的某个半球合成另一个半球的视角时就会出现拉伸、模糊。解决办法是增加训练视角的多样性或者在loss里加正则项约束未观测区域。问题现象可能原因排查方法解决措施loss不下降学习率过大降到1e-4试跑调整学习率衰减策略渲染模糊位置编码失效检查编码维度确认L值配置训练慢采样点过多统计每步耗时减少N_c和N_f新视角伪影视角覆盖不足可视化相机分布增加训练视角显存溢出batch太大监控显存占用减小batch或分辨率避坑技巧训练前先用小分辨率跑1000步确认loss在下降、渲染结果在变清晰再上全分辨率。这个预检步骤能省掉大量无效等待。6. 论文的局限与后续改进方向NeRF论文本身有几个明显的局限了解这些对理解后续的改进工作很有帮助。第一训练和渲染速度都很慢原始实现渲染一张图要几十秒训练要一两天。后续的Instant-NGP、Plenoxels等工作就是冲着速度去的。第二NeRF假设场景是静态的无法处理动态场景。第三NeRF需要精确的相机位姿位姿不准效果会大打折扣。第四NeRF的辐射场是视角相关的但只建模了直接光照没有显式建模阴影、全局光照。我在实际项目里用NeRF时最头疼的是训练速度。后来换用Instant-NGP的哈希编码方案训练时间从十几小时压缩到几分钟效果还差不多。如果你的场景对速度有要求建议直接上Instant-NGP或类似的高效变体没必要在原始NeRF上死磕。另外NeRF对反光表面和透明物体的处理也有局限。反光表面的颜色强烈依赖观察方向NeRF虽然输入了方向信息但MLP的表达能力有限复杂反射效果还是拟合不好。透明物体更麻烦因为密度场无法区分“透明”和“不存在”渲染出来要么不透明要么直接消失。这个方向后续还有很多可扩展的点比如结合SDF做表面重建、结合光照模型做重光照、结合动态场做4D重建。如果你刚入门建议先把原始NeRF吃透再挑一个感兴趣的方向深入。我自己是沿着“加速”这条线走的从原始NeRF到Instant-NGP再到3D Gaussian Splatting每一步都能感受到三维表示方法在效率和效果上的权衡。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑