Tutorial · Intermediate · ~50 min
Data Structures · Algorithms · Physics · JavaScript

Spatial Hashing for O(1) Neighbour Lookup

Naïve neighbour search checks every pair of particles: O(N²). A spatial hash grid maps 3D positions to a flat hash table using only integer arithmetic, giving O(1) average lookups per query and enabling smooth 100k-particle simulations in real time.

1The core idea: cells and hash function

The world is conceptually divided into uniform cells of size h (the interaction radius). Each 3D coordinate maps to an integer cell index, which is then hashed to a table slot:

const h = 0.5; // cell size = interaction radius // 3D position → integer cell coordinates function cellOf(x, y, z) { return [Math.floor(x / h), Math.floor(y / h), Math.floor(z / h)]; } // Integer cell → flat table index (large primes minimise collision clustering) const TABLE = 1 << 20; // 1M slots — must be power of 2 for cheap modulo const P1 = 73856093, P2 = 19349663, P3 = 83492791; function hashCell(cx, cy, cz) { // XOR of scaled coordinates — fast and reasonably uniform return ((cx * P1) ^ (cy * P2) ^ (cz * P3)) & (TABLE - 1); }
Cell size should equal the interaction radius h. Querying the 3×3×3 (27 cells in 3D) neighborhood then guarantees every potential neighbor is found. In 2D it's 3×3 = 9 cells.

2Hash table layout — flat arrays

Instead of JavaScript objects (slow allocation), use two integer-typed arrays — a count array (how many particles in each slot) and a particle list array (flat list of particle indices per slot).

const N = 100_000; // max particles // Flat sorted-list approach (fastest rebuild each frame): const cellStart = new Int32Array(TABLE + 1); // prefix-sum start index const cellCount = new Int32Array(TABLE); // particles per cell const sortedIds = new Int32Array(N); // particle indices sorted by cell // Alternatively: chained buckets (simpler to update, slightly slower iterate) const buckets = new Array(TABLE).fill(null).map(() => []);

3Build phase: assign particles to cells

// particles: Float32Array of [x,y,z, ...] with stride 3 function build(particles, n) { // 1. Count particles per cell cellCount.fill(0); for (let i = 0; i < n; i++) { const [cx, cy, cz] = cellOf(particles[i*3], particles[i*3+1], particles[i*3+2]); cellCount[hashCell(cx, cy, cz)]++; } // 2. Prefix sum → start index per cell cellStart[0] = 0; for (let b = 0; b < TABLE; b++) { cellStart[b + 1] = cellStart[b] + cellCount[b]; } // 3. Fill sortedIds (stable assignment) const tempCount = new Int32Array(TABLE); // working counter for (let i = 0; i < n; i++) { const [cx, cy, cz] = cellOf(particles[i*3], particles[i*3+1], particles[i*3+2]); const slot = hashCell(cx, cy, cz); sortedIds[cellStart[slot] + tempCount[slot]] = i; tempCount[slot]++; } } // Rebuild every frame for moving particles — O(N) time

4Query phase: iterate over neighbour cells

// Iterate all particles within radius h of point (qx, qy, qz) function query(particles, qx, qy, qz, callback) { const [bcx, bcy, bcz] = cellOf(qx, qy, qz); const h2 = h * h; // Check the 3×3×3 neighbourhood (27 cells) for (let dz = -1; dz <= 1; dz++) { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const slot = hashCell(bcx + dx, bcy + dy, bcz + dz); const start = cellStart[slot]; const end = start + cellCount[slot]; for (let k = start; k < end; k++) { const j = sortedIds[k]; const rx = particles[j*3] - qx; const ry = particles[j*3+1] - qy; const rz = particles[j*3+2] - qz; const r2 = rx*rx + ry*ry + rz*rz; if (r2 < h2) callback(j, Math.sqrt(r2), rx, ry, rz); } } } } } // Usage: for each particle, find its neighbours for (let i = 0; i < N; i++) { query(particles, particles[i*3], particles[i*3+1], particles[i*3+2], (j, dist) => { // j is a neighbour index, dist is the distance }); }
Hash collisions (two cells mapping to the same slot) cause false-positive candidates — particles that hash to the same slot but are in different cells. The exact distance check r2 < h2 filters these out, so correctness is preserved at the cost of a few extra candidates.

5SPH fluid density with spatial hashing

// SPH kernel: poly6 function poly6(r, h) { if (r >= h) return 0; const diff = h*h - r*r; return (315 / (64 * Math.PI * h**9)) * diff**3; } const mass = 0.02; const densities = new Float32Array(N); function computeDensities(particles) { build(particles, N); for (let i = 0; i < N; i++) { densities[i] = 0; query(particles, particles[i*3], particles[i*3+1], particles[i*3+2], (j, dist) => { densities[i] += mass * poly6(dist, h); } ); } } // Without spatial hashing: O(N²) = 10^10 operations for 100k particles // With spatial hashing: O(N·k) where k ≈ 27 × avg_per_cell ≈ 50..100

6Extensions and limits

// Tuning advice: // TABLE should be 2-4× N for good load factor (low collision rate) // Cell size h should match the interaction radius exactly // For 2D (particles confined to a plane): only query 3×3 = 9 cells const TABLE_2D = 1 << 18; // 256k slots for 2D sim with 50k particles function hashCell2D(cx, cy) { return ((cx * P1) ^ (cy * P2)) & (TABLE_2D - 1); } // Query 9 cells in 2D for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const slot = hashCell2D(bcx + dx, bcy + dy); // ... iterate slot } }