ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

33个代理并行开发:three.js与WebGL的3D动作游戏原型实战

33个代理并行开发:three.js与WebGL的3D动作游戏原型实战

1. 从一句需求到可玩原型:这个项目到底在做什么

先交代背景。前阵子我在折腾一个 3D 动作游戏的网页原型,需求特别简单,就一句话:“做一个能跑能跳能打怪的 3D 动作小游戏,浏览器打开就能玩。”没有美术素材,没有模型文件,没有音效,连一张贴图都没有。手头只有一台装了 Claude Code 的机器,和一个空文件夹。

结果呢,Claude Code 自己拆任务、自己派代理,前前后后拉起了 33 个代理并行干活,最后交付了一个能直接在浏览器里跑起来的 3D 动作游戏原型。核心渲染靠 three.js,底层是 WebGL,整个项目从零素材起步,几何体是代码生成的,材质是程序化的,动画是数学算出来的。

这件事真正值得聊的不是“AI 帮我写了个游戏”这种标题党,而是它背后那套多代理协作的工程组织方式。33 个代理不是噱头,它对应的是 33 个可以并行、可以隔离、可以独立验证的子任务。这套思路你完全可以迁移到自己的项目里——不管你是做 3D 网页、做后端服务,还是做数据处理流水线。

这篇文章我会把整个项目拆开讲:为什么这么设计、每个环节怎么落地、参数怎么算、坑在哪里。适合三类人看:想入门 three.js 和 WebGL 的前端、对多代理协作感兴趣的技术人、以及想用 AI 工具把想法快速变成可玩原型的产品或独立开发者。哪怕你完全没碰过 3D,我也会用生活化的类比把关键概念讲清楚。

2. 整体架构设计:为什么是 33 个代理而不是 1 个

2.1 单代理为什么扛不住这个任务

一个 3D 动作游戏,哪怕是最小可玩原型,拆开来看至少包含这些互相独立的模块:场景与相机、光照系统、角色控制器、物理与碰撞、动画状态机、敌人 AI、战斗判定、UI 与 HUD、输入系统、资源生成、性能优化、构建与打包。这还没算测试和调试。

如果让单个代理从头写到尾,会遇到两个致命问题。第一是上下文爆炸:3D 代码里几何、矩阵、着色器、状态机混在一起,单个上下文窗口很快被塞满,写到后面它就开始遗忘前面的接口约定,函数签名对不上。第二是串行阻塞:角色控制器没写完,敌人 AI 就没法基于角色位置做追踪逻辑,整条链路卡死。

我实测过,单代理写这种项目,写到第 6 到第 8 个文件时就开始出现“接口漂移”——前面定义的角色对象是{position, velocity},后面突然变成{pos, vel},然后运行直接报错。这不是模型能力问题,是任务粒度和上下文管理的问题。

2.2 多代理并行的核心逻辑

33 个代理的本质,是把一个大任务切成 33 个边界清晰、接口明确、可独立验证的子任务。每个代理只关心自己那一小块,输入是明确的接口约定,输出是可测试的产物。

打个比方,这就像装修一套房子。你不会让一个工人从水电到油漆全包,而是水电工、木工、油漆工各管一摊,中间靠图纸(接口约定)对齐。图纸定得越清楚,各工种越能并行,返工越少。

具体到这个项目,代理大致分成几类:

代理类型数量(约)职责产出物
架构代理2定义目录结构、模块接口、数据流接口文档、类型定义
场景代理4相机、光照、天空盒、地面场景初始化模块
角色代理5模型生成、控制器、动画状态机角色系统
战斗代理4攻击判定、受击反馈、血量战斗系统
敌人代理4生成、追踪、攻击 AI敌人系统
UI 代理3HUD、血条、菜单UI 模块
输入代理2键盘、鼠标、触屏输入系统
资源代理3程序化几何、材质、音效资源生成器
测试代理3单元测试、集成测试、性能测试报告
集成代理3合并、冲突解决、构建可运行产物

这个划分不是拍脑袋来的,核心原则是高内聚低耦合:每个代理的产出能被单独验证,且不依赖其他代理的内部实现,只依赖接口。

2.3 接口先行:多代理不打架的关键

多代理最容易翻车的地方是接口不一致。我的做法是让架构代理先产出一份接口契约,用 TypeScript 的类型定义固定下来。比如角色对象长这样:

interface Character { id: string; position: THREE.Vector3; velocity: THREE.Vector3; health: number; maxHealth: number; state: 'idle' | 'run' | 'jump' | 'attack' | 'hurt' | 'dead'; mesh: THREE.Object3D; update(delta: number): void; takeDamage(amount: number): void; }

所有涉及角色的代理,都必须按这个契约来。角色代理实现update,战斗代理调用takeDamage,UI 代理读取health。谁都不许改字段名,要改先提出来统一改。

提示:接口契约一定要在派代理之前定死。我踩过的坑是边写边改接口,结果三个代理同时改了position的类型,合并时冲突到怀疑人生。

3. 核心技术点拆解:three.js 与 WebGL 到底怎么用

3.1 WebGL 和 three.js 的关系,一句话讲清

WebGL 是浏览器提供的底层 3D 绘图接口,你可以把它理解成“浏览器内置的画笔”,但它是那种特别原始的画笔——画一个三角形要写几十行代码,设置顶点、编译着色器、绑定缓冲区。three.js 是在 WebGL 之上封装的库,相当于给你一套“预制积木”,相机、灯光、材质、模型都是现成的对象,你拼装就行。

这个项目选 three.js 而不是裸写 WebGL,理由很直接:动作游戏需要快速迭代,裸写 WebGL 光是搭个渲染管线就要大半天,而 three.js 几行代码就能出一个带光照的场景。性能上 three.js 对常规场景完全够用,真到了瓶颈再针对性优化底层。

3.2 场景三件套:场景、相机、渲染器

任何 three.js 项目的起点都是这三样东西。我用生活类比解释:场景(Scene)是舞台,相机(Camera)是观众的眼睛,渲染器(Renderer)是摄影师,负责把舞台上的东西拍下来显示到屏幕上。

import * as THREE from 'three'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x87ceeb); const camera = new THREE.PerspectiveCamera( 60, // 视野角度 window.innerWidth / window.innerHeight, // 宽高比 0.1, // 近裁剪面 1000 // 远裁剪面 ); camera.position.set(0, 5, 10); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement);

这里有几个参数值得说清楚。视野角度(FOV)选 60 度是动作游戏的常见值,太小视野窄像望远镜,太大边缘畸变严重。近裁剪面 0.1意味着离相机 0.1 单位以内的东西不渲染,防止相机穿模时看到内部。远裁剪面 1000是渲染距离上限,设太大浪费性能,设太小远处物体突然消失。

setPixelRatio那行是个性能关键点。高分屏设备devicePixelRatio可能是 3,意味着要渲染 3 倍像素,性能直接掉一半。用Math.min(..., 2)把它压到 2 以内,视觉几乎无差别,帧率能稳不少。

3.3 程序化几何:零素材怎么造出角色和场景

没有模型文件,所有东西都得用代码生成。three.js 内置了一批基础几何体:BoxGeometry(盒子)、SphereGeometry(球)、CapsuleGeometry(胶囊)、CylinderGeometry(圆柱)。角色用胶囊加球体拼,敌人用盒子加球体拼,地面用一个大平面。

// 角色身体:胶囊体 const bodyGeo = new THREE.CapsuleGeometry(0.4, 1.0, 8, 16); const bodyMat = new THREE.MeshStandardMaterial({ color: 0x3366ff }); const body = new THREE.Mesh(bodyGeo, bodyMat); body.position.y = 1.0; // 角色头:球体 const headGeo = new THREE.SphereGeometry(0.35, 16, 16); const headMat = new THREE.MeshStandardMaterial({ color: 0xffcc99 }); const head = new THREE.Mesh(headGeo, headMat); head.position.y = 1.9; const character = new THREE.Group(); character.add(body, head); scene.add(character);

CapsuleGeometry的参数是(半径, 圆柱段长度, 径向分段, 高度分段)。分段数决定圆滑程度,8 和 16 是性能和观感的平衡点。分段太高,一个角色几千个三角形,几十个敌人就卡了;分段太低,看起来像多面体。

注意:程序化几何虽然省素材,但顶点数要控制。我一开始给球体用了 64 分段,场景里 20 个敌人直接掉到 20 帧。降到 16 分段后回到 60 帧,肉眼几乎看不出区别。

3.4 光照系统:让几何体有立体感

没有光照,所有几何体都是纯色平面,毫无立体感。three.js 常用三种光:环境光(AmbientLight)均匀照亮全场,平行光(DirectionalLight)模拟太阳,点光源(PointLight)模拟灯泡。

const ambient = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient); const sun = new THREE.DirectionalLight(0xffffff, 1.0); sun.position.set(10, 20, 10); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.left = -30; sun.shadow.camera.right = 30; sun.shadow.camera.top = 30; sun.shadow.camera.bottom = -30; scene.add(sun);

环境光强度 0.6 是基础照明,保证背光面不会全黑。平行光强度 1.0 提供主光源和阴影。castShadow开启阴影后,shadow.mapSize决定阴影清晰度,2048 是画质和性能的平衡点,1024 会糊,4096 太吃性能。阴影相机的范围要覆盖游戏区域,设太大阴影会糊,设太小边缘物体没阴影。

4. 实操过程:从空文件夹到可玩原型

4.1 环境搭建与项目初始化

第一步是搭环境。Claude Code 的安装这里不展开,重点说项目本身。用 Vite 起项目是最省事的,热更新快,构建也简单。

npm create vite@latest action-game -- --template vanilla cd action-game npm install three npm run dev

目录结构我让架构代理定成这样:

action-game/ ├── src/ │ ├── main.js # 入口 │ ├── core/ # 核心系统 │ │ ├── scene.js # 场景管理 │ │ ├── camera.js # 相机控制 │ │ └── loop.js # 主循环 │ ├── entities/ # 实体 │ │ ├── character.js # 角色 │ │ └── enemy.js # 敌人 │ ├── systems/ # 系统 │ │ ├── input.js # 输入 │ │ ├── physics.js # 物理 │ │ ├── combat.js # 战斗 │ │ └── animation.js # 动画 │ ├── ui/ # 界面 │ │ └── hud.js # HUD │ └── utils/ # 工具 │ └── math.js # 数学辅助 └── index.html

这个结构的好处是每个目录对应一类代理的产出,合并时冲突面小。core是基础设施,entities是游戏对象,systems是逻辑处理,ui是界面,utils是纯函数工具。

4.2 主循环:游戏的心脏

游戏能跑起来,靠的是一个持续运行的循环,每一帧做三件事:处理输入、更新状态、渲染画面。

const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); const delta = clock.getDelta(); input.update(); character.update(delta); enemies.forEach(e => e.update(delta)); combat.update(delta); animation.update(delta); renderer.render(scene, camera); } animate();

requestAnimationFrame让浏览器在每次重绘前调用这个函数,通常每秒 60 次。clock.getDelta()返回距上一帧的秒数,这个值至关重要——所有运动都必须乘以 delta,否则在不同刷新率的设备上速度会不一样。60Hz 屏幕上每秒跑 60 帧,144Hz 屏幕上每秒跑 144 帧,如果不乘 delta,高刷设备上角色会跑得飞快。

提示:delta一定要做上限保护。切标签页回来时 delta 可能是好几秒,角色会瞬移。我一般写const delta = Math.min(clock.getDelta(), 0.1)。

4.3 角色控制器:让角色动起来

角色控制器负责把键盘输入转成移动。核心是速度向量和位置更新。

const MOVE_SPEED = 6; const JUMP_FORCE = 8; const GRAVITY = -20; class Character { constructor() { this.position = new THREE.Vector3(0, 0, 0); this.velocity = new THREE.Vector3(0, 0, 0); this.onGround = true; } update(delta) { const input = getInputDirection(); this.velocity.x = input.x * MOVE_SPEED; this.velocity.z = input.z * MOVE_SPEED; if (input.jump && this.onGround) { this.velocity.y = JUMP_FORCE; this.onGround = false; } this.velocity.y += GRAVITY * delta; this.position.addScaledVector(this.velocity, delta); if (this.position.y <= 0) { this.position.y = 0; this.velocity.y = 0; this.onGround = true; } this.mesh.position.copy(this.position); } }

参数怎么定的?MOVE_SPEED = 6意味着每秒移动 6 个单位,场景里角色高约 2 单位,这个速度跑起来有动感但不失控。JUMP_FORCE = 8配合GRAVITY = -20,跳跃高度是v²/(2g) = 64/40 = 1.6单位,差不多是角色身高的 0.8 倍,跳得过小障碍。重力设 -20 而不是真实的 -9.8,是因为游戏手感需要更“干脆”的下落,真实重力会显得飘。

4.4 战斗判定:距离检测加状态机

动作游戏的战斗判定,最朴素也最可靠的做法是距离检测。攻击时检测角色前方一定范围内有没有敌人,有就扣血。

const ATTACK_RANGE = 2.5; const ATTACK_ANGLE = Math.PI / 3; const ATTACK_DAMAGE = 25; function performAttack(character, enemies) { const forward = new THREE.Vector3(0, 0, 1) .applyQuaternion(character.mesh.quaternion); enemies.forEach(enemy => { const toEnemy = enemy.position.clone().sub(character.position); const distance = toEnemy.length(); if (distance > ATTACK_RANGE) return; const angle = forward.angleTo(toEnemy.normalize()); if (angle > ATTACK_ANGLE) return; enemy.takeDamage(ATTACK_DAMAGE); }); }

ATTACK_RANGE = 2.5是攻击距离,比角色体型略大,符合“挥剑”的直觉。ATTACK_ANGLE = 60度是攻击扇形的一半,意味着攻击覆盖前方 120 度范围,背后打不到。ATTACK_DAMAGE = 25配合敌人 100 血,四下打死一个,节奏不拖沓。

用扇形而不是圆形判定,是为了让“背刺”和“绕后”有战术意义。如果做成圆形,转身打和正面打没区别,动作游戏就失去了方向感。

4.5 敌人 AI:状态机驱动的追踪与攻击

敌人 AI 用状态机实现,三个状态:巡逻、追踪、攻击。

const DETECT_RANGE = 12; const ATTACK_RANGE = 2.0; const ENEMY_SPEED = 3; class Enemy { update(delta, player) { const toPlayer = player.position.clone().sub(this.position); const distance = toPlayer.length(); switch (this.state) { case 'patrol': if (distance < DETECT_RANGE) this.state = 'chase'; break; case 'chase': if (distance > DETECT_RANGE * 1.5) { this.state = 'patrol'; } else if (distance < ATTACK_RANGE) { this.state = 'attack'; } else { toPlayer.normalize(); this.position.addScaledVector(toPlayer, ENEMY_SPEED * delta); } break; case 'attack': if (distance > ATTACK_RANGE * 1.2) { this.state = 'chase'; } else { this.tryAttack(delta); } break; } } }

这里有个细节:脱离追踪的距离是DETECT_RANGE * 1.5,比进入追踪的距离大。这叫迟滞(hysteresis),防止敌人在边界反复横跳——刚进范围就追,刚出范围就停,来回抖动。攻击范围同理,进入是 2.0,退出是 2.4,避免攻击状态频繁切换。

5. 多代理协作的实操细节与踩坑记录

5.1 代理任务书怎么写才不返工

派代理之前,每个代理都要拿到一份任务书,包含四要素:输入接口、输出接口、验收标准、禁止事项。我举个真实的任务书片段:

任务:实现角色跳跃物理 输入:Character 接口(见 types.ts),delta 时间步长 输出:更新后的 position.y 和 velocity.y 验收:从地面起跳,最高点约 1.6 单位,落地后 onGround 为 true 禁止:修改 Character 接口字段名;引入外部物理库

“禁止事项”这一条特别重要。不写清楚,代理可能自作主张引入 cannon.js 或 ammo.js 这种物理引擎,结果和现有代码风格冲突,合并时全是麻烦。

5.2 合并冲突怎么处理

33 个代理并行产出,合并时冲突不可避免。我的经验是冲突集中在三类文件:入口文件、类型定义、共享工具。解决办法是这三类文件只让一个代理(集成代理)改,其他代理只读不写。

具体操作上,每个代理在自己的分支上工作,产出独立模块文件。集成代理负责把这些模块 import 进主入口。这样即使两个代理都改了main.js,也只有一个版本会被采纳,不会产生 Git 冲突。

提示:让代理产出纯函数模块能大幅降低合并难度。纯函数没有副作用,输入输出明确,拼装时几乎不用改。

5.3 性能优化:从 30 帧到 60 帧

原型跑起来后第一版只有 30 帧左右,卡顿明显。排查下来三个问题:

第一是几何体重复创建。每个敌人都 new 了一个SphereGeometry,20 个敌人就是 20 份几何数据。改成共享几何体后,内存和绘制调用都降了。

// 错误做法:每个敌人独立几何 enemies.forEach(e => { e.mesh = new THREE.Mesh(new THREE.SphereGeometry(0.5), mat); }); // 正确做法:共享几何和材质 const sharedGeo = new THREE.SphereGeometry(0.5, 16, 16); const sharedMat = new THREE.MeshStandardMaterial({ color: 0xff0000 }); enemies.forEach(e => { e.mesh = new THREE.Mesh(sharedGeo, sharedMat); });

第二是阴影贴图过大。一开始用了 4096,改成 2048 后帧率提升明显,画质几乎无损。

第三是每帧创建向量对象。new THREE.Vector3()在 update 里被调用几百次,触发大量垃圾回收。改成复用预分配的临时向量后,帧率稳定了很多。

优化项优化前优化后帧率变化
几何体共享每敌人独立全局共享+8 帧
阴影贴图40962048+10 帧
向量复用每帧新建预分配+12 帧

6. 常见问题与排查技巧实录

6.1 画面全黑或物体不显示

这是 three.js 新手最常见的问题。排查顺序:先看相机位置和朝向对不对,再看物体是否在相机视锥内,最后看材质是否需要光照。

如果用了MeshStandardMaterial但没加任何光源,物体会全黑。解决方法是加环境光,或者临时换成MeshBasicMaterial验证几何体是否真的存在。

6.2 贴图不显示

贴图不显示通常是三个原因:路径错误、跨域限制、纹理未更新。路径问题看控制台报错;跨域问题需要服务器配置 CORS;纹理更新问题在修改贴图后要设texture.needsUpdate = true。

这个项目因为是程序化生成,没用到外部贴图,反而避开了这类坑。这也是零素材项目的一个意外好处——少了一堆资源加载的破事。

6.3 帧率骤降的排查思路

帧率突然掉下来,按这个顺序查:打开浏览器开发者工具的 Performance 面板录制几秒,看哪一步耗时最长。常见元凶是每帧创建对象、绘制调用过多、阴影计算过重。

一个快速判断方法:把阴影关掉看帧率是否恢复,如果恢复就是阴影问题;把敌人数量减半看是否恢复,如果恢复就是绘制调用问题。

6.4 常见问题速查表

现象可能原因排查方法解决方案
画面全黑无光源/相机朝向错加环境光测试补光源或调相机
物体不显示在视锥外/被裁剪打印物体坐标调整位置或裁剪面
帧率低对象频繁创建Performance 录制复用对象
角色速度异常未乘 delta检查 update所有运动乘 delta
跳跃穿地未做地面检测打印 position.y加地面碰撞
敌人抖动状态频繁切换打印状态变化加迟滞区间

6.5 几个只有踩过才知道的坑

第一个坑:requestAnimationFrame在标签页隐藏时会暂停,切回来时 delta 巨大。必须做 delta 上限保护,否则角色瞬移穿墙。

第二个坑:相机跟随要做平滑。直接camera.position.copy(player.position)会让人晕,要用插值camera.position.lerp(target, 0.1),跟随有延迟才自然。

第三个坑:输入状态要每帧重置。“这一帧按了跳跃键”和“跳跃键当前是按下状态”是两回事。前者用于触发一次性动作,后者用于持续移动。混用会导致按住跳跃键角色一直跳。

第四个坑:多代理产出的代码风格要统一。有的代理用分号,有的不用;有的用箭头函数,有的用 function。合并前跑一遍 ESLint 自动格式化,能省大量手动调整。

7. 这套方法能迁移到哪里

33 个代理做 3D 游戏只是个例子,真正有价值的是这套任务拆分加接口契约加并行执行加集成验证的方法论。你完全可以把它用到别的场景。

比如做一个数据可视化大屏,可以拆成数据处理代理、图表渲染代理、交互代理、样式代理、性能代理。做一个后端 API 服务,可以拆成路由代理、业务逻辑代理、数据库代理、鉴权代理、测试代理。做一个自动化脚本,可以拆成解析代理、转换代理、输出代理。

关键就三条:接口先定死、任务边界清晰、每个产出可独立验证。做到这三条,代理数量是 3 个还是 33 个,只是并行度的问题,不会影响最终能不能拼起来。

我自己在实际操作中的体会是,代理数量不是越多越好。任务拆得太细,接口对齐的成本会超过并行带来的收益。33 个代理对这个规模的 3D 游戏刚好,再拆就过度了。判断标准很简单:如果一个子任务小到“写一个函数”,那它就不该单独派一个代理,应该合并到相邻任务里。

最后分享一个实用技巧:让每个代理在产出代码的同时,附上一段自测说明,写清楚“我怎么验证这个模块是好的”。集成时代理按这些说明逐个验证,比盲目跑整个项目高效得多。这个习惯一旦养成,多代理协作的返工率能降一大半。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进