如何在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函数逻辑存在两处核心错误:
- 用两个矩形中心的连线方向作为分离方向,这并非真正的碰撞法线
- 固定使用两个半宽之和作为平移距离,导致平移过度,出现“瞬移”效果
正确的做法是利用分离轴定理(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);
修复说明
- MTV计算:在碰撞检测时,遍历所有分离轴,计算每个轴上的重叠量,记录最小的重叠量和对应的法线方向,这就是最小平移向量。
- 方向修正:确保MTV的方向是从rect2指向rect1,这样平移rect1时会正确分离两个矩形。
- 响应逻辑:直接将rect1沿MTV方向平移,距离就是最小重叠量,这样rect1会沿碰撞法线平滑滑动,不会出现瞬移。
内容的提问来源于stack exchange,提问作者Pete21
相关产品推荐
相关产品推荐

