十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Three.js与Cannon.js构建3D物理世界:双世界同步与性能优化实战

Three.js与Cannon.js构建3D物理世界:双世界同步与性能优化实战 简介这是一套面向前端开发者与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端实现3DThree.js几乎是唯一成熟且生态丰富的选择。它封装了WebGL的复杂性提供了场景、相机、渲染器、几何体、材质、光源等一整套高层API让我们能专注于内容创作而非图形学细节。而物理引擎方面Cannon.js是纯JavaScript实现与Three.js同属一个技术栈集成起来天然友好文档和社区案例也相对丰富。虽然后来有了更强大的Ammo.jsBullet物理引擎的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)形状它的计算开销巨大。静态物体 mass0地形永远不会移动将其质量设为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 CullingThree.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 物理优化休眠SleepingCannon.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来加入车辆、敌人、可开关的门等更复杂的交互元素。开发中最享受的时刻莫过于看到自己创建的角色在一个由代码生成的、符合物理规律的世界里自由奔跑那种创造世界的成就感正是驱动我们不断前行的动力。本文还有配套的精品资源点击获取
返回列表