20 个 Agent、175 个模块,做出一个单文件 WebGL:歼-20「极境威龙」机场沙盘开发实录
在线体验:https://j20.dzt.cool/
交付物:一个 2.29 MB 的.html文件,双击就能在 Chrome 里跑起来
里面是一台 1:1 真实尺寸的歼-20:升力体边条鸭式布局、DSI 无附面层超音速进气道、全动双外倾垂尾、全动鸭翼、锯齿隐身喷管、可收放起落架、内置主/侧弹舱。它停在一座 40×34×13 m 的真实机库里,门外是停机坪、23 m 宽滑行道和 3000×45 m 的跑道。
打开大门 → 牵引车拖出库 → 脱钩 → 点火 → 滑行 → 对正 → 加力滑跑 → 抬前轮离地 → 收轮 → 巡航 → 超音速通场 → 一键返航 → 五边进近 → 3° 下滑道落地 → 放减速伞 → 滑回机库 → 关车。全程 63 条中文军航配音,发动机声音是浏览器实时合成的。
没有外部模型、没有外部贴图、没有外部音频文件,几何和材质全部程序化生成。
这篇记录的是它怎么被造出来的:20 个 Agent 的团队、175 个模块的构建系统、一次把机库造大了 20 倍的事故,以及把首帧从 20.3 秒压到 0.7 秒的过程。
一、起点:一份提示词
整个项目从一份提示词开始。这份提示词后来一直放在项目根目录,是唯一的验收基准:
# 任务目标:构建单文件 WebGL【歼-20“威龙”战机:全流程战备演练与高空机动仿真系统】
请使用 Three.js (r160 CDN) 和 Web Audio API,编写一个高完成度、零外部资源依赖(原生程序化材质与几何体)、在 Chrome 本地双击即可直接运行的单文件 HTML 应用程序。
---
## 1. 真实比例与场景空间层级(严格以米制为基准,1 Unit = 1 Meter)
必须严格遵照以下几何与空间比例,杜绝场景过大或战机过小的失衡问题:
1. **战机本体核心**:长 21.2m,翼展 13.0m,高 4.5m。升力体边条鸭式布局、DSI 鼓包进气道、全动双外倾垂尾、全动鸭翼、锯齿隐身喷管与可收放起落架。
2. **机身涂装与标识**:低可视度割裂迷彩吸波涂装;主翼与垂尾处以 Canvas 程序化贴图清晰绘制【中国国旗】与【低可视度八一军徽】。
3. **真实比例重型战备机库**:宽 32m、深 45m、净高 10m,机库大门可向两侧滑轨开启,内部包含防滑耐磨环氧地坪、顶部桁架工字钢与防爆冷光检修吊灯。
4. **外场跑道与外景**:机库门外平滑衔接宽 60m、长 1500m 的混凝土起降跑道,带有中心虚线、跑道端头斑马线与嵌入式跑道灯阵列,周围包含草地地坪与开阔高空大气散射环境盒(Skybox)。
5. **地勤保障车组**:包含 1 台低矮型轮式无人战机牵引车(含可解脱刚性牵引杆与地面引导指示)。
---
## 2. 机械结构运动学与 Shader 视觉特效
1. **全动舵面铰接(Pivot)**:
- 全动鸭翼与双垂尾具备真实转轴中心,严禁中心原地自转;
- 武器弹舱(腹部 4 枚霹雳-15、侧弹舱 2 枚霹雳-10)支持机械平滑开闭与滑轨外翻。
2. **起落架液压与舱门联动**:
- 前后起落架支持收放缓动、扭臂折叠,起飞离地后舱门自动完全贴合锁死,无多余千斤顶或占位杆穿模。
3. **程序化 GLSL Shader 特效**:
- **双发尾焰**:包含内层亮蓝核心焰、外层高温渐变,加力时拉长并激发出周期性明亮【马赫环(Shock Diamonds)】;
- **气动凝结**:超音速突防时机身激发出【音爆水雾环(Prandtl-Glauert 奇点)】与翼尖半透明螺旋涡流条带;
- **座舱镀膜**:金色氧化铟锡(ITO)薄膜干涉彩虹菲涅尔 Shader,座舱内清晰可见飞行员 HUD 轮廓。
---
## 3. 飞行动力学与全自由度操控(重点:滚转与压坡度机制)
1. **控制输入规范**:
- `[W / S]`:俯仰控制(抬机头 / 压机头);
- `[A / D]` 或 `[← / →]`:滚转速率控制(Roll Rate 控制,按住以 ~240°/s 连续滚转,支持倒飞平飞);
- **压坡度转弯(Coordinated Turn)**:当飞机横滚产生坡度(Bank Angle)时,自动产生空气动力协调偏航力矩,实现压坡度转向;松开滚转键后保持当前滚转角;
- `[Q / E]`:全动双垂尾差动方向舵微调;
- `[Space]`(按住):全加力推力冲刺(具有强烈的加速度推背镜头震颤与 FOV 动态畸变拉伸)。
2. **多视角无缝切换**:
- `[V]` 键切换:【外场轨道追随第三人称视角】(鼠标可自由旋转环视并自动阻尼对准尾部)/【座舱第一视角(含动态俯仰地平仪 HUD)】/【塔台/跑道旁观固定机位】。
---
## 4. 全流程战备演练状态机(完整逻辑闭环)
系统内置状态机,支持通过悬浮工程 HUD 面板(无圆角、军工硬核风格)与快捷键控制:
1. **机库冷机状态**:战机静置于机库,发动机关闭,默认机位贴近飞机细节展示。
2. **机库点火测试**:按下【机库点火】按钮,双发慢车点火,尾喷口发出暗红渐亮火焰与微弱热扰动,伴随怠速发动机轰鸣。
3. **出库准备**:牵引车连接前起落架,将战机慢速牵引至机库外跑道等待线,随后完成【解脱脱钩】并驶离。
4. **滑行起飞**:推力增加,战机在跑道加速滑跑,抬前轮离地起飞,起落架平滑收起进入自由空域巡航。
5. **一键自动返航(降落并滑回)**:按下【自动返航】按钮,飞控系统接管姿态,自动规整航向并对准跑道五边进近下滑道,降落滑跑减速,放下起落架,脱离跑道并自动滑回机库停车。
---
## 5. 原生中文音频与工程要求
1. **原生音效引擎(Web Audio API)**:
- 纯程序化合成真实多频段喷气式发动机声音(怠速风扇声、加力燃烧室爆燃低频震颤、超音速掠过音爆啸叫)。
2. **中文语音播报**:
- 在点火、出库、起飞、开舱、超音速、返航等关键节点触发军工通信风格的中文合成提示语音。
3. **运行保障**:
- 避免内存泄漏,利用 InstancedMesh 合并渲染跑道灯光与管线节点,确保在普通显卡上维持稳定 60 FPS。
- 所有逻辑(HTML、CSS、JS、GLSL)封装在一个 `.html` 文件中,禁止使用外部 obj/gltf 或外部音频贴图链接。
这份提示词里最关键的其实是最后几段:要求“生成 → 检查 → 优化 → 再检查”的迭代闭环,并明确禁止把“能运行”当成交付标准。它把“必须避免因为使用 Three.js、单文件 HTML、浏览器运行环境或 60FPS 性能目标,而主动降低场景的视觉质量”写成了硬约束——这一条决定了后面所有的技术取舍。
二、工程方法:为什么不是一个 AI 写一个 HTML
单文件 2.29 MB、175 个模块、20 个 Agent 并行开发——如果让每个人直接改同一个 HTML,冲突会多到没法收场。所以第一件事不是建模,而是先建构建系统。
2.1 每人只写自己的小模块
每个成员只写 src/ 下属于自己的一两个文件,文件名用数字前缀决定加载顺序:
J20.register('light.alert', (ctx) => {
// 构建几何/材质,挂到 ctx 上供其他模块使用
return { update(dt, t, state) { /* 每帧逻辑 */ } };
});
/*__END__ light.alert*/
构建器把所有模块拼成一个 HTML。规则很硬:
- 文件末尾必须写
/*__END__ <注册名>*/结束标记; - 禁止出现
__PART、TODO、placeholder、占位等词,严格模式直接报错; - 单个文件不超过 100 行;
- 产物整体再做一次语法检查才写出。
最终产物:175 个模块、19783 行 JS、2.29 MB。
2.2 “构建通过 ≠ 模块生效”
这是踩出来的规矩。出现过两次典型事故:
一次是有人把注释里写了构建器的禁用词,宽容构建把整个布局模块悄悄剔掉了,页面报 core.stopIdle is not a function;另一次是某个模块文件语法正常、register 也在,但从未真正执行注册。
所以构建器后来加了一条:核心框架文件(00_/01_/02_core*)不合规时,宽容模式也直接失败,不再“悄悄摘掉”。验收也不能只看 exit code,必须独立核对模块注册表。
2.3 契约驱动:6 次布局修订
20 个人同时改一个三维场景,最贵的成本是“接口对不上”。做法是维护一份全局布局契约(00_core_layout.js),由核心框架统一发布,冲突由 Lead 裁定。开发过程中真实发生过 6 次修订,每次都是成员发现几何冲突后提出的:
| 版本 | 冲突 | 裁定 |
|---|---|---|
| v1.1 | 侧弹舱 2.60 m 装不下 3.0 m 的霹雳-10 | 舱段扩到 [+0.50, +3.80],导弹按真实 3.0 m 做 |
| v1.2 | 主弹舱与发动机重叠 1.8 m,导弹会插进风扇 | 弹舱前移到 [-0.40, +3.80],风扇面后移到 x=-1.0,进气道抬高压过弹舱 |
| v1.4 | 鸭翼转轴 z=±1.42 会让翼根整段埋进进气道外壁 | 转轴外移到 z=±1.78,根部挂在外壁上棱线外侧 |
| v1.6 | 场景要从“桌面沙盘”改成 1:1 真实机场 | 撤掉木桌、底座、地下管线、千斤顶等 35 个文件(移入 _retired/,不删除) |
这张表里最有价值的不是数值,而是这些冲突都是负责该部件的成员自己发现并带着数据上报的——因为契约是共享的,谁都能算出自己的零件跟别人撞了。
三、几次真正花时间的排查
3.1 机库被造大了 20 倍
现象:用户反馈“飞机太小了,门这么大,飞机都看不见”。
第一轮排查是错的。核心框架只在自己默认的近景镜头下看,回复“复现不出来,可能是旧版本缓存”。用户第二次反馈后强制刷新了还是那样。
于是换了个查法:用用户打开的同一个交付文件、同样的窗口尺寸,打开后不做任何操作,实测相机位置、飞机在屏幕上的像素宽度,以及场景里最大的 20 个物体各自的尺寸。
结果很干脆:
- 飞机是对的:机长 20.9 m、翼展 13.3 m、高 5.3 m,停在机库中心,机头朝门;
- 机库是错的:墙体约 810 × 414 × 797 m,门洞约 447 m 宽、290 m 高——而设计值是 40 × 34 × 13 m、门宽 30 m 高 10 m;
- 旧机库的地板埋在地下 26 m,被草地盖住,所以机库里看到的是草。
也就是说:飞机没错,是机库大了约 20 倍,飞机自然就成了画面中间一个小点。原因是机库模块还停留在早期“桌面微缩沙盘”的尺寸,没有跟着场景切换改成真实尺度。
修复之后还补了一个工具:尺寸检查器——扫出场景里尺寸明显不合理的物体。所有做场景的成员提交前都要先跑一遍。这类“某个零件用了旧尺度的常数”的错误,靠肉眼看截图很难发现,靠量尺寸一查就出来。
3.2 首帧 20.3 秒:着色器预编译白做了
全量截图一直报“ready 超时”。查下来的原因是:着色器预编译的时候没有绑定后处理用的离屏渲染目标,预编译出来的程序全用不上;结果首帧又同步重编了 85 个着色器,单帧卡了 20 秒。
修正后:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 首帧渲染 | 20.3 s | 0.7 s |
| 页面就绪 | 28.4 s | 8.2 s |
| 稳态帧率 | — | 60 fps(118 个模块) |
顺带还定了一条约束:切光照预设时只调灯光强度,不许开关灯、增删灯或改阴影投射——灯数或阴影数一变,全场材质会在运行时重编,又是一次卡顿。
3.3 帧率:从 41 fps 回到 60 fps,且不降画质
在一个 1060 级别的显卡上实测停机时只有 41 fps。定位方法是把分辨率减半看帧率——直接跳到 59 fps,说明瓶颈在像素着色而不是几何。然后按模块拆开销,逐个交给负责人:
| 开销来源 | 做法 | 效果 |
|---|---|---|
| 14 盏实时灯 | 合并灯光 | 最大的一项 |
| 远景地形与云 | 被遮挡部分不算颜色、按距离逐级省略细节 | 地形 7.8 ms → 1.5~1.9 ms;机库内云层 2.9 ms → 0.07 ms |
| 发动机(17.7 万面) | 远处用简化模型、叶片批量绘制、剔除看不见的内部件 | — |
| 弹舱(11.4 万面) | 舱门关闭时不画舱内导弹 | — |
| 座舱(13.5 万面) | 外部视角按距离逐级隐藏小件(6 m 外隐藏安全带/操纵杆,9 m 外隐藏彩色按键) | 13.5 万 → 9.7 万面,外部默认视角可见面数 -53% |
整帧渲染时间:15.9 ms → 停机 9.8 ms / 门外 5.9 ms / 巡航 6.8 ms。
这里的取舍原则是:功能完整性、视觉精细度和性能冲突时,用技术手段解决,而不是删场景内容。所以上面全部是合批、实例化、LOD、视锥裁剪,没有一处是“把细节删掉”。
3.4 灯塔长在滑行道上:探针必须先证明自己能报错
用户反馈“这个杆子会让飞机机身碰到”。
查下来是停机坪上的四座 25 m 高泛光灯塔,每座离机坪角只有 6 m,正好落在滑行路线的转弯处;而返航滑行路线又是切机坪角走的,于是飞机直接穿杆而过。其中 (104, 49) 那座干脆就站在 x=110 滑行道正中间——就算严格沿中心线走也会撞。
修复是双管齐下:塔移到草地、离任何滑行中心线至少 15 m;滑行路线改成全程沿中心线,转弯半径 40 m,机坪上掉头后停在 (86, 0) 朝机库。
但真正值得记下来的是验收方式。测试跑完报告“138 个用例全部通过、最近距离 4.51 m、所有灯塔都在 25 m 以上”——问题是,这个检查本身从没被证明过能报错。
于是做了一次反向验证:把灯塔放回原来的位置,再跑一遍。结果报了 -0.74 m(碰撞)。
这才算通过:一个从来没返回过失败的检查,它的绿灯没有信息量。
3.5 五边下面有一座山
自动返航做完之后,负责空中制导的成员发现:跑道 36 的五边进近方向上有一道约 150 m 高的小山脊。就算严格沿标准 3° 下滑道飞,在 x≈-2500 处主轮离地也只剩约 13 m,会擦到山。
给了两个方案:
- A:改地形,在跑道延长线方向开进近净空走廊;
- B:不改地形,由制导根据实测地形自动加大下滑角(要用到 4°,就不再是标准进近了)。
选了 A,B 保留作兜底。这里有个坑:地形是在显卡上做位移的,飞行用的地形高度和画面渲染的地形必须一起改,否则会出现“画面里穿山、飞机悬空”。所以改完要沿走廊逐点核对两者一致,再截图确认看不出接缝。
四、飞行手感:从“坡度指令”改回“滚转速率指令”
第一版为了键盘好转弯,把左右键做成了坡度指令:按到底最多压到 65° 坡度就停住,一松手自动改回机翼水平。
用户问了一句:“飞行时按左右键飞机不能翻的吗?正常飞机可以翻吗?”
这就是个真问题。电传战斗机的真实手感是滚转速率指令:压杆的程度决定滚多快,一直压着就一直滚,松手保持当前姿态(包括倒飞)。于是改成:
- 按住 A/D(或 ←/→)持续滚转,最大约 240°/s;
- 松手保持当前姿态,不自动改平;
- 支持连续横滚和倒飞。
紧接着用户又想到一个连带问题:“如果左右键翻滚,那怎么控制改变方向?”——答案是真飞机就是这么转弯的:压坡度产生侧倾,机翼升力斜过来,飞机自动朝那一侧转。于是把“压坡度自动转弯”补进了同一个任务。
改完的实测数据:
| 功能 | 操作 | 实测 |
|---|---|---|
| 连续翻滚 | 按住 A/D | 最快 240°/s,按 1.5 秒滚过约 342°,左右一致 |
| 松手保持姿态 | 松开左右键 | 停在当前角度,不自动改平 |
| 倒飞 | 滚到肚皮朝天松手 | 保持 20 秒,倾斜角稳定在 178°,高度只掉约 40 m |
| 转弯 | 压 60° 坡度后松手 | 自动转弯约 4°/s,10 秒掉 29 m |
| 急转 | 压坡度后再按住 S | 16°~24°/s,最大过载约 8.8g |
有一条刻意保留的“缺陷”:机翼接近竖直(约 70°~110°)时松手,飞机每秒往下掉约 15 m。这是真实物理——机翼竖着就没有向上的升力了。没有为了“好开”去把它抹平。
五、自动返航:139 个用例
返航是整个项目里最难的一段。第一版的做法是 “朝一个写死的点飞” :进近航路点固定为 (-3500, 1800)、(-4200/-4500, 250)。这种做法在几种情况下必然失效:
- 飞机在跑道西侧很远、或已经飞过五边截获点 → 绕着那个点打转,永远进不了五边;
- 飞机在跑道南北两侧很远、正在跑道上空、姿态速度极端 → 完全没有处理;
- 着陆没有复飞逻辑,偏离下滑道就会在跑道外接地或冲出跑道;
- 地面滑回路线也是写死的,接地后停远了或停近了,路线接不上。
重构思路是按飞机当前位置、航向、速度动态规划:先用转弯半径算平滑的切线航线,把姿态改平、降到返航速度,再截获 3° 下滑道,进近不稳就复飞重新进近。地面段则根据实际停下的位置选最近的脱离道,沿真实滑行道滑回。
验收方式也值得说:写了个批量测试工具,覆盖 8 个方位 × 3 个距离(3/15/40 km)× 2 个高度 × 2 个航向,另加跑道正上方、逆向、超音速、大坡度、山区、z=±25 km 等特殊情况。每个场景输出成功与否、接地点、下沉率、最终停机误差。
验收标准:
- 每个场景都在跑道内接地,偏离中线 < 8 m,接地下沉率 < 3 m/s;
- 最终停回机库停机点,位置误差 < 0.5 m,航向误差 < 2°。
最终 139 个用例全部通过,包括“在倒飞状态按返航”——飞机会先自己滚正,再正常落地。加上前面提到的障碍物间隙检查,全程离最近障碍物 4.51 m。
六、声音也是程序化的
单文件、零外部资源,所以音频也不能是音频文件——发动机声音是用 Web Audio API 实时合成的:慢车风扇声、加力燃烧室低频爆燃、超音速通场的多普勒频移和音爆啸叫,并且随飞机位置、镜头距离、是否在座舱内、是否在机库内(混响)而变化。
中文配音是另一个路子:写军航口令台本(呼号“威龙洞幺”,数字按“幺两拐洞”读法,地面席管开车滑行、塔台管进跑道起飞通场、飞行员复诵),生成 63 条神经语音后以 base64 内嵌进单文件,体积增加控制在 700 KB 以内。播放引擎负责“一条指令后接复诵”的顺序,过时台词丢掉,无线电和旁白不同时说话。
一个绕不开的浏览器限制:页面必须先有一次点击或按键才允许出声,所以打开页面后第一次操作前是没有配音的。
七、可以带走的几条经验
- 先建构建系统,再建场景。 多人(或多 Agent)改同一个单文件产物,没有构建器就一定收不了场。
- “构建通过”不等于“代码生效”。 必须独立核对模块注册表——文件语法正常、注册语句也在,但从未执行的情况真的会发生。
- 共享契约 + 冲突裁定,比“各写各的然后对齐”便宜得多。 20 个并行工作者能自己算出几何冲突,前提是大家读同一份尺寸常量。
- “看起来不对”要变成“量出来不对”。 机库大了 20 倍这件事,肉眼看截图只能得出“飞机好像有点小”,量尺寸一秒定位。所以后来专门做了尺寸检查器。
- 性能优化先定位瓶颈在哪一类资源上(分辨率减半看帧率是个便宜好用的分水岭),再分给对应模块,而不是全局降画质。
- 验收判据本身要先被证明能报错。 障碍物检查通过之后,把灯塔放回原位再跑一遍——报了 -0.74 m,这个绿灯才可信。
- 不要用“这是 Demo/浏览器性能有限/单文件限制”当借口降质。 这条写在最初的提示词里,也是整个项目所有技术取舍的前提。
在线体验:https://j20.dzt.cool/
技术栈:Three.js r160 + Web Audio API + 自定义 GLSL,单文件 HTML,零外部资源
规模:175 个模块 / 19783 行 JS / 2.29 MB 单文件








