Many-body Simulation demo annotated source
Back to indexA 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