NeRF的全称是Neural Radiance Fields,即神经辐射场。它的核心想法听起来简单:用一个神经网络去隐式地表示一个三维场景,输入一个空间坐标和观察方向,网络输出这个点在该方向上的颜色和体积密度。但真正把这个想法落地,需要解决一连串问题——神经网络天生偏好平滑函数,怎么让它表达高频纹理;一个点的颜色为什么和方向有关;怎么从一堆离散采样点得到一张完整的图像。这篇文章就从零开始,把这些环节逐一推导清楚。

位置编码:为什么高频细节会丢失
要理解位置编码的必要性,得先看一个结论:神经网络在拟合低频函数时非常擅长,但在拟合高频函数时会遇到严重的频谱偏差,英文叫spectral bias。实验上,如果你直接把三维坐标xyz喂给一个MLP去拟合场景的颜色,得到的渲染结果往往是一团模糊的色块,纹理细节几乎全部丢失。原因在于MLP的激活函数大多是ReLU或tanh这类相对平滑的函数,多层复合之后,网络能表达的函数频率上限是受限的,天然偏向先拟合低频成分。
NeRF给出的解法是把坐标先映射到一组高频特征上,再送入网络。具体做法是用一组不同频率的正弦和余弦函数对每个坐标分量做编码。对于三维坐标p中的某一维x,编码函数写成如下形式:
import numpy as np
def positional_encoding(x, L=10):
# x形状为[N, 1],L为编码层数
freqs = 2.0 ** np.arange(L) # 频率从1到2^(L-1)指数递增
out = [x]
for f in freqs:
out.append(np.sin(f * x))
out.append(np.cos(f * x))
return np.concatenate(out, axis=-1) # 输出形状[N, 2*L]每一维坐标经过L层编码后变成2L维,三个维度合起来就是6L维。论文中位置坐标取L=10,方向向量取L=4。这样做的本质是把网络要拟合的目标从原始坐标函数转换到了一组频率从低到高的三角函数基上,网络只需要学习这些基的组合系数,高频信息的表达门槛大大降低。从傅里叶变换的角度看,这相当于先对输入做一次频率提升,让网络的可学习频谱范围覆盖到场景中的高频纹理。
还有个细节值得一提:后续论文如Mip-NeRF指出,直接用无限尖锐的点采样做位置编码会带来混叠问题,理想做法是对编码结果按采样锥体的积分做预滤波。不过在入门阶段,标准的正弦编码已经足够理解NeRF的主干思想。实践中有两个超参需要注意:一是L不能太小,太小等于没编码,高频依然缺失;二是训练初期可以先用低频、逐步加入高频,也就是所谓的课程学习式训练,能加速收敛。
MLP结构:从五维输入到颜色与密度
NeRF要拟合的函数是F: (x, y, z, θ, φ) 映射到 (R, G, B, σ)。前三个是空间位置,后两个θ和φ是观察方向的俯仰角和偏航角,通常用方向向量的单位化形式d = (dx, dy, dz)直接输入,省去三角函数转换。为什么不直接用九个数字一起输入一个网络?因为密度σ本质上是场景的静态属性,某个位置的介质浓不浓,和你从哪个方向看它没有关系;而颜色则强烈依赖观察方向,同一块玻璃从不同角度看颜色完全不同。所以NeRF把网络拆成了两部分。
主干网络只接收位置编码后的γ(x),经过8层256维的全连接层加ReLU,中间还加入了跳跃连接,把第5层的输入拼接到第5层的输出上,帮助网络保留低层的位置信息,避免深层网络的信息瓶颈。主干输出两个东西:体密度σ,以及一个256维的中间特征向量。这个特征再与方向编码γ(d)拼接,送入一个较小的分支网络,输出最终的RGB颜色。整个结构可以用PyTorch写出来:
import torch
import torch.nn as nn
class NeRFNetwork(nn.Module):
def __init__(self, pos_L=10, dir_L=4, W=256):
super().__init__()
self.pos_L, self.dir_L, self.W = pos_L, dir_L, W
in_pos = 3 * 2 * pos_L
in_dir = 3 * 2 * dir_L
# 主干8层,第5层做跳跃连接,所以输入维度是W加in_pos
self.pts_linears = nn.ModuleList(
[nn.Linear(in_pos, W)] +
[nn.Linear(W, W) if i != 4 else nn.Linear(W + in_pos, W)
for i in range(7)]
)
self.sigma_head = nn.Linear(W, 1) # 体密度
self.feature_head = nn.Linear(W, W) # 中间特征
self.dir_branch = nn.Sequential(
nn.Linear(W + in_dir, W // 2), nn.ReLU(),
nn.Linear(W // 2, 3), nn.Sigmoid() # RGB归一化到0到1
)
def forward(self, x, d):
# x为[N,3]的坐标,d为[N,3]的单位方向
enc_x = self.encode(x, self.pos_L)
enc_d = self.encode(d, self.dir_L)
h = enc_x
for i, layer in enumerate(self.pts_linears):
if i == 4:
h = layer(torch.cat([enc_x, h], dim=-1))
else:
h = layer(h)
h = torch.relu(h)
sigma = torch.relu(self.sigma_head(h)) # 密度非负
feat = self.feature_head(h)
rgb = self.dir_branch(torch.cat([feat, enc_d], dim=-1))
return rgb, sigma
def encode(self, v, L):
freqs = 2.0 ** torch.arange(L, device=v.device)
out = [v]
for f in freqs:
out.append(torch.sin(f * v))
out.append(torch.cos(f * v))
return torch.cat(out, dim=-1)注意σ输出后过了ReLU保证非负,RGB过了Sigmoid限制在0到1之间,这些约束和体渲染的物理含义是配套的。训练时对每张图片随机采样一批像素,每条光线上再采样若干点,用渲染方程算出像素颜色与真值的MSE损失做反向传播。整个场景没有显式的三维模型存储,所有几何信息都编码在网络权重里,这也是隐式表示这个名字的由来。
体渲染方程:离散采样点的颜色累积推导
有了网络,接下来的问题是:一条从相机出发的光线,怎么变成一个像素的颜色。经典图形学中的体渲染积分为:
C(r) = ∫ T(t) * σ(r(t)) * c(r(t), d) dt 其中 r(t) = o + t * d 是光线方程, T(t) = exp(-∫σ(r(s)) ds) 是从0到t的透射率, 物理含义是光线走到t处还没被遮挡的概率。
这个积分没有解析解,数值上用求和近似。把光线上的区间分成N段,在每段内随机取一个采样点ti,累积公式推导后得到:
def render_rays(rgb, sigma, t):
# rgb: [N,3]采样点颜色 sigma: [N]密度 t: [N]采样深度
delta = t[..., 1:] - t[..., :-1] # 相邻采样点间距
delta = torch.cat([delta, 1e10 * torch.ones_like(delta[..., :1])], -1)
alpha = 1.0 - torch.exp(-sigma * delta) # 每段的不透明度
# 透射率:前面所有段都没挡住光线的概率
T = torch.cumprod(
torch.cat([torch.ones_like(alpha[..., :1]), 1.0 - alpha + 1e-10], -1),
dim=-1)[..., :-1]
weights = T * alpha # 每个采样点的渲染权重
c = torch.sum(weights[..., None] * rgb, dim=-2) # 加权求和得像素色
return c, weights这里alpha compositing的思想和图形学中的alpha混合一脉相承:每个采样点贡献的颜色等于它自己的不透明度乘以它前面所有点的透射率。距离越远的点,被前面介质遮挡得越多,权重自然越小。数值上为了防止exp下溢,用cumprod时给1减alpha加一个极小量做稳定。这些实现细节看似琐碎,但任何一个没处理好都会导致训练NaN。
关于采样,NeRF用了两阶段策略。第一阶段coarse采样按均匀分布取点做一次渲染,得到的权重分布反映了场景中物体大概集中的深度区间;第二阶段按这个权重的逆变换采样再做fine采样。这种importance sampling让有限的采样预算集中到真正有内容的区域,对渲染质量和效率都是关键提升。
体素遍历:光线步进与射线生成实现
最后回到渲染的最外层:给定一张虚拟相机的位姿和内参,怎么生成光线并遍历场景。每条光线由原点o和方向d确定,方向d可以通过像素坐标结合内参矩阵计算:
def get_rays(H, W, focal, c2w):
# c2w是相机到世界的4x4变换矩阵
i, j = torch.meshgrid(torch.arange(W), torch.arange(H), indexing="xy")
dirs = torch.stack([(i - W * 0.5) / focal,
-(j - H * 0.5) / focal,
-torch.ones_like(i)], dim=-1) # 相机系方向
rays_d = torch.sum(dirs[..., None, :] * c2w[:3, :3], dim=-1) # 转世界系
rays_o = c2w[:3, -1].expand(rays_d.shape) # 光线起点即相机中心
return rays_o, rays_d得到光线后,需要在深度上确定采样的near和far边界,也就是光线上可能有内容的深度范围。对于360度环绕拍摄的物体场景,near和far可以按场景包围球估算;对于前向拍摄的场景则常用固定的深度区间。在标准实现里,每条光线取64个coarse点加128个fine点,采样时使用分层随机采样,即在每小段的均匀分布内随机取值,而不是固定取段中点,这能让网络在训练中见到连续变化的坐标输入,对隐式表示的泛化很重要。
所谓体素遍历,在NeRF中并不像八叉树光线追踪那样显式跳过空气区域,因为网络是连续函数,每个点都能查询。但这也意味着渲染一条光线必须查询上百个点,推理很慢。后来的加速工作,比如Plenoxels用稀疏体素替代网络、Instant-NGP用多分辨率哈希表配合轻量网络,本质上都是在遍历阶段加入显式的空间结构,让光线可以提前终止或跳过空区域。理解了原始NeRF的暴力遍历逻辑,再看这些加速方案的动机就非常清晰:它们只是把网络查询的成本用空间数据结构摊薄了。
总结一下,NeRF的四大组件各司其职:位置编码解决频谱问题,MLP把坐标映射成辐射属性,体渲染方程把离散点积分成像素颜色,光线采样与遍历把整个流程串成可微分的渲染管线。正因为整条链路都可微,才能用简单的MSE损失端到端优化出整个场景。把这四块的推导亲手过一遍,再去看NeRF后续的变体论文,会轻松很多。