Real-time GPU particle simulation built with ModernGL + GLFW where all physics runs in a GLSL compute shader. Demo video: https://www.youtube.com/watch?v=-8xZGHFHp90
Every engine takes --bench and sweeps N until a frame crosses abort_ms. The
slower engines share one config; P3M gets its own because it runs far enough that
bench_config_gpu.json would cut it short:
cd src
python Particles_Test_GPU_p3m.py --bench --config bench_config_p3m_sweep.json --out gpu_p3m_sweep.csv --mesh-res 512
python Particles_Test_GPU_bruteforce.py --bench --config bench_config_gpu.json --out gpu_bruteforce.csv
python Particles_Test_GPU_grid.py --bench --config bench_config_gpu.json --out gpu_grid.csv
python Particles_Test_CPU.py --bench --config bench_config.json --out cpu_moderngl.csv
python Particles_in_pygame.py --bench --config bench_config.json --out cpu_pygame.csv
python plot_bench.py --y fps --logx --logy --out ../assets/bench_gpu_vs_cpu.png \
--csv "GPU P3M (512 mesh)=gpu_p3m_sweep.csv" \
--csv "GPU brute force O(N^2)=gpu_bruteforce.csv" \
--csv "GPU uniform grid (capped)=gpu_grid.csv" \
--csv "CPU physics + GPU render (ModernGL)=cpu_moderngl.csv" \
--csv "CPU physics + CPU draw (Pygame)=cpu_pygame.csv"compute_ms is a GL timer query around the GPU physics passes only. frame_ms is the whole
frame including swap. --bench turns vsync off so you measure the GPU and not your
monitor.
Full benchmark sweep (all measured particle counts and all five engines):
Run on an RTX 5060, driver 591.59, GL 4.3, 900x700 window. Both axes are log.
| Engine | fps at N=400 | N at 60 fps | Largest N measured |
|---|---|---|---|
| GPU, P3M 512 mesh | ~247 | ~1,400,000 | 14,846,156 @ 0.65 fps |
| GPU, brute force O(N²) | 3621 | ~44,000 | 223,113 @ 3.4 fps |
| GPU, uniform grid (capped) | 1380 | ~42,100 | 377,059 @ 9.9 fps |
| CPU physics + GPU render | 11.7 | ~157 | 400 @ 11.7 fps |
| CPU physics + CPU draw (Pygame) | 12.5 | ~167 | 400 @ 12.5 fps |
The brute-force and grid lines are flat on the left because the GPU is mostly measuring launch overhead. P3M starts near 243 fps because every frame performs three fixed-size FFTs; unlike the other engines, its cost then barely changes as particles are added.
At N=400, which is about all the CPU version can handle, brute force on the GPU runs 309x faster. At the other end, P3M holds 377,000 particles at 196 fps while the capped grid manages 10 fps. P3M stays above 60 fps to about 1.4 million particles and was swept to 14.8 million at 0.65 fps, where a frame takes 1.5 s; the sweep stops there to stay under the 2 s Windows driver watchdog, not because the GPU ran out of memory (it peaked near 2 GB of 8 GB).
The brute force kernel does roughly 170 billion pair interactions per second. At N=223,113 that is 5x10^10 pairs in 267 ms.
Particles_Test_GPU_p3m.py preserves the full all-pairs force law. It places
the two particle types on a mesh as positive/negative density, evaluates the
smooth far field with GPU FFTs, and directly corrects extremely close pairs.
It also uses separate input/output particle buffers, so a time step never reads
positions another GPU thread is currently changing. Mesh deposition currently
uses NVIDIA's GL_NV_shader_atomic_float extension (available on the tested RTX 5060).
The error benchmark gives every solver the same frozen snapshot. At small N, a float64 CPU all-pairs calculation is the truth source; the race-free GPU brute solver and P3M are compared against it. The numbers below were rerun on August 4, 2026 using the same RTX 5060 as the full benchmark graph.
cd src
python bench_p3m_accuracy.py --mesh-res 256 512 --out-dir .Force accuracy at N=4,096 (particles initially concentrated in the center):
| Solver | Compute | RMS force error | 95th-percentile error¹ |
|---|---|---|---|
| GPU brute force, float32 | 0.543 ms | 0.00012% | 0.00023% |
| P3M, 256² mesh | 0.916 ms | 0.379% | 0.669% |
| P3M, 512² mesh | 3.619 ms | 0.0967% | 0.170% |
The FFT has a fixed cost, so P3M is slower at small N and rapidly wins as the all-pairs solver grows quadratically:
| N | Brute force | P3M 512² | Speedup |
|---|---|---|---|
| 20,000 | 2.86 ms | 3.64 ms | 0.8x |
| 50,000 | 18.17 ms | 3.70 ms | 4.9x |
| 100,000 | 62.60 ms | 3.63 ms | 17.2x |
| 200,000 | 234.31 ms | 3.73 ms | 62.9x |
Particle-by-particle trajectories eventually separate because this system is
chaotic. Starting both solvers from the same 2,048 particles, the 512² mesh had
0.11% of world-width position RMSE after 30 steps, 1.97% after 100, and 4.34%
after 300. A coarse 32×32 occupancy comparison was 1.12%, 25.1%, and 31.4% at
those checkpoints. This separates "the exact same particle trajectory" from
"the same large-scale visual distribution." Raw results are in
p3m_accuracy.csv, p3m_speed.csv, and p3m_drift.csv.
¹ Error magnitude divided by the reference RMS force magnitude.
python -m venv .venv
# Windows:
.venv\Scripts\activate
# macOS/Linux:
source .venv/bin/activate
pip install -r requirements.txtStart with src/Particles_Test_GPU_p3m.py for the fast accuracy-oriented version, or src/Particles_Test_GPU_grid.py for the cutoff-based visual experiment. Press spacebar to add particles. Particles added per spacebar press, forces, drag, and starting particle count are all constants at the top of the respective .py file (ex. at the top of Particles_Test_GPU_grid.py)
assets/ → images, GIFs, benchmarks
docs/ → notes
premade/ → visual experiments with premade configurations
src/ → CPU/GPU sims (press spacebar to add particles while running)
Particles_Test_GPU_p3m → full force law via GPU mesh FFT + direct near correction
Particles_Test_GPU_grid → neighbor-limited linked grid with a safety cap
Particles_Test_GPU_bruteforce → every pair; reference behavior, O(N²)
Particles_Test_CPU → physics on the CPU, rendered with ModernGL
Particles_in_pygame → physics on the CPU, drawn by Pygame
Every one of them takes --bench to sweep N and write a csv; plot_bench.py draws them together.
- Particles have types
- Same type → repel
- Different type → attract
- Simple local rules → complex global behavior
As particle count increases, structures like clusters, lattices, flows, and rigid formations emerge.

