HTML5 Canvas Container3d with EaselJS
Lately I’ve been playing around with EaselJS. Coming from Actionscript development it simply made sense.
My first attempt at using the framework was by porting an Airhockey game I previously wrote in AS3 to canvas.
You can see the final game here: http://spacehockey.madebyfibb.com/.
Runs perfectly smooth on an iPad and was a very fun first experience with canvas and EaselJS.
So far so good. My next project required some elements to be positioned in 3d space though. After some research I didn’t find anything similar related to EaselJS. So I ended up writing my own class that would transform element-positions (x, y, z) in a 3d space.
Let’s just go through it step by step:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | this.createjs = this.createjs||{}; (function() { var Container3d = function() { this.initialize(); }; var p = Container3d.prototype = new createjs.Container(); p.Container_initialize = p.initialize; p.initialize = function() { this.Container_initialize(); }; createjs.Container3d = Container3d; }()); |
Nothing special here. Just extending the basic Container class EaselJS provides.
Let’s come to the fun part: calculating the transformations.
As there is virtually no 3d space whatsoever in 2d-canvas we first need to calculate a basic scaling of the element based on its z-position.
That happens through a specified focal length value. If you’re not familiar with photography: focal length basically adjusts „how big an element gets projected to the visible plane“ (i.e. your canvas), which means: elements that are way back appear smaller on a small focal length and vice versa. That might sound light total nonsense, but here’s a good example that might describe it better: http://en.wikipedia.org/wiki/Focal_length#In_photography
With a specified focal length the scaling is calculated like this:
1 | var scale = focalLength / (focalLength + element.z); |
next up the scaled element needs to be repositioned according to the viewpoint your projection is set to.
Let’s say your element is at x: 100, y: 0 and you’re viewing your 3d-space at exactly x: 0, y: 0. By moving the element on the z-axis at z: 100 it’s no longer positioned at x: 100 but it’s getting nearer to your viewpoint the farther it’s positioned on your z-axis. By using a specified projection center (i.e. your viewpoint) the position gets recalculated like this:
1 | var x = projectionCenter.x - (projectionCenter.x - element.x) * scale; |
With all those formulas the Container3d class get’s some new properties and an overridden draw function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | p.perspectiveProjection = { focalLength: 635, projectionCenter: { x: 0, y: 0 } }; p.draw = function(ctx, ignoreCache) { var kids = this.children; for (var i=0,l=kids.length;i<l;i++) { var child = kids[i]; if (child) { // Store values that user changed at runtime if (child.x != child._calculatedX) { child._storeX = child.x; changed = true; } if (child.y != child._calculatedY) { child._storeY = child.y; changed = true; } if (child.z != child._calculatedZ) { child._storeZ = child.z; changed = true; } if (child.scaleX != child._calculatedScaleX) { child._storeScaleX = child.scaleX; changed = true; } if (child.scaleY != child._calculatedScaleY) { child._storeScaleY = child.scaleY; changed = true; } if (changed) { // calculate scaling var scale = this.perspectiveProjection.focalLength / (this.perspectiveProjection.focalLength + child._storeZ || 0); // store newly calculated values child._calculatedZ = scale; child._calculatedX = this.perspectiveProjection.projectionCenter.x - (this.perspectiveProjection.projectionCenter.x - child._storeX) * scale; child._calculatedY = this.perspectiveProjection.projectionCenter.y - (this.perspectiveProjection.projectionCenter.y - child._storeY) * scale; // scale element by calculated scaling and pre-defined user-scaling child._calculatedScaleX = child._storeScaleX * scale; child._calculatedScaleY = child._storeScaleY * scale; child.scaleX = child._calculatedScaleX; child.scaleY = child._calculatedScaleY; child.x = child._calculatedX; child.y = child._calculatedY; } } } if (this.Container_draw(ctx, ignoreCache)) { return true; } }; |
Now let’s say you don’t care about focal length but want your viewport to handle a specific field of view (i.e. the degree of what is visible on your projection plane). You can calculate the focal length based on a specified field of view and the size of your projection plane (usually the size of your canvas):
1 2 3 4 5 6 7 8 | p.setFocalLength = function(value, projectionPlaneWidth, projectionPlaneHeight) { this.perspectiveProjection.focalLength = value; if (!(projectionPlaneWidth && projectionPlaneHeight)) return; var diagonal = Math.sqrt( Math.pow(projectionPlaneWidth, 2) + Math.pow(projectionPlaneHeight, 2) ); this.perspectiveProjection.fieldOfView = 2 * Math.atan(diagonal / (2 * this.perspectiveProjection.focalLength)) * 180 / Math.PI; }; |
With all that being said here’s the final class I’ve come up with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | var Container3d = function() { this.initialize(); }; var p = Container3d.prototype = new createjs.Container(); // public properties: /** * Holds a perspectiveProjection Object containing a defined field of view and x- and y-projectionCenters * @property perspectiveProjection * @type Object * @default null **/ p.perspectiveProjection = { focalLength: 635, fieldOfView: 120, projectionCenter: { x: 0, y: 0 } }; // constructor: /** * @property Container_initialize * @type Function * @private **/ p.Container_initialize = p.initialize; /** * Initialization method. * @method initialize * @protected */ p.initialize = function() { this.Container_initialize(); }; // public methods: /** * Sets the specified focal length. * Calculates Field of view if projectionPlane sizes are passed * * @method setFocalLength **/ p.setFocalLength = function(value, projectionPlaneWidth, projectionPlaneHeight) { this.perspectiveProjection.focalLength = value; if (!(projectionPlaneWidth && projectionPlaneHeight)) return; var diagonal = Math.sqrt( Math.pow(projectionPlaneWidth, 2) + Math.pow(projectionPlaneHeight, 2) ); this.perspectiveProjection.fieldOfView = 2 * Math.atan(diagonal / (2 * this.perspectiveProjection.focalLength)) * 180 / Math.PI; }; /** * Sets the specified field of view. * Calculates focal length if projectionPlane sizes are passed * * @method setFieldOfView **/ p.setFieldOfView = function(value, projectionPlaneWidth, projectionPlaneHeight) { if (value <= 0 || value >= 180) throw new Error('field of view hast to be a value 0 and 180'); this.perspectiveProjection.fieldOfView = value; if (!(projectionPlaneWidth && projectionPlaneHeight)) return; var diagonal = Math.sqrt( Math.pow(projectionPlaneWidth, 2) + Math.pow(projectionPlaneHeight, 2) ); this.perspectiveProjection.focalLength = diagonal / (2 * Math.tan(Math.PI * this.perspectiveProjection.fieldOfView / 360)); }; /** * @property Container_draw * @type Function * @private **/ p.Container_draw = p.draw; /** * Draws the display object into the specified context ignoring its visible, alpha, shadow, and transform. * Returns true if the draw was handled (useful for overriding functionality). * * NOTE: This method is mainly for internal use, though it may be useful for advanced uses. * @method draw * @param {CanvasRenderingContext2D} ctx The canvas 2D context object to draw into. * @param {Boolean} [ignoreCache=false] Indicates whether the draw operation should ignore any current cache. * For example, used for drawing the cache (to prevent it from simply drawing an existing cache back * into itself). **/ p.draw = function(ctx, ignoreCache) { var kids = this.children; for (var i=0,l=kids.length;i<l;i++) { var child = kids[i]; if (child) { // Store values that user changed at runtime if (child.x != child._calculatedX) { child._storeX = child.x; changed = true; } if (child.y != child._calculatedY) { child._storeY = child.y; changed = true; } if (child.z != child._calculatedZ) { child._storeZ = child.z; changed = true; } if (child.scaleX != child._calculatedScaleX) { child._storeScaleX = child.scaleX; changed = true; } if (child.scaleY != child._calculatedScaleY) { child._storeScaleY = child.scaleY; changed = true; } if (changed) { // calculate scaling var scale = this.perspectiveProjection.focalLength / (this.perspectiveProjection.focalLength + child._storeZ || 0); // store newly calculated values child._calculatedZ = scale; child._calculatedX = this.perspectiveProjection.projectionCenter.x - (this.perspectiveProjection.projectionCenter.x - child._storeX) * scale; child._calculatedY = this.perspectiveProjection.projectionCenter.y - (this.perspectiveProjection.projectionCenter.y - child._storeY) * scale; child._calculatedScaleX = child._storeScaleX * scale; child._calculatedScaleY = child._storeScaleY * scale; child.scaleX = child._calculatedScaleX; child.scaleY = child._calculatedScaleY; child.x = child._calculatedX; child.y = child._calculatedY; } } } if (this.Container_draw(ctx, ignoreCache)) { return true; } }; createjs.Container3d = Container3d; }()); |
Here’s an example of how to use the class:
1 2 3 4 5 6 7 8 9 10 | var container = new createjs.Container3d(); var child = new createjs.Shape(); child.z = 200; child.graphics.beginFill('#000'); child.graphics.drawCircle(0, 0, 100); child.graphics.endFill(); container.perspectiveProjection.projectionCenter.x = stage.canvas.width / 2; container.perspectiveProjection.projectionCenter.y = stage.canvas.height / 2; container.setFieldOfView(90, canvas.width, canvas.height); container.addChild(child); |
It’s not a very fast approach but it does what I need it to.