Three.js 纯 JS 魔方实战:3D 状态建模、动画调度与拖拽旋转 简介这是一份面向JavaScript初学者与前端进阶者的纯JS魔方模拟项目源码围绕魔方旋转逻辑与网页交互展开适合用来练习DOM操作、事件监听、动画调度及状态数据结构也可作为算法可视化与在线魔方演示的参考实例。压缩包共21个文件包含15个JavaScript脚本、4个CSS样式、1个HTML入口及1张图片素材整体约255KB体量轻巧代码与界面资源划分清楚便于按模块阅读和二次改造。已有783人学习下载说明其在同类练手项目中具备一定参考度。读者可从中了解如何用原生脚本组织立方体状态、响应点击或拖动操作、实现平滑转动效果并体会精简冗余代码后的可读性与可维护性适合拿来拆解交互流程、补充前端实战经验或作为课程示例。1. 一个 index.html 就能玩的魔方纯 JS 版到底做了什么上周有个朋友丢来一个压缩包说你帮我看看这个魔方代码为什么转着转着就散架了。我解压完发现连构建工具都没有双击index.html屏幕上 27 个小方块以一个斜 45° 的视角悬在那里鼠标摁住某一面往右一拖整层就平滑地转了 90°。这套纯 JS 版魔方没有后端、没有游戏引擎、没有打包步骤靠的是一个 3D 渲染库加几百行状态逻辑把拧魔方这件事完整搬进了浏览器。它适合两类人一类是想在网页里塞一个可交互 3D 小玩具的前端直接改吧改吧就能嵌进自己的页面另一类是想搞明白3D 物体的状态和动画怎么分离的开发者魔方是个特别好的练手题材——状态空间小、规则明确、出错立刻看得见。下面我按自己拆这份代码的顺序从数据结构一路讲到怎么加求解器。2. 三层架构拆解26 个块、一个渲染器与一套动画调度这份代码最值得先看的是它的分层而不是某一段算法。整个项目被切成了三块互不越界的东西状态层只管哪个块在哪个位置、朝哪个方向渲染层只管把状态画出来动画层夹在中间负责把一次瞬时状态变更拉成一段 200ms 的过渡。分层没做好的魔方项目最常见的症状是转两次之后块的位置开始漂移或者动画播到一半状态已经改了画面和逻辑对不上。2.1 26 个块的状态表示位置、朝向与贴纸颜色魔方的状态建模有两条路。一条是54 张贴纸模型把六个面各 9 个格子当数组存颜色转动就是数组下标置换另一条是26 个块模型每个块有位置坐标、朝向、以及它自带的那几张贴纸。这份纯 JS 版走的是后者原因是渲染层要按块去画几何体块模型和 Three.js 的 Object3D 能一一对应省掉一层映射。位置用-1 / 0 / 1三个整数表示绕原点建坐标系这样 27 个格点里挖掉中心那个就是 26 个块。整数坐标的好处是旋转 90° 之后结果仍是整数不会积累浮点误差坏处是每次动画结束必须做一次量化否则0.9999999会让这个块在哪一层的过滤条件失效。朝向用一个四元数存。8 个角块每个有 3 种朝向12 个棱块每个有 2 种中心块只有 1 种但这些差异不需要在数据结构上体现——统一给每个块一个四元数转的时候跟着乘判定还原的时候再按贴纸法线去分组代码会短很多。贴纸信息挂在块的userData上记录本地法线和颜色这样这块的红色面现在朝哪随时能算出来。2.2 渲染层的两种选型Three.js 与 CSS 3D纯 JS 不等于不用库这份代码用的是 Three.js通过 importmap 从 CDN 直接加载没有 node_modules。选它的理由是attach()这个 API 太适合做层旋转了把一个块从场景图的一个父节点挂到另一个父节点时世界变换保持不变动画播完再挂回去位置和朝向自动就是旋转后的结果不用自己算矩阵乘法。另一条路是纯 CSS 3D每个小方块用 6 个div拼立方体父容器加transform-style: preserve-3d转层就是给一组元素加rotateX/Y/Z。这条路零依赖、体积小缺点是层旋转动画结束后得手动把每个块的 transform 矩阵重新分解成位置和朝向浏览器返回的是matrix3d(...)字符串解析起来很脏。我一般只在必须打进一个不能引外部脚本的单文件页面时才走 CSS 3D。维度Three.js 方案CSS 3D 方案依赖体积约 600KB可摇树裁剪零层旋转实现父节点旋转 attach逐个元素改 transform拾取点选某个面Raycaster 直接给面和法线靠elementFromPoint反查较绕光照与材质内置观感好调只能用渐变模拟适合场景独立页面、需要拾取交互嵌入现有页面、极致轻量2.3 动画调度把一次 90° 转动拆成帧状态变更必须是瞬时的、原子的动画只是把它演出来。这条原则落实到一个animating标志位加一个队列动画进行中再点按钮新的转动进队列等当前这帧收尾了再执行。没有这个队列玩家快速连点两下两个 pivot 组会同时存在块的父子关系错乱画面直接开花。时间基准用performance.now()而不是每帧固定加一个角度。固定步长在 60Hz 屏上看着正常换到 120Hz 屏动画速度就翻倍这是很典型的一个翻车点。缓动曲线用 easeOutCubic起始快收尾慢视觉上更接近真实手指搓动的手感线性缓动看起来会很机械。动画播完的收尾动作有三件把块从 pivot 挂回主组、位置向量round()到整数、四元数对齐到最近的 90° 倍数。三件事少做一件几十步之后就会看到某些面出现一条细缝——那是两个块的位置差了 0.0001 导致的 z-fighting。3. 核心链路实现从指针拖拽到 90° 层旋转的完整代码拆完架构落到能跑的具体代码。这一章按数据流顺序走先建块再讲转层再讲鼠标怎么把一次拖拽翻译成绕哪个轴、转哪一层、正转还是反转最后是打乱、计时和还原判定。3.1 块与贴纸的数据结构建块这一步很直白三重循环遍历-1..1跳过原点给每个块挂上它应该有的贴纸。判断该不该有贴纸的条件是块在某个轴上的坐标等于 ±1就在那个方向上贴一张。import * as THREE from three; const COLORS { R: 0xd93b3b, L: 0xf08a24, U: 0xf5f0e6, D: 0xf2d13c, F: 0x2fa84f, B: 0x2f6fd9 }; const FACE_DEF [ { key: R, normal: [ 1, 0, 0] }, { key: L, normal: [-1, 0, 0] }, { key: U, normal: [ 0, 1, 0] }, { key: D, normal: [ 0,-1, 0] }, { key: F, normal: [ 0, 0, 1] }, { key: B, normal: [ 0, 0,-1] }, ]; const cubies []; for (let x -1; x 1; x) { for (let y -1; y 1; y) { for (let z -1; z 1; z) { if (x 0 y 0 z 0) continue; // 中心不可见跳过 // 方块本体稍微缩到 0.95块与块之间留缝视觉上更清晰 const mesh new THREE.Mesh( new THREE.BoxGeometry(0.95, 0.95, 0.95), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.85 }) ); mesh.position.set(x, y, z); // 贴纸每个露出的面上贴一个薄片法线朝外 mesh.userData.stickers []; for (const f of FACE_DEF) { const [nx, ny, nz] f.normal; // 只有块在该方向上的坐标等于 ±1 时这个面才露在外面 if (nx * x ny * y nz * z ! 1) continue; const localNormal new THREE.Vector3(nx, ny, nz); mesh.userData.stickers.push({ localNormal, color: f.key }); } cubies.push(mesh); } } }nx * x ny * y nz * z ! 1这一行是判断面是否朝外的紧凑写法。以 (1,1,1) 这个角块为例法线 (1,0,0) 点乘坐标得 1说明 X 面朝外贴纸法线 (0,-1,0) 点乘得 -1说明 -Y 面被包在里面不贴。这样 26 个块自动得到 6 个中心各 1 张、12 个棱各 2 张、8 个角各 3 张共 54 张贴纸。贴纸本身在渲染时是独立的小平面PlaneGeometry比用 6 材质 Box 更好控制——想给贴纸加圆角或者描边换一个材质就行不用动块的几何体。userData.stickers里存的localNormal是关键它跟着块的四元数一起转任何时候都能算出这张贴纸现在朝哪个世界方向还原判定全靠它。3.2 单层旋转的数学绕轴旋转与坐标映射转层的实现分两段动画期间用一个临时的 pivot 组带着整层转动画结束时把块挂回主组并量化。这个 attach/detach 的套路能同时搞定视觉和状态比手写矩阵乘再赋值省事得多。const AXIS_VEC { x: new THREE.Vector3(1,0,0), y: new THREE.Vector3(0,1,0), z: new THREE.Vector3(0,0,1) }; let animating false; const queue []; let onMoveDone () {}; export function rotateLayer(axis, layer, turns, duration 200) { if (animating) { queue.push([axis, layer, turns, duration]); return; } const moving cubies.filter(c Math.round(c.position[axis]) layer); if (!moving.length) return; animating true; const pivot new THREE.Group(); scene.add(pivot); moving.forEach(c pivot.attach(c)); // attach 保持世界变换块跟着 pivot 转 const targetAngle -turns * Math.PI / 2; // 负号从轴正方向看向原点时正转是逆时针 const t0 performance.now(); function tick(now) { const p Math.min((now - t0) / duration, 1); const e 1 - Math.pow(1 - p, 3); // easeOutCubic pivot.setRotationFromAxisAngle(AXIS_VEC[axis], targetAngle * e); if (p 1) { requestAnimationFrame(tick); return; } // 收尾挂回主组 量化防浮点漂移 moving.forEach(c { scene.attach(c); c.position.round(); snapQuaternion(c.quaternion); }); scene.remove(pivot); animating false; onMoveDone({ axis, layer, turns }); if (queue.length) rotateLayer(...queue.shift()); } requestAnimationFrame(tick); } // 把四元数对齐到最近的 90° 倍数避免长跑之后出现倾斜 function snapQuaternion(q) { const m new THREE.Matrix4().makeRotationFromQuaternion(q); for (let i 0; i 16; i) m.elements[i] Math.round(m.elements[i]); q.setFromRotationMatrix(m); }参数上axis取x | y | zlayer取-1 | 0 | 10 是中层做 M/E/S 这类公式时要用turns取±1表示顺逆时针duration默认 200ms——低于 150ms 看着像瞬移高于 300ms 连拧时会有粘滞感200 是我试下来最舒服的值。targetAngle前面那个负号容易搞反。约定是从轴正方向朝原点看逆时针为正Three.js 的setRotationFromAxisAngle用的是右手定则两者对Y轴是一致的对X和Z就需要靠符号调整。我第一次写的时候三个轴的符号全配错转出来像是魔方在跳机械舞血泪经验是先固定只调一个轴拿一个已知的公式比如 R U R U对比真实魔方验证方向对了再推广。3.3 输入层鼠标拖拽选面与方向判定拖拽操作的难点不在拾取而在把屏幕上的二维位移翻译成三维的旋转决策。用户按住某个面往右拖这个右在屏幕上是一个方向在世界空间里可能对应绕 Y 轴、也可能对应绕 Z 轴取决于当前视角朝向。const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); let drag null; renderer.domElement.addEventListener(pointerdown, e { pointer.set( (e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 1 ); raycaster.setFromCamera(pointer, camera); const hit raycaster.intersectObjects(cubies, true)[0]; if (!hit) return; // hit.face.normal 是几何体本地空间转到世界空间才是真正的朝向 const n hit.face.normal.clone() .applyQuaternion(hit.object.getWorldQuaternion(new THREE.Quaternion())) .round(); drag { normal: n, startX: e.clientX, startY: e.clientY, cubie: hit.object }; }); renderer.domElement.addEventListener(pointerup, e { if (!drag) return; const dx e.clientX - drag.startX; const dy e.clientY - drag.startY; if (Math.hypot(dx, dy) 8) { drag null; return; } // 位移太小当点击处理 // 与法线垂直的两个轴就是这次拖拽可能的旋转轴 const candidates [x, y, z].filter(a Math.round(drag.normal[a]) 0); // 把候选轴投影到屏幕空间取与拖拽方向最接近的那个 let best null, bestScore -Infinity; for (const axis of candidates) { for (const sign of [1, -1]) { const screenVec projectToScreen(AXIS_VEC[axis].clone().multiplyScalar(sign)); const score screenVec.x * dx screenVec.y * (-dy); // 屏幕 y 轴向下取反 if (score bestScore) { bestScore score; best { axis, sign }; } } } // 层号由被按住的那个块在旋转轴上的坐标决定 const layer Math.round(drag.cubie.position[best.axis]); // 绕该轴转 -sign 方向才能让被按住的面跟着手指走 rotateLayer(best.axis, layer, -best.sign); drag null; }); // 把世界方向投影成归一化的屏幕方向向量 function projectToScreen(v) { const a v.clone().project(camera); const o new THREE.Vector3(0, 0, 0).project(camera); return new THREE.Vector2(a.x - o.x, -(a.y - o.y)).normalize(); }pointerdown里那段applyQuaternion是必须的hit.face.normal给的是几何体原始的法线块转过之后它就不代表屏幕方向了得乘上块当前的世界四元数才是真实的朝外方向。我见过不少实现漏了这一步表现是刚开局拖拽正常拧过几次之后拖拽方向就开始乱。pointerup的判断链条是法线定出两个候选轴 → 候选轴投影到屏幕 → 与拖拽向量做点积 → 分最高者胜出 → 层号取被按块在该轴上的坐标。最后那个-best.sign是方向修正因为轴正向和面跟着手指走是反的。这段逻辑看着绕但它对任意视角都成立把相机转到背面再拖也不会出错比按屏幕象限硬编码四个方向要稳。3.4 打乱、计时与还原检测打乱不能纯随机。同一条轴连着转两次在视觉上是冗余的转完又转回来而且会让打乱序列看起来没那么乱。代码里做了个最小约束相邻两步不用同一条轴。const AXES [x, y, z]; export function scramble(n 20) { const seq []; let lastAxis null; for (let i 0; i n; i) { let axis; do { axis AXES[(Math.random() * 3) | 0]; } while (axis lastAxis); lastAxis axis; // 只转外层中层的随机转动会让打乱结果难以肉眼核对 const layer Math.random() 0.5 ? -1 : 1; const turns Math.random() 0.5 ? 1 : -1; seq.push([axis, layer, turns]); } return seq; }还原判定按贴纸法线分组把每张贴纸的法线用块的四元数转到当前空间四舍五入成0,1,0这样的字符串当 key同一个 key 下的颜色集合大小必须是 1。export function isSolved() { const buckets new Map(); for (const c of cubies) { for (const s of c.userData.stickers) { const n s.localNormal.clone().applyQuaternion(c.quaternion); const key [x, y, z].map(a Math.round(n[a])).join(,); if (!buckets.has(key)) buckets.set(key, new Set()); buckets.get(key).add(s.color); } } // 六个方向、每个方向只有一种颜色就是还原态 return [...buckets.values()].every(set set.size 1); }这个判定的成本是 54 次四元数乘加几十次 Set 操作一帧调一次完全无压力不需要做脏标记优化。计时器挂在onMoveDone上第一转动开始时启动performance.now()isSolved()返回 true 时停表。这里有个细节——打乱序列本身也会触发onMoveDone得用一个scrambling标志位把这段时间排除掉否则秒表在打乱阶段就跑起来了。4. 跑起来目录结构、依赖加载与本地调试入口这份代码的部署成本几乎为零但要改得顺手目录和调试入口得先理清楚。下面是我整理后的结构和原包大同小异主要差别是我把solver.js单独抽了出来方便后面接求解器。4.1 目录结构与文件职责cube-js/ ├── index.html # 入口含 importmap 与 canvas 容器 ├── style.css # 画布铺满、按钮栏、计时器样式 └── src/ ├── main.js # 场景、相机、灯光、渲染循环 ├── cube.js # 建块、转层、打乱、还原判定第 3 章那些代码 ├── input.js # 指针拖拽 键盘映射 ├── ui.js # 按钮、计时器、步数显示 └── solver.js # 预留二次开发时接求解器main.js只做三件事初始化渲染器、注册渲染循环、把cube.js导出的接口挂到window上方便调试。渲染循环里除了renderer.render()还要更新一个THREE.Clock的 delta——虽然这个项目没用到物理但后面加惯性旋转或者拖拽甩动时会需要。键盘映射建议在这一层做键位沿用标准的魔方记号U/D/L/R/F/B对应六个面顺时针按住 Shift 表示逆时针斜体不方便输入就不做了。映射表放在input.js顶部改键位只动这一处。4.2 依赖加载与本地启动不用构建工具时靠 importmap 让浏览器知道import three去哪儿找!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1 / link relstylesheet href./style.css / 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 /head body div idapp/div div idhud span idtimer00:00.0/span button idbtn-scramble打乱/button button idbtn-reset复原/button /div script typemodule src./src/main.js/script /body /htmltypeimportmap必须出现在任何typemodule脚本之前顺序错了浏览器会直接报 Failed to resolve module specifier。这也是我在本地起服务时踩过的第一个坑——直接双击index.html打开是file://协议ES module 会被 CORS 拦下来必须起个静态服务# 任选一种Python 自带的最省事 python3 -m http.server 5173 # 或者 npx serve .three/addons/那条映射是留给OrbitControls的装上之后相机可以用鼠标右键拖动旋转跟左键拖拽转层不冲突——OrbitControls 默认左键也是旋转视角得把controls.mouseButtons改成只认右键和中键否则两种拖拽会打架。4.3 调试面板与状态快照改魔方这种项目肉眼调试的效率极低状态对不对得靠打印。我在main.js末尾挂了一个全局对象import * as Cube from ./cube.js; window.cube { ...Cube, // rotateLayer / scramble / isSolved snapshot() { return Cube.cubies.map(c ({ pos: c.position.toArray().map(Math.round), q: c.quaternion.toArray().map(v v.toFixed(3)), colors: c.userData.stickers.map(s s.color), })); }, // 把一段转动序列跑完用于复现 bug runSeq(seq) { seq.forEach(args Cube.rotateLayer(...args)); }, };snapshot()返回的是可序列化的纯数据复制到控制台里 diff 两次的结果能很快看出是哪一层哪个块没跟上。runSeq()更关键——把出问题的那串操作固化成数组一键重放比反复手拧快十倍。提示调试时把duration改成 0 会让动画瞬间完成但量化逻辑仍会执行适合验证纯状态问题验证手感时再改回 200。渲染性能上27 个块加 54 张贴纸不到 100 个 draw call任何设备都跑得动。真要优化把贴纸合并成InstancedMesh或者用合批材质但那是几百个魔方同屏才需要考虑的事。5. 排查记录五个让魔方转歪、闪面、卡帧的坑这一章是实打实踩出来的。每条按现象 → 原因 → 解决写遇到相似症状可以直接对号入座。坑一拧十几步之后块之间出现细缝面看起来在抖现象是画面出现一条极细的黑线快速转动时特别明显像贴纸没对齐。原因是动画结束后的量化不彻底。c.position.round()只处理了位置四元数没对齐累积几十步之后块的旋转角变成89.9997°贴纸和块本体的相对位置就偏了z-fighting 随之出现。解决办法是把四元数也 snap 到最近的 90° 倍数就是 3.2 里那个snapQuaternion。注意Math.round作用在矩阵元素上是安全的因为纯 90° 旋转的矩阵元素只有-1/0/1三种值浮点误差不会越过 0.5 的边界。坑二快速连点按钮某一层转到一半停住不动现象是动画卡在中间块悬在 45° 位置再点也没反应。原因是两个rotateLayer并发执行各自建了一个 pivot 组同一个块被attach到两个父节点下——Three.js 的attach是从旧父节点移除再挂到新父节点两个动画互相抢块最后就卡住了。解决办法是加animating标志位和队列3.2 里的写法。队列长度建议设个上限比如 8防止用户长按连续触发把内存堆起来。坑三拖拽方向时对时错转视角之后就全乱了现象是默认视角下拖拽正常把相机转到背面再拖转动的层和方向完全不符合预期。原因是用了屏幕坐标硬编码判断方向比如dx 0 就绕 Y 轴转这在正面视角下碰巧成立换个角度就失效。解决办法是把候选轴投影到屏幕空间再做点积也就是 3.3 里的projectToScreen。这套算法对任意视角都成立代价是每次抬手指要算两次project可以忽略不计。坑四打乱之后判定不出还原明明看着已经拼好了现象是肉眼看上去六面同色但isSolved()一直返回 false。原因是分组 key 用了toFixed(2)之类的字符串。四元数转出来的法线可能是-0.0000001toFixed得到-0.00和0.00是两个不同的 key贴纸被分到了错误的组里。解决办法是Math.round之后转整数再拼 key负零会被 round 成0问题消失。这类看着一样但不相等的坑在浮点参与的判定里几乎必然出现凡是拿浮点当 Map 的 key都要先量化。坑五手机上拖拽会连带触发页面滚动转层失灵现象是桌面浏览器一切正常手机上按住魔方一拖整个页面跟着上下滑魔方纹丝不动。原因是pointerdown之后浏览器把这次触摸判定成了滚动手势后续的pointermove被浏览器接管。解决办法是给 canvas 容器加touch-action: none同时在pointerdown里调e.preventDefault()。前者告诉浏览器这块区域的手势我自己处理后者是兜底。两件事都做iOS 和 Android 上才都稳。6. 二次开发加公式回放、接通求解器与手感调校想把这份魔方代码用起来最划算的三个改造方向是公式回放、求解器接入和手感调校投入产出比依次递减。公式回放最容易做也是最有用的调试工具。加一层记号解析把R U R U这样的标准记法翻译成[axis, layer, turns]数组再喂给队列就行const NOTATION { R: [x, 1, 1], L: [x, -1, -1], U: [y, 1, 1], D: [y, -1, -1], F: [z, 1, 1], B: [z, -1, -1], M: [x, 0, -1], E: [y, 0, -1], S: [z, 0, 1], // 中层方向依 R/U/F 定义 }; export function playFormula(str, gap 120) { const tokens str.trim().split(/\s/); tokens.forEach((tk, i) { const face tk[0]; const prime tk.includes(); const base NOTATION[face]; if (!base) return; const [axis, layer, turns] base; // 加引号表示逆时针层号不变、方向取反 setTimeout(() rotateLayer(axis, layer, prime ? -turns : turns), i * (200 gap)); }); }NOTATION里中层的方向定义是最容易错的地方M跟随L、E跟随D、S跟随F跟错了整条公式的结果会完全反掉。验证办法是跑一条已知的恒等式比如R U R U连做六次应该回到原状能回去就说明记号和方向都对了。求解器不要自己写。Kociemba 那套两阶段算法涉及坐标压缩和剪枝表实现成本远超这个项目本身。社区有现成的 JS 移植版输入 54 个面的颜色串输出一串解法记号正好能被上面的playFormula直接消费。接的时候注意颜色顺序约定多数库用U R F D L B的排列跟这份代码的FACE_DEF顺序不一致中间要加一层映射表别直接把贴纸数组传进去。手感调校是被最多人忽略的一块。默认 200ms 线性转完连拧时会有明显的停顿感我一般会加两条一是把队列里相邻的转动时长压缩到 140ms二是让相机支持惯性旋转记录pointermove的速度抬手后按衰减系数继续转几百毫秒。惯性这块要小心别跟层旋转的 pointerup 抢事件加个如果按在块上就不触发视角惯性的判断。改造项工作量需要的额外依赖风险点公式回放半天无中层方向定义求解器接入12 天一个 Kociemba 实现颜色顺序映射手感调校1 天无与拖拽事件冲突状态持久化半天无四元数序列化精度状态持久化顺带提一句把snapshot()的结果JSON.stringify存进 localStorage刷新页面后按位置和朝向重建块就能做关闭页面保留进度。重建时别存四元数的原始 4 个浮点数存量化后的整数矩阵更稳反序列化时用setFromRotationMatrix还原。从那以后我每次动这个项目的动画逻辑都会先把一帧的dt和量化后的位置打出来跑一遍再去看画面——凭手感改参数翻车过太多次日志比眼睛可靠。希望帮到你。本文还有配套的精品资源点击获取