13

We're working with the HTML5 canvas, displaying lots of images at one time.

This is working pretty well but recently we've had a problem with chrome.

When drawing images on to a canvas you seem to reach a certain point where the performance degrades very quickly.

It's not a slow effect, it seems that you go right from 60fps to 2-4fps.

Here's some reproduction code:

// Helpers
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Math/random
function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
// http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/
window.requestAnimFrame = (function () { return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function (callback) { window.setTimeout(callback, 1000 / 60); }; })();
// https://github.com/mrdoob/stats.js
var Stats = function () { var e = Date.now(), t = e; var n = 0, r = Infinity, i = 0; var s = 0, o = Infinity, u = 0; var a = 0, f = 0; var l = document.createElement("div"); l.id = "stats"; l.addEventListener("mousedown", function (e) { e.preventDefault(); y(++f % 2) }, false); l.style.cssText = "width:80px;opacity:0.9;cursor:pointer"; var c = document.createElement("div"); c.id = "fps"; c.style.cssText = "padding:0 0 3px 3px;text-align:left;background-color:#002"; l.appendChild(c); var h = document.createElement("div"); h.id = "fpsText"; h.style.cssText = "color:#0ff;font-family:Helvetica,Arial,sans-serif;font-size:9px;font-weight:bold;line-height:15px"; h.innerHTML = "FPS"; c.appendChild(h); var p = document.createElement("div"); p.id = "fpsGraph"; p.style.cssText = "position:relative;width:74px;height:30px;background-color:#0ff"; c.appendChild(p); while (p.children.length < 74) { var d = document.createElement("span"); d.style.cssText = "width:1px;height:30px;float:left;background-color:#113"; p.appendChild(d) } var v = document.createElement("div"); v.id = "ms"; v.style.cssText = "padding:0 0 3px 3px;text-align:left;background-color:#020;display:none"; l.appendChild(v); var m = document.createElement("div"); m.id = "msText"; m.style.cssText = "color:#0f0;font-family:Helvetica,Arial,sans-serif;font-size:9px;font-weight:bold;line-height:15px"; m.innerHTML = "MS"; v.appendChild(m); var g = document.createElement("div"); g.id = "msGraph"; g.style.cssText = "position:relative;width:74px;height:30px;background-color:#0f0"; v.appendChild(g); while (g.children.length < 74) { var d = document.createElement("span"); d.style.cssText = "width:1px;height:30px;float:left;background-color:#131"; g.appendChild(d) } var y = function (e) { f = e; switch (f) { case 0: c.style.display = "block"; v.style.display = "none"; break; case 1: c.style.display = "none"; v.style.display = "block"; break } }; var b = function (e, t) { var n = e.appendChild(e.firstChild); n.style.height = t + "px" }; return { REVISION: 11, domElement: l, setMode: y, begin: function () { e = Date.now() }, end: function () { var f = Date.now(); n = f - e; r = Math.min(r, n); i = Math.max(i, n); m.textContent = n + " MS (" + r + "-" + i + ")"; b(g, Math.min(30, 30 - n / 200 * 30)); a++; if (f > t + 1e3) { s = Math.round(a * 1e3 / (f - t)); o = Math.min(o, s); u = Math.max(u, s); h.textContent = s + " FPS (" + o + "-" + u + ")"; b(p, Math.min(30, 30 - s / 100 * 30)); t = f; a = 0 } return f }, update: function () { e = this.end() } } }
// Firefox events suck
function getOffsetXY(eventArgs) { return { X: eventArgs.offsetX == undefined ? eventArgs.layerX : eventArgs.offsetX, Y: eventArgs.offsetY == undefined ? eventArgs.layerY : eventArgs.offsetY }; }
function getWheelDelta(eventArgs) { if (!eventArgs) eventArgs = event; var w = eventArgs.wheelDelta; var d = eventArgs.detail; if (d) { if (w) { return w / d / 40 * d > 0 ? 1 : -1; } else { return -d / 3; } } else { return w / 120; }  }

// Reproduction Code
var stats = new Stats();
document.body.appendChild(stats.domElement);

var masterCanvas = document.getElementById('canvas');
var masterContext = masterCanvas.getContext('2d');

var viewOffsetX = 0;
var viewOffsetY = 0;
var viewScaleFactor = 1;
var viewMinScaleFactor = 0.1;
var viewMaxScaleFactor = 10;

var mouseWheelSensitivity = 10; //Fudge Factor
var isMouseDown = false;
var lastMouseCoords = null;

var imageDimensionPixelCount = 25;
var paddingPixelCount = 2;
var canvasDimensionImageCount = 50;
var totalImageCount = Math.pow(canvasDimensionImageCount, 2);

var images = null;

function init() {
    images = createLocalImages(totalImageCount, imageDimensionPixelCount);
    initInteraction();
    renderLoop();
}

function initInteraction() {
    var handleMouseDown = function (eventArgs) {
        isMouseDown = true;
        var offsetXY = getOffsetXY(eventArgs);

        lastMouseCoords = [
            offsetXY.X,
            offsetXY.Y
        ];
    };
    var handleMouseUp = function (eventArgs) {
        isMouseDown = false;
        lastMouseCoords = null;
    }

    var handleMouseMove = function (eventArgs) {
        if (isMouseDown) {
            var offsetXY = getOffsetXY(eventArgs);
            var panX = offsetXY.X - lastMouseCoords[0];
            var panY = offsetXY.Y - lastMouseCoords[1];

            pan(panX, panY);

            lastMouseCoords = [
                offsetXY.X,
                offsetXY.Y
            ];
        }
    };

    var handleMouseWheel = function (eventArgs) {
        var mouseX = eventArgs.pageX - masterCanvas.offsetLeft;
        var mouseY = eventArgs.pageY - masterCanvas.offsetTop;                
        var zoom = 1 + (getWheelDelta(eventArgs) / mouseWheelSensitivity);

        zoomAboutPoint(mouseX, mouseY, zoom);

        if (eventArgs.preventDefault !== undefined) {
            eventArgs.preventDefault();
        } else {
            return false;
        }
    }

    masterCanvas.addEventListener("mousedown", handleMouseDown, false);
    masterCanvas.addEventListener("mouseup", handleMouseUp, false);
    masterCanvas.addEventListener("mousemove", handleMouseMove, false);
    masterCanvas.addEventListener("mousewheel", handleMouseWheel, false);
    masterCanvas.addEventListener("DOMMouseScroll", handleMouseWheel, false);
}

function pan(panX, panY) {
    masterContext.translate(panX / viewScaleFactor, panY / viewScaleFactor);

    viewOffsetX -= panX / viewScaleFactor;
    viewOffsetY -= panY / viewScaleFactor;
}

function zoomAboutPoint(zoomX, zoomY, zoomFactor) {
    var newCanvasScale = viewScaleFactor * zoomFactor;

    if (newCanvasScale < viewMinScaleFactor) {
        zoomFactor = viewMinScaleFactor / viewScaleFactor;
    } else if (newCanvasScale > viewMaxScaleFactor) {
        zoomFactor = viewMaxScaleFactor / viewScaleFactor;
    }

    masterContext.translate(viewOffsetX, viewOffsetY);
    masterContext.scale(zoomFactor, zoomFactor);

    viewOffsetX = ((zoomX / viewScaleFactor) + viewOffsetX) - (zoomX / (viewScaleFactor * zoomFactor));
    viewOffsetY = ((zoomY / viewScaleFactor) + viewOffsetY) - (zoomY / (viewScaleFactor * zoomFactor));
    viewScaleFactor *= zoomFactor;

    masterContext.translate(-viewOffsetX, -viewOffsetY);
}

function renderLoop() {
    clearCanvas();
    renderCanvas();
    stats.update();
    requestAnimFrame(renderLoop);
}

function clearCanvas() {
    masterContext.clearRect(viewOffsetX, viewOffsetY, masterCanvas.width / viewScaleFactor, masterCanvas.height / viewScaleFactor);
}

function renderCanvas() {
    for (var imageY = 0; imageY < canvasDimensionImageCount; imageY++) {
        for (var imageX = 0; imageX < canvasDimensionImageCount; imageX++) {
            var x = imageX * (imageDimensionPixelCount + paddingPixelCount);
            var y = imageY * (imageDimensionPixelCount + paddingPixelCount);

            var imageIndex = (imageY * canvasDimensionImageCount) + imageX;
            var image = images[imageIndex];

            masterContext.drawImage(image, x, y, imageDimensionPixelCount, imageDimensionPixelCount);
        }
    }
}

function createLocalImages(imageCount, imageDimension) {
    var tempCanvas = document.createElement('canvas');
    tempCanvas.width = imageDimension;
    tempCanvas.height = imageDimension;
    var tempContext = tempCanvas.getContext('2d');

    var images = new Array();

    for (var imageIndex = 0; imageIndex < imageCount; imageIndex++) {
        tempContext.clearRect(0, 0, imageDimension, imageDimension);
        tempContext.fillStyle = "rgb(" + getRandomInt(0, 255) + ", " + getRandomInt(0, 255) + ", " + getRandomInt(0, 255) + ")";
        tempContext.fillRect(0, 0, imageDimension, imageDimension);

        var image = new Image();
        image.src = tempCanvas.toDataURL('image/png');

        images.push(image);
    }

    return images;
}

// Get this party started
init();

And a jsfiddle link for your interactive pleasure: http://jsfiddle.net/BtyL6/14/

This is drawing 50px x 50px images in a 50 x 50 (2500) grid on the canvas. I've also quickly tried with 25px x 25px and 50 x 50 (2500) images.

We have other local examples that deal with bigger images and larger numbers of images and the other browser start to struggle with these at higher values.

As a quick test I jacked up the code in the js fiddle to 100px x 100px and 100 x 100 (10000) images and that was still running at 16fps when fully zoomed out. (Note: I had to lower the viewMinScaleFactor to 0.01 to fit it all in when zoomed out.)

Chrome on the other hand seems to hit some kind of limit and the FPS drops from 60 to 2-4.


Here's some info about what we've tried and the results:

We've tried using setinterval rather than requestAnimationFrame.

If you load 10 images and draw them 250 times each rather than 2500 images drawn once each then the problem goes away. This seems to indicate that chrome is hitting some kind of limit/trigger as to how much data it's storing about the rendering.

We have culling (not rendering images outside of the visual range) in our more complex examples and while this helps it's not a solution as we need to be able to show all the images at once.

We have the images only being rendered if there have been changes in our local code, against this helps (when nothing changes, obviously) but it isn't a full solution because the canvas should be interactive.

In the example code we're creating the images using a canvas, but the code can also be run hitting a web service to provide the images and the same behaviour (slowness) will be seen.


We've found it very hard to even search for this issue, most results are from a couple of years ago and woefully out of date.

If any more information would be useful then please ask!


EDIT: Changed js fiddle URL to reflect the same code as in the question. The code itself didn't actually change, just the formatting. But I want to be consistent.


EDIT: Updated jsfiddle and and code with css to prevent selection and call requestAnim after the render loop is done.

4

2 回答 2

6

In Canary this code freezes it on my computer. As to why this happens in Chrome the simple answer is that it uses a different implementation than f.ex. FF. In-depth detail I don't know, but there is obviously room for optimizing the implementation in this area.

I can give some tip however on how you can optimize the given code to make it run in Chrome as well :-)

There are several things here:

  • You are storing each block of colors as images. This seem to have a huge performance impact on Canary / Chrome.
  • You are calling requestAnimationFrame at the beginning of the loop
  • You are clearing and rendering even if there are no changes

Try to (addressing the points):

  • If you only need solid blocks of colors, draw them directly using fillRect() instead and keep the color indexes in an array (instead of images). Even if you draw them to an off-screen canvas you will only have to do one draw to main canvas instead of multiple image draw operations.
  • Move requestAnimationFrame to the end of the code block to avoid stacking.
  • Use dirty flag to prevent unnecessary rendering:

I modified the code a bit - I modified it to use solid colors to demonstrate where the performance impact is in Chrome / Canary.

I set a dirty flag in global scope as true (to render the initial scene) which is set to true each time the mouse move occur:

//global
var isDirty = true;

//mouse move handler
var handleMouseMove = function (eventArgs) {

    // other code

    isDirty = true;

    // other code
};

//render loop
function renderLoop() {
    if (isDirty) {
        clearCanvas();
        renderCanvas();
    }
    stats.update();
    requestAnimFrame(renderLoop);
}

//in renderCanvas at the end:
function renderCanvas() {
    // other code
    isDirty = false;
}

You will of course need to check for caveats for the isDirty flag elsewhere and also introduce more criteria if it's cleared at the wrong moment. I would store the old position of the mouse and only (in the mouse move) if it changed set the dirty flag - I didn't modify this part though.

As you can see you will be able to run this in Chrome and in FF at a higher FPS.

I also assume (I didn't test) that you can optimize the clearCanvas() function by only drawing the padding/gaps instead of clearing the whole canvas. But that need to be tested.

Added a CSS-rule to prevent the canvas to be selected when using the mouse:

For further optimizing in cases such as this, which is event driven, you don't actually need an animation loop at all. You can just call the redraw when the coords or mouse-wheel changes.

Modification:
http://jsfiddle.net/BtyL6/10/

于 2013-05-24T19:37:17.610 回答
1

This was a legitimate bug in chrome.

https://code.google.com/p/chromium/issues/detail?id=247912

It has now been fixed and should be in a chrome mainline release soon.

于 2013-09-05T07:59:56.600 回答