mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3509 字
9 分钟
3D 魔方:React + Three.js 交互与层先法自动求解

一个可以拖拽转面、随机打乱、并且用层先法(入门法)自动还原并分步讲解的 3D 魔方。项目完全跑在浏览器里:React 19 + react-three-fiber 负责渲染与交互,纯 TypeScript 逻辑层负责状态与求解,zustand 串联状态与动画队列。本文记录状态模型、交互几何、求解器设计与测试闭环四个部分。

项目概览#

功能说明
拖拽转面按住某个面的贴纸沿切线方向拖拽,超过阈值触发该层 ±90° 转动
视角控制在空白背景按住拖拽旋转视角(OrbitControls,仅旋转、无缩放平移)
按钮转面U D L R F B 及反向 U′ D′ L′ R′ F′ B′(Singmaster 记法)
随机打乱 / 重置一键生成 20 步合法打乱序列,随时回到初始状态
自动还原层先法 7 阶段求解器,输出分步中文讲解并逐条动画演示,支持暂停 / 继续 / 取消

技术栈:Vite 6 + React 19 + TypeScript,react-three-fiber + drei 渲染,zustand 状态管理,vitest 单元测试。纯前端、无后端,核心求解逻辑与渲染完全解耦(src/cube/ 可以在 Node 环境直接测试)。

在线演示#

下面嵌入的是构建后的真实产物,可以直接玩:先点「打乱」再点「自动还原」,讲解面板会逐条高亮当前步骤;也可以自己拖拽某一层转面,或者按住空白背景旋转视角。

TIP

演示是 iframe 嵌入的独立应用,也支持在新窗口打开。整个还原过程由求解器实时计算,不是预录动画——每次打乱得到的还原步骤都可能不同。

一、状态模型:27 个 cubie 与一次 applyMove#

魔方状态最朴素也最可靠的表示,就是逐个记录 27 个小方块(含不可见的内部块):每个 cubie 有整数坐标 position 和 6 个贴纸槽位 colors

export function makeCubie(p: Vec3): Cubie {
const [x, y, z] = p;
const colors: (Color | null)[] = [null, null, null, null, null, null];
if (x === 1) colors[0] = 'R'; // +X → 红
if (x === -1) colors[1] = 'O'; // -X → 橙
if (y === 1) colors[2] = 'W'; // +Y → 白
if (y === -1) colors[3] = 'Y'; // -Y → 黄
if (z === 1) colors[4] = 'G'; // +Z → 绿
if (z === -1) colors[5] = 'B'; // -Z → 蓝
return { position: [...p], colors };
}

标准配色:白上、黄下、红右、橙左、绿前、蓝后。colors 的下标固定对应世界坐标轴方向,null 表示该 cubie 在这个方向没有贴纸(角块 3 张贴纸、棱块 2 张、中心块 1 张、内部块 0 张)。

一次转动做了什么#

每个面在 FACE_INFO 里登记了三个事实:转动轴、所在层(轴坐标 ±1)、顺时针对应的转角方向:

export const FACE_INFO: Record<Face, { axis: Axis; layer: 1 | -1; theta: 1 | -1 }> = {
R: { axis: 'x', layer: 1, theta: -1 },
L: { axis: 'x', layer: -1, theta: 1 },
U: { axis: 'y', layer: 1, theta: -1 },
D: { axis: 'y', layer: -1, theta: 1 },
F: { axis: 'z', layer: 1, theta: -1 },
B: { axis: 'z', layer: -1, theta: 1 },
};

applyMove 只筛选出位于该层的 cubie,对每个做「位置旋转 + 贴纸置换」两步:

applyMove(m: Move): void {
// ...整体旋转(x/y/z)分支省略
const { axis, layer, theta } = FACE_INFO[m.face];
const t = (theta * m.dir) as 1 | -1;
const ai = axis === 'x' ? 0 : axis === 'y' ? 1 : 2;
for (const cb of this.cubies) {
if (cb.position[ai] !== layer) continue;
cb.position = rotatePos(cb.position, axis, t);
cb.colors = rotateColors(cb.colors, axis, t);
}
}

rotatePos整数坐标旋转(x,y,z) 绕 x 轴转 90° 变成 (x,-z,y)),全程没有浮点误差——状态模型天然精确,这也是求解器可以放心在它之上做 BFS 的原因。rotateColors 则是把 6 个贴纸槽位按法向量旋转做置换,保证「贴纸始终与世界坐标轴对齐」这个不变量。

IMPORTANT

状态(cubie 的位置 + 贴纸)是唯一事实来源。渲染层只是把这个状态「投影」到屏幕上,动画期间状态不变、动画结束才提交——逻辑与表现彻底分离,这是后面测试能跑得那么快的前提。

判定已解:一张展开图#

isSolved() 不比较 cubie 数组,而是用 FACE_GRID 把每个面的 3×3 网格映射回世界坐标,逐格检查贴纸颜色是否符合预期。展开图(U 在上、F 居中、D 在 F 下、B 在 D 下、L/R 分居两侧)保证 12 条共享边的世界坐标两两一致、无镜像——这个不变量有专门的单元测试守护。

二、渲染与交互:把「转层」变成「拖拽」#

场景搭建#

27 个 RoundedBox(共享同一份几何体),间距 1.08、边长 0.94,留出约 0.14 的缝隙露出深色内部;每个 cubie 按 colors 组合贴 6 面独立材质,材质按颜色组合缓存复用。视角用 OrbitControls,只允许旋转、禁用缩放和平移。

拖拽转面的几何#

转面的核心问题是:如何把一个 2D 指针拖拽翻译成「哪一层、朝哪个方向转一次」。实现分三步:

  1. 命中定面pointerdown 命中某个 mesh 时,从 e.face.normal(对象空间法线)直接映射到面。空闲时 mesh 恒为零旋转,所以对象空间法线就是世界法线,可以放心映射。
  2. 平面投影:以命中点和该法线构造一个无限平面,把指针位置沿视线投射到平面上,得到 hit 点。
  3. 切线累积:转动方向就是角速度方向 cross(轴, 点)(ω×r),把指针位移在切线方向上投影并累加,超过阈值(0.2)触发一次转动:
// 切线 = 正转方向(ω×r);归一化后累加,消除面中心处幅值为 0 的死区
const tangent = new THREE.Vector3().crossVectors(AXIS_VEC[FACE_INFO[drag.face].axis], drag.point);
if (tangent.lengthSq() > 1e-8) tangent.normalize();
drag.accum += hit.clone().sub(drag.prev).dot(tangent);
drag.prev.copy(hit);
if (Math.abs(drag.accum) > 0.2) {
const dir = ((drag.accum > 0 ? 1 : -1) * FACE_INFO[drag.face].theta) as 1 | -1;
useCubeStore.getState().enqueueMove({ kind: 'face', face: drag.face, dir });
dragRef.current = null; // 一次拖拽只触发一次
}

几个细节值得一提:

  • move/upwindow 原生监听,指针拖出 mesh、甚至拖出画布后拖拽仍然持续;配合 setPointerCapture 保证指针移出浏览器窗口也能收到 pointerup
  • 归一化切线是为了消除面中心处的死区:面中心到轴的距离为 0,ω×r 幅值为 0,直接用它当方向会在中心附近出现「拖不动」的体验。
  • 拖拽期间禁用 OrbitControls,避免转层时视角被误旋转;多指按下时忽略新指针,防止两个手势打架。
  • 一次拖拽只触发一次转动(触发后清空 dragRef),方向由累积位移的符号决定。

动画:pivot 分组旋转#

每次 90° 转动是 250ms 的 ease-in-out。实现上把该层的 mesh 临时挂到一个 THREE.Group(pivot)下,只旋转 pivot;动画结束时把 mesh 挂回根节点、并把动画后的状态同步给渲染(syncMeshes),与 store 提交的状态保持一致。动画由 useFrame 逐帧驱动,不经过 React 渲染循环——动画期间没有 React 重渲染,性能开销极小。

三、层先法求解器:7 阶段、公式与 BFS#

求解器 src/cube/solver.ts 实现标准入门法(层先法)的 7 个阶段,每一步输出 { move, stage, stageTitle, description }description 就是讲解面板里的中文说明:

阶段目标
1 底层十字白十字(白棱就位且侧面颜色对齐中心)
2 底层角块四个白角归位
3 第二层棱块四个中层棱归位(至此下两层完成)
4 顶层十字标准公式 F R U R' U' F',dot→L→line→cross
5 顶层角块定位角块位置归位(朝向暂不管)
6 顶层角块朝向角块黄贴纸全部朝上
7 顶层棱块归位棱块全部就位

关键技巧一:观测帧 vs 物理状态#

经典入门法默认「白面在底」做 F2L。实现的做法是:白十字完成后整体 x2 翻转(白面转下),用经典公式做底层角块和第二层;顶层阶段结束后再 x2 翻回、补一个 y 旋转做帧规范化。x2/y 只改变观测帧,不扰动任何块——物理上魔方已经还原,只是我们看它的角度变了。

翻转带来的坑:x2 之后侧面中心的顺序是镜像序(红在右、蓝在前、绿在后、橙在左),所以顶层阶段的谓词全部「帧相关」——按中心色现算 home 位置,而不是查固定坐标表:

function yellowCornerHome(cube: Cube, colors: Color[]): Vec3 {
for (const h of TOP_CORNER_POSITIONS) {
const [x, , z] = h;
const xc = centerColorAt(cube, [x, 0, 0]);
const zc = centerColorAt(cube, [0, 0, z]);
if ((colors[1] === xc && colors[2] === zc) || (colors[1] === zc && colors[2] === xc)) return h;
}
...
}

关键技巧二:阶段 4-6 用「保层」公式 + BFS#

阶段 4 是确定性公式:F R U R' U' F',配合 U-setup 把当前形态转到规范形(dot / L / line),循环直到黄十字完成。

阶段 5/6 是本项目最有意思的部分。它们的移动集是 13 个经实测验证「对 F2L 与黄十字零影响」的经典公式(含 Sune 系、角三循环等,不含 R'D'RD——实测会破坏 F2L)。在这个受限移动集上做 BFS,搜索把顶层角块带到目标态的最短公式组合:

  • 状态编码:每个角块 = 位置(0..7)× 贴纸排列(0..5),共 48 态;4 个角块打包成一个 48⁴ 的数值 key;
  • 每个公式的作用被预计算成 48 态置换表(CORNER_MOVE_TABLES),模块级惰性缓存——BFS 里查表代替逐 move 模拟;
  • 深度上限 16、节点数上限 30 万,保证最坏情况可终止。

用 BFS 而不是背公式的好处:目标态可以精确表达。阶段 5 的目标是「位置归位、朝向任意」,阶段 6 是「完全正确」,两者在同一个搜索框架下只是目标态集合不同。

NOTE

设计文档原稿的阶段 6 用的是 R'D'RD 反复转角块的经典教法,实测发现它会把角块停在「黄朝上但侧面贴纸对调」的扭转类,最终魔方无法还原;改用 BFS 直接搜索到完全正确终态后,100 个随机打乱全部通过。这是「教学直觉」被「可验证正确性」纠正的一个例子。

关键技巧三:阶段 7 用整体旋转代替 U-setup#

阶段 7 用经典三循环公式 F2 U L R' F2 L' R U F2(作用在 (UL,UR,UF) 上)。标准教法是先 U-setup 把正确棱转到合适位置——但实测 U把已归位的角块整体挪走,导致最后魔方不还原。实现改为整体 y 旋转把正确棱转到后面(y 旋转不扰动任何块),循环结束后再转回来,最后统一做 x2 翻回 + y 帧规范化收尾。

求解入口#

export function solveCube(start: Cube): SolveStep[] {
if (start.isSolved()) return [];
const cube = start.clone();
const steps: SolveStep[] = [];
const record = (alg: Move[] | string, stage: number, description: string) => {
const moves = typeof alg === 'string' ? parseAlg(alg) : alg;
for (const m of moves) {
steps.push({ move: m, stage, stageTitle: STAGES[stage], description });
}
};
solveFirstTwoLayers(cube, record);
solveYellowCross(cube, record);
solveYellowCornerPosition(cube, record);
solveYellowCornerOrientation(cube, record);
solveYellowEdgePosition(cube, record);
return steps;
}

已解魔方直接返回空数组(避免白十字阶段记录的 x2 翻面噪音);record 是纯记录回调,各阶段函数在传入的克隆上自行 applyMove,收集和改动互不干扰。

四、状态管理与动画队列#

zustand store 维护了一份「已提交」的魔方状态和一条动画请求队列:

  • animQueue 是 FIFO;finishMove每次动画完成时恰好调用一次,消费队头、把它应用到模型状态、再决定是否启动下一步。所以求解演示就是「把 steps 的 move 逐个塞进队列」——队列天然驱动了动画的推进。
  • 动画期间 store 不变:渲染层拿到的 cube 快照不随动画更新,避免 React 每帧重渲染。

两个工程细节值得记录:

代际(epoch)防竞态。求解计算是异步的(setTimeout 30ms 后执行),而这段时间用户可能点了「打乱 / 重置 / 取消演示」——scramble/reset/cancelSolve/startSolve 都会把 animEpoch +1,异步回调开始前校验 epoch,过期结果直接丢弃:

const epoch = s.animEpoch;
const run = () => {
const cur = useCubeStore.getState();
if (cur.animEpoch !== epoch) { set({ computing: false }); return; }
...
};
if (immediate) { run(); return; }
set({ computing: true });
setTimeout(run, 30);

冷缓存延迟计算solveCube 首次调用要构建 BFS 移动表(1-7s),如果同步算会把首帧卡死。实现先置 computing=true 渲染出「求解中…」按钮态,30ms 后再真正计算,让 React 先提交一帧反馈;任何异常都会清掉 computing,避免 UI 永久锁死在「求解中…」。

五、测试与正确性#

项目有 92 个测试用例(36 个测试文件),全部跑在纯逻辑层,不依赖浏览器:

  • 状态还原性:对随机序列,按逆序、逆方向执行一遍后状态不变——这是 applyMove 正确性的黄金测试;
  • 求解收敛solveCube 对 ≥100 个固定种子(LCG 伪随机,保证确定性可复现)的 25 步打乱全部还原;已解输入返回空;
  • 阶段分片测试:白十字每步只放置一个棱块(步数 ≤ 4)、F2L 逐阶段收敛、顶层 4-7 阶段端到端验证;
  • 共享边一致性:展开图 12 条共享边无镜像。

工程上有个反直觉的坑:F2L 长测试(BFS 冷缓存未命中时较重)并行执行会把单文件时长推到 vitest worker 的 RPC 超时(60s)之上,报出 [vitest-worker] Timeout calling onTaskUpdate。解决方式是按小任务量拆分测试文件 + 限制 maxWorkers: 2,e2e 也从 4 个文件 × 25 种子拆成 8 个文件 × 12-13 种子。

describe('solveCube e2e (seeds 2000-2012)', () => {
it('solves 13 seeded random scrambles end-to-end', () => {
for (let seed = 2000; seed <= 2012; seed++) {
const start = new Cube();
start.applyMoves(scrambleMovesSeeded(25, seed));
const steps = solveCube(start);
const check = start.clone();
for (const s of steps) check.applyMove(s.move);
expect(check.isSolved()).toBe(true);
}
}, 120000);
});

六、总结与思考#

这个项目最有价值的地方在于分层带来的可测试性

  1. 状态即事实:27 个 cubie 的整数坐标 + 贴纸槽位是唯一事实来源,渲染只是它的投影。没有浮点误差、没有同步问题,求解器可以放心地在纯逻辑层做 BFS。
  2. 表现与逻辑解耦:动画队列、epoch 代际、异步计算——这些工程机制全部服务于「状态在动画完成后才提交」这一条纪律,换来的是 React 零额外重渲染。
  3. 公式与搜索的结合:前四层用确定性公式,顶层用「保层移动集 + BFS」——既保证了讲解步骤的经典性,又用搜索兜住了公式表的死角。

在浏览器里拖一拖、打乱、再看着它一步步讲着中文还原,是这套设计最好的验收方式。动手玩过之后,再回头看状态模型和 BFS 编码,会更有感觉。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

3D 魔方:React + Three.js 交互与层先法自动求解
https://hajim1.art/posts/rubiks-cube-3d/
作者
Takamatsu Tomori
发布于
2026-08-01
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录