从 73000 行压缩代码到 TypeScript 模块化:我把一个 Three.js FPS 游戏 1:1 复刻了一遍



不改场景、不改材质、不改光照、不改手感------只把一整个浏览器 3D 游戏从"压缩成一行"的引擎里,逐模块抢救成可维护的 TypeScript + React Three Fiber 工程。这可能是你近期能看到的最"硬核"的一次前端逆向工程实践。
一、这是个什么项目
Harbor Blackout 是一个纯浏览器端运行的 3D 第一人称射击游戏:雨夜港口、湿漉漉的混凝土地面反射着集装箱的灯光、闪电划过天空、无人机和猎犬成群向你扑来------全部由 Three.js 实时渲染,没有一张外部美术资源。
而我要做的事情听起来有点"自虐":
- 场景、材质、光照、道具、逻辑 全部与原版 1:1 一致,不允许自己"画一个差不多的";
- 原有引擎是一份 73000 行、无 source map 的压缩 bundle;
- 用 TypeScript 模块化结构 重写整个工程,技术栈迁移到 React 19 + React Three Fiber + Zustand + Vite。
换句话说:不是"仿一个游戏",而是给一个黑盒引擎做"外科手术"。
二、为什么不能"自己写一个差不多的"
拿到项目时我一开始也犯过经典错误:看着截图自己用 R3F 搭场景。结果很现实------
- 混凝土地面的 tint 参数只在无纹理回退路径生效,直接拿 concrete 材质做沙地会发灰发绿;
- R3F 默认在低画质下关闭 shadowMap,原版的软阴影直接失效;
- 原版的地面湿反射依赖平面反射预通道,漏掉它地面就会偏亮偏暖(实测修复前地面平均亮度 75,原版是 57)。
结论写进了我的硬性约束里:移植时必须从原版引擎逐段抽取代码,不脱离原实现手写替代逻辑。 感觉上的"差不多",在 PBR 管线面前都是"差很多"。
三、手术方案:逐模块抽取
73000 行压缩代码没法读懂全貌,但可以切片。我按功能边界把 bundle 拆成了一个个独立模块,每抽一个就用 npm run build + 浏览器实测验证一个:
bash
src/game/legacy/
├── worldBuilders.ts # 集装箱、关卡道具、场景搭建
├── proceduralMaterials.ts # 48 种程序化 PBR 材质
├── environment.ts # 天空、雨、平面反射预通道、室内环境探针
├── sunShadows.ts # 全开销太阳软阴影
├── skyBuilders.ts # 体积云、闪电事件
├── weaponBuilders.ts # AR-9 武器模型、后坐力、换弹动画
├── enemyBuilders.ts # Gull 无人机、Hound 猎犬 + AI
├── physicsBuilders.ts # 物理碰撞与寻路
├── fxBuilders.ts # 粒子特效
├── themeLevels.ts # 主题关卡(雪地/沙地等)
└── customLevels.ts # 自定义关卡扩展
最终对齐的数字:36 类程序化道具、298 个关卡道具实例、47 个集装箱、48 种程序化材质、557 个物理碰撞体 ,全部来自原版 PROPS.build 和材质系统实时生成,零简化。
四、几个踩坑深挖的点
1. 平面反射预通道:地面亮度差 22 的真相
玩家反馈"地面和原版不一样亮"。逐像素采样对比后发现:根因是漏掉了原版的反射预通道 。原版管线在主渲染前先渲染一份深度/反射 pass,地面材质靠它做湿面反射。补上 addPrePass / renderWorld 后,地面平均亮度从 75 对齐到 53(原版 57),全场景亮度分布基本一致------剩下的 5% 差异来自全局自动曝光的平衡点。
2. 光源池:枪战突发卡顿的元凶
最隐蔽的一个坑。three.js 的材质程序缓存键里包含运行时点光源数量 ------如果在枪战/爆炸时动态 new 点光源,整个世界的材质会瞬间全部重编译,表现为毫无征兆的掉帧尖刺。
解法是对齐原版设计:6 盏闪光灯常驻场景,只动画 intensity,从不切换 visible;连玩家头灯都从同一个池借灯。这样光源数量永远恒定,缓存键永远不失效。
3. 阴影:把渲染权还给原版管线
R3F 会接管 shadowMap 配置,低画质预设下直接把原版软阴影关了。最终方案是显式禁用 R3F 内建阴影控制 ,写了一个 sunShadows.ts 专门修复 Three.js r187 下阴影深度绑定的问题,把 shadowMap 完全交回原版渲染逻辑,同时保留画质预设切换阴影分辨率的能力。
4. 性能:对齐原版的"抠门"
高 DPI 画布 + 全开销阴影 + 每帧平面反射,帧率直接崩。原版引擎其实早就做好了优化,我逐条抄作业:像素预算 + 动态分辨率(含预热期)、阴影隔帧更新、PCSS 采样数分档、平面反射隔帧刷新且分辨率随画质档位同步。
5. 材质系统不能是单例
做资产预览页(#/assets 懒加载路由)时缩略图全黑。原因很微妙:程序化材质里的 Canvas 纹理绑定创建它的 WebGL 上下文 ,资产页单独的渲染器复用游戏材质单例就会上下文冲突。解法是每个渲染上下文各自 createMaterialSystem() 实例。
6. 手感级对齐
"1:1"不只体现在画面上。玩家指出准星形状不对、跳跃手感不对之后,我把整层 gameplay 逻辑全部对回原版:准星恢复标准十字、跳跃改为仅按下瞬间触发并校准初速/重力、后坐力改成弹簧式自动回正、FOV 切换、回血规则、中继交互、撤离机制、增援波次、弹药箱、HUD 与设置项------每一项数值都对照原版核对。甚至音效也保持原样:项目没有任何音频文件,57 个音效全部由 Web Audio API 实时合成 + 卷积混响。
五、最终架构
scss
React 19 (UI 层:HUD / 菜单 / 路由)
└─ React Three Fiber (场景编排)
├─ src/game/legacy/* ← 原版引擎抽取的核心逻辑(TS 化)
├─ Zustand (游戏状态)
└─ Vite 8 构建,tsc 严格模式
游戏入口保持纯粹的 FPS 体验,资产预览页通过 React.lazy 懒加载成独立 chunk,互不污染。
六、写在最后
这次移植最大的收获不是代码,而是一个方法论:面对无源码的黑盒系统,"尊重原实现"比"优雅重写"更靠谱。 每一个我试图凭直觉优化的地方,几乎都在渲染管线的某个深层机制上翻过车;而每一个我老老实实从 bundle 里抽出来的模块,都一次跑通。
如果你也在做类似的 Three.js 遗留工程迁移,欢迎交流。核心抽取模块在 src/game/legacy/ 目录下。
参考与出处
- 原版游戏仓库 :topway-ai/harbor-blackout(MIT License © 2026 topway-ai)
- 原版 Gemini Edition 在线试玩 :topway-ai.github.io/harbor-blac...
本项目的全部游戏玩法、美术与代码原始设计均来自上述原版仓库,本文所述工作为对原版引擎的 TypeScript 模块化移植复刻。
技术栈:Three.js 0.187 · React 19 · React Three Fiber 9 · Zustand 5 · TypeScript · Vite 8