做这个项目的起因其实很没出息:重看《哈利·波特与魔法石》里那场巫师棋大战,棋子被吃不是离场,而是被对手一剑劈成碎片,当时我脑子里第一个念头不是"好燃",而是"这效果我用 Three.js 在浏览器里能不能复刻一版"。于是就有了这个小项目——Wizard Chess,一个跑在浏览器里的 3D 国际象棋 Demo,核心亮点就一句话:被吃掉的棋子会碎裂、飞溅、落地。
先交代一下定位:它不是完整产品,更像一个技术验证型 Demo,用 3D 网页渲染把"棋子被吃→炸裂→碎片碰撞落地"这条效果链做通,顺便把棋盘交互、走棋规则、相机控制串起来。它适合三类人看:想入门 WebGL/Three.js 的前端开发者、做 3D 游戏原型但不想碰重型引擎的朋友,以及纯粹想看"浏览器里能不能碎得爽"的好奇心选手。
项目本身不复杂,但如果你是照着"象棋 Demo"的预期去做,大概率会翻车——真正吃时间的不是棋盘,不是规则,而是那一地的碎片怎么不穿模、不卡顿、不内存爆炸。这篇文章我会把技术选型、碎裂实现、棋盘逻辑、性能优化和踩坑过程完整拆开讲,代码骨架可以直接拿去抄作业。
1. 先动手再解释:一个"碎得爽"的 3D 象棋 Demo
1.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 方案 B:Blender Cell Fracture 预切碎
真正可靠的做法是"预碎化":在 Blender 里把每个棋子模型用 Cell Fracture 插件切好,导出成一组碎片网格,运行时从资源池里取碎片,而不是当场切。
具体步骤:
- 在 Blender 里导入棋子模型,应用 Cell Fracture(Voronoi 模式),碎块数量控制在 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 网页渲染最容易翻车的就是光源和阴影。我的配置是:
- 一个平行光(太阳光)作为主光源,开启阴影
castShadow,shadow 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 搅到怀疑人生。项目完整跑起来后回看,最有成就感的反而不是"象棋能玩了",而是"一地碎渣,每一块都老实落地、没有穿模"——这种物理真实感带来的满足,远超过功能清单上那些条条框框。