Thursday, September 8, 2011

Box2D and Web workers for JavaScript developers

Sponsor: Register today for New Game, the conference for HTML5 game developers. Learn from Mozilla, Opera, Google, Spil, Bocoup, Mandreel, Subsonic, Gamesalad, EA, Zynga, and others at this intimate and technically rich conference. Join us for two days of content from developers building HTML5 games today. Nov 1-2, 2011 in San Francisco. Register now!








UPDATE: The below code is non-optimal. While it is interested, and I encourage you to read it for completeness, be sure to continue to a following post on fixes and analysis of this technique.

This is the fourth post in a series on Box2D for the JavaScript developer.

Intro

Our previous post on Box2D frame rates, world step rates, and adaptive rates showed how changing the FPS and world step frequency has an effect on performance and perception. The rendering and physics simulations were running in the same loop, which is typical for web programming.

However, nothing in the simulation requires it to be tied so closely to the render loop. What if we could separate the two loops (physics update loop and render loop) and run them on different threads?

Enter Web workers

Yay! for HTML5 because modern browsers can in fact spawn what is essentially a new process for long running, computationally intensive tasks. Web workers are designed to allow JavaScript to run independently of the main event loop for the page. This can keep your UI responsive for the user as the browser, in a separate process, is crunching on expensive algorithms which would otherwise lock up the browser.

Browser Support



Thanks to the awesome caniuse.com we can see that support for Web workers is pretty good, even the new Internet Explorer 10 will include it. For our purposes, those users without Web workers can either get Chrome Frame or you as the developer can gracefully degrade the application experience by placing the Box2D simulation into the render loop.

For users with modern browsers, we can provide an enhanced experience by using more than one CPU core.

Web worker details

Workers shouldn't be considered threads, as they are generally more heavy and should be considered more like processes. For example, you should only spawn a few workers per page.

Creating a new worker is straight forward:

var worker = new Worker('worker.js');

Notice how you need to supply a path to a script to be run by the worker. While you can't just point a worker to a function on your page, you can use the Blob Builder to construct JavaScript from the host page to hand to the worker. Generally, though, you will write your worker code as a separate file.

Restrictions

A worker can't access the DOM of your page. It can't add elements, respond to user events, and it can't write to console.log(. Best to think of a worker as a server for your client app.

Communicating with a worker

Use postMessage() and onmessage to send and receive messages between the host page and the worker. This should look familiar, it's how you communicate between different documents and iframes.

For example, receiving a message from the worker:

worker.onmessage = function (event) {
  document.getElementById('result').textContent = event.data;
};

Notice event.data which contains the object sent from the worker. I've noticed the event data can contain any object, not just simple strings or numbers. Data sent from worker to host page will be serialized and copied from producer to consumer.

To send a message, use postMessage():

postMessage(msg);

To be clear, the data sent via postMessage() will be copied and serialized. This implies the consumer or receiver of the event will get a copy of the message.

This event queue is unbounded, in Chrome at least. Your producer should be aware of its message sending rate to ensure it doesn't get too far ahead of the consumer. Unfortunately, the Web worker spec doesn't have the API to manage the queue. If your producer is generating a lot of messages (as we will below) you will need to add throttling.

Debugging workers

It is unfortunately that workers can't access console.log(). It is more difficult to debug a worker, but not impossible. Luckily, the Chrome Developer Tools can debug a worker, although it is not automatic.


In the Scripts tag in the Dev Tools, you'll see a Worker inspectors drop down on the right. By clicking the Debug checkbox, the Dev Tools will place the worker inside an iframe of the host page (recent versions of Chrome >= 15 should not be debugging workers natively). You can then step through the worker and debug it like normal.

Learn more

We barely scratched the surface for workers. I can recommend the Web worker spec and the Basics of Web Workers article from HTML5 Rocks.

Box2D and Workers

Box2D functions best if it can run at a consistent rate. By placing Box2D into a Web worker, we can shelter it from the browser's UI thread and let it run its loop independently.

Our strategy will be to use the host page to build the world and to perform all rendering via requestAnimationFrame. The worker will run the Box2D simulation at its own rate, and will post updates for all the objects in the world back to the host page to render.

This separation of concerns lets the Box2D world update at its optimal rate, while the rendering of the world can run as fast as possible.

Creating the world

We start by creating a simple object hierarchy of Entities, both Circles and Rectangles. We need these entities because the host application doesn't know anything about Box2D, nor should it.

    function Entity(id, x, y) {
      this.id = id;
      this.x = x;
      this.y = y;
      this.angle = 0;
    }
    
    Entity.prototype.update = function(state) {
      this.x = state.x;
      this.y = state.y;
      this.angle = state.a;
    }
    
    Entity.prototype.draw = function(ctx) {
      ctx.fillStyle = 'black';
      ctx.beginPath();
      ctx.arc(this.x * SCALE, this.y * SCALE, 2, 0, Math.PI * 2, true);
      ctx.closePath();
      ctx.fill();
    }
    
    function CircleEntity(id, x, y, radius) {
      Entity.call(this, id, x, y);
      this.radius = radius;
    }
    CircleEntity.prototype = new Entity();
    CircleEntity.prototype.constructor = CircleEntity;
    
    CircleEntity.prototype.draw = function(ctx) {
      ctx.fillStyle = 'blue';
      ctx.beginPath();
      ctx.arc(this.x * SCALE, this.y * SCALE, this.radius * SCALE, 0, Math.PI * 2, true);
      ctx.closePath();
      ctx.fill();
      
      Entity.prototype.draw.call(this, ctx);
    }
    
    function RectangleEntity(id, x, y, halfWidth, halfHeight) {
      Entity.call(this, id, x, y);
      this.halfWidth = halfWidth;
      this.halfHeight = halfHeight;
    }
    RectangleEntity.prototype = new Entity();
    RectangleEntity.prototype.constructor = RectangleEntity;
    
    RectangleEntity.prototype.draw = function(ctx) {
      ctx.save();
      ctx.translate(this.x * SCALE, this.y * SCALE);
      ctx.rotate(this.angle);
      ctx.translate(-(this.x) * SCALE, -(this.y) * SCALE);
      ctx.fillStyle = 'red';
      ctx.fillRect((this.x-this.halfWidth) * SCALE,
                   (this.y-this.halfHeight) * SCALE,
                   (this.halfWidth*2) * SCALE,
                   (this.halfHeight*2) * SCALE);
      ctx.restore();
      
      Entity.prototype.draw.call(this, ctx);
    }

An entity has a position (x and y) and an angle. Box2D will provide these values.

We need to uniquely identify the entities so the Box2D worker and the host app can match up bodies and entities. Therefore, an ID is given to each entity.

Notice the entities know how to draw themselves. An entity also draws a small black dot in the center of the entity, which is very handy for visually debugging. We'll cover this in more detail later.

Next, we randomly create 150 entities for our world.

    var world = {};
    for (var i = 0; i < 150; i++) {
      world[i] = randomEntity(i);
    }

You will no doubt have something way better than this for your real app.

Creating the worker, populating the world

With our random assortment of entities, we are ready to start the Box2D simulation worker.

    var worker = new Worker('physics.js');
    worker.postMessage(world);

Note the postMessage(world) call which sends the world, and all 150 entities, to the worker. This is how the Box2D world receives its initial configuration.

Over in physics.js the worker receives the message:

// in physics.js worker
self.onmessage = function(e) {
    box.setBodies(e.data);
};

and it creates the Box2D bodies from the world entities:

bTest.prototype.setBodies = function(bodyEntities) {
    this.bodyDef.type = b2Body.b2_dynamicBody;
    for(var id in bodyEntities) {
        var entity = bodyEntities[id];
        if (entity.radius) {
            this.fixDef.shape = new b2CircleShape(entity.radius);
        } else {
            this.fixDef.shape = new b2PolygonShape;
            this.fixDef.shape.SetAsBox(entity.halfWidth, entity.halfHeight);
        }
       this.bodyDef.position.x = entity.x;
       this.bodyDef.position.y = entity.y;
       this.bodyDef.userData = entity.id;
       this.world.CreateBody(this.bodyDef).CreateFixture(this.fixDef);
    }
    this.ready = true;
}

Note that after the bodies are configured, the ready flag is set to true. This flag is required because the worker has already started its loop, and we want to signal when the bodies are set so the simulation can begin.

Looping

The worker starts the loop immediately, before any message is received:

var loop = function() {
    if (box.ready) box.update();
}

setInterval(loop, 1000/30);

As you can see, the loop is managed by setInterval to govern the frequency, which we have set to 30Hz.

Different frequencies

If you recall, the render loop on the host page is managed with requestAnimationFrame which should be running at 60Hz. This means we are rendering twice as fast as we are updating our physics simulation. The render loop simply draws whatever it knows about at the time of the frame.

Updating the canvas

The physics simulation is running in the worker, and updating the state of all the bodies. After each update, it needs to send a message to the host page with the new state of the objects.

bTest.prototype.sendUpdate = function() {
    var world = {};
    for (var b = this.world.GetBodyList(); b; b = b.m_next) {
        if (typeof b.GetUserData() !== 'undefined') {
            world[b.GetUserData()] = {x: b.GetPosition().x, y: b.GetPosition().y, a: b.GetAngle()};
        }
      }
    postMessage(world);
}

The sendUpdate function builds a temporary data structure to hold the x, y, and angle properties of all the objects. It only wants to inform the host page of bodies with IDs set in the UserData field. There is no need to send information about static bodies like the ground, as they don't change.

Note: a future enhancement will only send updated state for objects that are not at rest. If the object is sleeping, there's no need to send an update to the host page.

Remember that the world object is copied and serialized as it's sent via postMessage.

Updating entities

The host page receives the update messages from the worker and updates the state of all the entities.

    worker.onmessage = function(e) {
      for (var id in e.data) {
        world[id].update(e.data[id]);
      }
    };

You can see the use of the IDs as the order of the entities and bodies from the Box2D simulation are not maintained.

Render loop

In the host page, the render loop is running at its own 60Hz pace, drawing the objects to the canvas.

    (function loop() {
      ctx.clearRect(0, 0, 640, 480);
      for (var id in world) {
        var entity = world[id];
        entity.draw(ctx);
      }
      stats.update();
      requestAnimFrame(loop);
    })();

Yay! for polymorphism, as we let the implementation of Entity draw itself to the canvas.

Example

Now that we've walked through the code, let's see it in action:



Reload the page a few times to see it in action. Remember that the physics loop is running via a worker, and updating the host page at 30Hz with the new coordinates and angles for each of the entities. The rendering is governed by requestAnimationFrame on the host page, running at 60Hz.

Dangers

This is all well and good, but there is an added difficulty with this configuration. The Web worker, or producer, can generate too many messages for the host page, or consumer, to deal with. Although the queue is an unbounded vector in Chrome, at some point, if message sending rate isn't closely monitored, the browser will run out of memory.

The trick is to send messages just fast enough for the consumer to use them. Sending too many and you'll eventually run out of memory. Send too few and the perceived animation performance will suffer. If the consumer falls too far behind, it will want to catch up by pulling messages from the queue and throwing them out.

Web workers do not have APIs to manage the queue, so it's up to the application to build throttling logic and coordination between producers and consumers.

Summary

We've learned that we can separate our rendering and physics simulations by using Web workers. Placing Box2D into a worker allows it to run in a separate process, thus speeding up the rendering loop in the host page.

Future improvements include sending less updates from the worker when objects are asleep, and not rendering if all objects are asleep. Also, a necessary improvement would include update throttling so that the producer doesn't send more than the consumer can handle.

The full source code for this example is available and open source.

Visit the next stop on our Box2D tour: Twiddling the Update Freq and Draw Freq knobs.

Attend New Game for more HTML5 game nerdery

To learn more about JavaScript development for HTML5 games, I encourage you to join us at New Game.

New Game is the first conference in North America dedicated to HTML5 game development. Key developers and engineers from Google, Opera, Mozilla, Gamesalad, Spil, Subsonic, Mandreel, and others will present sessions on bringing modern games to the modern web browser. Keynotes from EA and Zynga will inspire and amaze. New Game is a conference by developers and for developers. Register today!
Post a Comment

Disclaimer

I'm probably required to say that the views expressed in this blog are my own, and do not necessarily reflect those of my employer. Also, except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 3.0 License, and code samples are licensed under the BSD License.