首页 / 资讯中心 / 文章详情

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

33个代理并行开发:three.js与WebGL的3D动作游戏原型实战 ★ FEATURED ARTICLE
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战斗代理调用takeDamageUI 代理读取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 vitelatest 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.tsdelta 时间步长 输出更新后的 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 了一个SphereGeometry20 个敌人就是 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 帧阴影贴图4096204810 帧向量复用每帧新建预分配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 游戏刚好再拆就过度了。判断标准很简单如果一个子任务小到“写一个函数”那它就不该单独派一个代理应该合并到相邻任务里。最后分享一个实用技巧让每个代理在产出代码的同时附上一段自测说明写清楚“我怎么验证这个模块是好的”。集成时代理按这些说明逐个验证比盲目跑整个项目高效得多。这个习惯一旦养成多代理协作的返工率能降一大半。
阅读完成 · 觉得有帮助?
咨询建站