Skip to main content

scirs2_vision/nerf/
hash_encoding.rs

1//! Instant-NGP multi-resolution hash encoding (Müller et al. 2022).
2//!
3//! Reference: "Instant Neural Graphics Primitives with a Multiresolution Hash Encoding"
4//! (Müller, Evans, Schied, Keller — SIGGRAPH 2022).
5//!
6//! Architecture summary
7//! --------------------
8//! L levels of resolution, each with a hash table of capacity 2^{T} entries.
9//! At resolution level l the grid spacing is 1 / (N_min · b^l) where
10//! b = (N_max / N_min)^{1/(L-1)}.
11//!
12//! For each query position:
13//!  1. Determine the 8 grid-cell corners at level l.
14//!  2. Hash each corner with a spatial hash function.
15//!  3. Look up F feature scalars from the hash table.
16//!  4. Trilinearly interpolate the 8 corner features.
17//!  5. Concatenate features across all L levels → L·F-dim vector.
18
19// ── Constants for the hash function ───────────────────────────────────────
20
21/// Knuth-style multiplicative constants (prime) used in the spatial hash.
22const PI1: u64 = 2_654_435_761;
23const PI2: u64 = 805_459_861;
24const PI3: u64 = 3_674_653_429;
25
26// ── LCG PRNG ──────────────────────────────────────────────────────────────
27
28const LCG_A: u64 = 6_364_136_223_846_793_005;
29const LCG_C: u64 = 1_442_695_040_888_963_407;
30
31struct Lcg(u64);
32
33impl Lcg {
34    fn new(seed: u64) -> Self {
35        Self(seed.wrapping_add(1))
36    }
37    fn next_f64(&mut self) -> f64 {
38        self.0 = self.0.wrapping_mul(LCG_A).wrapping_add(LCG_C);
39        (self.0 >> 11) as f64 / (1u64 << 53) as f64
40    }
41    /// Uniform in [-half, +half].
42    fn next_uniform(&mut self, half: f64) -> f64 {
43        self.next_f64() * 2.0 * half - half
44    }
45}
46
47// ── Spatial hash function ──────────────────────────────────────────────────
48
49/// Map a 3-D integer grid coordinate to a hash-table index.
50///
51/// Uses the XOR-multiply spatial hash from Müller et al. 2022:
52/// ```text
53/// h = (x·π₁ ⊕ y·π₂ ⊕ z·π₃) mod table_size
54/// ```
55///
56/// # Arguments
57///
58/// * `x`, `y`, `z`  – integer voxel coordinates (may be negative).
59/// * `table_size`    – capacity of the hash table (power-of-two preferred).
60pub fn hash_coords(x: i32, y: i32, z: i32, table_size: usize) -> usize {
61    let hx = (x as i64 as u64).wrapping_mul(PI1);
62    let hy = (y as i64 as u64).wrapping_mul(PI2);
63    let hz = (z as i64 as u64).wrapping_mul(PI3);
64    ((hx ^ hy ^ hz) as usize) % table_size
65}
66
67// ── Multi-resolution hash encoder ─────────────────────────────────────────
68
69/// Multi-resolution hash encoder from Instant-NGP (Müller et al. 2022).
70///
71/// # Fields
72///
73/// * `hash_tables`           – `[L]` levels, each a `[table_size × F]` array of feature scalars.
74/// * `n_levels`              – number of resolution levels L.
75/// * `n_features_per_level`  – number of features F per hash entry.
76/// * `base_resolution`       – grid resolution at level 0.
77/// * `resolution_growth`     – per-level resolution multiplier b.
78/// * `table_size`            – capacity of every hash table (= 2^{log2_hashmap_size}).
79pub struct HashEncoder {
80    /// `[L][table_size][F]` – learnable feature vectors.
81    pub hash_tables: Vec<Vec<Vec<f64>>>,
82    /// Number of resolution levels L.
83    pub n_levels: usize,
84    /// Number of feature scalars F stored per hash-table entry.
85    pub n_features_per_level: usize,
86    /// Grid resolution at the coarsest level (level 0).
87    pub base_resolution: usize,
88    /// Per-level resolution growth factor b = (N_max/N_min)^{1/(L-1)}.
89    pub resolution_growth: f64,
90    /// Capacity of each hash table (= 2^{log2_hashmap_size}).
91    pub table_size: usize,
92}
93
94impl HashEncoder {
95    /// Construct and randomly initialise a new `HashEncoder`.
96    ///
97    /// Features are initialised uniformly in `[-0.0001, 0.0001]`.
98    ///
99    /// # Arguments
100    ///
101    /// * `n_levels`             – number of resolution levels.
102    /// * `n_features_per_level` – features per hash entry.
103    /// * `log2_hashmap_size`    – log₂ of hash-table capacity.
104    /// * `base_resolution`      – coarsest grid resolution.
105    /// * `finest_resolution`    – finest grid resolution.
106    /// * `seed`                 – PRNG seed.
107    pub fn new(
108        n_levels: usize,
109        n_features_per_level: usize,
110        log2_hashmap_size: usize,
111        base_resolution: usize,
112        finest_resolution: usize,
113        seed: u64,
114    ) -> Self {
115        let table_size = 1_usize << log2_hashmap_size.min(30);
116        let resolution_growth = if n_levels > 1 {
117            (finest_resolution as f64 / base_resolution as f64).powf(1.0 / (n_levels - 1) as f64)
118        } else {
119            1.0
120        };
121
122        let mut rng = Lcg::new(seed);
123        let hash_tables: Vec<Vec<Vec<f64>>> = (0..n_levels)
124            .map(|_| {
125                (0..table_size)
126                    .map(|_| {
127                        (0..n_features_per_level)
128                            .map(|_| rng.next_uniform(0.0001))
129                            .collect()
130                    })
131                    .collect()
132            })
133            .collect();
134
135        Self {
136            hash_tables,
137            n_levels,
138            n_features_per_level,
139            base_resolution,
140            resolution_growth,
141            table_size,
142        }
143    }
144
145    /// Total output feature dimension = `n_levels × n_features_per_level`.
146    #[inline]
147    pub fn n_output_features(&self) -> usize {
148        self.n_levels * self.n_features_per_level
149    }
150
151    /// Grid resolution at level `l`.
152    fn level_resolution(&self, l: usize) -> f64 {
153        self.base_resolution as f64 * self.resolution_growth.powi(l as i32)
154    }
155
156    /// Retrieve the feature vector stored at hash-table entry `idx` at level `l`.
157    fn get_feature(&self, l: usize, idx: usize) -> &[f64] {
158        &self.hash_tables[l][idx % self.table_size]
159    }
160
161    /// Trilinearly interpolate the 8 corner features of the voxel containing
162    /// `pos` at resolution level `l`.
163    ///
164    /// Returns a `Vec<f64>` of length `n_features_per_level`.
165    pub fn lookup_features(&self, pos: &[f64; 3], level: usize) -> Vec<f64> {
166        let res = self.level_resolution(level);
167        // Scale position into [0, res]
168        let px = pos[0] * res;
169        let py = pos[1] * res;
170        let pz = pos[2] * res;
171
172        // Integer lower corner
173        let x0 = px.floor() as i32;
174        let y0 = py.floor() as i32;
175        let z0 = pz.floor() as i32;
176
177        // Trilinear weights
178        let wx = px - x0 as f64;
179        let wy = py - y0 as f64;
180        let wz = pz - z0 as f64;
181
182        let x1 = x0 + 1;
183        let y1 = y0 + 1;
184        let z1 = z0 + 1;
185
186        // 8 corners and their trilinear weights
187        let corners = [
188            // (dx, dy, dz, weight)
189            (x0, y0, z0, (1.0 - wx) * (1.0 - wy) * (1.0 - wz)),
190            (x1, y0, z0, wx * (1.0 - wy) * (1.0 - wz)),
191            (x0, y1, z0, (1.0 - wx) * wy * (1.0 - wz)),
192            (x1, y1, z0, wx * wy * (1.0 - wz)),
193            (x0, y0, z1, (1.0 - wx) * (1.0 - wy) * wz),
194            (x1, y0, z1, wx * (1.0 - wy) * wz),
195            (x0, y1, z1, (1.0 - wx) * wy * wz),
196            (x1, y1, z1, wx * wy * wz),
197        ];
198
199        let mut out = vec![0.0_f64; self.n_features_per_level];
200        for (cx, cy, cz, w) in &corners {
201            let idx = hash_coords(*cx, *cy, *cz, self.table_size);
202            let feat = self.get_feature(level, idx);
203            for (o, &f) in out.iter_mut().zip(feat.iter()) {
204                *o += w * f;
205            }
206        }
207        out
208    }
209
210    /// Encode a 3-D position by looking up and concatenating features from all
211    /// `n_levels` resolution levels.
212    ///
213    /// Output length: `n_levels × n_features_per_level`.
214    pub fn encode(&self, pos: &[f64; 3]) -> Vec<f64> {
215        let mut out = Vec::with_capacity(self.n_output_features());
216        for l in 0..self.n_levels {
217            out.extend_from_slice(&self.lookup_features(pos, l));
218        }
219        out
220    }
221}
222
223// ── Tiny MLP for Instant-NGP ───────────────────────────────────────────────
224
225/// A tiny two-layer MLP used in conjunction with [`HashEncoder`].
226///
227/// Input:  `hash_encoding(pos)` ∥ `direction_encoding(dir)`
228/// → hidden layer (64 ReLU) → output `(density, rgb)`.
229pub struct InstantNgpMlp {
230    w0: Vec<Vec<f64>>, // [hidden][in_dim]
231    b0: Vec<f64>,
232    w1: Vec<Vec<f64>>, // [1 + 3][hidden]
233    b1: Vec<f64>,
234    encoder: HashEncoder,
235    n_freq_dir: usize,
236    hidden_dim: usize,
237}
238
239impl InstantNgpMlp {
240    /// Build and initialise an `InstantNgpMlp`.
241    ///
242    /// # Arguments
243    ///
244    /// * `encoder`    – pre-constructed [`HashEncoder`].
245    /// * `n_freq_dir` – positional-encoding bands for view direction.
246    /// * `hidden_dim` – width of the hidden layer (typically 64).
247    /// * `seed`       – PRNG seed.
248    pub fn new(encoder: HashEncoder, n_freq_dir: usize, hidden_dim: usize, seed: u64) -> Self {
249        use super::positional_encoding::encoding_dim;
250
251        let dir_dim = encoding_dim(n_freq_dir);
252        let in_dim = encoder.n_output_features() + dir_dim;
253        let out_dim = 4; // density (1) + rgb (3)
254
255        let mut rng = Lcg::new(seed ^ 0x1234_5678);
256
257        let scale0 = (2.0 / in_dim as f64).sqrt();
258        let w0: Vec<Vec<f64>> = (0..hidden_dim)
259            .map(|_| {
260                (0..in_dim)
261                    .map(|_| rng.next_f64() * 2.0 * scale0 - scale0)
262                    .collect()
263            })
264            .collect();
265        let b0 = vec![0.0_f64; hidden_dim];
266
267        let scale1 = (2.0 / hidden_dim as f64).sqrt();
268        let w1: Vec<Vec<f64>> = (0..out_dim)
269            .map(|_| {
270                (0..hidden_dim)
271                    .map(|_| rng.next_f64() * 2.0 * scale1 - scale1)
272                    .collect()
273            })
274            .collect();
275        let b1 = vec![0.0_f64; out_dim];
276
277        Self {
278            w0,
279            b0,
280            w1,
281            b1,
282            encoder,
283            n_freq_dir,
284            hidden_dim,
285        }
286    }
287
288    /// Run a forward pass.
289    ///
290    /// Returns `(density, rgb)` where `density ≥ 0` and `rgb ∈ [0,1]³`.
291    ///
292    /// # Arguments
293    ///
294    /// * `pos` – 3-D world position.
295    /// * `dir` – unit view direction.
296    pub fn forward(&self, pos: &[f64; 3], dir: &[f64; 3]) -> (f64, [f64; 3]) {
297        use super::positional_encoding::encode_direction;
298
299        // Build input
300        let hash_feat = self.encoder.encode(pos);
301        let dir_feat = encode_direction(dir, self.n_freq_dir);
302
303        let mut inp: Vec<f64> = Vec::with_capacity(hash_feat.len() + dir_feat.len());
304        inp.extend_from_slice(&hash_feat);
305        inp.extend_from_slice(&dir_feat);
306
307        // Hidden layer (ReLU)
308        let h: Vec<f64> = (0..self.hidden_dim)
309            .map(|i| {
310                let raw: f64 = self.b0[i]
311                    + self.w0[i]
312                        .iter()
313                        .zip(inp.iter())
314                        .map(|(w, x)| w * x)
315                        .sum::<f64>();
316                raw.max(0.0)
317            })
318            .collect();
319
320        // Output layer
321        let raw_out: Vec<f64> = (0..4)
322            .map(|i| {
323                self.b1[i]
324                    + self.w1[i]
325                        .iter()
326                        .zip(h.iter())
327                        .map(|(w, x)| w * x)
328                        .sum::<f64>()
329            })
330            .collect();
331
332        let density = raw_out[0].max(0.0); // ReLU
333        let rgb = [
334            sigmoid(raw_out[1]),
335            sigmoid(raw_out[2]),
336            sigmoid(raw_out[3]),
337        ];
338        (density, rgb)
339    }
340}
341
342#[inline]
343fn sigmoid(x: f64) -> f64 {
344    1.0 / (1.0 + (-x).exp())
345}
346
347// ── Tests ─────────────────────────────────────────────────────────────────
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn build_encoder() -> HashEncoder {
354        HashEncoder::new(4, 2, 12, 16, 128, 42)
355    }
356
357    #[test]
358    fn test_hash_function_deterministic() {
359        // Same coords must always map to the same index.
360        let table_size = 1 << 16;
361        let h1 = hash_coords(3, -7, 11, table_size);
362        let h2 = hash_coords(3, -7, 11, table_size);
363        assert_eq!(h1, h2);
364
365        // Different coords should produce different indices (with high probability).
366        let h3 = hash_coords(3, -7, 12, table_size);
367        let h4 = hash_coords(4, -7, 11, table_size);
368        // Very unlikely to collide — just a sanity check.
369        assert!(
370            h1 != h3 || h1 != h4,
371            "suspicious: three distinct coords all hash to {h1}"
372        );
373    }
374
375    #[test]
376    fn test_hash_table_lookup_shape() {
377        let enc = build_encoder();
378        let feat = enc.lookup_features(&[0.25, 0.5, 0.75], 0);
379        assert_eq!(feat.len(), enc.n_features_per_level);
380    }
381
382    #[test]
383    fn test_hash_encoder_output_dim() {
384        let enc = build_encoder();
385        let out = enc.encode(&[0.1, 0.2, 0.3]);
386        assert_eq!(out.len(), enc.n_output_features());
387        assert_eq!(
388            enc.n_output_features(),
389            enc.n_levels * enc.n_features_per_level
390        );
391    }
392
393    #[test]
394    fn test_trilinear_interpolation_corners() {
395        // When the query point is exactly at a grid corner, the trilinear
396        // weights for the other 7 corners should be 0 and the result
397        // should equal that corner's hash-table features.
398
399        let enc = build_encoder();
400        let level = 0;
401        let res = enc.level_resolution(level);
402
403        // Position exactly on a grid corner: (0, 0, 0) in grid space → pos = (0, 0, 0)
404        let pos_corner = [0.0_f64, 0.0, 0.0];
405
406        // Manually compute what the table holds at (0,0,0)
407        let expected_idx = hash_coords(0, 0, 0, enc.table_size);
408        let expected_feat = enc.get_feature(level, expected_idx).to_vec();
409
410        let result = enc.lookup_features(&pos_corner, level);
411        for (i, (&r, &e)) in result.iter().zip(expected_feat.iter()).enumerate() {
412            assert!(
413                (r - e).abs() < 1e-12,
414                "feature[{i}] mismatch at corner: got {r}, expected {e}"
415            );
416        }
417
418        // Silence unused warning for `res`
419        let _ = res;
420    }
421
422    #[test]
423    fn test_instant_ngp_mlp_forward() {
424        let encoder = HashEncoder::new(4, 2, 12, 16, 128, 77);
425        let mlp = InstantNgpMlp::new(encoder, 2, 32, 99);
426        let pos = [0.3, 0.4, 0.5];
427        let dir = [0.0, 1.0, 0.0];
428        let (density, rgb) = mlp.forward(&pos, &dir);
429
430        assert!(density >= 0.0, "density must be >= 0, got {density}");
431        for (c, &ch) in rgb.iter().enumerate() {
432            assert!(
433                (0.0..=1.0).contains(&ch),
434                "rgb[{c}] = {ch} must be in [0,1]"
435            );
436        }
437    }
438}