A small 2D physics playground inspired by "2^27 balls dropped inside a circle": an offline MP4 renderer plus an interactive Pygame sandbox with multiple container shapes and live-tunable physics.
UI defaults to English; click EN / 中文 in the top-right to switch.
1. Offline renderer — sim.py
Renders the wall-only "drop balls into a circle" scene to output_nocollide.mp4
using NumPy + Numba.
uv run python sim.py- 22 000 balls in a 270 px circle
- Released simultaneously from a small cluster just below the top of the ring
- Per-frame colors = current y (top = red, bottom = violet)
- 10 s @ 60 FPS, ~15 MB
All knobs live in the CONFIG block at the top of sim.py.
2. Interactive sandbox — interactive.py / interactive_app.py
uv run python interactive.py # dev build (Numba JIT, faster physics)
uv run python interactive_app.py # pure-NumPy build (no JIT, ships in the .exe)Four container shapes (circle / square / triangle / ellipse), live sliders for gravity, restitution, damping, ball radius, balls-per-click and container size, plus pause / clear and language toggle.
| Mouse / Key | Action |
|---|---|
| Left click | Spawn a cluster of balls at the cursor |
| Right drag | Continuous spray |
| Scroll wheel | Fine-tune the slider under the cursor |
Esc |
Quit |
Everything else (gravity / restitution / damping / ball radius / spawn count / container size / shape / pause / clear / language) is on the left panel.
Grab BallSandbox.exe from the
latest release.
~20 MB, double-click to run. First launch takes 1-2 s while PyInstaller
unpacks to %TEMP%.
uv sync # creates .venv, installs numpy / numba / pygame-ce / imageio / pillow
uv run python interactive.pyuv is recommended; plain pip install -e .
in a venv also works.
Each physics substep (4× per frame → 240 Hz):
- Semi-implicit Euler integrate:
v += g·dt; v *= damping; x += v·dt - (offline renderer only) PBD position projection — uniform grid hash,
cell = max(2r, 1px), each ball checks its own cell + 8 neighbors - Re-derive
v = (x - x_prev) / dt(canonical PBD move) - Wall constraint — project the ball back inside and reflect the normal velocity. Must come after step 3, otherwise the position-derived velocity overwrites the bounce and balls appear to stick to the wall.
Per-shape wall constraints in the interactive sandbox:
- Circle: project to
R - r, reflect radial velocity - Square: 4 axis-aligned faces, each handled independently
- Triangle: equilateral, three outward edge normals + apothem
R/2 - Ellipse:
(x/a)² + (y/b)² > 1→ radial-scaling projection, gradient used as the surface normal for velocity reflection
Colors: each frame computes HSV → RGB from the current y coordinate so the ball cloud always reads as a top-red → bottom-violet spectrum.
Rendering:
- Ball radius ≤ 1 px: pokes pixels directly via
pygame.surfarray.pixels3d - Larger: per-ball
pygame.draw.circle
134 million balls is well past what this CPU pipeline can do. Practical paths:
- Taichi / CUDA: port
count / scatter / resolveinto GPU kernels withti.atomic_add. An RTX 30-series box can sustain ~10⁷ balls at 30 FPS. - NVIDIA Mochi (arXiv:2402.14801): treat balls as spheres in a BVH and trace queries with RT cores; reaches 10⁸.
- PIC / FLIP: once balls are well under one pixel each, individual collisions stop being visible. Most viral "many balls in a circle" videos are really fluid sims rendered as particles.
| File | Purpose |
|---|---|
sim.py |
Offline MP4 renderer (Numba) |
interactive.py |
Pygame sandbox, Numba physics — dev build |
interactive_app.py |
Pygame sandbox, pure NumPy — gets packaged into the exe |
snapshot.py |
Dumps PNG keyframes from sim.py |
gen_icon.py |
Procedurally renders icon.ico |
snapshots/ |
Pre-rendered keyframes |