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.

  • Fabian W

    I tried to run your class, but it doesn´t work. please test your code after blogging. There are some „&&“ instead of „&&“.
    Also I get errors about unexpected tokens „})“
    It´s a pitty that you do not show an live example. So after trying to get it work, I still don´t know what it will look like.
    I´m looking for a 4-corner-pin transformation example in easeljs.

    • firsara

      Hi, thanks for your comment. You can find a working example here on my forked EaselJS repository: https://github.com/firsara/EaselJS/blob/container3d/examples/Container3d.html

      sorry for not fixing the code example above. I think you won’t be able to do 4-corner-pin transformations with EaselJS, you would need WebGL to do this sort of things and I’d suggest you dig into three.js for this. The class is all about > positioning < elements in a virtual 3-dimensional space.