ARTICLE DETAIL

资讯详情

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

用Three.js与cannon-es构建纯前端3D台球游戏

用Three.js与cannon-es构建纯前端3D台球游戏 说实话我是被一个标题给勾住的——“一马当先王者3D台球开工特价来袭”本以为是什么营销活动页顺手点进去一看发现是个基于网页的3D台球小游戏演示。因为我最近恰好一直在折腾 Three.js 相关的东西索性顺着这个方向用纯前端方式完整还原了一版真正可玩的3D台球。这篇文章就把整个从零到一的过程做个复盘包括技术选型、场景搭建、物理引擎接入、击球手感调优还有那些文档里绝对不会写的各种坑。先给个结论如果你想在网页里做类似“3D桌球”“3D旋转相册”这类交互型小项目Three.js cannon-es 这套组合是目前最顺手的方案。它不需要安装重型引擎不需要懂C一个浏览器、一个编辑器就能跑起来。这篇文章适合有一定HTML/CSS/JavaScript基础、想快速上手浏览器端3D游戏开发的朋友也可以当作一个从“会看Three.js文档”到“能独立做个能玩的项目”的过渡案例。1. 先别急着写代码3D台球游戏的整体思路1.1 为什么选Three.js而不是Unity或原生WebGL很多人一听到“做个3D游戏”第一反应是Unity、Unreal再不济也是Godot。但网页端有一个天然限制你不能让用户先装个几GB的引擎再打开你的游戏。Three.js 直接把3D渲染封装成了JavaScript库页面加载一个CDN脚本就能用零安装、零构建工具也能跑非常适合这种“开工特价”式的小项目。那么原生 WebGL 行不行行但你得自己处理缓冲区、着色器、矩阵变换、深度测试、光照计算……光是让一个球在球桌上滚动可能就要几百行底层代码。Three.js的意义在于把这些问题抽象成场景、相机、网格、材质这样的概念让你把精力放在“游戏怎么做”而不是“像素怎么渲染”上。还有一点很关键Three.js 的生态里有大量现成组件比如 OrbitControls 做相机控制GLTFLoader 导入外部模型Raycaster 做鼠标拾取。这些能力单独拎出来都够写好几篇文章但在 Three.js 里就是几行代码的事情。1.2 先用最小可行性版本剪掉不需要的东西做任何小游戏第一件事不是写代码而是划边界。我给自己定的“3D台球MVP”包含这三个功能台球桌有完整的桌面、库边、六个袋口球能在桌面上滚动并和库边发生反弹。玩家能用鼠标或触屏完成瞄准和蓄力击球白球撞击彩球彩球进袋。彩球全部进袋后判胜白球进袋则复位重摆。这个版本明确不碰的东西也很多不做球杆动画不做严格“8球规则”的花色区分不做多人联机不做球之间的精细旋转摩擦模拟。这些不是做不到而是会增加好几倍的复杂度MVP阶段砍掉它们才能把核心体验——击球和碰撞——做扎实。后面的内容全部围绕这个最小闭环展开如果你后续想加球杆动画或者完整规则在这个基础上扩展就好。2. 从零搭建3D台球桌场景2.1 环境准备CDN快速引入Three.js和cannon-es先搭建最基础的项目结构。我这里没有用任何打包工具直接建了一个HTML文件用 ES Module 的方式从CDN引入依赖这样你复制代码就能跑省去配置Webpack或Vite的麻烦。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title王者3D台球/title style html, body { margin: 0; padding: 0; overflow: hidden; } canvas { display: block; } /style /head body script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js, three/addons/: https://unpkg.com/three0.160.0/examples/jsm/ } } /script script typemodule import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js; // 这里后续会继续写项目代码 /script /body /html这里有个细节importmap 是在浏览器层面做模块路径映射把three这个字符串指向实际的CDN文件。Three.js 最新版本已经到了 r160 以上但核心API从 r130 左右开始就很稳定了这篇内容在 r160 上实测没问题。物理引擎我选 cannon-es它是老牌cannon.js的维护分支修复了很多历史bug而且API保持简单。同样用CDN引入import * as CANNON from https://unpkg.com/cannon-es0.20.0/dist/cannon-es.js;建议不要一次性引入太多库先跑通渲染再挂物理否则出错的时候你都不确定是哪个环节的问题。2.2 台球桌尺寸和坐标规划3D项目里单位不统一是灾难的开端。Three.js 本身没有强制单位但物理引擎的数值都是按“1个单位1米”的常规物理单位设计的。我直接拿真实的美式8尺台球桌尺寸做参考换算到游戏单位。部件真实尺寸游戏单位桌面内径约2540mm x 1270mm2.54 x 1.27台球直径57.15mm0.057简化用0.06库边高度约40mm0.04桌高约800mm0.8袋口直径约120mm0.12桌面中心放在原点桌面高度定在0.8这样球放在桌面上时球心坐标是 (x, 0.83, z)因为球半径是0.03桌面顶部是0.8。这个0.03的高度差必须记住后面设置球的初始位置时经常要算。把坐标写在纸上或者代码注释里是一个好习惯。你会发现后面写瞄准线、判定进球、同步物理体位置时都需要频繁查这些数值。2.3 灯光组合为什么台球桌需要三种光源新手容易犯的错是只加一个环境光然后发现场景平平无奇球看起来像扁平贴纸。真实台球厅上方有大面积照明桌面上有柔和的主光球表面还有高光反射。为了模拟这个效果我用三组光源组合。// 环境光保证阴影面不至于死黑 const ambientLight new THREE.AmbientLight(0xffffff, 0.5); scene.add(ambientLight); // 主平行光模拟台球厅顶灯产生阴影 const dirLight new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(2, 6, 3); dirLight.castShadow true; dirLight.shadow.mapSize.width 2048; dirLight.shadow.mapSize.height 2048; scene.add(dirLight); // 辅助点光源放在球桌中心附近给球体补一点高光 const pointLight new THREE.PointLight(0xffffff, 0.6); pointLight.position.set(0, 1.2, 0); scene.add(pointLight);这里解释一下为什么这样配环境光负责基础亮度但它没有方向无法塑造立体感平行光提供主要方向和阴影让球和库边产生明暗对比点光源在球桌上方近距离补光让台球的高光更明显看起来材质更“润”。三者的强度比例在实际运行时要根据画面效果微调我给的这几个数值是调试后的经验值基本能还原台球厅那种明亮的氛围。3. 用代码“建模”球体与球桌的实现细节3.1 球体建模与数字贴图的Canvas方案3D建模通常依赖 Blender、3ds Max 这类工具但台球场景不复杂用代码建模就够了省去导出、转换的环节。球体直接用SphereGeometry即可。真正麻烦的是球面上的编号数字——你没法在代码里用文字直接生成一个网格贴在球体表面。我的方案是用 Canvas 画出一张圆形贴图再作为纹理贴到球体上。数字1的球画一个带黄圈的圆形把“1”写在圆心然后把整张贴图环绕到球面。这个思路本质上和做简易3D纹理贴图是一样的只不过纹理不是从网上下载的图片而是动态生成的。function createBallTexture(number, mainColor) { const canvas document.createElement(canvas); canvas.width 128; canvas.height 128; const ctx canvas.getContext(2d); // 画一个圆形底色 ctx.beginPath(); ctx.arc(64, 64, 60, 0, Math.PI * 2); ctx.fillStyle mainColor; ctx.fill(); // 画一个白色圆底用于显示数字 ctx.beginPath(); ctx.arc(64, 64, 32, 0, Math.PI * 2); ctx.fillStyle #ffffff; ctx.fill(); // 写数字 ctx.fillStyle #000000; ctx.font bold 36px Arial; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(String(number), 64, 66); const texture new THREE.CanvasTexture(canvas); return texture; } function createBall(number, color) { const geometry new THREE.SphereGeometry(0.03, 24, 24); const texture createBallTexture(number, color); const material new THREE.MeshStandardMaterial({ map: texture, roughness: 0.2, metalness: 0.1 }); return new THREE.Mesh(geometry, material); }需要提醒的是毗连球类编号贴图时球体纹理的UV是个球面环绕数字会被拉伸到球的侧面。虽然不完美但在游戏视角下玩家一般会从斜上方45度俯视数字居中偏上的位置基本能被看到。如果你有更洁癖的追求可以给每个球单独生成对齐到正面的贴图坐标那就要重写UV了MVP阶段不值得这样搞。白球不需要数字贴图直接用纯白色高光材质代码更简单。彩球按照标准台球的12种颜色来分配即可我建议用数组存颜色循环生成15颗球。球之间的初始位置要注意不能重叠三角形摆法每颗球的间距至少大于1个球的直径。3.2 球桌建模库边、袋口和木质桌面球桌是典型的“视觉模型”和“物理模型”分开的场景。视觉上桌面是一块没有厚度的平面或者一个长方体物理上必须有一层实实在在的碰撞体否则球会直接掉下去。我在渲染层做了一个长2.54、宽1.27的Box作为桌面主体边缘用绿色绒布颜色的材质。库边是桌面上凸起的四条长条Box颜色用深棕色模拟木质边框。袋口的位置则留空视觉上用一个黑色圆柱体嵌进桌面看起来像球洞。// 桌面主体 const tableTop new THREE.Mesh( new THREE.BoxGeometry(2.54, 0.04, 1.27), new THREE.MeshStandardMaterial({ color: 0x1b6b3a }) ); tableTop.position.y 0.78; tableTop.receiveShadow true; scene.add(tableTop); // 库边长边 const cushionMat new THREE.MeshStandardMaterial({ color: 0x4a2f1a, roughness: 0.6 }); function createCushion(w, h, d, x, y, z) { const mesh new THREE.Mesh(new THREE.BoxGeometry(w, h, d), cushionMat); mesh.position.set(x, y, z); mesh.castShadow true; scene.add(mesh); return mesh; } // 左右长库边 createCushion(2.54, 0.08, 0.06, 0, 0.84, 0.65); createCushion(2.54, 0.08, 0.06, 0, 0.84, -0.65); // 前后短库边 createCushion(0.06, 0.08, 1.19, 1.28, 0.84, 0); createCushion(0.06, 0.08, 1.19, -1.28, 0.84, 0);库边的碰撞体需要略微覆盖球桌内边界并且高度要超过球的半径否则球高速撞向库边时可能从上方“飞”出去。长库边的位置y0.84桌面高度0.78库边中心到桌面顶部的相对高度正好是0.06大于球半径0.03这个比例刚好挡住球。袋口的位置在四角和两边中间。视觉效果是在桌面边缘位置塞一个半径0.06、高度略高于桌面的黑色圆柱体物理效果靠后面物理引擎里留空来实现。3.3 碰撞体设计的核心原则初学者最容易犯的错是把“视觉模型”直接当“碰撞模型”用。Three.js 的Mesh只负责渲染不参与碰撞计算如果直接把渲染模型丢给物理引擎三角面片数量动辄几千上万计算量直接爆掉。正确做法是给物理引擎提供简单几何体球是球体桌边是盒子桌面是盒子。这也是为什么我不推荐在这类小项目中用“3D点云”或者“3D重建”的碰撞检测思路——那是工业扫描、医学影像等领域的技术方案做网页游戏属于杀鸡用牛刀。物理引擎用原始几何体就够了碰撞计算稳定、快速而且不容易出现“卡进墙体”的诡异现象。库里一共有16颗球1颗白球15颗彩球每个球对应一个Sphere形状的刚体桌面、库边对应Box形状的静态刚体。这样场景里总共就几十个碰撞体性能上毫无压力。4. 物理系统接入让球真的“滚”起来4.1 cannon-es与Three.js的桥接先创建一个物理世界const world new CANNON.World({ gravity: new CANNON.Vec3(0, -9.82, 0) });然后给每个 Three.js 球体创建一个对应的 Cannon 刚体// 假设threeBall是Three.js里的Mesh const body new CANNON.Body({ mass: 1, shape: new CANNON.Sphere(0.03), position: new CANNON.Vec3(threeBall.position.x, threeBall.position.y, threeBall.position.z) }); world.addBody(body); threeBall.userData.physicsBody body;每一帧渲染循环里用物理引擎计算后的位置和旋转同步到Three.js的Mesh上function syncMeshWithBody(mesh) { const body mesh.userData.physicsBody; if (!body) return; mesh.position.copy(body.position); mesh.quaternion.copy(body.quaternion); }这里需要注意一个细节Cannon的Position类型是CANNON.Vec3Three.js的Position类型是THREE.Vector3虽然数值上兼容但不能直接互相赋值。用copy方法或者手工逐个赋值坐标才能避免类型错误。另外Cannon的旋转是四元数Three.js的mesh.quaternion也是四元数直接copy即可。同步更新要在物理引擎step完之后再执行否则球的位置会落后一帧。4.2 击球力学初速度、摩擦和恢复系数击球本质上就是给白球一个初始速度。在真实台球中击球点、力度、杆法都会影响球的运动轨迹MVP阶段只模拟“水平方向速度”这一个量。为了让力度可控我设定最大击球速度为8单位/秒这个值经过实测足够让球从桌面一端滚到另一端并反弹几次。计算方式玩家拖动鼠标时拖动距离越长力度越大映射到0到8之间的速度。function shoot(angle, power) { const maxSpeed 8; const speed power * maxSpeed; const vx Math.sin(angle) * speed; const vz Math.cos(angle) * speed; whiteBallBody.velocity.set(vx, 0, vz); }这里有个隐藏问题球一旦获得速度不应该一直匀速运动。桌面的摩擦力会让它慢慢减速。在Cannon里摩擦系数materials要单独设置我给桌面和球做了一组接触材质const ballMat new CANNON.Material(ball); const tableMat new CANNON.Material(table); const contact new CANNON.ContactMaterial(ballMat, tableMat, { friction: 0.15, restitution: 0.8 }); world.addContactMaterial(contact);friction表示摩擦力大小0.15是个比真实台球桌面略大一点的值好处是球在MVP范围里能在可感知的时间内自然停稳不至于等上十秒才停。restitution是恢复系数0.8意味着球撞库边后会反弹掉二成速度这个数值比较接近木质库边的真实手感。4.3 库边反弹和球袋判定库边反弹不需要自己写碰撞检测Cannon的静态Box碰撞体会自动处理球撞到边界后的反弹。真正要自己写的是“球是否进了袋口”。台球袋口在真实世界是个带弧形的落球口物理引擎里用Box或Sphere模拟都比较别扭。我的做法是不把袋口建模为物理体而是在游戏循环中遍历所有球检查它们的水平坐标是否落入某个袋口圆心附近的范围。const pocketPositions [ new THREE.Vector3(1.26, 0, 0.63), new THREE.Vector3(1.26, 0, -0.63), new THREE.Vector3(-1.26, 0, 0.63), new THREE.Vector3(-1.26, 0, -0.63), new THREE.Vector3(0, 0, 0.65), new THREE.Vector3(0, 0, -0.65) ]; function checkPocket(ball) { const pos ball.position; for (let i 0; i pocketPositions.length; i) { const p pocketPositions[i]; const dx pos.x - p.x; const dz pos.z - p.z; if (Math.sqrt(dx * dx dz * dz) 0.08) { return true; } } return false; }半径0.08比袋口的视觉半径0.06稍大一点这样玩家用白球把彩球撞到袋口边缘时判定更宽容游戏体验更流畅。如果严格按0.06算会出现彩球半挂在袋口却不进洞的情况体验很拧巴。判定进球后要做三件事从Three.js场景中移除彩球、从Cannon世界中移除对应刚体、更新计分面板。注意移除顺序是先物理后渲染避免那一帧物理引擎还在计算已经消失的球。4.4 为什么不直接用Three.js的Raycaster做物理有人可能问Three.js自带Raycaster能检测射线和物体的交点那能不能用它做碰撞检测答案是Raycaster只是“射线拾取”工具它只能告诉你“某条射线穿过了哪些物体”不能处理物体之间的碰碰撞响应、不能模拟反弹速度、不能算摩擦力。你可以用Raycaster实现“瞄准线显示”——就是从白球发射一道射线看它会不会撞到库边、撞到什么位置、反弹方向是什么这些非常适合。但球和球之间的真实物理碰撞必须交给物理引擎。把 Raycaster 当物理引擎用等于用菜刀拧螺丝力气花了不少活儿还干不利索。cannon-es 的优势不只是省代码还在于它有一套稳定的离散碰撞检测算法能处理多体碰撞、睡眠、摩擦、材质参数这些复杂细节。如果非要自己手写16个球互相碰撞光碰撞响应的顺序问题就能写一整晚bug。5. 操作手感和交互设计5.1 鼠标和触屏事件的统一处理现代浏览器里PointerEvent算是统一了鼠标和触摸事件所以直接用pointerdown、pointermove、pointerup三个事件就能覆盖PC和移动端。关键点是计算鼠标点击在桌面上的3D坐标。我的做法在球桌上方放一个不可见的水平平面用Raycaster求鼠标射线与该平面的交点。这样就能把屏幕上的二维鼠标位置换算成桌面上的三维坐标。const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); const tablePlane new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); function getTablePoint(event) { mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const target new THREE.Vector3(); if (raycaster.ray.intersectPlane(tablePlane, target)) { return target; } return null; }tablePlane定义的是一个水平面法线朝上0,1,0第四参数0表示这个平面经过原点。但球桌高度是0.8所以更精确的做法应该是new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.8)这样才能让交点落在桌面平面上。这个细节如果不注意鼠标指向的位置和实际点击位置会有明显偏移。在移动端还有一个容易忽略的坑触摸滚动。如果不加touch-action: none样式手指在球桌上滑动时页面会跟着滚动或缩放导致pointermove事件被中断。我在初始化时对画布元素设置了style.touchAction none这个问题立刻消失。5.2 瞄准线和蓄力条的实现瞄准的本质是把鼠标位置和球位置连成一条线。但我想要的效果是鼠标拖动白球时白球不移动而是显示一条射向鼠标方向的瞄准线示意击球方向。const aimLine new THREE.Line( new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0xffaa00 }) ); scene.add(aimLine); function updateAimLine(from, to) { const points aimLine.geometry.attributes.position; points.setXYZ(0, from.x, from.y, from.z); points.setXYZ(1, to.x, to.y, to.z); points.needsUpdate true; }瞄准线的终点是沿鼠标方向延伸出去、直到撞到库边的点。可以用上一节提到的 Raycaster 射线检测检测射线与库边Box的交点取最近的交点作为瞄准线终点。蓄力条我用了一个简单的规则鼠标按下位置为起点鼠标拖动位置为终点计算两点之间的水平距离。拖动距离超过3个单位时力度固定为满值。屏幕左上角画一个HTML元素显示力度百分比平时隐藏拖动时显示。这里调优的一个关键点不要用帧间隔时间乘以速度来算力度因为鼠标移动的帧率不稳定会导致力度忽大忽小。正确的做法是用“起点到终点的距离”而不是“每秒移动的速度”距离在松开鼠标时只计算一次不依赖帧率。5.3 相机视角和OrbitControls的磨合默认的透视相机放在球桌的斜上方大概 (0, 2.2, 3.2) 的位置朝桌面中心看。这个视角接近真实台球厅里站在桌子一侧看到的角度比纯俯视图更有空间感。为了让玩家能调整视角我接入了OrbitControls但做了一些限制。如果不加限制玩家可以把相机拉到桌子底下或者转到奇怪的角度导致瞄准操作和实际视角脱节。const controls new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.8, 0); controls.enableDamping true; controls.minPolarAngle Math.PI / 6; controls.maxPolarAngle Math.PI / 2.4; controls.minDistance 1.5; controls.maxDistance 5;minPolarAngle和maxPolarAngle限制了相机在垂直方向上的旋转范围防止相机跑到桌子下面或者贴到桌面上。minDistance和maxDistance限制了缩放范围避免玩家把镜头拉得太近导致瞄准线显示异常。提示OrbitControls的默认鼠标左键是旋转视角会和我自定义的“鼠标拖动击球”冲突。我的解法是玩家按住Shift加鼠标拖动才旋转视角普通拖动全部交给击球逻辑。如果你觉得这个交互太怪也可以反过来普通拖动旋转视角点击白球时击球。这个根据自己的偏好定但一定要和玩家讲清楚操作方式。5.4 手感调试的几个关键参数调试击球手感时我最常调的参数是这几个参数经验值调整影响最大击球速度8过大球速太快眼睛跟不上过小击球无力线性阻尼 linearDamping0.02控制球自然减速的快慢桌面接触摩擦0.15影响球滚动距离和停止感库边恢复系数0.8反弹明显但不会无限弹袋口判定半径0.08越大越好进越小越难进这些参数我建议不要用固定值而是放到一个config对象里定义方便反复改。每调一次就实际打几杆试试手感直到“球该停的时候停、该弹的时候弹、进袋不松垮”才满意。6. 游戏逻辑进袋加分、回合与胜利6.1 进袋判定和球移除的完整流程前面提到用距离判断是否进球但真正实现时还要加几个保护条件否则会出bug。首先进球判定应该只在球速低于某个阈值时触发否则一颗球高速擦过袋口边缘距离小于0.08会被误判为进袋。我加了速度判断球速低于1.5时才开始检测。function checkPockets(ball) { const body ball.userData.physicsBody; const speed body.velocity.length(); if (speed 1.5) return; const pos ball.position; for (let i 0; i pocketPositions.length; i) { const p pocketPositions[i]; const dx pos.x - p.x; const dz pos.z - p.z; if (Math.sqrt(dx * dx dz * dz) 0.08) { handlePot(ball); break; } } }handlePot里要做的操作顺序先从物理世界移除 body再从场景移除 mesh把球放到“已进球”列表然后更新UI显示。这里有个细节物理世界移除 body 的时候Cannon会自动调用world.removeBody(body)但如果你后面还要用这个球的数据记得先把对象存到别的变量里不要直接销毁。彩球进袋后要在开球区再次摆放一颗不是的正规台球是一局里每个球只进一次MVP版本也是如此设计。每颗球进袋后直接从桌子上消失不会重新出现。只有白球进袋后才会重新摆回开球点。6.2 白球落袋的罚球处理白球进袋和彩球进袋性质完全不同。按照最简单的规则白球进袋算犯规需要把白球放回开球区的固定位置然后继续由当前玩家击球。这样规则简单适合刚上手的玩家。开球区我定在桌面左端四分之一处位置固定为 (-1.0, 0.83, 0)。白球进袋后function respawnWhiteBall() { whiteBallBody.position.set(-1.0, 0.83, 0); whiteBallBody.velocity.set(0, 0, 0); whiteBallBody.angularVelocity.set(0, 0, 0); whiteBallMesh.position.set(-1.0, 0.83, 0); }注意要把velocity和angularVelocity都清零否则白球会带着进袋时的速度继续滚动。这里如果不处理角速度球复位后还会自己原地转动看起来非常诡异。还有一种情况玩家击球后白球没有进袋但滚到了袋口附近悬挂。这种情况在纯物理引擎中比较少见因为cannon的球体碰撞比较严格大部分情况下球要么完全滚进袋口要么被库边挡住。如果真有悬挂可以调大袋口判定半径让球更容易判定为进袋。6.3 胜利条件与重置机制MVP的胜利条件很简单所有15颗彩球全部进袋即胜利。我维护了一个pottedCount变量每进一颗球加1当它等于15时弹出一个胜利提示。重置机制要同时处理三种情况整局重开、白球复位、某一颗彩球不小心被错误移除一般不会。我建议做一个resetGame()函数里面把所有球的位置、速度、显示状态全部还原到开局状态。function resetGame() { // 还原所有彩球 coloredBalls.forEach((ball, index) { const pos initialPositions[index]; ball.position.set(pos.x, pos.y, pos.z); ball.userData.physicsBody.position.set(pos.x, pos.y, pos.z); ball.userData.physicsBody.velocity.set(0, 0, 0); ball.userData.physicsBody.angularVelocity.set(0, 0, 0); ball.visible true; scene.add(ball); }); // 还原白球 respawnWhiteBall(); // 重置计分 pottedCount 0; }注意被移除的球虽然从Cannon世界里删了但从Three.js场景中移除后它的visible属性还是true重新加回场景时要注意这一点否则会出现“球明明已经复位位置但看不见”的情况。7. 常见问题与排查技巧实录7.1 球陷入桌面或者从桌子边缘掉下去这是新手最容易遇到的问题。原因通常是物理碰撞体的尺寸和渲染模型的尺寸没有对齐。举个例子渲染层桌面在 y0.78长2.54宽1.27但物理层我只做了一个长2.54、宽1.27的Box放在 y0.78。问题在于Cannon的Box是实心体渲染层的桌面盒也是实心体两者表面刚好贴在一起时球在物理层和渲染层的表现可能不一致因为物理层多出的厚度或者位置的微小偏移会让球“嵌入”桌面。我的解法是物理层桌面用一个比渲染层桌面略大一圈比如长3、宽1.8的薄板放置在渲染桌面稍微下方一点确保球永远不会从视觉桌面边缘漏下去。这样虽然物理上和视觉上不完全重合但玩家根本看不出差异。7.2 球速很快时直接穿透库边cannon-es底层使用的是离散碰撞检测球在两次物理计算之间移动的距离如果超过物体厚度就可能直接“跳过”碰撞体这就是经典的穿透问题。解决方案有两个一是调低物理引擎的步长把固定时间步长从默认的1/60改成1/120让每次计算的距离减半二是限制球的最大速度防止球速过快。我两个都做了实际效果很好。如果还出现穿透就把最大速度再调低一点。world.fixedStep 1 / 120;需要知道fixedStep越小物理越精确但CPU开销越大。桌上只有16颗球时1/120的开销完全可以接受移动端也流畅。7.3 阴影闪烁和贴图抖动开启阴影后球桌和球的接触面容易出现明暗交替的闪烁专业说法是 z-fighting即两个表面重叠在同一位置深度缓冲区分不清谁前谁后。我踩过的坑是桌面上的绿色绒布材质和物理桌面Box的顶面完全重合。解决方法是把渲染层的桌面做成一个没有厚度的平面PlaneGeometry并设置polygonOffset让它在深度测试时稍微偏移const tableFeel new THREE.Mesh( new THREE.PlaneGeometry(2.5, 1.24), new THREE.MeshStandardMaterial({ color: 0x1b6b3a }) ); tableFeel.position.y 0.802; tableFeel.rotation.x -Math.PI / 2; tableFeel.renderOrder 1;这里把绿色绒布的平面放到 y0.802比桌面顶面高0.002这个高度差肉眼完全看不出来但能避免闪烁。7.4 移动端卡顿和内存占用过高移动端跑Three.js项目最突出的问题就是性能。我做了三处优化第一限制像素比不让画布以超高分辨率渲染第二关闭不必要的阴影第三减少后台更新的纹理数量。renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));限制像素比到2对清晰度的影响很小但能明显降低 iPhone 等高分屏的GPU压力。阴影方面我只让主平行光投影点光源不投影。如果你发现手机上还是卡可以考虑把阴影图大小从2048降到1024。7.5 快速排查速查表现象可能原因解决方向球掉出桌面物理层桌面缺失或偏小扩大物理层桌面尺寸或检查位置y值球穿透库边步长过大或速度过快降低fixedStep到1/120限制最大速度瞄准线不准确交点平面高度未对齐确保tablePlane的偏移值等于桌面高度球体纹理数字方向反了贴图UV环绕方向问题旋转Mesh或者使用纹理rotation调整鼠标拖动视角和击球冲突事件绑定冲突使用Shift或点击白球才能击球的交互方案球复位后自己转动角速度未清零重置时把angularVelocity也设为0移动端画面模糊像素比过高用Math.min(devicePixelRatio, 2)限制白球进袋后没有复位物理体和渲染体未同步检查复位函数是否同时设置了两者位置最后分享一个我个人的调试习惯做这种物理交互项目最好在代码里加一个“后台日志”面板不需要多好看把每颗球的坐标、速度、进球状态实时打印到屏幕上。你很快就会发现很多问题在球“飞了”“卡了”之前日志里就已经有异常数据了。直接看数值定位问题比盯着画面空想快得多。这个小技巧帮我省下了大量时间尤其当你同时调试物理参数和交互逻辑时日志几乎是唯一的真相来源。
返回列表