Instant-NGP 核心揭秘:多分辨率哈希编码的原理与 PyTorch 实现
一句话总结
Nerfstudio v1.1.5 对 instant-ngp 的网格分辨率类型做了修复——这个看似不起眼的 patch 背后,藏着 Instant-NGP 最核心的设计决策:为什么哈希碰撞不是 bug,而是 feature。
为什么这篇文章值得读?
NeRF 原版的训练需要几十小时。Instant-NGP 把它压缩到了几分钟。
核心武器就是多分辨率哈希编码(Multi-Resolution Hash Encoding)。大多数介绍都停在”它很快”,但不解释:
- 哈希碰撞明明会丢信息,为什么还要故意用它?
- 多分辨率到底多了什么?
grid_resolution为什么必须是整数?类型错误会导致什么问题?
这篇文章从原理到代码,一次说清楚。
背景:传统 NeRF 为什么慢?
传统 NeRF 用一个 MLP 同时做两件事:
- 记住场景的全部几何和外观信息(编码)
- 查询给定坐标的颜色和密度(解码)
这就像把一本百科全书和索引系统合并成一个神经网络。MLP 不得不用它的权重”记住”所有空间信息,训练时每一条光线都要反向传播穿过整个网络。
Instant-NGP 的核心洞见很简单:把”记忆”从 MLP 里分离出来,放进一个专门的空间数据结构里。
多分辨率哈希编码:直觉优先
想象你要记录一座城市的每栋建筑。你可以用:
- 粗粒度地图:记录街区轮廓(低分辨率)
- 中粒度地图:记录每栋楼的轮廓(中分辨率)
- 细粒度地图:记录门窗细节(高分辨率)
Instant-NGP 就是这个思路:用 L 个不同分辨率的网格覆盖同一个三维空间,每个分辨率的网格顶点存一个可学习的特征向量。给定任意 3D 坐标,在每一层分辨率上做三线性插值,拼接所有层的特征,送入一个很小的 MLP。
关键问题来了:高分辨率网格顶点数会爆炸($512^3 \approx 1.3 \times 10^8$),根本存不下。
解决方案:哈希表压缩。把巨大的虚拟网格映射到一个固定大小为 $T$(通常 $T = 2^{19}$)的哈希表上。
哈希函数:
\[h(\mathbf{x}) = \left( \bigoplus_{i=1}^{3} x_i \cdot \pi_i \right) \bmod T\]其中 $\pi_1=1$,$\pi_2=2654435761$,$\pi_3=805459861$ 是大质数,$\bigoplus$ 是按位 XOR。
碰撞怎么办? 不管。两个不同的空间位置可能映射到同一个哈希表槽,它们会共享同一个特征向量。但是:
- 低分辨率层碰撞率低(网格顶点少),负责记录精确的粗结构
- 高分辨率层碰撞率高,但 MLP 学会了通过上下文消歧
这就是为什么哈希碰撞不是 bug——MLP 充当了”碰撞解决器”。
核心数学
设第 $l$ 层分辨率为 $N_l$,由以下公式确定(指数增长):
\[N_l = \lfloor N_{\min} \cdot b^l \rfloor, \quad b = \exp\left(\frac{\ln N_{\max} - \ln N_{\min}}{L-1}\right)\]对于坐标 $\mathbf{x} \in [0,1]^3$,在第 $l$ 层的编码过程:
- 缩放到网格坐标:$\mathbf{x}_g = \mathbf{x} \cdot N_l$
- 找到 8 个相邻格点($\lfloor \mathbf{x}_g \rfloor$ 的 8 个方向邻居)
- 哈希查表取特征,三线性插值
- 所有 $L$ 层特征拼接:$\mathbf{enc}(\mathbf{x}) \in \mathbb{R}^{L \cdot F}$,其中 $F$ 是每层特征维度
代码实现
核心:多分辨率哈希编码器
import torch
import torch.nn as nn
import numpy as np
class MultiResHashEncoding(nn.Module):
def __init__(
self,
n_levels: int = 16,
n_features_per_level: int = 2,
log2_hashmap_size: int = 19, # 哈希表大小 2^19
base_resolution: int = 16,
finest_resolution: int = 512,
):
super().__init__()
self.n_levels = n_levels
self.n_features = n_levels * n_features_per_level
self.T = 2 ** log2_hashmap_size # 哈希表容量
# 计算每层分辨率(指数增长)
b = np.exp(
(np.log(finest_resolution) - np.log(base_resolution)) / (n_levels - 1)
)
# 注意:resolutions 必须是整数!浮点数会导致量化错误
self.resolutions = [int(base_resolution * (b ** i)) for i in range(n_levels)]
# 每层独立哈希表,小分辨率层直接用密集网格
self.embeddings = nn.ModuleList([
nn.Embedding(min(res ** 3, self.T), n_features_per_level)
for res in self.resolutions
])
for emb in self.embeddings:
nn.init.uniform_(emb.weight, -1e-4, 1e-4)
def _hash(self, coords: torch.Tensor) -> torch.Tensor:
"""XOR 哈希:coords [... , 3] int -> 哈希表索引"""
pi = torch.tensor([1, 2654435761, 805459861],
dtype=torch.int64, device=coords.device)
h = torch.zeros(*coords.shape[:-1], dtype=torch.int64, device=coords.device)
for i in range(3):
h ^= coords[..., i].to(torch.int64) * pi[i]
return (h % self.T).long()
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""x: [..., 3] in [0,1] -> [..., n_levels * n_features_per_level]"""
all_features = []
for res, emb in zip(self.resolutions, self.embeddings):
feat = self._level_encode(x, res, emb)
all_features.append(feat)
return torch.cat(all_features, dim=-1)
def _level_encode(self, x, res, emb):
"""单层编码:三线性插值"""
x_grid = x * res # [..., 3]
x_floor = x_grid.long() # 格点坐标(整数)
x_frac = x_grid - x_floor.float() # 插值权重
feat = torch.zeros(*x.shape[:-1], emb.embedding_dim, device=x.device)
# 遍历 8 个角点
for dz in [0, 1]:
for dy in [0, 1]:
for dx in [0, 1]:
offset = torch.tensor([dx, dy, dz], device=x.device)
corner = (x_floor + offset) % res
# 查哈希表(小分辨率用线性索引,大分辨率用哈希)
if emb.num_embeddings < res ** 3:
idx = self._hash(corner)
else:
idx = (corner[...,0] * res*res
+ corner[...,1] * res
+ corner[...,2]).long()
# 三线性插值权重
wx = x_frac[..., 0] * dx + (1 - x_frac[..., 0]) * (1 - dx)
wy = x_frac[..., 1] * dy + (1 - x_frac[..., 1]) * (1 - dy)
wz = x_frac[..., 2] * dz + (1 - x_frac[..., 2]) * (1 - dz)
w = (wx * wy * wz).unsqueeze(-1)
feat = feat + w * emb(idx)
return feat
组合完整的 Instant-NGP 网络
class InstantNGP(nn.Module):
def __init__(self):
super().__init__()
# 空间特征编码器
self.encoder = MultiResHashEncoding(
n_levels=16, n_features_per_level=2,
log2_hashmap_size=19,
base_resolution=16, finest_resolution=512
)
# 密度 MLP(很小!只有 1 个隐层)
self.density_net = nn.Sequential(
nn.Linear(32, 64), nn.ReLU(),
nn.Linear(64, 16), # 16 维几何特征 + 1 维密度
)
# 颜色 MLP(加上方向编码)
self.color_net = nn.Sequential(
nn.Linear(15 + 16, 64), nn.ReLU(),
nn.Linear(64, 3), nn.Sigmoid()
)
def forward(self, pos, dir):
# pos: [N, 3], dir: [N, 3](球谐函数编码后的方向)
h = self.encoder(pos) # [N, 32]
geo_feat = self.density_net(h) # [N, 16]
density = torch.relu(geo_feat[..., 0:1])
color = self.color_net(
torch.cat([dir, geo_feat], dim=-1) # 方向影响颜色,不影响密度
)
return density, color
grid_resolution 为什么必须是整数?
Nerfstudio v1.1.5 修复了”change grid resolution type”——这不是小事。
考虑分辨率 $N_l = 16.7$(浮点数):
# 错误示例:浮点分辨率导致量化漂移
res = 16.7
x_grid = 0.5 * res # = 8.35
x_floor = int(8.35) # = 8
corner = (8 + 1) % res # = 9 % 16.7 ≈ 9.0 → 但取模语义在整数才正确
# 正确:先取整再用
res = int(16.7) # = 16
x_grid = 0.5 * 16 # = 8.0,一切正常
浮点分辨率会导致相邻层的网格边界不对齐,三线性插值产生系统性偏差,在场景边缘尤为明显。
在 Nerfstudio 中的实践
Nerfstudio 把上述逻辑封装进了 HashMLPDensityField。实际配置时主要调这几个参数:
from nerfstudio.fields.instant_ngp_field import InstantNGPField
field = InstantNGPField(
aabb=scene_aabb,
num_levels=16, # 哈希层数,越多越精细,显存越大
log2_hashmap_size=19, # 哈希表大小,19 约占 8MB
base_resolution=16, # 最低分辨率,决定场景范围感知能力
features_per_level=2, # 每层特征维度,2 是经验最优
)
参数调优经验:
| 场景类型 | 推荐 finest_resolution |
推荐 log2_hashmap_size |
|---|---|---|
| 室内小场景 | 512 | 19 |
| 室外大场景 | 2048 | 21 |
| 细粒度物体 | 1024 | 20 |
| 显存受限 | 256 | 17 |
常见坑
坑 1:坐标未归一化
哈希编码假设输入在 [0, 1],实际场景坐标可能跨度很大:
# 坐标必须先归一化到 scene AABB
pos_normalized = (pos - aabb_min) / (aabb_max - aabb_min)
pos_normalized = pos_normalized.clamp(0, 1) # 防止越界
features = encoder(pos_normalized)
坑 2:哈希表太小引发碰撞风暴
log2_hashmap_size=17(约 1MB)在高分辨率层会有大量碰撞,导致纹理模糊。判断方法:训练 loss 在低 level 已经收敛但高频细节还是糊的,大概率是哈希表太小。
坑 3:数值溢出
哈希计算中 x_i * π_i 可能溢出 32 位整数,必须用 int64:
# 错误:int32 溢出
h = coords[..., 0].int() * 2654435761 # 溢出!
# 正确:明确指定 int64
h = coords[..., 0].to(torch.int64) * 2654435761
局限性:什么时候 Instant-NGP 会失败?
| 适用场景 | 不适用场景 |
|---|---|
| 静态场景,有清晰几何结构 | 透明/反射物体(散射光路复杂) |
| 快速原型验证 | 需要精确表面法向量的下游任务 |
| 单场景拟合 | 跨场景泛化(哈希表不可迁移) |
| 显存充足(8GB+) | 极细长结构(竹竿、电线,哈希碰撞严重) |
| 室内场景 | 无界室外场景(需要额外的空间收缩策略) |
我的观点
Instant-NGP 的真正贡献不是”快”,而是证明了可学习的空间数据结构可以替代深层 MLP 的空间编码能力,同时快两个数量级。这个范式已经影响了后续的 3D Gaussian Splatting、Zip-NeRF 等方法。
但 Nerfstudio v1.1.5 的这个修复暴露了一个工程现实:论文实现和生产代码之间的距离往往在类型细节上。grid_resolution 是浮点还是整数,论文不会提,但错了就是隐性 bug。
如果你在用 Nerfstudio,这个版本值得更新——不只是这个 bug 修复,viser 0.2.7 的升级也改善了实时 3D 可视化的响应速度。
参考资料
- Müller et al., Instant Neural Graphics Primitives with a Multiresolution Hash Encoding, SIGGRAPH 2022
- Nerfstudio 官方文档
- Nerfstudio v1.1.5 Release Notes
Comments