Barnes-Hut Quadtree
Approximating the far field with a tree.
Advanced / tree / gravity / approximation
Barnes-Hut makes large N-body simulation affordable: build a quadtree over the bodies, and when a distant cell's size-to-distance ratio drops below theta, treat the whole cluster as one mass. The force-error metric compares the approximation against direct summation - the theta slider is the dial between speed and honesty.
Tagged: quadtree, n-body, theta criterion, spatial partitioning
Live simulation
The theta slider trades force accuracy for speed; the error metric audits it. Keyboard: Space runs or pauses, . steps, r resets.
Speed
The theta slider trades force accuracy for speed; the error metric audits it.
Mathematics
Barnes-Hut approximates the gravitational effect of a distant cluster by its center of mass whenever the cluster subtends a small enough angle: s/d < theta, cell size over distance. The multipole acceptance criterion turns an O(n^2) pairwise sum into roughly O(n log n).
a_i = G sum over clusters near i of m_c (r_c - r_i) / (|r_c - r_i|^2 + eps^2)^(3/2)
accept cluster when s / d < theta; else subdivide
force error vs direct sum: err = mean |F_BH - F_direct| / |F_direct|
The theta slider is the honesty dial: theta near 0 forces the tree to open almost every node, converging to the direct sum (and the error metric agrees); theta near 1.4 opens almost nothing and the error shows it. Watching the measured error respond to theta is the calibration exercise every force-field code runs.
How it works
The quadtree is rebuilt every tick into flat typed arrays: four children per node, a body slot per leaf, and per-node mass and center of mass computed by one upward pass. Force queries walk from the root, opening nodes that fail the theta test.
Every 90 ticks the engine runs the direct O(n^2) sum on the same positions and reports the mean relative force error - the number that tells you the approximation is real.
Implementation
src/engine/experiments/barnes-hut.ts · 349 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | const CAPACITY = 320; |
| 5 | |
| 6 | /** |
| 7 | * Barnes-Hut: build a quadtree over the bodies each tick and approximate |
| 8 | * distant clusters by their center of mass when the node's size/distance |
| 9 | * ratio falls below theta. The accuracy metric compares the total force |
| 10 | * against a direct O(n^2) sum sampled periodically. |
| 11 | */ |
| 12 | export class BarnesHutExperiment extends BaseExperiment { |
| 13 | readonly id = "barnes-hut"; |
| 14 | |
| 15 | private count = 0; |
| 16 | private x = new Float32Array(CAPACITY); |
| 17 | private y = new Float32Array(CAPACITY); |
| 18 | private vx = new Float32Array(CAPACITY); |
| 19 | private vy = new Float32Array(CAPACITY); |
| 20 | private fx = new Float32Array(CAPACITY); |
| 21 | private fy = new Float32Array(CAPACITY); |
| 22 | // Quadtree in flat arrays: node -> children (4), center of mass, mass, bounds. |
| 23 | private nodeChild = new Int32Array(0); |
| 24 | private nodeMass = new Float64Array(0); |
| 25 | private nodeComX = new Float64Array(0); |
| 26 | private nodeComY = new Float64Array(0); |
| 27 | private nodeX = new Float64Array(0); |
| 28 | private nodeY = new Float64Array(0); |
| 29 | private nodeSize = new Float64Array(0); |
| 30 | private nodeBody = new Int32Array(0); |
| 31 | private nextBody = new Int32Array(CAPACITY); |
| 32 | private nodeCount = 0; |
| 33 | private directFx = new Float32Array(0); |
| 34 | private directFy = new Float32Array(0); |
| 35 | private forceError = 0; |
| 36 | private directTick = 0; |
| 37 | |
| 38 | protected params(): ParameterDef[] { |
| 39 | return [ |
| 40 | { key: "bodies", label: "Bodies", min: 60, max: 320, step: 20, defaultValue: 180 }, |
| 41 | { key: "theta", label: "Opening angle theta", min: 0.2, max: 1.4, step: 0.1, defaultValue: 0.8 }, |
| 42 | { |
| 43 | key: "showTree", |
| 44 | label: "Show quadtree", |
| 45 | options: [ |
| 46 | { value: "off", label: "Off" }, |
| 47 | { value: "on", label: "On" }, |
| 48 | ], |
| 49 | defaultValue: "off", |
| 50 | }, |
| 51 | ]; |
| 52 | } |
| 53 | |
| 54 | private readonly G = 9000; |
| 55 | private readonly softening = 4; |
| 56 | |
| 57 | protected onReset(): void { |
| 58 | this.count = this.num("bodies"); |
| 59 | for (let i = 0; i < this.count; i++) { |
| 60 | // Spiral galaxy initial condition. |
| 61 | const arm = i % 3; |
| 62 | const radius = 20 + (i / this.count) * Math.min(this.width, this.height) * 0.44; |
| 63 | const angle = radius * 0.02 + (arm * Math.PI * 2) / 3 + (this.rng() - 0.5) * 0.6; |
| 64 | this.x[i] = this.width / 2 + Math.cos(angle) * radius; |
| 65 | this.y[i] = this.height / 2 + Math.sin(angle) * radius; |
| 66 | const v = Math.sqrt((this.G * 60 * (i / this.count + 0.3)) / radius); |
| 67 | this.vx[i] = -Math.sin(angle) * v; |
| 68 | this.vy[i] = Math.cos(angle) * v; |
| 69 | } |
| 70 | this.directFx = new Float32Array(this.count); |
| 71 | this.directFy = new Float32Array(this.count); |
| 72 | } |
| 73 | |
| 74 | protected onParameterChange(key: string): void { |
| 75 | if (key === "bodies") this.reset(); |
| 76 | } |
| 77 | |
| 78 | resize(width: number, height: number): void { |
| 79 | this.width = width; |
| 80 | this.height = height; |
| 81 | this.reset(); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Insert one body into the quadtree. Leaves hold linked lists of bodies; |
| 86 | * a leaf with more than one body subdivides (depth-capped), so coincident |
| 87 | * positions never drop mass from the tree. |
| 88 | */ |
| 89 | private insert(body: number): void { |
| 90 | let node = 0; |
| 91 | let depth = 0; |
| 92 | while (this.nodeBody[node] === -2) { |
| 93 | depth += 1; |
| 94 | const q = this.quadrant(node, this.x[body], this.y[body]); |
| 95 | if (this.nodeChild[node * 4 + q] === -1) { |
| 96 | this.nodeChild[node * 4 + q] = this.newNode( |
| 97 | this.childX(node, q), |
| 98 | this.childY(node, q), |
| 99 | this.nodeSize[node] / 2, |
| 100 | ); |
| 101 | } |
| 102 | node = this.nodeChild[node * 4 + q]; |
| 103 | } |
| 104 | this.placeInLeaf(node, body, depth); |
| 105 | } |
| 106 | |
| 107 | private placeInLeaf(node: number, body: number, depth: number): void { |
| 108 | this.nextBody[body] = this.nodeBody[node]; |
| 109 | this.nodeBody[node] = body; |
| 110 | if (depth >= 48 || this.listLength(node) <= 1) return; |
| 111 | // Subdivide: detach the list, mark internal, re-place every body. |
| 112 | let b = this.nodeBody[node]; |
| 113 | const list: number[] = []; |
| 114 | while (b !== -1) { |
| 115 | list.push(b); |
| 116 | b = this.nextBody[b]; |
| 117 | } |
| 118 | this.nodeBody[node] = -2; |
| 119 | for (const bb of list) { |
| 120 | const q = this.quadrant(node, this.x[bb], this.y[bb]); |
| 121 | if (this.nodeChild[node * 4 + q] === -1) { |
| 122 | this.nodeChild[node * 4 + q] = this.newNode( |
| 123 | this.childX(node, q), |
| 124 | this.childY(node, q), |
| 125 | this.nodeSize[node] / 2, |
| 126 | ); |
| 127 | } |
| 128 | this.placeInLeaf(this.nodeChild[node * 4 + q], bb, depth + 1); |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | private listLength(node: number): number { |
| 133 | let n = 0; |
| 134 | for (let b = this.nodeBody[node]; b !== -1; b = this.nextBody[b]) n += 1; |
| 135 | return n; |
| 136 | } |
| 137 | |
| 138 | private newNode(x: number, y: number, size: number): number { |
| 139 | if (this.nodeCount >= this.nodeMass.length) { |
| 140 | const grow = <T extends Float64Array | Int32Array>(arr: T, minLen: number): T => { |
| 141 | const next = new (arr.constructor as { new(length: number): T })(Math.max(minLen, arr.length * 2)); |
| 142 | next.set(arr); |
| 143 | return next; |
| 144 | }; |
| 145 | this.nodeChild = grow(this.nodeChild, 64).fill(-1, this.nodeCount * 4); |
| 146 | this.nodeMass = grow(this.nodeMass, 16); |
| 147 | this.nodeComX = grow(this.nodeComX, 16); |
| 148 | this.nodeComY = grow(this.nodeComY, 16); |
| 149 | this.nodeX = grow(this.nodeX, 16); |
| 150 | this.nodeY = grow(this.nodeY, 16); |
| 151 | this.nodeSize = grow(this.nodeSize, 16); |
| 152 | this.nodeBody = grow(this.nodeBody, 16); |
| 153 | } |
| 154 | const id = this.nodeCount++; |
| 155 | this.nodeChild[id * 4] = -1; |
| 156 | this.nodeChild[id * 4 + 1] = -1; |
| 157 | this.nodeChild[id * 4 + 2] = -1; |
| 158 | this.nodeChild[id * 4 + 3] = -1; |
| 159 | this.nodeMass[id] = 0; |
| 160 | this.nodeX[id] = x; |
| 161 | this.nodeY[id] = y; |
| 162 | this.nodeSize[id] = size; |
| 163 | this.nodeBody[id] = -1; |
| 164 | return id; |
| 165 | } |
| 166 | |
| 167 | private quadrant(node: number, x: number, y: number): number { |
| 168 | const half = this.nodeSize[node] / 2; |
| 169 | const right = x > this.nodeX[node] + half; |
| 170 | const bottom = y > this.nodeY[node] + half; |
| 171 | return (right ? 1 : 0) + (bottom ? 2 : 0); |
| 172 | } |
| 173 | |
| 174 | private childX(node: number, q: number): number { |
| 175 | return this.nodeX[node] + (q % 2 === 1 ? this.nodeSize[node] / 2 : 0); |
| 176 | } |
| 177 | |
| 178 | private childY(node: number, q: number): number { |
| 179 | return this.nodeY[node] + (q >= 2 ? this.nodeSize[node] / 2 : 0); |
| 180 | } |
| 181 | |
| 182 | private computeMass(node: number): void { |
| 183 | if (this.nodeBody[node] >= 0) { |
| 184 | let mass = 0; |
| 185 | let comX = 0; |
| 186 | let comY = 0; |
| 187 | for (let b = this.nodeBody[node]; b !== -1; b = this.nextBody[b]) { |
| 188 | mass += 1; |
| 189 | comX += this.x[b]; |
| 190 | comY += this.y[b]; |
| 191 | } |
| 192 | this.nodeMass[node] = mass; |
| 193 | this.nodeComX[node] = comX / mass; |
| 194 | this.nodeComY[node] = comY / mass; |
| 195 | return; |
| 196 | } |
| 197 | let mass = 0; |
| 198 | let comX = 0; |
| 199 | let comY = 0; |
| 200 | for (let q = 0; q < 4; q++) { |
| 201 | const child = this.nodeChild[node * 4 + q]; |
| 202 | if (child === -1) continue; |
| 203 | this.computeMass(child); |
| 204 | mass += this.nodeMass[child]; |
| 205 | comX += this.nodeMass[child] * this.nodeComX[child]; |
| 206 | comY += this.nodeMass[child] * this.nodeComY[child]; |
| 207 | } |
| 208 | this.nodeMass[node] = mass; |
| 209 | this.nodeComX[node] = mass > 0 ? comX / mass : this.nodeX[node]; |
| 210 | this.nodeComY[node] = mass > 0 ? comY / mass : this.nodeY[node]; |
| 211 | } |
| 212 | |
| 213 | /** Accumulate force on body i by walking the tree with the theta criterion. */ |
| 214 | private treeForce(i: number, node: number): void { |
| 215 | if (this.nodeMass[node] === 0) return; |
| 216 | // Leaves are always resolved exactly, body by body. |
| 217 | if (this.nodeBody[node] >= 0) { |
| 218 | for (let b = this.nodeBody[node]; b !== -1; b = this.nextBody[b]) { |
| 219 | if (b === i) continue; |
| 220 | const ddx = this.x[b] - this.x[i]; |
| 221 | const ddy = this.y[b] - this.y[i]; |
| 222 | const dd = Math.sqrt(ddx * ddx + ddy * ddy + this.softening * this.softening); |
| 223 | const force = (this.G * 1) / (dd * dd * dd); |
| 224 | this.fx[i] += ddx * force; |
| 225 | this.fy[i] += ddy * force; |
| 226 | } |
| 227 | return; |
| 228 | } |
| 229 | const dx = this.nodeComX[node] - this.x[i]; |
| 230 | const dy = this.nodeComY[node] - this.y[i]; |
| 231 | const d = Math.sqrt(dx * dx + dy * dy + this.softening * this.softening); |
| 232 | // Far internal node: one center-of-mass interaction. |
| 233 | if (this.nodeSize[node] / d < this.num("theta")) { |
| 234 | const force = (this.G * this.nodeMass[node]) / (d * d * d); |
| 235 | this.fx[i] += dx * force; |
| 236 | this.fy[i] += dy * force; |
| 237 | return; |
| 238 | } |
| 239 | for (let q = 0; q < 4; q++) { |
| 240 | const child = this.nodeChild[node * 4 + q]; |
| 241 | if (child !== -1) this.treeForce(i, child); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | private directForce(): void { |
| 246 | for (let i = 0; i < this.count; i++) { |
| 247 | let fxi = 0; |
| 248 | let fyi = 0; |
| 249 | for (let j = 0; j < this.count; j++) { |
| 250 | if (j === i) continue; |
| 251 | const dx = this.x[j] - this.x[i]; |
| 252 | const dy = this.y[j] - this.y[i]; |
| 253 | const d = Math.sqrt(dx * dx + dy * dy + this.softening * this.softening); |
| 254 | const s = (this.G * 1) / (d * d * d); |
| 255 | fxi += dx * s; |
| 256 | fyi += dy * s; |
| 257 | } |
| 258 | this.directFx[i] = fxi; |
| 259 | this.directFy[i] = fyi; |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | protected onUpdate(dt: number): void { |
| 264 | // Root bounds must enclose every body, or the tree's s/d criterion lies. |
| 265 | let minX = Infinity; |
| 266 | let minY = Infinity; |
| 267 | let maxX = -Infinity; |
| 268 | let maxY = -Infinity; |
| 269 | for (let i = 0; i < this.count; i++) { |
| 270 | if (this.x[i] < minX) minX = this.x[i]; |
| 271 | if (this.y[i] < minY) minY = this.y[i]; |
| 272 | if (this.x[i] > maxX) maxX = this.x[i]; |
| 273 | if (this.y[i] > maxY) maxY = this.y[i]; |
| 274 | } |
| 275 | const span = Math.max(maxX - minX, maxY - minY) * 1.05 + 1; |
| 276 | this.nodeChild = new Int32Array(this.count * 8 * 4).fill(-1); |
| 277 | this.nodeMass = new Float64Array(this.count * 8); |
| 278 | this.nodeComX = new Float64Array(this.count * 8); |
| 279 | this.nodeComY = new Float64Array(this.count * 8); |
| 280 | this.nodeX = new Float64Array(this.count * 8); |
| 281 | this.nodeY = new Float64Array(this.count * 8); |
| 282 | this.nodeSize = new Float64Array(this.count * 8); |
| 283 | this.nodeBody = new Int32Array(this.count * 8).fill(-1); |
| 284 | this.nodeCount = 0; |
| 285 | this.newNode(minX, minY, span); |
| 286 | for (let i = 0; i < this.count; i++) this.insert(i); |
| 287 | this.computeMass(0); |
| 288 | |
| 289 | // Forces first, for every body, at this tick's positions... |
| 290 | for (let i = 0; i < this.count; i++) { |
| 291 | this.fx[i] = 0; |
| 292 | this.fy[i] = 0; |
| 293 | this.treeForce(i, 0); |
| 294 | } |
| 295 | // ...so the periodic audit compares direct and tree forces on the SAME |
| 296 | // positions - never a stale sample from before the bodies moved. |
| 297 | this.directTick += 1; |
| 298 | if (this.directTick % 90 === 0) { |
| 299 | this.directForce(); |
| 300 | let err = 0; |
| 301 | let total = 0; |
| 302 | for (let i = 0; i < this.count; i++) { |
| 303 | err += Math.hypot(this.fx[i] - this.directFx[i], this.fy[i] - this.directFy[i]); |
| 304 | total += Math.hypot(this.directFx[i], this.directFy[i]); |
| 305 | } |
| 306 | this.forceError = total > 0 ? err / total : 0; |
| 307 | } |
| 308 | for (let i = 0; i < this.count; i++) { |
| 309 | this.vx[i] += this.fx[i] * dt; |
| 310 | this.vy[i] += this.fy[i] * dt; |
| 311 | this.x[i] += this.vx[i] * dt; |
| 312 | this.y[i] += this.vy[i] * dt; |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 317 | ctx.fillStyle = theme.bg; |
| 318 | ctx.fillRect(0, 0, this.width, this.height); |
| 319 | if (this.str("showTree") === "on") { |
| 320 | ctx.strokeStyle = theme.line; |
| 321 | ctx.lineWidth = 1; |
| 322 | for (let node = 1; node < this.nodeCount; node++) { |
| 323 | ctx.strokeRect(this.nodeX[node], this.nodeY[node], this.nodeSize[node], this.nodeSize[node]); |
| 324 | } |
| 325 | } |
| 326 | for (let i = 0; i < this.count; i++) { |
| 327 | ctx.fillStyle = i % 24 === 0 ? theme.accent : theme.fg; |
| 328 | ctx.fillRect(this.x[i] - 1.2, this.y[i] - 1.2, 2.4, 2.4); |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | getMetrics() { |
| 333 | return { |
| 334 | bodies: this.count, |
| 335 | quadtreeNodes: this.nodeCount.toLocaleString("en-US"), |
| 336 | theta: this.num("theta").toFixed(1), |
| 337 | forceError: `${(this.forceError * 100).toFixed(2)} %`, |
| 338 | }; |
| 339 | } |
| 340 | |
| 341 | describe(): string { |
| 342 | return `${this.count} bodies under Barnes-Hut gravity: a ${this.nodeCount.toLocaleString("en-US")}-node quadtree approximates distant clusters within opening angle ${this.num("theta").toFixed(1)}, measured force error ${(this.forceError * 100).toFixed(2)}%.`; |
| 343 | } |
| 344 | |
| 345 | entities(): number { |
| 346 | return this.count; |
| 347 | } |
| 348 | } |
| 349 | |
Performance
- Time per tick
- O(n log n) per tick for the tree plus force walk
- Space
- O(n) tree nodes per tick
- Notes
- A spiral-galaxy initial condition makes clusters visible: turn on the quadtree overlay and watch the box boundaries follow the density.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.