基于 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 | let scene, camera, renderer; |
最后,我们还需要定义一个渲染器。通过渲染器我们才能够将 3D
场景的渲染结果并绑定至 2D
平面,相关代码如下所示。在具体实现中,我们将渲染器的 DOM 元素绑定至
body
中,这样我们才能在 2D 网页中看到渲染效果。
1 | function setupRenderer() { |
另外,为了方便查看空间效果,一般我们会创建一个轨道控制器。基于轨道控制器,我们可以通过鼠标旋转整个空间坐标系,从而可以在不同角度进行观测,相关代码如下所示。
1 | let controller; |
魔方建模
完成了场景布置之后,我们将在空间中对魔方进行建模。建模的过程非常简单,只需创建 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 | function setupEvents() { |
确定触点方块与平面法向量
对于确定目标触点方块和平面法向量,这里有两个问题:
- 如何通过二维平面中的鼠标位置确定三维空间中的位置呢?
- 立方体的位置不固定,那么该如何确定触点平面的方向呢?
对于第一个问题,解决方法是 射线(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
45function 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)——变换》 一文中也介绍过。
我们以 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 | function rotateAroundWorldX(cube, rad){ |
由于这几个方法仅仅旋转物体、更新坐标,实际上我们需要在一段时间内连续进行调用,从而实现一个完整的旋转动画,具体的调用实现如下所示。
1 | function rotateAnimation(cubes, direction, currentstamp, startstamp, laststamp) { |
至此,我们实现了通过鼠标控制魔方的旋转,完整的代码可以参考 RubiksCube02.vue 文件。
总结
本文我们基于 Threejs 实现了一个 3D 魔方,并支持了通过鼠标控制魔方旋转的功能。后续,我们将进一步介绍如何实现魔方的自动还原算法。