Kris10an
Kris10an

Reputation: 559

Html5 canvas - Translate function behaving weirdly

Im trying to use the translate function when drawing a circle, but when i try to do it it doesnt behave properly. Instead of drawing the circle it draws this:

screenshot

if the image doesnt show up: click here

This is my code for the drawing of the circle (inside a circle class):

ctx.strokeStyle = "white"
ctx.translate(this.x, this.y)
ctx.beginPath()
// Draws the circle
ctx.arc(0, 0, this.r, 0, 2 * Math.PI)
ctx.stroke()
ctx.closePath()
// tried with and without translating back, inside and outside of this function
ctx.translate(0, 0)

This is the rest of my code:

let canvas
let ctx
let circle

function init() {
    canvas = document.querySelector("#canvas")
    ctx = canvas.getContext("2d")

                               // x, y, radius
    circle = new Circle(canvas.width/5, canvas.height/2, 175)

    requestAnimationFrame(loop)
}

function loop() {
    // Background
    ctx.fillStyle = "black"
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    // The function with the drawing of the circle
    circle.draw()
    requestAnimationFrame(loop)
}

Btw: When i dont use the translate function it draws the circle normally.

Edit:

I answered my own question below as i found that the translate functions a little bit differently in javascript than how i thought it would.

Upvotes: 0

Views: 1172

Answers (4)

Blindman67
Blindman67

Reputation: 54128

Answer

Your function

ctx.strokeStyle = "white"
ctx.translate(this.x, this.y)
ctx.beginPath()
// Draws the circle
ctx.arc(0, 0, this.r, 0, 2 * Math.PI)
ctx.stroke()
ctx.closePath()
// tried with and without translating back, inside and outside of this function
ctx.translate(0, 0)

Can be improved as follows

ctx.strokeStyle = "white"
ctx.setTransform(1, 0, 0, 1, this.x, this.y); //BM67 This call is faster than ctx.translate
ctx.beginPath()
ctx.arc(0, 0, this.r, 0, 2 * Math.PI)
ctx.stroke()
// ctx.closePath() //BM67 This line does nothing and is not related to beginPath.

// tried with and without translating back, inside and outside of this function

//ctx.translate(0, 0) //BM67 You don't need to reset the transform
                      //     The call to ctx.setTransfrom replaces
                      //     the current transform before you draw the circle

and would look like

ctx.strokeStyle = "white"
ctx.setTransform(1, 0, 0, 1, this.x, this.y);
ctx.beginPath()
ctx.arc(0, 0, this.r, 0, 2 * Math.PI)
ctx.stroke()

Why this is better will need you to understand how 2D transformations work and why some 2D API calls should not be used, and that 99% of all transformation needs can be done faster and with less mind f with ctx.setTransform than the poorly named ctx.translate, ctx.scale, or ctx.rotate

Read on if interested.

Understanding the 2D transformation

When you render to the canvas all coordinates are transformed via the transformation matrix.

The matrix consists of 6 values as set by setTransform(a,b,c,d,e,f). The values a,b,c,d,e,f are rather obscure and the literature does not help explaining them.

The best way to think of them is by what they do. I will rename them as setTransform(xAxisX, xAxisY, yAxisX, yAxisY, originX, originY) they represent the direction and size of the x axis, y axis and the origin.

  • xAxisX, xAxisY are X Axis X, X Axis Y
  • yAxisX, yAxisY are Y Axis X, Y Axis Y
  • originX, originY are the canvas real pixel coordinates of the origin

The default transform is setTransform(1, 0, 0, 1, 0, 0) meaning that the X Axis moves across 1 down 0, the Y Axis moves across 0 and down 1 and the origin is at 0, 0

You can manually apply the transform to a 2D point as follows

function transformPoint(x, y) {
    return {
       // Move x dist along X part of X Axis
       // Move y dist along X part of Y Axis
       // Move to the X origin
        x : x * xAxisX + y * yAxisX + originX,   

       // Move x dist along Y part of X Axis
       // Move y dist along Y part of Y Axis
       // Move to the Y origin
        y : x * xAxisY + y * yAxisY + originY,   
     };
 }

If we substitute the default matrix setTransform(1, 0, 0, 1, 0, 0) we get

 {
     x : x * 1 + y * 0 + 0,   
     y : x * 0 + y * 1 + 0,   
 }

 // 0 * n is 0 so removing the * 0
 {
     x : x * 1,   
     y : y * 1,   
 }
 // 1 time n is n so remove the * 1                                     
 {
     x : x,
     y : y,
 }

As you can see the default transform does nothing to the point

Translation

If we set the translation ox, oy to setTransform(1, 0, 0, 1, 100, 200) the transform is

 {
     x : x * 1 + y * 0 + 100,   
     y : x * 0 + y * 1 + 200,   
 }
 // or simplified as
 {
     x : x + 100,   
     y : y + 200,   
 }

Scale

If we set the scale of the X Axis and Y Axis to setTransform(2, 0, 0, 2, 100, 200) the transform is

 {
     x : x * 2 + y * 0 + 100,   
     y : x * 0 + y * 2 + 200,   
 }
 // or simplified as
 {
     x : x * 2 + 100,   
     y : y * 2 + 200,   
 }

Rotation

Rotation is a little more complex and requires some trig. You can use cos and sin to get a unit vector in a direction angle (NOTE all angles are in radians PI * 2 is 360deg, PI is 180deg, PI / 2 is 90deg)

Thus the unit vector for 0 radians is

 xAxisX = Math.cos(0);
 yAxisY = Math.sin(0);

So for angles 0, PI * (1 / 2), PI, PI * (3 / 2), PI * 2

 angle = 0; 
 xAxisX = Math.cos(angle); // 1
 yAxisY = Math.sin(angle); // 0

 angle = Math.PI * (1 / 2);  // 90deg (points down screen) 
 xAxisX = Math.cos(angle); // 0
 yAxisY = Math.sin(angle); // 1

 angle = Math.PI;  // 180deg (points to left screen) 
 xAxisX = Math.cos(angle); // -1
 yAxisY = Math.sin(angle); // 0

 angle = Math.PI * (3 / 2);  // 270deg (points to up screen) 
 xAxisX = Math.cos(angle); // 0
 yAxisY = Math.sin(angle); // -1

Uniform transformation

In 90% of cases when you transform points you want the points to remain square, that is the Y axis remains at PI / 2 (90deg) clockwise of the X axis and the Scale of the Y axis is the same as the scale of the X axis.

You can rotate a vector 90 deg by swapping the x and y and negating the new x

 x = 1;  // X axis points from left to right
 y = 0;  // No downward part
 // Rotate 90deg clockwise
 x90 = -y;  // 0 no horizontal part
 y90 = x;   // Points down the screen

We can take advantage of this simple 90 rotation to create a uniform rotation by only defining the angle of the X Axis

 xAxisX = Math.cos(angle);
 xAxisY = Math.sin(angle);
 // create a matrix as setTransform(xAxisX, xAxisY, -xAxisY, xAxisX, 0, 0)

 // to transform the point
 {
     x : x * xAxisX + y * (-xAxisY) + 0,   
     y : x * xAxisY + y *   xAxisX  + 0,   
 }
 // to simplify
 {
     x : x * xAxisX - y * xAxisY,   
     y : x * xAxisY + y * xAxisX,   
 }

Rotate, scale, and translate

Using the above info you can now manually create a uniform matrix using only 4 values, The origin x,y the scale, and the rotate

 function transformPoint(x, y, originX, originY, scale, rotate) {
      // get the direction of the X Axis
      var xAxisX = Math.cos(rotate);
      var xAxisY = Math.sin(rotate);

      // Scale the x Axis
      xAxisX *= Math.cos(rotate);
      xAxisY *= Math.sin(rotate);

      // Get the Y Axis as X Axis rotated 90 deg
      const yAxisX = -xAxisY;
      const yAxisY = xAxisX;

      // we have the 6 values for the transform 
      // [xAxisX, xAxisY, yAxisX, yAxisY, originX, originY]

      // Transform the point
      return {
          x : x * xAxisX + y * yAxisX + originX,
          y : x * xAxisY + y * yAxisY + originY,
      }
  }
  // we can simplify the above down to 
 function transformPoint(x, y, originX, originY, scale, rotate) {
      // get the direction and scale of the X Axis
      const xAxisX = Math.cos(rotate) * scale;
      const xAxisY = Math.sin(rotate) * scale;

      // Transform the point
      return {
          x : x * xAxisX - y * xAxisY + originX,
          // note the    ^ negative
          y : x * xAxisY + y * xAxisX + originY,
      }
  }

Or we can create the matrix using ctx.setTransform using the above and let the GPU hardware do the transform

 function createTransform(originX, originY, scale, rotate) {
      const xAxisX = Math.cos(rotate) * scale;
      const xAxisY = Math.sin(rotate) * scale;
      ctx.setTransform(xAxisX, xAxisY, -xAxisY, xAxisX, originX, originY);
 }

Setting or Multiplying the transform.

I will rename this section to

WHY YOU SHOULD AVOID ctx.translate, ctx.scale, or ctx.rotate

The 2D API has some bad naming which is the reason for 90% of the transform question that appear in html5-canvas tag.

If we rename the API calls you will get a better understanding of what they do

ctx.translate(x, y); // should be ctx.multiplyCurrentMatirxWithTranslateMatrix
                     // or shorten ctx.matrixMutliplyTranslate(x, y)

The function ctx.translate does not actually translate a point, but rather it translates the current matrix. It does this by first creating a matrix and then multiplying that matrix with the current matrix

Multiplying one matrix by another, means that the 6 values or 3 vectors for X Axis, Y Axis, and Origin are transform by the other matrix.

If written as code

const current = [1,0,0,1,0,0]; // Default matrix
function translate(x, y) {  // Translate current matrix
    const translationMatrix = [1,0,0,1,x,y];
    const c = current
    const m = translationMatrix 
    const r = []; // the resulting matrix

    r[0] = c[0] * m[0] + c[1] * m[2]; // rotate current X Axis with new transform
    r[1] = c[0] * m[1] + c[1] * m[3];
    r[2] = c[2] * m[0] + c[3] * m[2]; // rotate current Y Axis with new transform
    r[3] = c[2] * m[1] + c[3] * m[3];
    r[4] = c[4] + m[4]; // Translate current origine with transform
    r[5] = c[5] + m[5];

    c.length = 0;
    c.push(...r);
}

That is the simple version. Under the hood you can not multiply the two matrix as they have different dimensions. The actual matrix is stored as 9 values and requires 27 multiplications and 18 additions

  // The real 2D default matrix
  const current = [1,0,0,0,1,0,0,0,1];
  // The real Translation matrix
  const translation = [1,0,0,0,1,0,x,y,1];

  //The actual transformation calculation

  const c = current
  const m = translationMatrix 
  const r = []; // the resulting matrix

  r[0] = c[0] * m[0] + c[1] * m[3] + c[2] * m[6]; 
  r[1] = c[0] * m[1] + c[1] * m[4] + c[2] * m[7];
  r[2] = c[0] * m[2] + c[1] * m[5] + c[2] * m[8];
  r[3] = c[3] * m[0] + c[4] * m[3] + c[5] * m[6]; 
  r[4] = c[3] * m[1] + c[4] * m[4] + c[5] * m[7];
  r[5] = c[3] * m[2] + c[4] * m[5] + c[5] * m[8];
  r[6] = c[6] * m[0] + c[7] * m[3] + c[8] * m[6]; 
  r[7] = c[6] * m[1] + c[7] * m[4] + c[8] * m[7];
  r[8] = c[6] * m[2] + c[7] * m[5] + c[8] * m[8];

That's a bucket load of math that is always done under the hood when you use ctx.translate and NOTE that this math is not done on the GPU, it is done on the CPU and the resulting matrix is moved to the GPU.

If we continue the renaming

ctx.translate(x, y);       // should be ctx.matrixMutliplyTranslate(
ctx.scale(scaleY, scaleX); // should be ctx.matrixMutliplyScale(
ctx.rotate(angle);         // should be ctx.matrixMutliplyRotate(
ctx.transform(a,b,c,d,e,f) // should be ctx.matrixMutliplyTransform(

It is common for JS scripts to use the above function to scale translate and rotates, usually with reverse rotations and translations because their objects are not defined around there local origins.

Thus when you do the following

ctx.rotate(angle);
ctx.scale(sx, sy);
ctx.translate(x, y);

The under the hood math must do all of the following

  // create rotation matrix
  rr = [Math.cos(rot), Math.sin(rot), 0, -Math.sin(rot), Math.cos(rot), 0, 0, 0, 1];
  // Transform the current matix with the rotation matrix
  r[0] = c[0] * rr[0] + c[1] * rr[3] + c[2] * rr[6]; 
  r[1] = c[0] * rr[1] + c[1] * rr[4] + c[2] * rr[7];
  r[2] = c[0] * rr[2] + c[1] * rr[5] + c[2] * rr[8];
  r[3] = c[3] * rr[0] + c[4] * rr[3] + c[5] * rr[6]; 
  r[4] = c[3] * rr[1] + c[4] * rr[4] + c[5] * rr[7];
  r[5] = c[3] * rr[2] + c[4] * rr[5] + c[5] * rr[8];
  r[6] = c[6] * rr[0] + c[7] * rr[3] + c[8] * rr[6]; 
  r[7] = c[6] * rr[1] + c[7] * rr[4] + c[8] * rr[7];
  r[8] = c[6] * rr[2] + c[7] * rr[5] + c[8] * rr[8];

  // STOP the GPU and send the resulting matrix over the bus to set new state
  c = [...r]; // set the current matrix

  // create the scale matrix
  ss = [scaleX, 0, 0, 0, scaleY, 0, 0, 0, 1];
  // scale the current matrix      
  r[0] = c[0] * ss[0] + c[1] * ss[3] + c[2] * ss[6]; 
  r[1] = c[0] * ss[1] + c[1] * ss[4] + c[2] * ss[7];
  r[2] = c[0] * ss[2] + c[1] * ss[5] + c[2] * ss[8];
  r[3] = c[3] * ss[0] + c[4] * ss[3] + c[5] * ss[6]; 
  r[4] = c[3] * ss[1] + c[4] * ss[4] + c[5] * ss[7];
  r[5] = c[3] * ss[2] + c[4] * ss[5] + c[5] * ss[8];
  r[6] = c[6] * ss[0] + c[7] * ss[3] + c[8] * ss[6]; 
  r[7] = c[6] * ss[1] + c[7] * ss[4] + c[8] * ss[7];
  r[8] = c[6] * ss[2] + c[7] * ss[5] + c[8] * ss[8];

  // STOP the GPU and send the resulting matrix over the bus to set new state
  c = [...r]; // set the current matrix

 // create the translate matrix
  tt = [1, 0, 0, 0, 1, 0, x, y, 1];

  // translate the current matrix      
  r[0] = c[0] * tt[0] + c[1] * tt[3] + c[2] * tt[6]; 
  r[1] = c[0] * tt[1] + c[1] * tt[4] + c[2] * tt[7];
  r[2] = c[0] * tt[2] + c[1] * tt[5] + c[2] * tt[8];
  r[3] = c[3] * tt[0] + c[4] * tt[3] + c[5] * tt[6]; 
  r[4] = c[3] * tt[1] + c[4] * tt[4] + c[5] * tt[7];
  r[5] = c[3] * tt[2] + c[4] * tt[5] + c[5] * tt[8];
  r[6] = c[6] * tt[0] + c[7] * tt[3] + c[8] * tt[6]; 
  r[7] = c[6] * tt[1] + c[7] * tt[4] + c[8] * tt[7];
  r[8] = c[6] * tt[2] + c[7] * tt[5] + c[8] * tt[8];

  // STOP the GPU and send the resulting matrix over the bus to set new state
  c = [...r]; // set the current matrix

So that is a total of 3 GPU state changes, 81 floating point multiplications, 54 floating point additions, 4 high level math calls and about 0.25K RAM allocated and dumped for GC to clean up.

Easy and Fast

The function setTransform does not multiply matrices. It converts the 6 arguments to a 3 by 3 matrix by directly putting the values into the current transform and the moving it to the GPU

  // ct is the current transform 9 value under hood version
  // The 6 arguments of the ctx.setTransform call

  ct[0] = a;
  ct[1] = b;
  ct[2] = 0;
  ct[3] = c;
  ct[4] = d;
  ct[5] = 0;
  ct[6] = e;
  ct[7] = f;
  ct[8] = 1;
  // STOP the GPU and send the resulting matrix over the bus to set new state

So if you use the JS function

 function createTransform(originX, originY, scale, rotate) {
      const xAxisX = Math.cos(rotate) * scale;
      const xAxisY = Math.sin(rotate) * scale;
      ctx.setTransform(xAxisX, xAxisY, -xAxisY, xAxisX, originX, originY);
 }

You reduce the complexity under the hood to 2 floating point multiplications, 2 high level math function calls, 1 floating point addition (negating the -xAxisY), one GPU state change, and using only 64 bytes of RAM from the heap.

And because the ctx.setTransform does not depend on the current state of the 2D transform you don't need to use ctx.resetTransform, or ctx.save and restore

When animating many items the performance benefit is noticeable. When struggling with the complexity of transformed matrices the simplicity of setTransform can save you hours of time better spend creating good content.

Upvotes: 4

ggorlen
ggorlen

Reputation: 57425

The problem is that after each translation in Circle.draw(), the context is not restored to its original state. Future translate(this.x, this.y); calls keep moving the context right and downward relative to the previous transformation endlessly.

Use ctx.save() and ctx.restore() at the beginning and end of your draw() function to move the context back to its original location after drawing.

class Circle {
  constructor(x, y, r) {
    this.x = x;
    this.y = y;
    this.r = r;
  }

  draw() {
    ctx.save();
    
    ctx.strokeStyle = "white";
    ctx.translate(this.x, this.y);
    ctx.beginPath();
    ctx.arc(0, 0, this.r, 0, 2 * Math.PI);
    ctx.closePath();
    ctx.stroke();
    
    ctx.restore();
  }
}

let canvas;
let ctx;
let circle;

(function init() {
  canvas = document.querySelector("canvas");
  canvas.width = innerWidth;
  canvas.height = innerHeight;
  ctx = canvas.getContext("2d");
  circle = new Circle(canvas.width / 2, canvas.height / 2, 30);
  loop();
})();

function loop() {
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  circle.draw();
  requestAnimationFrame(loop);
}
body {
  margin: 0;
  height: 100vh;
}
<canvas></canvas>

Alternately, you can just write:

ctx.strokeStyle = "white";
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
ctx.closePath();
ctx.stroke();

and skip the translation step entirely.

Upvotes: 1

BoltKey
BoltKey

Reputation: 2198

In your code, the ctx.translate(0, 0) does absolutely nothing, because that function sets transformation relative to current transformation. You are telling the context "move 0 pixels right and 0 pixels down". You could fix that by changing the line to ctx.translate(-this.x, -this.y) so you do the opposite transformation.

However, usually, this is done by saving the context state with CanvasRenderingContext2D.save before making transformations and then restoring it with CanvasRenderingContext2D.restore. In your example, it would look like this:

ctx.save();  // here, we are saving state of the context
ctx.strokeStyle = "white";
ctx.translate(this.x, this.y);
ctx.beginPath();
// Draws the circle
ctx.arc(0, 0, this.r, 0, 2 * Math.PI);
ctx.stroke();
ctx.closePath();
ctx.restore();  // after this, context will have the state it had when we called save()

This way is good in cases when you want to return the context to its original state after the operation, rather than the default state (which you usually do when making more complex operations), and when you do multiple transformations which would be complicated to revert.

Upvotes: 0

Kris10an
Kris10an

Reputation: 559

I just found the answer. As @mpen commented ctx.translate(0, 0) doesnt reset the translation, but this does: ctx.setTransform(1, 0, 0, 1, 0, 0);. The ctx.translate function translates related to the previous translation.

Upvotes: 1

Related Questions