Search code examples
javascriptgraphicshtml5-canvas

How to rotate groups of objects around pivot in JS (Graphics Programming)


I am working on making a lightweight and simple 2d engine in HTML Canvas with JavaScript, and I am working on inheritance. For example, I have two structures, an entity class and a container class. (Inspired by PixiJS)

They both have draw function that takes four inputs, a reference to my graphics class (basic sprite renderer), rotation offset, x position, and y position. The container pretty much allows groups of entities to move together. How would I go about rotating them around the container's pivot?

Here's my code.

//Container Class
class container {
    //List of objects (children)
    objects = [];
    //Position and rotation
    x = 0;
    y = 0;
    rot = 0;
    //Layer
    layer = 0;

    //Add a child to the container
    addChild = function(child) {
        //Add reference to array
        this.objects.push(child);
        //Sort by layer
        this.objects = this.objects.sort((a, b) => { if (a.layer > b.layer) { return 1 } return -1})
    }

    //Draw
    draw = function(gfx, rot, x, y) {
        //Loop through all children
        for(let i=0; i<this.objects.length; i++) {
            let ent = this.objects[i]
            //Call draw function from child and offset rotation, x, and y, by current x and y.
            //Basically, the x and y from the input of the function offsets the container, and then we
            //offset the children by the container's x and y.
            //Allows for endless inheritance and offsets
            ent.draw(gfx, this.rot + rot, this.x + x, this.y + y);
        }
    }
}

//Entity class
class entity {
    //Sprite
    sprite = null;
    //Position and rotation
    x = 0;
    y = 0;
    rot = 0;
    //Layer
    layer = 0;

    //Create new sprite on creation (don't worry about this)
    constructor() {
        this.sprite = new sprite();
    }

    //Draw function
    draw = function(gfx, rot, x, y) {
        //Draws the sprite at this entities position offsetted by the input position and rotation
        gfx.drawspr(this.sprite, this.x + x, this.y + y, this.rot + rot);
    }
}

Keep in mind that the "scene" is also just a container that starts drawing at position (0,0) and rotation 0.

EDIT: DrawSpr..?

First, there is the sprite class. The entity contains the sprite.

//Sprite class
class sprite {
    //Image
    img = null;
    width = 0;
    height = 0;
    //Loads image
    load = async function(src) {
        //New image object
        this.img = new Image();
        //Sets source to src
        this.img.src = src
        //Waits for image to load
        await new Promise((resolve, reject) => {
            setTimeout(() => {
              resolve("timeout");
            }, 10000);
            this.img.onload = function() {
                resolve();
            }
        });
        //Sets the width to the image
        this.img.onload = null;
        this.width = this.img.width;
        this.height = this.img.height;
    }
}

Here is the drawspr function in question.

//Draws sprite. (spr - sprite object, x - x position on screen to render, y - y position on screen to render
    //rot - rotation to render the sprite
    drawspr = function(spr, x, y, rot) {
        //Save current 2d context transform
        this.ctx.save();
        if(rot!=0) {
            //Rotate the screen to draw the image if its rotation is bigger than 0
            this.ctx.rotate(rot*Math.PI/180);
        }
        //Draw the image at the x and y, and reset 2d context transform
        this.ctx.drawImage(spr.img, x, y, spr.width, spr.height)
        this.ctx.restore();
    }

Here's an example of what I'm trying to do. figure of rotation


Solution

  • Matrix math

    You will need to get farmiliar with matrix math to get a performat solution using the 2D API transformations.

    The setup

    You need to define a matrix for each level (object, world, player, gun, bullet)

    The top level is the world or view matrix. It defines what part of the game world you are looking at. The scale, position, and rotation.

    Each object then has its own transform (local matrix) that is used to position, rotate and scale it. The object is defined by its local coordinates, centered on 0,0 and pointing along its x axis.

    If the object has parts that will be rotated and or scaled relative to itself then you need a matrix for that.

    Each matrix is relative to the matrix above it.

    Apply matices

    To draw an object you multiply the world matrix by the local (object) matrix. To draw the sub object you multiply the world matrix by the local matrix by the sub object matrix.

    The 2D API has the functionality to do all the matrix multiplication for you. You need only ensure that the current transform is the one you want. This is done using ctx.save() and ctx.restore()

    Example

    For example lets consider a world (view) containing tanks. Each tank has a position and rotation. On that tank is are two independent guns mounted on a turret, that move and rotate with the tank, but can be independently moved and rotated.

    We define the world (view) matrix as

    const viewMat = [1,0,0,1,0,0];
    

    The tank matrix can be created using the function below

    function Matrix(x, y, scale = 1, rotate = 0) {
        const xAx = Math.cos(rotate) * scale;
        const xAy = Math.sin(rotate) * scale;
        return [xAx, xAy, -xAy, xAx, x, y]; /* Y Axis is 90 deg to x axis */
    }
    

    To create the matrix

    const tankMat = Matrix(tank.pos.x, tank.pos.y, 1, tank.rotate);
    

    The turret matrix is relative to the tank and set as

    const turretMat = Matrix(tank.turret.pos.x, tank.turret.pos.y, 1, tank.turret.rotate);
    

    Note ctx is the 2D API in following code.

    Note It is far more performant to use ctx.transform and ctx.setTransform creating (and reusing) the matrix array (via Math.cos and Math.sin) than using a combination of ctx.translate, ctx.rotate, ctx.scale as much of the complexity is eliminated as we assume that the y axis is at 90 degree to the x axis.

    To draw...

    • You set the view matrix to make it the current matrix. ctx.setTransform(...viewMat) Note ctx.setTransform sets (replaces) current transform
    • Save the state. ctx.save()
    • Then multiply by the tank matrix. The current matrix is now the tanks. ctx.transform(...tankMat) Note ctx.transform multiplies current transform
    • Draw the bottom parts of the tank. ctx.drawImage(tankImg, -tankImg.width * 0.5, -tankImg.height * 0.5, tankImg.width, tankImg.height);
    • Save the state (current tank matrix) ctx.save()
    • Then multiply the current matrix by the turret matrix. ctx.transform(...turretMat )
    • Draw the turrets. ctx.drawImage(gunImg, -10, -10, gunImg.width, gunImg.height); ctx.drawImage(gunImg, -10, 10, gunImg.width, gunImg.height);
    • Restore (pop) the state (restores tank matrix). ctx.restore()
    • Draw any parts of the tank that are over the turret.
    • Restore (pop) the state (restores view matrix). ctx.restore()
    • You are ready to draw the next item.

    Example Code

    requestAnimationFrame(gameLoop);
    const ctx = canvas.getContext("2d");
    
    /* Helper functions */
    const V2 = (x = 0, y = 0) => ({x, y});
    const Mat = (pos, scale = 1, rotate = 0) => {
        const xAx = Math.cos(rotate) * scale;
        const xAy = Math.sin(rotate) * scale;
        return [xAx, xAy, -xAy, xAx, pos.x, pos.y];
    };
    const setMat = (mat, pos, scale = 1, rotate = 0)  => {
        const xAx = Math.cos(rotate) * scale;
        const xAy = Math.sin(rotate) * scale;
        mat[0] = xAx;
        mat[1] = xAy;
        mat[2] = -xAy;
        mat[3] = xAx;
        mat[4] = pos.x;
        mat[5] = pos.y;
        return mat;
    };
    
    const worldMat = Mat(V2());
    const turret = { // relative to tank
        pos: V2(-20, 0),
        scale: 1,
        rot: 0,
        mat: Mat(V2(-20,0)),
        gunPos: 0,  /* move the guns in and out */
        update() {
           this.rot += 0.01;
           this.gunPos = Math.sin(performance.now() * 0.01) * 20;
           setMat(this.mat, this.pos, this.scale, this.rot); // update matrix
        },
        draw() {
            ctx.save();
            ctx.fillStyle = "#4A5";
            ctx.strokeStyle = "#232";
            ctx.lineWidth = 1;
            ctx.transform(...this.mat);
            /* draw left right guns */
            ctx.fillRect(20 + this.gunPos, -30, 90, 20);     
            ctx.fillRect(20 + this.gunPos, 10, 90, 20);
            
            /* draw turret */
            ctx.fillStyle = "#8E9";
            ctx.fillRect(-40, -40, 80, 80);
            ctx.strokeRect(-40, -40, 80, 80);
            ctx.restore();
        }
    };
    const tank = { // relative to world
        pos: V2(200, 50),
        scale: 1,
        rot: 0,
        mat: Mat(V2(200,200)),
        turret,
        update() {
           this.rot += 0.01;
           this.pos.x += this.mat[0];  // move along x axis
           this.pos.y += this.mat[1];
           setMat(this.mat, this.pos, this.scale, this.rot); // update matrix
           this.turret.update();
        },
        draw() {
            ctx.save();
            ctx.fillStyle = "#6C7";
            ctx.strokeStyle = "#121";
            ctx.lineWidth = 2;
            ctx.transform(...this.mat);
            
            /* draw tank body */
            ctx.fillRect(-60, -50, 180, 100);
            ctx.strokeRect(-60, -50, 180, 100);
            this.turret.draw();    
            ctx.restore();
        }
    };
    
    
    function gameLoop() {
      ctx.setTransform(1,0,0,1,0,0); /* default transform */
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      
      tank.update();
      
      ctx.setTransform(worldMat);
      tank.draw();
    
      requestAnimationFrame(gameLoop);
    }
    <canvas id="canvas" width="400" height="400"></canvas>

    More

    There is much more to learn in regards to matrices. They are also used in the 3D world in the same way.

    Often you need to reverse the transform. IE locate where the mouse is on the transformed object. Or where a bullet hits your transformed tank Example answer.

    It is best to become very familiar with matrices and how they are used in computer graphics. Remember that matrices only represent a set of multiplications and additions, there are often shortcuts you can leverage to get improved performance.