Skip to content

Instantly share code, notes, and snippets.

@tfry-git
Last active June 15, 2024 12:55
Show Gist options
  • Save tfry-git/3f5faa0b1c252dd1e6849da18c16570f to your computer and use it in GitHub Desktop.
Save tfry-git/3f5faa0b1c252dd1e6849da18c16570f to your computer and use it in GitHub Desktop.
**Fast** javascript pixel accurate collision detection

Pixel accurate collision detection is a standard problem for games, but also for many other graphics applications.

Sadly, the HTML5 canvas API does not provide for this, directly. Here's a fast approach to collision detection. The key tricks are:

  1. Collision mask is created only once
  2. Pixel accurate collision detection uses bitwise operation to check up to 32 pixels at once.

Live demo:

(May or may not be up to date:) https://jsfiddle.net/ak7gu54L/

Usage:

  1. Create CollisionMask-objects from ImageData of all objects of interest.
  2. Call object1.collidesWith(object2, dx, dy) to check for collision.

The API is deliberately no more specialized than that, so you can easily add it on top of whatever framework you may already be using.

TODOs:

  1. Add convenience functions for collision with rectangle or point.
  2. Add example for how to scale down collision mask (e.g. to 4x4 pixel accuracy) to provide even faster detection.
  3. Check endianness compatibilty.

Limitations:

Works for unrotated, unscaled objects, only. For rotation, you'll need one max per practically discernable degree of rotation. Fortunately, the collision masks are comparatively lightweight, again thanks to storing 32 mask bits in each field of the array.

<html>
<body>
<p>
The actual canvas, where drawing happens. Mouse over to move the green open circle object.
</p>
<canvas id="canvas" width=200 height=200></canvas>
<p id="result">
</p>
<p id="timing">
</p>
<p>
Dummy canvas to draw a custom "sprite". Usually this would be a hidden img tag or something. It's needed, because, sadly, putImageData() does not work with alpha...
</p>
<canvas id="dummy" width=50 height=50></canvas>
</body>
</html>
/** Copyright 2019 by Thomas Friedrichsmeier.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
/** C'tor. Create a CollisionMask object from ImageData */
function CollisionMask(data) {
this.w = data.width;
this.h = data.height;
this.mask = [];
for (var y = 0; y < this.h; ++y) {
this.mask[y] = new Uint32Array(Math.ceil(this.w / 32));
for (var x = 0; x < this.w; x += 32) {
var bits = 0;
for (var bit = 0; bit < 32; ++bit) {
bits = bits << 1;
if (x + bit < this.w) {
if (data.data[(y*data.width + x + bit) * 4 + 3] > 5) {
bits += 1;
}
}
}
this.mask[y][Math.floor(x / 32)] = bits;
}
}
}
/** Test if this CollisionMask-objects collides with the given other collision mask object. dx and dy specify the
screen coordinates of the other object, relative to this one. Note that this function performs rectangle intersection
check before going into the more expensive pixel-based collision detection, so there is no need to do this, yourself. */
CollisionMask.prototype.collidesWith = function(other, dx, dy) {
// make sure, this object is the left one of the two
if (dx < 0) {
// console.log("swapping");
return other.collidesWith(this, -dx, -dy);
}
// determine collision rectangle (if any) in terms of coordinates of this
if (dx > this.w) return false;
var y1, y2;
if (dy < 0) { // other is above
if (other.h < -dy) return false;
y1 = 0; y2 = Math.min(other.h + dy, this.h);
} else { // other is below
if (this.h < dy) return false;
y1 = dy; y2 = Math.min(other.h + dy, this.h);
}
var x1 = dx; var x2 = Math.min(this.w, other.w + dx);
//console.log("recthit " + x1 + "," + y1 + " - " + x2 + "," + y2);
const lshift = dx % 32;
const rshift = 32 - lshift;
const x1scaled = Math.floor(x1/32);
const x2scaled = Math.ceil(x2/32);
for (var y = y1; y < y2; ++y) {
const trow = this.mask[y];
const orow = other.mask[y-dy];
for (var x = x1scaled; x < x2scaled; ++x) {
var bits = trow[x] << lshift;
if (rshift < 32) { // note: for whatever reason, at rshift==32, rshift will turn into a no-op,
// silently, while actually we really want all zeros in the right fragement, then
bits |= (trow[x + 1] >>> rshift); // Note: zero-fill rshift
}
// since other is known to be to the right of this, its mask is always left-aligned.
if (orow[x-x1scaled] & bits) {
//console.log("collision at line " + y + "!");
return true;
}
}
}
}
//// NOTE: Everything below this line is just a usage demonstration. You'll only want to use the code, above. /////
var dummycanvas = document.getElementById("dummy");
var dummyctx = dummycanvas.getContext("2d");
dummyctx.strokeStyle = "green";
dummyctx.lineWidth = 4;
dummyctx.beginPath();
dummyctx.arc(22, 22, 20, 0.25*Math.PI, 1.75*Math.PI);
dummyctx.stroke();
var arcMask = new CollisionMask(dummyctx.getImageData(0,0,44,44));
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
ctx.fillStyle = "red";
ctx.fillRect(50,98,100,4);
ctx.fillRect(98,50,4,100);
var crossImg = ctx.getImageData(50,50,100,100);
var crossMask = new CollisionMask(crossImg);
canvas.addEventListener('mousemove', e => {
ctx.clearRect(0,0,200,200);
ctx.putImageData(crossImg, 50, 50);
ctx.drawImage(dummycanvas, e.offsetX, e.offsetY);
//arcMask.collidesWith(crossMask, 50 - e.offsetX, 50 - e.offsetY);
var x = e.offsetX - 50; var y = e.offsetY - 50; // remove surprsingly large amount of noise from timing
var t1 = performance.now() + 50;
var num = 0;
while (performance.now() < t1) {
var collision = crossMask.collidesWith(arcMask, x, y);
++num;
}
document.getElementById("result").innerHTML = collision ? "Collsion!" : "No collision";
document.getElementById("timing").innerHTML = "Performed " + num + " collision tests within 50 milliseconds.";
});
@tfry-git
Copy link
Author

@lorgan3 I see to got close to the bug, meanwhile (only read your edit, after I had found the bug, myself).

rshift doesn't overflow, but surprisingly, JS thinks it reasonable to implement >>> such that for 32 bit-number X, (X >>> 32)==X, rather than (X >>> 32)==0. Whoever thought that up, we simply need to special-case it.

@lorgan3
Copy link

lorgan3 commented Nov 23, 2023

Seems to work indeed, thanks a lot!
I'm messing with an idea with destructible terrain, I can post an update if I end up with something worth sharing.

@lorgan3
Copy link

lorgan3 commented Jun 4, 2024

I do have something worth sharing now so I will 😛
I've used this to make a game with basic physics and fully destructible terrain and this worked quite well!
I've made some additions to easily add and subtract other collisions masks as well as check if a single point collides, you can find the code here https://github.com/lorgan3/sorcerers/blob/main/src/data/collision/collisionMask.ts

@tfry-git
Copy link
Author

Pretty cool work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment