基于 Threejs 实现 3D 魔方

最近这段时间学习了计算机图形学和 Threejs,为了巩固一下学习效果,同时也希望给「莫负休息」新增主题,于是基于 Threejs 实现了一个魔方程序。当然,基于 Threejs 的魔方程序其实早就已经有了,我只不过是站在前人的成果上做了一次实践和总结而已。

源码传送门——Rubiks Cube,Demo 传送门——rubiks.chuquan.me

魔方的定义

魔方(Rubik's Cube),是匈牙利建筑学教授和雕塑鲁比克·埃尔内,于 1974 年发明的机械益智玩具。

魔方是一个正立方体,一共 6 个面,对应 6 种颜色。魔方的官方配色是:白色、红色、橙色、黄色、绿色、蓝色,其中黄白相对,红橙相对,蓝绿相对,如下所示。

一个三阶魔方由 3 x 3 x 3 共 27 个方块组成,根据方块的位置,可以分为 3 种类型,分别是:

  • 中心块:中心块有 6 个,位于魔方每面的正中心,只有一种颜色。中心块彼此之间的相对位置不会变化。
  • 棱块:棱块有 12 个,位于魔方每个魔方中心块的上下左右,有两种颜色。
  • 角块:角块有 8 个,位于魔方每个魔方中心块的斜对角,有三种颜色。

场景布置

对于任意 3D 场景,我们都需要先对场景中的基本元素进行设置,主要包括:相机、灯光、渲染器。

首先初始化一个场景 Scene,后续所有相关元素都将添加至这个场景中,并设置位置坐标。

然后,我们初始化相机,Threejs 中有两种相机:正交相机、透视相机。透视相机成像的画面具有近大远小的效果,所以我们这里使用透视相机。当然,相机的位置确立之后,我们还需要确定它的观测方向,这里使用 lookAt 方法。此外,我们还可以设置相机的视场(Field of View),它表示相机的可视角度值,决定了屏幕画面的可视范围。

对于灯光,这里我只设置了一个环境光,因此无需设置坐标。当然,Threejs 中有很多光源,比如:点光源、面光源、射线光源等。

相关的代码实现如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let scene, camera, renderer;

function setupScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0xFFFFFF);
}

function setupCamera() {
camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(10, 12, 10);
camera.lookAt(0, 3, 0);
camera.fov = 45
camera.updateProjectionMatrix();
}

function setupLights() {
const ambientLight = new THREE.AmbientLight(0xFFFFFF);
scene.add(ambientLight);
}

最后,我们还需要定义一个渲染器。通过渲染器我们才能够将 3D 场景的渲染结果并绑定至 2D 平面,相关代码如下所示。在具体实现中,我们将渲染器的 DOM 元素绑定至 body 中,这样我们才能在 2D 网页中看到渲染效果。

1
2
3
4
5
6
7
function setupRenderer() {
renderer = new THREE.WebGLRenderer({
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
}

另外,为了方便查看空间效果,一般我们会创建一个轨道控制器。基于轨道控制器,我们可以通过鼠标旋转整个空间坐标系,从而可以在不同角度进行观测,相关代码如下所示。

1
2
3
4
5
6
7
let controller;

function setupControls() {
// 初始化控制器
controller = new OrbitControls(camera, renderer.domElement);
controller.enableDamping = true;
}

魔方建模

完成了场景布置之后,我们将在空间中对魔方进行建模。建模的过程非常简单,只需创建 3 x 3 x 3 共 27 个立方体即可,每个立方体的表面使用贴图作为材质。为了便于后续旋转魔方时获取同一平面中的 9 个立方体,我们在建模时会对每个立方体设置编号索引,如下所示。

魔方建模的实现代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 创建立方体,并加入场景
function setupCubes() {
cubes = createCube(0, 0, 0, 3, 1);
for (var i = 0; i < cubes.length; i++) {
var cube = cubes[i];
scene.add(cube);
}
}

// 创建立方体,设置空间左边,使用贴图作为材质
function createCube(x, y, z, num, len) {
// 魔方左上角坐标
var leftUpX = x - num / 2 * len;
var leftUpY = y + num / 2 * len;
var leftUpZ = z + num / 2 * len;
// 根据颜色生成材质
const loader = new THREE.TextureLoader();
const textures = [
loader.load("./img/blue.png"),
loader.load("./img/green.png"),
loader.load("./img/yellow.png"),
loader.load("./img/white.png"),
loader.load("./img/orange.png"),
loader.load("./img/red.png"),
];
const materials = textures.map(texture => new THREE.MeshBasicMaterial({ map: texture }));
// 生成小方块
var cubes = [];
for (var i = 0; i < num; i++) {
for (var j = 0; j < num * num; j++) {
var box = new THREE.BoxGeometry(len, len, len);
var mesh = new THREE.Mesh(box, materials);
// 依次计算各个小方块中心点坐标
mesh.position.x = (leftUpX + len / 2) + (j % num) * len;
mesh.position.y = (leftUpY - len / 2) - parseInt(j / num) * len;
mesh.position.z = (leftUpZ - len / 2) - i * len;
mesh.tag = i * 9 + j;
cubes.push(mesh);
}
}
return cubes;
}

至此,魔方建模实现完成,完整的代码可以参考 RubiksCube01.vue 文件。

魔方控制

魔方控制是基于鼠标实现的,核心思想分为以下几个步骤:

  • 首先,通过鼠标触点确定触点目标方块和触点平面法向量
  • 其次,根据鼠标移动方向和触点平面法向量确定旋转方向
  • 然后,通过旋转方向和触点目标方块获取整个旋转平面
  • 最后,对整个旋转平面中的所有方块执行旋转动画

监听鼠标事件

鼠标事件是控制魔方的基础,因此我们需要实现鼠标事件的监听。相关实现如下所示,我们同时处理了鼠标控制和触摸控制两种情况。

1
2
3
4
5
6
7
8
function setupEvents() {
window.addEventListener('mousedown', startMouse);
window.addEventListener('mousemove', moveMouse);
window.addEventListener('mouseup', stopMouse);
window.addEventListener('touchstart', startMouse);
window.addEventListener('touchmove', moveMouse);
window.addEventListener('touchend', stopMouse);
}

确定触点方块与平面法向量

对于确定目标触点方块和平面法向量,这里有两个问题:

  • 如何通过二维平面中的鼠标位置确定三维空间中的位置呢?
  • 立方体的位置不固定,那么该如何确定触点平面的方向呢?

对于第一个问题,解决方法是 射线(Raycaster),其基本原理是:通过相机位置和鼠标位置确定三维空间中的一根射线,延伸射线,找到三维空间中与射线相交的物体,根据自定义规则(比如:第一个)来找到目标物体。

对于第二个问题,我们首先需要了解一下 Threejs 中的坐标系统: - 全局坐标系:也称世界坐标系,是整个 3D 场景的坐标系。 - 局部坐标系:也称物体坐标系。在 iOS/Android 中存在视图层级树,在 Threejs 中同样存在场景层级树,整个 3D 场景是根场景,空间中的物体可以作为子场景,子场景又可以继续添加场景。每个场景有自己的坐标系,当对一个场景进行仿射变换,那么它的子场景也会发生仿射变换,这就是物体坐标系的作用。

由于魔方旋转过程中,每个立方体自身的也在不停的旋转和移动,此时每个物体的局部坐标系也会发生变换,如下图所示。

此时,如果基于目标立方体获取其表面法向量,那么获取到的法向量是基于局部坐标系的,不具备全局意义。因此,我们必须要将基于 局部坐标系 的表面法向量转换成基于 全局坐标系 的表面法向量。

对此,有两种解决方法:

  • 对基于局部坐标系的法向量通过矩阵变换,转换成基于全局坐标系。
  • 增加一个固定不变的透明物体,通过射线获取其表面法向量,以代表立方体的表面法向量。

对于前者,我们需要记录立方体从原始位置到当前位置的所有变换操作,再对基于局部坐标系的法向量做逆变换。这种方案实现难度且计算量都很大。

对于后者,其实现难度显然更低。我们只需创建一个透明的立方体,其大小与魔方整体相同,如下图所示。当判断表面法向量时,通过该透明立方体获取即可,由此得到的是基于全局坐标系的法向量。

如下所示为确定触点方块与平面法向量的核心代码逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function setupRubiks() {
// 透明正方体
let box = new THREE.BoxGeometry(3, 3, 3);
let mesh = new THREE.MeshBasicMaterial({vertexColors: THREE.FaceColors, opacity: 0, transparent: true});
rubiks = new THREE.Mesh(box, mesh);
rubiks.cubeType = 'coverCube';
scene.add(rubiks);
}

/**
* 获取操作焦点以及该焦点所在平面的法向量
* */
function getIntersectAndNormalize(event) {
let mouse = new THREE.Vector2();
if (event.touches) {
// 触摸事件
var touch = event.touches[0];
mouse.x = (touch.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(touch.clientY / window.innerHeight) * 2 + 1;
} else {
// 鼠标事件
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
// Raycaster方式定位选取元素,可能会选取多个,以第一个为准
var intersects = raycaster.intersectObjects(scene.children);
var intersect, normalize;
if (intersects.length) {
try {
if (intersects[0].object.cubeType === 'coverCube') {
intersect = intersects[1];
normalize = intersects[0].face.normal;
} else {
intersect = intersects[0];
normalize = intersects[1].face.normal;
}
} catch(err) {
//nothing
}
}
return {intersect: intersect, normalize: normalize};
}

确定旋转方向

下面,我们基于触点目标方块、表面法向量,再结合鼠标移动方向,计算旋转方向。具体实现原理主要包括以下几个步骤:

  • 计算鼠标的平移向量
  • 判断平移向量与全局坐标系 6 个方向之间的夹角,选择夹角最小的方向
  • 结合表面法向量,确定旋转方向

为什么要结合表面法向量来确定旋转方向?因为同一平移向量时,表面法向量不同,则魔方的旋转方向也不同。如下所示,当鼠标平移方向接近 x 轴方向,如果表面法向量与 z 轴方向相同,那么魔方将环绕 y 轴进行逆时针旋转;如果表面法向量与 y 轴方向相同,那么魔方将环绕 z 轴进行顺时针旋转。

如下所示,为判断魔方旋转方向的代码逻辑。我们根据不同的拖拽方向分情况讨论,最终确定魔方的 6 种旋转方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// 魔方转动的六个方向
const xLine = new THREE.Vector3( 1, 0, 0 ); // X轴正方向
const xLineAd = new THREE.Vector3( -1, 0, 0 ); // X轴负方向
const yLine = new THREE.Vector3( 0, 1, 0 ); // Y轴正方向
const yLineAd = new THREE.Vector3( 0, -1, 0 ); // Y轴负方向
const zLine = new THREE.Vector3( 0, 0, 1 ); // Z轴正方向
const zLineAd = new THREE.Vector3( 0, 0, -1 ); // Z轴负方向

/**
* 获得旋转方向
* vector3: 鼠标滑动的方向
*/
function getDirection(vector3) {
var direction;
// 判断差向量和 x、y、z 轴的夹角
var xAngle = vector3.angleTo(xLine);
var xAngleAd = vector3.angleTo(xLineAd);
var yAngle = vector3.angleTo(yLine);
var yAngleAd = vector3.angleTo(yLineAd);
var zAngle = vector3.angleTo(zLine);
var zAngleAd = vector3.angleTo(zLineAd);
var minAngle = Math.min(...[xAngle, xAngleAd, yAngle, yAngleAd, zAngle, zAngleAd]); // 最小夹角
switch(minAngle){
case xAngle:
direction = 10; // 向x轴正方向旋转90度(还要区分是绕z轴还是绕y轴)
if (normalize.equals(yLine)) {
direction = direction + 5; // 绕z轴顺时针
} else if (normalize.equals(yLineAd)) {
direction = direction + 6; // 绕z轴逆时针
} else if (normalize.equals(zLine)) {
direction = direction + 4; // 绕y轴逆时针
} else if (normalize.equals(zLineAd)) {
direction = direction + 3; // 绕y轴顺时针
}
break;
case xAngleAd:
direction = 20; // 向x轴反方向旋转90度
if (normalize.equals(yLine)) {
direction = direction + 6; // 绕z轴逆时针
} else if (normalize.equals(yLineAd)) {
direction = direction + 5; // 绕z轴顺时针
} else if (normalize.equals(zLine)) {
direction = direction + 3; // 绕y轴顺时针
} else if (normalize.equals(zLineAd)) {
direction = direction + 4; // 绕y轴逆时针
}
break;
case yAngle:
direction = 30; // 向y轴正方向旋转90度
if (normalize.equals(zLine)) {
direction = direction + 1; // 绕x轴顺时针
} else if (normalize.equals(zLineAd)) {
direction = direction + 2; // 绕x轴逆时针
} else if (normalize.equals(xLine)) {
direction = direction + 6; // 绕z轴逆时针
} else {
direction = direction + 5; // 绕z轴顺时针
}
break;
case yAngleAd:
direction = 40; // 向y轴反方向旋转90度
if (normalize.equals(zLine)) {
direction = direction + 2; // 绕x轴逆时针
} else if (normalize.equals(zLineAd)) {
direction = direction + 1; // 绕x轴顺时针
} else if (normalize.equals(xLine)) {
direction = direction + 5; // 绕z轴顺时针
} else {
direction = direction + 6; // 绕z轴逆时针
}
break;
case zAngle:
direction = 50; // 向z轴正方向旋转90度
if (normalize.equals(yLine)) {
direction = direction + 2; // 绕x轴逆时针
} else if (normalize.equals(yLineAd)) {
direction = direction + 1; // 绕x轴顺时针
} else if (normalize.equals(xLine)) {
direction = direction + 3; // 绕y轴顺时针
} else if (normalize.equals(xLineAd)) {
direction = direction + 4; // 绕y轴逆时针
}
break;
case zAngleAd:
direction = 60; // 向z轴反方向旋转90度
if (normalize.equals(yLine)) {
direction = direction + 1; // 绕x轴顺时针
} else if (normalize.equals(yLineAd)) {
direction = direction + 2; // 绕x轴逆时针
} else if (normalize.equals(xLine)) {
direction = direction + 4; // 绕y轴逆时针
} else if (normalize.equals(xLineAd)) {
direction = direction + 3; // 绕y轴顺时针
}
break;
default:
break;
}
return direction;
}

确定旋转平面

随后,我们可以根据触点目标方块的位置,结合旋转方向,找到与它同一旋转平面的立方体。比如,对于绕 x 轴旋转时,我们只需要找到所有与触点目标方块的 x 坐标相同的立方体即可。相关实现如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 根据立方体和旋转方向,找到同一平面上的所有立方体
*/
function getPlaneCubes(cube, direction) {
let results = [];
let orientation = direction % 10;
let radians = (orientation % 2 == 1) ? 90 : -90;
switch (orientation) {
case 1:
case 2:
// 绕x轴
for (let i = 0; i < cubes.length; i++) {
let curr = cubes[i];
console.log(curr.position);
if (Math.abs(curr.position.x - cube.position.x) < 0.2) {
results.push(curr);
}
}
break;
case 3:
case 4:
// 绕y轴
for (let i = 0; i < cubes.length; i++) {
let curr = cubes[i];
console.log(curr.position);
if (Math.abs(curr.position.y - cube.position.y) < 0.2) {
results.push(curr);
}
}
break;
case 5:
case 6:
// 绕z轴
for (let i = 0; i < cubes.length; i++) {
let curr = cubes[i];
console.log(curr.position);
if (Math.abs(curr.position.z - cube.position.z) < 0.2) {
results.push(curr);
}
}
break;
}
return results;
}

实现旋转动画

最后,我们需要实现旋转动画。对此,我们首先定义动画时长,根据当前时长与动画时长的比例,计算当前旋转角度的比例,并更新位置,从而实现旋转效果。关于旋转变换,我们在 《计算机图形学基础(2)——变换》 一文中也介绍过。

我们以 2D 平面中的物体旋转来推导旋转矩阵。上图所示,我们将左边的图片进行旋转得到右边的图片,那么我们必须求解如下所示的矩阵运算公式,其中 \(A\)\(B\)\(C\)\(D\) 为待求解的变量。

\[\begin{aligned} \left( \begin{matrix} x' \\ y' \end{matrix} \right) = \left( \begin{matrix} A & B \\ C & D \\ \end{matrix} \right) \left( \begin{matrix} x \\ y \end{matrix} \right) \end{aligned}\]

为了求解 \(A\)\(B\)\(C\)\(D\) 四个变量,我们将以 \((0, 1)\)\((1, 0)\) 两个点的旋转为例求解方程。

对于 \((0, 1)\) 点的旋转,我们可以得到如下方程:

\[\begin{aligned} \left( \begin{matrix} -sin \theta \\ cos \theta \end{matrix} \right) = \left( \begin{matrix} A & B \\ C & D \\ \end{matrix} \right) \left( \begin{matrix} 0 \\ 1 \end{matrix} \right) \\ -sin \theta = A * 0 + B * 1 = B \\ cos \theta = C * 0 + D * 1 = D \end{aligned}\]

对于 \((1, 0)\) 点的旋转,我们可以得到如下方程:

\[\begin{aligned} \left( \begin{matrix} cos \theta \\ sin \theta \end{matrix} \right) = \left( \begin{matrix} A & B \\ C & D \\ \end{matrix} \right) \left( \begin{matrix} 1 \\ 0 \end{matrix} \right) \\ cos \theta = A * 1 + B * 0 = A \\ sin \theta = C * 1 + D * 0 = C \end{aligned}\]

至此 \(A\)\(B\)\(C\)\(D\) 四个变量均已求解,由此得到旋转矩阵如下:

\[\begin{aligned} \left( \begin{matrix} x' \\ y' \end{matrix} \right) = \left( \begin{matrix} cos\theta & -sin\theta \\ sin\theta & cos\theta \\ \end{matrix} \right) \left( \begin{matrix} 1 \\ 0 \end{matrix} \right) \end{aligned}\]

进而得到 \(x'\)\(y'\) 的计算公式如下:

\[\begin{aligned} x' = cos\theta x - sin\theta y \\ y' = sin\theta x + cos\theta y \end{aligned}\]

由于魔方的旋转都是沿着一个轴进行旋转,所以我们可以将它看成三种情况的 2D 平面旋转,由此得到如下 3 个旋转方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function rotateAroundWorldX(cube, rad){
var y0 = cube.position.y;
var z0 = cube.position.z;
var q = new THREE.Quaternion();
q.setFromAxisAngle(new THREE.Vector3( 1, 0, 0 ), rad);
cube.quaternion.premultiply(q);
cube.position.y = Math.cos(rad) * y0 - Math.sin(rad) * z0;
cube.position.z = Math.cos(rad) * z0 + Math.sin(rad) * y0;
}

function rotateAroundWorldY(cube, rad){
var x0 = cube.position.x;
var z0 = cube.position.z;
var q = new THREE.Quaternion();
q.setFromAxisAngle(new THREE.Vector3( 0, 1, 0 ), rad);
cube.quaternion.premultiply( q );
cube.position.x = Math.cos(rad) * x0 + Math.sin(rad) * z0;
cube.position.z = Math.cos(rad) * z0 - Math.sin(rad) * x0;
}

function rotateAroundWorldZ(cube, rad){
var x0 = cube.position.x;
var y0 = cube.position.y;
var q = new THREE.Quaternion();
q.setFromAxisAngle(new THREE.Vector3( 0, 0, 1 ), rad);
cube.quaternion.premultiply( q );
cube.position.x = Math.cos(rad) * x0 - Math.sin(rad) * y0;
cube.position.y = Math.cos(rad) * y0 + Math.sin(rad) * x0;
}

由于这几个方法仅仅旋转物体、更新坐标,实际上我们需要在一段时间内连续进行调用,从而实现一个完整的旋转动画,具体的调用实现如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function rotateAnimation(cubes, direction, currentstamp, startstamp, laststamp) {
if(startstamp === 0){
startstamp = currentstamp;
laststamp = currentstamp;
}
if(currentstamp - startstamp >= rotateDuration){
currentstamp = startstamp + rotateDuration;
isRotating = false;
startPoint = null;
}
let orientation = direction % 10;
let radians = (orientation % 2 == 1) ? -90 : 90;
switch (orientation) {
case 1:
case 2:
for (let i = 0; i < cubes.length; i++) {
rotateAroundWorldX(cubes[i], radians * Math.PI / 180 * (currentstamp - laststamp) / rotateDuration);
}
break;
case 3:
case 4:
for (let i = 0; i < cubes.length; i++) {
rotateAroundWorldY(cubes[i], radians * Math.PI / 180 * (currentstamp - laststamp) / rotateDuration);
}
break;
case 5:
case 6:
for (let i = 0; i < cubes.length; i++) {
rotateAroundWorldZ(cubes[i], radians * Math.PI / 180 * (currentstamp - laststamp) / rotateDuration);
}
break;
}
if(currentstamp - startstamp < rotateDuration){
requestAnimationFrame((timestamp) => {
rotateAnimation(cubes, direction, timestamp, startstamp, currentstamp);
});
}
}

至此,我们实现了通过鼠标控制魔方的旋转,完整的代码可以参考 RubiksCube02.vue 文件。

总结

本文我们基于 Threejs 实现了一个 3D 魔方,并支持了通过鼠标控制魔方旋转的功能。后续,我们将进一步介绍如何实现魔方的自动还原算法。

参考

  1. 魔方基本定义