12 KiB
3DTank后视镜开发经验记录
1. 项目背景
逆向分析 Tanki Online 中国版的 webpack 打包 JavaScript,实现"3DTank后视镜"功能:在屏幕右上角小 Canvas 中复用游戏原生渲染器(Ff.y3s)渲染玩家背后的 3D 场景,并叠加 UI 标记(敌方/友方坦克位置)。
文件:tools/hornet_rearview.js
2. 核心渲染方案
2.1 FBO 重定向
通过 Hook gl.bindFramebuffer,在后视渲染时将所有 FBO 绑定重定向到自定义的 rearFbo:
var origBindFramebuffer = gl.bindFramebuffer.bind(gl);
gl.bindFramebuffer = function(target, fbo) {
if (!rearviewRendering && target === gl.FRAMEBUFFER) {
mainFbo = fbo; // 记录主 FBO
}
if (rearviewRendering && target === gl.FRAMEBUFFER && fbo !== rearFbo) {
return origBindFramebuffer(target, rearFbo); // 重定向
}
return origBindFramebuffer(target, fbo);
};
2.2 BS 尺寸 Hook
y3s 会调用 BS.h3c()/g3c() 获取 drawingBuffer 尺寸设置相机 aspect ratio。必须 Hook 返回 FBO 尺寸,否则画面比例错误:
ff.p39_1.h3c = function() { return rearviewRendering ? rw : origH3c(); };
ff.p39_1.g3c = function() { return rearviewRendering ? rh : origG3c(); };
2.3 PBO 双缓冲异步回读
WebGL2 用 PIXEL_PACK_BUFFER 异步回读,避免 readPixels 同步 GPU 停顿:
// 写入 PBO(GPU→PBO,异步)
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[pboIndex]);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, pixels, 0, byteSize);
// 下一帧读取另一个 PBO 的数据
2.4 UI 层禁用
ff.x38_1 是渲染层队列数组,替换 UI 层(索引 20-24)为空假对象,防止技能条等 UI 混入后视 FBO:
var fakeQueue = {
e1: function() { return 0; },
g1: function(i) { return null; },
v2a: function(x) {}, d3: function() {}, ii: function(x) {}
};
for (var ui = 0; ui < uiLayerIndices.length; ui++) {
ff.x38_1[uiLayerIndices[ui]] = fakeQueue;
}
3. o37 函数与视图矩阵(核心经验)
3.1 o37 矩阵构建
Ha.o37(module_92980.js:8749)构建视图矩阵 u36_1。
欧拉角参数(l35_1):
f20_1= pitch(绕 X 轴)g20_1= yaw(绕 Y 轴)h20_1= roll(绕 Z 轴)
变换前矩阵 d[0..15](列主序)行向量:
| 行 | 分量 | 含义 | 角度全0时 |
|---|---|---|---|
| 行0 | (d[0], d[4], d[8]) |
right(右) | (1,0,0) |
| 行1 | (d[1], d[5], d[9]) |
forward(前) | (0,1,0) |
| 行2 | (d[2], d[6], d[10]) |
up(上) | (0,0,1) |
关键教训:之前项目记忆中错误标注为"行1=up, 行2=backward",导致后续所有分析出错。验证方法:角度全0时,行1=(0,1,0)=+Y=前方(游戏世界 Y 朝前),行2=(0,0,1)=+Z=上方。
矩阵元素公式:
d[0] = cos(yaw)*cos(roll)
d[1] = sin(pitch)*sin(yaw)*cos(roll) - cos(pitch)*sin(roll) // forward.x
d[2] = cos(pitch)*sin(yaw)*cos(roll) + sin(pitch)*sin(roll) // up.x
d[4] = cos(yaw)*sin(roll)
d[5] = cos(pitch)*cos(roll) + sin(pitch)*sin(yaw)*sin(roll) // forward.y
d[6] = cos(pitch)*sin(yaw)*sin(roll) - sin(pitch)*cos(roll) // up.y
d[8] = -sin(yaw)
d[9] = sin(pitch)*cos(yaw) // forward.z
d[10] = cos(pitch)*cos(yaw) // up.z
3.2 Ua 变换(Y↔Z 行交换)
Ua(module_92980.js:750)带负号的行交换:
function Ua(t, n, i) {
var r = t[n];
t[n] = t[i];
t[i] = -r;
}
o37 末尾调用 Ua(d,1,2), Ua(d,5,6), Ua(d,9,10), Ua(d,13,14)。
变换后矩阵行向量:
| 行 | 变换后 | 含义 |
|---|---|---|
| 行0 | (d[0], d[4], d[8]) |
right(不变) |
| 行1 | (d[1], d[5], d[9]) = 原 up |
up |
| 行2 | (d[2], d[6], d[10]) = -原 forward |
-forward |
变换后相机空间约定(标准 OpenGL):
camX = right·rel(右)camY = up·rel(上,正=上方当up朝上)camZ = -forward·rel(深度,负=前方)
关键教训:变换后 camZ 负值表示前方(因为行2=-forward)。这与
Project3D函数中camZ=forward·rel(正=前方)的约定相反。
3.3 从 u36_1 提取相机向量
变换后的 u36_1 矩阵,提取相机基向量:
var vm = mainCamera.u36_1;
var right = { x: vm[0], y: vm[4], z: vm[8] };
var up = { x: vm[1], y: vm[5], z: vm[9] };
var forward = { x: -vm[2], y: -vm[6], z: -vm[10] }; // 注意取负!
关键教训:诊断日志中直接打印
vm[8],vm[9],vm[10]标记为"前向"是错误的,实际是(right.z, up.z, -forward.z)。必须打印(-vm[2],-vm[6],-vm[10])才是真正的 forward。
4. 后视相机参数设置
4.1 欧拉角配置
mainCamera.l35_1.f20_1 = -camInfo.pitch; // pitch 取负
mainCamera.l35_1.g20_1 = Math.PI; // yaw = π
mainCamera.l35_1.h20_1 = camInfo.yaw + Math.PI; // roll = 原roll + π
方向推导:
yaw=π:sin(yaw)=0,forward.y = cos(pitch)*cos(roll),cos(roll)=cos(原roll+π)=-cos(原roll)- 后视
forward.y = -cos(pitch)*cos(原roll)= -主视 forward.y → 水平方向反向(朝后看)
- 后视
roll=原roll+π:保持水平视角正确(与主视镜像)pitch=-camInfo.pitch:forward.z = sin(-camInfo.pitch)*cos(π) = sin(camInfo.pitch)= 主视 forward.z- 垂直方向与主视一致(低头时都朝下看地形)
4.2 up 向量朝下
rearPitch=-camInfo.pitch 时,后视相机 up.z = cos(pitch)*cos(yaw) = cos(-camInfo.pitch)*(-1) < 0(朝下)。
up 朝下导致 FBO 渲染画面上下颠倒,但通过 GL readback 机制自动修正(见下节)。
5. GL Readback Y 翻转机制
5.1 渲染管线
- FBO 渲染(OpenGL Y 朝上):相机 up 朝下时,世界"上"映射到 FBO 低 Y 区域(底部)
gl.readPixels(从左下角开始):FBO 底部 → 数组开头putImageData(y=0 顶部):数组开头 → canvas 顶部
结果:世界"上" → canvas 顶部(画面正立)
5.2 代码实现
// up 朝下时,渲染 + readback 自动 Y 翻转,直接复制即可
for (var ai = 0; ai < pixels32.length; ai++) {
pixels32[ai] = pixels32[ai] | 0xFF000000; // 修复半透明边缘
}
imageData.data.set(pixels);
uiCtx.putImageData(imageData, 0, 0);
关键教训:up 朝下 + GL readback = 画面正立。up 朝上 + 直接复制 = 画面颠倒(需要额外 Y 翻转)。所以后视相机保持 up 朝下是有利的。
6. Marker 投影(核心 Bug 修复)
6.1 正确的投影代码
// 变换后相机空间:camX=右,camY=上(up·rel),camZ=深度(-forward·rel,负=前方)
var wx = pos.x, wy = pos.y, wz = pos.z;
var m = viewMatrix;
var camX = m[0]*wx + m[4]*wy + m[8]*wz + m[12];
var camY = m[1]*wx + m[5]*wy + m[9]*wz + m[13]; // 上:up·rel
var camZ = m[2]*wx + m[6]*wy + m[10]*wz + m[14]; // 深度:-forward·rel
if (camZ >= -0.1) continue; // 剔除后方(深度非负)
var depth = -camZ; // 正值=距离
var focalLength = rh / (2 * Math.tan(Config.fov / 2));
var screen = {
x: rw / 2 + (camX / depth) * focalLength,
y: rh / 2 + (camY / depth) * focalLength, // + 匹配 GL readback Y 翻转
depth: depth
};
6.2 screen.y 符号分析
后视相机 up 朝下,世界"上"的物体 camY = up·rel < 0(因为 up 朝下,rel 朝上时点积为负)。
渲染画面:世界"上" → FBO 底部 → readback → canvas 顶部(screen.y 小)
Marker:世界"上" camY<0 → 用 + → screen.y = rh/2 + 负 = 小 → canvas 顶部 ✓
关键教训:screen.y 用
+还是-取决于渲染画面的 Y 方向。当 up 朝下 + GL readback 翻转时,画面正立(世界上方在 canvas 顶部),marker 需用+匹配。
6.3 遇到的坑:camY/camZ 角色搞反
症状:用户反馈"摄像机上下移动和后视镜敌人上下位置变换写反了"
错误根因:之前错误地认为 o37 变换后行1=backward, 行2=-up,导致:
- 错误地用 camY 当深度(实际是上)
- 错误地用 camZ 当上下(实际是深度)
- 剔除条件
camY >= -0.1实际剔除了上方/下方物体(不是后方物体)
修复:通过直接阅读 o37 源码,确认变换后行1=up, 行2=-forward,camY/camZ 角色对调。
7. 相机系统关键字段
7.1 Ha 相机类(module_92980.js)
| 字段 | 含义 |
|---|---|
l35_1 |
欧拉角(f20_1=pitch, g20_1=yaw, h20_1=roll) |
k35_1 |
位置(f20_1=x, g20_1=y, h20_1=z) |
u36_1 |
视图矩阵(Float32Array(16),列主序,Ua 变换后) |
v36_1 |
投影矩阵 |
x36_1 |
viewProjection 矩阵 |
l36_1 |
viewport 宽 |
m36_1 |
viewport 高 |
s36_1 |
FOV |
q36_1 |
near |
r36_1 |
far |
z36_1 |
right 向量(O$类型) |
y36_1 |
forward 向量(O$类型) |
a37_1 |
up 向量(O$类型) |
方法:
h37():更新视图矩阵(调用 o37)q37(point):世界坐标转屏幕坐标
7.2 获取相机信息
function GetCameraInfo() {
// 遍历所有 entity 查找活跃 $r/gq 相机组件
// 字段:kym_1(位置), lym_1(方向), jym_1(FOV)
// 注意:lym_1.f20_1=pitch, lym_1.g20_1=yaw, lym_1.h20_1=roll
return {
yaw: found.lym_1.h20_1, // 实际是 roll
pitch: found.lym_1.f20_1,
fov: found.jym_1,
pos: { x, y, z }
};
}
注意:
lym_1.h20_1是 roll 不是 yaw!命名容易误导。
8. 渲染器关键字段
8.1 Ff 绘制器
| 字段 | 含义 |
|---|---|
p39_1 |
BS 实例(渲染上下文) |
p39_1.p73_1 |
WebGL gl context |
p39_1.h3c()/g3c() |
drawingBuffer 宽/高 |
r74_1 |
hC 渲染器 |
x38_1 |
渲染层队列数组(20=Hud, 21-24=GUI) |
p38_1 |
渲染开关(x3n_1=阴影, y3n_1=后处理, z3n_1=其他) |
f3b_1.w3p_1 |
当前渲染状态矩阵 |
方法:
y3s(rootNode, camera):主渲染入口
8.2 场景对象 jr
| 字段 | 含义 |
|---|---|
lye_1 |
hC 渲染器实例 |
kyf_1 |
场景根节点 |
nyf_1 |
主相机 Ha 实例 |
pyj_1 |
物理世界 |
9. 调试经验
9.1 诊断日志的正确写法
// 变换后 forward = (-vm[2], -vm[6], -vm[10])
// 变换后 up = (vm[1], vm[5], vm[9])
console.log('forward=[' + (-vm[2]).toFixed(2) + ',' + (-vm[6]).toFixed(2) + ',' + (-vm[10]).toFixed(2) + ']' +
' up=[' + vm[1].toFixed(2) + ',' + vm[5].toFixed(2) + ',' + vm[9].toFixed(2) + ']');
9.2 验证相机方向
通过 forward 向量验证:
- 主相机
forward.y < 0→ 朝 -Y(前方)✓ - 后视相机
forward.y > 0→ 朝 +Y(后方)✓ forward.z < 0→ 朝下(看地形)up.z < 0→ up 朝下(FBO 画面颠倒,readback 修正)
10. 关键教训总结
-
矩阵行向量标签必须通过源码验证:不能凭直觉或外部文档假设,必须直接读 o37 源码并用角度=0 的边界情况验证。
-
Ua 变换后的矩阵布局:行0=right, 行1=up, 行2=-forward(不是 backward)。
-
camY/camZ 角色必须正确:变换后 camY=上, camZ=深度(负=前方)。搞反会导致剔除条件和投影全部错误。
-
screen.y 符号与 GL readback 相关:up 朝下 + readback 翻转 = 画面正立,marker 用
+匹配。 -
诊断日志标签要与数学一致:打印
vm[8,9,10]标记为"前向"是错误的,会误导调试。 -
rearPitch 取负的原因:
forward.z = -sin(rearPitch),要与主视同垂直方向需rearPitch = -camInfo.pitch。 -
不能缓存场景图节点引用:游戏引擎会重建节点树导致悬空引用(3D 线框经验)。
11. 相关文件
tools/hornet_rearview.js:后视镜主实现tools/hook_gamemode.js:Hook 获取 GameMode 实例3dtank.com/play/static/js/unpacked/module_92980.js:o37 函数、Ha 相机类、Ua 函数3dtank.com/play/static/js/unpacked/module_87072.js:核心游戏逻辑