- Published on
- 约 19686 字
QQ飞车 3D 赛车游戏 · 源码解读
- Authors

- Name
- 小辉辉
第 0 章 导读:这个项目是什么
0.1 项目概览
qq-speed/ 是一个纯 Three.js 实现的 3D 竞速游戏,模仿《QQ飞车》的核心玩法:
项目地址:https://github.com/riba2534/claude-opus-5-5-demo/tree/main/qq-speed
| 能力 | 实现 |
|---|---|
| 4 张地图 | 城市环岛(跨海大桥)、爱琴海(海滨)、法老金字塔(沙漠)、冰雪城堡(雪原) |
| 6 车竞速 | 1 名玩家 + 5 名 AI,三档难度(新手/熟练/车神),带橡皮筋机制 |
| 2 种模式 | 竞速模式(漂移集气→氮气)、道具模式(导弹/香蕉/磁铁/护盾/随机道具箱) |
| 漂移体系 | 漂移集气、小喷、落地喷、完美喷、双喷(W C W 连招) |
| 全程序化 | 零外部资源:没有 .glb 模型、没有图片、没有音频文件——所有车、楼、贴图、音乐全部用代码生成 |
| 部署 | Cloudflare Workers(wrangler deploy),单页面应用 |
技术栈只有两个依赖(package.json):
three ^0.169.0—— 渲染esbuild ^0.28.2—— 打包
0.2 文件清单与依赖分层
qq-speed/
├── src/
│ ├── util.js (69 行) 数学工具 ← 最底层,谁都可以 import
│ ├── track.js (625 行) 赛道几何与"一维化" ← 只依赖 util/textures
│ ├── vehicle.js (369 行) 玩家车物理 ← 只依赖 util
│ ├── main.js (1073 行) Game 主类:状态机、主循环、粘合 ← 依赖上面全部
│ ├── ai.js (138 行) AI 车 ← 依赖 vehicle 的 TUNE
│ ├── items.js (228 行) 道具系统 ← 被 main 调用
│ ├── world.js (278 行) 天空/地面/水/山/合批 ← 只依赖 util
│ ├── textures.js (610 行) Canvas 程序化贴图 ← 只依赖 util
│ ├── effects.js (187 行) 粒子/胎痕 ← 只依赖 util
│ ├── audio.js (297 行) WebAudio 合成音效/BGM ← 零依赖
│ ├── hud.js (290 行) 仪表盘/小地图 ← 零依赖
│ ├── input.js (79 行) 键盘/触摸输入 ← 零依赖
│ ├── carModel.js 车模型程序化构建
│ ├── layouts.js 4 张地图的赛道控制点
│ ├── maps.js 地图主题配置(颜色/雾/道具点位)
│ ├── props.js 路边物件(楼、棕榈树、金字塔、路灯…)
│ └── index.html 唯一的 HTML
└── package.json / build.mjs / wrangler.jsonc
依赖方向图(箭头 = import):
util.js ──→ track.js ──┐
│ ├──→ main.js ──→ ai.js / items.js
├──→ vehicle.js ────┤
├──→ world.js ──────┤ (main 也 import 它们)
├──→ textures.js ──→│
├──→ effects.js ────┤
│ │
audio.js ──→ main.js │ audio / hud / input 互不依赖,只被 main 调用
hud.js ──→ main.js │
input.js ──→ main.js ──┘
关键性质:箭头全部朝一个方向,没有环。track.js 不知道 vehicle.js 的存在,vehicle.js 不知道谁在渲染它。这是单人能把 3000+ 行项目写完并维护住的根基(详见第 8 章)。
0.3 怎么跑起来
npm install
npx wrangler dev # 或 npm run preview —— 本地开发服务器
npx wrangler deploy # 部署到 Cloudflare
操作(input.js:1-2 的注释就是官方说明):
↑↓←→ 驾驶 · Shift 漂移 · Ctrl 氮气/道具 · ↑(出弯点按) 或 W 小喷
Alt 道具换位 · R 复位 · C 视角 · Esc 暂停 · M 音乐
0.4 给 Three.js 新手的三个心智模型
如果你写过前端 DOM,可以这样映射:
① 场景图 ≈ DOM 树。 THREE.Scene 是 <html>,THREE.Group 是 <div>(纯容器),THREE.Mesh 是带样式的元素。parent.add(child) 等于 parent.appendChild(child),子对象继承父对象的位移/旋转/缩放。本项目 main.js:286 建一个 root = new THREE.Group(),然后把地面、赛道、天空、水、山全部 root.add(...) 进去——换地图时 this.scene.remove(this.level) 一行就能整体卸载(main.js:349),像摘掉一个 DOM 子树。
② Mesh = Geometry + Material。 Geometry 回答"顶点在哪"(位置数组 + 每个三角形用哪三个顶点),Material 回答"表面长什么样"(颜色/贴图/粗糙度/是否发光)。对应 CSS 里"结构 + 样式"的分离。本项目最核心的渲染技巧之一(world.js:21 的 Batch)就是把上百个 Mesh 合并成一个大 Geometry——相当于把 100 个 DOM 节点合成一张雪碧图。
③ 游戏循环 ≈ requestAnimationFrame + 状态。 前端的 rAF 通常只做渲染;游戏循环里它还要驱动逻辑:每帧读输入 → 更新物理 → 更新 AI/道具 → 更新相机 → 渲染。本项目的循环在 main.js:468-477,一帧内用累加器把物理切成固定 1/120 秒的小步(第 4 章详解为什么)。
还有一个游戏开发特有的观念:逻辑和表现分离。车的"真实状态"只是几个数字(坐标 x/z、朝向 h、速度 s…),3D 模型只是每帧读取这些数字去摆放自己(vehicle.js:326 的 syncModel)。物理代码完全不碰 Three.js 的 Mesh(除了 syncModel 阶段),所以跑起来又快又稳。
0.5 一局游戏的数据流(总览)
键盘/触摸 (input.js)
↓ 每帧 frame() 返回 {up,down,left,right,shift,...}
main.js step() ── 状态机 'menu'/'countdown'/'race'/'finish'/'paused'
↓ 固定步长 1/120s
PlayerCar.update() ← 车辆物理,产出 events 事件队列
AICar.update() ×5 ← AI 决策 + 同一套简化物理
↓
track.project() ← 世界坐标 → 赛道坐标 (d, lat),查碰撞/进度/腾空
↓
syncModel() ← 把数字状态同步到 3D 模型(纯视觉)
↓
handleEvents() ← 事件 → HUD 提示 / 音效 / 粒子
render(dt) ← 相机、太阳、辉光后期 → WebGL 绘制
下面按依赖顺序逐层拆开。
第 1 章 util.js —— 69 行的地基
文件:src/util.js(全 69 行)。这是整个项目被引用最多的文件,导出 10 个纯函数,全部无副作用。先读懂它们,后面每一章的公式都不再神秘。
1.1 区间与插值:clamp / lerp / damp / smoothstep
export const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); // util.js:1
export const lerp = (a, b, t) => a + (b - a) * t; // util.js:2
export const damp = (a, b, lambda, dt) => lerp(a, b, 1 - Math.exp(-lambda * dt)); // util.js:3
export const smoothstep = (a, b, x) => { // util.js:4-7
const t = clamp((x - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
};
clamp(v,a,b):把 v 夹进 [a,b]。游戏里到处是"速度不能为负""角度不能超过 65°"。lerp(a,b,t):线性插值,t=0 得 a,t=1 得 b。这是游戏开发的第一公式——所有"平滑过渡"的本质。smoothstep(a,b,x):也是插值,但曲线是 S 形(两端斜率为 0)。用于地形过渡:lerp(-6, 0, smoothstep(44, 64, r))(main.js:44,河岸到平地的过渡)在 44 米处开始、64 米处结束,且两端是"缓入缓出"的,不会出现生硬折角。damp(a, b, lambda, dt):帧率无关的指数平滑,本项目最核心的工具(后面出现几十次)。
为什么不用 lerp(a, b, k*dt) 直接插值? 因为帧率会变。60fps 时 dt≈1/60,144fps 时 dt≈1/144,同一个 k 画出来的平滑速度差一倍多。damp 的推导:
设每帧把 a 向 b 移动剩余距离的固定比例,且该比例随 dt 指数衰减,解微分方程 da/dt = -λ(a-b),得通解:
a(t) = b + (a₀ - b)·e^(−λt)
离散化到每帧:a ← b + (a-b)·e^(−λ·dt),整理即 lerp(a,b, 1-e^(−λ·dt))。λ 是"速率"——λ=10 大约意味着 0.1 秒走完 63% 的路程、0.3 秒走完 95%。无论 60fps 还是 144fps,到达时间相同。项目里的用法:转向平滑 damp(this.steer, steerT, 12, dt)(vehicle.js:120)、相机跟随 camPos.lerp(want, 1-Math.exp(-14*dt))(main.js:1019)、车身侧倾 damp(u.root.rotation.z, lean, 8, dt)(vehicle.js:342)。
1.2 角度的回绕:wrapAngle / dampAngle
export function wrapAngle(a) { // util.js:9-13
while (a > Math.PI) a -= Math.PI * 2;
while (a < -Math.PI) a += Math.PI * 2;
return a;
}
export function dampAngle(a, b, lambda, dt) { // util.js:15-17
return a + wrapAngle(b - a) * (1 - Math.exp(-lambda * dt));
}
游戏里朝向用弧度(一圈 = 2π ≈ 6.28)。问题:朝向 179° 和 -179° 其实只差 2°,但数值差 358°——直接 lerp 会让车"绕远路"转一整圈。wrapAngle 把任意角度折回 (-π, π],保证差值永远取最短的那条弧。所以凡是角度插值都要先 wrap 差值,dampAngle 就是干这个的(相机朝向 main.js:1011、AI 车头 ai.js:113)。只要看到角度参与运算,就找 wrapAngle,这是本项目的铁律。
1.3 可复现的随机数:mulberry32
export function mulberry32(seed) { // util.js:19-27
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Math.random() 每次运行结果都不同;做程序化生成(同一张地图每次长得一样、贴图每次画得一样)需要种子随机:给同一个 seed,返回同一个数列。mulberry32 是著名的 32 位 PRNG,只有 5 步位运算,快到可以每帧调用上千次。用法举例:
const rnd = mulberry32(id.length * 997 + 13); // main.js:293 —— 地图 id 决定云/山的排布
const rnd = mulberry32((Date.now() & 0xffff) + 7); // main.js:380 —— 每场比赛 AI 皮/名字不同
前者用固定种子(每次进同一张地图,世界一致);后者用时间种子(每局不同)——两种用法覆盖了游戏里几乎所有"随机"。
1.4 噪声:makeNoise2D
export function makeNoise2D(seed = 1) { // util.js:30-61
const rnd = mulberry32(seed);
const perm = new Uint8Array(512); // 洗牌后的置换表
...
const grad = new Float32Array(256); // 每个格点一个 -1..1 的梯度值
const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10); // util.js:41 五次缓动
function noise(x, y) { // util.js:42-50
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
... // 取周围 4 格的梯度值
return lerp(lerp(a, b, u), lerp(c, d, u), v); // 双线性混合
}
noise.fbm = (x, y, oct = 4) => { // util.js:51-59 分形叠加
let s = 0, amp = 0.5, f = 1;
for (let i = 0; i < oct; i++) {
s += amp * noise(x * f, y * f);
f *= 2.03; // 故意不是 2,避免各倍频在同一坐标对齐产生纹路
amp *= 0.5;
}
return s;
};
return noise;
}
这是 Perlin noise 的简化版(value noise)。直觉:把平面划成 1×1 格子,每个格角随机一个 -1~1 的值,查询任意点时取周围 4 个格角做双线性插值——得到的是连续、无规律的起伏场,而不是每次调用都突变的随机数。fade 用五次曲线缓动插值权重,否则插值出来的面在格线处有折角。
fbm(分形布朗运动)是把噪声叠加 4 个倍频:频率 ×2、振幅 ×0.5。只叠一层像"缓坡",叠四层就有了大起伏 + 小碎波的自然质感。整个项目的地形都靠它:
noise.fbm(x * 0.004, z * 0.004, 4) * 26 // main.js:70 雪原地形:26 米高的丘陵
1.5 formatTime
util.js:63-68,把秒数格式化成 01:23.45。唯一的小心思是 padStart(5, '0') 让 s.toFixed(2) 的 "3.5" 变 "03.50",分钟秒数对齐。
本章小结:damp(指数平滑)+ wrapAngle(最短弧)+ mulberry32(种子随机)+ fbm(分形噪声)——这四个原语分别解决了平滑、角度、随机、地形四大问题,后面所有章节都在复用它们。
第 2 章 track.js —— 把三维赛道"一维化"
文件:src/track.js(625 行)。这是整个项目最核心的架构决策,先讲清楚思想,代码就好读了。
2.1 核心思想:为什么"一维化"
想象一条闭环赛道。车的世界坐标是 (x, z),如果直接在平面直角坐标系里做碰撞(撞墙?)、进度(跑了多少米?超过谁了?)、AI 决策(前面多远有弯?),每件事都要写独立的几何算法,又慢又容易错。
本项目的做法:先把赛道铺成一条一维的"带子",再把所有问题都换到赛道坐标系里解:
- d(distance):沿赛道中心线走了多少米。进度、名次、AI 视距、道具位置全是 d。
- lat(lateral):偏离中心线左右多少米。撞墙就是 |lat| 超过半宽,AI 走线就是控制 lat。
于是:
| 三维世界的问题 | 赛道坐标系里的问题 |
|---|---|
| 车撞墙了吗? | |lat| > 半宽 − 1.1? |
| 谁跑在前面? | 比 d(跨圈用总里程) |
| 前方 100 米有急弯? | curvAhead(d, 100) |
| 加速带压到了吗? | d 落在某区间且 |lat−lat₀| < 半宽 |
| 这段路是桥吗? | 查 bridge[i] |
这个变换有数学保证:赛道是一条不自交的闭曲线,中心线上每个点 (d) 都唯一对应世界一个位置——所以 (d, lat) ↔ (x, z) 在赛道附近是双射。代价是两个换算函数:sample()(d → xyz)和 project()(xyz → d,lat)。写好这两个函数,整栋楼就立起来了。
2.2 构造:从控制点到采样数组
每张地图只存十几个控制点(layouts.js),构造函数把它们变成密采样:
const curve = new THREE.CatmullRomCurve3( // track.js:12-16
layout.points.map((p) => new THREE.Vector3(p[0], p[2] || 0, p[1])),
true, // closed:闭环
'centripetal', // 样条类型
);
curve.arcLengthDivisions = 6000; // track.js:17
curve.updateArcLengths();
const approxLen = curve.getLength();
const N = Math.round(approxLen / 1.5); // track.js:20 约 1.5 米一个采样点
const pts = curve.getSpacedPoints(N).slice(0, N);
给新手的解释:CatmullRomCurve3 是"样条曲线"——你给十几个锚点,它生成一条平滑穿过所有锚点的曲线(类似 CSS 里连接多个点的 path,或贝塞尔曲线的通用版)。'centripetal' 参数化能让锚点疏密不均时曲线也不过冲。getSpacedPoints(N) 返回曲线上等弧长的 N+1 个点——注意不是"等参数",等参数在急弯处会挤成一团。最终一条约 3000 米的赛道变成 N ≈ 2000 个点,相邻两点间距恒为 1.5 米,这个数字后面到处用。
SoA(结构体数组)布局——把一个"赛道点"拆进多列平行数组:
this.px = new Float32Array(N); // 每个采样点的 x
this.py = new Float32Array(N); // y(高度!赛道本身有起伏)
this.pz = new Float32Array(N); // z
this.hd = new Float32Array(N); // heading 朝向
this.tx = new Float32Array(N); // 切线向量 x 分量
this.tz = new Float32Array(N); // 切线向量 z 分量
this.rx = new Float32Array(N); // 右法线 x 分量
this.rz = new Float32Array(N); // 右法线 z 分量
this.curv = new Float32Array(N); // 曲率
this.bank = new Float32Array(N); // 路面倾角
this.slope = new Float32Array(N);// 坡度
(track.js:23-25, 39-46)新手可能想写成 points = [{x, y, z, heading, ...}, ...]——数组套对象。Float32Array 快得多:连续内存、数字类型固定、无属性查找。游戏里每帧对 6 辆车 × 多次采样做查询,用 SoA 是标准做法。注意 slice(0, N) 丢掉重复的尾点(闭环时首尾点重合),所以索引永远 i % N 回绕。
每帧预算观念:这些数组只在加载地图时算一次(625 行里构造函数占了 100 行),之后运行期全是只读查询——"一次昂贵、终身免费",这是程序化生成能跑满帧的根本策略。
2.3 派生量:朝向、切线、坡度
for (let i = 0; i < N; i++) {
const a = (i - 1 + N) % N, b = (i + 1) % N; // track.js:48
const h = Math.atan2(this.px[b] - this.px[a], this.pz[b] - this.pz[a]); // :49
this.hd[i] = h;
this.tx[i] = Math.sin(h); this.tz[i] = Math.cos(h); // :51-52 切线
this.rx[i] = -Math.cos(h); this.rz[i] = Math.sin(h); // :53-54 右法线
this.slope[i] = (this.py[b] - this.py[a]) / (2 * this.ds); // :55 坡度
}
朝向用前后邻居连线(中心差分)而不是相邻两点,抗锯齿噪声。atan2(dx, dz) 注意参数顺序——本项目约定"朝向 0 = 朝 +z,90° = 朝 +x",之后所有 Math.sin(h) 是 x 分量、Math.cos(h) 是 z 分量(vehicle.js:228-230 的位移积分用的同一约定)。
切线 (tx,tz) 是前进方向单位向量;右法线 (rx,rz) 是它转 90°。验证:h=0 时切线 (0,1)、右法线 (-1,0)——朝 +z 走时右手边是 −x。lat 的正负号由此定义:lat = 位置相对中心线在右法线上的投影,lat>0 在赛道右侧。
2.4 曲率 → 倾角 → 竖直曲率(三层平滑)
曲率 = 单位长度的朝向变化量(弧度/米):
const raw = new Float32Array(N);
for (let i = 0; i < N; i++) raw[i] = wrapAngle(this.hd[(i+1) % N] - this.hd[i]) / this.ds; // track.js:57-58
const W = 7;
for (let i = 0; i < N; i++) {
let s = 0;
for (let k = -W; k <= W; k++) s += raw[(i + k + N) % N];
this.curv[i] = s / (2 * W + 1); // track.js:59-64 ±7 点滑动平均
}
直道 curv≈0;半径 50 米的圆 curv=1/50=0.02。wrapAngle 再次登场:359°→1° 的朝向跳变不能算成 -358°。然后做 ±7 点滑动平均(box blur)——因为控制点样条在锚点处会留下曲率毛刺,平滑后 AI 决策和倾角生成才稳定。
倾角 bank(弯道外侧抬高):
const bk = new Float32Array(N);
for (let i = 0; i < N; i++) bk[i] = clamp(this.curv[i] * 5.5, -0.13, 0.13); // track.js:67
for (let i = 0; i < N; i++) {
let s = 0;
for (let k = -12; k <= 12; k++) s += bk[(i + k + N) % N];
this.bank[i] = s / 25; // track.js:68-72 再平滑一次(±12 点)
}
bank 是路面的横向倾角(弧度),curv*5.5 让越急的弯倾斜越多,clamp(±0.13)(约 7.5°)封顶防止路肩穿地。注意平滑了两层且窗口不同:曲率 ±7、倾角 ±12——倾角直接影响路面网格的 y 坐标,必须比曲率更"绵",否则桥面会出现波浪。这是"视觉量要比逻辑量更平滑"的通用经验。
左转 curv>0、左低右高 → 车向弯心"沉"。路面某点的实际高度:y = py + lat·sin(bank)(track.js:205 的 surfY 同式)。
竖直曲率 vcurv(跳台检测的关键):
// 竖直曲率(平滑后):v²·κ > g 时车辆会腾空(跳台) track.js:73
const sy = new Float32Array(N);
for (let i = 0; i < N; i++) { let a = 0; for (let k = -3; k <= 3; k++) a += this.py[(i+k+N)%N]; sy[i] = a / 7; }
this.vcurv = new Float32Array(N);
this.sslope = new Float32Array(N);
const K = 4;
for (let i = 0; i < N; i++) {
const s0 = (sy[i] - sy[(i - K + N) % N]) / (K * this.ds); // 后 4 点坡度
const s1 = (sy[(i + K) % N] - sy[i]) / (K * this.ds); // 前 4 点坡度
this.vcurv[i] = (s1 - s0) / (K * this.ds); // 坡度的变化率 = 竖直曲率
this.sslope[i] = (sy[(i+1) % N] - sy[(i-1) % N]) / (2 * this.ds); // 平滑坡度
}
vcurv = 纵剖面(y-d 曲线)的二阶差分。物理含义(第 3 章会用到):车以速度 v 过凸坡,需要的向心加速度是 v²·κ;超过重力 g 时,路面"追不上"车 → 车腾空。所以 v²·κ > g 就是飞跳条件,赛道设计师在 layouts.js 里放的凸点会自动变成跳台。
2.5 空间哈希:把 O(N) 查询变 O(1)
this.cell = 16; // track.js:92
this.grid = new Map();
for (let i = 0; i < N; i++) {
const k = this._key(Math.floor(this.px[i] / this.cell), Math.floor(this.pz[i] / this.cell));
let arr = this.grid.get(k);
if (!arr) this.grid.set(k, (arr = []));
arr.push(i);
}
_key(cx, cz) { return cx * 73856093 ^ cz * 19349663; } // track.js:110
把世界划成 16×16 米格子,每格记录落在其中的采样点索引。查询"离 (x,z) 最近的赛道点"只需扫周围几格(nearest(),track.js:113-128),不用遍历 2000 个点。哈希函数 cx*73856093 ^ cz*19349663 是经典的空间格子哈希(两个大质数乘+异或,把二维格子坐标揉成一个 int)。这个结构服务于非赛道物体:地形压平、路边物件摆放避让(props.js 的 isFree)、埃及绿洲定位(main.js:296)。
2.6 sample():d → 世界坐标(O(1))
sample(d, out = this._tmp) { // track.js:148
const L = this.length;
d = ((d % L) + L) % L; // 任意 d 回绕到 [0, L)
const f = d / this.ds;
const i = Math.floor(f) % this.N;
const j = (i + 1) % this.N;
const t = f - Math.floor(f);
out.x = this.px[i] + (this.px[j] - this.px[i]) * t; // 位置线性插值
...
const h = this.hd[i] + wrapAngle(this.hd[j] - this.hd[i]) * t; // track.js:161 朝向必须 wrap!
out.hd = h;
out.tx = Math.sin(h); out.tz = Math.cos(h);
out.rx = -out.tz; out.rz = out.tx; // 派生量从插值后的 h 重算
out.bank = this.bank[i] + (this.bank[j] - this.bank[i]) * t;
return out;
}
任意里程 d:先 ((d % L) + L) % L 三明治回绕(JS 的 % 对负数返回负值,所以要先 +L 再 %),再夹在相邻两个采样点间 lerp。唯一陷阱在 161 行:两个朝向 179° 和 -179° 插值必须先 wrapAngle 差值,否则在赛道"最北端"车头会瞬间反向。out = this._tmp 复用一个对象避免每帧产生 GC 垃圾——调用方必须立即消费结果,这个约定贯穿全项目。
2.7 project():世界坐标 → (d, lat)(本项目最精巧的函数)
project(x, y, z, hint, out) { // track.js:173
const N = this.N;
let bi = 0, best = Infinity;
if (hint < 0) { // 全局搜索(初始定位)
for (let i = 0; i < N; i++) {
const dx = this.px[i] - x, dz = this.pz[i] - z, dy = (this.py[i] - y) * 3; // :178
const d2 = dx * dx + dz * dz + dy * dy;
if (d2 < best) { best = d2; bi = i; }
}
} else { // 局部搜索(帧间跟踪)
for (let k = -30; k <= 30; k++) {
const i = (hint + k + N) % N;
const dx = this.px[i] - x, dz = this.pz[i] - z;
const d2 = dx * dx + dz * dz;
if (d2 < best) { best = d2; bi = i; }
}
}
// 在 bi 前后两段上精确投影 track.js:190-202
let i = bi, j = (i + 1) % N;
let ex = this.px[j] - this.px[i], ez = this.pz[j] - this.pz[i];
let t = ((x - this.px[i]) * ex + (z - this.pz[i]) * ez) / (ex * ex + ez * ez);
if (t < 0) { /* 换到前一段重新投影 */ }
t = clamp(t, 0, 1);
const s = this.sample((i + t) * this.ds, out);
s.lat = (x - s.x) * s.rx + (z - s.z) * s.rz; // track.js:204
s.surfY = s.y + s.lat * Math.sin(s.bank); // track.js:205
return s;
}
三段式,逐段加速:
- 粗定位。
hint是车上一帧的采样索引——车一帧最多移动 ~1 米、采样间距 1.5 米,所以只需在 hint±30(±45 米)里找最近点,61 次循环。复位/初始化时 hint=-1 才走全局 2000 次循环。这是"时间连贯性"(temporal coherence)优化的教科书案例:帧间状态不会突变,就用上帧结果缩小搜索域。 - 精投影(190-202):最近采样点只是 1.5 米网格上的近似。把车位置向采样点连线段做点积投影:
t = P−A · B−A / |B−A|²(把车投影到线段 AB 上求参数 t∈[0,1]),得到亚采样点精度。t < 0说明投影落在线段外,换前一段重算。 - 换算 lat(204):
lat = (P − S)·右法线。surfY(205)顺带算出车底路面的准确高度(含倾角),供贴地/腾空判断。
178 行的 dy*3 值得注意:全局搜索时把高度差放大 3 倍再参与距离——为了立体交叉路段(城市图的跨海大桥)能正确区分"桥上"和"桥下"的车,平面近但高度远的候选点被惩罚。一个乘法解决上下层归属,很划算。
2.8 curvAhead():给 AI 的"前视雷达"
curvAhead(d, range) { // track.js:212-222
const i0 = Math.floor(...);
const steps = Math.max(1, Math.floor(range / this.ds));
let m = 0;
for (let k = 0; k < steps; k += 2) { // 隔点采样省一半
const c = this.curv[(i0 + k) % N];
if (Math.abs(c) > Math.abs(m)) m = c; // 绝对值最大的(带符号)
}
return m;
}
返回前方 range 米内绝对值最大的曲率(保留符号)。AI 刹车距离长,需要看 140 米;转方向盘只需要看 8 米——同一个函数,两种调用(ai.js:59, 75)。
2.9 build():把数组变成三角形网格
构造函数算完"数据赛道",build()(track.js:225-440)把它变成 Three.js 能画的 Mesh。核心是缎带(ribbon)网格:
// 路面:每行 2 个顶点(左边缘、右边缘),行间距 = 采样间距 1.5 米
for (let r = 0; r < rows; r++) {
const i = r % N;
const sb = Math.sin(this.bank[i]);
for (let s = 0; s < 2; s++) {
const lat = s ? hw : -hw; // 半宽 hw,左右边缘
const o = (r * 2 + s) * 3;
pos[o] = this.px[i] + this.rx[i] * lat; // 中心点 + 法线 × lat
pos[o + 1] = this.py[i] + lat * sb; // 倾角抬边 track.js:240
pos[o + 2] = this.pz[i] + this.rz[i] * lat;
uv[(r*2+s)*2] = s; // u: 0→1 横跨路宽
uv[(r*2+s)*2 + 1] = (r * this.ds) / TEX_LEN; // v: 每 24 米重复贴图 track.js:243
}
}
for (let r = 0; r < N; r++) {
const a = r*2, b = a+1, c = a+2, d = a+3;
idx.push(a, b, c, b, d, c); // 两三角形 = 一格路面
}
新手拆解:Three.js 画的一切都是三角形。要铺一条 3000 米的路面,就生成 N 行 × 2 列顶点,每相邻两行连出 2 个三角形(索引数组 idx 每组 6 个数字 = 2 个三角形,每个 3 个顶点索引)。2000 行 = 4000 个三角形,一次 drawcall 画完整条路。
UV 坐标是贴图的"归一化寻址":u∈[0,1] 从路左缘到右缘(贴图横着贴一次),v = 里程/24——TEX_LEN=24(track.js:5)表示贴图沿路方向每 24 米重复一次。这就是"贴图跟着赛道走"的秘密:不旋转贴图,而是让几何本身自带沿路坐标。护栏同理,WALL_TEX_LEN=16(track.js:6)。
路面高度那行(240)就是倾角的落地:同一横截面上左缘 y = py − hw·sin(bank)、右缘 y = py + hw·sin(bank)——一条斜的路面。物理与渲染共用同一公式(track.js:205 的 surfY),所以车永远不会悬空或穿地。
之后的构建块(每个都值得按行号读一遍,思路同上):
- 护栏 + 路基(
track.js:268-342):内侧面从路缘往上 1.25 米(wallH),外挂 0.6 米厚、往下延伸 1.6 米的"裙板"——桥段从远处看是实心的桥体。材质side: THREE.DoubleSide(333 行附近)因为从内外两个方向都要可见。 - 弯心路肩(红白相间的 curb,
track.js:344-398):只在弯心侧生成。判定inner = this.curv[i] * -side > 0.011(374)——曲率超过阈值的路段才铺,直道不铺。连续段run.length < 8太短就丢弃(354),避免碎片。材质加polygonOffset: -2(389-391)抗 z-fighting:路肩几何贴着路面(只抬高 0.035 米),两共面三角形深度抖动会闪烁,polygonOffset 把它在深度上整体推近一点。 - 起跑线(400-405)与加速带(407-421):都走
roadDecal()(443-469)——把一个四边形沿赛道细分后"贴"在路面上(抬高 0.05 米 + polygonOffset)。加速带用MeshBasicMaterial且toneMapped: false、color: new THREE.Color(1.8,1.8,1.8)(413-416):不受光照、不受色调映射、颜色超 1 → 自发光霓虹感,配合后期 Bloom 泛光。运行时main.js:558还会滚动它的贴图 offset 做出"箭头流动"动画。 - 隧道(
buildTunnel,471-529):沿赛道扫出半圆柱(lat = -cos(a)*hw,a从 0 到 π 扫过拱顶),内面发光、外面土色,两端加 TorusGeometry 门框。 - 桥墩(
buildPillars,579-618):每 12 米一根。判断"这段是桥吗"用bridge数组(构造时由cfg.isBridge(x,z,y)回调标记,track.js:89-90);桥下地面高度到桥面不足 1.2 米的不放墩;同层重叠段(Math.min(di, N-di)>40 && py[j]<y-3,590-593)跳过避免立体交叉处穿模。最终用 InstancedMesh(一个 BoxGeometry + 每墩一个变换矩阵)画所有墩——比逐个 Mesh 省几十倍 drawcall。
本章小结:Track 类 = 一次性的几何预处理机。输入 20 个控制点,输出(a)一套平行数组供物理/AI O(1) 查询,(b)一套 BufferGeometry 供渲染。它既不懂车也不懂渲染管线——这就是"层"的含义。
第 3 章 vehicle.js —— 物理与"手感"
文件:src/vehicle.js(369 行)。玩家车的全部状态机与物理。注意它 import 了什么:
import { clamp, wrapAngle, damp } from './util.js'; // vehicle.js:1
只有 util!不知道 Three.js 的 Mesh 长什么样(模型只是挂在 this.model 上被 syncModel 摆弄),不知道键盘、不知道分数。369 行里 320 行是纯数学。先看调参表:
3.1 TUNE:所有手感的旋钮集中在一处
export const TUNE = { // vehicle.js:4-24
vmax: 55, // ~198 km/h 基础极速(米/秒;×3.6 = km/h)
vmaxNitro: 76, // ~274 km/h 氮气极速
accel: 24, // 基础加速度 m/s²
nitroAccel: 40, // 氮气加速度
brake: 45, // 刹车减速度
reverseMax: 12, // 倒车极速
roll: 2.2, // 松油门滑行减速度
turnRate: 1.9, // 最大转向角速度(弧度/秒 ≈ 109°/s)
grip: 12, // 抓地(车身回正速率 λ)
driftMinSpeed: 13, // 低于此速无法起漂
driftYaw: 1.5, // 漂移中的额外角速度
driftYawAlign: 0.85, // 反打方向修正量
driftGrip: 2.0, // 漂移中车身回正 λ(松→5.2,抓地 12)
maxDriftAngle: 1.15, // 最大漂移角(约 66°)
smallWindow: 0.5, // 出弯小喷窗口 0.5 秒
gaugeRate: 0.36, // 集气速率
nitroTime: 2.8, // 一次氮气 2.8 秒
gravity: 30, // 重力(游戏常用的"放大重力",30 而不是 9.8)
};
把所有魔数收进一个对象并导出(AI 也 import 它,ai.js 开头),调手感只改这一处。为什么 gravity 是 30 不是 9.8? 真实重力下 1 秒才掉 4.9 米,游戏里显得"飘"。街机赛车惯用 2~3 倍重力让跳跃干脆利落。这行注释就是"游戏感优先于物理正确"的注脚。
3.2 双角度模型:h(车头朝向)与 m(速度方向)
这是本章最重要的概念。真实世界里,车头朝向 ≠ 速度方向:冰面上猛打方向,车头转过去了、速度还沿老方向走——这就是漂移。本项目用两个角度显式建模:
h(heading):车头指向,由方向盘直接驱动。m(motion):速度矢量方向,被grip拉着追 h。
抓地时 m 紧追 h(λ=12,0.1 秒追平);漂移时故意把 λ 降到 2.0,m 追不上 h,h − m 就是漂移角。看普通转弯的代码:
} else { // vehicle.js:214-222 非漂移
const turn = TUNE.turnRate * clamp(sp / 9, 0, 1) // 低速转不动(避免原地掉头)
* (1 - 0.55 * clamp(sp / TUNE.vmax, 0, 1.3)); // 高速衰减 55%
this.h += this.steer * turn * dt * dir; // 方向盘 → 车头
if (this.s >= 0) this.m += wrapAngle(this.h - this.m) * (1 - Math.exp(-TUNE.grip * dt)); // :220
else this.m = this.h; // 倒车时速度方向 = 车头
}
turn 公式的三个因子各司其职:clamp(sp/9,0,1) 让起步时方向是"渐进生效"的(防止 0 速原地旋转);(1-0.55·sp/vmax) 让高速转向变钝(不然 200km/h 还能满舵就假了)。220 行又是 damp 的手写展开——m 指数逼近 h,λ=grip=12。
速度矢量的合成(积分,vehicle.js:226-231):
const dirSign = this.s >= 0 ? 1 : -1;
const mv = this.s >= 0 ? this.m : this.h; // 前进沿 m,倒车沿 h
let vx = Math.sin(mv) * Math.abs(this.s) * dirSign;
let vz = Math.cos(mv) * Math.abs(this.s) * dirSign;
this.x += vx * dt;
this.z += vz * dt;
s 是标量速度(带符号:正=前进),把方向角 mv 转成单位向量 (sin, cos)——注意与 track.js:51-54 同一套约定。为什么倒车用 h? 倒车是"推着车尾走",速度沿车头反方向,此时不存在漂移。这些小分支就是"手感"的砖石。
3.3 纵向:油门曲线与坡度
if (inp.up) {
...
else if (this.s < vmax) this.s += acc * (1 - Math.pow(this.s / vmax, 2) * 0.85) * dt; // vehicle.js:158
}
...
} else { // 松油门
const dec = (TUNE.roll + Math.abs(this.s) * 0.06) * dt; // :163 滑行阻力随速增大
this.s = Math.abs(this.s) <= dec ? 0 : this.s - Math.sign(this.s) * dec;
}
if (this.s > vmax) this.s -= (this.s - vmax) * 1.1 * dt; // :166 超速回落
this.s -= tr.slope[this.hint] * Math.cos(this.h - tr.hd[this.hint]) * 6 * dt; // :168 坡度
158 行是渐减加速曲线:速度越接近 vmax,有效加速度越小(×0.85 保底不归零)——接近极速时"推背感"消失,符合真实体验也防止数值过冲。163 行滑行阻力 = 基础滚阻 + 空气阻(近似 ∝ v)。166 行把超速部分以 1.1/s 的速率拉回——被氮气/加速带顶到 86 的速度会缓慢回落而非瞬间归位,这是"出弯残留速度"手感的来源。
168 行坡度最妙:tr.slope[hint] 是赛道坡度(第 2 章),cos(h − hd) 是车头与赛道方向的夹角余弦——上坡正对才吃满减速,横着漂过坡几乎不受影响。系数 6 把坡度放大成明显的速度变化(游戏感again)。
3.4 漂移状态机:起漂 → 维持 → 出弯
QQ飞车的灵魂。状态迁移:
正常 ──Shift+方向+速度>13──→ 漂移中 ──松Shift且回正/掉速──→ 正常
│
└─出弯(clean)→ smallWindow=0.5s(小喷窗口)
起漂(vehicle.js:172-180):
if (!this.drifting && inp.shift && steerT !== 0 && this.s > TUNE.driftMinSpeed && onGround && !spinning) {
this.drifting = true;
this.driftDir = steerT; // 记住漂移方向(之后方向键含义改变!)
this.driftTime = 0;
this.h += this.driftDir * 0.07; // :178 起漂瞬间车头猛甩一下
this.emit('driftStart', {});
}
178 行那个 0.07 弧度的"甩头"是起漂的手感点——视觉上车身瞬间切入姿态。
漂移核心(182-213,逐行拆):
const align = steerT * this.driftDir; // :184 方向键与漂移方向同向=+1,反打=-1
const held = inp.shift;
if (held) this.releaseTime = 0;
else this.releaseTime += dt;
const yaw = held
? TUNE.driftYaw + TUNE.driftYawAlign * align // 按住 Shift
: (0.45 + 0.9 * align) * Math.exp(-2.2 * this.releaseTime); // 松开后衰减
this.h += this.driftDir * yaw * clamp(this.s / 26, 0.35, 1) * dt * (onGround ? 1 : 0.4); // :188
- 按住 Shift:车头以
1.5 + 0.85·align弧度/秒持续转。同向打方向转得更狠,反打拉回——这就是漂移中方向键的两义性(同键既是"加深漂移"也是"反打救车")。 - 松开 Shift:转向量
e^(−2.2·t)指数衰减,0.5 秒后基本归零——漂移自然收束而不是瞬间锁死。 clamp(s/26, 0.35, 1):速度低时漂不动;腾空时 ×0.4。
const k = held ? TUNE.driftGrip : TUNE.driftGripRelease; // :189 松开后回正加速 5.2
this.m += wrapAngle(this.h - this.m) * (1 - Math.exp(-k * dt)); // :190
let a = wrapAngle(this.h - this.m);
if (Math.abs(a) > TUNE.maxDriftAngle) { // :191-195
this.m = this.h - Math.sign(a) * TUNE.maxDriftAngle; // 漂移角封顶 66°
a = Math.sign(a) * TUNE.maxDriftAngle;
}
this.driftAngle = a;
const sa = Math.abs(Math.sin(a));
if (onGround) this.s -= (2.5 + 11 * sa) * dt; // :198 漂移掉速
漂移中 grip 从 12 降到 2.0(松开瞬间跳 5.2)——m 追 h 的速度骤降,漂移角 a = h−m 张开;封顶 ±1.15 弧度防止"转圈烧胎"。198 行:漂移损失速度 ∝ sin(漂移角)——漂得越浪掉速越多,甩尾过弯不是免费的,这是玩家要权衡的核心代价。
集气与结束:
if (!itemMode && onGround) { // :199-209
this.gauge += dt * TUNE.gaugeRate * (0.3 + sa * 1.7) * clamp(this.s / 35, 0.3, 1.1);
while (this.gauge >= 1) {
if (this.nitroCount < 2) { this.nitroCount++; this.gauge -= 1; this.emit('gaugeFull', {}); }
else { this.gauge = 1; break; } // 满两罐后集气值封顶囤着
}
}
this.driftTime += dt;
if ((!held && (Math.abs(a) < 0.1 || (align < 0 && Math.abs(a) < 0.26))) || this.s < 7)
this.endDrift(this.s >= 7); // :213
集气速率 = 时间 × (0.3 + 1.7·漂移烈度) × 速度系数——大角度高速长漂才充得快,浅漂几乎白漂。集满 1 格 → nitroCount++(最多囤 2 罐,多出来的集气值停在 1 等待释放槽位)。结束条件:松开 Shift 且车尾基本回正(a<0.1)或反打明显收束(align<0 且 a<0.26),或速度掉到 7 以下(速度太低漂移作废,不给小喷——endDrift(this.s >= 7) 的 clean 参数)。
endDrift(clean = true) { // vehicle.js:98-106
if (!this.drifting) return;
this.drifting = false;
if (clean && this.driftTime > 0.22) { // :101 漂移短于 0.22s 不算
this.smallWindow = TUNE.smallWindow; // 0.5s 小喷窗口开启!
this.windowKind = 'drift';
}
this.emit('driftEnd', { time: this.driftTime });
}
3.5 小喷 / 落地喷 / 双喷:窗口判定
出漂/落地不直接给速度,而是开一扇时间窗,玩家在窗内点↑/W 才兑现:
if (this.smallWindow > 0) { // vehicle.js:135-149
this.smallWindow -= dt;
if (inp.upPressed || inp.wPressed) { // 注意是"按下沿"不是"按住"
const elapsed = (this.windowKind === 'drift' ? TUNE.smallWindow : 0.45) - this.smallWindow;
const perfect = elapsed < 0.2; // :139 出弯 0.2s 内点 = 完美喷
const dbl = this.nitroTime > 0; // :140 氮气中还接小喷 = 双喷素材
this.smallBoost = perfect ? 1.0 : 0.75;
this.smallBoostPower = (perfect ? 11 : 8) + (dbl ? 5 : 0); // :142
this.s += perfect ? 5.5 : 4;
if (dbl) this.nitroTime += 0.3;
this.emit(this.windowKind === 'land' ? 'landBoost' : 'smallBoost', { perfect, double: dbl });
if (dbl) this.emit('double', {});
this.smallWindow = 0;
}
}
三种结果:普通小喷(+8 极限速 0.75s)、完美喷(出弯 0.2 秒内点,+11 且瞬时 +5.5 m/s)、双喷(氮气未结束时接小喷:小喷更强 + 氮气延长 0.3s + 专属 'double' 事件给特效音效)。输入判断用 upPressed/wPressed(按下沿,input.js 每帧清一次)——按住不放只能吃到一次,必须主动再点,这就是"点按"技巧的判定基础。
落地喷的窗口在落地时开(vehicle.js:285-288):
this.emit('land', { air: this.airTime, v: hard });
if (this.airTime > 0.3) { // 腾空超过 0.3s 落地才有
this.smallWindow = 0.45;
this.windowKind = 'land';
}
3.6 加速叠加栈:所有加速汇成一张表
每帧把互不冲突的加速源叠起来(vehicle.js:123-132):
let vmax = TUNE.vmax, acc = TUNE.accel;
if (this.nitroTime > 0) { vmax = TUNE.vmaxNitro; acc = TUNE.nitroAccel; this.nitroTime -= dt; } // 氮气:换档
if (this.smallBoost > 0) { vmax += this.smallBoostPower; acc += 14; this.smallBoost -= dt; } // 小喷:加成
if (this.padTime > 0) { vmax += 12; acc += 20; this.padTime -= dt; } // 加速带
if (this.startBoost > 0) { vmax += 10; acc += 26; this.startBoost -= dt; } // 完美起步
if (this.magnet > 0) { vmax += 10; acc += 12; this.magnet -= dt; } // 磁铁
vmax = Math.min(vmax, 86); // :129 物理上限封顶
if (this.slowTime > 0) { vmax *= 0.55; this.slowTime -= dt; } // 香蕉:减速 debuff
注意设计差异:氮气是"换档"(vmax 整个换成 76),小喷/加速带是"加成"(在当前 vmax 上加)——所以氮气中接小喷可以摸到 86 上限。Math.min(vmax, 86) 是全局护栏,保证任何叠加都出不了物理失控区。boosting getter(vehicle.js:83)汇总所有状态给相机/音效/火焰判断。
triggerNitro(85-96)是氮气按钮的逻辑:nitroTime = Math.min(4.5, max(0,nitroTime) + 2.8)——连喷叠加上限 4.5 秒;s = max(s,20)+4 保证低速也能被氮气"拉起来";wasSmall 判定(松小喷瞬间接氮气)再发一次 'double' 事件。
3.7 撞墙:位置修正 + 速度反射 + 车头摆正
const lim = tr.halfW - 1.1; // vehicle.js:238 离墙留 1.1m 车身余量
this.impact = 0;
if (Math.abs(p.lat) > lim) {
const sg = Math.sign(p.lat);
const nx = p.rx * sg, nz = p.rz * sg; // 墙的法线(指向路内)
const over = Math.abs(p.lat) - lim;
this.x -= nx * over; // :244-245 位置修正:直接推回墙内
this.z -= nz * over;
const vn = vx * nx + vz * nz; // 速度在法线上的分量(>0 = 往墙里撞)
if (vn > 0) {
const sp = Math.max(1, Math.hypot(vx, vz));
const impact = vn / sp; // 法向分量占速度的比例 ∈ (0,1]
vx -= nx * vn * 1.25; // :250-251 反弹 1.25 倍法向速度
vz -= nz * vn * 1.25;
const keep = 1 - 0.45 * impact; // :252-254 正撞失去 45%,蹭墙几乎不掉速
vx *= keep; vz *= keep;
const ns = Math.hypot(vx, vz);
if (this.s >= 0) {
this.s = ns;
if (ns > 0.5) this.m = Math.atan2(vx, vz); // :257 速度方向改为反弹后方向
// 车头顺着墙摆正 :259-261
const th = Math.abs(wrapAngle(this.h - p.hd)) < Math.PI / 2 ? p.hd : p.hd + Math.PI;
this.h += wrapAngle(th - this.h) * Math.min(1, 0.6 * impact + 0.05);
} else this.s = -ns;
this.impact = impact * sp;
if (impact > 0.3 && this.drifting) this.endDrift(false); // :263 重撞终止漂移(无小喷)
if (impact * sp > 6) this.emit('crash', {...}); // :265
else if (sp > 8) this.emit('scrape', {...});
}
p.lat = sg * lim;
}
四步经典流程:
- 位置修正(推回墙内)——先修位置再修速度,防止连续多帧嵌墙越陷越深。
- 速度反射:
vn = v·n是点积(速度在墙法线上的投影)。反射公式v ← v − n·vn·(1+e),e=0.25 即反弹 1.25 倍(不完全弹性)。 - 切向惩罚 ∝ 撞击角:
keep = 1 − 0.45·impact。impact=法向速度/总速度:垂直撞墙 impact≈1(速度掉 45%),贴墙蹭 impact≈0.1(掉 4.5%)。一个系数同时实现了"撞墙重罚、蹭墙轻罚"。 - 车头摆正(259-261):把 h 拉向赛道方向 hd(或其反方向,看车头原本朝哪边),力度 ∝ impact——撞完墙车自动顺墙滑行,不会横在路中间。
endDrift(false) 里的 clean=false 很关键:撞墙终止的漂移不给小喷窗口——失误不受奖励。
3.8 垂直方向:贴地、腾空、落地
const groundY = p.y + p.lat * Math.sin(p.bank); // vehicle.js:273 车底路面高度(含倾角)
if (this.airborne) {
this.vy -= T.gravity * dt; // 简单重力积分
this.y += this.vy * dt;
this.airTime += dt;
if (this.y <= groundY) { // 落地判定
...
this.emit('land', { air: this.airTime, v: hard });
if (this.airTime > 0.3) { this.smallWindow = 0.45; this.windowKind = 'land'; } // :285-288
}
} else {
const va = this.s * Math.cos(this.m - p.hd); // :293 沿赛道方向的速度分量
if (va > 18 && va * va * -tr.vcurv[p.i] > T.gravity) { // :294 腾空条件!
this.airborne = true;
this.vy = tr.sslope[p.i] * va; // :297 起跳初速 = 坡度×前进速度
this.y = groundY + this.vy * dt;
if (this.drifting) this.endDrift(false);
} else {
this.y = groundY; // 贴地
}
}
腾空条件的推导(呼应 2.4 节):车过凸坡顶时做圆周运动,需要的向心加速度 a = v²·κ(κ=vcurv,凸坡为负所以 -vcurv 为正)。这个需求只能由"重力压着车"提供;当 v²κ > g,重力不够 → 路面开始"离开"车 → 腾空。va > 18 加一道最低速度门槛(慢速过坡不跳)。起跳初速 vy = 坡度 × 前进速度——把斜面上的速度矢量分解出竖直分量,物理正确且免费。
腾空中的转向/漂移衰减 ×0.4/×0.35(188、219 行)——空中操作近似无效,落地喷才是跳台的回报。
3.9 其他细节:逆行、加速带、起步
- 逆行检测(305-308):
cos(h − hd) < −0.35 且 s > 4持续计时,HUD 会弹"逆行"警告。 - 加速带(311-322):纯 d/lat 区间判断(第 2 章的承诺兑现)——
dd = this.d − (bp.d − bp.len/2)回绕后< bp.len且|lat − bp.lat| < halfW+0.6。padTime=1.1是持续加速;if (this.padTime < 0.6)保证一次踩带只发一次 'pad' 事件(0.6~1.1 之间重复踩不重复给瞬时速度)。 - 起步喷射:倒计时结束前 0.28 秒(
cdT > 2.72,main.js:493)按住油门 →startBoost。applyStartBoost(main.js:577-583)给startBoost=1.4, s=max(s,16)。
3.10 syncModel:数字 → 3D 姿态(唯一的渲染代码)
syncModel(dt) { // vehicle.js:326-351
const mdl = this.model;
mdl.position.set(this.x, this.y, this.z);
const rel = this.h - tr.hd[i]; // 车头相对赛道夹角
const slopeAlong = tr.slope[i] * Math.cos(rel); // 坡度在车头方向的分量
const bankAlong = tr.bank[i] * Math.cos(rel);
const pitchT = this.airborne ? clamp(-this.vy * 0.012, -0.3, 0.3) : -Math.atan(slopeAlong);
this.pitchVis = damp(this.pitchVis, pitchT, 10, dt); // 视觉俯仰也做平滑!
this.rollVis = damp(this.rollVis, -bankAlong, 10, dt);
mdl.rotation.set(this.pitchVis, this.h, this.rollVis, 'YXZ'); // :338
...
u.root.rotation.z = damp(u.root.rotation.z, lean, 8, dt); // 车身侧倾(子节点)
u.root.rotation.x = damp(u.root.rotation.x, pitchDyn - (nitro ? 0.02 : 0), 6, dt); // 加减速点头
for (const w of u.wheels) {
w.spin.rotation.x += (this.s * dt) / 0.47; // :346 轮子滚动(0.47=轮半径)
if (w.front) w.steer.rotation.y = this.drifting ? -this.driftDir * 0.3 : this.steer * 0.38;
}
updateFlames(u, this.nitroTime > 0, ...);
}
三个要点:
rotation.set(pitch, yaw, roll, 'YXZ'):Three.js 的欧拉角默认按 XYZ 顺序应用。赛车必须先偏航(y,车头朝向)再俯仰/侧倾——顺序错了,朝北时的俯仰会变成侧倾。'YXZ' 是载具的标配顺序。新手常见 bug 就在这里。- 视觉量全部再过一层 damp(pitchVis/rollVis λ=10):物理值是瞬间跳变的(撞墙 h 立刻变),视觉上平滑 100ms 才自然——"逻辑可以硬,眼睛要顺滑"。
- 姿态分两层:外层 Mesh 管物理姿态(位置+朝向+坡度俯仰),内层
u.root(车壳子节点)管动态姿态(转弯侧倾、油门点头、氮气下压)。两层独立阻尼,撞墙时车身"晃"而朝向不乱。
updateFlames(354-366)是尾焰动画:sin(t*60 + position.x*10) + random() 高频抖动 + 氮气蓝/小喷橙两种颜色 multiplyScalar(2.2) 超亮(配合 Bloom)。
本章小结:PlayerCar 是一台纯函数状态机——输入 (dt, input) + 当前状态 → 新状态 + 事件列表(this.events)。渲染、音效、HUD 全部由 main.js 消费事件驱动。369 行里只有 25 行碰了 this.model,且全部集中在 syncModel。这就是"物理层"的完整形态。
第 4 章 main.js 的 step() —— 粘合层
文件:src/main.js(1073 行)。Game 类是唯一的"总指挥",本章聚焦它最核心的 90 行:主循环 loop()(468-477)和物理调度 step()(479-567)。其余(菜单、选图、结算)是常规 UI 代码,读起来没有门槛。
4.1 状态机:游戏的一切状态只是一个字符串
this.state = 'menu'; // main.js:106
// 取值:'menu' | 'countdown' | 'race' | 'finish' | 'paused'
所有分支都问这一个字符串(step() 开头 const st = this.state)。暂停时 state='paused' 且记住 pausedFrom 以便恢复(togglePause,main.js:221-233)。对比新手常见的做法(一堆 boolean:isRacing/isPaused/isMenu…组合爆炸),单字符串状态机非法状态不可表示,这是最省心的写法。
4.2 主循环与固定步长积分器
loop() { // main.js:468-477
requestAnimationFrame(() => this.loop()); // 自我排程,60/120/144fps 由显示器决定
const now = performance.now();
const dt = Math.min(0.05, (now - this.last) / 1000); // :471 帧间隔,上限 50ms
this.last = now;
this.time += dt;
const inp = this.input.frame(); // 本帧输入快照
if (this.state !== 'paused') this.step(dt, inp);
this.render(dt);
}
471 行的 Math.min(0.05, …) 是防切页炸弹:切走标签页 10 秒再回来,rAF 暂停后恢复的第一帧 dt=10——若不钳制,物理会把车瞬移 550 米穿墙。钳到 50ms 相当于"最多快进 5 帧物理"。
物理为什么不能直接用 dt?因为 vehicle.js 的数值积分(速度×dt 累加进位置)对步长敏感:60fps 和 144fps 跑出的轨迹会不一样,while (gauge>=1)、碰撞修正这类逻辑在超大步长下还会穿透。解法是经典的累加器模式:
// main.js:527-536(step() 内)
const DT = 1 / 120; // 物理步长固定 1/120 秒(main.js:37)
this.acc += dt;
let first = true;
const noEdge = { ...pin, upPressed: false, wPressed: false, nitroPressed: false };
while (this.acc >= DT) {
this.acc -= DT;
if (P) P.update(DT, first ? pin : noEdge, active, this.itemMode);
first = false;
for (const r of this.racers) if (!r.isPlayer) r.update(DT, aiActive, this.raceTime, this.rubber(r));
this.collide(); // 车与车的碰撞也按物理步算
}
每帧把真实 dt 存进累加器 acc,凑够一个 1/120 秒就跑一轮完整物理(玩家 + 5 AI + 碰撞)。144Hz 屏幕多数帧跑 0~1 轮,60Hz 屏幕每帧跑 2 轮——物理速率与显示帧率解耦,任何设备上手感一致。
noEdge 是本章最精妙的一行。"按下沿"类输入(upPressed:本帧刚按下)只在物理第一子步有效——否则一帧内跑 2 个子步,小喷窗口会被同一个按键触发两次。把边缘输入在后续子步强制置 false,而"按住"类(up/left/shift)保持原样。这就是事件语义在固定步长下的正确处理,很多业余赛车游戏的小喷"偶尔双倍触发"就是栽在这里。
4.3 step() 的完整骨架
step(dt, inp)
├─ ① countdown 阶段(482-502):3-2-1 倒计时动画;cdT>2.72 检测起步喷射时机;
│ cdT>=3 → state='race'(起跑门 gate.set(0) 打开)
├─ ② 玩家输入组装(511-523):菜单里代跑用 autopilot、触屏自动油门、
│ Ctrl=道具、Alt=换位、R=复位(resetPlayer)
├─ ③ active 判定(524-525):玩家物理只在 'race'/'finish' 跑;AI 额外在 'menu' 也跑(演示模式)
├─ ④ 固定步长物理(527-536)↑
├─ ⑤ syncModel + updateProgress(537-540):视觉同步、计圈
├─ ⑥ standings 排序(541-546):名次
├─ ⑦ 道具系统 update + aiThink(547-550)
├─ ⑧ handleEvents(551):消费玩家车事件队列
├─ ⑨ emitEffects(552):烟雾/火花/胎痕
├─ ⑩ 杂项:加速带贴图滚动(558)、HUD(560-561)、引擎音(563-566)
4.4 事件队列:物理层与表现层的单向管道
vehicle.js 里你看到的所有 this.emit('driftStart', {}) 推进了 this.events 数组。谁消费?handleEvents(main.js:793-852):
handleEvents() {
const P = this.player;
if (!P || !P.events.length) return;
for (const ev of P.events.splice(0)) { // splice(0):取出并清空
const d = ev.data || {};
switch (ev.type) {
case 'driftStart': /* 音效+开始胎痕 */ break;
case 'gaugeFull': this.hud.message('集气完成!'); this.audio.play('gauge'); break;
case 'smallBoost': P.stats.small++; ... break;
case 'double': P.stats.double++; this.hud.message('双喷!'); this.audio.play('double'); break;
case 'crash': this.shake = Math.min(1, this.shake + d.power * 0.05); ... break;
...
}
}
}
为什么这么设计? 物理一帧可能跑 2 个子步、每步多个事件;如果物理直接调 hud.message(),1) 物理层就依赖了 HUD(破坏分层),2) 暂停/重放/倍速时表现层的时序没法控制。事件队列把"发生了什么"和"如何呈现"解耦——物理只负责陈述事实。游戏引擎里这就是 ECS 的 Observer 模式缩影。消费端处理完 splice(0) 清空,队列每帧归零。
4.5 计圈与名次:wraparound 难题
进度追踪(updateProgress,main.js:604-626)要解决一个纯一维问题:d 是回绕的。起点线在 d=0,车从 d=2995 跨到 d=5——是前进了一圈还是倒退了一圈?
// 玩家:用"欠账"标记法(main.js:608-617)
const L = this.track.length;
let dist = r.d;
if (r.owed) { // 还没过第一圈起点
if (r.half && dist > L * 0.6 && r.lastD < L * 0.4) { // 跨过 0 线(2995→5 的特征)
dist -= L; r.owed = false; // 补上 -L:真实进度是负的(起点后退车)
}
else if (dist < L * 0.5) r.half = true; // 记录"到过后半圈"
} else if (r.lastD > L * 0.6 && dist < L * 0.4) { // 正常跨线
dist += r.lapsDone * L + L; ... // 总里程 = 圈数×L + d
}
r.lastD = r.d;
玩家从起点线后方发车(负里程起步,makeRacers 里 d = -14 - floor(k/2)*11,main.js:390),所以需要 owed/half 两个标记把"发车时往后退着跨过起点线"的情形折算成负进度。dist 一旦折算成连续总里程(不打回绕),一切比较都变成普通数字比较:
// AI 更简单(621-624):直接 floor
r.totalD = r.d + r.lapsDone * L;
// 名次排序(541-546):按 totalD 降序排,完赛者按 finishTime
this.standings = [...this.racers].sort((a, b) =>
b.finished !== a.finished ? (a.finished ? -1 : 1) : (b.finished ? b.finishTime - a.finishTime : 0) || b.totalD - a.totalD);
4.6 橡皮筋:让比赛永远胶着
rubber(r) { // main.js:569-575
const P = this.player;
const gap = (r.totalD || 0) - (P ? P.totalD : 0);
return gap > 0
? Math.max(diff.rubber[0], 1 - gap * 0.00045) // AI 领先越多,被压越狠
: Math.min(diff.rubber[1], 1 + (-gap) * 0.0004); // AI 落后越多,buff 越强
}
rubber 是乘在 AI 极速上的系数(ai.js:53 的 vmaxBase × rubber)。玩家落后 100 米 → AI 极速 ×0.955(不知不觉等你);玩家领先 100 米 → AI ×1.04(悄悄追上来)。三档难度只是数值不同(DIFFS,main.js:25-29:新手 0.881.03、熟练 0.931.06、车神 0.97~1.10)。系数 0.00045/米意味着 300 米差距约 ±13%——够胶着又察觉不到。单人游戏 AI 的"公平"是设计出来的,不是算出来的。
4.7 车与车碰撞:为什么放在物理步内
collide()(main.js:702-730):
const R = 3.1; // :704 两车碰撞半径之和
for (let i = 0; i < this.racers.length; i++)
for (let j = i + 1; j < this.racers.length; j++) {
const a = this.racers[i], b = this.racers[j];
if (Math.abs(a.y - b.y) > 2.5) continue; // :710 立体交叉上下层不撞
const dx = b.x - a.x, dz = b.z - a.z;
const d2 = dx * dx + dz * dz;
if (d2 > R * R || d2 < 1e-6) continue;
const d = Math.sqrt(d2);
// 推开 + 交换动量
...
const imp = -rel * 0.5; // :720 相对速度的 50% 转为冲量
...
}
6 车 15 对两两检测(无 broadphase,15 对 × 240 次/秒 = 3600 次距离平方比较,毫无压力)。这个函数被放在固定步长循环里(535 行 this.collide())——碰撞和物理同步积分,否则 120Hz 物理 + 60Hz 碰撞会让"推开"的速度修正隔帧生效,表现为贴着抖动。推开后的速度以赛道坐标系分解回各车(pushRacer,738-743:r.lat += px·s.rx + pz·s.rz; r.pushD += px·s.tx + pz·s.tz)——AI 的位置状态就是 (dist, lat),直接加在这两个量上才不会和它的世界坐标脱节。
4.8 相机:第三人称跟随的全部技巧
updateCamera(main.js:978-1039):
const MODES = [
{ dist: 8.2, h: 3.0, look: 5, lookH: 1.3 }, // :1003 近景
{ dist: 12.5, h: 4.6, look: 6, lookH: 1.5 }, // 远景
{ dist: 5.2, h: 1.9, look: 8, lookH: 1.1 }, // 车头视角
];
const yawT = P.s >= 0 ? P.m + wrapAngle(P.h - P.m) * 0.35 : P.h; // :1010
this.camYaw = dampAngle(this.camYaw, yawT, P.drifting ? 4.5 : 7, dt); // :1011
1010 行是相机对准哪里的学问:不是车头 h,也不全是速度方向 m,而是 m + (h−m)×0.35——车头与速度方向之间取 35% 偏向车头。漂移时相机主要跟速度方向(看到的是"甩过去的路径"),但略带车头指向(保留姿态感)。λ 也切换:漂移 4.5(松)、抓地 7(紧)——漂移时相机"甩"得开,视觉冲击强。
this.camPos.lerp(want, 1 - Math.exp(-14 * dt)); // :1019 位置强跟随 λ=14
朝向跟随 λ=7 但位置跟随 λ=14——位置贴得紧、朝向有惰性,这是第三人称赛车相机的黄金配比,反了就会晕。
速度反馈三件套(1025-1036):撞击 shake(衰减 dt*1.8,按平方施加随机偏移)、FOV 拉伸 fovT = 66 + |s|·0.12 + nitro?9 : 0 + small?4 : 0(速度感不是加动效,而是拉广角——生理级错觉)、render() 里还有速度线(main.js:1056)。
4.9 render():帧内最后的渲染编排
render(dt) { // main.js:1041-1062
if (this.skyMesh) this.skyMesh.position.copy(this.camera.position); // :1045
// 太阳阴影相机对齐到纹素网格,消除阴影边缘闪烁 :1049-1052
const texel = 150 / this.sun.shadow.mapSize.x;
sun.position.set(...Math.round(v / texel) * texel...);
this.hud.speedLines(k, dt); // :1056 速度线(高速时)
if (this.bloom) this.bloom.strength = lerp(0.55, 1.05, ...nitro/speed); // :1059
this.composer ? this.composer.render() : this.renderer.render(this.scene, this.camera);
}
- 天空球钉在相机上(1045):天空球半径 4000,相机移动时它跟着——永远走不到"天边"。
- 纹素对齐(1049-1052):DirectionalLight 的阴影是正交相机,若其位置在纹素网格上不对齐,光源每帧微动会导致阴边缘"沸腾"。把太阳位置量化到
150/mapSize的网格上是标准修复。 - Bloom 强度动态(1059):平时 0.55,氮气中拉到 1.05——泛光本身就是速度反馈的一部分。
本章小结:step() 只做四件事——分步调度(固定步长)、状态路由(字符串状态机)、结果消费(事件队列)、表现编排(相机/HUD/音效的参数喂给)。它不包含任何一条物理公式,也不画任何像素——它是层与层之间的"总装车间"。读懂本章,你就知道往这个项目里加一个新玩法该动哪里:改 vehicle 的状态机和事件,加一个 handleEvents 分支,完事。
第 5 章 玩法层:ai.js 与 items.js
这两章是"游戏规则"所在的层。它们读下层(track/vehicle)的数据结构,被上层(main)调度,彼此互不认识。
5.1 ai.js —— 一台 PD 控制器 + 一张弯速表
文件:src/ai.js(138 行)。先纠正一个预期:AI 不是和玩家跑同一套物理。它复用 TUNE 参数和 (dist, lat) 坐标系,但更新逻辑是自己的(AICar.update(dt, active, raceTime, rubber))——没有漂移集气、没有小喷、没有撞墙反弹,取而代之是三个决策:目标速度、目标走线、何时用氮气。AI 漂移是纯视觉的。这是刻意的工程取舍:AI 的任务是"可信的对手",不是"第二套物理模拟"。
目标速度:弯速表
const cNow = this.track.curvAhead(this.dist, 8); // ai.js:59 眼前 8 米曲率
const look = 18 + this.s * 1.25; // :60 视距随速度拉长
const cAhead = this.track.curvAhead(this.dist, look);
const A = 16 + this.skill; // 等等,实际是 16 + 30*skill
const vCorner = Math.sqrt(A / Math.max(Math.abs(cAhead), 1e-4)); // :64 弯速公式!
let vt = Math.min(vmaxBase, vCorner); // 目标速度 = min(极速, 弯速)
(原文 A = 16 + 30 * skill,ai.js:63。)弯速公式就是第 2/3 章推导的向心加速度公式反解:
向心加速度 a = v²·κ ≤ A(轮胎抓地上限)
⇒ v ≤ √(A/κ)
κ 是前方 look 米内最大曲率。A 是"AI 的抓地等级":新手 16+30×0.55≈32.5,车神 16+30×1.38≈57.4——同一个弯(κ=0.02,半径 50 米),新手过弯 40 m/s、车神 53 m/s。难度不是"AI 更快",而是"AI 敢更快地过弯",其余速度都由同一张表统一约束,观感自然。
look = 18 + s·1.25(60 行)是刹车距离的廉价近似:速度越快看得越远,保证进弯前已经减速。要减速时:
if (this.s > vt + 1) this.s -= (16 + this.skill * 8) * dt; // 刹车
else this.s += (nitro ? 34 : 20 + 4*skill) * (1 - Math.pow(this.s / Math.max(vt,1), 2) * 0.7) * dt; // :69 加速曲线同款
走线:PD 控制器(这一节值得慢慢读)
AI 不操作"方向盘",直接控制横向位置 lat 逼近目标线:
const hw = this.track.halfW - 2.2; // ai.js:81 安全半宽
// 弯心偏移:进弯切弯心,越急切得越深
const inside = -Math.sign(cAhead) * clamp(Math.abs(cAhead) * 45, 0, 1) * hw * 0.8; // :82
const target = clamp(inside + this.personal * hw * 0.8, -hw, hw); // :83 个人习惯偏移
// PD 控制器
this.latV += clamp((target - this.lat) * 2.2 - this.latV * 2.4, -14, 14) * dt; // :84
this.lat += this.latV * dt;
if (Math.abs(this.lat) > hw) { this.lat = clamp(this.lat, -hw, hw); this.latV *= -0.3; } // :87 撞墙轻弹
什么是 PD 控制器(Proportional-Derivative,比例-微分)?只有 P(比例)项:误差大→修正大。但纯 P 会过冲振荡:到 target 时还有速度,冲过去,再修回来,蛇形前进。D 项(微分,即当前横向速度 latV)提供阻尼——"离目标越近且冲得越快,越提前刹车"。
输出 = Kp·(target − lat) − Kd·latV
= 2.2 × 位置误差 − 2.4 × 当前横向速度
两个系数同量级(2.2/2.4)≈ 临界阻尼附近,AI 走线是"画着圆滑过去"而不是"之字形逼近"。82 行的 inside 是走线几何:−sign(κ)·min(|κ|·45,1)·hw·0.8——左转(κ>0)目标线向左偏,急弯偏到离墙 20% 处(顶点切弯)。personal(构造时 (rnd()−0.5)·0.6,ai.js:14)给每台 AI ±0.3 的走线习惯,六台车不会叠在一条线上。
进弯/出弯时 target 平滑变化(因为 cAhead 随 dist 滚动),走线自动是"外-内-外"——不是AI 懂赛车,是几何自然涌现。
氮气决策与视觉漂移
if (Math.abs(this.track.curvAhead(this.dist, 140)) < 0.006 // ai.js:74 前方 140 米无弯
&& this.s > 35 && this.nitroCd <= 0) { ... } // :75 且速度够
this.nitroCd = (6 + Math.random() * 8) / this.skill; // :77 冷却随难度缩短
直道(140 米曲率 < 0.006)+ 速度 > 35 → 放氮气,随后 6~14 秒冷却。ai.js:89 this.dist += this.s * dt + this.pushD——AI 的推进是 dist 直接积分(被撞的冲量 pushD 也汇入),世界坐标只用于渲染同步。
// 视觉漂移(ai.js:109-113):不改变运动,只摆姿态
const wantDrift = Math.abs(cNow) > 0.012 && this.s > 26;
this.drift = damp(this.drift, wantDrift ? 1 : 0, 5, dt);
const yawT = Math.sign(cNow) * Math.min(0.62, Math.abs(cNow) * 22);
this.yawOff = dampAngle(this.yawOff, this.drift * yawT, 5, dt);
// :115 车头 = 赛道朝向 + 横向速度角 + 漂移甩尾角
this.h = s.hd + Math.atan2(this.latV, Math.max(this.s, 3)) + this.yawOff;
115 行是 AI 姿态的合成公式:赛道朝向 + atan2(横向速度/前进速度)(车身自动指向合速度方向,天然带一点姿态)+ 人为的甩尾角。观感上 AI 在漂移,物理上它稳如轨道车。玩家看到的世界和模拟的世界是两回事——这是表现层的特权。
5.2 items.js —— 加权随机 + 简单追踪
文件:src/items.js(228 行)。
按名次加权的道具表
const WEIGHTS = { // items.js:4-8
lead: { banana: 35, shield: 30, nitro: 20, missile: 10, magnet: 5 }, // 领头:防
mid: { missile: 25, nitro: 25, banana: 20, magnet: 15, shield: 15 }, // 中游:均衡
back: { missile: 32, nitro: 30, magnet: 25, shield: 10, banana: 3 }, // 落后:追
};
经典"橡皮筋道具"设计:名次决定池子。挑选用累计权重随机(pick(),10-19)。这就是马里奥赛车"红甲虫只在末位出现"的同款机制——游戏平衡从来不是对称的。
道具箱生成与拾取
20 个箱子的位置(ItemSystem 构造,items.js:22-60):里程 f∈{0.1, 0.35, 0.6, 0.85} × 横向 lat∈{−0.6,−0.3,0,0.3,0.6}·半宽 的网格取 20 个,外观是旋转的问号方块(悬浮 y+1.6)。拾取判定(update 内):
if (dx*dx + dz*dz < 6.5 && Math.abs(dy) < 3) { giveRandom(r); box.t = 2.5; } // 2.5s 后重生
又是 (d/lat) 坐标系里两三个乘法的事——第 2 章的承诺第二次兑现(加速带、道具箱、香蕉命中、导弹命中,四类判定全部是区间/距离查询)。
导弹:追踪 + 命中
// items.js:186-203(节选)
const to = _v.set(target.x - m.x, target.y - m.y, target.z - m.z);
const dist = to.length();
m.dir.lerp(to.normalize(), Math.min(1, dt * 6)).normalize(); // 转向速率限制
m.v = Math.max(m.v, Math.abs(target.s) + 35); // 永远比目标快 35
m.x += m.dir.x * m.v * dt; ...
if (dist < 2.6) hitRacer(target, 'missile'); // 命中
if (m.life > 6) explode(m); // 6 秒寿命(防绕圈)
追踪核心一行:dir.lerp(to, min(1, dt·6))——导弹的朝向每秒最多转 6 弧度的"刚性",不是瞬间对准。这带来两个免费的好处:导弹飞出的是圆滑的追踪弧线(好看),且急转可以甩掉它(玩法深度:hitRacer 里玩家被导弹命中 spin=1.3 秒失控 + s *= 0.35——重罚但不致死)。
香蕉、磁铁、护盾与 AI 用道具
- 香蕉:放在车主后方 3.2 米(
r.x − sin(h)*3.2),踩中slowTime减速。宽限期 1.2 秒(age < 1.2时主人踩自己的香蕉无效)——刚扔出去压自己线的尴尬由一行 if 兜底。90 秒未触发自动过期(防止赛道变成雷区)。 - 磁铁:
magnet=3秒,吸向racerAhead()(前方最近对手);main.js:960-966还给磁铁状态画金色粒子拖尾。 - 护盾:7 秒,
hitRacer(main.js:757-774)先查 shield——吸收导弹/香蕉并弹出护盾特效,不减速。 - AI 用道具(
aiThink,items.js:217-228):每 1~4 秒决策一次(不是每帧——AI 也要"反应时间")。规则全是阈值:导弹在手且与目标 gap>350 → 先swap()把道具挪到一号位再用;磁铁gap<150才放(贴身吸);盾/氮气立即用。没有任何搜索算法,全部是 if——道具决策的本质是战术枚举,不需要聪明。
本章小结:AI = 查表(弯速 √(A/κ))+ 控制器(PD 走线)+ 阈值(氮气/道具),没有一行"学习"或"搜索",但六台车跑起来各有性格、有攻有防。这就是"够用的智能"——游戏 AI 的目标从来不是最优解,是戏剧性。
第 6 章 表现层:world.js / textures.js / effects.js
这一层不参与任何游戏规则——删掉它们游戏照样"跑"(只是黑屏)。但视觉的成本预算观念在这里最重:每帧 16 毫秒,渲染占 12+。
6.1 world.js —— 天空、地面、水、山,以及"合批"
天空:一个全屏"作弊"球
// world.js:59-89 buildSky 的 ShaderMaterial(节选)
varying vec3 vWorld;
void main() {
vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_Position = p.xyww; // world.js:69 —— 关键技巧
vWorld = position;
}
// 片元:按方向向量算渐变天幕 + 太阳光斑
float h = normalize(vWorld).y;
vec3 c = h > 0.0
? mix(horizon, top, pow(clamp(h, 0.0, 1.0), 0.55))
: mix(horizon, bottom, pow(clamp(-h, 0.0, 1.0), 0.4)); // :74
float s = dot(normalize(vWorld), sunDir);
vec3 sun = vec3(pow(s, 900.0) * 6.0 + pow(s, 40.0) * 0.35 + pow(s, 6.0) * 0.12); // :76
gl_Position = p.xyww 解密:齐次裁剪空间里 w 是透视除法的分母,把 z 设成 w 之后 z/w = 1——永远落在最远的深度平面上。所以这个半径 4000 的球(其实用什么都行)画出来永远在所有物体"后面",无需关心它的实际远近。配合 renderOrder=-10(先画)+ fog:false(天空不吃雾)+ BackSide(从内往外看球壳)。太阳是三个不同锐度的 pow 光斑叠加(900=核心、40=光晕、6=大气散射),太阳方向 sunDir 同时喂给水和光照——整个世界的光来自同一个向量,这是"画面统一"的数学根基。
地面:顶点色 + 包络 + 压平
buildGround(world.js:107-174)造一张 3200×320 米的大平面网格(320×320 段),每个顶点:
h = fbm 噪声地形; // world.js:113-141 三个包络函数
h = Math.max(h, lo(x, z)); h = Math.min(h, hi(x, z)); // 上下包络钳制
if (离赛道中心 < flatR) h = lerp(赛道高, h, 平滑衰减); // 赛道周围压平
colorAt(x, z, h, col); // :152-165 顶点色(省一张大贴图)
包络(envelope)是程序化地形的"设计图":下包络保证海边低于海面、上包络防止山吞掉赛道。main.js:40-63 的 baseFor(id) 就是每张地图的包络函数——城市图的河道(r<44 ? -6 : …)、爱琴海的向南倾斜入海(lerp(land, -14, smoothstep(-200,-262,z)))、埃及的绿洲凹坑全写在这里。数据(layouts)给形状,包络(baseFor)给地貌,噪声(fbm)给质感——三层各司其职。
顶点直接带颜色(vertex colors)而不用贴图:320×320 = 10 万顶点的渐变地形,一张等分辨率贴图要 1000×1000 像素还带重复感;顶点色是连续的、且随光照正常着色。
水面:四个余弦波叠加出"真的水"
buildWater(world.js:177-229)的片元着色器:
// 法线 = 4 个不同频率/速度的余弦波梯度之和 world.js:201-204
// 频率 0.09/0.17/0.41/0.83,速度 1.3/1.9/2.7/3.4(大浪慢、涟漪快)
float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0); // :207 菲涅尔
vec3 col = mix(deepColor, skyColor, fres);
vec3 spec = pow(max(dot(reflect(-sunDir, n), v), 0.0), 180.0) * 2.5; // :211-212 高光
// sparkle:伪随机像素闪烁 :213
菲涅尔(Fresnel):视线越掠水面,反射越强(看脚下的水是透明的深绿,看远处的水是天空色)——1−dot(n,v) 的三次幂恰好重现这个物理现象。镜面高光 pow(…,180) 是超锐光斑(太阳在波峰上碎成星星)。没有反射贴图、没有折射 pass,一个法线扰动 + 两个 pow,便宜且在 60fps 里骗过所有人的眼睛。
山:两圈"裙边"几何 + fbm 峰顶
buildMountains(world.js:232-275):围绕赛道中心两圈环形山(远圈大而高),每座山是 360 边的锥形裙边,峰顶高度由 fbm 决定,雪线以上换顶点色画雪帽(t > 0.62 && top > h*0.45,256 行附近)。放在 1400~1600 米外圈,功能是视觉围墙:远处地平线永远有轮廓,玩家不会看到"世界尽头"。
Batch:静态物体合批(表现层最重要的一招)
class Batch { // world.js:21-49
add(geo, mat, matrix, shadow) {
const e = matrix.elements;
const key = `${mat.uuid}|${Math.floor(e[12] / 260)}|${Math.floor(e[14] / 260)}|${shadow ? 1 : 0}`;
...按 key 分桶
}
build(parent) {
for (每个桶) {
const merged = mergeGeometries(桶里的几何, false); // :37 拼成一个 BufferGeometry
const mesh = new THREE.Mesh(merged, 桶的材质);
mesh.matrixAutoUpdate = false; // :44 静态物体冻结矩阵计算
parent.add(mesh);
}
}
}
问题:城市图有几百栋楼、几十盏路灯、上千个物件,每个一个 Mesh = 几百次 drawcall,帧率崩盘。 方案:把同一材质且同一 260×260 米空间分桶的几何拼成一个大 BufferGeometry,一次 drawcall 画完。材质相同才能合并(否则无法共享渲染状态);按空间分桶是因为视锥剔除以 Mesh 为单位——合成一个大 Mesh 后整桶要么全画要么全不画,分桶保证远处整桶被剔除时不会牵连近处。 代价:完全静态(一动就不能合);memory 翻倍缓存顶点。 matrixAutoUpdate=false 顺手省掉每帧矩阵乘法。这一招让整张地图静态部分压到个位数 drawcall,是"程序生成海量物件"在浏览器里跑 60fps 的前提。
6.2 textures.js —— 全部贴图来自 <canvas> 2D 绘图
文件:src/textures.js(610 行),15 个纹理生成函数。管线:
function mk(w, h) { // textures.js:7-12
const c = document.createElement('canvas');
c.width = w; c.height = h;
return [c, c.getContext('2d')];
}
function toTex(c, { repeat = true, srgb = true } = {}) { // :14-20
const t = new THREE.CanvasTexture(c);
if (repeat) t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.anisotropy = MAX_ANISO;
if (srgb) t.colorSpace = THREE.SRGBColorSpace;
return t;
}
CanvasTexture 直接把 canvas 位图当 GPU 纹理——你会的 Canvas 2D API(fillRect/渐变/arc/文字)就是贴图工厂。例如城市护栏(wallTexture('city'),207-299):
const cols = ['#1565c0', '#fafafa', '#e53935', '#fafafa']; // 蓝白红白围挡
for (let i = 0; i < 4; i++) { g.fillStyle = cols[i]; g.fillRect(i * 256, 0, 256, H); }
g.font = 'italic bold 60px Arial Black, Arial';
g.fillText('SPEED', ...); // 直接写广告字
1024×128 的画布 = 沿护栏每 16 米重复一次(WALL_TEX_LEN)。路面(41-177)更精细:沥青画车辙暗带、车道虚线、蓝霓虹边;石板路画错缝砖 + 每块随机亮度;法老路画紫色边线 + 沙岩裂纹。每个纹理函数都是一段小小的 Canvas 美术课,配 mulberry32 固定种子保证每次生成一致。
两个工程细节值得抄:
cacheMap(34-38 行):cached('road-' + style, fn)——同一风格贴图全游戏只生成一次,换图/重赛不再重复绘制。- sRGB 标记(19 行):颜色类贴图必须声明
colorSpace = SRGBColorSpace,否则线性采样会把颜色"洗白"。(softDotTexture特意srgb:false,因为粒子亮度要走数学混合,不是美术颜色。)
为什么程序化贴图可行? 贴图信息量低(平铺、重复、无方向性)而规则性强(砖缝等距、路缘等宽)。规则的东西代码写得出来;照片级的东西才需要美术。本项目全部选了"规则风格",这是整个"零资源"决策的成立前提。
6.3 effects.js —— 粒子与胎痕:环形缓冲区的两堂课
粒子:ShaderMaterial + gl_PointSize
// effects.js:4-16 顶点着色器
attribute float aSize;
uniform float uScale;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * uScale / max(0.1, -mvPosition.z); // :13 透视缩放
gl_Position = projectionMatrix * mvPosition;
}
Point Sprites 是粒子系统的最省形态:一个顶点 = 一个方形粒子(GPU 自动光栅成方块),不需要每粒子四个顶点。gl_PointSize / -mvPosition.z——离相机越远越小,透视由除法免费获得。片元着色器(17-28)用中心距离做圆形软边渐隐(配合 softDotTexture 的径向渐变贴图)。
Particles 类(31-105)管理 1800×2 个粒子槽(IS_TOUCH 时降到 900):
// 11 列平行 Float32Array(位置/速度/寿命/大小/颜色…) effects.js:34-45
this.pos = new Float32Array(N * 3); // 生命周期结束的槽位 size=0 隐藏
geometry.boundingSphere = new THREE.Sphere(0, 1e7); // :51 巨大的包围球:永不被视锥剔除
emit(x, y, z, vx, vy, vz, life, size, r, g, b, drag, grav) { // :68-77
// 从 this.i 环形取下一个槽,覆盖旧数据
this.rgb[i * 3] = r * r; // :75 sRGB→线性的廉价近似(平方)
}
环形缓冲:新粒子永远覆盖最老的槽,不需要释放/分配。r*r 平方是 sRGB→线性转换 pow(c, 2.2) 的一阶近似——粒子发光叠加在线性空间才不会"发灰"。燃烧数学都在 update(79-104):位置积分、drag 指数衰减、alpha 用 a0*(1-k)*min(1, k*8+0.2)(出生淡入 + 死亡淡出)。一个 drawcall 画所有粒子(一个 BufferGeometry,DynamicDrawUsage 每帧整包上传)。
调用方在 main.js 的 emitEffects(920-968):氮气蓝焰、漂移烟、撞墙火花、集气金粉(gauge>=1 || nitroCount>=2 时的金色拖尾)、磁铁金线、导弹尾迹……全部同走一个池。距离 250 米外的特效直接不发射(925 行)——又一处"看不见的不算钱"。
胎痕:环形四边形条带
SkidMarks(108-187):漂移时每帧往路面"贴"一小段四边形(左右轮各一)。同样是环形缓冲(2600 段,写满就覆盖最老的)。两个细节:
// 断续判定:本帧与前帧距离 < 25 米才连成条带 effects.js:154
if ((x - prev.x) ** 2 + (z - prev.z) ** 2 < 25) { /* 与上一段缝合 */ }
// 材质 polygonOffset: -4 :139-141
漂移中断(腾空、出漂)必须断开条带,否则会拉出横穿赛道的"钢丝"。polygonOffset 把胎痕在深度上推近(胎痕贴路面只抬高几毫米,必须防 z-fighting)。胎痕 alpha 随漂移烈度变化——急漂印黑、缓漂印浅。
本章小结:表现层三板斧——合批(静态合一个大几何)、池化(动态环形缓冲零 GC)、着色器小技巧(xyww 天空、透视点精灵、菲涅尔水面)。全部技巧的共同点:用数学换内存和 drawcall,而不是用美术换。这与"零外部资源"的项目基调完全一致。
第 7 章 外围层:audio.js 与 hud.js
这两个文件零依赖(audio.js 连 util 都不 import),彼此互不知道对方存在,只被 main.js 调用。它们验证了分层架构的最后一环:最外圈的东西最容易替换——换成 WebAudio 采样器或 React HUD,内核一行不改。
7.1 audio.js —— 没有一个音频文件的"声音引擎"
文件:src/audio.js(297 行)。所有声音 = WebAudio 节点图实时合成。
音色工厂
init()(23-95)搭好常驻节点:
// 引擎音(持续存在,改参数不重建) audio.js:47-64
const osc1 = new OscillatorNode(ctx, { type: 'sawtooth' }); // 锯齿波:粗糙的"引擎"底噪
const osc2 = new OscillatorNode(ctx, { type: 'square', detune: 1200 }); // 方波高八度
osc1.connect(lpf); osc2.connect(lpf);
const lpf = new BiquadFilterNode(ctx, { type: 'lowpass', frequency: 900, Q: 4 });
// + 打滑带通 2300Hz、风噪带通 700Hz 两个常驻通道
合成器思维:锯齿波泛音丰富像引擎、方波像涡轮;lowpass Q=4 在截止频率处共振出"呜——"的腔体感。主链 master(0.8) → DynamicsCompressor(−12dB, ratio 4)——压限器保证 18 种音效叠加不爆音,免费的混音师。
瞬时音效走两个一次性工厂:tone(freq, dur, …)(118-132,OscillatorNode 加音量包络)和 noiseBurst(dur, …)(134-150,从预生成的 2 秒噪声 buffer 里切片 + 带通滤波)。play(name)(152-191)switch 分发 18 个音效——撞墙是低频 noiseBurst,集气完成是上滑 tone,双喷是两个 tone 叠拍……每个音效就是两三个振荡器的参数配置。
引擎音:6 挡转速模拟
setEngine(speed, boosting, drifting) { // audio.js:97-113
const kmh = Math.abs(speed) * 3.6;
const g = Math.min(5, Math.floor(kmh / 33)); // 挡位 = 速度阶梯
const inGear = (kmh - g * 33) / 33; // 挡内进度 0..1
const rpm = 0.3 + inGear * 0.7 * (0.75 + g * 0.05); // :104 转速曲线
const base = 55 + rpm * 130 + (boosting ? 35 : 0); // :105 基频
osc1.frequency.value = base;
osc2.frequency.value = base * 1.5; // 五度泛音
lpf.frequency.value = 400 + rpm * 1800;
}
速度直接映射基频会是一根单调的"呜——"。挡位台阶让频率呈锯齿状循环(升挡瞬间 rpm 掉回 0.3 再爬升)——光这一个函数就把街机引擎的"换挡感"做出来了。boosting 时 +35Hz、drifting 时打开打滑通道。main.js 每帧把玩家车状态喂进来(563-566 行),声音是状态的函数。
音乐:25ms 定时器 + 前瞻调度的音序器
4 首 BGM(SONGS,4-9 行:每首 = bpm + 根音 + 和弦进行 + 旋律序列)由 16 分音符步进音序器播放:
schedule() { // audio.js:209-220
const spb = 60 / this.bpm / 4; // :212 一个 16 分音符的时长
if (this.nextT - ctx.currentTime < 0) this.nextT = ctx.currentTime; // :214 积压重置
while (this.nextT < ctx.currentTime + 0.12) { // :215 只调度未来 120ms 内的音符
this.playStep(this.step % 16, this.step);
this.nextT += spb;
this.step++;
}
}
// init 里:setInterval(() => this.schedule(), 25) // :94
为什么不能在回调里直接 play? WebAudio 的 currentTime 与主线程帧率不同步;若每 25ms 醒来播放"现在"的音符,主线程卡顿时音符全部迟到、节拍漂移。正确姿势(WebAudio 官方推荐模式):提前 120ms 把音符调度到绝对时间轴(osc.start(nextT) 接受未来时刻),主线程抖动 ±50ms 也只影响"调度"不影响"发生"——音乐节拍由音频硬件时钟保证。:214 的积压重置处理后台标签页(定时器被节流到 1Hz,醒来时积压几小节,直接放弃对齐)。
playStep(222-259)每 16 分音符发 4 个声部:kick(150→40Hz 频率下扫 + 快衰减)、bass(根音−24 + 和弦低音)、pad(长音 spb×15)、lead(根音+12 + 和弦 + 旋律偏移)。NOTE(n) = 440·2^((n−69)/12)(2 行)——MIDI 音高换算公式,一个 Math.pow 让代码里的整数变成音符。
7.2 hud.js —— Canvas 小地图 + DOM 缓存
文件:src/hud.js(290 行)。两种渲染技术的分工:HUD 的静态图形用 Canvas,文字数字用 DOM。
小地图:一次预绘 + 每帧贴图
setupMinimap(track) { // hud.js:68-119
// 离屏 canvas:把赛道中心线画一遍(黑 16px 底 + 白 9px 线)
// 桥段加蓝色覆盖、起点画格子旗 :95-117
const mmT = `translate(${ox}px,${oy}px) scale(${k}) rotate(0)`; // :72 变换字符串
this.pathEl.style.transform = mmT;
}
drawMinimap(P) { // hud.js:121-152
// 玩家箭头:一个小三角 DOM 元素
this.carEl.style.transform = `translate(${P.x * k + ox}px,${P.z * k + oy}px) rotate(${P.h + Math.PI})`;
...
}
赛道轮廓只在换图时画一次到离屏 canvas;每帧只做两件事:把 6 个车的位置换算成像素、挪 DOM transform。rotate(P.h + Math.PI) 那个 +π 是坐标系换手(世界朝向 0=+z,屏幕角度 0=+x)的经典 off-by-90° 补偿。AI 车箭头用 transform 直接挪——60fps 下 DOM transform 是合成器加速的,比 canvas 重绘还省。
仪表与消息:DOM 更新的"值缓存"
set(el, v) { // hud.js:56-60
if (el._v !== v) { el.textContent = v; el._v = v; } // 值没变就不碰 DOM
}
速度、圈数、名次每帧都在算,但 textContent 只在变化时写入——el._v 一个字段挡掉 95% 的无效 DOM 更新(浏览器 layout 是隐形成本大头)。消息条(217-225)最多同时 3 条、1150ms 自动淡出;速度表(154-191)是 canvas 弧线仪表,指针角 a0 + 1.5π·speed/300。
本章小结:外围层教的两课——音频要调度到硬件时钟(schedule-ahead 模式),DOM 要值缓存(text 只写变化)。加上前面各章,你已经把这个项目从数学原语到像素和声波完整走了一遍。
第 8 章 架构总结:为什么一个人写得完、改得动
8.1 依赖分层(收束全文)
第 1 层 util.js 数学原语(damp/wrapAngle/mulberry32/fbm)
↑ 被所有层使用
第 2 层 track.js 一维化:(d,lat)↔(x,z) 双射 + 曲率/坡度/倾角派生量
↑ 只依赖第 1 层
第 3 层 vehicle.js 状态机物理:h/m 双角度、漂移 FSM、事件队列
↑ 只依赖第 1、2 层
第 4 层 main.js 粘合:固定步长调度、状态机路由、事件消费、相机
↑ 组装第 1~3 层 + 调度第 5~7 层
第 5 层 ai.js/items.js 玩法规则:弯速表/PD 控制器/加权道具
↑ 读第 2 层数据 + TUNE
第 6 层 world/textures/effects 表现:合批/程序贴图/粒子池
↑ 只依赖第 1 层(甚至不知道 track 的存在)
第 7 层 audio/hud/input 外围:合成音频/DOM HUD/输入
↑ 零依赖,纯被调用
没有一条反向边:track 不知道车、vehicle 不知道渲染、ai 不知道 hud。main.js 是唯一同时看见所有层的文件——而它自己几乎没有算法,只有调度。
8.2 这个架构回答了三个工程问题
① 为什么一个人写得完? 因为每层心智负载独立:写 track.js 时只需要想"一维坐标变换";写 vehicle.js 时只需要想"手感";写 audio.js 时只需要想"合成器"。3000 行不是 3000 行纠缠的代码,是 7 个各自 <700 行的独立问题。循环依赖的坏处从来不是"编译报错",而是任何一行修改的波及范围变成全图——分层把这个范围压成"只往下传"。
② 为什么 bug 好找? 依赖单向 = 因果单向。小喷偶发双倍触发 → 只可能是第 4 章的 noEdge 逻辑或第 3 章的窗口判定,两个文件 20 行代码里找;车穿墙 → 只可能是 project() 精度或碰撞步长,与贴图、音频、AI 全部无关。分层是给调试划的搜索域。
③ 为什么好扩展? 每个横向功能都只碰一条竖线:加新地图 = layouts.js 加控制点 + maps.js 加主题(两文件,零逻辑);加新道具 = items.js 的 WEIGHTS 表 + use() 一个 case + handleEvents 一个分支;换渲染风格 = 重写第 6 层、内核不动。数据驱动(地图=数据、道具=表)+ 分层(每类改动有明确的家),这就是全部秘密。
8.3 贯穿全项目的十个手法(速查表)
| # | 手法 | 出处 |
|---|---|---|
| 1 | 指数平滑 1−e^(−λdt),帧率无关 | util.js:3,全项目几十处 |
| 2 | 角度差先 wrapAngle 再插值 | util.js:9,track.js:161 等 |
| 3 | (d,lat) 一维化:进度/碰撞/AI 全换坐标系 | track.js:148/173 |
| 4 | hint 局部搜索:时间连贯性省掉全局扫描 | track.js:183 |
| 5 | 固定步长累加器 + 边沿输入只发一次 | main.js:527-536 |
| 6 | 事件队列:物理陈述事实、表现自行取用 | vehicle.js:80,main.js:793 |
| 7 | 环形缓冲池:粒子/胎痕零 GC | effects.js:31/108 |
| 8 | 合批:同材质+空间分桶 merge 成单 drawcall | world.js:21 |
| 9 | 程序化一切:Canvas 贴图、WebAudio 音色、fbm 地形 | textures/audio/util |
| 10 | 视觉与逻辑分离:syncModel 只读状态、AI 漂移纯摆姿态 | vehicle.js:326,ai.js:109 |
8.4 按本文顺序读完之后
如果你逐章读到了这里,建议的下一步实践(按难度递增):
- 调手感:改
TUNE(vehicle.js:4)——把 grip 从 12 调到 5,感受"冰面";把 gravity 调到 9.8,感受"月球跳台"。 - 加一张地图:在 layouts.js 抄一份控制点改形状,在 maps.js 配主题色——体会"数据驱动"意味着不用写逻辑。
- 加一个道具(如油污/烟雾):WEIGHTS 表加一行、use() 加 case、update 加判定、handleEvents 加音效——四个明确的落点。
- 加回放功能:固定步长 + 纯函数状态机已经为此铺好了路(每 1/120s 存输入即可重放)——这是对这个架构"可扩展性"的终极测试。
