一句话总结

Nerfstudio v1.1.5 对 instant-ngp 的网格分辨率类型做了修复——这个看似不起眼的 patch 背后,藏着 Instant-NGP 最核心的设计决策:为什么哈希碰撞不是 bug,而是 feature


为什么这篇文章值得读?

NeRF 原版的训练需要几十小时。Instant-NGP 把它压缩到了几分钟

核心武器就是多分辨率哈希编码(Multi-Resolution Hash Encoding)。大多数介绍都停在”它很快”,但不解释:

  • 哈希碰撞明明会丢信息,为什么还要故意用它?
  • 多分辨率到底多了什么?
  • grid_resolution 为什么必须是整数?类型错误会导致什么问题?

这篇文章从原理到代码,一次说清楚。


背景:传统 NeRF 为什么慢?

传统 NeRF 用一个 MLP 同时做两件事:

  1. 记住场景的全部几何和外观信息(编码)
  2. 查询给定坐标的颜色和密度(解码)

这就像把一本百科全书和索引系统合并成一个神经网络。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$ 层的编码过程:

  1. 缩放到网格坐标:$\mathbf{x}_g = \mathbf{x} \cdot N_l$
  2. 找到 8 个相邻格点($\lfloor \mathbf{x}_g \rfloor$ 的 8 个方向邻居)
  3. 哈希查表取特征,三线性插值
  4. 所有 $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 可视化的响应速度。


参考资料