DirectX11--實現一個3D魔方(2)


前言

上一章我們主要講述了魔方的構造和初始化、紋理的准備工作。目前我還沒有打算講Direct3D 11關於底層繪圖的實現,因此接下來這一章的重點是魔方的旋轉。因為我們要的是能玩的魔方游戲,而不是一個觀賞品。所以對旋轉這一步的處理就顯得尤其重要,甚至可以展開很大的篇幅來講述。現在光是為了實現旋轉的這個動畫就弄了我大概500行代碼。

這個旋轉包含了單層旋轉、雙層旋轉、整個魔方旋轉以及魔方的自動旋轉動畫。

章節
實現一個3D魔方(1)
實現一個3D魔方(2)
實現一個3D魔方(3)

Github項目--魔方

日常安利一波本人正在編寫的DX11教程。

DirectX11 With Windows SDK完整目錄

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。

一個立方體繞魔方的旋轉

回顧一下立方體結構體Cube的定義:

struct Cube
{
	// 獲取當前立方體的世界矩陣
	DirectX::XMMATRIX GetWorldMatrix() const;

	RubikFaceColor faceColors[6];	// 六個面的顏色,索引0-5分別對應+X, -X, +Y, -Y, +Z, -Z面
	DirectX::XMFLOAT3 pos;			// 旋轉結束后中心所處位置
	DirectX::XMFLOAT3 rotation;		// 僅允許存在單軸旋轉,記錄當前分別繞x軸, y軸, z軸旋轉的弧度

};

這里可以通過修改rotaion分量的值來指定魔方繞中心點以什么軸旋轉,比如說rotation.x = XM_PIDIV2是指當前立方體需要繞中心點以X軸按順時針旋轉90度(從坐標軸正方向朝中心點看)。

之前提到魔方的正中心位於世界坐標系的原點,這樣方便我們進行旋轉操作以節省不必要的平移。現在我們只討論魔方的其中一個立方體的旋轉情況,它需要繞Z軸順時針旋轉θ度。

這整個過程可以拆分成旋轉和平移。其中立方體的旋轉可以理解為移到中心按順時針旋轉θ度,然后再平移到目標位置。

變換過程可以用下面的公式表示,其中p為旋轉前立方體的中心位置(即成員pos),p' 為旋轉后立方體的中心位置,Rz(θ) 為繞z軸順時針旋轉θ度(即成員rotation.z),Tp'則是平移矩陣,vv'分別為變換前后的立方體頂點:

\[\mathbf{p'} = \mathbf{p} \times \mathbf{R_{z}(θ)} \]

\[\mathbf{v'} = \mathbf{v} \times \mathbf{R_{z}(θ)} \times \mathbf{T_{p'}} \]

現在我們來考慮這樣一個場景,假如rotation允許其x,y,z值任意,當這個魔方處於已經被完全打亂的狀態時,這個魔方的物理(內存索引)位置和邏輯(游戲中)的位置僅能憑借posrotation聯系起來。那么,我現在要順時針轉動現在這個魔方的右面,我怎么知道這9個邏輯上的立方體原來所處的物理位置在哪里?顯然要找到它們對應所處的索引是困難的,這么做還不如保證魔方的物理位置和邏輯位置是一致的,這樣才能方便我直接根據索引來指定哪些立方體需要旋轉。

此外,在實際游玩魔方的時候始終只會對其中一層或整個魔方進行旋轉,不可能會同時出現諸如正面順時針和頂面順時針旋轉的情況,即所有的立方體在同一時間段絕不可能會出現類似rotation.yrotation.z都是非0的情況。因此最終Cube::GetWorldMatrix的代碼可以表示成:

DirectX::XMMATRIX Cube::GetWorldMatrix() const
{
	XMVECTOR posVec = XMLoadFloat3(&pos);
	// rotation必然最多只有一個分量是非0,保證其只會繞其中一個軸進行旋轉
	XMMATRIX R = XMMatrixRotationRollPitchYaw(rotation.x, rotation.y, rotation.z);
	posVec = XMVector3TransformCoord(posVec, R);
	// 立方體轉動后最終的位置
	XMFLOAT3 finalPos;
	XMStoreFloat3(&finalPos, posVec);

	return XMMatrixRotationRollPitchYaw(rotation.x, rotation.y, rotation.z) *
		XMMatrixTranslation(finalPos.x, finalPos.y, finalPos.z);
}

XMMatrixRotationRollPitchYaw函數是先按Z軸順時針旋轉,再按X軸順時針旋轉,最后按Y軸順時針旋轉。它實際上只會根據rotation來按其中一個軸旋轉。

現在我們嘗試給魔方的頂面繞Y軸順時針旋轉,在Rubik::Update方法內部用下述代碼嘗試一下

void Rubik::Update(float dt)
{
	for (int i = 0; i < 3; ++i)
		for (int k = 0; k < 3; ++k)
			mCubes[i][2][k].rotation.y += XM_PI * dt;
}

然后在GameApp::UpdateScene調用Rubik::Update

void GameApp::UpdateScene(float dt)
{
	mRubik.Update(dt);
}

你看,它轉起來啦!

魔方的旋轉保護

之前的旋轉都是基於rotation最多只能有一個分量是非0的理想情況,但是如果上面的旋轉不做防護的話,難免會導致用戶在操作魔方的時候出現異常。現在Rubik類的變動如下:

class Rubik
{
public:
	template<class T>
	using ComPtr = Microsoft::WRL::ComPtr<T>;

	Rubik();

	// 初始化資源
	void InitResources(ComPtr<ID3D11Device> device, ComPtr<ID3D11DeviceContext> deviceContext);
	// 立即復原魔方
	void Reset();
	// 更新魔方狀態
	void Update(float dt);
	// 繪制魔方
	void Draw(ComPtr<ID3D11DeviceContext> deviceContext, BasicEffect& effect);
	// 當前是否在進行動畫中
	bool IsLocked() const;


	// pos的取值為0-2時,繞X軸旋轉魔方指定層 
	// pos的取值為-1時,繞X軸旋轉魔方pos為0和1的兩層
	// pos的取值為-2時,繞X軸旋轉魔方pos為1和2的兩層
	// pos的取值為3時,繞X軸旋轉整個魔方
	void RotateX(int pos, float dTheta, bool isPressed = false);

	// pos的取值為3時,繞Y軸旋轉魔方指定層 
	// pos的取值為-1時,繞Y軸旋轉魔方pos為0和1的兩層
	// pos的取值為-2時,繞Y軸旋轉魔方pos為1和2的兩層
	// pos的取值為3時,繞Y軸旋轉整個魔方
	void RotateY(int pos, float dTheta, bool isPressed = false);

	// pos的取值為0-2時,繞Z軸旋轉魔方指定層 
	// pos的取值為-1時,繞Z軸旋轉魔方pos為0和1的兩層
	// pos的取值為-2時,繞Z軸旋轉魔方pos為1和2的兩層
	// pos的取值為3時,繞Z軸旋轉整個魔方
	void RotateZ(int pos, float dTheta, bool isPressed = false);
	
	
	

	// 設置旋轉速度(rad/s)
	void SetRotationSpeed(float rad);

	// 獲取紋理數組
	ComPtr<ID3D11ShaderResourceView> GetTexArray() const;

private:
	// 繞X軸的預旋轉
	void PreRotateX(bool isKeyOp);
	// 繞Y軸的預旋轉
	void PreRotateY(bool isKeyOp);
	// 繞Z軸的預旋轉
	void PreRotateZ(bool isKeyOp);

	// 獲取需要與當前索引的值進行交換的索引,用於模擬旋轉
	// outArr1 { [X1][Y1] [X2][Y2] ... }
	//              ||       ||
	// outArr2 { [X1][Y1] [X2][Y2] ... }
	void GetSwapIndexArray(int times, std::vector<DirectX::XMINT2>& outArr1, 
		std::vector<DirectX::XMINT2>& outArr2) const;

	// 獲取繞X軸旋轉的情況下需要與目標索引塊交換的面,用於模擬旋轉
	// cube[][Y][Z].face1 <--> cube[][Y][Z].face2
	RubikFace GetTargetSwapFaceRotationX(RubikFace face, int times) const;
	// 獲取繞Y軸旋轉的情況下需要與目標索引塊交換的面,用於模擬旋轉
	// cube[X][][Z].face1 <--> cube[X][][Z].face2
	RubikFace GetTargetSwapFaceRotationY(RubikFace face, int times) const;
	// 獲取繞Z軸旋轉的情況下需要與目標索引塊交換的面,用於模擬旋轉
	// cube[X][Y][].face1 <--> cube[X][Y][].face2
	RubikFace GetTargetSwapFaceRotationZ(RubikFace face, int times) const;

private:
	// 魔方 [X][Y][Z]
	Cube mCubes[3][3][3];

	// 當前是否鼠標正在拖動
	bool mIsPressed;
	// 當前是否有動畫在播放
	bool mIsLocked;
	// 當前自動旋轉的速度
	float mRotationSpeed;

	// 頂點緩沖區,包含6個面的24個頂點
	// 索引0-3對應+X面
	// 索引4-7對應-X面
	// 索引8-11對應+Y面
	// 索引12-15對應-Y面
	// 索引16-19對應+Z面
	// 索引20-23對應-Z面
	ComPtr<ID3D11Buffer> mVertexBuffer;	

	// 索引緩沖區,僅6個索引
	ComPtr<ID3D11Buffer> mIndexBuffer;
	
	// 紋理數組,包含7張紋理
	ComPtr<ID3D11ShaderResourceView> mTexArray;
};

其中mIsPressedmIsLocked兩個成員用於保護控制。考慮到魔方項目需要同時支持鍵盤和鼠標的操作,但是鍵盤和鼠標的操作特性是不一樣的,鍵盤是按鍵后就會響應旋轉動畫,而鼠標則是在拖動的時候就在旋轉魔方,並且放開后魔方還要歸位。

下面是關於旋轉保護的狀態圖:

mIsLockedtrue時,此時將會拒絕鍵盤或鼠標的響應,也就是說這個時候的旋轉函數應該是不進行任何的操作。

比如說現在我們魔方旋轉的方法是這樣的:

// pos的取值為0-2時,繞X軸旋轉魔方指定層 
// pos的取值為-1時,繞X軸旋轉魔方pos為0和1的兩層
// pos的取值為-2時,繞X軸旋轉魔方pos為1和2的兩層
// pos的取值為3時,繞X軸旋轉整個魔方
void RotateX(int pos, float dTheta, bool isPressed = false);

其中isPressedtrue的時候會告訴魔方現在正在用鼠標拖動,反之則為鍵盤操作或者鼠標完成了拖動。

這里還有一個潛藏的問題要解決。當mIsLockedfalse的時候,可能這時鼠標正在拖動魔方,然后突然來了個鍵盤的響應,這時候導致的結果就很嚴重了。要想讓鍵盤和鼠標的操作互斥,就必須嚴格按照狀態圖的流程來執行。(寫到這里含淚修改自己的代碼)

由於鍵盤按下后會導致在這一幀產生一個90度的瞬時響應,而讓鼠標在一幀內拖動出90度是幾乎不可能的,我們可以把它用作判斷此時執行的是鍵盤操作。如果mIsPressedtrue,說明現在同時發生了鍵盤和鼠標的操作,需要把來自鍵盤的操作給拒絕掉。

此外我們可以推廣到180度, 270度等情況。雖然說鍵盤只能產生90度旋轉,但是如果我們要用棧來記錄玩家的操作的話,鼠標拖動產生的180度旋轉如果也能被標記為所謂的鍵盤輸入,這樣就可以一個調用讓魔方自動產生180度的旋轉了。

現在排除所有旋轉相關的實現,加上保護后的代碼如下:

void Rubik::RotateX(int pos, float dTheta, bool isPressed)
{
	if (!mIsLocked)
	{
		// 檢驗當前是否為鍵盤操作
		// 可以認為僅當鍵盤操作時才會產生絕對值為pi/2的倍數(不包括0)的瞬時值
		bool isKeyOp =  static_cast<int>(round(dTheta / XM_PIDIV2)) != 0 &&
			(fabs(fmod(dTheta, XM_PIDIV2) < 1e-5f));
		// 鍵盤輸入和鼠標操作互斥,拒絕鍵盤的操作
		if (mIsPressed && isKeyOp)
		{
			return;
		}

		mIsPressed = isPressed;

		// ...

		// 鼠標或鍵盤操作完成
		if (!isPressed)
		{
			
			// 開始動畫演示狀態
			mIsLocked = true;
			
			// ...
		}
	}
}

魔方的旋轉動畫

旋轉動畫可以說是本篇文章的核心部分了。可以說這個旋轉本身包含了很多的tricks,不是給rotation加個值這么簡單的事情,還需要考慮鍵鼠操作的可連續性。

首先,鍵盤操作的話必然只會順(逆)時針旋轉90度,並且只會產生一次有效的Rotation操作。

鼠標操作的隨意性比鍵盤會大的多,在釋放的時候旋轉的角度都可能會是任意的,它會產生連續的Rotation操作,在拖動的時候傳遞mIsPressed = true,僅在最后釋放的時候傳遞mIsPressed = false

現在讓我們給Rubik::RotateX加上初步的更新操作:

void Rubik::RotateX(int pos, float dTheta, bool isPressed)
{
	if (!mIsLocked)
	{
		// 檢驗當前是否為鍵盤操作
		// 可以認為僅當鍵盤操作時才會產生絕對值為pi/2的倍數(不包括0)的瞬時值
		bool isKeyOp =  static_cast<int>(round(dTheta / XM_PIDIV2)) != 0 &&
			(fabs(fmod(dTheta, XM_PIDIV2) < 1e-5f));
		// 鍵盤輸入和鼠標操作互斥,拒絕鍵盤的操作
		if (mIsPressed && isKeyOp)
		{
			return;
		}

		mIsPressed = isPressed;

		// 更新旋轉狀態
		for (int j = 0; j < 3; ++j)
			for (int k = 0; k < 3; ++k)
			{
				switch (pos)
				{
				case 3: mCubes[0][j][k].rotation.x += dTheta;
				case -2: mCubes[1][j][k].rotation.x += dTheta;
					mCubes[2][j][k].rotation.x += dTheta;
					break;
				case -1: mCubes[0][j][k].rotation.x += dTheta; 
					mCubes[1][j][k].rotation.x += dTheta; 
					break;
				
				default: mCubes[pos][j][k].rotation.x += dTheta;
				}
				
			}

		// 鼠標或鍵盤操作完成
		if (!isPressed)
		{
			
			// 開始動畫演示狀態
			mIsLocked = true;
			
			// 進行預旋轉
			PreRotateX(isKeyOp);
		}
	}
}

然后要討論的就是怎么實現這個自動旋轉的動畫了(即整個PreRotateX函數的實現)。之前提到為了方便后續操作,必須保持魔方的邏輯位置(游戲中的坐標)與物理位置(內存索引)一致,這意味所謂的旋轉是通過將被旋轉立方體的數據全部按規則轉移到目標立方體中。其中旋轉角度對於旋轉中的所有立方體都是一致的,所以理論上我們只需要修改魔方的6個面顏色。

不過在此之前,還需要解決一個鼠標/鍵盤釋放后歸位的問題。

魔方的預旋轉

操作完成后魔方按區間歸位的問題

使用鍵盤操作的話,如果我對頂層順時針旋轉90度,那理論要播放這個動畫的話就是讓魔方的旋轉角度值從0度一路增加到90度。

但是使用鼠標操作的話,如果我拖到順時針30度后釋放(這個操作由於拖動的角度不夠大,最終會歸回到0度),然后這個動畫就是要讓魔方的旋轉角度值從順時針30度變回0度,只有當鼠標拖動到順時針在45度到接近90度的范圍后釋放的時候,旋轉動畫才會一路增加到90度。這里進行一個總結:

釋放時旋轉角度落在[-45°, 45°)時,旋轉動畫結束后會歸位到0度,釋放時旋轉角度落在[45°, 135°)時,旋轉動畫結束后會歸位到90度,以此類推...

從上面的需求我們可以看出一些需要解決的問題,一是終止條件不唯一,不利於我們做判斷;二是魔方在旋轉完成后可能會出現有的立方體rotation存在分量非0的情況,然后違背了魔方的邏輯位置(游戲中的坐標)與物理位置(內存索引)一致的要求,對后續操作產生影響。

因此,這里有兩個tricks:

  1. 把所有的終止條件都變為歸位到0度,這樣意味着只要rotation存在分量的值大於0,就需要讓它逐漸減小到0;rotation存在分量的值小於0,就需要讓它逐漸增加到0.
  2. 我們可以在鍵盤按下,或者鼠標釋放后動畫即將開始的瞬間,立即對換所有准備旋轉的立方體的表面,進行預旋轉。這樣正在執行的動畫就只涉及普通的旋轉操作了。

舉個例子,我鼠標拖動某一層到順時針60度的位置釋放,這時候我可以讓這一層的貼圖先進行一次90度順時針旋轉,然后把rotation的值減90度,來到-30度,然后一路加回0度。這樣就相當於從60度過渡到90度了。

同理,我鼠標拖動某一層到逆時針160度的位置(超過135度)釋放,這時候我可以讓這一層的貼圖先進行一次180度逆時針旋轉,然后把rotation的值加180度,來到20度,然后一路減回0度。這樣就相當於從-160度過渡到-180度了。

而對於鍵盤操作的處理稍微有點特別,按下順時針旋轉的按鍵后會產生一個90度的變化值,這時候我可以讓這一層的貼圖先進行一次90度順時針旋轉,然后把rotation的值取反變成-90度,然后一路加回0度。這樣就相當於從0度過渡到90度了。

一個小小的旋轉,里面竟藏着這么大的玄機!

緊接着就是要進行代碼分析了,我們需要先計算出當前開始旋轉的角度需要預先進行幾次90度的順時針旋轉(可能為負)。再看看這個映射關系:

區間 次數
... ...
(-135°, 45°] -1
(-45°, 45°) 0
[45°, 135°) 1
... ...

我們可以推導出:

\[times = round(\frac{2θ}{\pi}) \]

然后每4次90度順時針旋轉為一個循環,並且1次90度逆時針旋轉等價於3次90度順時針旋轉。首先我們進行一次模4運算,這樣結果就映射到區間[-3, 3]內,為了把times再映射到范圍[0, 4),可以對結果加4,再進行一次模4運算。

這兩部分代碼可以寫成:

// 由於此時被旋轉面的所有方塊旋轉角度都是一樣的,可以從中取一個來計算。
// 計算歸位回[-pi/4, pi/4)區間需要順時針旋轉90度的次數
int times = static_cast<int>(round(mCubes[pos][0][0].rotation.x / XM_PIDIV2));
// 將歸位次數映射到[0, 3],以計算最小所需順時針旋轉90度的次數
int minTimes = (times % 4 + 4) % 4;

然后如果是鼠標操作的話,我們可以利用times做區間歸位:

// 歸位回[-pi/4, pi/4)的區間
mCubes[pos][j][k].rotation.x -= times * XM_PIDIV2;

如果是鍵盤操作的話,則可以直接做值反轉:

// 順時針旋轉90度--->實際演算從-90度加到0度
// 逆時針旋轉90度--->實際演算從90度減到0度
mCubes[pos][j][k].rotation.x *= -1.0f;

現在我們將整個預旋轉的操作放到了Rubic::PreRotateX方法中,部分代碼如下(未包含面的對換):

void Rubik::PreRotateX(bool isKeyOp)
{
	for (int i = 0; i < 3; ++i)
	{
		// 當前層沒有旋轉則直接跳過
		if (fabs(mCubes[i][0][0].rotation.x) < 10e-5f)
			continue;
		// 由於此時被旋轉面的所有方塊旋轉角度都是一樣的,可以從中取一個來計算。
		// 計算歸位回[-pi/4, pi/4)區間需要順時針旋轉90度的次數
		int times = static_cast<int>(round(mCubes[i][0][0].rotation.x / XM_PIDIV2));
		// 將歸位次數映射到[0, 3],以計算最小所需順時針旋轉90度的次數
		int minTimes = (times % 4 + 4) % 4;

		// 調整所有被旋轉方塊的初始角度
		for (int j = 0; j < 3; ++j)
		{
			for (int k = 0; k < 3; ++k)
			{
				// 鍵盤按下后的變化
				if (isKeyOp)
				{
					// 順時針旋轉90度--->實際演算從-90度加到0度
					// 逆時針旋轉90度--->實際演算從90度減到0度
					mCubes[i][j][k].rotation.x *= -1.0f;
				}
				// 鼠標釋放后的變化
				else
				{
					// 歸位回[-pi/4, pi/4)的區間
					mCubes[i][j][k].rotation.x -= times * XM_PIDIV2;
				}
			}
		}

		// ...
	}
}

實際的預旋轉操作

有兩種方式可以完成魔方的預旋轉:

  1. 開啟一個3x3的立方體臨時數據,然后從源數據按旋轉規則傳遞給臨時數據,再復制回來。
  2. 通過交換的方式完成就址旋轉。

從實現難度來看明顯是2比1難的多,但是從DX9的魔方項目我都是用第2種方式來解決旋轉問題的。我也還是接着這個思路來繼續談。

現在我依然要面臨兩個難題:

  1. 怎么的交換順序才能產生最終類似旋轉的效果
  2. 交換時兩個立方體的六個面應該按怎樣的規則來交換

交換實現旋轉的原理

之前提到,所有的旋轉最終都可以化為0次到3次順時針旋轉的問題,我們為此要分3種情況來討論。為此我做了一幅圖來說明一切:

可見順時針旋轉90度和270度的情況下需要交換6次,而旋轉180度的情況下只需要交換4次。

所有的交換規則可以用下面的函數來獲取:

void Rubik::GetSwapIndexArray(int minTimes, std::vector<DirectX::XMINT2>& outArr1, std::vector<DirectX::XMINT2>& outArr2) const
{
	// 進行一次順時針90度旋轉相當逆時針交換6次(頂角和棱各3次)
	// 1   2   4   2   4   2   4   1
	//   *   ->  *   ->  *   ->  *
	// 4   3   1   3   3   1   3   2
	if (minTimes == 1)
	{
		outArr1 = { XMINT2(0, 0), XMINT2(0, 1), XMINT2(0, 2), XMINT2(1, 2), XMINT2(2, 2), XMINT2(2, 1) };
		outArr2 = { XMINT2(0, 2), XMINT2(1, 2), XMINT2(2, 2), XMINT2(2, 1), XMINT2(2, 0), XMINT2(1, 0) };
	}
	// 進行一次順時針90度旋轉相當逆時針交換4次(頂角和棱各2次)
	// 1   2   3   2   3   4
	//   *   ->  *   ->  *  
	// 4   3   4   1   2   1
	else if (minTimes == 2)
	{
		outArr1 = { XMINT2(0, 0), XMINT2(0, 1), XMINT2(0, 2), XMINT2(1, 2) };
		outArr2 = { XMINT2(2, 2), XMINT2(2, 1), XMINT2(2, 0), XMINT2(1, 0) };
	}
	// 進行一次順時針90度旋轉相當逆時針交換6次(頂角和棱各3次)
	// 1   2   4   2   4   2   4   1
	//   *   ->  *   ->  *   ->  *
	// 4   3   1   3   3   1   3   2
	else if (minTimes == 3)
	{
		outArr1 = { XMINT2(0, 0), XMINT2(1, 0), XMINT2(2, 0), XMINT2(2, 1), XMINT2(2, 2), XMINT2(1, 2) };
		outArr2 = { XMINT2(2, 0), XMINT2(2, 1), XMINT2(2, 2), XMINT2(1, 2), XMINT2(0, 2), XMINT2(0, 1) };
	}
	// 0次順時針旋轉不變,其余異常數值也不變
	else
	{
		outArr1.clear();
		outArr2.clear();
	}
	
}

交換兩個立方體表面時的規則

這又是一個需要畫圖來理解的問題,通過下圖應該就可以理解一個立方體旋轉前后六個面的變化了:

然后我們可以轉換成下面的代碼:

RubikFace Rubik::GetTargetSwapFaceRotationX(RubikFace face, int times) const
{
	if (face == RubikFace_PosX || face == RubikFace_NegX)
		return face;
	while (times--)
	{
		switch (face)
		{
		case RubikFace_PosY: face = RubikFace_NegZ; break;
		case RubikFace_PosZ: face = RubikFace_PosY; break;
		case RubikFace_NegY: face = RubikFace_PosZ; break;
		case RubikFace_NegZ: face = RubikFace_NegY; break;
		}
	}
	return face;
}

RubikFace Rubik::GetTargetSwapFaceRotationY(RubikFace face, int times) const
{
	if (face == RubikFace_PosY || face == RubikFace_NegY)
		return face;
	while (times--)
	{
		switch (face)
		{
		case RubikFace_PosZ: face = RubikFace_NegX; break;
		case RubikFace_PosX: face = RubikFace_PosZ; break;
		case RubikFace_NegZ: face = RubikFace_PosX; break;
		case RubikFace_NegX: face = RubikFace_NegZ; break;
		}
	}
	return face;
}

RubikFace Rubik::GetTargetSwapFaceRotationZ(RubikFace face, int times) const
{
	if (face == RubikFace_PosZ || face == RubikFace_NegZ)
		return face;
	while (times--)
	{
		switch (face)
		{
		case RubikFace_PosX: face = RubikFace_NegY; break;
		case RubikFace_PosY: face = RubikFace_PosX; break;
		case RubikFace_NegX: face = RubikFace_PosY; break;
		case RubikFace_NegY: face = RubikFace_NegX; break;
		}
	}
	return face;
}

最終完整的預旋轉方法Rubik::PreRotateX實現如下:

void Rubik::PreRotateX(bool isKeyOp)
{
	for (int i = 0; i < 3; ++i)
	{
		// 當前層沒有旋轉則直接跳過
		if (fabs(mCubes[i][0][0].rotation.x) < 10e-5f)
			continue;
		// 由於此時被旋轉面的所有方塊旋轉角度都是一樣的,可以從中取一個來計算。
		// 計算歸位回[-pi/4, pi/4)區間需要順時針旋轉90度的次數
		int times = static_cast<int>(round(mCubes[i][0][0].rotation.x / XM_PIDIV2));
		// 將歸位次數映射到[0, 3],以計算最小所需順時針旋轉90度的次數
		int minTimes = (times % 4 + 4) % 4;

		// 調整所有被旋轉方塊的初始角度
		for (int j = 0; j < 3; ++j)
		{
			for (int k = 0; k < 3; ++k)
			{
				// 鍵盤按下后的變化
				if (isKeyOp)
				{
					// 順時針旋轉90度--->實際演算從-90度加到0度
					// 逆時針旋轉90度--->實際演算從90度減到0度
					mCubes[i][j][k].rotation.x *= -1.0f;
				}
				// 鼠標釋放后的變化
				else
				{
					// 歸位回[-pi/4, pi/4)的區間
					mCubes[i][j][k].rotation.x -= times * XM_PIDIV2;
				}
			}
		}

		std::vector<XMINT2> indices1, indices2;
		GetSwapIndexArray(minTimes, indices1, indices2);
		size_t swapTimes = indices1.size();
		for (size_t idx = 0; idx < swapTimes; ++idx)
		{
			// 對這兩個立方體按規則進行面的交換
			XMINT2 srcIndex = indices1[idx];
			XMINT2 targetIndex = indices2[idx];
			// 若為2次順時針旋轉,則只需4次對角調換
			// 否則,需要6次鄰角(棱)對換
			for (int face = 0; face < 6; ++face)
			{
				std::swap(mCubes[i][srcIndex.x][srcIndex.y].faceColors[face],
					mCubes[i][targetIndex.x][targetIndex.y].faceColors[
						GetTargetSwapFaceRotationX(static_cast<RubikFace>(face), minTimes)]);
			}
		}
	}
}

Rubik::RotateYRubik::RotateZ的實現這里忽略。

然后Rubik::Update完成旋轉動畫的部分

void Rubik::Update(float dt)
{
	if (mIsLocked)
	{
		int finishCount = 0;
		for (int i = 0; i < 3; ++i)
		{
			for (int j = 0; j < 3; ++j)
			{
				for (int k = 0; k < 3; ++k)
				{
					// 令x,y, z軸向旋轉角度逐漸歸0
					// x軸
					float dTheta = (signbit(mCubes[i][j][k].rotation.x) ? -1.0f : 1.0f) * dt * mRotationSpeed;
					if (fabs(mCubes[i][j][k].rotation.x) < fabs(dTheta))
					{
						mCubes[i][j][k].rotation.x = 0.0f;
						finishCount++;
					}
					else
					{
						mCubes[i][j][k].rotation.x -= dTheta;
					}
					// y軸
					dTheta = (signbit(mCubes[i][j][k].rotation.y) ? -1.0f : 1.0f) * dt * mRotationSpeed;
					if (fabs(mCubes[i][j][k].rotation.y) < fabs(dTheta))
					{
						mCubes[i][j][k].rotation.y = 0.0f;
						finishCount++;
					}
					else
					{
						mCubes[i][j][k].rotation.y -= dTheta;
					}
					// z軸
					dTheta = (signbit(mCubes[i][j][k].rotation.z) ? -1.0f : 1.0f) * dt * mRotationSpeed;
					if (fabs(mCubes[i][j][k].rotation.z) < fabs(dTheta))
					{
						mCubes[i][j][k].rotation.z = 0.0f;
						finishCount++;
					}
					else
					{
						mCubes[i][j][k].rotation.z -= dTheta;
					}
				}
			}
		}

		// 所有方塊都結束動畫才能解鎖
		if (finishCount == 81)
			mIsLocked = false;
	}
}

最后GameApp::UpdateScene測試一下效果:

void GameApp::UpdateScene(float dt)
{
	// 反復旋轉
	static float theta = XM_PIDIV2;
	if (!mRubik.IsLocked())
	{
		theta *= -1.0f;
	}
	// 就算擺出來也不會有問題(只有未上鎖的幀才會生效該調用)
	mRubik.RotateY(0, theta);
	// 下面的也不會被調用
	mRubik.RotateX(0, theta);
	mRubik.RotateZ(0, theta);
	// 更新魔方
	mRubik.Update(dt);
}

上面的代碼會反復旋轉底層。

來個鬼畜的動圖:

細思恐極,我居然花了那么大篇幅來將一個魔方的旋轉,寫這部分實現的代碼只是用了半天,然后寫這篇博客差不多一天又過去了。。。這個系列目前還沒有結束,下一章主要講的是鍵鼠操作。

Github項目--魔方

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM