资讯详情

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

📅 2026/10/28 7:25:20 | 华诺云谱 👁 阅读
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 里孤零零的three0.162.0和cannon-es0.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)第三个参数maxSubSteps1表示单帧最多执行一次物理步。若设为 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 }); // mass0 → 静态刚体 body.addShape(heightFieldShape, new CANNON.Vec3(0, 0, 0), new CANNON.Quaternion()); body.position.set(0, 0, 0); return body; }为什么必须用 HeightFieldPlaneGeometry 生成的是平面cannon-es 对其BoxShape或PlaneShape的碰撞检测极其脆弱尤其斜坡、凹凸地形。HeightField 是专为地形设计的形状它把高度数据离散成网格每个格子独立参与碰撞检测能精确响应角色在山坡上的滑动、跳跃落点反弹。实测中用PlaneShape的角色在 15° 斜坡上会直接滑穿而 HeightField 可稳定支持 45° 以下自然坡度。2.3 角色控制器用 capsule kinematic body 实现稳定移动与跳跃开放世界里玩家角色不是“被物理推着走”而是“主动控制位移被动响应物理”。直接给角色 body 设mass 0会导致加速迟滞、跳跃飘忽、斜坡卡顿。正确姿势是用 capsule 形状的 kinematic bodymass0手动设置 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 顶点数约 12kglb 文件压缩后 ≤ 1MBHTTP/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默认linearDamping0.01过低空气阻力不足friction0.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 后内存占用超 2GBTab 卡死或崩溃。原因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、建筑mass0且typeCANNON.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默认devicePixelRatio3渲染分辨率是 CSS 尺寸的 9 倍GPU 压力陡增。降为 1 后视觉损失有限文字稍糊但帧率提升 40%。实测中iOS Safari 在pixelRatio1下稳定 45fps2时跌至 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 nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno。5.3 跨设备资源加载策略用 HTTP Range Request 加载分块地形开放世界地形数据动辄百 MB不能让用户等完整下载才开始玩。必须支持本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑