Many-body Simulation demo annotated source

Back to index

        

A many-body simulation of gravitationally interacting masses, designed as a potential DOM stress test. (This is why this is implemented in DOM. Otherwise, this would be ideal for canvas2D.)

4

Bootstrap the required globals from Torus, since we're not bundling

6for (const exportedName in Torus) {
7    window[exportedName] = Torus[exportedName];
8}
9

We allow the user to specify the number of particles to include in the simulated universe using the q=??? query parameter in the URL. This bit of code tries to detect that using a pretty native but reliable approach.

13let digits = null;
14if (window.location.search.length > 0) {
15    const digitsMatch = window.location.search.match(/\Wp=(\d*)/);
16    if (digitsMatch !== null) {
17        digits = +digitsMatch[1];
18    }
19}
20

Constants in the simulation are set here.

22const PARTICLE_COUNT = digits || 500;
23const PARTICLE_DIAMETER = 4;
24const GRAV_CONST = 2000;
25
26console.log(`Simulating with ${PARTICLE_COUNT} particles.`);
27

These functions are used to seed the initial particle positions such that they're uniformly distributed within the browser viewport.

30const randomWindowX = () => {
31    return Math.random() * window.innerWidth;
32}
33const randomWindowY = () => {
34    return Math.random() * window.innerHeight;
35}
36

Class ParticleSystem models the many-body problem and behavior of gravitationally attracted objects. It's in charge of computing incremental changes to positions and velocities in every frame.

40class ParticleSystem {
41
42    constructor() {

Each entry in this.particles is [xPos, yPos, xVel, yVel, mass] Particle data are represented in a naked array (not class instances or objects) for performance.

46        this.particles = [];
47        for (let i = 0; i < PARTICLE_COUNT; i ++) {
48            this.particles.push([randomWindowX(), randomWindowY(), 0, 0, 1]);
49        }
50    }
51

step() runs a single frame of the simulation, assuming the frame was duration seconds long. This step takes a while even on modern computers for n > 1000, and may benefit from a more optimized data structure for particles like a quadtree.

56    step(duration) {

Memoize.

58        const particles = this.particles;
59        const len = particles.length;
60

First, loop through all particles and update their velocities from our newly computed values of acceleration between particles.

63        for (let i = 0; i < PARTICLE_COUNT; i ++) {
64            const p = particles[i];
65            let xAcc = 0;
66            let yAcc = 0;
67            for (let j = 0; j < len; j ++) {

Particles should only be attracted to particles that aren't them.

69                if (j !== i) {
70                    const q = particles[j];
71
72                    const xOffset = p[0] - q[0];
73                    const yOffset = p[1] - q[1];
74
75                    let sqDiagonal = (xOffset * xOffset) + (yOffset * yOffset);
76                    if (sqDiagonal < PARTICLE_DIAMETER) {
77                        sqDiagonal = PARTICLE_DIAMETER;
78                    }
79                    const diagonal = Math.sqrt(sqDiagonal)

This seems a little odd, but is a more performant, least redundant to compute something mathematically equivalent to the formula for gravitational acceleration.

83                    const accel = ((GRAV_CONST / sqDiagonal) / diagonal) * q[4];
84
85                    xAcc -= accel * xOffset;
86                    yAcc -= accel * yOffset;
87                }
88            }
89            p[2] += xAcc * duration;
90            p[3] += yAcc * duration;
91        }
92

Now that we have new velocities, update positions from those velocities.

94        for (let i = 0; i < PARTICLE_COUNT; i ++) {
95            const part = particles[i];
96            part[0] += part[2] * duration;
97            part[1] += part[3] * duration;
98        }
99    }
100
101}
102

The Particle function is a functional Torus component that renders an individual point, given the data backing the point from the simulation. To minimize any overhead of jdom parsing the HTML template at runtime, this functional component returns a dictionary representing the new DOM.

106const Particle = pData => {

We floor (~~) the result here, because the exact velocity doesn't matter, and it reduces Torus's parsing overhead for CSS -- these are microoptimizations.

109    const vel = ~~Math.sqrt((pData[2] * pData[2]) + (pData[3] * pData[3]));
110    return {
111        tag: 'div',
112        attrs: {
113            class: 'particle',
114            style: {

We use transform to position our particles on the page.

116                transform: `translate(${pData[0]}px, ${pData[1]}px)`,

Background color of these particles vary by their velocities.

118                backgroundColor: `hsl(${vel > 240 ? 240 : vel}, 90%, 60%)`,
119            },
120        },
121    }
122}
123

The Simulation component represents all simulation state and the view that encapsulates it.

125class Simulation extends StyledComponent {
126
127    init() {
128        this.system = new ParticleSystem();
129

Create a function to be called at every animation frame, for a demo. step() measure the elapsed time since last call, and steps through the simulated system by that elapsed duration, then calls render.

133        let lastTime = new Date().getTime();
134        const step = () => {
135            const thisTime = new Date().getTime();
136            this.system.step((thisTime - lastTime) / 1000);
137            lastTime = thisTime;
138            this.render();

We use requestAnimationFrame to schedule re-renders every reasonable frame.

140            requestAnimationFrame(step);
141        }
142        step();
143

Bind event listeners.

145        this.handleMousedown = this.handleMousedown.bind(this);
146        this.handleMousemove = this.handleMousemove.bind(this);
147        this.handleMouseup = this.handleMouseup.bind(this);
148        this.trackingMouse = false;
149    }
150

When the user starts dragging on the screen, we represent that point as a 100x more massive particle in the system, with constant 0 velocity.

153    handleMousedown(evt) {
154        this.trackingMouse = true;
155        this.system.particles.push([
156            evt.clientX,
157            evt.clientY,
158            0,
159            0,
160            100,
161        ]);
162    }
163

When the user moves the mouse, if we're dragging, move the touch particle position.

165    handleMousemove(evt) {
166        if (this.trackingMouse) {
167            const touchParticle = this.system.particles[PARTICLE_COUNT];
168            touchParticle[0] = evt.clientX;
169            touchParticle[1] = evt.clientY;
170        }
171    }
172

Stop dragging and remove the touch particle.

174    handleMouseup() {
175        this.trackingMouse = false;
176        this.system.particles.pop();
177    }
178
179    styles() {
180        return {
181            'background': '#000',
182            'height': '100vh',
183            'width': '100vw',
184            'position': 'absolute',
185            'top': '0',
186            'left': '0',
187            'overflow': 'hidden',
188

Because particles are just functions that map to JDOM (not Torus components themselves), we define their styles here.

191            '.particle': {
192                'height': PARTICLE_DIAMETER + 'px',
193                'width': PARTICLE_DIAMETER + 'px',
194                'border-radius': (PARTICLE_DIAMETER / 2) + 'px',
195                'background': '#fff',
196                'position': 'absolute',
197                'top': '0',
198                'left': '0',
199            },
200        }
201    }
202
203    compose() {

Touch support is trivial, but not added for sake of simplicity. If we wanted to add multi-touch support, we'd do something analogous to what we've done for mouse click.

207        return jdom`<div class="simulation"
208            onmousedown="${this.handleMousedown}"
209            onmousemove="${this.handleMousemove}"
210            onmouseup="${this.handleMouseup}"
211            >
212            ${this.system.particles.map(p => Particle(p))}
213        </div>`;
214    }
215
216}
217

Main app that contains the simulation and a couple of other UI elements.

219class App extends StyledComponent {
220
221    init() {
222        this.simulation = new Simulation();
223    }
224
225    styles() {
226        return {
227            'footer': {
228                'position': 'absolute',
229                'right': '4px',
230                'bottom': '4px',
231                'color': '#ccc',
232                'font-family': 'sans-serif',
233                'font-size': '14px',
234            },
235            'a': {
236                'color': '#ccc',
237                'cursor': 'pointer',
238                '&:hover': {
239                    'opacity': '.7',
240                },
241            },
242        }
243    }
244
245    compose() {
246        return jdom`<main>
247            ${this.simulation.node}
248            <footer>
249                DOM / JS stress test by
250                <a href="https://linus.zone/now">Linus</a>,
251                built with
252                <a href="https://linus.zone/torus">Torus</a>,
253            </footer>
254        </main>`;
255    }
256
257}
258

Create an instance of the app and mount it to the page DOM.

260const app = new App();
261document.body.appendChild(app.node);
262document.body.style.margin = '0';
263
264