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,以及有什么问题也可以在这里汇报。

posted @ 2019-01-08 20:44  X_Jun  阅读(2481)  评论(8编辑  收藏  举报
levels of contents