1 需求实现

绘制魔方 中基于OpenGL ES 实现了魔方的绘制,实现较复杂,本文基于 Unity3D 实现了 2 ~ 10 阶魔方的整体旋转和局部旋转。

​ 本文完整代码资源见→基于 Unity3D 的 2 ~ 10 阶魔方实现。下载资源后,进入【Build/Windows】目录,打开【魔方.exe】文件即可体验产品。

​ 详细需求如下:

1)魔方渲染模块

  • 用户选择魔方阶数,渲染指定阶数的魔方;

2)魔方整体控制模块

  • 用户 Scroll 或 Ctrl + Scroll,控制魔方放大和缩小;
  • 用户 Drag 空白处(或右键 Drag),控制魔方整体连续旋转;
  • 用户点击翻面按钮(或方向键,或 Ctrl + Drag,或 Alt + Drag),控制魔方翻面;
  • 用户点击朝上的面按钮,控制魔方指定面朝上;
  • 可以实时识别用户视觉下魔方的正面、上面、右面;

3)魔方局部控制模块

  • 用户点击刷新按钮,打乱魔方;
  • 用户 Drag 魔方相邻的两个方块,控制该层旋转,Drag 结束自动对齐魔方(局部旋转);
  • 用户输入公式,提交后执行公式旋转对应层;
  • 每次局部旋转结束,检验魔方是否拼成,若拼成,弹出通关提示;

4)魔方动画模块

  • 魔方翻面动画;
  • 魔方指定面朝上动画;
  • 魔方打乱动画;
  • 魔方局部旋转对齐动画;
  • 公式控制魔方旋转动画;
  • 通关弹窗动画(渐变+缩放+平移);
  • 撤销和逆撤销动画;
  • 整体旋转和局部旋转动画互不干扰,可以并行;

5)魔方撤销和逆撤销模块

  • Drag 魔方连续整体旋转支持撤销和逆撤销;
  • 魔方翻面支持撤销和逆撤销;
  • 魔方指定面朝上支持撤销和逆撤销;
  • 魔方局部旋转支持撤销和逆撤销;
  • 公式控制魔方旋转支持撤销和逆撤销(撤销整个公式,而不是其中的一步);

6)其他模块

  • 用户点击返回按钮,可以返回到选择阶数界面;
  • 用户每进行一次局部旋转,记步加 1,公式每走一步,记步加1 ;
  • 显示计时器;
  • 用户点击开始 / 暂停按钮,可以控制计时器运行 / 暂停,暂停时,只能整体旋转,不能局部旋转;
  • 用户异常操作,弹出 Toast 提示(主要是公式输入合法性校验);

​ 选择阶数界面如下:

​ 魔方界面如下:

2 相关技术栈

3 原理介绍

3.1 魔方编码

​ 为方便计算,需要对魔方的轴、层序、小立方体、方块、旋转层进行编码,编码规则如下(假设魔方阶数为 n):

  • 轴:x、y、z 轴分别编码为 0、1、2,x、y、z 轴分别指向 right、up、forward(由魔方的正面指向背面,左手坐标系);
  • 层序:每个轴向,由负方向到正方向分别编码为 0 ~ (n-1);
  • 小立方体:使用仅包含 3 个元素的一维数组 loc 标记,loc[axis] 表示该小立方体在 axis 轴下的层序;
  • 方块:红、橙、绿、蓝、粉、黄、黑色方块分别编码为:0、1、2、3、4、5、-1;
  • 旋转层:旋转层由旋转轴 (axis) 和层序 (seq) 决定。

3.2 渲染原理

​ 在 Hierarchy 窗口新建一个空对象,重命名为 Cube,在 Cube 下创建 6 个 Quad 对象,分别重命名为 0 (x = -0.5)、1 (x = 0.5)、2 (y = -0.5)、3 (y = 0.5)、4 (z = -0.5)、5 (z = 0.5) (方块的命名标识了魔方所属的面,在魔方还原检测中会用到),调整位置和旋转角度,使得它们围成一个小立方体,将 Cube 拖拽到 Assets 窗口作为预设体。

​ 在创建一个 n 阶魔方时,新建一个空对象,重命名为 Rubik,复制 n^3 个 Cube 作为 Rubik 的子对象,调整所有 Cube 的位置使其拼成魔方结构,根据立方体和方块位置,为每个方块设置纹理图片,如下:

​ 说明:对于任意小方块 Square,Square.forward 始终指向小立方体中心,该结论在旋转层检测中会用到;Inside.png 为魔方内部色块,用粉红色块代替白色块是为了凸显白色线框。

​ 每个小立方体的贴图代码如下:

​ Cube.cs

private void GetTextures()
{ // 获取纹理
textures = new Texture[COUNT];
for (int i = 0; i < COUNT; i++)
{
textures[i] = RubikRes.INSET_TEXTURE;
squares[i].name = "-1";
}
for(int i = 0; i < COUNT; i++)
{
int axis = i / 2;
// loc为小立方体的位置序号(以魔方的左下后为坐标原点, 向右、向上、向前分别为x轴、y轴、z轴, 小立方体的边长为单位刻度)
if (loc[axis] == 0 && i % 2 == 0 || loc[axis] == Rubik.Info().order - 1 && i % 2 == 1)
{
textures[i] = RubikRes.TEXTURES[i];
squares[i].name = i.ToString();
}
squares[i].GetComponent<Renderer>().material.mainTexture = textures[i];
}
}

3.3 整体旋转原理

​ 通过调整相机前进和后退,控制魔方放大和缩小;通过调整相机的位置和姿态,使得相机绕魔方旋转,实现魔方整体旋转。详情见缩放、平移、旋转场景

​ 使用相机绕魔方旋转以实现魔方整体旋转的好处主要有:

  • 整体旋转和局部旋转可以独立执行,互不干扰,方便实现整体旋转和局部旋转的动画并行;
  • 魔方的姿态始终固定,其 x、y、z 轴始终与世界坐标系的 x、y、z 轴平行,便于后续计算,不用进行一系列的投影计算,也节省了性能;
  • 整体旋转的误差不会对局部旋转造成影响,不会影响魔方结构,不会出现魔方崩塌问题。

3.4 用户视觉下魔方坐标轴检测原理

​ 用户翻面、选择朝上的面等整体旋转操作,会改变魔方的正面、右面、上面(即魔方朝上的面不一定是蓝色面、朝右的面不一定是橙色面、朝前的面不一定是粉色面),用户视觉下魔方的 x、y、z 轴也会发生变化。假设魔方的 x、y、z 轴正方向单位向量为 ox、oy、oz,用户视觉下魔方的 x、y、z 轴正方向单位向量为 ux、uy、uz,相机的 right、up、forward 轴正方向单位向量分别为 cx、cy、cz,则 ux、uy、uz 的取值满足以下关系:

​ 相关代码如下:

​ AxisUtils.cs

using UnityEngine;

/*
* 坐标轴工具类
* 坐标轴相关计算
*/
public class AxisUtils
{
private static Vector3[] worldAxis = new Vector3[] { Vector3.right, Vector3.up, Vector3.forward }; // 世界坐标轴 public static Vector3 Axis(int axis)
{ // 获取axis轴向量
return worldAxis[axis];
} public static Vector3 NextAxis(int axis)
{ // 获取axis的下一个轴向量
return worldAxis[(axis + 1) % 3];
} public static Vector3 Axis(Transform trans, int axis)
{ // 获取trans的axis轴向量
if (axis == 0)
{
return trans.right;
}
else if (axis == 1)
{
return trans.up;
}
return trans.forward;
} public static Vector3 NextAxis(Transform trans, int axis)
{ // 获取trans的axis下一个轴向量
return Axis(trans, (axis + 1) % 3);
} public static Vector3 FaceAxis(int face)
{ // 获取face面对应的轴向量
Vector3 vec = worldAxis[face / 2];
if (face % 2 == 0)
{
vec = -vec;
}
return vec;
} public static Vector3 GetXAxis()
{ // 获取与相机right轴夹角最小的世界坐标轴
return GetXAxis(Camera.main.transform.right);
} public static Vector3 GetYAxis()
{ // 获取与相机up轴夹角最小的世界坐标轴
return GetYAxis(Camera.main.transform.up);
} public static Vector3 GetZAxis()
{ // 获取与相机forward轴夹角最小的世界坐标轴
return GetZAxis(Camera.main.transform.forward);
} public static Vector3 GetXAxis(Vector3 right)
{ // 获取与right向量夹角最小的世界坐标轴
int x = GetZAxisIndex(right);
Vector3 xAxis = worldAxis[x];
if (Vector3.Dot(worldAxis[x], right) < 0)
{
xAxis = -xAxis;
}
return xAxis;
} public static Vector3 GetYAxis(Vector3 up)
{ // 获取与up向量轴夹角最小的世界坐标轴
int y = GetZAxisIndex(up);
Vector3 yAxis = worldAxis[y];
if (Vector3.Dot(worldAxis[y], up) < 0)
{
yAxis = -yAxis;
}
return yAxis;
} public static Vector3 GetZAxis(Vector3 forward)
{ // 获取与forward向量夹角最小的世界坐标轴
int z = GetZAxisIndex(forward);
Vector3 zAxis = worldAxis[z];
if (Vector3.Dot(worldAxis[z], forward) < 0)
{
zAxis = -zAxis;
}
return zAxis;
} public static int GetAxis(int flag)
{ // 根据flag值, 获取与相机坐标轴较近的轴
if (flag == 0)
{
return GetXAxisIndex(Camera.main.transform.right);
}
if (flag == 1)
{
return GetXAxisIndex(Camera.main.transform.up);
}
if (flag == 2)
{
return GetXAxisIndex(Camera.main.transform.forward);
}
return -1;
} private static int GetXAxisIndex(Vector3 right)
{ // 获取与right向量夹角最小的世界坐标轴索引
float[] dot = new float[3];
for (int i = 0; i < 3; i++)
{ // 计算世界坐标系的坐标轴在相机right轴上的投影
dot[i] = Mathf.Abs(Vector3.Dot(worldAxis[i], right));
}
int x = 0;
if (dot[x] < dot[1])
{
x = 1;
}
if (dot[x] < dot[2])
{
x = 2;
}
return x;
} private static int GetYAxisIndex(Vector3 up)
{ // 获取与up向量轴夹角最小的世界坐标轴索引
float[] dot = new float[3];
for (int i = 0; i < 3; i++)
{ // 计算世界坐标系的坐标轴在相机up轴上的投影
dot[i] = Mathf.Abs(Vector3.Dot(worldAxis[i], up));
}
int y = 1;
if (dot[y] < dot[2])
{
y = 2;
}
if (dot[y] < dot[0])
{
y = 0;
}
return y;
} private static int GetZAxisIndex(Vector3 forward)
{ // 获取与forward向量夹角最小的世界坐标轴索引
float[] dot = new float[3];
for (int i = 0; i < 3; i++)
{ // 计算世界坐标系的坐标轴在相机forward轴上的投影
dot[i] = Mathf.Abs(Vector3.Dot(worldAxis[i], forward));
}
int z = 2;
if (dot[z] < dot[0])
{
z = 0;
}
if (dot[z] < dot[1])
{
z = 1;
}
return z;
}
}

3.5 选择朝上的面原理

​ 首先生成 24 个视觉方向(6 个面,每个面 4 个视觉方向),如下(不同颜色的线条代表该颜色的面对应的 4 个视觉方向),记录相机在这些视觉方向下的 forward 和 right 向量,分别记为:forwardViews、rightViews(数据类型:Vector3[6][4])。

​ 当选择 face 面朝上时,需要在 forwardViews[face] 的 4 个向量中寻找与相机的 forward 夹角最小的向量,记该向量的索引为 index,旋转相机,使其 forward 和 right 分别指向 forwardViews[face][index]、rightViews[face][index]。

3.6 旋转层检测原理

​ 1)旋转轴检测

​ 假设屏幕射线检测到的两个相邻方块分别为 square1、square2。

  • 如果 square1 与 square2 在同一个小立方体里,square1.forward 与 square2.forward 叉乘的向量就是旋转轴方向向量;
  • 如果 square1 与 square2 在相邻小立方体里,square1.forward 与 (square2.position - square1.position) 叉乘的向量就是旋转轴方向向量;

​ 假设叉乘后的向量的单位向量为 crossDir,我们将 crossDir 与 3 个坐标轴的单位方向向量进行点乘(记为 project),如果 Abs(project) > 0.99(夹角小于 8°),就选取该轴作为旋转轴,如果每个轴的点乘绝对值结果都小于 0.99,说明屏幕射线拾取的两个方块不在同一旋转层,舍弃局部旋转。补充:project 在 3)中会再次用到。

​ 2)层序检测

​ 坐标分量与层序的映射关系如下,其中 order 为魔方阶数,seq 为层序,pos 为坐标分量,cubeSide 为小立方体的边长。由于频繁使用到 pos 与 seq 的映射,建议将 0 ~ (order-1) 层的层序 seq 对应的 pos 存储在数组中,方便快速查找。

​ square1 与 square2 在旋转轴方向上的坐标分量一致,假设为 pos(如果旋转轴是 axis,pos = square1.position[axis]),由上述公式就可以推导出层序 seq。

3)拖拽正方向

​ 拖拽正方向用于确定局部旋转的方向,计算如下,project 是 1)中计算的点乘值。

​ SquareUtils.cs

private static Vector2 GetDragDire(Transform square1, Transform square2, int project)
{ // 获取局部旋转拖拽正方向的单位方向向量
Vector2 scrPos1 = Camera.main.WorldToScreenPoint(square1.position);
Vector2 scrPos2 = Camera.main.WorldToScreenPoint(square2.position);
Vector2 dire = (scrPos2 - scrPos1).normalized;
return -dire * Mathf.Sign(project);
}

3.7 局部旋转原理

1)待旋转的小立方体检测

​ 对于每个小立方体,使用数组 loc[] 存储了小立方体在 x、y、z 轴方向上的层序,每次旋转结束后,根据小立方体的中心坐标可以重写计算出 loc 数组(3.6 节中公式)。

​ 假设检测到的旋转轴为 axis,旋转层为 seq,所有 loc[axis] 等于 seq 的小立方体都是需要旋转的小立方体。

2)局部旋转

​ 在 Rubik 对象下创建一个空对象,重命名为 RotateLayer,将 RotateLayer 移至坐标原点,旋转角度全部置 0。

​ 将处于旋转层的小立方体的 parent 都设置为 RotateLayer,对 RotateLayer 进行旋转,旋转结束后,将这些小立方体的 parent 重置为 Rubik,RotateLayer 的旋转角度重置为 0,根据小立方体中心的 position 更新 loc 数组。

3.8 还原检测原理

​ 对于魔方的每个面,通过屏幕射线射向每个 Square 的中心,获取检测到的 Square 的 name,如果存在两个 Square 的 name 不一样,则魔方未还原,否则继续检测下一个面,如果每个面都还原了,则魔方已还原。

​ SuccessDetector.cs

public void Detect()
{ // 检测魔方是否已还原
for (int i = 0; i < squareRays.squareRays.Length - 1; i++)
{ // 检测每个面(只需检查5个面)
string name = GetSquareName(i, 0);
for (int j = 1; j < squareRays.squareRays[i].Length; j++)
{ // 检测每个方块
if (!name.Equals(GetSquareName(i, j)))
{
return;
}
}
}
Success();
} private string GetSquareName(int face, int index)
{ // 获取方块名
if (Physics.Raycast(squareRays.squareRays[face][index], out hitInfo))
{
return hitInfo.transform.name;
}
return "-1";
}

​ 说明:squareRays 里存储了每个方块对应的射线,这些射线由方块的外部垂直指向方块中心。

4 运行效果

1)2 ~ 10 阶魔方渲染效果

2)魔方打乱动画

​ 说明:在打乱的过程中可以缩放和整体旋转,体现了局部控制和整体控制相互独立,互不干扰。

3)按钮翻面动画

4)Ctrl + Drag 翻面动画

5)选择朝上的面动画

6)局部旋转动画

7)公式控制局部旋转动画

​ 说明:在公式执行过程中,不影响魔方的整体旋转和缩放。

8)通关动画

​ 声明:本文转自【Unity3D】魔方

【Unity3D】魔方的更多相关文章

  1. Unity3D手游开发实践

    <腾讯桌球:客户端总结> 本次分享总结,起源于腾讯桌球项目,但是不仅仅限于项目本身.虽然基于Unity3D,很多东西同样适用于Cocos.本文从以下10大点进行阐述: 架构设计 原生插件/ ...

  2. DirectX11--实现一个3D魔方(1)

    前言 可以说,魔方跟我的人生也有一定的联系. 在高中的学校接触到了魔方社,那时候的我虽然也能够还原魔方,可看到大神们总是可以非常快地还原,为此我也走上了学习高级公式CFOP的坑.当初学习的网站是在魔方 ...

  3. (转)Unity3D手游开发实践

    作者:吴秦出处:http://www.cnblogs.com/skynet/本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名吴秦(包含链接). (转)& ...

  4. Unity3d学习 预设体(prefab)的一些理解

    之前一直在想如果要在Unity3d上创建很多个具有相同结构的对象,是如何做的,后来查了相关资料发现预设体可以解决这个问题! 预设体的概念: 组件的集合体 , 预制物体可以实例化成游戏对象. 创建预设体 ...

  5. Unity3d入门 - 关于unity工具的熟悉

    上周由于工作内容较多,花在unity上学习的时间不多,但总归还是学习了一些东西,内容如下: .1 根据相关的教程在mac上安装了unity. .2 学习了unity的主要的工具分布和对应工具的相关的功 ...

  6. TDD在Unity3D游戏项目开发中的实践

    0x00 前言 关于TDD测试驱动开发的文章已经有很多了,但是在游戏开发尤其是使用Unity3D开发游戏时,却听不到特别多关于TDD的声音.那么本文就来简单聊一聊TDD如何在U3D项目中使用以及如何使 ...

  7. warensoft unity3d 更新说明

    warensoft unity3d 组件的Alpha版本已经发布了将近一年,很多网友发送了改进的Email,感谢大家的支持. Warensoft Unity3D组件将继续更新,将改进的功能如下: 1. ...

  8. Unity3D框架插件uFrame实践记录(一)

    1.概览 uFrame是提供给Unity3D开发者使用的一个框架插件,它本身模仿了MVVM这种架构模式(事实上并不包含Model部分,且多出了Controller部分).因为用于Unity3D,所以它 ...

  9. Unity3D 5.3 新版AssetBundle使用方案及策略

    1.概览 Unity3D 5.0版本之后的AssetBundle机制和之前的4.x版本已经发生了很大的变化,一些曾经常用的流程已经不再使用,甚至一些老的API已经被新的API所取代. 因此,本文的主要 ...

  10. 山寨Unity3D?搜狐畅游的免费开源游戏引擎Genesis-3D

    在CSDN上看到了<搜狐畅游发布3D游戏引擎Genesis-3D 基于MIT协议开源>(http://www.csdn.net/article/2013-11-21/2817585-cha ...

随机推荐

  1. Unity3D中的Attribute详解(三)

    上一篇我们对系统的Attributes进行了MSIL代码的查看,了解到了其本质就是一个类的构造函数.本章我们将编写自己的Attributes. 首先我们定义书的属性代码,如下: [AttributeU ...

  2. PyCharm解决Git冲突

    技术背景 在前面的一篇博客中,我们介绍了Fork到自己名下的本地仓库如何与远程原始仓库创建链接的方法.在这篇文章中,我们将要讲解如何应对在这种异步开发的过程中经常有可能会遇到的Git冲突问题,在Pyc ...

  3. 镜像搬运工 skopeo

    镜像搬运工 skopeo 介绍 skopeo 是一个命令行工具,可对容器镜像和容器存储进行操作. 在没有dockerd的环境下,使用 skopeo 操作镜像是非常方便的. 安装 # 安装 skopeo ...

  4. 基于SpringBoot实现单元测试的多种情境/方法(二)

    本文分享自天翼云开发者社区@<基于SpringBoot实现单元测试的多种情境/方法(二)>,  作者:才开始学技术的小白 1 Mock基础回顾 在上一篇分享中我们详细介绍了简单的.用moc ...

  5. 五月二号java基础知识

    1.使用Runnable接口可以轻松实现多个线程共享相同数据,只要用用一个可运行对象作为参数创建多个线程就可以了2.当一个线程对共享的数据进行操作时,应使之成为一个"原子操作"即在 ...

  6. Sqlmap注入dvwa平台low级别

    工具介绍:sqlmap是一款开源的软件 SQL注入攻击是黑客对数据库进行攻击的常用手段之一.随着B/S模式应用开发的发展,使用这种模式编写应用程序的程序员也越来越多.但是由于程序员的水平及经验也参差不 ...

  7. Java构建树结构的公共方法

    一.前提 pId需要传入用来确认第一级的父节点,而且pId可以为null. 树实体类必须实现:TreeNode接口 MyTreeVo必须有这三个属性:id.pId.children 可以根据不同需求, ...

  8. Gateway同时使用断言跟过滤器查询数据库报了这个错误怎么解决?

    DynamicServerListLoadBalancer for client shop-product-sentinel initialized: DynamicServerListLoadBal ...

  9. class(类)和构造函数(原型对象)

    构造函数和class的关系,还有面向对象和原型对象,其实很多人都会很困惑这些概念,这是第二次总结这些概念了,之前一次,没有class类,其实了解了构造函数,class也就很容易理解了 一. 构造函数和 ...

  10. HTML中link标签的那些属性

    在HTML中, link 标签是一个自闭合元素,通常位于文档的 head 部分.它用于建立与外部资源的关联,如样式表.图标等. link 标签具有多个属性,其中 rel 和 href 是最常用的. r ...