• 【Unity3D】魔方


    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 提示(主要是公式输入合法性校验);

    ​ 选择阶数界面如下:

    img

    ​ 魔方界面如下:

    img

    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 的位置使其拼成魔方结构,根据立方体和方块位置,为每个方块设置纹理图片,如下:

    img

    ​ 说明:对于任意小方块 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().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 的取值满足以下关系:

    img

    ​ 相关代码如下:

    ​ 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])。

    img

    ​ 当选择 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 存储在数组中,方便快速查找。

    img

    ​ 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 阶魔方渲染效果

    img

    2)魔方打乱动画

    img

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

    3)按钮翻面动画

    img

    4)Ctrl + Drag 翻面动画

    img

    5)选择朝上的面动画

    img

    6)局部旋转动画

    img

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

    img

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

    8)通关动画

    img

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

  • 相关阅读:
    关于打代码的一些些心得
    基于自监督学习的多模态推荐算法
    Linux系统之进程控制
    双网卡网络设置:有线网卡优先级高于无线网卡
    分离变数法
    CSS定位
    雪花算法(Snowflake Algorithm)
    16、XDC引脚约束
    Image Sensor卷帘曝光(Rolling Shutter)基本原理
    优思学院|六西格玛改进阶段中选择解决方案的原则和技巧
  • 原文地址:https://www.cnblogs.com/zhyan8/p/17489695.html