Search code examples
javascriptcanvashtml5-canvascollision-detectiongame-physics

How to resolve rotated rectangle to rotated rectangle collision in javascript?


I have this canvas demo with two rotated rectangles. I already know how to detect the collision between the two rotated rectangles, but I don't know how to resolve the collision properly or realistically. (You can see how rectangle1 sorta "teleports" instead of sliding along the collision normal.)

(Move your mouse to move rectangle1.)

Here is the code:

function degreesToRadians(degrees) {
  return degrees * Math.PI / 180;
}

class RectangleToRectangle {
  static collisionDetection(rect1, rect2) {
    // Convert rectangle coordinates to corner points
    const rect1Corners = this.getRectangleCorners(rect1);
    const rect2Corners = this.getRectangleCorners(rect2);

    // Get the axes to be tested
    const axes = this.getAxes(rect1Corners.concat(rect2Corners));

    // Test each axis
    for (const axis of axes) {
      const projection1 = this.projectPoints(rect1Corners, axis);
      const projection2 = this.projectPoints(rect2Corners, axis);

      if (!this.overlap(projection1, projection2)) {
        // If the projections do not overlap, the rectangles are not colliding
        return false;
      }
    }

    // All axes overlap, so the rectangles are colliding
    return true;
  }

  static getRectangleCorners(rect) {
    const {
      x,
      y,
      width,
      height,
      rotation
    } = rect;

    const cx = x + width / 2;
    const cy = y + height / 2;

    const corners = [{
        x,
        y
      },
      {
        x: x + width,
        y
      },
      {
        x: x + width,
        y: y + height
      },
      {
        x,
        y: y + height
      }
    ];

    // Rotate the corners around the rectangle center
    const cos = Math.cos(rotation);
    const sin = Math.sin(rotation);

    for (const corner of corners) {
      const dx = corner.x - cx;
      const dy = corner.y - cy;

      corner.x = cx + dx * cos - dy * sin;
      corner.y = cy + dx * sin + dy * cos;
    }

    return corners;
  }

  static getAxes(points) {
    const axes = [];

    for (let i = 0; i < points.length; i++) {
      const p1 = points[i];
      const p2 = points[(i + 1) % points.length];

      const edge = {
        x: p2.x - p1.x,
        y: p2.y - p1.y
      };
      const axis = {
        x: -edge.y,
        y: edge.x
      };

      // Normalize the axis
      const length = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
      axis.x /= length;
      axis.y /= length;

      axes.push(axis);
    }

    return axes;
  }

  static projectPoints(points, axis) {
    let min = Number.MAX_SAFE_INTEGER;
    let max = Number.MIN_SAFE_INTEGER;

    for (const point of points) {
      const dotProduct = point.x * axis.x + point.y * axis.y;
      min = Math.min(min, dotProduct);
      max = Math.max(max, dotProduct);
    }

    return {
      min,
      max
    };
  }

  static overlap(projection1, projection2) {
    return (
      projection1.min <= projection2.max && projection1.max >= projection2.min
    );
  }

  static collisionResolution(rect1, rect2) {
    var mtv = (rect2.width / 2) + (rect1.width / 2);
    var sepA = -Math.atan2((rect2.y + (rect2.height / 2)) - (rect1.y + (rect1.height / 2)), (rect2.x + (rect2.width / 2)) - (rect1.x + (rect1.width / 2)));
    rect1.x += Math.cos(sepA) * mtv;
    rect1.y += Math.sin(sepA) * mtv;
  }
}

var scene = document.getElementById("scene");
var ctx = scene.getContext("2d");

var fps = 60;

var vWidth = window.innerWidth;
var vHeight = window.innerHeight;

var gameLoop;

function resizeCanvas() {
  vWidth = window.innerWidth;
  vHeight = window.innerHeight;
  scene.width = vWidth;
  scene.height = vHeight;
}

resizeCanvas();

var rectangle1 = {
  x: 0,
  y: 0,
  width: 50,
  height: 25,
  rotation: 0
};
var rectangle2 = {
  x: 50,
  y: 50,
  width: 25,
  height: 25,
  rotation: 1
};

function main() {
  rectangle1.rotation += degreesToRadians(1);
  if (RectangleToRectangle.collisionDetection(rectangle1, rectangle2) == true) {
  RectangleToRectangle.collisionResolution(rectangle1, rectangle2);
  }

  ctx.clearRect(0, 0, vWidth, vHeight);
  ctx.fillStyle = "#000000";

  ctx.save();
  ctx.translate(rectangle1.x + rectangle1.width / 2, rectangle1.y + rectangle1.height / 2);
  ctx.rotate(rectangle1.rotation);
  ctx.beginPath();
  ctx.rect(-rectangle1.width / 2, -rectangle1.height / 2, rectangle1.width, rectangle1.height);
  ctx.fill();
  ctx.closePath();
  ctx.restore();

  ctx.save();
  ctx.translate(rectangle2.x + rectangle2.width / 2, rectangle2.y + rectangle2.height / 2);
  ctx.rotate(rectangle2.rotation);
  ctx.beginPath();
  ctx.rect(-rectangle2.width / 2, -rectangle2.height / 2, rectangle2.width, rectangle2.height);
  ctx.fill();
  ctx.closePath();
  ctx.restore();
}

window.onload = function() {
  gameLoop = setInterval(main, 1000 / fps);
}

window.addEventListener("mousemove", (e) => {
  rectangle1.x = e.clientX - rectangle1.width / 2;
  rectangle1.y = e.clientY - rectangle1.height / 2;
});

window.addEventListener("resize", resizeCanvas);
*,
*:before,
*:after {
  font-family: roboto, Arial, Helvetica, sans-serif, system-ui;
  padding: 0px 0px;
  margin: 0px 0px;
  box-sizing: border-box;
}

body {
  overflow: hidden;
}

canvas {
  display: block;
}
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <canvas id="scene"></canvas>
</body>

</html>


Solution

  • The easiest way to do it with your code is just to fix this line:

    var sepA = -Math.atan2((rect2.y + (rect2.height / 2)) - (rect1.y + (rect1.height / 2)), (rect2.x + (rect2.width / 2)) - (rect1.x + (rect1.width / 2)));
    

    to this:

    var sepA = Math.atan2((rect1.y + (rect1.height / 2)) - (rect2.y + (rect2.height / 2)), (rect1.x + (rect1.width / 2)) - (rect2.x + (rect2.width / 2)));
    

    but this will not create "sliding effect". To get more beautiful "collision resolution" I've rewritten your code to get the value how deep one figure inside another:

    function degreesToRadians(degrees) {
      return degrees * Math.PI / 180;
    }
    
    class RectangleToRectangle {
      static collisionDetection(rect1, rect2) {
        // Convert rectangle coordinates to corner points
        const corners1 = this.getRectangleCorners(rect1);
        const corners2 = this.getRectangleCorners(rect2);
        const edges1 = this.edges(corners1);
        const edges2 = this.edges(corners2);
        const angle = Math.atan2(
          rect1.y + rect1.height/2 - rect2.y - rect2.height/2,
          rect1.x + rect1.width/2 - rect2.x - rect2.width/2
        );
        const dist = Math.hypot(rect1.width, rect1.height) + Math.hypot(rect2.width, rect2.height);
        const direction1 = {
          x: Math.cos(angle) * dist, y: Math.sin(angle) * dist
        };
        const direction2 = {x: -direction1.x, y: -direction1.y};
        let maxDist = -1;
        corners1.forEach(p => {
          maxDist = edges2.reduce((acc, edge) => {
            const int = this.rayLineIntersection(p, direction1, edge);
            return Math.max(acc, int);
          }, maxDist);
        });
        corners2.forEach(p => {
          maxDist = edges1.reduce((acc, edge) => {
            const int = this.rayLineIntersection(p, direction2, edge);
            return Math.max(acc, int);
          }, maxDist);
        });
        //console.log(maxDist);
        
        return maxDist;
      }
      
      static edges(poly) {
        return poly.map((p, i, arr) => ({a: p, b: arr[(i + 1) % arr.length]}));
      }
      
      static getRectangleCorners({x, y, width, height, rotation}) {
        const cx = x + width / 2;
        const cy = y + height / 2;
    
        const corners = [
          {x, y},
          {x: x + width, y},
          {x: x + width, y: y + height},
          {x, y: y + height}
        ];
    
        // Rotate the corners around the rectangle center
        const cos = Math.cos(rotation);
        const sin = Math.sin(rotation);
    
        for (const corner of corners) {
          const dx = corner.x - cx;
          const dy = corner.y - cy;
    
          corner.x = cx + dx * cos - dy * sin;
          corner.y = cy + dx * sin + dy * cos;
        }
        return corners;
      }
      
      static rayLineIntersection(p, r, line2){
        return this.lineLineIntersection({a: p, b: {x: p.x + r.x, y: p.y + r.y}}, line2);
      }
      
      static lineLineIntersection(line1, line2){
        const p = line1.a;
        const r = {x: line1.b.x - line1.a.x, y: line1.b.y - line1.a.y};
        const q = line2.a;
        const s = {x: line2.b.x - line2.a.x, y: line2.b.y - line2.a.y};
        const denum = this.cross(r, s);
        if (Math.abs(denum) < 1e-6)
          return -1;
        const d = {x: q.x - p.x, y: q.y - p.y};
        const t = this.cross(d, s) / denum;
        const u = this.cross(d, r) / denum;
        if (t > 0 && u > 0 && t < 1 && u < 1)
          return t;
        return -1;
      }
      
      static cross(vec1, vec2){
        return vec1.x * vec2.y - vec1.y * vec2.x;
      }
    
      static collisionResolution(rect1, rect2, factor) {
        const dist = Math.hypot(rect1.width, rect1.height) + Math.hypot(rect2.width, rect2.height);
        var mtv = dist * factor;
        var sepA = Math.atan2((rect1.y + (rect1.height / 2)) - (rect2.y + (rect2.height / 2)), (rect1.x + (rect1.width / 2)) - (rect2.x + (rect2.width / 2)));
        rect1.x += Math.cos(sepA) * mtv;
        rect1.y += Math.sin(sepA) * mtv;
      }
    }
    
    var scene = document.getElementById("scene");
    var ctx = scene.getContext("2d");
    
    var fps = 60;
    
    var vWidth = window.innerWidth;
    var vHeight = window.innerHeight;
    
    var gameLoop;
    
    function resizeCanvas() {
      vWidth = window.innerWidth;
      vHeight = window.innerHeight;
      scene.width = vWidth;
      scene.height = vHeight;
    }
    
    resizeCanvas();
    
    var rectangle1 = {
      x: 0,
      y: 0,
      width: 50,
      height: 25,
      rotation: 0
    };
    var rectangle2 = {
      x: 50,
      y: 50,
      width: 25,
      height: 25,
      rotation: 1
    };
    
    function main() {
      rectangle1.rotation += degreesToRadians(1);
      const collision = RectangleToRectangle.collisionDetection(rectangle1, rectangle2);
      if (collision > 0) {
        RectangleToRectangle.collisionResolution(rectangle1, rectangle2, collision);
      }
    
      ctx.clearRect(0, 0, vWidth, vHeight);
      ctx.fillStyle = "#000000";
    
      ctx.save();
      ctx.translate(rectangle1.x + rectangle1.width / 2, rectangle1.y + rectangle1.height / 2);
      ctx.rotate(rectangle1.rotation);
      ctx.beginPath();
      ctx.rect(-rectangle1.width / 2, -rectangle1.height / 2, rectangle1.width, rectangle1.height);
      ctx.fill();
      ctx.closePath();
      ctx.restore();
    
      ctx.save();
      ctx.translate(rectangle2.x + rectangle2.width / 2, rectangle2.y + rectangle2.height / 2);
      ctx.rotate(rectangle2.rotation);
      ctx.beginPath();
      ctx.rect(-rectangle2.width / 2, -rectangle2.height / 2, rectangle2.width, rectangle2.height);
      ctx.fill();
      ctx.closePath();
      ctx.restore();
    }
    
    window.onload = function() {
      gameLoop = setInterval(main, 1000 / fps);
    }
    
    window.addEventListener("mousemove", (e) => {
      rectangle1.x = e.clientX - rectangle1.width / 2;
      rectangle1.y = e.clientY - rectangle1.height / 2;
    });
    
    window.addEventListener("resize", resizeCanvas);
    *,
    *:before,
    *:after {
      font-family: roboto, Arial, Helvetica, sans-serif, system-ui;
      padding: 0px 0px;
      margin: 0px 0px;
      box-sizing: border-box;
    }
    
    body {
      overflow: hidden;
    }
    
    canvas {
      display: block;
    }
    <canvas id="scene"></canvas>

    For more beautiful effect look at this codepen. This is code above but with returning rectangle back after fixing.

    Hope it will help!