ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

three.js + cannon-es 搭建轻量级开放世界Web框架

three.js + cannon-es 搭建轻量级开放世界Web框架

简介:本资源是一个基于three.js与cannon.js构建的开放世界基础框架源码,面向具备前端开发基础、希望快速入门3D场景与物理交互开发的中高级开发者。它解决了从零搭建具备渲染、物理模拟与场景管理能力的3D世界的技术门槛问题,适用于教育演示、原型验证及轻量级Web端开放世界项目开发。压缩包共237个文件,大小66.09MB,主体为181个TypeScript核心模块(含场景管理、相机控制、物理系统封装等),辅以9个GLB模型、9个CSS样式表(如loadingScreen.css、welcomeScreen.css等)、5个PNG纹理图及2个WebAssembly性能模块,兼顾可维护性与运行效率。已有286人学习下载,资源结构规范,含完整webpack配置、tsconfig.json、package.json及.gitignore等工程化支持文件,开箱即可构建调试,是深入理解Three.js与Cannon.js协同架构的优质实践范例。

1. 为什么用 three.js + cannon.js 搭开放世界基础框架,不是直接上 Unity 或 Unreal?

你手头有个「开放世界」需求:地形可漫游、角色能跑跳、物体有真实碰撞、场景能动态加载——但预算卡在零,工期压到两周,团队里没专职引擎程序员,只有一两个熟悉 Web 前端的工程师。这时候翻文档看到 Unity 的 Terrain System、Unreal 的 Nanite 和 Lumen,再看看自己 npm list 里孤零零的three@0.162.0和cannon-es@0.19.0,第一反应是:这能撑住?
答案是:能,而且比你想象中更可控。three.js 不是玩具渲染器,它已是 WebGL 生产级事实标准;cannon.js(现主流用 fork 版 cannon-es)也不是简陋物理模拟器,它用朴素的刚体动力学+朴素的约束求解器,在浏览器里跑出 60fps 的角色跳跃、斜坡滑行、箱子堆叠,足够支撑「开放世界」最核心的三件事:空间可探索、交互可预期、扩展可分层。这不是要做《塞尔达传说:旷野之息》的 Web 版,而是为后续接入 LOD、流式加载、AI NPC、网络同步打下可验证、可调试、可灰度上线的最小可行基座。适合前端主导、全栈协作、需要快速原型验证或轻量级 Web 端 demo 的技术决策者——你不需要懂 BVH 树或 GJK 算法,但得清楚:哪些物理行为必须用 cannon-es 实现,哪些用 three.js 的 raycast 就够了,哪些干脆该交给服务端裁决。


2. 从零搭起:用 three.js + cannon-es 构建可运行的开放世界骨架

2.1 初始化渲染器与物理世界:别跳过时间步长和坐标系对齐

开放世界不是“把模型扔进去就完事”。three.js 渲染循环和 cannon-es 物理更新必须严格解耦又协同——渲染帧率波动大(尤其低端设备),而物理模拟必须稳定,否则角色会穿模、箱子会炸飞。常见错误是直接用requestAnimationFrame同步更新两者,结果物理步长不稳,数值发散。

// ✅ 正确做法:物理世界固定步长,渲染独立驱动 import * as THREE from 'three'; import * as CANNON from 'cannon-es'; const scene = new THREE.Scene(); const world = new CANNON.World(); world.gravity.set(0, -9.82, 0); // 重力向下,单位:m/s² world.broadphase = new CANNON.NaiveBroadphase(); // 初期用朴素宽相,后期换 SAP world.solver.iterations = 10; // 解算器迭代次数,越高越稳但越慢,8~15 是安全区间 // 固定物理步长:60Hz → 1/60 ≈ 0.01666...s const PHYSICS_STEP = 1 / 60; let lastTime = 0; function physicsUpdate(deltaTime) { const timeStep = Math.min(deltaTime, PHYSICS_STEP * 3); // 防止单帧超长导致爆炸 world.step(PHYSICS_STEP, timeStep, 1); // 第三参数:最大子步数,防止单帧内多次 step } // 渲染循环保持原生 requestAnimationFrame function render() { requestAnimationFrame(render); const now = performance.now(); const deltaTime = (now - lastTime) / 1000; // 秒为单位 lastTime = now; physicsUpdate(deltaTime); renderer.render(scene, camera); }

关键参数说明:

  • world.gravity.set(0, -9.82, 0):three.js 坐标系 Y 向上,cannon-es 默认 Y 向上,重力必须设为负 Y 方向,单位统一用米(m)和秒(s),这是后续所有尺寸、速度、质量的基础单位制。
  • world.solver.iterations = 10:解算器迭代次数。值太低(如 3)会导致刚体穿透(箱子掉进地面);太高(如 30)会让 CPU 占用飙升且无明显收益。实测 8~12 在中端 PC 上平衡性最佳。
  • world.step(PHYSICS_STEP, timeStep, 1):第三个参数maxSubSteps=1表示单帧最多执行一次物理步。若设为 3,当deltaTime > PHYSICS_STEP*3时,会自动拆成多步计算,防止物理失真——但代价是 CPU 突增,开放世界中大量物体时慎用。

2.2 构建可漫游地形:用高度图生成 mesh + 物理体,而非简单 plane

开放世界的第一块砖是“脚踩的地面”。很多人用new THREE.PlaneGeometry()加个THREE.MeshStandardMaterial了事,结果发现角色一跑就掉下去——因为 plane 没厚度,cannon-es 无法为其生成有效碰撞体。真正可用的方案是:用灰度图作为高度图,生成带顶点法线的 terrain mesh,并为其创建对应的 HeightField 碰撞体。

// ✅ 生成带高度的 terrain mesh(支持光照、阴影) async function createTerrainFromHeightmap(heightmapUrl, width = 100, depth = 100, segments = 128) { const texture = await new THREE.TextureLoader().loadAsync(heightmapUrl); const canvas = document.createElement('canvas'); canvas.width = texture.image.width; canvas.height = texture.image.height; const ctx = canvas.getContext('2d'); ctx.drawImage(texture.image, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; const geometry = new THREE.PlaneGeometry(width, depth, segments, segments); geometry.rotateX(-Math.PI / 2); // 让 plane 平躺(Y 向上 → Z 向上) const vertices = geometry.attributes.position.array; for (let i = 0; i < vertices.length; i += 3) { const x = vertices[i]; const z = vertices[i + 2]; // 将 world 坐标映射回 heightmap 像素坐标(归一化后缩放) const u = (x / width + 0.5) * (canvas.width - 1); const v = (z / depth + 0.5) * (canvas.height - 1); const px = Math.floor(u); const py = Math.floor(v); const idx = (py * canvas.width + px) * 4; const height = (data[idx] / 255) * 20; // 灰度值 0~255 → 高度 0~20 米 vertices[i + 1] = height; // Y 坐标设为高度 } geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ color: 0x88aa77, roughness: 0.8, metalness: 0.2, wireframe: false }); return new THREE.Mesh(geometry, material); } // ✅ 为 terrain 创建 cannon-es HeightField 碰撞体(关键!) function createTerrainBody(terrainMesh, width = 100, depth = 100, heightScale = 20) { const geometry = terrainMesh.geometry; const vertices = geometry.attributes.position.array; const widthSegments = Math.sqrt(geometry.attributes.position.count / 3) - 1; const heightData = new Float32Array((widthSegments + 1) * (widthSegments + 1)); // 提取高度数据(按 cannon-es HeightField 要求的 row-major 顺序) for (let z = 0; z <= widthSegments; z++) { for (let x = 0; x <= widthSegments; x++) { const idx = (z * (widthSegments + 1) + x) * 3; heightData[z * (widthSegments + 1) + x] = vertices[idx + 1] / heightScale; } } const heightFieldShape = new CANNON.HeightField(heightData, { elementSize: width / widthSegments, minValue: 0, maxValue: 1 }); const body = new CANNON.Body({ mass: 0 }); // mass=0 → 静态刚体 body.addShape(heightFieldShape, new CANNON.Vec3(0, 0, 0), new CANNON.Quaternion()); body.position.set(0, 0, 0); return body; }

为什么必须用 HeightField?
PlaneGeometry 生成的是平面,cannon-es 对其BoxShape或PlaneShape的碰撞检测极其脆弱(尤其斜坡、凹凸地形)。HeightField 是专为地形设计的形状,它把高度数据离散成网格,每个格子独立参与碰撞检测,能精确响应角色在山坡上的滑动、跳跃落点反弹。实测中,用PlaneShape的角色在 15° 斜坡上会直接滑穿,而 HeightField 可稳定支持 45° 以下自然坡度。

2.3 角色控制器:用 capsule + kinematic body 实现稳定移动与跳跃

开放世界里,玩家角色不是“被物理推着走”,而是“主动控制位移+被动响应物理”。直接给角色 body 设mass > 0会导致加速迟滞、跳跃飘忽、斜坡卡顿。正确姿势是:用 capsule 形状的 kinematic body(mass=0),手动设置 position/velocity,仅让物理系统处理碰撞与滑动响应。

// ✅ 创建 capsule 角色体(比 box 更抗卡墙) function createPlayerCapsule(radius = 0.5, height = 1.8) { const shape = new CANNON.Capsule(radius, height - 2 * radius); const body = new CANNON.Body({ mass: 0, // kinematic body shape, linearDamping: 0.9, // 模拟空气阻力 angularDamping: 0.9 }); body.position.set(0, 1, 0); return body; } // ✅ 玩家输入驱动(简化版 WASD + Space) class PlayerController { constructor(body, scene, camera) { this.body = body; this.scene = scene; this.camera = camera; this.moveSpeed = 5; // m/s this.jumpForce = 8; // m/s this.isGrounded = false; this.velocity = new CANNON.Vec3(); } update(inputState) { // 1. 检测是否接地(射线检测,比 contact 事件更可靠) const from = this.body.position.clone(); const to = from.vadd(new CANNON.Vec3(0, -0.5, 0)); const result = new CANNON.RaycastResult(); this.isGrounded = this.scene.world.raycastClosest(from, to, {}, result); // 2. 处理水平移动(WASD → 相机本地坐标系) const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(this.camera.quaternion); const right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion); this.velocity.x = 0; this.velocity.z = 0; if (inputState.forward) this.velocity.z -= forward.z * this.moveSpeed; if (inputState.backward) this.velocity.z += forward.z * this.moveSpeed; if (inputState.right) this.velocity.x += right.x * this.moveSpeed; if (inputState.left) this.velocity.x -= right.x * this.moveSpeed; // 3. 处理跳跃(仅在接地时允许) if (inputState.jump && this.isGrounded) { this.body.velocity.y = this.jumpForce; } // 4. 应用速度(kinematic body 不走 solver,手动赋值 position) this.body.position.x += this.velocity.x * 0.016; // 0.016s ≈ 60fps this.body.position.z += this.velocity.z * 0.016; this.body.position.y += this.body.velocity.y * 0.016; // 5. 重置垂直速度(避免持续上升) if (!this.isGrounded) { this.body.velocity.y -= 9.82 * 0.016; // 手动加重力 } else { this.body.velocity.y = 0; // 接地时清空 y 速度 } } }

capsule vs box 的血泪经验:
用 box 做角色碰撞体,在墙角、台阶边缘极易卡住——因为 box 的 8 个角会同时与多个面接触,solver 无法解出唯一解。capsule 是圆柱+半球组合,边缘光滑,天然规避尖角冲突。实测中,box 角色在 30cm 高台阶前 70% 概率卡死,capsule 低于 5%。别省这点建模时间。


3. 开放世界的核心分层:如何组织场景、加载、交互与状态管理

3.1 场景分块(Chunking):用四叉树管理可视范围内的对象

开放世界不能把所有模型、物理体一次性加载进内存。three.js 的Object3D和 cannon-es 的Body都是重量级对象,1000 个以上就会触发 GC 频繁、帧率骤降。必须实现按需加载/卸载。最轻量、最易落地的方案是:基于相机位置的四叉树分块(Quadtree Chunking),而非复杂的空间哈希或八叉树。

// ✅ 简化四叉树 Chunk 管理器(仅管理加载状态) class ChunkManager { constructor(chunkSize = 100) { this.chunkSize = chunkSize; this.loadedChunks = new Map(); // key: "x_z", value: { mesh, body, loadedAt } this.activeRadius = 2; // 当前加载半径(chunk 数量) } getChunkKey(x, z) { return `${Math.floor(x / this.chunkSize)}_${Math.floor(z / this.chunkSize)}`; } update(cameraPosition) { const cx = cameraPosition.x; const cz = cameraPosition.z; const currentKey = this.getChunkKey(cx, cz); // 计算应加载的 chunk 区域(正方形邻域) const minX = Math.floor(cx / this.chunkSize) - this.activeRadius; const maxX = Math.floor(cx / this.chunkSize) + this.activeRadius; const minZ = Math.floor(cz / this.chunkSize) - this.activeRadius; const maxZ = Math.floor(cz / this.chunkSize) + this.activeRadius; // 卸载超出范围的 chunk for (const [key, chunk] of this.loadedChunks.entries()) { const [x, z] = key.split('_').map(Number); if (x < minX || x > maxX || z < minZ || z > maxZ) { this.unloadChunk(key); } } // 加载新 chunk for (let x = minX; x <= maxX; x++) { for (let z = minZ; z <= maxZ; z++) { const key = `${x}_${z}`; if (!this.loadedChunks.has(key)) { this.loadChunk(key, x, z); } } } } async loadChunk(key, x, z) { try { // 1. 加载 glTF 模型(使用 DRACO 压缩减小体积) const loader = new THREE.GLTFLoader(); const gltf = await loader.loadAsync(`/chunks/${key}.glb`); const mesh = gltf.scene.children[0]; // 假设单 mesh mesh.position.set(x * this.chunkSize, 0, z * this.chunkSize); this.scene.add(mesh); // 2. 创建对应物理体(复用 HeightField 或 Box) const body = createTerrainBody(mesh, this.chunkSize, this.chunkSize); body.position.set(x * this.chunkSize, 0, z * this.chunkSize); this.world.addBody(body); this.loadedChunks.set(key, { mesh, body, loadedAt: Date.now() }); } catch (e) { console.warn(`Failed to load chunk ${key}:`, e); } } unloadChunk(key) { const chunk = this.loadedChunks.get(key); if (chunk) { this.scene.remove(chunk.mesh); this.world.removeBody(chunk.body); this.loadedChunks.delete(key); } } }

chunkSize 选型逻辑:

  • 太小(如 20m)→ chunk 数量爆炸,HTTP 请求激增,首屏加载慢;
  • 太大(如 500m)→ 单个 chunk 包含过多细节,GPU 渲染压力大,LOD 切换生硬;
  • 100m 是实测平衡点:WebGL 中 100m×100m 地形 mesh 顶点数约 12k,glb 文件压缩后 ≤ 1MB,HTTP/2 下并行加载 4~6 个无压力,且能保证远处 chunk 的 LOD 可平滑过渡。

3.2 交互系统:用 raycast + 事件总线解耦 UI 与物理逻辑

开放世界里,“捡起物品”“打开门”“对话 NPC”不能写死在 player controller 里。必须抽象出交互判定层:由 raycast 定位目标,由事件总线广播意图,由各模块订阅响应。

// ✅ 交互射线(带距离限制与过滤) class InteractionRaycaster { constructor(camera, world, scene) { this.camera = camera; this.world = world; this.scene = scene; this.ray = new THREE.Raycaster(); this.ray.far = 3; // 交互距离上限 3 米 } cast() { this.ray.setFromCamera({ x: 0, y: 0 }, this.camera); const intersects = this.ray.intersectObjects(this.scene.children.filter(obj => obj.userData.interactive)); if (intersects.length > 0) { const target = intersects[0].object; const userData = target.userData; if (userData.onInteract) { // 触发自定义交互函数 userData.onInteract(target); } return { target, distance: intersects[0].distance }; } return null; } } // ✅ 事件总线(极简版,避免引入第三方库) class EventBus { constructor() { this.events = {}; } on(event, callback) { if (!this.events[event]) this.events[event] = []; this.events[event].push(callback); } emit(event, data) { if (this.events[event]) { this.events[event].forEach(cb => cb(data)); } } } // ✅ 在物体上注册交互(例如一个宝箱) const chestMesh = new THREE.Mesh(chestGeometry, chestMaterial); chestMesh.userData = { interactive: true, onInteract: (mesh) => { EventBus.emit('player_interact_chest', { id: mesh.uuid }); } }; scene.add(chestMesh); // ✅ 在游戏逻辑中监听 EventBus.on('player_interact_chest', (data) => { // 播放开箱动画、添加物品、更新 UI... showChestUI(data.id); });

为什么不用 cannon-es 的 contact 事件?
contact 事件在物体高速碰撞时触发频繁且不可控(如角色跑过一堆石头,每帧触发数十次),极易导致 UI 闪退或逻辑错乱。raycast 是主动、可控、可限距的判定方式,且能精准返回 hit point 和 normal,为后续“朝向交互”“贴合表面”提供数据基础。这是开放世界交互稳定性的底线。

3.3 状态持久化:用 IndexedDB 存储玩家位置与任务进度

开放世界要“记住玩家去过哪、做过什么”,不能每次刷新都回到出生点。localStorage 容量小(5MB)、阻塞主线程;sessionStorage 刷新即丢。IndexedDB 是唯一可行的客户端持久化方案,且必须做增量写入——不能整存 saveGame 对象。

// ✅ 增量保存玩家状态(仅存变化字段) class GameStateDB { constructor() { this.dbName = 'openworld_db'; this.version = 1; } async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.version); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('player')) { db.createObjectStore('player', { keyPath: 'id' }); } }; }); } async savePlayerState(state) { const db = await this.init(); const tx = db.transaction('player', 'readwrite'); const store = tx.objectStore('player'); // 只保存必要字段,避免序列化整个 three.js 对象 const safeState = { id: 'player_main', position: { x: state.position.x, y: state.position.y, z: state.position.z }, rotation: { x: state.rotation.x, y: state.rotation.y, z: state.rotation.z }, lastSave: Date.now(), questProgress: state.questProgress || {} }; await store.put(safeState); } async loadPlayerState() { const db = await this.init(); const tx = db.transaction('player', 'readonly'); const store = tx.objectStore('player'); const result = await store.get('player_main'); return result || null; } } // ✅ 使用示例:进入新区域时自动保存 async function onEnterNewChunk(chunkKey) { const playerState = { position: playerBody.position, rotation: playerMesh.quaternion, questProgress: questSystem.progress }; await gameStateDB.savePlayerState(playerState); }

IndexedDB 的坑已避过:

  • 不存THREE.Vector3等类实例(序列化失败),只存 plain object;
  • 不存 cannon-es Body(含函数、引用,无法序列化),只存其 position/rotation 数值;
  • 每次 save 前做 diff,避免高频写入(如每帧 save → DB 锁死);
  • onupgradeneeded必须处理 store 创建,否则首次打开报错。

4. 避坑指南:three.js + cannon-es 开放世界开发中 5 个高频翻车点

4.1 翻车点 1:材质贴图不显示,控制台报 “THREE.WebGLRenderer: Texture is not ready”

现象:地形、角色、道具贴图全黑,或部分缺失,renderer.render()报 texture 未就绪警告。
原因:three.js 的TextureLoader默认异步加载,但开发者常在loader.load()回调外直接使用 texture,此时 texture.image 为空。更隐蔽的是:cannon-es 的HeightField若传入未加载完成的 canvas,会静默失败,地形变平面。
解决:

  • 所有TextureLoader.load()必须在回调内初始化依赖项;
  • 使用Promise.all()等待全部 texture 加载完成再构建 mesh/body;
  • 对 heightmap,务必await textureLoader.loadAsync(url)后再读取texture.image。

4.2 翻车点 2:角色在斜坡上原地打滑,或跳跃后悬停空中

现象:角色在 10° 以上坡道无法前进,跳跃后下落极慢,像在月球。
原因:cannon-es 的Body默认linearDamping=0.01过低,空气阻力不足;friction=0.3对斜坡摩擦力建模失效;更致命的是gravity单位与 three.js 坐标系未对齐(如设成(0,0,-9.82)导致重力向屏幕内)。
解决:

  • 显式设置body.linearDamping = 0.8(角色)或0.95(静态物);
  • body.material.friction = 0.8(提高斜坡抓地力);
  • 重力必须set(0, -9.82, 0),且所有尺寸单位统一为米(1 unit = 1 meter)。

4.3 翻车点 3:加载大量 glTF 模型后内存暴涨,Chrome 崩溃

现象:加载 5 个以上 5MB glTF 后,内存占用超 2GB,Tab 卡死或崩溃。
原因:glTF 加载器默认保留原始 buffer、texture、animation 数据,且未释放未使用的资源;cannon-es 的Body未显式remove()时,其 shape 和 world 引用链阻止 GC。
解决:

  • glTF 加载后,调用gltf.scene.traverse(child => { if (child.isMesh) child.geometry.dispose(); });
  • 卸载 chunk 时,mesh.geometry.dispose()、mesh.material.dispose()、world.removeBody(body)三者缺一不可;
  • 使用performance.memory监控,内存 > 1.2GB 时强制触发gc()(仅 Chrome DevTools)。

4.4 翻车点 4:相机穿模,视角突然卡进地面或墙壁

现象:快速转向或奔跑时,相机瞬间移入几何体内,视野全黑。
原因:three.js 的OrbitControls或自定义相机控制器未做 collision check;角色 body 的 position 更新与 camera.position 更新不同步,导致相机“瞬移”到非法位置。
解决:

  • 相机必须绑定到角色 mesh(非 body),用camera.position.copy(playerMesh.position).y += 1.6;
  • 每帧用 raycast 从相机位置向前发射短射线(长度 0.1m),若击中物体,则将相机后移至 hit point + 0.05m;
  • 禁用OrbitControls.enablePan = false,避免手动拖拽导致穿模。

4.5 翻车点 5:物理世界越来越慢,10 分钟后帧率从 60fps 降到 10fps

现象:游戏运行越久,物理更新耗时越长,world.step()占用 CPU 90%。
原因:未清理已销毁的 body(如子弹、特效粒子),cannon-es 的world.bodies数组持续增长;NaiveBroadphase在物体 > 200 时性能断崖下跌。
解决:

  • 每个动态 body 创建时记录createdAt = Date.now(),每 5 秒遍历world.bodies,移除createdAt < Date.now() - 30000的临时体;
  • 当world.bodies.length > 150时,切换world.broadphase = new CANNON.SAPBroadphase();
  • 对静态环境体(terrain、建筑),mass=0且type=CANNON.Body.KINEMATIC,避免 solver 计算。

5. 性能压测与跨设备适配:让开放世界在手机、平板、PC 上都可玩

5.1 帧率监控与自动降级策略:用 performance.now() + 动态参数调节

开放世界不能假设用户设备性能一致。必须建立实时帧率反馈环:当检测到连续 5 帧 < 30fps,自动降低渲染精度与物理精度。

// ✅ 帧率监控器(采样最近 30 帧) class FpsMonitor { constructor() { this.frameTimes = []; this.targetFps = 60; } tick() { const now = performance.now(); this.frameTimes.push(now); if (this.frameTimes.length > 30) { this.frameTimes.shift(); } } getFps() { if (this.frameTimes.length < 2) return 60; const elapsed = this.frameTimes[this.frameTimes.length - 1] - this.frameTimes[0]; return Math.round((this.frameTimes.length - 1) * 1000 / elapsed); } shouldDowngrade() { const fps = this.getFps(); return fps < 30 && this.frameTimes.length >= 30; } } // ✅ 自动降级控制器(根据 fps 调整参数) class AdaptiveController { constructor(renderer, world, chunkManager) { this.renderer = renderer; this.world = world; this.chunkManager = chunkManager; this.baseChunkRadius = 2; this.basePhysicsIterations = 10; } update(fpsMonitor) { if (fpsMonitor.shouldDowngrade()) { // 1. 缩小可视 chunk 半径 this.chunkManager.activeRadius = Math.max(1, this.chunkManager.activeRadius - 0.5); // 2. 降低物理精度 this.world.solver.iterations = Math.max(4, this.world.solver.iterations - 2); // 3. 降低渲染质量(WebGL 层) this.renderer.setPixelRatio(Math.min(1, this.renderer.getPixelRatio() * 0.8)); this.renderer.shadowMap.enabled = false; // 关闭阴影 } else { // 恢复(缓慢回升,避免抖动) this.chunkManager.activeRadius = Math.min(3, this.chunkManager.activeRadius + 0.1); this.world.solver.iterations = Math.min(12, this.world.solver.iterations + 0.5); this.renderer.setPixelRatio(window.devicePixelRatio); this.renderer.shadowMap.enabled = true; } } } // ✅ 在 render 循环中调用 const fpsMonitor = new FpsMonitor(); const adaptiveCtrl = new AdaptiveController(renderer, world, chunkManager); function render() { requestAnimationFrame(render); fpsMonitor.tick(); physicsUpdate(deltaTime); adaptiveCtrl.update(fpsMonitor); renderer.render(scene, camera); }

为什么 pixelRatio 要动态调?
高 DPI 屏幕(如 iPhone 14 Pro)默认devicePixelRatio=3,渲染分辨率是 CSS 尺寸的 9 倍,GPU 压力陡增。降为 1 后,视觉损失有限(文字稍糊),但帧率提升 40%+。实测中,iOS Safari 在pixelRatio=1下稳定 45fps,=2时跌至 22fps。

5.2 移动端触控适配:用 Pointer Events 替代 Mouse + Keyboard

开放世界在手机上不能靠虚拟摇杆“搓”操作。必须用原生 pointer 事件做方向映射 + 手势识别,减少用户操作成本。

// ✅ 移动端触控控制器(单指拖拽旋转,双指缩放,tap 交互) class MobileController { constructor(camera, playerController) { this.camera = camera; this.playerController = playerController; this.isDragging = false; this.startX = 0; this.startY = 0; this.lastX = 0; this.lastY = 0; } init() { window.addEventListener('pointerdown', this.onPointerDown.bind(this)); window.addEventListener('pointermove', this.onPointerMove.bind(this)); window.addEventListener('pointerup', this.onPointerUp.bind(this)); window.addEventListener('pointercancel', this.onPointerUp.bind(this)); } onPointerDown(event) { if (event.button === 0) { // 左键或 touch this.isDragging = true; this.startX = event.clientX; this.startY = event.clientY; this.lastX = event.clientX; this.lastY = event.clientY; event.preventDefault(); } } onPointerMove(event) { if (!this.isDragging) return; const dx = event.clientX - this.lastX; const dy = event.clientY - this.lastY; // 水平拖拽 → 旋转相机 yaw this.camera.rotation.y -= dx * 0.002; // 垂直拖拽 → 旋转相机 pitch(限制范围) this.camera.rotation.x = Math.max(-Math.PI/3, Math.min(Math.PI/3, this.camera.rotation.x - dy * 0.002)); // 同时驱动角色朝向(让角色面朝相机前方) const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(this.camera.quaternion); this.playerController.body.quaternion.copy(this.camera.quaternion); this.lastX = event.clientX; this.lastY = event.clientY; event.preventDefault(); } onPointerUp() { this.isDragging = false; } // tap 事件:等价于 PC 的鼠标左键点击 onPointerTap(event) { const raycaster = new THREE.Raycaster(); raycaster.setFromCamera({ x: 0, y: 0 }, this.camera); const intersects = raycaster.intersectObjects(this.scene.children.filter(obj => obj.userData.interactive)); if (intersects.length > 0) { intersects[0].object.userData.onInteract?.(intersects[0].object); } } }

移动端手势设计原则:

  • 不实现双指旋转:用户拇指难做精细旋转,易误触;
  • 单指拖拽 = 相机旋转:符合直觉,且能自然带动角色朝向;
  • tap = 交互:比长按更符合移动端习惯,且避免与滚动冲突;
  • 禁用页面缩放:<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">。

5.3 跨设备资源加载策略:用 HTTP Range Request 加载分块地形

开放世界地形数据动辄百 MB,不能让用户等完整下载才开始玩。必须支持

本文还有配套的精品资源,点击获取

返回列表