Simulation of Gravity / Galaxy Birth simulation

July 12, 2026 ยท View on GitHub

Simulation gif

N-Body Simulation

The simulation is an N-Body system, where all particles interact with each other. It can be referred to as a galaxy simulation, although certain assumptions are made. The formation of a galaxy is a lengthy process, shaped over millions of years of interactions. Furthermore, the sheer number of particles in a galaxy is so immense that recreating the birth of a galaxy with high accuracy is beyond the capabilities of any computer. However, this simulation provides the opportunity to witness the process on a smaller scale.

50,000 particles forms a Galaxy-like image (Try it yourself: #1, #2, #3)

Imported universe state files restore the particle data and saved universe parameters without rewriting the current URL. Runtime choices such as backend, worker count, renderer, debug options and performance tuning remain unchanged unless they are part of the imported state. Large local exports use the binary .nbody format: a compact JSON header followed by the raw interleaved Float32 particle buffer. This avoids the memory and string-size limits of JSON exports with tens of millions of particles. Legacy JSON state files remain importable.

image image image image image image image

Given the complexity of accurately calculating gravitational interactions, several optimizations have been employed. The particles are organized into hierarchical segments, forming a Spatial Tree. Each particle within a segment interacts with every other particle in the same segment, rather than engaging with particles in different segments. Consequently, the segments themselves are treated as larger particles and interact with one another. This approach ensures an acceptable complexity level: O(N*logN), as opposed to the unoptimized O(N*N) approach.

Visualization of Spatial tree used to optimize 100,000 particles interaction

image

You can see Spatial Tree segmentation in real-time with the default CPU backend: link

In practical terms, this means that we can simulate 100,000 particles using approximately 500,000 operations. Without optimization, simulating 100,000 particles would require 10,000,000,000 operations, which is 20,000 times more computationally intensive.

Visualization of 1,000,000 particles (click image to open YouTube video)

image

  • Accurate CPU simulation, galaxy-like pattern may born (#1): link
  • Accurate CPU simulation, galaxy-like pattern may born (#2): link
  • Fast CPU simulation (#1): link
  • Fast CPU simulation (#2): link
  • Big CPU multithreaded simulation (#1): link
  • Big CPU multithreaded simulation (#2): link
  • Big GPGPU simulation (#1): link
  • Big GPGPU simulation (#2): link
  • Particle collisions CPU (accurate): link
  • Particle collisions CPU (fast): link
  • Particle collisions GPGPU (accurate): link
  • Particle collisions GPGPU (fast): link

More links you can find below.

Simulation Player

You can use Simulation Player to watch recorded simulations.

Playback is paced from recording time rather than from the browser's raw requestAnimationFrame count. The player presents at the configured target FPS, uses DFRI to interpolate between adjacent recorded frames, and skips source frames when a high playback speed requires more than one recorded frame per target frame. The settings popup also exposes the WebGL particle sprite, color mode and fixed-color picker used by the live simulation.

Note: Please be patient, files may be very large, so loading may take a while. Pay attention to the package size written in brackets.

Demo links:

Real-Time Simulation

You can combine different parameters, renderer and backend. To change parameter just add it to url as query parameter, e.g.: /?particle_count=50000&particle_init=bang

Collision:

  • Enabled collisions with the default CPU backend: link Collision impulses use a configurable dense-contact response: collision_contacts=balanced (default) uses RMS-style normalization, average is calmer, and full keeps the complete multi-contact sum. collision_separation adds a small penetration-depth bias so overlapping or resting particles can move apart instead of sticking. collision_cap limits only pathological full sums, while collision_micro=0 keeps tiny bounce impulses active.
  • Enabled collisions with gpgpu simulation: link
  • Enabled collisions with gpgpu simulation and min_distance=3: link

Different initializers:

  • circle initializer: link
  • uniform initializer: link
  • bang initializer: link
  • rotation initializer: link
  • collision initializer: link
  • swirl initializer: link

Different gravity forces:

  • rotation initializer with x1000 gravity: link
  • collision initializer with x1000 gravity: link

Different resistance:

  • bang initializer with 0.99 resistance and x100 gravity: link
  • collision initializer with 0.995 resistance and x100 gravity: link

Particle mass variation:

  • Mass variation 3 and x0.5 gravity: link
  • Mass variation 5 with accurate gpgpu simulation: link
  • Mass vartiation 10 with accurate big gpgpu simulation: link

Debug mode:

  • Spatial tree with CPU multithreading: link
  • Speed and momentum vectors: link

Renderer

Supported render engines:

Canvas

Use HTML5 Canvas to render particles. In order to reduce delays, rendering through the ImageBuffer is utilized. The rendering performs well on mobile platforms but experiences a significant drop in performance at high resolutions. Furthermore, canvas renderer does not support dynamic particle size, making particles difficult to discern on screens with high pixel density.

Demo links:

  • With enabled device pixel rate: link
  • With disabled device pixel rate: link
webgl2

Use WebGL 2.0 to render particles. This rendering method effectively displays numerous particles in high resolution. The render works well on screens with high pixel density and maintains dynamic particle size. However, it may not function on older browser versions and older mobile devices. This rendering technique is highly recommended for screens with high refresh rates due to its fast performance.

Demo links:

  • With enabled device pixel rate: link
  • With disabled device pixel rate: link

Backend

Supported backends:

worker-mt

worker-mt is the default and recommended CPU backend. It uses one production pipeline rather than exposing the historical scheduler experiments:

  • four root seed regions are prepared in parallel;
  • heavy regions are split recursively and returned to a globally sorted queue;
  • exact interactions inside leaves use the symmetric pair kernel, evaluating every pair once;
  • SharedArrayBuffer keeps particle and tree-index data shared between workers.

The only worker-specific tuning exposed to users is worker_threads. auto keeps one logical processor available for the coordinator/main thread and selects the nearest supported worker count. The effective leaf size can be controlled with segment_max_count or selected automatically with segment_auto=1.

Real multithreading requires cross-origin isolation and SharedArrayBuffer. On static hosting, the app can install a local COOP/COEP service worker and reload once. If isolation is unavailable, worker-mt falls back to its single-threaded path.

Demos with different worker counts:

  • Auto threads, 500k particles: link
  • 4 threads, 500k particles: link
  • 8 threads, 1M particles: link
  • 12 threads, 1M particles: link
  • 16 threads, 2M particles: link
  • 20 threads, 3M particles: link

Heavier multithreaded demos:

  • 750k particles with 4 threads: link
  • 1M particles with 4 threads and fixed leaf size: link
worker

worker is the legacy single-threaded CPU backend. It is still useful as a compatibility fallback and as a baseline when comparing the multithreaded scheduler, but it is no longer the recommended CPU path for large real-time demos. Prefer worker-mt for normal desktop CPU simulations.

The single-threaded worker keeps physics calculations away from the main rendering thread, so the UI can remain responsive, but all physics work still runs in one worker. For larger N-Body demos this usually becomes the bottleneck much earlier than worker-mt.

You can fine-tune the performance by adjusting the segmentation_max_count parameter. Decreasing its value reduces the computational complexity but sacrifices the accuracy of the simulation.

Legacy single-threaded demos with different segment max sizes:

  • Max segment size 8: link
  • Max segment size 32: link
  • Max segment size 128: link
  • Max segment size 256: link

Increasing segmentation_max_count significantly degrades performance, but improves calculation accuracy.

Legacy maximum-accuracy single-threaded demos:

  • Max segment size 1024: link
  • Max segment size 2048: link
  • Max segment size 4096: link
gpgpu

GPGPU (General-purpose computing on graphics processing units) is employed for calculations in a dedicated worker, utilizing the power of the GPU. The highly parallelized nature of these computations enables significant acceleration, particularly for complex simulation configurations. However, it's worth noting that this method may not be suitable for mobile platforms but delivers excellent results on desktops equipped with discrete graphics cards.

In this calculation method, the segmentation_max_count parameter is interpreted as the dimension of the 2D texture, indicating the size of each segment. For example, a value of 128 actually corresponds to a segment size of 16,348 (128 * 128).

This method enables the simulation of gravity with utmost accuracy, accommodating a high volume of particles. However, it exhibits inefficiency and performs worse than the worker backend when used with small segment sizes.

Demos with different segment max sizes:

  • Max segment size 64*64 and 32k particles: link
  • Max segment size 128*128 and 131k particles: link
  • Max segment size 256*256 and 262k particles: link

Although increasing the segmentation_max_count adversely affects GPGPU performance, it facilitates the simulation of a significantly larger number of particles while maintaining maximum accuracy.

Simulation demo links with maximum accuracy:

  • Max segment size 96*96: link
  • Max segment size 128*128: link
  • Max segment size 176*176: link
  • Max segment size 256*256: link

Parameters:

See params description here: link

Tests

The project uses the built-in Node.js test runner, so no separate test framework is required.

npm install
npm test
npm run test:coverage

Limitations:

Application originally developed and optimized for Chrome browser. In other browsers app can have significant performance degradation.

Known issues

Worker MT production pipeline

The historical static, dynamic, recursive and configurable hybrid variants have been consolidated into one tested worker-mt pipeline. The implementation always uses shallow parallel seed bootstrap, split-first recursive work release, tail-oriented queue sorting and symmetric leaf-force evaluation. These details remain visible in performance reports, but they are no longer user-selectable settings.

segment_random remains available because randomized split positions soften persistent grid-aligned artifacts caused by the spatial-tree approximation. Set it to 0 when you need clearly visible deterministic segmentation for debugging.

tree_mass_center controls where an approximated tree block places its aggregate mass. It defaults to 1 (center of mass); set it to 0 to reproduce the legacy geometric cell-center model.

Reproducible performance benchmark suites

The browser console exposes window.nBodyRunBenchmark(...) for comparing settings against the exact same live universe state. The runner captures one in-memory particle snapshot at the start, restores it before every case, restarts the physics pipeline, waits for warm-up physics steps and a stabilization delay, and then calls the regular performance report collector.

Object-form cases creates the Cartesian product of array values. Scalar values are applied to every generated case:

await window.nBodyRunBenchmark({
  name: "heavy-tree-grid",
  cases: {
    worker_threads: [12, 16, 20],
    segment_max_count: [24, 32, 48]
  },
  warmupSteps: 3,
  stabilizationMs: 500,
  report: {
    frames: 240,
    blocks: 4,
    intervalMs: 5000
  }
})

Array-form cases runs only the explicitly listed combinations:

await window.nBodyRunBenchmark({
  name: "selected-cases",
  cases: [
    {
      name: "baseline",
      worker_threads: 16,
      segment_max_count: 32
    },
    {
      name: "lower-leaf-size",
      worker_threads: 16,
      segment_max_count: 24
    }
  ]
})

Setting keys can use URL/query names such as worker_threads, JavaScript property names such as workerThreads, or full paths such as simulation.workerThreads. Settings that require a page reload or change the captured particle universe are rejected before the suite starts.

The runner downloads one ZIP containing:

manifest.json
config.json
summary.json
summary.csv
cases/*.json
reports/*.json

The original snapshot and settings are restored after the suite by default. Use window.nBodyCancelBenchmark() to stop a running suite and window.nBodyBenchmarkHelp() for console examples.

For WebGL buffer uploads, use the same generic benchmark runner with an explicit balanced case list. No separate benchmark API is needed:

await window.nBodyRunBenchmark({
  name: "webgl-buffer-upload-modes",
  cases: [
    {name: "stream-start", upload_mode: "stream"},
    {name: "buffer-data-1", upload_mode: "bufferData"},
    {name: "buffer-sub-data-1", upload_mode: "bufferSubData"},
    {name: "stream-middle", upload_mode: "stream"},
    {name: "buffer-sub-data-2", upload_mode: "bufferSubData"},
    {name: "buffer-data-2", upload_mode: "bufferData"},
    {name: "stream-end", upload_mode: "stream"}
  ],
  warmupSteps: 5,
  stabilizationMs: 1000,
  report: {
    frames: 240,
    blocks: 4,
    intervalMs: 5000
  }
})

Each case restores the same captured universe. The regular summary already includes uploadMode, average and p95 render/upload times, uploaded MiB per frame, draw-call time and GPU draw time. Upload-mode performance is driver-dependent. Measurements on the target 120 Hz system showed a consistent advantage for bufferSubData, so it is now the default; stream and bufferData remain available as compatibility alternatives.