Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Three mouse detection techniques for HTML5 canvas, none adequate

I've built a canvas library for managing scenes of shapes for some work projects. Each shape is an object with a drawing method associated with it. During a refresh of the canvas, each shape on the stack is drawn. A shape may have typical mouse events bound which are all wrapped around the canvas' own DOM mouse events.

I found some techniques in the wild for detecting mouseover on individual shapes, each of which works but with some pretty serious caveats.

  1. A cleared ghost canvas is used to draw an individual shape by itself. I then store a copy of the ghost canvas with getImageData(). As you can imagine, this takes up a LOT of memory when there are many points with mouse events bound (100 clickable shapes on a 960x800 canvas is ~300MB in memory).

  2. To sidestep the memory issue, I began looping over the pixel data and storing only addresses to pixels with non-zero alpha. This worked well for reducing memory, but dramatically increased the CPU load. I only iterate on every 4th index (RGBA), and any pixel address with a non-zero alpha is stored as a hash key for fast lookups during mouse moves. It still overloads mobile browsers and Firefox on Linux for 10+ seconds.

  3. I read about a technique where all shapes would be drawn to one ghost canvas using color to differentiate which shape owned each pixel. I was really happy with this idea, because it should theoretically be able to differentiatate between millions of shapes.

    Unfortunately, this is broken by anti-aliasing, which cannot be disabled on most canvas implementations. Each fuzzy edge creates dozens of colors which might be safely ignored except that /they can blend/ with overlapping shape edges. The last thing I want to happen when someone crosses the mouse over a shape boundary is to fire semi-random mouseover events for unrelated shapes associated with colors that have emerged from the blending due to AA.

I know that this not a new problem for video game developers and there must be fast algorithms for this kind of thing. If anyone is aware of an algorithm that can resolve (realistically) hundreds of shapes without occupying the CPU for more than a few seconds or blowing up RAM consumption dramatically, I would be very grateful.

There are two other Stack Overflow topics on mouseover detection, both of which discuss this topic, but they go no further than the 3 methods I describe. Detect mouseover of certain points within an HTML canvas? and mouseover circle HTML5 canvas.

EDIT: 2011/10/21

I tested another method which is more dynamic and doesn't require storing anything, but it's crippled by a performance problem in Firefox. The method is basically to loop over the shapes and: 1) clear 1x1 pixel under mouse, 2) draw shape, 3) get 1x1 pixel under mouse. Surprisingly this works very well in Chrome and IE, but miserably under Firefox.

Apparently Chrome and IE are able to optimize if you only want a small pixel area, but Firefox doesn't appear to be optimizing at all based on the desired pixel area. Maybe internally it gets the entire canvas, then returns your pixel area.

Code and raw output here: http://pastebin.com/aW3xr2eB.

Canvas getImageData() Performance (ms)

like image 760
jcampbelly Avatar asked Oct 17 '11 20:10

jcampbelly


2 Answers

If I understand the question correctly, you want to detect when the mouse enters/leaves a shape on the canvas, correct?

If so, then you can use simple geometric calculations, which are MUCH simpler and faster than looping over pixel data. Your rendering algorithm already has a list of all visible shapes, so you know the position, dimension and type of each shape.

Assuming you have some kind of list of shapes, similar to what @Benjammmin' is describing, you can loop over the visible shapes and do point-inside-polygon checks:

// Track which shape is currently under the mouse cursor, and raise
// mouse enter/leave events
function trackHoverShape(mousePos) {
    var shape;
    for (var i = 0, len = visibleShapes.length; i < len; i++) {
        shape = visibleShapes[i];
        switch (shape.type ) {
            case 'arc':
                if (pointInCircle(mousePos, shape) &&
                    _currentHoverShape !== shape) {
                        raiseEvent(_currentHoverShape, 'mouseleave');
                        _currentHoverShape = shape;
                        raiseEvent(_currentHoverShape, 'mouseenter');
                    return;
                }
                break;
            case 'rect':
                if (pointInRect(mousePos, shape) &&
                    _currentHoverShape !== shape) {
                       raiseEvent(_currentHoverShape, 'mouseleave');
                       _currentHoverShape = shape;
                       raiseEvent(_currentHoverShape, 'mouseenter');
                }
                break;
        }
    }
}

function raiseEvent(shape, eventName) {
    var handler = shape.events[eventName];
    if (handler)
        handler();
}

// Check if the distance between the point and the shape's
// center is greater than the circle's radius. (Pythagorean theroem)
function pointInCircle(point, shape) {
    var distX = Math.abs(point.x - shape.center.x),
        distY = Math.abs(point.y - shape.center.y),
        dist = Math.sqrt(distX * distX + distY * distY);
    return dist < shape.radius;
}

So, just call the trackHoverShape inside your canvas mousemove event and it will keep track of the shape currently under the mouse.

I hope this helps.

like image 166
alekop Avatar answered Oct 05 '22 23:10

alekop


From comment:

Personally I would just switch to using SVG. It's more what it was made for. However it may be worth looking at EaselJS source. There's a method Stage.getObjectUnderPoint(), and their demo's of this seem to work perfectly fine.

I ended up looking at the source, and the library utilises your first approach - separate hidden canvas for each object.

One idea that came to mind was attempting to create some kind of a content-aware algorithm to detect anti-aliased pixels and with what shapes they belong. I quickly dismissed this idea.

I do have one more theory, however. There doesn't seem to be a way around using ghost canvases, but maybe there is a way to generate them only when they're needed.

Please note the following idea is all theoretical and untested. It is possible I may have overlooked something that would mean this method would not work.

Along with drawing an object, store the method in which you drew that object. Then, using the method of drawing an object you can calculate a rough bounding box for that object. When clicking on the canvas, run a loop through all the objects you have on the canvas and extract ones which bounding boxes intercept with the point. For each of these extracted objects, draw them separately onto a ghost canvas using the method reference for that object. Determine if the mouse is positioned over a non-white pixel, clear the canvas, and repeat.

As an example, consider I have drawn two objects. I will store the methods for drawing the rectangle and circle in a readable manner.

  • circ = ['beginPath', ['arc', 75, 75, 10], 'closePath', 'fill']
  • rect = ['beginPath', ['rect', 150, 5, 30, 40], 'closePath', 'fill']

(You may want to minify the data saved, or use another syntax, such as the SVG syntax)

As I am drawing these circles for the first time, I will also keep note of the dimensional values and use them to determine a bounding box (Note: you will need to compensate for stroke widths).

  • circ = {left: 65, top: 65, right: 85, bottom: 85}
  • rect = {left: 150, top: 5, right: 180, bottom: 45}

A click event has occurred on the canvas. The mouse point is {x: 70, y: 80}

Looping through the two objects, we find that the mouse coordinates fall within the circle bounds. So we mark the circle object as a possible candidate for collision.

Analysing the circles drawing method, we can recreate it on a ghost canvas and then test if the mouse coordinates fall on a non-white pixel.

After determining if it does or does not, we can clear the ghost canvas to prepare for any more objects to be drawn on it.

As you can see this removes the need to store 960 x 800 x 100 pixels and only 960 x 800 x2 at most.

This idea would best be implemented as some kind of API for automatically handling the data storage (such as the method of drawing, dimensions...).

like image 44
Ben Avatar answered Oct 06 '22 00:10

Ben