【當前熱聞】【Unity3D】魔方

    來源: 博客園2023-06-18 20:15:50
      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 相關技術棧MonoBehaviour的生命周期Transform組件人機交互Input場景切換、全屏/恢復切換、退出游戲、截屏燈光組件Light碰撞體組件Collider發射(Raycast)物理射線(Ray)相機縮放、平移、旋轉場景UGUI概述UGUI之TextUGUI之Image和RawImageUGUI之ButtonUGUI之InputFieldUGUI回調函數UGUI之布局組件協同程序空間和變換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().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】魔方

    關鍵詞:

    責任編輯:sdnew003

    相關新聞

    版權與免責聲明:

    1 本網注明“來源:×××”(非商業周刊網)的作品,均轉載自其它媒體,轉載目的在于傳遞更多信息,并不代表本網贊同其觀點和對其真實性負責,本網不承擔此類稿件侵權行為的連帶責任。

    2 在本網的新聞頁面或BBS上進行跟帖或發表言論者,文責自負。

    3 相關信息并未經過本網站證實,不對您構成任何投資建議,據此操作,風險自擔。

    4 如涉及作品內容、版權等其它問題,請在30日內同本網聯系。