
做这个项目的起因其实很没出息重看《哈利·波特与魔法石》里那场巫师棋大战棋子被吃不是离场而是被对手一剑劈成碎片当时我脑子里第一个念头不是好燃而是这效果我用 Three.js 在浏览器里能不能复刻一版。于是就有了这个小项目——Wizard Chess一个跑在浏览器里的 3D 国际象棋 Demo核心亮点就一句话被吃掉的棋子会碎裂、飞溅、落地。先交代一下定位它不是完整产品更像一个技术验证型 Demo用 3D 网页渲染把棋子被吃→炸裂→碎片碰撞落地这条效果链做通顺便把棋盘交互、走棋规则、相机控制串起来。它适合三类人看想入门 WebGL/Three.js 的前端开发者、做 3D 游戏原型但不想碰重型引擎的朋友以及纯粹想看浏览器里能不能碎得爽的好奇心选手。项目本身不复杂但如果你是照着象棋 Demo的预期去做大概率会翻车——真正吃时间的不是棋盘不是规则而是那一地的碎片怎么不穿模、不卡顿、不内存爆炸。这篇文章我会把技术选型、碎裂实现、棋盘逻辑、性能优化和踩坑过程完整拆开讲代码骨架可以直接拿去抄作业。1. 先动手再解释一个碎得爽的 3D 象棋 Demo1.1 核心需求拆解项目标题虽然只有一句话但拆开看其实包含三个独立的技术需求一是 3D 棋盘场景。包括棋盘建模、棋子摆放、灯光相机这是 Three.js 最擅长的领域没什么门槛。二是完整的国际象棋交互。点击选棋、高亮合法落点、走子、吃子、胜负判定。这部分是纯逻辑跟 3D 关系不大反而是翻译成 3D 坐标时最容易出 bug 的地方。三是被吃棋子碎裂的视觉效果。这是项目的灵魂也是最麻烦的部分棋子被吃时不能只是消失要炸成一堆碎片碎片要有物理碰撞、有旋转、有落地弹跳最后再自动清理。当初把需求拆完我就意识到前两点加起来工作量可能只占三分之一剩下三分之二全在第三点上。1.2 为什么碎裂必须是物理驱动力而不是单纯动画这里先说一个反直觉的结论如果你只想让画面看起来像碎了用粒子系统或者预烘焙动画就能糊弄过去——但一旦玩家拖动视角、视角变化假碎裂就露馅了。原因很简单粒子和预烘焙动画没有碰撞响应碎片会穿透棋盘、穿透相邻棋子违和感极强。所以真正的碎裂必须由物理引擎接管每个碎片是独立刚体有质量、有形状、有初始冲量落地要弹跳互相推挤最后静止。这也是这个 Demo 技术含量最集中的地方。1.3 成果概览与验收标准我自己给这个 Demo 定的验收标准有三条任意棋子被吃时碎裂后碎片数量在 20~40 块之间飞散方向与攻击方向大致相反落地后有 1~2 次真实弹跳。在 60FPS 下碎片同时活跃数量不超过 200 块且 3 秒后自动淡出不留内存垃圾。棋盘和走棋逻辑在纯浏览器端跑通支持人机对弈或双人热座完整处理吃过路兵王车易位这类边界规则。现在回头看三条中前两条都做到了第三条的花边规则是真的花了不少时间。2. 技术选型Three.js 负责好看cannon-es 负责碎得真实2.1 渲染层为什么是 Three.js 而不是别的做 3D 网页渲染渲染层的选择其实没太大悬念。原生 WebGL 写一个旋转立方体没问题但要在这个 Demo 里管理几十个网格、几十个材质、阴影、拾取、相机控制工作量会爆炸。Babylon.js 确实内置了物理插件和场景工具但它的上手曲线比 Three.js 陡社区里针对某个具体效果怎么做的资料也少一截。Three.js 的优势是生态和教学资源密度。你搜Three.js shatter能搜到一堆碎片化教程和现成思路而且配合 Vite 做模块化开发非常顺手调试时 HMR 一刷新改完材质立刻看效果开发效率高一大截。方案上手难度物理支持生态资料选型结论原生 WebGL高无少不选Three.js cannon-es低需另接极多选用Babylon.js中内置中可备选React Three Fiber中低需另接多适合 React 项目2.2 物理层cannon-es 还是 rapier物理引擎的选择我在 cannon-es 和 rapier 之间犹豫过。rapier 的物理精度和性能都很强但它是 WASM 编译的打包体积多出 1MB 左右在小项目里有点杀鸡用牛刀而且 WASM 加载和初始化在弱网环境偶尔会抽风调试多一层复杂度。cannon-es 是纯 TypeScript 实现的物理引擎API 设计简洁圆形、箱子、凸包、网格碰撞体都有配合 CannonJS 时代的文档和经验贴基本踩不到悬崖。性能上它确实不如 rust 系的引擎但对这个 Demo 的量级——同时活跃碎片控制在 200 块以内——绰绰有余。2.3 工具链与整体项目结构工程上用 Vite 起步装三件套three渲染核心cannon-es物理世界vite开发服务器和打包模型处理上棋子模型我用的是 Blender 做的基础建模导出为 GLTF 格式真正关键的是后续预碎化管线这在第三节细说。项目目录大致长这样wizard-chess/ ├── index.html ├── src/ │ ├── main.js # 入口初始化渲染器和场景 │ ├── chess/ │ │ ├── board.js # 棋盘建模与坐标映射 │ │ ├── rules.js # 走棋规则校验 │ │ └── ai.js # 简易 AI可选的 │ ├── fx/ │ │ ├── shatter.js # 碎裂效果控制 │ │ └── debris.js # 碎片资源管理 │ └── render/ │ ├── camera.js # 相机控制 │ └── picker.js # 鼠标拾取 └── public/ └── models/ # Blender 导出模型和切片资源这个结构把游戏逻辑和特效逻辑分开后面调试的时候会非常省心。3. 被吃棋子怎么碎放弃运行时切割改走预碎化管线3.1 方案 A运行时 Voronoi 切割为什么我主动放弃很多教程会告诉你用 Three.js 的 CSG 或 Raum 等库在运行时把棋子模型切成碎片。理论上很优雅被吃瞬间对模型做 Voronoi 三维剖分生成几十个凸包体交给物理引擎。但实操三个月后我负责任的结论是别在运行时做这件事。原因有三个。第一三维 Voronoi 切割非常吃 CPU棋子被吃的瞬间本来就要触发物理碰撞和碎片激活再塞一个几十毫秒甚至几百毫秒的同步切割计算画面会卡一下正好卡在最需要爽感的时刻。第二切割算法对非凸模型比如马的造型、城堡的城齿处理极易出错经常剖出退化三角形、破洞、飞点。第三你看看 Three.js 周边切割库的维护状态就懂了年久失修的居多新版本兼容性是个隐雷。3.2 方案 BBlender Cell Fracture 预切碎真正可靠的做法是预碎化在 Blender 里把每个棋子模型用 Cell Fracture 插件切好导出成一组碎片网格运行时从资源池里取碎片而不是当场切。具体步骤在 Blender 里导入棋子模型应用 Cell FractureVoronoi 模式碎块数量控制在 30 块左右。调整碎块大小分布——棋盘碎块密一些马和象的细部比如耳朵单独处理避免生成细小到物理引擎算不过来的碎屑。导出为 GLTF每个碎块保留独立的位置和旋转偏移。这一步很关键碎片默认是完整棋子在坐标系里的局部位置运行时我可以直接把碎块作为子物体挂到棋子的爆炸中心不需要额外对齐。对每个碎块生成简化的碰撞体。如果碎块是凸的用 convex polyhedron如果形状太怪直接退化成球体碰撞体——视觉上完全能接受。在实际项目里这一步的困难不在 Blender 操作而在生成多少个碎块的平衡太少碎得假太多物理引擎 CPU 开销起来FPS 直接跳水。我最终把每个棋子切到 28~36 块骑兵马因为造型细切完后自动剔除长宽比我小于 0.05 的超薄碎片避免穿模闪烁。3.3 碎片激活、冲量与消失流程被吃瞬间棋子本体隐藏所有碎片被唤醒。每块碎片做三件事第一加入物理世界。我在 cannon-es 里为每块碎片创建一个刚体形状用 convex polyhedron 近似mass 设成原棋子的 1/碎片数这样整体质量不会凭空翻倍。第二施加冲量。冲量方向 棋子被吃点 - 攻击棋子位置归一化大小和棋子吃子方向相关再加一个随机圆锥角度让碎片飞得自然。这里有个重要参数冲量不能太大。如果给 100 的冲量碎片会飞远并穿透棋盘。第三定时淡出。碎片落地静止后我不立即销毁而是用 Three.js 的材质透明度做 0.5 秒淡出再移除场景、从物理世界移除刚体并且调用 geometry.dispose() 和 material.dispose()。这是防止 GPU 内存泄漏的关键动作。核心代码骨架大概是这样的以 JavaScript 为例import * as CANNON from cannon-es; function triggerShatter(pieceMesh, attackDirection) { const fragments pieceMesh.userData.fragments; pieceMesh.visible false; fragments.forEach((frag, index) { const body new CANNON.Body({ mass: pieceMass / fragments.length, shape: createApproxConvex(frag.geometry), position: new CANNON.Vec3(...frag.worldPosition), material: debrisMaterial, }); world.addBody(body); const force attackDirection .clone() .multiplyScalar(12 Math.random() * 6); body.applyImpulse(force.addRandomCone(0.4)); frag.visible true; frag.userData.body body; // 每帧更新 Three.js 位置 cannon 刚体位置 const mesh frag; const sync () { mesh.position.copy(body.position); mesh.quaternion.copy(body.quaternion); if (body.sleepState CANNON.Body.SLEEPING) { startFadeOut(mesh, body); } }; syncList.push(sync); }); }注意applyImpulse的冲量方向我加了随机圆锥偏移这是让碎片炸得自然的最大秘诀。完全沿着攻击方向飞看起来像被射出去的子弹毫无炸裂感。3.4 为什么不在 Three.js 里直接用刚体几何体有人会问既然预碎化了为什么不直接拿碎片网格作为碰撞体因为碎片的网格通常是非凸的而 cannon-es 的Trimesh碰撞体在动态碰撞时相当昂贵特别是几十个碎片互相碰撞CPU 会迅速撑不住。我的做法是保留高精度 GLTF 网格做渲染碰撞体采用自动生成的凸包近似用ConvexPolyhedron。视觉网格和碰撞网格不一致带来的误差在碎片飞溅这种高速运动下肉眼几乎不可见。4. 棋盘与交互把国际象棋搬进 3D 空间时绕不开的几件事4.1 棋盘坐标系统从代数符号到三维坐标国际象棋的坐标用字母列数字行表示比如 e4、b6。在 3D 场景里我得做一个映射函数function squareToPosition(square) { // square 如 e4 - x 轴映射到列z 轴映射到行 const file square.charCodeAt(0) - 97; // a - 0 const rank parseInt(square[1]) - 1; // 1 - 0 return new THREE.Vector3( (file - 3.5) * CELL_SIZE, 0, (rank - 3.5) * CELL_SIZE ); }这里有个坑棋盘在 3D 里是 XOZ 平面Y 轴朝上。棋盘的 a1 在左上角还是左下角直接影响后续旋转镜头时的视觉方向。建议 a1 放左下角镜头从 135 度斜上方俯视这是国际象棋 3D 游戏的主流视角玩家不会方向感错乱。4.2 走棋规则校验边界情况比想象中多国际象棋的规则校验一开始我打算偷懒只实现常规走法和吃子但实际跑起来发现吃过路兵会直接破坏棋局逻辑。我用一个纯函数库管理规则输入棋盘状态一个 8x8 数组和走法输出合法走法列表。实现时有两个细节非常坑王车易位必须检查中间格子和王经过格子是否被攻击不能只检查目标格子。兵的前进在起始行允许走两格但 前进两格 会被其他棋子挡住这时候不能跳过去。调试这些规则最好的方式是写棋盘状态的序列化工具把 64 格数组打印成文本棋盘一步一看特别直观。4.3 拾取、高亮和移动动画3D 场景里的点击拾取用 Three.js 的 Raycaster 就够了。核心逻辑点击时从相机发射射线检测命中的棋子或棋盘格子。如果选中棋子计算它的所有合法走法用半透明平面方块高亮落点。点击高亮格子执行走子。走子动画我用了简单的线性插值lerp加缓动棋子不是瞬移而是以 0.3 秒时长平滑移动到目标格同时做一个向上的抛物线凸起模拟跳的感觉。这个细节极大提升观感代码量很小值得做。4.4 相机控制让玩家看清碎得爽相机的目标很简单玩家要能从多个角度欣赏棋盘和碎裂瞬间。我实现了轨道控制OrbitControls 思路但做了一点定制限制俯仰角在 15°~85° 之间防止玩家钻到棋盘底部看穿帮。限制缩放距离最近不能小于 5 个格子最远不能超过透视视角能看到整张棋盘。最关键的是当棋子开始碎裂时镜头不要自动移动。任何抢镜行为都会让玩家错失碎裂瞬间效果再好也白搭。5. 性能与适配别让碎裂一刻变成卡死一刻5.1 碎片数量与物理步进的平衡物理引擎的 CPU 开销主要来自碰撞检测和约束求解。cannon-es 默认每帧步长 1/60 秒但碎片数量一多碰撞对数量按几何级数增长。我的经验公式是活跃刚体数量超过 300 个物理帧时间会从 2ms 跳到 8ms 以上直接拖累渲染帧率。所以做了一下三档控制普通棋子碎裂碎片 28~36 块。同时最多允许 6 个棋子的碎片活跃超过上限的碎裂效果排队等待。碎片在物理世界中 3 秒后如果还没静止强制进入休眠bodies[i].sleep()并在下一帧淡出。5.2 光源、阴影和材质减法3D 网页渲染最容易翻车的就是光源和阴影。我的配置是一个平行光太阳光作为主光源开启阴影castShadowshadow map 分辨率用 2048。一个环境光辅助照明避免阴影面死黑。所有棋子用 MeshStandardMaterial但金属度和粗糙度控制在一定范围避免玻璃感的反射计算开销过高。阴影贴图类型用基础 PCFShadowMap不用 VSM——VSM 在这种多碎片的场景下很容易出现漏光条纹。5.3 移动端降级为了能跑做的妥协移动端浏览器其实是这个 Demo 不可忽视的使用场景但 GPU 能力和内存都弱不少。我在运行环境检测里做降级碎片数量直接减半28 块变 14 块。关闭阴影。关闭后处理抗锯齿不做 FXAA/SMAA。cannon-es 的步长降低到 1/30 秒视觉上稍微慢一点但物理不会崩。坦白说移动端体验下降明显——碎得爽的感受大打折扣。所以我的结论是这个 Demo 本质上是为桌面浏览器设计的移动端只是能跑。5.4 资源回收从内存泄漏到 GPU 泄漏一个很容易被忽略的坑是Three.js 删除场景对象时如果没调用geometry.dispose()和material.dispose()GPU 内存不会释放。这个 Demo 每局游戏会产生几十次碎裂每次几十个碎片如果回收不干净跑 10 分钟后浏览器明显变卡甚至标签页崩溃。我的策略是引入一个全局资源管理器所有碎片创建时登记引用销毁时统一处理const resourceRegistry new Set(); function registerDisposable(geometry, material) { resourceRegistry.add({ geometry, material }); } function disposeAll() { resourceRegistry.forEach(({ geometry, material }) { geometry.dispose(); material.dispose(); }); resourceRegistry.clear(); }这个管理器放在重开一局的入口处调用确保新旧局之间资源干净利落。6. 三次踩坑复盘从棋子穿模到开局自爆6.1 坑一碎片穿透棋盘好像落进了无底洞第一次给碎片加物理时棋盘用的是一个大 Box 碰撞体棋子碎裂后碎片往下掉然后直接穿透棋盘往下飞。排查链路是先怀疑碰撞体位置不对——打印刚体位置发现棋盘 Box 的 Y 坐标偏了半个格子导致碎片和棋盘实际碰撞面是空气。修正棋盘 Y 轴对齐后仍有少量碎片穿模集中在棋盘边缘。最后定位到原因是棋盘碰撞体只有一个 Box但棋盘格子在视觉上有凸起的防滑纹局部碰撞判定不一致。解决方式是给棋盘加一圈边条用很薄的 Box 组不让碎片从侧面溜走。这事的教训是碰撞体不要追求完全还原视觉能用凸包就不要用凹形凹形碰撞对碎片这种高速运动物体非常不友好。6.2 坑二一开局就自爆棋子像被诅咒了一样还有一个更搞笑的 bug加载完成后所有棋子突然全部碎裂场面像棋盘上放了一串摔炮。那次排查花了一整个晚上。代码逻辑上碎裂函数只会在被吃和测试按钮两个入口触发但测试按钮明明没按。最后我打开 DevTools 的 Performance 面板才看到是物理引擎步进时棋子本体和棋盘产生了巨大碰撞反应。问题根源是棋子刚量在加载时同时存在渲染网格和物理刚体我把刚体初始位置设置到了棋子脚下 0.1 单位物理引擎第一帧发现穿透自动分离时产生了极大的反弹力。这个初始位置错误被物理引擎瞬间放大所有棋子一起弹开触发碎裂判定。解决办法是在加载阶段给所有刚体设置body.type CANNON.Body.STATIC等棋子被选中或场景稳定后再切换为DYNAMIC。这个延迟启动物理的思路对很多动态场景都适用。6.3 坑三阴影在碎片上疯狂闪烁碎片飞溅时每块碎片的阴影都在剧烈抖动像老电视机的雪花。这个问题在 Three.js 社区里很常见根源是 shadow bias 设置太小。碎片在飞行时阴影贴图的分辨率有限如果 bias 过小碎片和棋盘表面的深度比较产生自阴影穿透间隙就会出现闪烁。修复很简单把平行光的 shadow.bias 从默认值调到-0.0005左右必要时再配合shadow.normalBias调大。另外我还顺便把碎片的阴影投射关掉——碎片太小看不见投影反而更自然又省了一笔渲染开销。7. 从 Demo 到产品的扩展方向如果继续往下做我会优先加这几样东西一是音效。碎裂瞬间配一个清脆的玻璃碎裂声能显著提升打击感。浏览器里用 Web Audio API 合成不依赖外部音频文件也很轻量。二是碎片缓慢落地后散落在棋盘上的保留机制。现在碎片 3 秒后自动淡出如果让它们一直留在棋盘上既能制造战损感又可以给后续玩家复盘提供真实的视觉线索。三是人机 AI。棋盘逻辑已经做好接一个 minimax 算法的 AI 并不难但前置条件是先节流物理开销否则 AI 计算那一帧物理碎片很容易把 FPS 拖垮。最后再分享一个经验如果你也想做类似的项目我强烈建议把碎裂效果和棋盘逻辑完全拆成两个模块先调试碎裂用一个假棋子反复触发再接入棋盘。这两个系统交叉调试你会被 bug 搅到怀疑人生。项目完整跑起来后回看最有成就感的反而不是象棋能玩了而是一地碎渣每一块都老实落地、没有穿模——这种物理真实感带来的满足远超过功能清单上那些条条框框。