javascriptcanvasoptimizationdrawimage

Optimise javascript canvas for mass-drawing of tiny objects


I've been working on a game which requires thousands of very small images (20^20 px) to be rendered and rotated each frame. A sample snippet is provided.

I've used every trick I know to speed it up to increase frame rates but I suspect there are other things I can do to optimise this.

Current optimisations include:

Tried but not present in example:

//initial canvas and context
var canvas = document.getElementById('canvas');
    canvas.width = 800; 
    canvas.height = 800;
var ctx = canvas.getContext('2d');

//create an image (I) to render
let myImage = new OffscreenCanvas(10,10);
let myImageCtx = myImage.getContext('2d');
myImageCtx.fillRect(0,2.5,10,5);
myImageCtx.fillRect(0,0,2.5,10);
myImageCtx.fillRect(7.5,0,2.5,10);


//animation 
let animation = requestAnimationFrame(frame);

//fill an initial array of [n] object positions and angles
let myObjects = [];
for (let i = 0; i <1500; i++){
  myObjects.push({
      x : Math.floor(Math.random() * 800),
      y : Math.floor(Math.random() * 800),
      angle : Math.floor(Math.random() * 360),
   });
}

//render a specific frame 
function frame(){
  ctx.clearRect(0,0,canvas.width, canvas.height);
  
  //draw each object and update its position
  for (let i = 0, l = myObjects.length; i<l;i++){
    drawImageNoReset(ctx, myImage, myObjects[i].x, myObjects[i].y, myObjects[i].angle);
    myObjects[i].x += 1; if (myObjects[i].x > 800) {myObjects[i].x = 0}
    myObjects[i].y += .5; if (myObjects[i].y > 800) {myObjects[i].y = 0}   
    myObjects[i].angle += .01; if (myObjects[i].angle > 360) {myObjects[i].angle = 0}   
    
  }
  //reset the transform and call next frame
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  requestAnimationFrame(frame);
}

//fastest transform draw method - no transform reset
function drawImageNoReset(myCtx, image, x, y, rotation) {
    myCtx.setTransform(1, 0, 0, 1, x, y);
    myCtx.rotate(rotation);
    myCtx.drawImage(image, 0,0,image.width, image.height,-image.width / 2, -image.height / 2, image.width, image.height);
}
<canvas name = "canvas" id = "canvas"></canvas>


Solution

  • You are very close to the max throughput using the 2D API and a single thread, however there are some minor points that can improve performance.

    WebGL2

    First though, if you are after the best performance possible using javascript you must use WebGL

    With WebGL2 you can draw 8 or more times as many 2D sprites than with the 2D API and have a larger range of FX (eg color, shadow, bump, single call smart tile maps...)

    WebGL is VERY worth the effort

    Performance related points

    Non performance point.

    Each animation call you are preparing a frame for the next (upcoming) display refresh. In your code you are displaying the game state then updating. That means your game state is one frame ahead of what the client sees. Always update state, then display.

    Example

    This example has added some additional load to the objects

    The example includes a utility that attempts to balance the frame rate by varying the number of objects.

    Every 15 frames the (work) load is updated. Eventually it will reach a stable rate.

    DON`T NOT gauge the performance by running this snippet, SO snippets sits under all the code that runs the page, the code is also modified and monitored (to protect against infinite loops). The code you see is not the code that runs in the snippet. Just moving the mouse can cause dozens of dropped frames in the SO snippet

    For accurate results copy the code and run it alone on a page (remove any extensions that may be on the browser while testing)

    Use this or similar to regularly test your code and help you gain experience in knowing what is good and bad for performance.

    Meaning of rate text.

    const IMAGE_SIZE = 10;
    const IMAGE_DIAGONAL = (IMAGE_SIZE ** 2 * 2) ** 0.5 / 2;
    const DISPLAY_WIDTH = 800;
    const DISPLAY_HEIGHT = 800;
    const DISPLAY_OFFSET_WIDTH = DISPLAY_WIDTH + IMAGE_DIAGONAL * 2;
    const DISPLAY_OFFSET_HEIGHT = DISPLAY_HEIGHT + IMAGE_DIAGONAL * 2;
    const PERFORMANCE_SAMPLE_INTERVAL = 15;  // rendered frames
    const INIT_OBJ_COUNT = 500;
    const MAX_CPU_COST = 8; // in ms
    const MAX_ADD_OBJ = 10;
    const MAX_REMOVE_OBJ = 5;
    
    canvas.width = DISPLAY_WIDTH; 
    canvas.height = DISPLAY_HEIGHT;
    requestAnimationFrame(start);
    
    function createImage() {
        const image = new OffscreenCanvas(IMAGE_SIZE,IMAGE_SIZE);
        const ctx = image.getContext('2d');
        ctx.fillRect(0, IMAGE_SIZE / 4, IMAGE_SIZE, IMAGE_SIZE / 2);
        ctx.fillRect(0, 0, IMAGE_SIZE / 4, IMAGE_SIZE);
        ctx.fillRect(IMAGE_SIZE * (3/4), 0, IMAGE_SIZE / 4, IMAGE_SIZE);
        image.neg_half_width = -IMAGE_SIZE / 2;  // snake case to ensure future proof (no name clash)
        image.neg_half_height = -IMAGE_SIZE / 2; // use of Image API
        return image;
    }
    function createObject() {
        return {
             x : Math.random() * DISPLAY_WIDTH,
             y : Math.random() * DISPLAY_HEIGHT,
             r : Math.random() * Math.PI * 2,
             dx: (Math.random() - 0.5) * 2,
             dy: (Math.random() - 0.5) * 2,
             dr: (Math.random() - 0.5) * 0.1,
        };
    }
    function createObjects() {
        const objects = [];
        var i = INIT_OBJ_COUNT;
        while (i--) { objects.push(createObject()) }
        return objects;
    }
    function update(objects){
        for (const obj of objects) {
            obj.x = ((obj.x + DISPLAY_OFFSET_WIDTH + obj.dx) % DISPLAY_OFFSET_WIDTH);
            obj.y = ((obj.y + DISPLAY_OFFSET_HEIGHT + obj.dy) % DISPLAY_OFFSET_HEIGHT);
            obj.r += obj.dr;       
        }
    }
    function render(ctx, img, objects){
        for (const obj of objects) { drawImage(ctx, img, obj) }
    }
    function drawImage(ctx, image, {x, y, r}) {
        const ax = Math.cos(r), ay = Math.sin(r);
        ctx.setTransform(ax, ay, -ay, ax, x  - IMAGE_DIAGONAL, y  - IMAGE_DIAGONAL);    
        ctx.drawImage(image, image.neg_half_width, image.neg_half_height);
    }
    function timing(framesPerTick) {  // creates a running mean frame time
        const samples = [0,0,0,0,0,0,0,0,0,0];
        const sCount = samples.length;
        var samplePos = 0;
        var now = performance.now();
        const maxRate = framesPerTick * (1000 / 60);
        const API = {
            get FPS() {
                var time = performance.now();
                const FPS =  1000 / ((time - now) / framesPerTick);
                const dropped = ((time - now) - maxRate) / (1000 / 60) | 0;
                now = time;
                if (FPS > 30) { return "60fps " + dropped + "dropped" };
                if (FPS > 20) { return "30fps " + (dropped / 2 | 0) + "dropped" };
                if (FPS > 15) { return "20fps " + (dropped / 3 | 0) + "dropped" };
                if (FPS > 10) { return "15fps " + (dropped / 4 | 0) + "dropped" };
                return "Too slow";
            },
            time(time) { samples[(samplePos++) % sCount] = time },
            get mean() { return samples.reduce((total, val) => total += val, 0) / sCount },
        };
        return API;
    }
    function updateStats(CPUCost, objects) {
        const fps = CPUCost.FPS;
        const mean = CPUCost.mean;            
        const cost = mean / objects.length; // estimate per object CPU cost
        const count =  MAX_CPU_COST / cost | 0;
        const objCount = objects.length;
        var str = "0";
        if (count < objects.length) {
            var remove = Math.min(MAX_REMOVE_OBJ, objects.length - count);
            str = "-" + remove;
            objects.length -= remove;
        } else if (count > objects.length + MAX_ADD_OBJ) {
            let i = MAX_ADD_OBJ;
            while (i--) {
                objects.push(createObject());
            }
            str = "+" + MAX_ADD_OBJ;
        }
        info.textContent = str + ": "  + objCount + " sprites " + mean.toFixed(3) + "ms " + fps;
    }
    
    function start() {
        var frameCount = 0;
        const CPUCost = timing(PERFORMANCE_SAMPLE_INTERVAL);
        const ctx = canvas.getContext('2d');
        const image = createImage();
        const objects = createObjects();
        function frame(time) {
            frameCount ++;
            const start = performance.now();
            ctx.setTransform(1, 0, 0, 1, 0, 0);
            ctx.clearRect(0, 0, DISPLAY_WIDTH, DISPLAY_WIDTH);
            update(objects);
            render(ctx, image, objects);
            requestAnimationFrame(frame);
            CPUCost.time(performance.now() - start);
            if (frameCount % PERFORMANCE_SAMPLE_INTERVAL === 0) {
                updateStats(CPUCost, objects);
            }
        }
        requestAnimationFrame(frame);
    }
    #info {
       position: absolute;
       top: 10px;
       left: 10px;
       background: #DDD;
       font-family: arial;
       font-size: 18px;
    }
    <canvas name = "canvas" id = "canvas"></canvas>
    <div id="info"></div>