The one-body problem

Using the WebGL-based Three.js library, learn how to model and visualize the dynamics of the one-body problem.

The one-body problem

Consider a universe devoid of everything except for a single celestial body (mass) such as a star or planet. For such a universe, the one-body problem is:

Given an initial position and velocity for the mass, predict its motion as a function of time.

The answer, as you may know, is straightforward:

  • If the velocity v of the mass is 0, the mass never moves (has no motion).
  • If the velocity v of the mass is not 0, the mass moves at a constant speed and direction, as defined by v.

Although the one-body problem has no interesting dynamics, it does provide an opportunity to set up a Three.js "universe", as described next.

The code

The source code for the one-body problem is available through 1BodyProblem.html and 1BodyWorker.js (right-click to view source).

We'll start with a skeletal version of 1BodyProblem.html:

<!DOCTYPE html>

<html>
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=Edge" /> <!-- For IE on an intranet. -->
  <title>One-Body Problem</title>
  <style>
    html, body {
      margin: 0;
      padding: 0;
    }

    body {
      width: 1024px; /* Currently, most screens can handle this. */
      margin: auto; /* Center the page content. */
      overflow: hidden; /* To stop the right-vertical scrollbar from "randomly" appearing. */
      background-color: #777;
      font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; /* Start screen font. */
    }

    header {
      color: #FFF;
      text-shadow: 5px 5px 10px #333;
    }

    section {
      position: relative; /* Float children relative to this element. */
    }

      section form {
        width: 210px; /* This is a bit less than the "section #WebGLCanvasElementContainer margin-left" value. */
        float: left;
        text-align: center; /* Center the button elements. */
      }

        section form fieldset {
          text-align: left; /* Undo the button center aligning trick for the text in the form. */
          margin-bottom: 1.25em; /* Adjust this so that the height of the form is about the same height as the WebGL Three.js viewport element. */
        }

          section form fieldset input {
            width: 100%;
          }

        section form td {
          white-space: nowrap; /* Don't let words like "x-position" break at the hyphen (which occurs in Chrome). */
        }

      section #WebGLCanvasElementContainer {
        border: 1px solid #DDD; /* Match the native color of the fieldset border. */
        width: 800px; /* The assumed fixed width of the WebGL Three.js viewport element. */
        height: 600px; /* The assumed fixed height of the WebGL Three.js viewport element. */
        margin-left: 224px; /* This is "body width" minus "section #WebGLCanvasElementContainer width" or 1024px - 800px = 224px. */
        background-image: url('images/starField_0.15.png'); /* 0.15 opacity value. */
      }

      section article {
        padding: 0 1em;
        color: white;
      }

      section button {
        width: 4.5em;
      }
  </style>
  <script>
    .
    .
    .
  </script>
</head>

<body>
  <header>
    <h1>One-Body Problem</h1>
  </header>
  <section>
    <form id="initialConditions">
      <fieldset>
        <legend>Mass 1</legend>
        <table id="mass1">
          <tr>
            <td>mass:</td>
            <td><input id="m1_mass" type="number" value="1E20" required="required" /></td>
          </tr>
          <tr>
            <td>x-position:</td>
            <td><input id="m1_position_x" type="number" value="0" required="required" /></td>
          </tr>
          <tr>
            <td>y-position:</td>
            <td><input id="m1_position_y" type="number" value="0" required="required" /></td>
          </tr>
          <tr>
            <td>x-velocity:</td>
            <td><input id="m1_velocity_x" type="number" value="150" required="required" /></td>
          </tr>
          <tr>
            <td>y-velocity:</td>
            <td><input id="m1_velocity_y" type="number" value="150" required="required" /></td>
          </tr>
          <tr style="display: none;">
            <td>bitmap:</td>
            <td><input type="text" value="images/jupiter.jpg" required="required" /></td>
          </tr>
        </table>
      </fieldset>
      <button id="submitButton">Submit</button>
      <button id="reloadButton">Reload</button>
    </form>
    <div id="WebGLCanvasElementContainer">
      <!-- Three.js will add a canvas element to the DOM here. -->
      <!-- The following <article> element (along with its content) will be removed via JavaScript just before the simulation starts: -->
      <article>
        <h2>What's the 1-body problem?</h2>
        <p>Consider a universe devoid of everything except for a single celestial body (mass) such as a star or planet. 
           For such a universe, the one-body problem is:</p>
        <blockquote>
          <em>Given an initial position and velocity for the mass, predict its motion as a function of time.</em>
        </blockquote>
        <h2>Running the simulation</h2>
        <ul>
          <li>To start the simulation with the current set of initial conditions, click the <strong>Submit</strong> button.</li>
          <li>To enter your own initial conditions, enter numeric values of your choice (in the form to the left) and click 
              the <strong>Submit</strong> button. Note that large values such as 10<sup>18</sup> can be entered as 1E18.</li>
          <li>To restart the simulation from scratch, click the <strong>Reload</strong> button (this is equivalent to 
              refreshing the page).</li>
        </ul>
      </article>
    </div>
  </section>
  <script>
     .
     .
     .
  </script>
</body>
</html>

When you review this code, you can see that the core purpose of the CSS is to provide a traditional two-column layout (with header). To understand the following CSS, be aware that the Three.js library requires a DOM element to append its viewport to. This requirement is provided by the div element whose id is #WebGLCanvasElementContainer, and is styled as follows:

section #WebGLCanvasElementContainer {
  border: 1px solid #DDD; /* Match the native color of the fieldset border. */
  width: 800px; /* The assumed fixed width of the WebGL Three.js viewport element. */
  height: 600px; /* The assumed fixed height of the WebGL Three.js viewport element. */
  margin-left: 224px; /* This is "body width" minus "section #WebGLCanvasElementContainer width" or 1024px - 800px = 224px. */
  background-image: url('images/starField_0.15.png'); /* 0.15 opacity value. */
}

As noted in the CSS comments above, it's assumed that the Three.js viewport will be 800 pixels wide by 600 pixels high.

Moving on to the JavaScript details, you'll notice that there are two main script blocks, one in the <head> section and the other near the end of the <body> section. The purpose of the top script block is to preload image files to help avoid initial image glitching:

var preloadImages = [];
var preloadImagePaths = ["images/jupiter.jpg", "images/starField.png", "images/starField_0.15.png"];

for (var i = 0; i < preloadImagePaths.length; i++) {
  preloadImages[i] = new Image();

  preloadImages[i].onerror = function () {
    if (console) {
      console.error(this.src + " error.");
    } // if
  }; // onerror

  preloadImages[i].src = preloadImagePaths[i]; // Preload images to improve perceived app speed.
} // for  

A skeletal version of the lower script blocks follows:

<script src="https://rawgithub.com/mrdoob/three.js/master/build/three.js"></script> <!-- Provides the WebGL-based Three.js library. -->
<script>
  var DENSITY = 1.38E15; // This value determined qualitatively by observing how large the sphere looks onscreen (i.e., its radius).

  document.getElementById('submitButton').addEventListener('click', handleSubmitButton, false);
  document.getElementById('reloadButton').addEventListener('click', handleReloadButton, false);

  var simulation = Simulation(); // Call the Simulation constructor to create a new simulation object.

  function Simulation() {
    var that = {};
    .
    .
    .

    var init = function (initialConditions) { // Public method.
      .
      .
      .
    }
                that.init = init; // This is what makes this method public.

    var run = function () {
      .
      .
      .
    };
    that.run = run;

    return that; // The object returned by the constructor.
  } // Simulation

Starting from the top, the first line includes the Three.js library, providing access to its core THREE object. Next, we define a DENSITY "constant" which is used to determine a planet's radius based on its (initial condition) mass value (discussed below). Then, two event handlers are declared for the two buttons. Finally, we invoke the Simulationconstructor, which constructs a simulation object.

Note that the Simulation constructor is loosely based on the functional inheritance pattern. For example, the returned that object contains all items needed to perform a celestial simulation.

To flesh out the remaining JavaScript code, we start by following the var simulation = Simulation() code execute path. When Simulation() is invoked, the following one-time initialization code is run:

var that = {}; // The object returned by this constructor.
var worker = null; // Will contain a reference to a fast number-chrunching worker thread that runs outside of this UR/animation thread.
var requestAnimationFrameID = null; // Used to cancel a prior requestAnimationFrame request.
var gl = {}; // Will contain WebGL related items.

gl.viewportWidth = 800; // The width of the Three.js viewport.
gl.viewportHeight = 600; // The height of the Three.js viewport.

gl.cameraSpecs = {
  aspectRatio: gl.viewportWidth / gl.viewportHeight, // Camera frustum aspect ratio.
  viewAngle: 20 // Camera frustum vertical field of view, in degrees.
};

gl.clippingPlane = {
  near: 0.1, // The distance of the near clipping plane (which always coincides with the monitor).
  far: 1000 // The distance of the far clipping plane (note that you get a negative far clipping plane for free, which occurs at the negative of this value).
};

gl.quads = 32; // Represents both the number of vertical segments and the number of horizontal rings for each mass's sphere wireframe.

// If WebGL isn't supported, fallback to using the canvas-based renderer (which most browsers support). Note that passing in "{ antialias: true }" is 
// unnecessary in that this is the default behavior. However, we pass in "{ alpha: true }" in order to let the background PNG image shine through:
gl.renderer = window.WebGLRenderingContext ? new THREE.WebGLRenderer({ alpha: true }) : new THREE.CanvasRenderer({ alpha: true }); 

// Make the background completely transparent (the actual color, black in this case, does not matter) so that the PNG background image can shine through:
gl.renderer.setClearColor(0x000000, 0); 

gl.renderer.setSize(gl.viewportWidth, gl.viewportHeight); // Set the size of the renderer.

gl.scene = new THREE.Scene(); // Create a Three.js scene.

// Set up the viewer's eye position:
gl.camera = new THREE.PerspectiveCamera(gl.cameraSpecs.viewAngle, gl.cameraSpecs.aspectRatio, gl.clippingPlane.near, gl.clippingPlane.far);

gl.camera.position.set(0, 95, 450); // The camera starts at the origin, so move it to a good position.
gl.camera.lookAt(gl.scene.position); // Make the camera look at the origin of the xyz-coordinate system.

gl.pointLight = new THREE.PointLight(0xFFFFFF); // Set the color of the light source (white).
gl.pointLight.position.set(100, 100, 250); // Position the light source at (x, y, z).
gl.scene.add(gl.pointLight); // Add the light source to the scene.

gl.spheres = []; // Will contain a WebGL sphere mesh object representing the point mass.

Starting from the top, we create the constructor's that object and then set worker to null. As suggested by its code comment, worker will contain a reference to a web worker used to perform the majority of the simulation's number crunching.

Next, we set up the Three.js "universe" (renderer, scene, camera, and point light) - something that only needs to occur one time. These details are covered by their code comments and in Basic 3D graphics using Three.js. That said, the gl object is used internally within the Simulation constructor to communicate Three.js related info between the public init method and public run method (which are invoked by the handleSubmitButton event handler).

After this one-time initialization code runs (within the Simulation constructor), the webpage (UI thread) waits for the user to click the Submit button. When this occurs, handleSubmitButton executes:

function handleSubmitButton(evt) {
  var m1 = InitialCondition(document.getElementById('mass1').querySelectorAll('input')); // A constructor returning an initial condition object.

  evt.preventDefault(); // Don't refresh the page when the user clicks this form button.

  // If necessary, warn the user that they're using a canvas-based Three.js renderer and that they should upgrade their browser so that a faster 
  // WebGL-based renderer can be used instead"
  if (!window.WebGLRenderingContext) { displayCanvasRendererWarning(); }

  simulation.init([m1]);
  simulation.run(); // The images have been preloaded so this works immediately.

  function InitialCondition(inputElements) {
    var mass = parseFloat(inputElements[0].value);

    return {
      mass: mass,
      radius: calculateRadius(mass),
      rotation: calculateRotation(mass),
      position: { x: parseFloat(inputElements[1].value), y: parseFloat(inputElements[2].value) },
      velocity: { x: parseFloat(inputElements[3].value), y: parseFloat(inputElements[4].value) },
      bitmap: inputElements[5].value // This is a string value (hence the non-use of parseFloat).
    };

    function calculateRadius(mass) {
      /*
        Mass equals density times volume or m = D * V = D * (4/3 * PI * r^3), and solving for r = [(3 * m)/(4 * PI * D)]^(1/3)
      */
      var radicand = (3 * mass) / (4 * Math.PI * DENSITY); // Only change the value of DENSITY to affect the value returned by this function.

      return Math.pow(radicand, 1 / 3);
    } // calculateRadius

    function calculateRotation(mass) {
      /*
        Using a power model, let the x-axis represent the radius and the y-axis the rotational rate of the sphere. 
        The power model is y = a * x^b, where a and b are constants (which were empirically derived).
      */
      var radius = calculateRadius(mass);

      return 1.7 * Math.pow(radius, -1.9); // Rotational rate as a function of the sphere's radius.
    } // calculateRotation
  } // InitialCondition
} // handleSubmitButton

The first task of handleSubmitButton is to get the form's (<form id="initialConditions">) current numeric data. We use querySelectorAll to get a list of the form's input elements:

document.getElementById('mass1').querySelectorAll('input')

This list is then passed to InitialCondition (see prior code example), which parses it and returns an object, m1, containing the mass's initial conditions. Note the use of the hidden input element (<input type="text" value="images/jupiter.jpg" required="required" />) containing a path to a bitmap, used to layer the associated Three.js sphere mesh.

The InitialCondition constructor contains two functions, calculateRadius and calculateRotation. Given a sphere's mass, calculateRadius calculates a radius for the sphere based on its density. Because mass = densityvolume and volume is 4/3⋅ϖ⋅r³ for a sphere, we can calculate r as a function of its density. This density value, DENSITY = 1.38E15, was empirically chosen to provide reasonable onscreen radii for common user-entered mass values.

Also based on mass, calculateRotation is used to return a visually appealing rotational rate value for a sphere based on its radius (by first calling calculateRadius(mass)) . A power model R = arb was used to return the rotational rate R as a function of the sphere's radius r. The constants a and b were empirically derived by placing reasonable R and r values into Excel, plotting, and adding a power trend line. The constants a and b can be read off of the displayed equation (that is, a ≈ 1.7 and b ≈ -1.9):

After InitialCondition returns m1, we prevent the form from refreshing the page, swap out the viewport's background image and content, and call simulation.init([m1]). Note that m1 is passed via array in preparation for the two- and three-body code (which will have m1, m2 and m1, m2, m3 objects, respectively). The code for the public simulation.init method is shown next:

var init = function (initialConditions) { // Public method, resets everything when called.
  if (requestAnimationFrameID) {
    cancelAnimationFrame(requestAnimationFrameID); // Cancel the previous requestAnimationFrame request.
  }

  if (worker) {
    worker.terminate(); // Terminate the previously running worker thread to ensure a responsive UI thread.
  }
  worker = new Worker('1BodyWorker.js'); // Spawn a fast number-chrunching thread that runs outside of this UR/animation thread.

  // Switch back to the non-opaque PNG background image:
  document.getElementById('WebGLCanvasElementContainer').style.backgroundImage = "url('images/starField.png')";

  // Remove from page-flow the one (and only) article element (along with all of its content):
  document.getElementsByTagName('article')[0].style.display = "none";

  document.getElementById('WebGLCanvasElementContainer').appendChild(gl.renderer.domElement); // Append renderer element to DOM.

  while (gl.spheres.length) { // Remove any prior spheres from the scene and empty the gl.spheres array:
    gl.scene.remove(gl.spheres.pop());
  } // while

  for (var i = 0; i < initialConditions.length; i++) { // Set the sphere object in gl.spheres to initial condition values.
    initializeMesh(initialConditions[i]); // This call sets the gl.spheres array.
  } // for

  worker.postMessage({
    cmd: 'init', // Pass the initialization command to the web worker.
    initialConditions: initialConditions // Send a copy of the initial conditions to the web worker, so it can initialize its persistent global variables.
  }); // worker.postMessage

  worker.onmessage = function (evt) { // Process the results of the "crunch" command sent to the web worker (via this UI thread).
    for (var i = 0; i < evt.data.length; i++) {
      gl.spheres[i].position.x = evt.data[i].p.x;
      gl.spheres[i].position.y = evt.data[i].p.y;
      gl.spheres[i].position.z = 0; // The 2D physics occur only in the xy-plane (and have nothing to do with the canned planetary rotation).
      gl.spheres[i].rotation.y += initialConditions[i].rotation; // Place worker.onmessage in the init method in order to access its initialConditions array.
    }
    gl.renderer.render(gl.scene, gl.camera); // Update the position of the mass (sphere mesh) onscreen based on the data returned by 1BodyWorker.js.
  }; // worker.onmessage

  function initializeMesh(initialCondition) {
    var texture = THREE.ImageUtils.loadTexture(initialCondition.bitmap); // Create texture object based on the given bitmap path.
    var material = new THREE.MeshPhongMaterial({ map: texture }); // Create a material (for the spherical mesh) that reflects light, potentially causing sphere surface shadows.
    var geometry = new THREE.SphereGeometry(initialCondition.radius, gl.quads, gl.quads); // Radius size, number of vertical segments, number of horizontal rings.
    var mesh = new THREE.Mesh(geometry, material); // A mesh represents the object (typically composed of many tiny triangles) to be displayed - in this case a hollow sphere with a bitmap on its surface.

    mesh.position.x = initialCondition.position.x;
    mesh.position.y = initialCondition.position.y;
    mesh.position.z = 0; // The physics are constrained to the xy-plane (i.e., the presumed xy-plane in 1BodyWorker.js).

    gl.scene.add(mesh); // Add the sphere to the Three.js scene.
    gl.spheres.push(mesh); // Make the Three.js mesh sphere object accessible outside of this helper function.
  } // initializeMesh
} // init
that.init = init; // This is what makes the method public.

Starting from the top, the m1 initial conditions object is passed in as initialConditions. To understand the next lines of code, assume that a simulation is currently running and the user clicks the Submit button to run a new simulation (with possibly different initial conditions):

if (requestAnimationFrameID) {
  cancelAnimationFrame(requestAnimationFrameID); // Cancel the previous requestAnimationFrame request.
}

if (worker) {
  worker.terminate(); // Terminate the previously running worker thread to ensure a responsive UI thread.
}
worker = new Worker('1BodyWorker.js'); // Spawn a fast number-chrunching thread that runs outside of this UR/animation thread.

while (gl.spheres.length) { // Remove any prior spheres from the scene and empty the gl.spheres array:
  gl.scene.remove(gl.spheres.pop());
} // while

Because we're scheduling the Three.js rendering via requestAnimationFrame, we must cancel the currently running requestAnimationFrame request from the current simulation to prepare for the new one. Similarly, we have a web worker crunching numbers for the current simulation, so we terminate it and spawn a new worker. Finally, we empty our sphere array (gl.spheres.pop()) and remove the returned object from the Three.js scene (the while loop becomes useful in the two- and three-body code):

while (gl.spheres.length) { // Remove any prior spheres from the scene and empty the gl.spheres array:
  gl.scene.remove(gl.spheres.pop());
} // while

We can now run a new simulation based on user-entered initial conditions:

worker.postMessage({
  cmd: 'init', // Pass the initialization command to the web worker.
  initialConditions: initialConditions // Send a copy of the initial conditions to the web worker, so it can initialize its persistent global variables.
}); // worker.postMessage

worker.onmessage = function (evt) { // Process the results of the "crunch" command sent to the web worker (via this UI thread).
  for (var i = 0; i < evt.data.length; i++) {
    gl.spheres[i].position.x = evt.data[i].p.x;
    gl.spheres[i].position.y = evt.data[i].p.y;
    gl.spheres[i].position.z = 0; // The 2D physics occur only in the xy-plane (and have nothing to do with the canned planetary rotation).
    gl.spheres[i].rotation.y += initialConditions[i].rotation; // Place worker.onmessage in init in order to access the initialConditions array.
  }
  gl.renderer.render(gl.scene, gl.camera); // Update the positions of the masses (sphere meshes) onscreen based on the data returned by 1BodyWorker.js.
}; // worker.onmessage

function initializeMesh(initialCondition) {
  var texture = THREE.ImageUtils.loadTexture(initialCondition.bitmap); // Create texture object based on the given bitmap path.
  var material = new THREE.MeshPhongMaterial({ map: texture }); // Create a material (for the spherical mesh) that reflects light.
  var geometry = new THREE.SphereGeometry(initialCondition.radius, gl.quads, gl.quads); // Radius, vertical segments, horizontal rings.
  var mesh = new THREE.Mesh(geometry, material); // A mesh represents the object (typically composed of many tiny triangles) to be displayed.

  mesh.position.x = initialCondition.position.x;
  mesh.position.y = initialCondition.position.y;
  mesh.position.z = 0; // The physics are constrained to the xy-plane (i.e., the presumed xy-plane in 1BodyWorker.js).

  gl.scene.add(mesh); // Add the sphere to the Three.js scene.
  gl.spheres.push(mesh); // Make the Three.js mesh sphere object accessible outside of this helper function.
} // initializeMesh

To understand the first statement (worker.postMessage), we note that the associated web worker, defined in 1BodyWorker.js, expects one of two possible commands: 'init' and 'crunch'. For the 'init' command, the worker code receives a copy of { cmd: 'init', initialConditions: initialConditions } through the evt parameter of the self.onmessage callback:

1BodyWorker.js

var N = 1; // The number of bodies (point masses) this code is designed to handle.
var h = 0.000001; // Interval between time steps, in seconds. The smaller the value the more accurate the simulation.
var iterationsPerFrame = 400; // The number of calculations made per animation frame, this is an empirically derived number based on the value of h.
    
var m1;

self.onmessage = function (evt) { // evt.data contains the data passed from the calling main page thread.
  switch (evt.data.cmd) {
    case 'init':
      init(evt.data.initialConditions); // Transfer the initial conditions data to the persistant variables in this thread.
      break;
    case 'crunch':
      crunch();
      break;
    default:
      console.error("ERROR FROM worker.js: SWITCH STATEMENT ERROR IN self.onmessage");
  } // switch
};

this.init = function (initialConditions) {

  // Define local mass object constructor function:
  function Mass(initialCondition) {
    this.m = initialCondition.mass; // The mass of the point mass, which is unimportant in the one-body problem.
    this.p = { x: initialCondition.position.x, y: initialCondition.position.y }; // The position of the mass.
    this.v = { x: initialCondition.velocity.x, y: initialCondition.velocity.y }; // The x- and y-components of velocity for the mass.
    this.a = {}; // Will contain the x- and y-components of acceleration for the mass.
  }

  if (initialConditions.length != N) {
    console.error("ERROR FROM worker.js: THE initialConditions ARRAY DOES NOT CONTAIN EXACTLY " + N + " OBJECTS - init() TERMINATED");
    return;
  }

  // Set the local mass object global variables:
  m1 = new Mass(initialConditions[0]);

  // For the 1-body problem, the velocity is a constant, therefore the x- and y-components of acceleration are always 0:
  m1.a.x = 0;
  m1.a.y = 0;
} // this.init


this.crunch = function () {
  for (var i = 0; i < iterationsPerFrame; i++) {
    // Since the velocity is constant, we can calculate position values using distance = speed * time for each component:
    m1.p.x += m1.v.x * h;
    m1.p.y += m1.v.y * h;
  } // for

  self.postMessage([m1]); // Send the crunched data back to the UI thread to be rendered onscreen.
} // this.crunch

As can be seen above, a switch statement is used to determine which command was sent. Because 'init' was sent, the init function is called with a copy of the initial conditions object from the UI thread (1BodyProblem.html) passed via the initialConditions parameter:

this.init = function (initialConditions) {

  // Define local mass object constructor function:
  function Mass(initialCondition) {
    this.m = initialCondition.mass; // The mass of the point mass, which is unimportant in the one-body problem.
    this.p = { x: initialCondition.position.x, y: initialCondition.position.y }; // The position of the mass.
    this.v = { x: initialCondition.velocity.x, y: initialCondition.velocity.y }; // The x- and y-components of velocity for the mass.
    this.a = {}; // Will contain the x- and y-components of acceleration for the mass.
  }

  if (initialConditions.length != N) {
    console.error("ERROR FROM worker.js: THE initialConditions ARRAY DOES NOT CONTAIN EXACTLY " + N + " OBJECTS - init() TERMINATED");
    return;
  }

  // Set the local mass object global variables:
  m1 = new Mass(initialConditions[0]);

  // For the 1-body problem, the velocity is a constant, therefore the x- and y-components of acceleration are always 0:
  m1.a.x = 0;
  m1.a.y = 0;
} // this.init

For the one-body problem, init sets the web worker's global m1 variable by calling the Mass constructor. Mass returns an object (representing a spherical mass in space) designed to simplify math related typing (in the worker code) by providing one character property names (m, p, v, and a). Additionally, and unnecessarily, init sets the x- and y-components of the mass's acceleration vector to 0 to emphasize the fact that the one-body problem involves only constant velocity (whose derivative, acceleration, is 0).

In 1BodyProblem.html, after the init command has been sent to the web worker via simulation.init([m1]), simulation.run() is called:

var run = function () { // Public method.
  worker.postMessage({
    cmd: 'crunch' // This processing occurs between animation frames and, therefore, is assumed to take a relatively small amount of time.
  }); // worker.postMessage
  requestAnimationFrameID = requestAnimationFrame(run); // Allow for the cancellation of this requestAnimationFrame request.
}; // run()
that.run = run;

The run code posts the 'crunch' command to the web worker and then continually calls itself via requestAnimationFrame (until cancelAnimationFrame(requestAnimationFrameID) is called). Therefore, the run method will be invoked up to 60 times per second, once per animation frame. This suggests that the web worker must be tuned to return its crunched data well before the next call to run occurs. When this occurs, that is, when the web worker returns its data, the following code (in 1BodyProblem.html) executes:

worker.onmessage = function (evt) { // Process the results of the "crunch" command sent to the web worker (via this UI thread).
  for (var i = 0; i < evt.data.length; i++) {
    gl.spheres[i].position.x = evt.data[i].p.x;
    gl.spheres[i].position.y = evt.data[i].p.y;
    gl.spheres[i].position.z = 0; // The 2D physics occur only in the xy-plane (and have nothing to do with the canned planetary rotation).
    gl.spheres[i].rotation.y += initialConditions[i].rotation; // Place worker.onmessage in init in order to access the initialConditions array.
  }
  gl.renderer.render(gl.scene, gl.camera); // Update the position of the sphere onscreen, based on the data returned by 1BodyWorker.js
}; // worker.onmessage

You can see that the worker.onmessage callback transfers the results of the web worker (contained in evt) to the Three.js sphere by updating its position:

gl.spheres[i].position.x = evt.data[i].p.x;
gl.spheres[i].position.y = evt.data[i].p.y;

This transfer is necessary because, as of this writing, web workers cannot directly access DOM elements (that is, the WebGL viewport element).

worker.onmessage also rotates the sphere by a small amount (gl.spheres[i].rotation.y += initialConditions[i].rotation) and renders the scene in order to display these changes in position and rotation:

gl.renderer.render(gl.scene, gl.camera);

We now know how worker.onmessage handles the results of the 'crunch' command from the web worker, therefore we next cover how those results are generated within the web worker itself:

this.crunch = function () {
  for (var i = 0; i < iterationsPerFrame; i++) {
    // Since the velocity is constant, we can calculate position values using distance = velocity * time for each component:
    m1.p.x += m1.v.x * h;
    m1.p.y += m1.v.y * h;
  } // for

  self.postMessage([m1]); // Send the crunched data back to the UI thread to be rendered onscreen.

The concept used to calculate the mass's position is simple: distance = velocitytime (this is appropriate in that velocity will always be a constant in the one-body problem):

m1.p.x += m1.v.x * h;
m1.p.y += m1.v.y * h;

Here, h represents a small amount of time (Δt). The smaller h is, the more accurate the simulation. But if h is too small, the web worker may not return its crunched data back to the UI thread (1BodyProblem.html) before the UI thread's next animation frame (execution of run). To ensure that the web worker returns its data well before the next animation frame, h and iterationsPerFrame were tuned to provide acceptable accuracy in well less than 1/60th of a second (assuming an upper limit of 60 frames per second for requestAnimationFrame).

Reviewing the web worker's crunch code above, when the for loop completes (in well less than a 1/60th of a second), the results are posted to the UI thread via self.postMessage([m1]).

Now the cycle repeats - the sphere's positional values are updated (via worker.onmessage in the UI thread), requestAnimationFrame calls run again, and the simulation progresses forward in time until the user clicks either the Submit button or the Reload button (which clears the currently running simulation).

In the next section, we extend these concepts to the more interesting two-body problem and describe how to orbit, pan, and zoom a rendering Three.js scene.

Basic 3D graphics using Three.js

The two-body problem

The three-body problem

The physics and equations of the two- and three-body problem