简介:这是一套面向前端开发者与3D图形学习者的开放世界基础框架源码,专为快速构建具备物理交互能力的浏览器端3D场景而设计,解决初学者在three.js与cannon.js协同开发中常见的场景搭建难、物理集成弱、工程结构松散等痛点。资源共237个文件,包含181个TypeScript核心模块(覆盖场景管理、相机控制、物理世界同步、资源加载器等)、9个GLB三维模型、9个CSS样式表(含loadingScreen.css、welcomeScreen.css等UI组件样式)、5个PNG纹理图及2个WebAssembly模块(用于高性能物理计算加速),整体压缩包大小为66.09MB。已有284人下载学习。开发者可直接复用其模块化架构、标准化配置(tsconfig.json、webpack.config.js等)与完整目录体系,快速启动个人开放世界项目;同时获得从建模加载、光照渲染、碰撞响应到UI集成的一站式技术参考,显著降低3D前端工程化门槛。
1. 项目概述:从零构建一个可玩的物理世界
最近在整理过去的项目资料,翻到了一个几年前做的“开放世界基础框架”的Demo源码。这个项目的核心目标很明确:用Three.js渲染一个看起来还不错的3D世界,再用Cannon.js为这个世界注入真实的物理规则,最终形成一个可供角色自由探索、与物体交互的基础框架。它不是某个具体游戏的完整实现,而是一个“地基”,一个包含了地形生成、物理碰撞、角色控制、基础交互等核心模块的工程化样板。如果你正想入门3D网页游戏开发,或者厌倦了零散的教程,希望看到一个相对完整、可扩展的项目结构,那么这个框架的设计思路和源码细节,或许能给你带来不少启发。
简单来说,这个框架解决了几个关键问题:如何高效地组织Three.js的庞大场景与对象?如何让Cannon.js的物理世界与Three.js的视觉世界保持同步且高效?如何设计一个既灵活又不过度复杂的主角控制器?以及,如何为未来的内容扩展(如NPC、任务、更多类型的交互物体)预留好接口?接下来,我就把这个“地基”是如何一砖一瓦搭建起来的,包括其中的设计权衡、踩过的坑和优化技巧,详细拆解一遍。
2. 核心架构设计与技术选型解析
2.1 为什么是Three.js + Cannon.js?
在Web端实现3D,Three.js几乎是唯一成熟且生态丰富的选择。它封装了WebGL的复杂性,提供了场景、相机、渲染器、几何体、材质、光源等一整套高层API,让我们能专注于内容创作而非图形学细节。而物理引擎方面,Cannon.js是纯JavaScript实现,与Three.js同属一个技术栈,集成起来天然友好,文档和社区案例也相对丰富。虽然后来有了更强大的Ammo.js(Bullet物理引擎的Emscripten移植版),但Cannon.js的轻量、易上手和足够的性能(对于中小型开放世界Demo而言),使其成为快速原型开发阶段的理想选择。
这个组合的核心挑战在于“双世界同步”。Three.js管理着渲染世界(视觉表现),Cannon.js管理着物理世界(碰撞与运动)。一个盒子在屏幕上显示的位置、旋转,必须和它在物理引擎中刚体的位置、旋转完全一致,否则就会出现“视觉上穿墙而过,物理上却被撞飞”的诡异情况。我们的框架设计,很大程度上就是在优雅地解决这个同步问题。
2.2 框架的顶层模块划分
为了让代码不至于变成一锅粥,我采用了基于功能职责的模块化设计。整个源码结构大致如下:
src/ ├── core/ # 框架核心 │ ├── Game.js # 主游戏循环、模块调度 │ ├── World.js # 场景与物理世界管理器(双世界同步核心) │ └── Loader.js # 资源加载管理器(纹理、模型、音频) ├── entities/ # 游戏实体 │ ├── Player.js # 玩家角色控制器(输入、状态、动画) │ ├── PhysicsEntity.js # 所有具有物理属性实体的基类 │ ├── Terrain.js # 地形生成器与管理器 │ └── Prop.js # 静态/动态道具(树木、石头、箱子等) ├── physics/ # 物理相关封装 │ ├── PhysicsWorld.js # Cannon.js世界实例的封装与配置 │ └── CollisionGroups.js # 碰撞层/组定义,优化性能 ├── utils/ # 工具函数 │ ├── helpers.js # 调试辅助线、坐标系等 │ └── math.js # 自定义数学工具 └── ui/ # 用户界面(如简单的HUD) └── HUD.js设计核心思想:Game类是总指挥,负责启动、更新和渲染循环。World类是中枢,它持有THREE.Scene和CANNON.World的实例,并负责它们之间对象的增删改查同步。所有需要同时存在于两个世界中的物体(如玩家、可拾取物品),都继承自PhysicsEntity基类,由它来封装同步逻辑。这种设计确保了关注点分离,物理代码不会污染渲染逻辑,反之亦然。
3. 双世界同步:渲染与物理的精密耦合
这是整个框架最核心、也最容易出问题的部分。实现同步主要有两种模式:物理驱动和视觉驱动。我们的框架主要采用物理驱动。
3.1 PhysicsEntity 基类:同步的基石
PhysicsEntity是一个抽象基类,它定义了一个实体必须具备的“双身”。任何既要被看见又要参与物理模拟的物体,都应继承它。
// PhysicsEntity.js 简化示例 import * as THREE from 'three'; import * as CANNON from 'cannon-es'; // 注意:使用cannon-es,这是Cannon.js的活跃维护分支 export class PhysicsEntity { constructor(meshParams, bodyParams) { // 1. 创建视觉对象(Three.js Mesh) this.mesh = this.createMesh(meshParams); // 2. 创建物理刚体(Cannon.js Body) this.body = this.createBody(bodyParams); // 3. 初始化位置和旋转同步 this.syncFromPhysics(); } createMesh(params) { // 根据参数创建几何体和材质,返回THREE.Mesh const geometry = new THREE.BoxGeometry(...params.size); const material = new THREE.MeshStandardMaterial(params.material); return new THREE.Mesh(geometry, material); } createBody(params) { // 根据参数创建物理形状和刚体 const shape = new CANNON.Box(new CANNON.Vec3(...params.halfExtents)); const body = new CANNON.Body({ mass: params.mass, shape: shape, material: params.physicsMaterial, }); body.position.set(...params.position); return body; } // 关键方法:从物理世界同步到渲染世界(物理驱动) syncFromPhysics() { if (this.mesh && this.body) { // 位置同步 this.mesh.position.copy(this.body.position); // 旋转同步。Cannon.js的quaternion和Three.js的quaternion可以直接复制 this.mesh.quaternion.copy(this.body.quaternion); } } // 如果需要视觉驱动(如播放动画时),则同步到物理世界 syncToPhysics() { if (this.mesh && this.body) { this.body.position.copy(this.mesh.position); this.body.quaternion.copy(this.mesh.quaternion); } } update(deltaTime) { // 每帧更新,通常调用 syncFromPhysics() this.syncFromPhysics(); } }关键细节与避坑:
- 单位一致性:Three.js默认单位是“米”,Cannon.js也是。但美术导出的模型比例可能不是1:1。务必在模型加载阶段或创建实体时统一缩放,确保视觉上的1单位等于物理上的1米,否则物理效果会非常奇怪。
- 旋转的表示:直接同步
rotation欧拉角会遇到万向节死锁和顺序问题。务必使用四元数quaternion进行同步,如上例所示,这是最安全、最准确的方式。 - 更新时机:
syncFromPhysics必须在每一帧的渲染之前调用。通常放在Game类的更新循环中,在physicsWorld.step()计算完新一帧物理状态之后,在renderer.render()之前。
3.2 World 管理器:同步的调度中心
World类负责管理所有PhysicsEntity实例的生命周期和同步。
// World.js 简化示例 export class World { constructor() { this.scene = new THREE.Scene(); this.physicsWorld = new CANNON.World(); this.physicsWorld.gravity.set(0, -9.82, 0); // 设置重力 this.entities = new Set(); // 存储所有PhysicsEntity实例 } addEntity(entity) { this.entities.add(entity); this.scene.add(entity.mesh); this.physicsWorld.addBody(entity.body); } removeEntity(entity) { this.entities.delete(entity); this.scene.remove(entity.mesh); this.physicsWorld.removeBody(entity.body); } update(deltaTime) { // 1. 步进物理世界 this.physicsWorld.step(1 / 60, deltaTime, 3); // 固定时间步长,3次子步进 // 2. 更新所有实体,触发它们的同步 for (const entity of this.entities) { entity.update(deltaTime); } } getScene() { return this.scene; } }注意:
physicsWorld.step的参数非常关键。第一个参数是固定时间步长(通常用1/60秒),第二个参数是自上一帧以来的真实时间差(deltaTime),第三个参数是最大子步进数。使用固定时间步长可以保证物理模拟的稳定性,不受帧率波动影响。deltaTime可能大于固定步长,因此需要子步进来“追赶”真实时间。
4. 地形系统与碰撞优化
开放世界离不开大地图。我们不可能用一个个小方块手动拼接,需要程序化生成或加载大型地形网格。
4.1 地形生成与物理匹配
这里以简单的程序化高度图生成为例。我们创建一个视觉上的地形网格,同时需要为它创建一个匹配的物理碰撞体。
// Terrain.js 简化示例 export class Terrain extends PhysicsEntity { constructor(width, depth, segments) { // 1. 生成高度图数据 const heightData = this.generateHeightMap(width, depth, segments); // 2. 创建Three.js地形几何体 const geometry = new THREE.PlaneGeometry(width, depth, segments, segments); const vertices = geometry.attributes.position.array; for (let i = 0; i <= segments; i++) { for (let j = 0; j <= segments; j++) { const idx = (i * (segments + 1) + j) * 3; vertices[idx + 2] = heightData[i][j]; // 设置Z轴高度(假设地面在XZ平面) } } geometry.computeVertexNormals(); // 重要!重新计算法线用于光照 const material = new THREE.MeshStandardMaterial({ color: 0x3a7c3a }); const mesh = new THREE.Mesh(geometry, material); mesh.rotation.x = -Math.PI / 2; // 旋转平面使其成为“地面” mesh.receiveShadow = true; // 接收阴影 // 3. 创建Cannon.js高度场形状(Heightfield) // Heightfield是专门为地形设计的碰撞形状,效率远高于使用无数个三角网格。 const matrix = []; // Cannon.js需要的矩阵格式 for (let i = 0; i <= segments; i++) { matrix.push([]); for (let j = 0; j <= segments; j++) { matrix[i].push(heightData[i][j]); } } const heightfieldShape = new CANNON.Heightfield(matrix, { elementSize: width / segments, }); const body = new CANNON.Body({ mass: 0 }); // 质量为0表示静态物体 body.addShape(heightfieldShape); body.position.set(-width/2, 0, -depth/2); // 对齐原点 // 4. 调用父类构造函数 super({ mesh: mesh }, { body: body }); } generateHeightMap(width, depth, segments) { const data = []; const noiseScale = 0.1; // 这里可以使用简单的噪声函数,如Perlin Noise或Simplex Noise for (let i = 0; i <= segments; i++) { data[i] = []; for (let j = 0; j <= segments; j++) { // 示例:使用正弦波生成起伏 const x = (i / segments) * width * noiseScale; const z = (j / segments) * depth * noiseScale; data[i][j] = Math.sin(x) * Math.cos(z) * 5; } } return data; } // 地形是静态的,不需要每帧从物理同步位置,所以重写update为空 update() {} }关键点:
- 使用Heightfield:对于基于高度图的地形,Cannon.js的
Heightfield形状是性能最优解。切勿尝试用三角网格(Trimesh)形状,它的计算开销巨大。 - 静态物体 mass=0:地形永远不会移动,将其质量设为0,物理引擎会将其视为无限质量的静态物体,不参与动力学计算,能极大提升性能。
- 法线计算:修改顶点高度后,必须调用
geometry.computeVertexNormals(),否则光照会出错,地形看起来是平的。
4.2 碰撞层(Collision Groups)优化
当场景中有成百上千的物体时,让每个物体都互相检测碰撞是不现实的。Cannon.js支持碰撞过滤(Collision Filters),我们可以通过定义碰撞层来大幅减少检测次数。
// CollisionGroups.js export const COLLISION_GROUPS = { DEFAULT: 1 << 0, // 二进制 0001 PLAYER: 1 << 1, // 二进制 0010 PROP: 1 << 2, // 二进制 0100 TERRAIN: 1 << 3, // 二进制 1000 // ... 可以继续定义更多层 }; export const COLLISION_MASKS = { // 玩家可以与地形和道具碰撞 PLAYER: COLLISION_GROUPS.TERRAIN | COLLISION_GROUPS.PROP, // 道具可以与地形和玩家碰撞 PROP: COLLISION_GROUPS.TERRAIN | COLLISION_GROUPS.PLAYER, // 地形可以与所有物体碰撞(通常) TERRAIN: -1, // -1 表示与所有层碰撞 };在创建物理刚体时应用:
const body = new CANNON.Body({ mass: 1, shape: shape, collisionFilterGroup: COLLISION_GROUPS.PLAYER, // 我属于哪一层 collisionFilterMask: COLLISION_MASKS.PLAYER, // 我能与哪些层碰撞 });例如,设置后,两个同为PROP层的箱子之间就不会进行碰撞检测(除非你在COLLISION_MASKS.PROP中加入了COLLISION_GROUPS.PROP),这能有效提升性能,尤其是在道具密集的区域。
5. 玩家角色控制器:输入、移动与状态机
一个手感良好的角色控制器是开放世界体验的核心。我们的Player类继承自PhysicsEntity,并处理输入和移动逻辑。
5.1 输入管理与向量计算
// Player.js 部分代码 export class Player extends PhysicsEntity { constructor() { // ... 初始化mesh和body this.velocity = new THREE.Vector3(); // 用于存储计算出的速度 this.moveSpeed = 5; this.jumpForce = 7; this.isOnGround = false; this.keys = {}; // 记录按键状态 this.setupInput(); } setupInput() { window.addEventListener('keydown', (e) => this.keys[e.code] = true); window.addEventListener('keyup', (e) => this.keys[e.code] = false); } update(deltaTime, camera) { // 1. 处理输入,计算期望的速度向量 const moveDirection = new THREE.Vector3(0, 0, 0); if (this.keys['KeyW']) moveDirection.z -= 1; if (this.keys['KeyS']) moveDirection.z += 1; if (this.keys['KeyA']) moveDirection.x -= 1; if (this.keys['KeyD']) moveDirection.x += 1; // 2. 将输入方向基于相机朝向进行转换(实现第三人称或第一人称的相对移动) moveDirection.normalize(); moveDirection.applyQuaternion(camera.quaternion); moveDirection.y = 0; // 确保移动在水平面 moveDirection.normalize(); // 3. 计算目标速度 this.velocity.copy(moveDirection).multiplyScalar(this.moveSpeed); // 4. 处理跳跃(需要检测是否在地面) if (this.keys['Space'] && this.isOnGround) { this.body.velocity.y = this.jumpForce; this.isOnGround = false; } // 5. 将计算出的水平速度应用到物理刚体上 // 注意:直接设置velocity会覆盖物理引擎的计算,更推荐用applyForce或applyImpulse // 这里为了简单,直接设置XZ轴速度,Y轴速度由重力控制 this.body.velocity.x = this.velocity.x; this.body.velocity.z = this.velocity.z; // 6. 调用父类同步 super.update(deltaTime); } }5.2 地面检测与状态管理
如何判断玩家是否在地面?一个可靠的方法是通过射线检测(Raycast)。
// 在Player的update方法中,加入地面检测 update(deltaTime, camera) { // ... 前面的移动计算逻辑 // 地面检测 const rayStart = new CANNON.Vec3( this.body.position.x, this.body.position.y, this.body.position.z ); const rayEnd = new CANNON.Vec3( this.body.position.x, this.body.position.y - 1.1, // 从脚底向下发射,长度略大于角色“皮肤宽度” this.body.position.z ); const raycastResult = new CANNON.RaycastResult(); this.world.physicsWorld.raycast(rayStart, rayEnd, raycastResult); this.isOnGround = raycastResult.hasHit; // ... 应用速度,调用父类同步 }避坑指南:
- 不要直接设置position:为了移动角色而直接修改
body.position会破坏物理模拟。应该通过力(applyForce)、冲量(applyImpulse)或速度(velocity)来驱动刚体。 - 射线检测的“皮肤宽度”:射线长度应略大于角色底部到地面的理论距离(例如,角色胶囊体高度2米,射线长度设1.1米)。这可以防止角色在微小起伏的地面上被误判为腾空,导致“抽搐”。
- 输入与物理更新分离:将输入处理放在
update中,但要注意deltaTime的累积问题。更健壮的做法是使用固定的时间步长处理输入和物理,与渲染帧率解耦。
6. 资源加载与管理
一个开放世界会有大量模型、纹理和声音。一个良好的加载管理器能提升用户体验并简化代码。
6.1 实现一个简单的资源加载器
// Loader.js import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { TextureLoader } from 'three'; export class Loader { constructor() { this.assets = new Map(); this.loadingManager = new THREE.LoadingManager(); this.gltfLoader = new GLTFLoader(this.loadingManager); this.textureLoader = new TextureLoader(this.loadingManager); // 可以添加加载进度回调 this.loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => { console.log(`Loading: ${itemsLoaded}/${itemsTotal} - ${url}`); }; } loadGLTF(key, url) { return new Promise((resolve, reject) => { this.gltfLoader.load( url, (gltf) => { this.assets.set(key, gltf); resolve(gltf); }, undefined, // 进度回调(可选) (error) => reject(error) ); }); } loadTexture(key, url) { return new Promise((resolve, reject) => { this.textureLoader.load( url, (texture) => { this.assets.set(key, texture); resolve(texture); }, undefined, (error) => reject(error) ); }); } get(key) { return this.assets.get(key); } // 批量加载 async loadAll(manifest) { const promises = []; for (const item of manifest) { if (item.type === 'gltf') { promises.push(this.loadGLTF(item.key, item.url)); } else if (item.type === 'texture') { promises.push(this.loadTexture(item.key, item.url)); } } await Promise.all(promises); console.log('All assets loaded.'); } }在Game初始化时,先使用Loader加载所有必要资源,再进入主循环。这能避免渲染过程中因资源未加载完成导致的错误或卡顿。
7. 性能优化与调试技巧
当场景变得复杂时,性能问题会凸显。以下是一些实战中总结的优化点:
7.1 渲染优化
- 视锥体剔除(Frustum Culling):Three.js默认会对物体进行视锥体剔除。确保你的相机
frustumCulled属性为true(默认是)。对于非常大的地形,可能需要手动分割成区块(Chunk)来实现更精细的剔除。 - 细节层次(LOD):对于远处的复杂模型,使用简化版模型。Three.js 提供了
THREE.LOD对象。 - 实例化网格(InstancedMesh):对于大量重复的物体(如草地、树木、石头),使用
THREE.InstancedMesh可以极大减少绘制调用(Draw Calls)。这是提升开放世界渲染性能的杀手锏。 - 阴影优化:阴影是性能杀手。限制阴影贴图分辨率(如
shadow.mapSize.width = 1024),合理设置相机的阴影相机视锥范围(shadow.camera的near/far/left/right/top/bottom),只让必要的物体投射和接收阴影。
7.2 物理优化
- 休眠(Sleeping):Cannon.js支持刚体休眠。当一个物体静止一段时间后,物理引擎会将其“休眠”,不再计算其运动,直到它被外力唤醒。确保
world.allowSleep = true。 - 使用简单的碰撞形状:能用球体(
Sphere)、盒子(Box)、圆柱体(Cylinder)近似,就不要用复杂的凸包(ConvexPolyhedron)或三角网格(Trimesh)。对于角色,胶囊体(Capsule)是比圆柱体更好的选择,因为它两端是圆滑的,不容易卡住。 - 碰撞层:如前所述,善用碰撞过滤,这是最有效的物理优化手段之一。
7.3 调试辅助
Three.js和Cannon.js都提供了强大的调试工具。
- Three.js 场景查看器:在控制台输入
yourRenderer.domElement获取canvas元素,然后使用浏览器的检查器,有些版本可以直接查看场景图。 - Cannon.js 调试渲染器:有一个名为
cannon-es-debugger的库,它可以将Cannon.js的物理刚体用线框形式渲染到Three.js场景中,让你直观地看到碰撞体的位置和形状,对于排查物理同步问题不可或缺。
import { CannonEsDebugger } from 'cannon-es-debugger'; // ... 初始化后 const cannonDebugger = new CannonEsDebugger(scene, physicsWorld); // 在渲染循环中 function animate() { // ... 更新物理世界 cannonDebugger.update(); // 更新调试线框 // ... 渲染场景 }8. 常见问题与排查实录
在开发过程中,我遇到了不少典型问题,这里记录下排查思路:
问题1:物体抖动或穿透
- 原因:最常见的原因是渲染更新和物理更新顺序错误,或者时间步长
deltaTime传递不稳定。 - 排查:确保在主循环中严格按照
物理步进(step)->实体更新(含同步)->渲染的顺序执行。使用固定时间步长进行物理计算。 - 代码检查点:
function gameLoop(currentTime) { const deltaTime = Math.min((currentTime - lastTime) / 1000, 0.1); // 限制最大deltaTime lastTime = currentTime; // 正确顺序 physicsWorld.step(FIXED_TIMESTEP, deltaTime, MAX_SUBSTEPS); // 1. 物理 world.update(deltaTime); // 2. 更新所有实体(内部同步) renderer.render(scene, camera); // 3. 渲染 requestAnimationFrame(gameLoop); }
问题2:角色在斜坡上打滑或难以行走
- 原因:物理材质摩擦力设置不当,或者角色刚体形状不合适(如使用球体)。
- 解决:调整角色刚体的物理材质
friction值(通常在0.3-0.8之间尝试)。将角色形状改为胶囊体(Capsule)能显著改善斜坡和台阶上的运动表现。
问题3:性能随着物体增多急剧下降
- 排查清单:
- 打开浏览器开发者工具的“性能(Performance)”面板录制一段操作,查看“脚本(Scripting)”和“渲染(Rendering)”耗时。
- 检查Draw Calls:在Three.js中,可以通过
renderer.info.render.calls查看。如果数字异常高(如超过1000),考虑使用InstancedMesh合并重复物体。 - 检查物理引擎中的刚体数量,是否有很多本该休眠的物体还在活跃。检查碰撞检测对数,是否因为没有使用碰撞层而导致全量检测。
问题4:加载大型GLTF模型后内存泄漏
- 原因:Three.js的几何体(
BufferGeometry)和材质(Material)需要手动释放。 - 解决:在移除模型时,不仅要将其从场景中移除(
scene.remove(mesh)),还要遍历其几何体和材质进行释放:mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); if (child.material.isMaterial) { child.material.dispose(); } else if (Array.isArray(child.material)) { child.material.forEach(m => m.dispose()); } } });
这个基础框架的源码,就像一套乐高积木的基础板。它提供了最核心的联动结构(渲染与物理同步)、关键组件(世界、实体、控制器)和稳定的连接方式(架构与设计模式)。基于它,你可以快速搭建出一个小镇、一片森林或是一个迷宫,并通过扩展PhysicsEntity来加入车辆、敌人、可开关的门等更复杂的交互元素。开发中最享受的时刻,莫过于看到自己创建的角色,在一个由代码生成的、符合物理规律的世界里自由奔跑,那种创造世界的成就感,正是驱动我们不断前行的动力。
本文还有配套的精品资源,点击获取