Three.js Sub-tick:漏判 60.7%→0
同一个 Three.js 第一人称小项目,把「每帧只在当前点测一次」换成「拿这一帧的移动线段去测」,10fps 下必中弹的落空率从 60.7% 掉到 0。这篇把思路拆成 3 段源码讲清。
本文素材全部来自本地真实项目 biubiu/kungfu_girl_shooter(卡通武侠 FPS),所有数字都是把项目自带的 three.min.js r159 在 Node 里加载起来、对着真实源码跑出来的,没有一个估值。
一、这套东西到底有多小
先给个体量印象。去掉两个库文件,全部自己写的代码只有 1451 行:
| 文件 | 体积 | 行数 | 职责 |
|---|---|---|---|
| index.html | 8733 B | 217 | HUD、准星、狙击镜、开始/结束界面 |
| js/character.js | 11871 B | 325 | 程序化卡通建模(主角 + 敌人) |
| js/world.js | 6071 B | 183 | 石莲花擂台、红灯笼、远山、云 |
| js/main.js | 27445 B | 726 | 输入、射击、波次、主循环 |
| 自写合计 | 54120 B | 1451 | 零资产、零构建、双击即玩 |
跑一遍真实建模函数,对象数是这样的:
| 模块 | Mesh 数 | 三角面 | 备注 |
|---|---|---|---|
| buildPlayer() | 68 | 6976 | 含灯笼裤、双髻、长辫、绸带、竹节火枪 |
| buildWorld() | 153 | 12452 | 半径 13.5 m 的圆擂台 |
| buildEnemy() | 11 | 1224 | 每只都是从零新建的 11 份几何 |
没有任何一张贴图、没有一个 glTF,three.min.js 668KB 直接随包。这种「零资产 + 全程序化」的写法正是 2026 年浏览器小游戏最常见的形态。
二、60.7% 是怎么来的
先看判定本身的几何。敌人被视为地面上的一个圆点,判定半径 sqrt(0.36) = 0.6 m;子弹速度 24 m/s。
如果按最朴素的写法------每帧推进一次,然后测「当前点离敌人圆心够不够近」------那么两帧之间子弹走过:
text
每帧位移 step = 24 x dt
在 60fps 下 step = 0.4 m,小于判定圆直径 1.2 m,运气好还能命中;到了 20fps,step = 1.2 m,正好等于直径,子弹可以一步跨过整个命中窗口。
我用 3 万次采样做了对照实验:让敌人中心到弹道直线的垂距落在 [0, 0.6) 区间内(也就是理论上必中),发射相位随机,再分别用两种判据统计命中。
| fps | dt | 每帧位移 | 离散(只测当前点) | 扫掠(项目现状) |
|---|---|---|---|---|
| 144 | 0.0069 | 0.1667 m | 漏 0.16% | 0 漏 |
| 60 | 0.0167 | 0.4000 m | 漏 1.70% | 0 漏 |
| 40 | 0.0250 | 0.6000 m | 漏 3.93% | 0 漏 |
| 30 | 0.0333 | 0.8000 m | 漏 8.05% | 0 漏 |
| 20 | 0.0500 | 1.2000 m | 漏 20.87% | 0 漏 |
| 15 | 0.0667 | 1.6000 m | 漏 41.14% | 0 漏 |
| 12 | 0.0833 | 2.0000 m | 漏 52.79% | 0 漏 |
| 10 | 0.1000 | 2.4000 m | 漏 60.72% | 0 漏 |
这就是标题里那个数。同一套代码、同一个必中弹,只因为帧率从 144 掉到 10,就有 60.7% 的子弹从敌人身体里穿了过去。
放到 2026 年看这件事,它其实就是 Valve 在 CS2 里要解决的那类问题。sub-tick 的本质不是「服务器跑更快」,而是别让你的判定结果取决于你把时间切成了多大的块:服务器照旧 64 tick,但玩家的开火带上精确时间戳,命中判定在这个瞬间重建,而不是等下一个 tick 再算。单人游戏没有服务器,解法落在客户端渲染循环上:空间维度做「连续」,也就是下面这段。
三、项目里真正跑的那段代码
js/main.js 第 523 行起,作者写对了关键的一步------他没测「当前点」,而是测「这一帧走过的那条线段」:
js
// 扫掠检测:用本帧移动线段与敌人的最近距离判定,避免高速子弹穿模
const seg = tmpA.subVectors(b.mesh.position, b.prev); // 本帧位移向量
const segLen2 = seg.lengthSq();
for (let j = enemies.length - 1; j >= 0; j--) {
const e = enemies[j];
// 敌人圆心相对「线段起点」的位置
const toE = tmpB.set(e.group.position.x - b.prev.x, 0, e.group.position.z - b.prev.z);
// 把敌人圆心投影到线段上,k 夹到 [0,1]
let k = segLen2 > 1e-6 ? (toE.x * seg.x + toE.z * seg.z) / segLen2 : 0;
k = k < 0 ? 0 : (k > 1 ? 1 : k);
// 线段上离圆心最近的那个点,与圆心的距离
const dx = (b.prev.x + seg.x * k) - e.group.position.x;
const dz = (b.prev.z + seg.z * k) - e.group.position.z;
if (dx * dx + dz * dz < 0.36) { /* 命中 */ }
}
这是一个点到线段的最短距离,本质是连续碰撞检测(CCD)在游戏世界里最常见的用法。它把上一节表里那 60.7% 直接打到 0,代价只是两个减法、一个点积和一次钳制------10 行以内。
值得强调的是它省了什么:不需要缩小步长做子迭代,不需要判断射线和胶囊体求交,不需要把物理引擎搬进来。只要判定只有「点 vs 圆」,一次投影就够。
四、但这套只做了一半
仔细看代码会发现:子弹是连续的,敌人却不是。主循环里子弹的更新排在敌人之前,而判定用的是 e.group.position------敌人自己在这帧还没动就被钉在了帧起点。
误差上限很好算,就是「敌人速度 × dt」:
| fps | dt | 第 1 波 (2.2~3.0 m/s) | 第 10 波 (3.4~4.2) | 第 20 波 (4.6~5.4) | 第 30 波 (5.8~6.6) |
|---|---|---|---|---|---|
| 144 | 0.0069 | 0.016~0.022 m | 0.024~0.029 | 0.032~0.037 | 0.040~0.046 |
| 60 | 0.0167 | 0.039~0.052 m | 0.057~0.070 | 0.077~0.090 | 0.097~0.110 |
| 30 | 0.0333 | 0.077~0.104 m | 0.113~0.140 | 0.153~0.180 | 0.193~0.220 |
| 20 | 0.0500 | 0.116~0.156 m | 0.170~0.210 | 0.230~0.270 | 0.290~0.330 |
| 10 | 0.1000 | 0.232~0.312 m | 0.340~0.420 | 0.460~0.540 | 0.580~0.660 |
判定半径是 0.6 m。第 30 波最快的敌人跑到 6.6 m/s,帧率掉到 10fps 时单帧位移 0.66 m 已经超过判定半径------它可以整个跨过命中窗口而不被抓到。
补齐最后一步的做法是把敌人的位移也算进相对线段:
js
// 记录敌人本帧起点后,用「相对位移」代替「子弹绝对位移」做扫掠
const seg = tmpA.subVectors(b.mesh.position, b.prev);
tmpC.subVectors(e.group.position, ePrev); // 敌人本帧位移
seg.sub(tmpC); // 相对线段 = 子弹位移 - 敌人位移
const rel = tmpB.set(ePrev.x - b.prev.x, 0, ePrev.z - b.prev.z);
let k = seg.lengthSq() > 1e-6 ? rel.dot(seg) / seg.lengthSq() : 0;
k = Math.max(0, Math.min(1, k));
const dx = b.prev.x + seg.x * k - ePrev.x;
const dz = b.prev.z + seg.z * k - ePrev.z;
if (dx * dx + dz * dz < 0.36) { /* 命中 */ }
五、命中盒是一个「无限高圆柱」
判定里只比了 x 和 z,y 完全没参与。这意味着敌人的碰撞体是一个半径 0.6 m、上下无限延伸的圆柱。
而我实测的结果是------这救了整个游戏的命:
text
敌人包围盒(group.y = 0.5):y 0.4500 ~ 1.6947 m
加上跳动 |sin| x 0.14 后的最高点: 1.8347 m
子弹的恒定飞行高度: 1.9207 m
差: 0.086 m
子弹比敌人头顶还高 8.6 厘米。原因在 shoot() 里,dir.y = 0 把俯仰分量整个抹掉了,子弹永远在同一水平面上飞。一旦有人「顺手」给判定加上 y 分量,全部敌人会立刻变成打不中的幽灵。
另外,判定半径 0.6 m 比建模的 0.45 m 胖了 1.333 倍,圆面积大 1.778 倍------手感是偏宽松的。而敌人近身开打的距离是 1.05 m,比判定半径多出 0.45 m 的「贴脸但不中」缝隙。
六、准星、瞄准点、枪口,三个地方各有想法
第一人称下的瞄准链路是这个样子的:
js
const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch);
const dir = tmpV.set(Math.sin(state.yaw) * cp, sp, Math.cos(state.yaw) * cp);
const eyeY = player.position.y + FP_EYE; // 0.5 + 1.74 = 2.24 m
if (dir.y < -0.02) {
const tt = (1.1 - eyeY) / dir.y; // 视线与 y=1.1 平面求交
if (tt > 0.5 && tt < 120) {
aimPoint.set(player.position.x + dir.x * tt, 1.1, player.position.z + dir.z * tt);
} else {
aimPoint.set(player.position.x + dir.x * 60, 1.1, player.position.z + dir.z * 60);
}
} else {
const hh = 30; // 平视/仰视:固定取 30 m
aimPoint.set(player.position.x + Math.sin(state.yaw) * hh, 1.1,
player.position.z + Math.cos(state.yaw) * hh);
}
我把这段源码原样抠出来,在 Node 里遍历了俯仰角的有效区间 -0.55, 0.42:
| pitch | 瞄准点距玩家 | 走的分支 |
|---|---|---|
| -0.55 | 1.859 m | A(斜率求交) |
| -0.30 | 3.685 m | A |
| -0.06(默认) | 18.977 m | A |
| -0.025 | 45.590 m | A |
| -0.02 | 30.000 m | B(固定 30 m) |
| 0 / 0.2 / 0.42 | 30.000 m | B |
俯仰角从 -0.025 挪到 -0.02,也就是约 0.3 度,瞄准距离从 45.59 m 跳到 30 m,一次跳变 34% 。同时那两个守卫 tt > 0.5 和 tt < 120 在 106 个采样点里触发了 0 次 :因为 player.position.y 恒为 0.5、eyeY 恒为 2.24,再叠加俯仰角钳制 -0.55, 0.42,分支 A 里的 tt 永远落在 (2.18, 57.0) 之内,两段兜底都是死代码。
好在这件事真的不影响命中:横向视差最大只有 0.2735 m(19 m 处、俯仰 -0.42),没超过 0.6 m 的判定半径。真正被牺牲的是垂直方向------默认 pitch = -0.06 时:
| 水平距离 | 准星指向的高度 | 子弹实际高度 | 差值 |
|---|---|---|---|
| 4 m | 2.000 m | 1.921 m | 0.079 m |
| 8 m | 1.759 m | 1.921 m | -0.161 m |
| 12 m | 1.519 m | 1.921 m | -0.402 m |
| 16 m | 1.279 m | 1.921 m | -0.642 m |
你往下瞄,准星往地上走,子弹却照直平飞,越远偏差越大。这一段同样是靠「判定不看 y」才没暴露。
七、开镜灵敏度:不降反升
这段是全文最反直觉的一处。
js
const s = MOUSE_SENS * (state.zoomOn ? 0.32 : 1) * (60 / camera.fov * 0.6 + 0.4);
state.yaw -= dx * s;
作者的意图很清楚:开镜时乘 0.32 降灵敏度。但后面那个 FOV 项是随 fov 变小而变大的。开到最低 fov 14 时:
| 状态 | fov | 实际灵敏度 | 相对腰射 |
|---|---|---|---|
| 腰射 | 68 | 2044.71 µrad/px | 1.0000x |
| 开镜到底(意图减速) | 14 | 2091.89 µrad/px | 1.0231x |
| 理论等角速度 | 14 | 420.97 µrad/px | 0.2059x |
开镜之后鼠标反而比腰射还快 2.31%,而且比「放大 4 倍后应有的手感」快了 4.97 倍。 那个 0.32 的减速系数被 FOV 项的 3.2 倍放大几乎完全抵消掉了。
开镜渐变过程中还会经过一段「灵敏度反而最低」的区间:
text
zoom=0.00 fov=68.0 → 654.31 µrad/px
zoom=0.25 fov=54.5 → 746.63 µrad/px
zoom=0.50 fov=41.0 → 899.75 µrad/px
zoom=0.75 fov=27.5 → 1203.20 µrad/px
zoom=1.00 fov=14.0 → 2091.89 µrad/px
正确的做法是丢掉那个经验公式,直接按镜头倍率等比缩放:const s = MOUSE_SENS * (camera.fov / FOV_HIP);。
八、数值设计:围殴是无意义的
先把敌人的节奏列出来(出怪间隔 0.7 s,敌人数量 3 + 2w,单体 HP 3 + floor(w/2)):
| 波次 | 敌数 | 单敌 HP | 需命中弹数 | 敌速区间 | 单击伤害 | 理论最大 DPS | 贴身存活 |
|---|---|---|---|---|---|---|---|
| 1 | 5 | 3 | 15 | 2.32~3.12 | 9 | 16.4 | 6.11 s |
| 5 | 13 | 5 | 65 | 2.80~3.60 | 13 | 23.6 | 4.23 s |
| 10 | 23 | 8 | 184 | 3.40~4.20 | 18 | 32.7 | 3.06 s |
| 15 | 33 | 10 | 330 | 4.00~4.80 | 23 | 41.8 | 2.39 s |
| 20 | 43 | 13 | 559 | 4.60~5.40 | 28 | 50.9 | 1.96 s |
| 30 | 63 | 18 | 1134 | 5.80~6.60 | 38 | 69.1 | 1.45 s |
「理论最大 DPS」这一列藏着一个硬约束:
js
e.hitCd -= dt;
if (dist < 1.05 && e.hitCd <= 0 && state.hurtCd <= 0) {
state.hp -= 8 + state.wave;
state.hurtCd = 0.55; // 全局共享的无敌帧
...
}
e.hitCd 在整个包里从头到尾只有这一个自减、零次赋值 ,所以每个敌人自己的冷却形同虚设;唯一生效的闸门是全局共享的 state.hurtCd = 0.55。结果是:被 1 只敌人围住和被 63 只敌人围住,玩家每秒挨的伤害完全一样 ,都是 (8 + wave) / 0.55。
顺手把速度也算清楚了:
- 敌人最低速度超过开镜移动速度 2.6 m/s → 第 4 波起,开镜必被追上
- 敌人最高速度超过腰射移动速度 5.2 m/s → 第 19 波(5.28 m/s),从此跑是跑不掉的
- 20 波累计需要处理 480 只敌人、打出 4510 发子弹
九、踩坑清单
以下是这一轮实测撞出来的,每一条都能在源码里对上行号:
- 判定只看 xz:等于给所有敌人套了个无限高圆柱。好消息是它掩盖了第五节那个 0.086 m 的高度差;坏消息是一旦补上 y 轴,游戏会当场变成「打不中任何东西」。
- dt 的钳制会让游戏变慢 :
Math.min(clock.getDelta(), 0.1)意味着低于 10fps 之后,每帧最多只推进 0.1 s 的模拟时间。5fps 时时间膨胀系数只有 0.5x,2fps 时 0.2x------掉帧不是卡顿,是直接进入慢动作,敌人也跟着一起慢。 - 射速被帧率量化 :
fireCd = 0.16的实际间隔是ceil(0.16 / dt) x dt。50fps 时恰好 6.25 发/秒(理论值),40fps 时变成 7 帧即 0.175 s,只有 5.714 发/秒,比 60fps 的 6.000 还慢;20fps 以下统一掉到 5.000 发/秒。 - 每颗子弹都在泄漏 :
shoot()里每次都新建SphereGeometry(0.1,8,8)(81 顶点 / 336 索引 / 3264 B)、一个MeshBasicMaterial、一个Sprite加SpriteMaterial,移除时只scene.remove()。全文 0 处dispose()。连续开火 10 分钟 = 3600 发,泄漏 11.4 MB 顶点数据 + 7200 个材质。 - 每只敌人也在泄漏 :
buildEnemy()每次新建 11 份几何(35536 B)和 11 个材质,也没有释放。20 波 480 只累计泄漏 16.27 MB 几何 + 5280 个材质 ,每个MeshToonMaterial还是独立的着色器变体。 - 站在擂台边缘朝外开枪只有 3 m 射程:子弹销毁判据是「到擂台圆心的距离 > ARENA_R + 3」,不是飞行距离。距圆心 13.5 m 时,朝外 3.00 m、沿切线 9.49 m、朝圆心 30.00 m;站在正中心则三向都是 16.50 m。敌人出生在距圆心 15.5~17.5 m,所以贴着边缘往出生圈打是一枪都够不到的。
- 第一人称只藏了头 :
P.head.visible = false确实藏掉了 36 个 Mesh / 4952 面,但躯干、腿、手臂、火枪还有 32 个 Mesh / 2024 面每帧照常提交------占主角的 29.0%,只为占住枪口那一个点。 - 敌人分离是 O(n²):两两比较,第 30 波 63 只 = 1953 对/帧。实测单次开销小到可以忽略,但它是随波次平方增长的,真到大波次就该上网格分桶了。
十、改法清单
按投入产出排了个序:
| 优先级 | 问题 | 改法 | 收益 |
|---|---|---|---|
| 高 | 0 处 dispose | 在 killEnemy / 子弹销毁前遍历释放 |
20 波省下 16 MB+ 显存对象 |
| 高 | 敌人不在 CCD 里 | 用相对位移线段(见第四节代码) | 低帧率下半连续 → 全连续 |
| 中 | 开镜灵敏度反升 | s = MOUSE_SENS * camera.fov / FOV_HIP |
开镜头感立刻对得上倍率 |
| 中 | 站在边缘没射程 | 销毁判据改用「已飞距离」或「出活动区后再给 5 m」 | 边缘不再出现打不到人 |
| 低 | updateAim 分支跳变 | 去掉 hh = 30 硬编码,统一按最大距离 60 m 取 |
俯仰不再有 34% 的瞄准距离跳变 |
| 低 | 全局无敌帧 | 保留全局但补上 e.hitCd = 1.0 |
围殴终于有惩罚,波次压力回归 |
最小的那一条改法长这样:
js
function killEnemy(e, idx) {
burst(/* ...省略 burst 调用... */);
scene.remove(e.group);
// 新增:释放这只敌人独占的 11 份几何与 11 个材质
e.group.traverse(function (o) {
if (o.isMesh) { o.geometry.dispose(); o.material.dispose(); }
});
enemies.splice(idx, 1);
state.score += 10 * state.wave;
sfx('boom');
}
十一、小结
这个项目最值钱的不是「波次生存」这个玩法,而是第 523 行那 10 行代码:它用一次点到线段的投影,把命中判定从依赖帧率里彻底捞了出来。 放在 2026 年看,这跟 Valve 在 CS2 里用时间戳把玩家输入从 tick 边界里捞出来是同一个思路------判定的正确性不该由「你把时间切成了多大的块」来决定。
真要说还差什么,就是第四节那条:只让子弹连续、不让敌人连续,等于做了 sub-tick 的一半。补上相对位移之后才算真正闭环。
下一步想做的三件事:把击中判定换成胶囊体,让判定盒真正贴合模型;给子弹和敌人都上对象池,彻底解决泄漏;把开镜灵敏度曲线重拟成真正的等角速度。
完整工程已经整理好(含一键启动脚本 + 部署文档,双击 index.html 即玩),需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,卡通 FPS 这个系列会继续往下更。