ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Three.js与Cannon.js的开放世界3D游戏框架设计与实现

基于Three.js与Cannon.js的开放世界3D游戏框架设计与实现 简介这是一套面向前端开发者与3D Web应用学习者的开放世界基础框架源码专为快速构建具备物理交互能力的浏览器端3D场景而设计解决初学者在three.js与cannon.js协同开发中常见的架构缺失、物理集成困难及工程化不足等问题。资源共237个文件包含181个TypeScript核心模块涵盖场景管理、实体系统、物理同步、加载器等、19个JavaScript辅助脚本、9个GLB三维模型、9个CSS样式表如loadingScreen.css、welcomeScreen.css等支撑UI与渲染层定制、5个PNG纹理资源、4个JSON配置用于场景参数与实体定义以及2个WebAssembly模块加速物理计算。压缩包大小66.09MB结构规范含完整webpack构建配置、tsconfig.json与.gitattributes等工程化支持文件。目前已有284人学习下载读者可直接复用模块化架构、参考真实GLB模型集成流程、借鉴CSS驱动的3D界面设计实践并基于TypeScript强类型体系快速扩展自定义功能。1. 项目概述从零构建一个可玩的3D世界最近在整理过去的项目资料翻出了一个几年前做的“玩具”——一个基于Three.js和Cannon.js的开放世界基础框架。当时做这个的初衷很简单就是想验证一下仅靠前端技术栈能否在浏览器里搭出一个具备基础物理交互、可自由探索的3D世界雏形。没想到这个“玩具”后来成了我理解WebGL游戏开发、物理引擎集成以及大型场景管理的一个绝佳起点。所谓“开放世界基础框架”听起来挺唬人其实核心目标就三个第一得能把一个大地图流畅地渲染出来山是山水是水树是树第二得有个“我”在里面走跑跳并且撞到墙会停下掉下悬崖会摔下去这就是物理交互第三这个世界不能是静态的得能动态加载东西比如走近了房子才显示细节走远了就简化甚至消失以保证性能。Three.js负责解决第一个“看”的问题Cannon.js则包揽了第二个“碰”的问题而第三个“优化”的问题就需要我们自己设计一套管理逻辑了。这个框架的源码本质上是一套组织代码的模式和一系列解决特定问题的工具函数。它不适合直接拿去开发现成的商业游戏但非常适合想要深入理解3D Web游戏开发、有志于自己创造小型虚拟世界的开发者。如果你已经会点Three.js想了解怎么把物理引擎整合进来或者好奇大型场景是怎么被拆解和管理的那么这套设计思路和源码剖析应该能给你不少启发。接下来我就把这个框架的核心设计和实现细节拆开揉碎了讲一讲。2. 核心架构设计渲染、物理与逻辑的三角平衡设计一个框架尤其是涉及实时渲染和物理模拟的框架首要任务不是写代码而是划分边界、定义数据流。我的核心设计原则是渲染、物理、游戏逻辑三权分立通过一个中央状态管理器进行通信。这听起来有点抽象我画个简单的数据流图大家就明白了。游戏逻辑 (输入、状态机、AI) - 更新实体状态 - 中央状态管理器 | 物理引擎 (Cannon.js World) - 同步状态位置、旋转- 中央状态管理器 | 渲染引擎 (Three.js Scene) - 同步状态矩阵、可见性- 中央状态管理器这个架构最大的好处是解耦。Three.js和Cannon.js是完全独立的两个世界。Three.js关心的是如何用最少的Draw Call把画面画得漂亮Cannon.js关心的是如何快速稳定地计算刚体运动与碰撞。它们不应该直接对话。例如当玩家按下“W”键时游戏逻辑系统接收到输入计算出“玩家想向前移动”。这个指令被转化为对玩家实体在“逻辑状态”上的修改意向如设置一个目标速度。中央状态管理器将这个意向同步给物理引擎。Cannon.js根据这个速度、当前受力、碰撞情况通过积分计算出一帧后玩家物理身体Cannon.Body的真实新位置和旋转。物理引擎计算完毕后将新的位置和旋转数据写回中央状态管理器。渲染循环在下一帧从中央状态管理器读取这个最终确定的位置和旋转然后更新Three.js中对应模型Three.Mesh的矩阵使其被绘制到正确的位置。注意这里有一个关键细节逻辑“想”让玩家移动但物理引擎“决定”玩家最终能移动到哪。比如玩家想穿墙逻辑发出了指令但物理引擎通过碰撞检测阻止了这次移动并将身体卡在墙边。这个“裁决权”必须交给物理引擎否则就会出现穿模的Bug。2.1 实体-组件系统的轻量级实践为了实现这种分离我采用了一种简化版的ECS实体-组件-系统模式。注意这不是一个完整的、复杂的ECS框架而是一种组织思路。实体Entity就是一个唯一的ID和一个空容器。它本身什么都不是只是一个标签比如Entity#1024代表玩家Entity#1025代表一棵树。组件Component是附着在实体上的数据块。我定义了最核心的几种TransformComponent: 存储实体的位置、旋转、缩放。这是唯一真相源物理和渲染都以此为准。ThreeComponent: 存储对Three.js中对应Mesh、Group等对象的引用。CannonComponent: 存储对Cannon.js中对应Body刚体的引用。PlayerComponent: 一个标签标记此实体是玩家包含一些玩家专属数据如生命值、背包。StaticComponent: 标记实体是静态的如地形、建筑物理引擎可以对其做优化。系统System是处理拥有特定组件组合的实体的函数集合。它们在一个游戏循环中按顺序执行InputSystem: 处理用户输入更新玩家的TransformComponent意向。PhysicsSystem: 遍历所有拥有CannonComponent的实体驱动Cannon.js世界步进world.step()然后将Cannon.Body的最新位姿同步回实体的TransformComponent。RenderSystem: 遍历所有拥有ThreeComponent的实体根据其TransformComponent的数据更新Three.js对象的矩阵。这种设计让添加新功能变得非常清晰。比如我想给怪物加一个“寻路”功能我只需要创建一个AIControllerComponent组件里面存着目标点、移动速度等数据。创建一个AISystem在PhysicsSystem之前运行。它遍历所有拥有AIControllerComponent和TransformComponent的实体计算移动方向并修改TransformComponent的速度意向。剩下的物理裁决和渲染同步现有的PhysicsSystem和RenderSystem就自动处理了我完全不用碰Three.js或Cannon.js的代码。3. 开放世界场景管理与动态加载开放世界意味着巨大的地图不可能一次性把所有的模型、纹理都加载到内存和显存里。我的框架实现了一个基于视锥体剔除和动态分块的加载策略。3.1 世界分块与坐标系统首先我将整个无限大的世界理论上在逻辑上划分为固定大小的“块”Chunk例如每块256x256单位。每个块有一个唯一的坐标(chunkX, chunkZ)。任何实体的世界坐标(x, y, z)都可以通过除法取整快速计算出它所属的块坐标。// 示例计算实体所在块坐标 const CHUNK_SIZE 256; function getChunkCoord(worldX, worldZ) { const chunkX Math.floor(worldX / CHUNK_SIZE); const chunkZ Math.floor(worldZ / CHUNK_SIZE); return { x: chunkX, z: chunkZ }; }所有静态场景元素地形、固定建筑、树木都按块进行组织。我预先为每个块定义了一个配置文件可以是JSON描述这个块里有哪些物体、它们的类型、位置、旋转等信息。3.2 动态加载与卸载在每一帧系统都会根据玩家摄像机当前的世界坐标计算出玩家所在的块以及周围一定半径例如3个块内的所有块坐标。// 示例获取需要加载的块范围 const LOAD_RADIUS 3; // 加载周围3圈内的块 const playerChunkCoord getChunkCoord(playerPosition.x, playerPosition.z); const chunksToLoad new Set(); for (let dx -LOAD_RADIUS; dx LOAD_RADIUS; dx) { for (let dz -LOAD_RADIUS; dz LOAD_RADIUS; dz) { chunksToLoad.add(${playerChunkCoord.x dx}_${playerChunkCoord.z dz}); } }然后系统维护两个集合当前已加载的块和本轮需要加载的块。需要加载对于在chunksToLoad中但不在已加载集合的块发起异步加载请求。加载过程包括读取该块的配置文件根据配置创建Three.js模型和Cannon.js刚体注册为实体并添加到场景和物理世界中。需要卸载对于在已加载集合中但不在chunksToLoad中的块执行卸载。卸载需要小心将对应的实体从渲染场景和物理世界中移除并销毁其对应的Three.js几何体/材质和Cannon.js形状/身体以释放内存。实操心得卸载是性能优化的关键也是内存泄漏的重灾区。Three.js的几何体和材质、Cannon.js的形状和身体如果不手动dispose()即使从场景和世界中移除它们占用的内存也不会被JavaScript垃圾回收。我的框架里每个通过“块”加载创建的实体其销毁逻辑都被集中管理确保资源被正确释放。3.3 细节层次LOD与视锥体剔除仅仅分块加载还不够同一个块内距离玩家很远的物体也不需要以高精度渲染。我为一些复杂的模型如树木、岩石群实现了简单的LOD。高模距离摄像机50单位时使用面数多细节丰富。中模距离在50-150单位时使用面数减少。低模/广告牌距离150单位时直接用一张贴着模型图片的平面Billboard代替大幅减少面数。这个距离判断和模型切换是在RenderSystem中每帧进行的。同时Three.js本身内置了视锥体剔除Frustum Culling对于完全在摄像机视野外的物体GPU根本不会绘制它们。我们的动态加载逻辑实际上是在CPU层面做了一次“超大规模”的剔除两者结合才能保证巨大世界的流畅渲染。4. 物理引擎集成与碰撞优化将Cannon.js集成进来并让其高效地服务于一个开放世界是框架的另一大核心。4.1 刚体与渲染对象的绑定如前所述绑定是通过CannonComponent和ThreeComponent指向同一个TransformComponent实现的。在创建实体时流程如下function createTreeEntity(x, y, z) { const entityId generateId(); const transform new TransformComponent(x, y, z); // 1. 创建渲染部分 const treeMesh new THREE.Mesh(treeGeometry, treeMaterial); const threeComp new ThreeComponent(treeMesh); // 2. 创建物理部分 const treeShape new CANNON.Box(new CANNON.Vec3(1, 5, 1)); // 假设树是一个长方体 const treeBody new CANNON.Body({ mass: 0 }); // 质量为0表示静态物体 treeBody.addShape(treeShape); treeBody.position.set(x, y, z); const cannonComp new CannonComponent(treeBody); // 3. 组装实体 const entity new Entity(entityId); entity.addComponent(transform); entity.addComponent(threeComp); entity.addComponent(cannonComp); entity.addComponent(new StaticComponent()); // 标记为静态 // 4. 注册到系统 physicsWorld.addBody(treeBody); // 加入物理世界 threeScene.add(treeMesh); // 加入渲染场景 return entity; }在PhysicsSystem的更新循环中function updatePhysicsSystem(deltaTime) { // 1. Cannon.js 计算物理步进 physicsWorld.step(deltaTime); // 2. 同步物理状态到Transform for (const entity of entitiesWithCannon) { const transform entity.getComponent(TransformComponent); const cannonBody entity.getComponent(CannonComponent).body; transform.position.copy(cannonBody.position); transform.quaternion.copy(cannonBody.quaternion); // 使用四元数避免万向节锁 } }4.2 碰撞分组与过滤开放世界物体类型繁多不是所有东西都需要相互碰撞。让每一片草都和玩家计算碰撞是灾难性的。Cannon.js提供了碰撞过滤Collision Filter功能。我为不同的物体类型定义了碰撞分组CollisionGroups和掩码CollisionMaskGROUP_PLAYER 1GROUP_TERRAIN 2GROUP_TRIGGER 4(用于触发事件的区域如传送点)GROUP_DYNAMIC_OBJECT 8(如可被推动的木箱)每个刚体在创建时都会设置它的collisionFilterGroup我属于哪组以及collisionFilterMask我能和哪组碰撞。// 玩家能与地形和动态物体碰撞但不能与触发器碰撞避免被卡住 playerBody.collisionFilterGroup GROUP_PLAYER; playerBody.collisionFilterMask GROUP_TERRAIN | GROUP_DYNAMIC_OBJECT; // 地形只与玩家和动态物体碰撞 terrainBody.collisionFilterGroup GROUP_TERRAIN; terrainBody.collisionFilterMask GROUP_PLAYER | GROUP_DYNAMIC_OBJECT; // 触发器只与玩家碰撞 triggerBody.collisionFilterGroup GROUP_TRIGGER; triggerBody.collisionFilterMask GROUP_PLAYER;这样触发器和其他触发器之间就不会产生不必要的碰撞计算极大地提升了性能。4.3 静态与动态物体的性能考量Cannon.js对静态物体mass0有内部优化。在我的框架中所有地形、建筑、树木都被标记为静态。物理引擎知道它们永远不会动因此在碰撞检测和求解时可以采用更高效的算法。对于动态物体如木箱要严格控制数量。我会在物理世界中设置一个“睡眠”Sleep的阈值。如果一个木箱被推动后慢慢停下来速度低于某个值一段时间Cannon.js会将其置为“睡眠”状态。睡眠的物体在下一帧物理计算中会被跳过直到有外力再次唤醒它。这是物理引擎一个非常重要的自动优化手段。5. 核心模块源码深度解析现在让我们深入到几个关键模块的源码层面看看具体是如何实现的。5.1 主循环与时间管理一个稳定的游戏循环是一切的基础。我使用了requestAnimationFrame来实现循环并计算一个稳定的增量时间deltaTime。class GameEngine { constructor() { this.lastTime 0; this.isRunning false; // 各个系统的引用... } start() { this.isRunning true; this.lastTime performance.now(); this.gameLoop(); } gameLoop(currentTime 0) { if (!this.isRunning) return; // 1. 计算增量时间秒并夹紧防止异常值如标签页切换回来 const deltaTime Math.min((currentTime - this.lastTime) / 1000, 0.1); this.lastTime currentTime; // 2. 按固定顺序更新各系统 this.inputSystem.update(deltaTime); this.aiSystem.update(deltaTime); // 如果有AI this.physicsSystem.update(deltaTime); // 物理步进使用固定的时间步长更稳定 this.renderSystem.update(deltaTime); // 3. 动态加载/卸载场景块频率可以低于每帧比如每500ms检查一次 this.sceneManager.update(this.playerTransform.position); // 4. 请求下一帧 requestAnimationFrame((time) this.gameLoop(time)); } stop() { this.isRunning false; } }注意事项这里有一个经典问题deltaTime直接用于物理计算world.step(deltaTime)可能会导致“非确定性”模拟。在配置不同的电脑上由于帧率波动物理模拟结果可能会有细微差异。对于要求极高的场景物理更新应该使用一个固定的时间步长如1/60 ≈ 0.0167s并在主循环中“累积”时间进行多次固定步长的物理更新这被称为“固定时间步长物理”。我的基础框架为了简单直接使用了可变时间步长这在大多数非竞技类场景下是可接受的。5.2 场景管理器SceneManager的实现SceneManager是开放世界动态加载的核心。它的主要数据结构和方法如下class SceneManager { constructor() { this.loadedChunks new Map(); // key: chunkX_chunkZ, value: { entityIds: [], config } this.loadingQueue []; // 等待加载的块队列 this.unloadQueue []; // 等待卸载的块队列 this.loadRadius 3; this.chunkSize 256; this.lastUpdateTime 0; this.updateInterval 500; // 每500ms更新一次加载状态 } update(playerPosition) { const now Date.now(); if (now - this.lastUpdateTime this.updateInterval) return; this.lastUpdateTime now; const playerChunk this._getChunkCoord(playerPosition); const chunksInRange this._getChunksInRange(playerChunk); // 找出需要加载和卸载的块 const toLoad []; const currentlyLoaded Array.from(this.loadedChunks.keys()); for (const chunkKey of chunksInRange) { if (!this.loadedChunks.has(chunkKey) !this.loadingQueue.includes(chunkKey)) { toLoad.push(chunkKey); } } const toUnload currentlyLoaded.filter(key !chunksInRange.includes(key)); // 加入队列异步处理 this.loadingQueue.push(...toLoad); this.unloadQueue.push(...toUnload); // 处理卸载同步立即执行避免内存滞留 this._processUnloadQueue(); // 处理加载异步分批避免卡顿 this._processLoadQueueAsync(); } async _processLoadQueueAsync() { if (this.loadingQueue.length 0 || this.isLoading) return; this.isLoading true; const chunkKey this.loadingQueue.shift(); const [cx, cz] chunkKey.split(_).map(Number); try { // 1. 异步加载块配置文件 const config await this._fetchChunkConfig(cx, cz); // 2. 根据配置创建实体 const entityIds await this._createEntitiesFromConfig(config, cx, cz); // 3. 记录已加载块 this.loadedChunks.set(chunkKey, { config, entityIds }); console.log(Chunk ${chunkKey} loaded.); } catch (error) { console.error(Failed to load chunk ${chunkKey}:, error); } finally { this.isLoading false; // 继续处理队列中的下一个 setTimeout(() this._processLoadQueueAsync(), 0); } } _processUnloadQueue() { for (const chunkKey of this.unloadQueue) { const chunkData this.loadedChunks.get(chunkKey); if (chunkData) { // 销毁所有实体会调用实体自身的dispose方法清理Three.js和Cannon.js资源 chunkData.entityIds.forEach(id this.entityManager.destroyEntity(id)); this.loadedChunks.delete(chunkKey); console.log(Chunk ${chunkKey} unloaded.); } } this.unloadQueue.length 0; // 清空卸载队列 } // ... 其他辅助方法 (_getChunkCoord, _getChunksInRange, _fetchChunkConfig, _createEntitiesFromConfig) }5.3 实体管理器与组件查询实体管理器EntityManager负责所有实体的生命周期和组件查询。为了高效地让系统找到它们关心的实体我使用了“位掩码”技术。每个组件类型都有一个唯一的位标识如Transform1,Three2,Cannon4,Player8。每个实体都有一个“组件签名”它是其拥有的所有组件位标识的按位或|结果。class EntityManager { constructor() { this.entities new Map(); // id - Entity this.componentSignatures new Map(); // id - signature (number) } createEntity() { const id generateId(); const entity new Entity(id); this.entities.set(id, entity); this.componentSignatures.set(id, 0); return entity; } addComponent(entity, component) { entity.addComponentInternal(component); // 实体内部存储 const compBit getComponentBit(component.constructor); const currentSig this.componentSignatures.get(entity.id); this.componentSignatures.set(entity.id, currentSig | compBit); } getEntitiesWithSignature(signature) { const result []; for (const [id, entitySig] of this.componentSignatures) { // 关键查询实体签名是否包含所需签名的所有位 if ((entitySig signature) signature) { result.push(this.entities.get(id)); } } return result; } } // 系统使用示例PhysicsSystem需要所有拥有CannonComponent和TransformComponent的实体 const PHYSICS_SIGNATURE getComponentBit(CannonComponent) | getComponentBit(TransformComponent); class PhysicsSystem { update(deltaTime) { const entities entityManager.getEntitiesWithSignature(PHYSICS_SIGNATURE); // ... 处理这些实体 } }这种位掩码查询的效率远高于遍历所有实体然后检查每个实体是否有某个组件是ECS模式高效的核心。6. 性能调优与常见问题实录在实际开发和测试这个框架的过程中我踩过不少坑也总结出一些关键的优化点和问题排查方法。6.1 内存与GPU资源泄漏排查这是WebGL应用最常见的性能杀手。我的排查清单如下Three.js 对象几何体Geometry/BufferGeometry 手动调用.dispose()。材质Material 手动调用.dispose()。注意如果多个Mesh共享同一个材质需要在所有使用该材质的Mesh都被销毁后才能dispose。纹理Texture 手动调用.dispose()。检查工具 在Chrome开发者工具的Memory面板中拍摄堆快照Heap Snapshot搜索THREE关键字查看Three.js相关对象是否在预期卸载后依然存在。Cannon.js 对象形状Shape 从Body中移除body.removeShape(shape)后形状本身可能仍需手动置空。刚体Body 从World中移除world.removeBody(body)是必须的。注意 Cannon.js的对象是纯粹的JavaScript对象其内存由JS GC管理。确保没有任何地方如事件监听器、全局数组保留了对已卸载Body或Shape的引用它们就会被正常回收。JavaScript 常规对象我的Entity、Component对象在销毁时需要确保从EntityManager的entities和componentSignaturesMap中删除并清空其内部对组件和其他实体的引用。6.2 渲染性能瓶颈分析当帧率下降时使用Chrome的Performance面板录制一段时间重点关注Scripting 时间过长 可能是某段逻辑如AI计算、复杂的碰撞检测回调太耗时。需要优化算法或考虑分帧处理。Rendering 时间过长Draw Calls 过高 这是WebGL渲染的最大开销之一。使用Three.js的stats.js插件查看calls数量。优化方法包括合并几何体BufferGeometryUtils.mergeBufferGeometries、使用InstancedMesh实例化网格渲染大量相同物体如草地、树木。材质和着色器复杂 复杂的片元着色器Fragment Shader计算如动态光照、雾效、后期处理会极大增加GPU负担。在移动端或低端显卡上需要简化。过度绘制 确保视锥体剔除正常工作对于不透明物体尽量从前向后渲染Early-Z对于透明物体则从后向前。6.3 物理引擎性能调优Cannon.js的性能主要消耗在碰撞检测和求解约束上。减少活动刚体数量 这是最有效的优化。充分利用静态物体mass0和“睡眠”状态。简化碰撞形状 能用球Sphere、盒Box就别用凸包ConvexPolyhedron或三角网格Trimesh。三角网格的碰撞检测开销最大。对于复杂模型可以用一个或多个简单的原生形状球、盒来近似其碰撞体积这被称为“碰撞代理”。调整世界步长和迭代次数world.step(deltaTime)的第二个参数是固定时间步长默认为1/60第三个参数是最大子步数。在帧率波动时物理引擎会通过多次子步进来追赶时间。子步进太多会影响性能。可以适当调大固定步长如1/30并减少最大子步数在物理精度和性能之间取得平衡。使用碰撞过滤 如前所述精确设置碰撞分组和掩码避免不必要的碰撞对计算。6.4 常见问题速查表问题现象可能原因排查与解决思路物体抖动或穿透1. 物理更新和渲染更新顺序错误或频率不一致。2. 物理形状与渲染模型尺寸不匹配。3. 刚体质量、阻尼等参数设置不当。1. 确保每帧顺序逻辑输入 - 物理步进 - 同步状态 - 渲染。2. 开启Three.js和Cannon.js的调试视图对比显示碰撞框和渲染模型是否对齐。3. 增加刚体的线性阻尼linearDamping和角阻尼angularDamping可以减少抖动。动态加载边界处物体“闪现”加载/卸载判断逻辑有误或异步加载完成时玩家已移动到新位置。1. 检查_getChunksInRange函数逻辑是否正确。2. 加载完成后再次判断该块是否仍在需要加载的范围内如果不在则直接卸载。帧率随时间逐渐降低内存/资源泄漏。使用Chrome Memory面板拍摄堆快照对比重点检查Three.js和自定义的Entity/Component对象是否被正确释放。移动端严重卡顿1. 面数过多Draw Calls过高。2. 物理刚体过多或形状太复杂。3. 着色器计算太复杂。1. 大幅降低LOD切换距离使用更简化的模型和纹理。2. 减少动态物体简化碰撞形状。3. 禁用或简化后期处理、动态阴影等高级特性。物理模拟“时快时慢”使用了可变时间步长world.step(deltaTime)且帧率波动大。改为固定时间步长物理更新在主循环中累积时间进行多次固定步长更新。Cannon.js报错“Quaternion is not normalized”四元数未标准化通常是由于直接修改了刚体的quaternion属性值。永远使用body.quaternion.set(x, y, z, w)或body.quaternion.copy(otherQuat)来设置四元数避免直接赋值。Cannon.js在内部步进时会假设四元数已标准化。这个基础框架的设计和实现就像搭积木先有了稳固的架构三权分立、ECS思想再一块块填上功能动态加载、物理集成。它最大的价值不在于实现了多么炫酷的效果而在于提供了一种清晰、可扩展、易于调试的方式来组织复杂的3D交互应用代码。当你需要添加一个新类型的怪物、一种新的地形、或者一个复杂的任务系统时你知道该在哪里写代码而不会牵一发而动全身。希望这份详细的拆解能为你打开一扇自己动手构建3D世界的大门。本文还有配套的精品资源点击获取
返回列表