Three.js Sub-tick:漏判 60.7%→0

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 这个系列会继续往下更。

相关推荐
码农小河6633 分钟前
基于SSM+JSP的高校专业信息管理系统【附源码+数据库】
java·开发语言·数据库
excel1 小时前
基于 GPT 的自动发布文章接口方案:从一句话或一张图片生成并发布内容
前端·javascript·后端
东鹏特饮3 小时前
系统运维(传统/通用运维)面试题
开发语言·php
今天要早睡_3 小时前
C++01_入门基础复习
开发语言·c++
黑马程序员毕设4 小时前
基于 Java 的餐饮点餐系统设计与应用报告
java·开发语言
1-1=05 小时前
C# 委托与事件
开发语言·c#
Fuyo_11196 小时前
C++ 运行时多态与虚函数机制全面剖析【面试重点】
开发语言·c++·面试
w_zero_one6 小时前
递归算法(一)
java·开发语言·算法
OpsEye6 小时前
大模型 Token 计量不准,是什么原因,怎么解决??
javascript·ai编程