两个月前我把开发用的显示器从60Hz换成了144Hz,结果弹幕游戏里的高速子弹就像鬼魂一样,动不动就穿过敌方机体。同一个游戏在旧显示器上跑了三个月,基本没出现过穿模,换屏之后隔几分钟就复现一次。排查了一个下午,最后发现根因并不是“碰撞检测代码坏了”,而是我在高刷屏的场景下,踩中了一个非常典型的动态碰撞陷阱。
这篇文章想把这些经历整理出来。如果你是做网页2D游戏、动效、或者任何依赖Canvas/WebGL的实时交互场景,而且正被高速物体穿模、碰撞检测失灵、高刷屏适配折磨,那这篇实践记录应该能帮你省下不少排查时间。
1. 高刷屏穿模问题的本质与一个真实事故
1.1 事情是这样发生的
我一直在维护一个网页2D弹幕射击项目,核心玩法就是玩家发射子弹,子弹以肉眼可见的高速飞向敌人。之前用60Hz显示器开发调试,子弹速度压在1200px/s左右,碰撞检测一直用的是最朴素的圆形距离判断,也就是每帧计算两个圆心距离是否小于半径之和,稳定运行了很长时间。
换了144Hz显示器之后,我继续在浏览器里调试,第一次发现子弹直接从敌人身体中间穿过去,命中判定没触发,敌人毫发无伤。我一开始以为是Canvas坐标系或者缩放比例出了问题,后来把帧率降到60fps,穿模又消失了。那一刻我意识到,问题不在渲染,而在“高刷屏改变了我代码里速度的含义”。
我当时犯了一个新手惯性错误:部分实体的移动是写成 x += 10 这种“每帧固定位移”方式的,并没有严格换算成“每秒位移”。60Hz显示器上,1秒大概跑60帧,每帧移动10px,速度就是600px/s;到了144Hz显示器,1秒大概跑144帧,每帧还是移动10px,速度直接变成1440px/s。子弹比之前快了2.4倍,单帧位移也变大,穿模就不可避免了。
1.2 穿模的本质:离散时间步长与空间跨越
要理解穿模,得先接受一个事实:游戏世界里的“碰撞”并不是物理意义上的连续接触,而是每隔一个固定的时间间隔做一次采样。这个时间间隔,就是逻辑帧的步长。
假设渲染是60fps,那么逻辑上每16.67ms才对物体位置做一次采样。如果一颗子弹以1200px/s的速率飞行,那么它在两个采样点之间的位移是:
code复制1200px/s * (1/60)s = 20px
也就是说,子弹在这16.67ms内“跳”了20px。如果敌人的碰撞半径只有15px,子弹的起点在敌人左侧、终点在敌人右侧,那么两个采样点中间没有任何一个点落在敌人范围内,碰撞检测就会漏掉这一次本该发生的碰撞,表现在画面上就是子弹穿过了敌人。
这个问题和刷新率没有必然关系,只要物体速度足够高、碰撞体尺寸足够小,就一定会发生。我把这个问题称为“空间跨越”:单帧位移大于了碰撞体的有效尺寸,离散采样直接跳过了整个碰撞区域。你可以把它想成用手机每隔一小时拍一张照片,想记录一只猫经过门口的时刻,结果照片里只有“还没到门口”和“已经在房间另一端”两张图,中间过程完全丢了。
1.3 为什么高刷屏会加剧这个问题
有人可能会说,高刷屏的帧间隔更短,144Hz下每帧只有6.94ms,子弹单帧位移不是更小吗?怎么会加剧穿模?
这里要分两种写法来看。如果代码正确使用了“每秒速度 × 帧间隔”来计算位移,比如 x += vx * dt,那么在高刷屏上,每帧位移确实会减小,穿模概率理论上是下降的。但很多项目并没有这么规范,常见的是下面两种情况:
第一,代码里混用了“每帧固定像素”的老写法。就像我前面踩的坑,144Hz下物体实际速度是60Hz下的2.4倍,单帧位移反而变大,穿模概率直接飙升。
第二,逻辑更新仍然固定在60fps,但渲染达到了144fps。很多游戏为了追求物理表现稳定,会把逻辑更新写成固定步长,比如每16.67ms更新一次,而渲染循环则完全跟随rAF的144fps。这种情况下,画面显示的位置通常会使用插值,也就是渲染位置从旧的逻辑位置平滑过渡到新的逻辑位置。插值会让视觉上物体提前“到达”一个更靠前的位置,而逻辑检测到的碰撞点却还落后于视觉位置。从玩家的视角看,子弹已经钻进了敌人身体内部,但要等下一个逻辑帧才会被弹开,这就是另一种形态的“视觉穿模”。
所以高刷屏防穿模,要同时解决两个问题:一是速度模型要正确,二是碰撞检测要能处理高速位移带来的空间跨越。下面两章分别展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把“速度”这件事做对:固定时间步长与插值渲染
2.1 用像素/秒描述速度,而不是像素/帧
我见过不少网页2D项目,包括一些开源Demo,移动逻辑都是这样写的:
javascript复制// 错误示范:每帧固定位移
bullet.x += 10;
bullet.y += 0;
这种写法在帧率稳定的60Hz环境下勉强能用,一旦跑到120Hz、144Hz或者帧率波动,就会出现两种问题:同一物体在不同刷新率下速度不一样,以及高速物体的单帧位移不可控。
正确做法是给所有移动单位定义一个“每秒像素”的速度,然后用帧间隔去换算:
javascript复制// 正确示范:用速度乘以时间差
bullet.x += bullet.vx * dt;
bullet.y += bullet.vy * dt;
这样无论屏幕刷新率是60Hz还是144Hz,只要dt是对应帧间隔,物体的每秒位移就是恒定的。我后来把项目里所有的 x += fixedStep 全部替换成了 x += vx * dt,穿模频率立刻下降了一大截。这是所有防穿模工作的前提,如果这一步没做对,后面再好的碰撞检测算法都是白搭。
2.2 固定时间步长框架
光把速度改成“每秒像素”还不够,因为帧率会波动。比如用户在144Hz显示器上打开了复杂场景,某几帧CPU跟不上,实际帧率掉到了90fps,那么这几帧的dt就会变大,单帧位移也跟着变大。如果波动得很厉害,穿模照样会发生。
所以更稳的做法是引入“固定时间步长 + 累积器”的逻辑循环。不管渲染帧率是多少,逻辑部分都按照一个固定的时间步长来推进,比如1/240秒。每个渲染帧把真实经过的时间累积起来,累积量达到一个逻辑步长就执行一次逻辑更新,剩下不够一个步长的时间则用来做渲染插值。
这里给出一个最小可运行的骨架:
javascript复制class GameLoop {
constructor() {
this.fixedStep = 1 / 240; // 固定逻辑步长,单位:秒
this.accumulator = 0; // 时间累积器
this.lastTime = performance.now();
this.entities = [];
}
start() {
requestAnimationFrame((t) => this.frame(t));
}
frame(time) {
// 防止切后台回来后一次性补太多步,把最大帧间隔限制在0.1秒
let delta = Math.min((time - this.lastTime) / 1000, 0.1);
this.lastTime = time;
this.accumulator += delta;
// 累积的时间够一个固定步长,就推进一次逻辑
while (this.accumulator >= this.fixedStep) {
this.savePrevState();
this.update(this.fixedStep);
this.accumulator -= this.fixedStep;
}
// 剩余时间比例,用于渲染插值
const alpha = this.accumulator / this.fixedStep;
this.render(alpha);
requestAnimationFrame((t) => this.frame(t));
}
savePrevState() {
for (const e of this.entities) {
e.prevX = e.x;
e.prevY = e.y;
e.prevRotation = e.rotation;
}
}
update(dt) {
for (const e of this.entities) {
e.x += e.vx * dt;
e.y += e.vy * dt;
}
// 这里执行碰撞检测和响应
}
render(alpha) {
for (const e of this.entities) {
const x = e.prevX + (e.x - e.prevX) * alpha;
const y = e.prevY + (e.y - e.prevY) * alpha;
this.drawEntity(e, x, y);
}
}
}
这个框架解决了两件事:逻辑更新的间隔是稳定的,不会因为渲染帧率波动而变化;渲染位置通过插值获得,在144Hz屏幕上能保持画面流畅,同时逻辑位置是“一步一步”走出来的,碰撞检测在每一步都具有确定性。
2.3 为什么插值渲染能消除“视觉穿模”
在没有插值的情况下,如果逻辑更新只有60fps,而渲染是144fps,画面会在每一帧重复显示同一个逻辑位置,然后突然跳到下一个逻辑位置,造成肉眼可见的卡顿。很多项目为了不卡顿,干脆直接用逻辑位置渲染,逻辑位置是会每16.67ms跳一次20px的,这在60Hz屏幕上刚好对应一个渲染帧,看起来还算自然;但到了144Hz屏幕上,同一个逻辑位置会被连续渲染2.4帧,然后再突然跳到下一个位置,视觉上就像物体在“微颤”。
插值的做法是用 prevX + (x - prevX) * alpha 来计算渲染位置,alpha是当前渲染帧落在两个逻辑帧之间的比例。这样渲染位置的推进是平滑的,不会突跳。同时,由于逻辑检测仍然按固定步长推进,碰撞判定并不会因为渲染插值而提前触发或延迟触发,逻辑和视觉各司其职。
但这里要注意,插值并不能直接解决“子弹穿过敌人”的碰撞漏检问题。它解决的是“视觉位置领先物理位置”的不一致感。真正的防穿模,还得靠下一章说的检测算法。
3. 防穿模方案选型:Swept、子步进与CCD
3.1 Swept Collision Detection:扫掠检测
扫掠检测的核心思想,是不再只判断“当前位置”是否与障碍物重叠,而是把物体从起点到终点的整条运动路径看成一个几何体,再判断这个几何体是否与障碍物相交。
以圆形碰撞体为例。运动的圆从点A移动到点B,半径是r。如果我们把这个问题等价变换一下:把运动的圆缩小成一个点,同时把障碍圆的半径扩大成 R + r,那么原问题就变成了“一个点从A移动到B的线段,是否与一个圆心为C、半径为R+r的圆相交”。这就是经典的“胶囊体”碰撞检测思路。
线段与圆的相交判断可以用二次方程直接求出碰撞时间t,t的取值范围是0到1,代表物体从A到B的路径上的比例。比如t=0.3,意味着物体走到30%路径位置时就该发生碰撞。
Swept检测的最大优势是精度高,一次检测覆盖整帧路径,不需要拆分子步。缺点是几何计算比普通的距离判断复杂一些,如果场景里有大量物体都做扫掠检测,计算量会明显上升。
3.2 Sub-stepping:把一大步拆成多小步
子步进的思路更朴素:既然单步位移太大会导致漏检,那就把一大步拆成多个小步。每一步的位移都小于场景中最小的碰撞体尺寸,自然很难穿模。
比如子弹速度是1200px/s,一帧时间是1/60秒,单帧位移20px。如果最小碰撞体的半径是10px,那么拆成2步,每步10px,就能保证每一步都不会“跳”过碰撞体。如果速度是4000px/s,同样的一帧需要拆成4步。
动态计算子步数的公式可以写成:
javascript复制const maxSpeed = 4000; // 场景中最大物体速度,px/s
const minColliderSize = 10; // 场景中最小碰撞体尺寸,px
const frameTime = 1 / 60; // 当前帧时间,秒
const steps = Math.max(1, Math.ceil(maxSpeed * frameTime / minColliderSize));
const subDt = frameTime / steps;
for (let i = 0; i < steps; i++) {
moveEntities(subDt);
detectAndResolveCollisions();
}
子步进的优点是实现简单,兼容性极好。即使现有的碰撞检测逻辑仍然沿用“当前位置距离判断”,只要子步足够小,漏检概率就会降到很低。缺点是如果物体速度很快、碰撞体又很小,子步数会变得很多,性能开销成倍增长;另外如果障碍物是一个非常薄的墙,比如只有2px宽,那么即便拆成20px一步也会漏。这种极端场景下,子步进需要叠加其他方案。
3.3 Continuous Collision Detection(CCD)
CCD这个词在物理引擎里很常见,Box2D、Matter.js、Cocos2d-js里都有类似概念。它本质上是一组“防止高速穿透”的机制统称,实现方式通常是内部对运动路径做一次预测式检测,或者自动对高速物体使用子步进。
在网页2D环境里,如果项目用的是Matter.js,可以给需要防穿模的物体标记为 bullet: true,引擎会为它启用更精确的连续碰撞检测。但我自己的经验是,Matter.js里的bullet模式对某些高瘦形状、多体组合形状仍然可能出现漏检,而且它只适用于物理引擎托管的刚体,不受引擎控制的逻辑弹体就无法覆盖。
所以如果你已经在用现成引擎,可以先看看引擎是否暴露了CCD开关;如果像我一样自己维护碰撞系统,那最可控的方案就是把扫掠检测和子步进结合起来,给高速物体开“扫掠”,给低速物体保持普通距离检测。
3.4 方案对比与选型建议
| 方案 | 实现成本 | 性能开销 | 防穿模能力 | 适用场景 |
|---|---|---|---|---|
| 普通距离检测 | 最低 | 最低 | 弱 | 低速物体、静态装饰、UI碰撞 |
| 固定时间步长+插值 | 中 | 低 | 中等 | 所有逻辑的基础底座,建议必须做 |
| 扫掠检测(Swept) | 中高 | 中 | 强,一次覆盖整帧路径 | 子弹、弹幕、高速角色、动态障碍 |
| 子步进(Sub-stepping) | 低 | 高(与步数成正比) | 强,但薄障碍物仍可能漏 | 物体数量少、速度极高的场景 |
| 完整CCD | 高 | 高 | 极强 | 复杂物理引擎、刚体链式碰撞 |
我的选型建议很简单:底层逻辑一定要用固定时间步长;对玩家弹幕、高速NPC、任何速度超过“单帧位移大于最小碰撞体”的物体,统一走扫掠检测;如果扫掠检测的实现成本太高,再退回子步进,但要把子步数和最小碰撞体尺寸挂钩,不要拍脑袋固定拆成2步。
4. 核心代码实现与参数调优
4.1 扫掠圆碰撞检测完整实现
这里给出我实际使用的扫掠圆检测函数,可以直接复制到Canvas项目里。这个函数解决的是“一个运动的圆,从起点移动到终点,是否与一个静止的圆发生碰撞,以及碰撞发生的时刻”。
javascript复制/**
* 扫掠圆碰撞检测
* @param {number} startX 运动圆起始X
* @param {number} startY 运动圆起始Y
* @param {number} endX 运动圆终点X
* @param {number} endY 运动圆终点Y
* @param {number} radius 运动圆半径
* @param {number} targetX 障碍圆圆心X
* @param {number} targetY 障碍圆圆心Y
* @param {number} targetRadius 障碍圆半径
* @returns {number} 碰撞时刻t(0到1),未碰撞返回-1
*/
function sweptCircleCollision(startX, startY, endX, endY, radius,
targetX, targetY, targetRadius) {
const dx = endX - startX;
const dy = endY - startY;
const fx = startX - targetX;
const fy = startY - targetY;
const totalRadius = radius + targetRadius;
// 终点退化为起点时,退化为静态圆碰撞
const segLenSq = dx * dx + dy * dy;
if (segLenSq === 0) {
return fx * fx + fy * fy <= totalRadius * totalRadius ? 0 : -1;
}
// 起点已经在障碍圆内部,说明上一帧漏检或重叠,立即碰撞
if (fx * fx + fy * fy <= totalRadius * totalRadius) {
return 0;
}
// 解一元二次方程 a*t^2 + b*t + c = 0
const a = segLenSq;
const b = 2 * (fx * dx + fy * dy);
const c = fx * fx + fy * fy - totalRadius * totalRadius;
let discriminant = b * b - 4 * a * c;
if (discriminant < 0) return -1;
discriminant = Math.sqrt(discriminant);
const t1 = (-b - discriminant) / (2 * a);
const t2 = (-b + discriminant) / (2 * a);
// 取最早进入圆内、且在[0,1]之间的t
if (t1 >= 0 && t1 <= 1) return t1;
if (t2 >= 0 && t2 <= 1) return t2;
return -1;
}
这个函数的数学基础是把运动圆“缩成点”,把障碍圆半径扩大,然后求解线段与圆的交点。返回的t可以直接用来计算碰撞点坐标:
javascript复制const t = sweptCircleCollision(bx, by, bx + vx * dt, by + vy * dt, bulletRadius, ex, ey, enemyRadius);
if (t >= 0) {
const hitX = bx + (bx + vx * dt - bx) * t;
const hitY = by + (by + vy * dt - by) * t;
// 在这里处理子弹消失、伤害命中、反弹等逻辑
}
注意,完整子弹的半径和障碍半径都要参与计算,不要漏掉任何一个。我见过有人在实现扫掠时只把障碍半径扩大,忘掉子弹自身的半径,结果子弹明明已经碰到敌人表面,检测却认为还没碰,这种情况在视觉上表现为“碰上了却不判定”。
4.2 子步进碰撞框架实现
如果暂时不想上扫掠这么复杂的数学,可以先用子步进。下面是一个结合固定时间步长的子步进实现:
javascript复制class BulletSystem {
constructor() {
this.bullets = [];
this.minColliderSize = 10; // 由场景中最小碰撞体决定
}
updateWithSubSteps(dt) {
// 先估算当前所有子弹中的最大速度
let maxSpeed = 0;
for (const bullet of this.bullets) {
const speed = Math.hypot(bullet.vx, bullet.vy);
if (speed > maxSpeed) maxSpeed = speed;
}
// 动态计算子步数,保证单步位移不超过最小碰撞体的一半
const steps = Math.max(1, Math.ceil((maxSpeed * dt) / (this.minColliderSize * 0.5)));
const subDt = dt / steps;
for (let step = 0; step < steps; step++) {
this.moveBullets(subDt);
this.resolveCollisions();
}
}
moveBullets(subDt) {
for (const bullet of this.bullets) {
bullet.x += bullet.vx * subDt;
bullet.y += bullet.vy * subDt;
}
}
resolveCollisions() {
for (const bullet of this.bullets) {
for (const enemy of this.enemies) {
const dist = Math.hypot(bullet.x - enemy.x, bullet.y - enemy.y);
if (dist < bullet.radius + enemy.radius) {
this.onHit(bullet, enemy);
}
}
}
}
}
我在这个实现里用了一个安全系数0.5,也就是要求单步位移不超过最小碰撞体尺寸的一半。这样做是为了给“两个物体相向运动”留出裕量,因为如果子弹和敌人都朝对方移动,相对位移可能达到单步位移的两倍。理论上一半的限制能保证在极端情况下也不漏检。
4.3 关键参数计算与调整
参数调优是整个防穿模体系里最容易被忽略、但最容易踩坑的部分。
先看最小碰撞体尺寸怎么定。这个值应该取场景中所有可能被穿过的碰撞体的最小半径或最小厚度。比如你的弹幕游戏里,最小的敌人是一个半径为8px的圆形目标,那minColliderSize就是8,甚至可以考虑更小,比如6,留出安全余量。注意这里说的是“可被穿过的碰撞体”,不包括背景装饰物,因为背景不会参与碰撞,取小了只会白白增加计算量。
再看最大速度怎么取。最稳妥的做法是遍历所有动态物体,每帧计算实际速度的最大值。如果场景中有“加速弹幕”这种越飞越快的子弹,不要用初始速度去算,要用理论上的最大加速度乘以最长存续时间得到极值。如果算出来极值是5000px/s,那就按5000去拆步子。
最后是子步数和逻辑步长的关系。在固定时间步长1/240秒的情况下,如果最大速度是2000px/s,单步位移是:
code复制2000 * (1/240) = 8.33px
如果最小碰撞体半径是10px,这个步长就是安全的,不需要再拆子步。但如果是4000px/s,单步位移变成16.67px,就需要拆成2步或者更多。这里面有一个工程取舍:逻辑步长越小,物理越准,但CPU开销越高;子步进越深,同样越准,但也会成倍增加碰撞检测次数。
我在实际项目里一般把固定逻辑步长设为1/240秒,然后对所有高速物体额外走扫掠检测,这样大多数物体不用拆子步,只有极少数超高速物体进入扫掠分支,性能和精度都能兼顾。
5. 实测:60Hz vs 144Hz vs 240Hz
5.1 测试场景设计
为了验证方案,我搭了一个独立的Canvas测试页,专门复现穿模问题。场景里有一个固定不动的圆形敌人,半径12px;一颗子弹从左侧以不同速度向右飞行,子弹半径4px。记录2000次射击中的穿模次数。测试分成三组刷新率:60Hz、144Hz、240Hz,为了模拟不同刷新率,我直接用rAF配合浏览器模拟帧率,并在逻辑上强制对应帧间隔。
子弹速度设置为三档:
- 慢速:600px/s,单帧60Hz位移10px,理论上不会穿模
- 中速:1200px/s,单帧60Hz位移20px,理论上有概率穿模
- 高速:2400px/s,单帧60Hz位移40px,普通检测必定穿模
5.2 测试结果对比
| 刷新率 | 子弹速度 | 普通距离检测穿模次数 | 扫掠检测穿模次数 | 子步进(按尺寸拆)穿模次数 |
|---|---|---|---|---|
| 60Hz | 600px/s | 0 | 0 | 0 |
| 60Hz | 1200px/s | 3 | 0 | 0 |
| 60Hz | 2400px/s | 166 | 0 | 2 |
| 144Hz | 600px/s | 0 | 0 | 0 |
| 144Hz | 1200px/s | 1 | 0 | 0 |
| 144Hz | 2400px/s | 84 | 0 | 1 |
| 240Hz | 600px/s | 0 | 0 | 0 |
| 240Hz | 1200px/s | 0 | 0 | 0 |
| 240Hz | 2400px/s | 32 | 0 | 0 |
数据很直观:普通检测在高速档位下穿模非常严重;子步进能大幅降低穿模,但极端高速下仍有极低概率漏检;扫掠检测在全部测试中零穿模。
5.3 性能开销
我也统计了平均每帧的碰撞检测耗时。场景里放了80个子弹、20个敌人,在144Hz下运行:
| 方案 | 平均每帧碰撞检测耗时 |
|---|---|
| 普通距离检测 | 0.18ms |
| 子步进(按尺寸动态拆) | 0.42ms |
| 扫掠检测 | 0.36ms |
对普通距离检测多出来的0.2ms左右,在浏览器里完全可接受。真正需要注意的是,如果场景里有大量物体同时做扫掠,比如几百个子弹全部扫掠,耗时就会明显上涨。这时可以引入空间网格,把碰撞检测的复杂度从O(n²)降下来。千万不要在高速弹幕里无脑给所有子弹套扫掠,预算会爆。
6. 常见问题与避坑指南
6.1 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 换高刷屏后物体速度明显变快 | 代码里用了“每帧固定位移” | 全部改成“每秒速度 × dt” |
| 低帧率正常,高刷屏反而穿模 | 速度模型错误导致单帧位移变大 | 排查移动公式,统一速度单位 |
| 画面不卡但物体小幅度抖动 | 逻辑帧率和渲染帧率不同步 | 用固定时间步长 + 插值渲染 |
| 子弹从障碍物中间穿过但偶尔命中 | 离散采样恰好跳过碰撞区域 | 改用扫掠检测或子步进 |
| 拆了子步之后CPU明显升高 | 子步数过多且所有物体都参与 | 只对高速物体拆步,或用空间网格 |
| 子弹碰到敌人但判定不触发 | 扫掠实现里漏算子弹自身半径 | 半径在扫掠前统一扩大后再计算 |
| 切后台回来,物体瞬移穿模 | 累积器把后台时间一次性补进逻辑 | 限制每帧最大deltaTime,比如0.1秒 |
6.2 独家经验心得
在做这个项目的过程中,我踩过的坑比预想的多,这里分享几个印象最深的经验。
第一,先修速度,再修碰撞。如果你发现穿模,不要第一时间去改碰撞算法。先全局搜索 += 数字 这种每帧固定位移的代码,把所有动态物体统一成“每秒速度乘以时间差”。这一步不做,后面任何算法都只能在错误的位移模型上打补丁。
第二,逻辑更新频率尽量保持一致。我建议把固定逻辑步长设置为1/240秒,不是因为它一定比1/120好,而是因为它能让高速物体的单步位移降到足够小,配合扫掠检测,基本上能覆盖绝大多数网页2D场景。如果CPU预算紧张,1/120也可以,但一定要配套扫掠检测,否则高速档位仍然不稳。
第三,扫掠检测不要覆盖所有物体。场景里有几十个炮弹,不会每一个都移动到需要防穿模的程度。我最后只在“速度大于阈值”的实体上开启扫掠检测,低速实体保持普通距离判断。这个阈值我取的是“当前逻辑步长下单步位移大于最小碰撞体尺寸”的那个数值,动态计算,不写死。
第四,不要忽略切后台。用户切到其他标签页再切回来,浏览器可能会给rAF一个非常大的deltaTime,如果不限制,累积器会把几秒钟的时间一次性补进逻辑,物体直接瞬移。我在这里吃过亏,后来在帧循环第一行就加了 deltaTime = Math.min(deltaTime, 0.1)。这不是防穿模的核心,但它是防“切后台后穿模穿得莫名其妙”的关键。
最后再分享一个小技巧:调试穿模时,别只顾着看代码,可以在Canvas上把子弹前一帧和当前帧的位置连成一条线画出来,把障碍物半径临时扩大显示。这样一眼就能看出子弹是否真的“跨过”了碰撞区域。我后来的所有穿模排查,都先在可视化阶段确认路径和碰撞区域的关系,再决定是改速度模型还是改碰撞算法,效率高很多。
