尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Three.js与Cannon.js集成:构建可交互3D物理世界的架构与实战

Three.js与Cannon.js集成:构建可交互3D物理世界的架构与实战 简介本资源是一个基于three.js与cannon.js构建的开放世界基础框架源码面向具备前端开发基础、希望快速切入3D场景开发的中高级开发者解决从零搭建具备物理交互能力的Web端开放世界的技术门槛问题。压缩包共237个文件含181个TypeScript核心模块实现渲染管线、物理系统、相机控制与场景管理、9个GLB三维模型、9个CSS样式表涵盖加载屏、欢迎界面、面板布局等UI视觉控制、5个PNG纹理资源及2个WebAssembly模块用于高性能物理计算加速整体大小为66.09MB。已有284人学习下载。框架采用模块化设计内置webpack构建配置、tsconfig与lint规范并提供完整目录结构与Git版本管理支持预览可见多套专用CSS如loadingScreen.css、welcomeScreen.css、githubCorner.css等表明其已集成用户引导、性能监控与交互反馈等工程化细节可直接作为个人3D项目起点进行功能扩展与二次开发。1. 项目概述从零构建一个可交互的3D物理世界最近在整理过去的项目资料翻出了一个几年前做的“开放世界基础框架”的源码。这个项目本质上是一个技术原型核心目标很明确如何用最精简、最清晰的代码结构搭建一个具备基础物理交互和视觉渲染的3D沙盒环境。它不是某个具体的游戏而是一个“地基”一个可以让你在上面快速搭建各种3D互动应用的脚手架。这个框架的核心技术栈是Three.js和Cannon.js。Three.js负责将3D模型、灯光、摄像机这些视觉元素渲染到网页上让你“看”到这个世界而Cannon.js则负责模拟重力、碰撞、刚体运动这些物理规则让你“感受”到这个世界。听起来简单但要把这两套系统无缝、高效地整合在一起让一个物体既能在屏幕上正确显示又能遵循物理定律运动、碰撞中间有不少设计上的门道。我之所以花时间设计这个框架是因为当时发现很多Three.js的初学者包括几年前的我自己在尝试做点有交互的东西时代码很容易变成一锅粥。物理更新和渲染更新混在一起对象管理混乱想加个新功能都不知道从哪下手。这个框架源码就是我对这些问题的一个系统性解答。它适合那些已经了解Three.js和Cannon.js基础但想学习如何架构一个中等复杂度3D应用的朋友。通过拆解这个框架你能学到如何设计一个清晰的数据流、如何管理场景中的对象生命周期以及如何为未来的功能扩展预留空间。2. 框架核心设计思路与架构拆解2.1 为什么是“实体-组件”思想的轻量级实践在设计之初我考察过纯粹的ECS实体-组件-系统架构比如使用ecsy这类库。但对于一个旨在展示基础集成的框架来说引入完整的ECS可能会让代码变得复杂掩盖了Three.js与Cannon.js集成本身的重点。因此我采用了一种更贴近实际、易于理解的面向对象与组件化混合的思路。核心设计哲学是一个具有物理交互能力的物体在框架内被视作一个统一的“实体”Entity。这个实体内部封装了至少两个核心组件视觉组件VisualComponent对应一个Three.js的Object3D可能是Mesh,Group等负责“样子”。物理组件PhysicsComponent对应一个Cannon.js的Body负责“物理属性”。框架的核心管理器我称之为World并不直接操作单独的Object3D或Body而是操作这些“实体”。实体内部负责同步视觉组件和物理组件的状态。这样做的好处非常明显高内聚所有关于某个“箱子”或“角色”的渲染和物理逻辑都封装在一起便于理解和调试。低耦合World管理器只需要调用实体统一的接口如update()无需关心内部是Three.js还是Cannon.js在干活。易扩展未来要增加“声音组件”、“AI组件”只需要在实体类内部添加对外部世界透明。2.2 核心模块职责划分整个框架的源码围绕几个核心类展开职责清晰World(世界管理器)这是框架的“大脑”和“调度中心”。它单例化Three.js的Scene和Cannon.js的World并持有所有实体的引用。它的核心职责是启动/停止主循环animation loop。在每一帧中先调用Cannon.js世界进行物理模拟world.step()然后遍历所有实体通知它们根据最新的物理状态更新视觉位置。提供添加/移除实体的接口。管理灯光、摄像机等全局资源。Entity(基础实体类)所有可交互对象的基类。它定义了接口并持有visualObject和physicsBody的引用。它包含一个关键的update()方法在这个方法里将physicsBody的位置和旋转数据同步给visualObject。PhysicsSystem(物理系统配置)虽然Cannon.js的World是核心但我们通常需要对其进行配置。这个模块可能是一个类或一组配置函数负责创建物理世界实例并设置重力例如new CANNON.Vec3(0, -9.8, 0)。定义和注册公共的物理材质如groundMaterial,boxMaterial及其间的接触材质ContactMaterial以确定不同物体碰撞时的摩擦力和弹性。提供创建通用物理形状如盒子、球体、平面的工厂方法。RendererManager(渲染器管理器)负责Three.js渲染器WebGLRenderer的创建、画布canvas的DOM插入、以及响应窗口大小变化window.resize事件。它确保视觉输出能正确适配浏览器窗口。CameraController(摄像机控制器)开放世界需要一个可自由观察的视角。这个类封装了对Three.js摄像机通常是PerspectiveCamera的控制逻辑例如通过鼠标和键盘实现第一人称漫游FPS或第三人称轨道控制OrbitControls。它将用户输入转化为摄像机的移动和旋转。注意这里没有采用严格的“系统”来遍历处理同类组件是因为在基础框架中实体数量有限每个实体自己同步状态的方式更直观。当实体数量达到成千上万时才需要考虑用“系统”进行批处理优化那是性能优化阶段的事情。2.3 数据流与主循环剖析这是框架运作的“心跳”理解它至关重要。每一帧通常以60FPS为目标都遵循以下顺序// 伪代码展示主循环逻辑 function animate() { requestAnimationFrame(animate); // 1. 更新摄像机控制器处理用户输入 cameraController.update(); // 2. 物理步进固定时间步长保证模拟稳定性 const fixedTimeStep 1.0 / 60.0; // 60Hz const maxSubSteps 3; physicsWorld.step(fixedTimeStep, deltaTime, maxSubSteps); // 3. 更新所有实体同步物理状态到渲染 for (const entity of entities) { entity.update(); } // 4. 渲染场景 renderer.render(scene, camera); }关键点解析固定物理时间步长fixedTimeStep这是物理模拟稳定性的基石。无论你的画面帧率是30还是120物理世界都按照固定的频率如每秒60次更新。world.step()方法会内部处理时间差可能一次调用执行多次子步进substeps来“追上”真实时间。maxSubSteps参数防止在标签页休眠后恢复时因时间差过大导致一次执行过多步进而卡死。更新顺序必须先进行物理计算再用结果去更新视觉。反过来就会导致“视觉先行物理滞后”出现穿透等诡异现象。entity.update()做了什么核心就是一行代码this.visualObject.position.copy(this.physicsBody.position)以及类似的旋转同步。但这里有个坑Three.js和Cannon.js的坐标系可能不一致比如Y轴向上还是Z轴向上需要在实体初始化时做一次性转换。3. 关键实现细节与源码解析3.1 实体基类粘合渲染与物理的桥梁让我们深入看看Entity基类的核心实现。它并不复杂但体现了设计思想。import * as THREE from three; import * as CANNON from cannon-es; // 注意这里使用cannon-es它是Cannon.js的活跃维护分支 export class Entity { constructor() { this.uuid THREE.MathUtils.generateUUID(); // 唯一标识 this.visualObject null; // THREE.Object3D this.physicsBody null; // CANNON.Body this._syncOffset new THREE.Vector3(); // 用于处理模型原点与质心不重合的情况 this._syncQuaternion new THREE.Quaternion(); } // 核心同步方法每一帧被World调用 update() { if (!this.visualObject || !this.physicsBody) return; // 同步位置 this.visualObject.position.copy(this.physicsBody.position); this.visualObject.position.add(this._syncOffset); // 应用偏移 // 同步旋转使用四元数避免万向节锁 this.visualObject.quaternion.copy(this.physicsBody.quaternion); } // 将实体添加到世界 addToWorld(worldInstance) { if (this.visualObject) { worldInstance.scene.add(this.visualObject); } if (this.physicsBody) { worldInstance.physicsWorld.addBody(this.physicsBody); } worldInstance.registerEntity(this); } // 从世界移除 removeFromWorld(worldInstance) { // ... 移除逻辑 } }实操心得使用cannon-es原始的cannon.js仓库已不活跃cannon-es是社区维护的ES模块版本与现代构建工具如Vite、Webpack配合更好推荐使用。四元数同步旋转同步务必使用quaternion而不是rotation。rotation欧拉角在插值和连续旋转时会产生万向节锁而quaternion能完美避免。Cannon.js的Body也直接提供了quaternion属性。偏移量_syncOffset这是一个非常重要的技巧。有时你从3D建模软件导出的模型其几何原点(0,0,0)点并不在它的物理质心上。比如一个角色模型原点可能在脚底但物理质心应该在躯干中央。这时你可以在子类中计算一个偏移向量在update时加到位置上就能让视觉模型“挂在”物理质心上正确显示。3.2 物理世界的精细配置物理世界的配置直接影响了交互的“手感”。在PhysicsSystem中我们进行如下设置export function createPhysicsWorld() { const world new CANNON.World(); world.gravity.set(0, -9.82, 0); // 标准重力Y轴向下 world.broadphase new CANNON.SAPBroadphase(world); // 使用SAP广相性能较好 world.allowSleep true; // 允许物体静止时“休眠”节省计算资源 // 创建通用材质 const groundMaterial new CANNON.Material(ground); const defaultMaterial new CANNON.Material(default); // 创建接触材质定义两者碰撞时的行为 const groundContactMaterial new CANNON.ContactMaterial( groundMaterial, defaultMaterial, { friction: 0.5, // 摩擦力 restitution: 0.3 // 弹性系数 (0为完全非弹性1为完全弹性) } ); world.addContactMaterial(groundContactMaterial); // 将材质默认关联到世界简化后续使用 world.defaultMaterial defaultMaterial; world.groundMaterial groundMaterial; return world; }参数选择背后的考量重力值-9.82是地球表面重力加速度的近似值。调大它物体会更快下落感觉世界更“重”调小则感觉像在月球上。根据游戏风格调整。SAPBroadphase广相Broadphase是碰撞检测的第一步用于快速找出“可能碰撞”的物体对排除明显不碰撞的。SAPSweep and Prune算法在物体均匀分布或移动不快时效率很高。如果物体非常多且高速运动可以考虑NaiveBroadphase简单但慢或网格划分。allowSleep务必开启。当一个物体速度近乎为零且一段时间不受力Cannon.js会将其置为“睡眠”状态不再计算其运动。当它再次被碰撞或施加力时会自动唤醒。这对性能提升巨大。摩擦力和弹性friction: 0.5是比较常见的值物体在平面上滑动会有适度阻力。restitution: 0.3表示碰撞后只会保留30%的速度大部分能量损失了模拟非完全弹性碰撞看起来更自然。乒乓球可以设为0.9橡皮球0.7铅球0.1。3.3 具体实体示例一个可交互的立方体看懂了基类我们来实现一个具体的BoxEntity。import { Entity } from ./Entity.js; export class BoxEntity extends Entity { constructor(world, options {}) { super(); const size options.size || 1; const color options.color || 0x00ff00; const mass options.mass || 1; // 质量为零则为静态物体 // 1. 创建视觉对象Three.js Mesh const geometry new THREE.BoxGeometry(size, size, size); const material new THREE.MeshStandardMaterial({ color: color }); this.visualObject new THREE.Mesh(geometry, material); this.visualObject.castShadow true; // 启用阴影 this.visualObject.receiveShadow true; // 2. 创建物理刚体Cannon.js Body const shape new CANNON.Box(new CANNON.Vec3(size/2, size/2, size/2)); // Cannon.js的Box是半扩展 this.physicsBody new CANNON.Body({ mass: mass }); this.physicsBody.addShape(shape); this.physicsBody.material world.physicsWorld.defaultMaterial; // 3. 设置初始位置 const pos options.position || { x: 0, y: 5, z: 0 }; this.visualObject.position.set(pos.x, pos.y, pos.z); this.physicsBody.position.set(pos.x, pos.y, pos.z); // 4. 如果需要设置初始速度或力 if (options.velocity) { this.physicsBody.velocity.set(options.velocity.x, options.velocity.y, options.velocity.z); } } }使用方式const redBox new BoxEntity(myWorld, { size: 2, color: 0xff0000, mass: 5, position: { x: 0, y: 10, z: 0 }, velocity: { x: 1, y: 0, z: 0 } // 给一个横向初速度 }); redBox.addToWorld(myWorld);注意事项尺寸对应Three.js的BoxGeometry(width, height, depth)参数是全尺寸。而Cannon.js的CANNON.Box(halfExtents)参数是半尺寸即从中心到边的距离。所以上面代码中将size/2传给Cannon.js确保物理边界和视觉边界完全匹配。这是新手最常见的错误之一会导致碰撞检测位置偏移。质量Mass这是区分静态和动态物体的关键。mass 0的物体是静态的如地面、墙壁不受重力影响其他物体撞到它它岿然不动。mass 0的物体是动态的会下落会被撞击。静态物体在性能上开销更小。阴影通过设置castShadow和receiveShadow并在渲染器中启用阴影可以立刻增加场景的立体感和真实感。记得也要为你的灯光如DirectionalLight开启castShadow。4. 性能优化与高级特性集成4.1 渲染性能避免每帧都创建对象在animate循环中最忌讳的就是每帧都new一个THREE.Vector3或THREE.Quaternion来做临时计算。垃圾回收GC会引发卡顿。正确的做法是复用对象。// 在Entity类中 this._tempVec3 new THREE.Vector3(); this._tempQuat new THREE.Quaternion(); update() { // 错误每帧创建新对象 // this.visualObject.position.copy(new THREE.Vector3().copy(this.physicsBody.position)); // 正确复用临时对象 this._tempVec3.set(this.physicsBody.position.x, this.physicsBody.position.y, this.physicsBody.position.z); this.visualObject.position.copy(this._tempVec3); // 或者更直接如果Cannon.js的Vec3提供了toArray方法 // this.visualObject.position.fromArray(this.physicsBody.position.toArray()); }对于从Cannon.js的CANNON.Vec3到Three.js的THREE.Vector3的转换如果同步非常频繁可以考虑在Entity基类中只保存一个THREE.Vector3引用然后直接修改其分量避免创建中间对象。// 假设我们扩展Entity让physicsBody.position和visualObject.position共享数据高级技巧需谨慎 // 这通常需要修改Cannon.js源码或使用Proxy不推荐初学者但要知道这个优化方向。4.2 物理性能合理使用形状和碰撞过滤简化碰撞形状视觉模型可以很复杂但物理形状一定要简单。一个复杂的人物模型用几个胶囊体CANNON.Cylinder或盒子来组合逼近远比用其精确的三角网格CANNON.Trimesh高效得多。Trimesh用于静态复杂地形如从高度图生成的地面用于动态物体性能很差。碰撞过滤Collision Filtering不是所有物体都需要互相碰撞。比如子弹不需要和子弹碰撞特效粒子不需要和任何东西碰撞。可以通过设置Body的collisionFilterGroup和collisionFilterMask来实现。// 定义分组 const GROUPS { DEFAULT: 1, PLAYER: 2, BULLET: 4, SENSOR: 8 }; // 设置玩家身体 playerBody.collisionFilterGroup GROUPS.PLAYER; playerBody.collisionFilterMask GROUPS.DEFAULT; // 只和DEFAULT组碰撞 // 设置子弹 bulletBody.collisionFilterGroup GROUPS.BULLET; bulletBody.collisionFilterMask GROUPS.PLAYER; // 只和PLAYER组碰撞这能显著减少不必要的碰撞检测计算。4.3 扩展添加第一人称控制器FPS Controller一个开放世界框架自然少不了让用户在其中行走探索。我们可以基于Entity概念创建一个PlayerEntity。export class PlayerEntity extends Entity { constructor(world, camera) { super(); this.camera camera; // 将摄像机作为玩家的“眼睛” this.moveSpeed 5; this.jumpForce 10; this.keysPressed {}; // 1. 创建物理身体胶囊体适合角色 const radius 0.5; const height 2; const capsuleShape new CANNON.Capsule(radius, height - 2 * radius); this.physicsBody new CANNON.Body({ mass: 70 }); // 成人质量约70kg this.physicsBody.addShape(capsuleShape); this.physicsBody.position.set(0, 5, 0); this.physicsBody.fixedRotation true; // 防止角色摔倒 this.physicsBody.linearDamping 0.9; // 增加移动阻尼模拟空气阻力 // 2. 视觉对象可以很简单甚至直接用摄像机第一人称 this.visualObject this.camera; // 将摄像机位置绑定到物理身体头部位置需加偏移 this._headOffset new THREE.Vector3(0, height / 2, 0); // 3. 监听键盘事件 window.addEventListener(keydown, (e) this.keysPressed[e.code] true); window.addEventListener(keyup, (e) this.keysPressed[e.code] false); } update(deltaTime) { super.update(); // 先同步基础位置 // 根据按键更新物理身体速度 const velocity new CANNON.Vec3(0, 0, 0); if (this.keysPressed[KeyW]) velocity.z - this.moveSpeed; if (this.keysPressed[KeyS]) velocity.z this.moveSpeed; if (this.keysPressed[KeyA]) velocity.x - this.moveSpeed; if (this.keysPressed[KeyD]) velocity.x this.moveSpeed; // 将局部速度转换到世界方向考虑摄像机朝向 if (velocity.lengthSquared() 0) { // 这里需要根据摄像机Y轴旋转将前向向量投影到XZ平面 const forward new THREE.Vector3(0, 0, -1); forward.applyQuaternion(this.camera.quaternion); forward.y 0; forward.normalize(); const right new THREE.Vector3(1, 0, 0); right.applyQuaternion(this.camera.quaternion); right.y 0; right.normalize(); const worldVelocity new CANNON.Vec3(); worldVelocity.x forward.x * velocity.z right.x * velocity.x; worldVelocity.z forward.z * velocity.z right.z * velocity.x; // 保持原有垂直速度如下落速度 worldVelocity.y this.physicsBody.velocity.y; this.physicsBody.velocity.copy(worldVelocity); } else { // 没有按键时水平速度归零只保留垂直速度重力 this.physicsBody.velocity.x 0; this.physicsBody.velocity.z 0; } // 跳跃检测例如按空格键且在地面上 if (this.keysPressed[Space] this._isOnGround()) { this.physicsBody.velocity.y this.jumpForce; } // 更新摄像机位置到身体头部 const headPos this.physicsBody.position; this.camera.position.set(headPos.x, headPos.y this._headOffset.y, headPos.z); } _isOnGround() { // 简化版地面检测通过向下发射射线raycast // 实际项目中需要更健壮的检测这里仅为示例 const ray new CANNON.Ray(this.physicsBody.position, new CANNON.Vec3(0, -1, 0)); ray.intersectWorld(this.world.physicsWorld); return ray.hasHit ray.result.distance 1.1; // 距离略大于胶囊体底部到中心的距离 } }实现要点fixedRotation设置为true可以防止角色在斜坡上或碰撞时翻滚符合第一人称控制直觉。速度控制直接设置velocity而非施加力响应更直接适合角色控制。linearDamping模拟阻力防止松开按键后无限滑动。运动方向将基于摄像机朝向的输入前、后、左、右转换为世界坐标系下的速度向量这是第一人称移动的核心。地面检测这是一个简化版。生产环境可能需要更复杂的方法如检查身体底部与地面的接触点body.contacts或使用多个射线。5. 常见问题排查与调试技巧5.1 物体穿透或碰撞抖动这是最常见的问题通常由以下原因导致时间步长问题fixedTimeStep设置过大如1/30或maxSubSteps设置过小。物理更新跟不上渲染帧率导致物体“瞬移”穿过薄墙。解决方案确保fixedTimeStep为1/60或更小maxSubSteps至少为3。形状不匹配如之前所述Three.js和Cannon.js的尺寸定义不一致。一个1x1x1的视觉盒子物理盒子也应该是半尺寸0.5。解决方案仔细检查创建形状时的参数使用调试视图见下文可视化物理形状。物体速度过快如果一帧内物体移动的距离超过了其自身尺寸就可能跳过碰撞检测“隧道效应”。解决方案对于子弹等高速物体启用连续碰撞检测CCD。在Cannon.js中可以设置body.ccdSpeedThreshold和body.ccdIterations。bulletBody.velocity.set(100, 0, 0); // 高速 bulletBody.ccdSpeedThreshold 0.1; // 速度超过此阈值就启用CCD bulletBody.ccdIterations 5; // CCD迭代次数5.2 物理模拟不稳定物体“爆炸”或剧烈抖动质量比例悬殊一个质量1的物体去碰撞一个质量0.0001的物体后者可能会获得极大的速度飞出去。解决方案尽量让相互碰撞的物体质量在同一数量级。对于静态物体地面质量必须为0。堆叠不稳定将多个盒子堆叠起来时容易摇晃甚至倒塌。解决方案增加接触材质的friction摩擦力。降低restitution弹性减少反弹能量。增加物理世界的迭代次数solver.iterations但这会消耗更多性能。world.solver.iterations 10; // 默认是10不稳定时可尝试增加到15或20数值误差累积长时间运行后微小误差可能导致问题。解决方案定期检查并重置极端位置的物体或使用body.sleep()让静止物体休眠。5.3 不可或缺的调试工具“看不见”物理世界是调试的最大障碍。必须将物理形状可视化。创建调试渲染器用Three.js的线框几何体WireframeGeometry或辅助对象BoxHelper,SphereHelper来绘制Cannon.js的形状。import { BoxHelper } from three; // 为每个物理实体创建一个对应的线框辅助对象 const debugBox new BoxHelper(entity.visualObject, 0xffff00); scene.add(debugBox); // 在实体update时同步更新debugBox的位置更系统化的做法是创建一个DebugRenderer类遍历物理世界中的所有Body根据其形状类型动态创建对应的线框网格并更新。使用现成的调试库社区有cannon-es-debugger这样的库可以一键将Cannon.js世界渲染成Three.js线框极大提升调试效率。控制台输出在关键位置如碰撞发生时添加日志输出物体的位置、速度、碰撞点信息。world.addEventListener(beginContact, (event) { console.log(碰撞发生在:, event.contact.impactPoint); console.log(物体A:, event.bodyA); console.log(物体B:, event.bodyB); });5.4 内存泄漏排查在单页应用SPA或频繁创建销毁物体的场景中内存泄漏需要关注。Three.js确保从场景scene中移除remove()不再需要的Object3D并调用其geometry.dispose()和material.dispose()方法。Cannon.js确保从物理世界world中移除removeBody()Body。Shape对象如果被多个Body共享需谨慎管理。事件监听器在实体或控制器被销毁时移除其绑定的全局事件监听器如keydown。框架在设计时通过Entity.removeFromWorld()方法集中处理这些清理工作确保资源被正确释放。这个基于Three.js和Cannon.js的开放世界基础框架其价值不在于实现了多么炫酷的效果而在于提供了一种清晰、可维护、可扩展的架构模式。它把复杂的物理渲染集成问题分解成了World、Entity、PhysicsSystem等职责单一的模块。当你基于这个框架去添加地形、水体、NPC、任务系统时你会发现自己是在“添砖加瓦”而不是在“搅和泥潭”。源码中每一行设计都是为了应对实际开发中那些琐碎但关键的挑战希望这份拆解能帮助你更好地理解它并构建出属于你自己的、更精彩的3D世界。本文还有配套的精品资源点击获取
返回列表