Raiden Raiden - 7 months ago 19
Javascript Question

Rotating a group of objects while keeping their orientation intact

Basically I have a container object with 'children' that are modified relative to their parent, and I want to rotate all of the objects by changing the parent's rotation value, while keeping the orientation of the individual children stable. (as in, rotate the whole object) I feel like I'm not explaining this very well, so here are two examples. PhysicsJS: http://wellcaffeinated.net/PhysicsJS/ (see the first example, with the 0.7 and the balls -- notice how when the zero or seven are rotated after a collision the overall shape of the object is maintained. Same goes for this example in PhaserJS (http://phaser.io/examples/v2/groups/group-transform-rotate) with the robot. Now, just to see if I could, I tried to duplicate the aforementioned PhysicsJS example with my own library -- https://jsfiddle.net/khanfused/r4LgL5y9/ (simplified for brevity)

Art.prototype.modules.display.rectangle.prototype.draw = function() {

// Initialize variables.

var g = Art.prototype.modules.display.rectangle.core.graphics,
t = this;

// Execute the drawing commands.

g.save();
g.translate(t.parent.x ? t.parent.x + t.x : t.x, t.parent.y ? t.parent.y + t.y : t.y);

/* Point of interest. */

g.rotate(t.parent.rotation ? t.rotation : t.rotation);

g.scale(t.scale.x, t.scale.y);
g.globalAlpha = t.opacity === 'super' ? t.parent.opacity : t.opacity;
g.lineWidth = t.lineWidth === 'super' ? t.parent.lineWidth : t.lineWidth;
g.fillStyle = t.fill === 'super' ? t.parent.fill : t.fill;
g.strokeStyle = t.stroke === 'super' ? t.parent.stroke : t.stroke;
g.beginPath();
g.rect(t.width / -2, t.height / -2, t.width, t.height);
g.closePath();
if (t.fill) {
g.fill();
}
if (t.stroke) {
g.stroke();
}
g.restore();

return this;

};


Refer to the labeled point of interest -- that's where I rotate the canvas. If the object has a parent, it's rotated by the parent's value plus the object's value -- otherwise, just the object's value. I've tried some different combinations, like...

• parent - object


• object - parent

...and I looked through PhysicsJS and Phaser's sources for some kind of clue in the right direction, to no avail.

How do I rotate a group but not change its layout?

Answer

Nested Transform

To transform a group of objects surround the group with the transform you wish to apply to all the members of the group and then just render each member with its own transform. Before each member is transformed by its local transform you need to save the current transform so it can be used for the next group member. At the end of rendering each group member you must restore the transform back to the state for the group above it.

The data structure

group = {
    origin : { x : 100, y : 100},
    rotate : 2,
    scale : { x : 1, y : 1},
    render : function(){ // the function that draws to the canvas
        ctx.strokeRect(-50,-50,100,100);
    },
    groups : [ // array of groups
    {   
        origin : { x : 100, y : 100},
        rotate : 2,
        scale : { x : 1, y : 1},
        render : function(){... }// draw something 
        groups : [] // could have more members
    }],  // the objects to be rendered
}

Recursive rendering

Rendering nested transformations is best done via recursion where the renderGroup function checks for any sub groups and calls itself to render that group. This makes it very easy to have complex nested objects with the minimum of code. A tree is a simple example of recursion where the terminating condition is reaching the last node. But this can easily go wrong if you allow nested group members to reference other members within the tree. This will result in Javascript blocking the page and a crash.

function renderGroup(group){
    ctx.save();
    // it is important that the order of transforms us correct
    ctx.translate(group.origin.x, group.origin.y);
    ctx.scale(group.scale.x, group.scale.y);
    ctx.rotate(group.rotate);
    // draw what is needed
    if(group.render !== undefined){
        group.render();
    } 

    // now draw each member of this group.groups
   for ( var i = 0 ; i < group.groups.length; i ++){
        // WARNING this is recursive having any member of a group reference 
        // another member within the nested group object will result in an 
        // infinite recursion and computers just don't have the memory or 
        // speed to complete the impossible 
        renderGroup(group.groups[i]); // recursive call 
    };
   // and finally restore the  original transform
   ctx.restore();
}

That is how to nest transforms and how the W3C has intended for the render to be used. But I would never do it this way. It is a killer of frame rate due to the need to use save and restore, this is because ctx.getTransform support is very limited (only Chrome). As you can not get the transform you must mirror is in code, needless as there are many optimisations that can be applied if you are maintaining the matrix. Where you may get 1000 sprites in realtime using setTransform and a little math, doing it this way on canvas quarters or worse the frame rate.

Demo

Running example with safe recursion.

Draws nested objects centered on where the mouse is.

The demo is simply a recursive render taken from some other code I have and cut to suit this demo. It extends the recursive render to allow for animation and render order. Note that the scales are non uniform thus there will be some skewing the deeper the iterations go.

// adapted from QuickRunJS environment. 

//===========================================================================
// simple mouse
//===========================================================================
var mouse = (function(){
    function preventDefault(e) { e.preventDefault(); }
    var mouse = {
        x : 0, y : 0, buttonRaw : 0,
        bm : [1, 2, 4, 6, 5, 3], // masks for setting and clearing button raw bits;
        mouseEvents : "mousemove,mousedown,mouseup".split(",")
    };
    function mouseMove(e) {
        var t = e.type, m = mouse;
        m.x = e.offsetX; m.y = e.offsetY;
        if (m.x === undefined) { m.x = e.clientX; m.y = e.clientY; }
        if (t === "mousedown") { m.buttonRaw |= m.bm[e.which-1];
        } else if (t === "mouseup") { m.buttonRaw &= m.bm[e.which + 2];}
        e.preventDefault();
    }
    mouse.start = function(element, blockContextMenu){
        if(mouse.element !== undefined){ mouse.removeMouse();}
        mouse.element = element;
        mouse.mouseEvents.forEach(n => { element.addEventListener(n, mouseMove); } );
        if(blockContextMenu === true){
            element.addEventListener("contextmenu", preventDefault, false);
            mouse.contextMenuBlocked = true;
        }        
    }
    mouse.remove = function(){
        if(mouse.element !== undefined){
            mouse.mouseEvents.forEach(n => { mouse.element.removeEventListener(n, mouseMove); } );
            if(mouse.contextMenuBlocked === true){ mouse.element.removeEventListener("contextmenu", preventDefault);}
            mouse.contextMenuBlocked = undefined;            
            mouse.element = undefined;
        }
    }
    return mouse;
})();

//===========================================================================
// fullscreen canvas
//===========================================================================
// delete needed for my QuickRunJS environment
function removeCanvas(){
    if(canvas !== undefined){
        document.body.removeChild(canvas);
    }
    canvas = undefined;    
}
// create onscreen, background, and pixelate canvas
function createCanvas(){
    canvas = document.createElement("canvas"); 
    canvas.style.position = "absolute";
    canvas.style.left     = "0px";
    canvas.style.top      = "0px";
    canvas.style.zIndex   = 1000;
    document.body.appendChild(canvas);
}
function resizeCanvas(){
    if(canvas === undefined){ createCanvas(); }
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight; 
    ctx = canvas.ctx = canvas.getContext("2d"); 
}

//===========================================================================
// general set up
//===========================================================================
var canvas,ctx;
canvas = undefined;
// create and size canvas
resizeCanvas();
// start mouse listening to canvas
mouse.start(canvas,true); // flag that context needs to be blocked
// listen to resize
window.addEventListener("resize",resizeCanvas);
var holdExit = 0; // To stop in QuickRunJS environment
var font = "18px arial";


//===========================================================================
// The following function are for creating render nodes.
//===========================================================================
// render functions
// adds a box render to a node;
function addBoxToNode(node,when,stroke,fill,lwidth,w,h){
    function drawBox(){
        ctx.strokeStyle = this.sStyle;
        ctx.fillStyle = this.fStyle;
        ctx.lineWidth = this.lWidth;
        ctx.fillRect(-this.w/2,-this.h/2,this.w,this.h);
        ctx.strokeRect(-this.w/2,-this.h/2,this.w,this.h);
    }
    var renderNode = {
        render : drawBox,
        sStyle : stroke,
        fStyle : fill,
        lWidth : lwidth,
        w : w,
        h : h,
    }
    node[when].push(renderNode);
    return node;
}
// adds a text render to a node
function addTextToNode(node,when,text,x,y,fill){
    function drawText(){
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillStyle = this.fStyle
        ctx.fillText(this.text,this.x,this.y);
    }
    var renderNode = {
        render : drawText,
        text : text,
        fStyle : fill,
        x : x,
        y : y,
    }    
    node[when].push(renderNode); // binds to this node
    return node;
}
// renders a node
function renderNode(renderList){
    var i,len = renderList.length;
    for(i = 0; i < len; i += 1){
        renderList[i].render();
    }
}

//---------------------------------------------------------------------------
// animation functions
// add a rotator to a node. Rotates the node
function addRotatorToNode(node,speed){
    function rotator(){
        this.transform.rot += this.rotSpeed;
    }
    node.animations.push(rotator.bind(node))
    node.rotSpeed = speed;
}
// addd a wobbla to a nod. Wobbles the node
function addWobblaToNode(node,amount){
    function wobbla(){
        this.transform.sx = 1 - ((Math.cos(this.transform.rot) + 1) / 2) * this.scaleAmount ;
        this.transform.sy = 1 - ((Math.sin(this.transform.rot) + 1) / 2) * this.scaleAmount ;
    }
    node.animations.push(wobbla.bind(node))
    node.scaleAmount = amount;
}
// add a groover to a node. Move that funcky thang.
function addGrooverToNode(node,amount){
    function wobbla(){
        this.transform.x += Math.cos(this.transform.rot) * this.translateDist ;
        this.transform.y += Math.sin(this.transform.rot*3) * this.translateDist ;
    }
    node.animations.push(wobbla.bind(node))
    node.translateDist = amount;
}
// function to animate and set a transform
function setTransform(){
    var i, len = this.animations.length;
    for(i = 0; i < len; i ++){ // do any animtions that are on this node
        this.animations[i]();
    }
    // set the transfomr
    ctx.scale(this.transform.sx, this.transform.sy);
    ctx.translate(this.transform.x, this.transform.y);
    ctx.rotate(this.transform.rot);
}

//---------------------------------------------------------------------------
// node creation
// creats a node and returns it
function createNode(){
    return {
        transform : undefined,
        setTransform : setTransform, // function to apply the current transform
        animations : [], // animation functions
        render : renderNode,  // render main function
        preRenders : [],  // render to be done befor child nodes are rendered
        postRenders : [],  // render to be done after child nodes are rendered
        nodes : [],
        itterationCounter : 0,  // important counts iteration depth
    };
}
function addNodeToNode(node,child){
    node.nodes.push(child);
}

// adds a transform to a node and returns the transform
function createNodeTransform(node,x,y,sx,sy,rot){
    return node.transform =  {
        x : x,  // translate
        y : y,
        sx : sx,  //scale 
        sy : sy,
        rot : rot,  //rotate
    };
}
// only one top node 
var nodeTree = createNode(); // no details as yet
// add a transform to the top node and keep a ref for moving
var topTransform = createNodeTransform(nodeTree,0,0,1,1,0);
// top node has no render
var boxNode = createNode();
createNodeTransform(boxNode,0,0,0.9,0.9,0.1)
addRotatorToNode(boxNode,-0.02)
addWobblaToNode(boxNode,0.2)
addBoxToNode(boxNode,"preRenders","Blue","rgba(0,255,0,0.2)",3,100,100)
addTextToNode(boxNode,"postRenders","FIRST",0,0,"red")
addTextToNode(boxNode,"postRenders","text on top",0,20,"red")
addNodeToNode(nodeTree,boxNode)


function Addnode(node,x,y,scale,rot,text,anRot,anSc,anTr){
    var boxNode1 = createNode();
    createNodeTransform(boxNode1,x,y,scale,scale,rot)
    addRotatorToNode(boxNode1,anRot)
    addWobblaToNode(boxNode1,anSc)
    addGrooverToNode(boxNode1,anTr)
    addBoxToNode(boxNode1,"preRenders","black","rgba(0,255,255,0.2)",3,100,100)
    addTextToNode(boxNode1,"postRenders",text,0,0,"black")
    addNodeToNode(node,boxNode1)
    
    // add boxes to coners
    var boxNode2 = createNode();
    createNodeTransform(boxNode2,50,-50,0.8,0.8,0.1)
    addRotatorToNode(boxNode2,0.2)
    addBoxToNode(boxNode2,"postRenders","black","rgba(0,255,255,0.2)",3,20,20)
    addNodeToNode(boxNode1,boxNode2)
    
    var boxNode2 = createNode();
    createNodeTransform(boxNode2,-50,-50,0.8,0.8,0.1)
    addRotatorToNode(boxNode2,0.2)
    addBoxToNode(boxNode2,"postRenders","black","rgba(0,255,255,0.2)",3,20,20)
    addNodeToNode(boxNode1,boxNode2)

    var boxNode2 = createNode();
    createNodeTransform(boxNode2,-50,50,0.8,0.8,0.1)
    addRotatorToNode(boxNode2,0.2)
    addBoxToNode(boxNode2,"postRenders","black","rgba(0,255,255,0.2)",3,20,20)
    addNodeToNode(boxNode1,boxNode2)
    
    var boxNode2 = createNode();
    createNodeTransform(boxNode2,50,50,0.8,0.8,0.1)
    addRotatorToNode(boxNode2,0.2)
    addBoxToNode(boxNode2,"postRenders","black","rgba(0,255,255,0.2)",3,20,20)
    addNodeToNode(boxNode1,boxNode2)
}
Addnode(boxNode,50,50,0.9,2,"bot right",-0.01,0.1,0);
Addnode(boxNode,50,-50,0.9,2,"top right",-0.02,0.2,0);
Addnode(boxNode,-50,-50,0.9,2,"top left",0.01,0.1,0);
Addnode(boxNode,-50,50,0.9,2,"bot left",-0.02,0.2,0);
//===========================================================================
// RECURSIVE NODE RENDER
//===========================================================================
// safety var MUST HAVE for those not used to recursion
var recursionCount = 0;  // number of nodes 
const MAX_RECUSION = 30; // max number of nodes to itterate
// safe recursive as global recursion count will limit nodes reandered
function renderNodeTree(node){
    var i,len;
    // safty net
    if((recursionCount ++) > MAX_RECUSION){
        return;
    }

    ctx.save(); // save context state
    node.setTransform(); // animate and set transform
    // do pre render
    node.render(node.preRenders);
    
    // render each child node
    len = node.nodes.length;
    for(i = 0; i < len; i += 1){
        renderNodeTree(node.nodes[i]);
    }
    // do post renders
    node.render(node.postRenders);

    ctx.restore(); // restore context state
}

//===========================================================================
// RECURSIVE NODE RENDER
//===========================================================================
ctx.font = font;
function update(time){

    ctx.setTransform(1,0,0,1,0,0);  // reset top transform
    ctx.clearRect(0,0,canvas.width,canvas.height);
    // set the top transform to the mouse position
    topTransform.x = mouse.x;
    topTransform.y = mouse.y; 
    recursionCount = 0;
    
    renderNodeTree(nodeTree);

    requestAnimationFrame(update);

}
requestAnimationFrame(update);