Ever since Web Workers arrived, I’ve been thinking about how to use them in my own stuff. I’ve been developing a game that involves a procedurally generated cliff path and I want that path, like all good things, to go on forever.
(Want to see it in action? Try the Live Demo. WSAD moves, Q for fullscreen.)
I update the scene every time the player moves a set distance from the last update. If I do this within an animation frame, I’m going to get a drop in frame rate, and possibly a visible skip in the game experience. That’s an immersion killer. Workers to the rescue!
Well, not so fast.
The GL context itself can’t be used in a worker thread, as it’s derived from a DOM object. I can’t update my display meshes directly, and I can’t send them back and forth—function members are stripped out when sending objects between threads. Only data is allowed. So: data it is. Generate updates in the worker thread, and hand the results over to the main thread for display. (I was worried that objects would be serialized JSON-style across thread boundaries, but Chrome and Firefox perform a higher-performance binary copy of the data instead.)
Creating a thread is simple enough.
T.worker = new Worker("thread.js"); T.worker.onmessage = function(e) { T.handle(e); }; T.worker.onerror = function(e) { console.log("worker thread error: " + e.message + " at line " + e.lineno + " in file " + e.filename); };
Sadly, that error handler is all the debugging support you’re likely to get. Because the worker thread is running inside another page context, you can’t set a breakpoint in it. Back to the heady days of printf debugging! Ah, memories.
After creating the thread, I want to tell it to construct whatever objects it needs to fulfill my requests—in this case, the objects that describe the cliff path surfaces. There’s a problem, however, as I also need those objects in the main thread to handle collisions. I can’t share the surface objects directly as they contain function members. So, I create the surface maps, which are data-only, and ship those across.
// generate road and cliff surface maps this.road.map = SOAR.space.make(128); SOAR.pattern.randomize(this.road.map, T.seed(), 0, 1); this.road.height = SOAR.space.makeLine(this.road.map, 6, 0.05); this.cliff.map = SOAR.space.make(128, 128); SOAR.pattern.walk(this.cliff.map, T.seed(), 8, 0.05, 1, 0.5, 0.5, 0.5, 0.5); SOAR.pattern.normalize(this.cliff.map, 0, 1); var surf0 = SOAR.space.makeSurface(this.cliff.map, 10, 0.05, 0.1); var surf1 = SOAR.space.makeSurface(this.cliff.map, 5, 0.25, 0.5); var surf2 = SOAR.space.makeSurface(this.cliff.map, 1, 0.75, 1.5); this.cliff.surface = function(y, z) { return surf0(y, z) + surf1(y, z) + surf2(y, z); }; // send the maps to the worker thread and let it initialize T.worker.postMessage({ cmd: "init", map: { road: this.road.map, cliff: this.cliff.map }, seed: { rocks: T.seed(), brush: T.seed() } });
On the thread side, a handler picks up the message and routes it to the necessary function.
this.addEventListener("message", function(e) { switch(e.data.cmd) { case "init": init(e.data); break; case "generate": generate(e.data.pos); break; } }, false);
The initialization function uses the surface maps to build surface objects, plus a set of “dummy” mesh objects.
function init(data) { // received height map from main thread; make line object road.height = SOAR.space.makeLine(data.map.road, 6, 0.05); // generate dummy mesh for vertex and texture coordinates road.mesh = SOAR.mesh.create(display); road.mesh.add(0, 3); road.mesh.add(0, 2); // received surface map from main thread; make surface object var surf0 = SOAR.space.makeSurface(data.map.cliff, 10, 0.05, 0.1); var surf1 = SOAR.space.makeSurface(data.map.cliff, 5, 0.25, 0.5); var surf2 = SOAR.space.makeSurface(data.map.cliff, 1, 0.75, 1.5); cliff.surface = function(y, z) { return surf0(y, z) + surf1(y, z) + surf2(y, z); }; cliff.mesh = SOAR.mesh.create(display); cliff.mesh.add(0, 3); cliff.mesh.add(0, 2); // received RNG seed brush.seed = data.seed.brush; brush.mesh = SOAR.mesh.create(display); brush.mesh.add(0, 3); brush.mesh.add(0, 2); rocks.seed = data.seed.rocks; rocks.mesh = SOAR.mesh.create(display); rocks.mesh.add(0, 3); rocks.mesh.add(0, 2); }
Creating these dummy objects might seem strange. Why not just use arrays? Well, the mesh object wraps a vertex array plus an index array, and provides methods to deal with growing these arrays and handling stride and so on. It’s more convenient and means I have to write less code. When I send the mesh object across the thread boundary, I can simply treat it as a data object.
On every frame update, I check the player’s position, and once it’s moved a set distance from the position of the last update, I tell the worker thread to get busy.
update: function() { SOAR.capture.update(); T.player.update(); // update all detail objects if we've moved far enough var ppos = T.player.camera.position; if (T.detailUpdate.distance(ppos) > T.DETAIL_DISTANCE) { T.worker.postMessage({ cmd: "generate", pos: ppos }); T.detailUpdate.copy(ppos); } T.draw(); }
The worker handles the request by populating the dummy meshes and sending them to the display thread.
function generate(p) { // lock the z-coordinate to integer boundaries p.z = Math.floor(p.z); // road is modulated xz-planar surface road.mesh.reset(); indexMesh(road.mesh, 2, 64, function(xr, zr) { var z = p.z + (zr - 0.5) * 16; var y = road.height(z); var x = cliff.surface(y, z) + (xr - 0.5); road.mesh.set(x, y, z, xr, z); }, false); // cliff is modulated yz-planar surface // split into section above path and below cliff.mesh.reset(); indexMesh(cliff.mesh, 32, 64, function(yr, zr) { var z = p.z + (zr - 0.5) * 16; var y = road.height(z) + yr * 8; var x = cliff.surface(y, z) + 0.5; cliff.mesh.set(x, y, z, y, z); }, false); indexMesh(cliff.mesh, 32, 64, function(yr, zr) { var z = p.z + (zr - 0.5) * 16; var y = road.height(z) - yr * 8; var x = cliff.surface(y, z) - 0.5; cliff.mesh.set(x, y, z, y, z); }, true); // brush and rocks are generated in "cells" // cells occur on integral z boundaries (z = 0, 1, 2, ...) // and are populated using random seeds derived from z-value brush.mesh.reset(); (function(mesh) { var i, j, k; var iz, x, y, z, a, r, s; // 16 cells because path/cliff is 16 units long in z-direction for (i = -8; i < 8; i++) { iz = p.z + i; // same random seed for each cell rng.reseed(Math.abs(iz * brush.seed + 1)); // place 25 bits of brush at random positions/sizes for (j = 0; j < 25; j++) { s = rng.get() < 0.5 ? -1 : 1; z = iz + rng.get(0, 1); y = road.height(z) - 0.0025; x = cliff.surface(y, z) + s * (0.5 - rng.get(0, 0.15)); r = rng.get(0.01, 0.1); a = rng.get(0, Math.PI); // each brush consists of 4 triangles // rotated around the center point for (k = 0; k < 4; k++) { mesh.set(x, y, z, 0, 0); mesh.set(x + r * Math.cos(a), y + r, z + r * Math.sin(a), -1, 1); a = a + Math.PI * 0.5; mesh.set(x + r * Math.cos(a), y + r, z + r * Math.sin(a), 1, 1); } } } })(brush.mesh); rocks.mesh.reset(); (function(mesh) { var o = SOAR.vector.create(); var i, j, k; var iz, x, y, z, r, s; var tx, ty; for (i = -8; i < 8; i++) { iz = p.z + i; // same random seed for each cell--though not // the same as the brush, or rocks would overlap! rng.reseed(Math.abs(iz * rocks.seed + 2)); // twenty rocks per cell for (j = 0; j < 20; j++) { s = rng.get() < 0.5 ? -1 : 1; z = iz + rng.get(0, 1); y = road.height(z) - 0.005; x = cliff.surface(y, z) + s * (0.5 - rng.get(0.02, 0.25)); r = rng.get(0.01, 0.03); tx = rng.get(0, 5); ty = rng.get(0, 5); // each rock is an upturned half-sphere indexMesh(mesh, 6, 6, function(xr, zr) { o.x = 2 * (xr - 0.5); o.z = 2 * (zr - 0.5); o.y = (1 - o.x * o.x) * (1 - o.z * o.z); o.norm().mul(r); mesh.set(x + o.x, y + o.y, z + o.z, xr + tx, zr + ty); }, false); } } })(rocks.mesh); // send mesh data back to main UI postMessage({ cmd: "build-meshes", cliff: cliff.mesh, road: road.mesh, brush: brush.mesh, rocks: rocks.mesh }); }
Once it’s received an update, the display thread copies over the mesh data.
copyMesh: function(dest, src) { dest.load(src.data); dest.length = src.length; dest.loadIndex(src.indexData); dest.indexLength = src.indexLength; }, build: function(data) { this.cliff.mesh.reset(); this.copyMesh(this.cliff.mesh, data.cliff); this.cliff.mesh.build(true); this.road.mesh.reset(); this.copyMesh(this.road.mesh, data.road); this.road.mesh.build(true); this.brush.mesh.reset(); this.copyMesh(this.brush.mesh, data.brush); this.brush.mesh.build(true); this.rocks.mesh.reset(); this.copyMesh(this.rocks.mesh, data.rocks); this.rocks.mesh.build(true); }
And that’s that. Nice work, worker! Take a few milliseconds vacation time.