Files
TankIC/docs/Hornet_Rearview_Experience.md
2026-07-28 01:16:15 +08:00

12 KiB
Raw Permalink Blame History

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 渲染管线

  1. FBO 渲染(OpenGL Y 朝上):相机 up 朝下时,世界"上"映射到 FBO 低 Y 区域(底部)
  2. gl.readPixels(从左下角开始):FBO 底部 → 数组开头
  3. 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. 关键教训总结

  1. 矩阵行向量标签必须通过源码验证:不能凭直觉或外部文档假设,必须直接读 o37 源码并用角度=0 的边界情况验证。

  2. Ua 变换后的矩阵布局:行0=right, 行1=up, 行2=-forward(不是 backward)。

  3. camY/camZ 角色必须正确:变换后 camY=上, camZ=深度(负=前方)。搞反会导致剔除条件和投影全部错误。

  4. screen.y 符号与 GL readback 相关:up 朝下 + readback 翻转 = 画面正立,marker 用 + 匹配。

  5. 诊断日志标签要与数学一致:打印 vm[8,9,10] 标记为"前向"是错误的,会误导调试。

  6. rearPitch 取负的原因:forward.z = -sin(rearPitch),要与主视同垂直方向需 rearPitch = -camInfo.pitch。

  7. 不能缓存场景图节点引用:游戏引擎会重建节点树导致悬空引用(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:核心游戏逻辑