You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在JavaScript中正确处理旋转矩形的碰撞响应?

旋转矩形Canvas碰撞响应问题

我制作了一个包含两个旋转矩形的Canvas演示,已实现旋转矩形间的碰撞检测,但不知如何正确、真实地完成碰撞响应(可以看到rectangle1并非沿碰撞法线滑动,而是类似“瞬移”的效果)。

(移动鼠标可控制rectangle1)


原始代码

HTML

<!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>

CSS

*,
*: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;
}

JavaScript

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(rect1, rect2);
  }

  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);

问题分析

原始的collisionResolution函数逻辑存在两处核心错误:

  1. 用两个矩形中心的连线方向作为分离方向,这并非真正的碰撞法线
  2. 固定使用两个半宽之和作为平移距离,导致平移过度,出现“瞬移”效果

正确的做法是利用分离轴定理(SAT)计算最小平移向量(MTV)——即能让两个物体分开的最短距离和对应法线方向,然后沿该方向平移矩形。


修复后的代码

修改collisionDetection函数使其返回碰撞状态和MTV,同时重写collisionResolution函数:

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

class RectangleToRectangle {
  static collisionDetection(rect1, rect2) {
    const rect1Corners = this.getRectangleCorners(rect1);
    const rect2Corners = this.getRectangleCorners(rect2);
    const axes = this.getAxes(rect1Corners.concat(rect2Corners));
    
    let minOverlap = Infinity;
    let collisionNormal = { x: 0, y: 0 };

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

      if (!this.overlap(projection1, projection2)) {
        return { isColliding: false };
      }

      // 计算当前轴上的重叠量
      const overlap = Math.min(projection1.max, projection2.max) - Math.max(projection1.min, projection2.min);
      // 取绝对值最小的重叠量,对应MTV
      if (Math.abs(overlap) < Math.abs(minOverlap)) {
        minOverlap = overlap;
        collisionNormal = axis;
      }
    }

    // 修正法线方向:确保是从rect2指向rect1的方向
    const rect1Center = { x: rect1.x + rect1.width/2, y: rect1.y + rect1.height/2 };
    const rect2Center = { x: rect2.x + rect2.width/2, y: rect2.y + rect2.height/2 };
    const centerDiff = { x: rect1Center.x - rect2Center.x, y: rect1Center.y - rect2Center.y };
    if (centerDiff.x * collisionNormal.x + centerDiff.y * collisionNormal.y < 0) {
      collisionNormal.x = -collisionNormal.x;
      collisionNormal.y = -collisionNormal.y;
      minOverlap = -minOverlap;
    }

    return {
      isColliding: true,
      mtv: {
        x: collisionNormal.x * minOverlap,
        y: collisionNormal.y * minOverlap
      }
    };
  }

  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 }
    ];

    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 };
      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, mtv) {
    // 沿MTV方向平移rect1,使其与rect2分离
    rect1.x += mtv.x;
    rect1.y += mtv.y;
  }
}

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 collisionInfo = RectangleToRectangle.collisionDetection(rectangle1, rectangle2);
  if (collisionInfo.isColliding) {
    RectangleToRectangle.collisionResolution(rectangle1, collisionInfo.mtv);
  }

  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);

修复说明

  1. MTV计算:在碰撞检测时,遍历所有分离轴,计算每个轴上的重叠量,记录最小的重叠量和对应的法线方向,这就是最小平移向量。
  2. 方向修正:确保MTV的方向是从rect2指向rect1,这样平移rect1时会正确分离两个矩形。
  3. 响应逻辑:直接将rect1沿MTV方向平移,距离就是最小重叠量,这样rect1会沿碰撞法线平滑滑动,不会出现瞬移。

内容的提问来源于stack exchange,提问作者Pete21

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 16:07:03