(2019/1/9 09:23)上一章咱们主要讲述了魔方的旋转,这个旋转真是有毒啊,搞完这个部分搭键鼠操做不到半天应该就能够搭完了吧...html
(2019/1/9 21:25)啊,真香git
有人发这张图片问我写魔方的目的是否是这个。。。噗github
如今光是键鼠相关的代码也搭了400行左右。。其中键盘相关的调用真的是毫无技术可言,重点实现基本上都被鼠标给耽搁了。ide
回来看一眼发现阅读量竟然比前面两篇都还高了= =话说以前没看过这个教程的。。。或许大家应该先看看前面两章讲了什么内容?函数
本章将魔方应用层的剩余实现补全。动画
章节 |
---|
实现一个3D魔方(1) |
实现一个3D魔方(2) |
实现一个3D魔方(3) |
Github项目--魔方spa
最后平常安利一波本人正在编写的DX11教程。code
DirectX11 With Windows SDK完整目录orm
欢迎加入QQ群: 727623616 能够一块儿探讨DX11,以及有什么问题也能够在这里汇报。htm
键盘操做使用的是DXTK
通过修改的Keyboard
库。
由于以前说过,Rubik::RotateX
函数在响应了来自键盘的输入后,就会进入自动旋转模式,此时的键盘输入将不会响应。但后续还须要考虑作栈操做记录,若是此时魔方正在旋转,仍是要提早结束这个函数:
void GameApp::KeyInput() { Keyboard::State keyState = mKeyboard->GetState(); mKeyboardTracker.Update(keyState); // // 整个魔方旋转 // // 此时正在旋转的话则提早结束 if (mRubik.IsLocked()) return; // 公式x if (mKeyboardTracker.IsKeyPressed(Keyboard::Up)) { mRubik.RotateX(3, XM_PIDIV2); return; } // ... // // 双层旋转 // // 公式r if (keyState.IsKeyDown(Keyboard::LeftControl) && mKeyboardTracker.IsKeyPressed(Keyboard::I)) { mRubik.RotateX(-2, XM_PIDIV2); return; } // ... // // 单层旋转 // // 公式R if (mKeyboardTracker.IsKeyPressed(Keyboard::I)) { mRubik.RotateX(2, XM_PIDIV2); return; } // ... }
我列个表格来描述键盘的36种操做,就当作说明书来看吧:
键位 | 对应公式 | 描述 | 键位 | 对应公式 | 描述 |
---|---|---|---|---|---|
Up | x | 整个魔方按x轴顺时针旋转 | I | R | 右面两层按x轴顺时针旋转 |
Down | x' | 整个魔方按x轴逆时针旋转 | K | R' | 右面两层按x轴逆时针旋转 |
Left | y | 整个魔方按y轴顺时针旋转 | J | U | 顶面两层按y轴顺时针旋转 |
Right | y' | 整个魔方按y轴逆时针旋转 | L | U' | 顶面两层按y轴逆时针旋转 |
Pg Up | z' | 整个魔方按z轴逆时针旋转 | U | F' | 正面两层按z轴逆时针旋转 |
Pg Down | z | 整个魔方按z轴顺时针旋转 | O | F | 正面两层按z轴顺时针旋转 |
-------- | ---- | ------------------------ | -------- | ---- | ------------------------ |
LCtrl+I | r | 右面两层按x轴顺时针旋转 | T | M | 右面两层按x轴顺时针旋转 |
LCtrl+K | r' | 右面两层按x轴逆时针旋转 | G | M' | 右面两层按x轴逆时针旋转 |
LCtrl+J | u | 顶面两层按y轴顺时针旋转 | F | E | 顶面两层按y轴顺时针旋转 |
LCtrl+L | u' | 顶面两层按y轴逆时针旋转 | H | E' | 顶面两层按y轴逆时针旋转 |
LCtrl+U | f' | 正面两层按z轴逆时针旋转 | R | S' | 正面两层按z轴逆时针旋转 |
LCtrl+O | f | 正面两层按z轴顺时针旋转 | Y | S | 正面两层按z轴顺时针旋转 |
-------- | ---- | ------------------------ | -------- | ---- | ------------------------ |
LCtrl+W | l' | 左面两层按x轴逆时针旋转 | W | L' | 右面两层按x轴顺时针旋转 |
LCtrl+S | l | 左面两层按x轴顺时针旋转 | S | L | 右面两层按x轴逆时针旋转 |
LCtrl+A | d' | 底面两层按y轴逆时针旋转 | A | D' | 顶面两层按y轴顺时针旋转 |
LCtrl+D | d | 底面两层按y轴顺时针旋转 | D | D | 顶面两层按y轴逆时针旋转 |
LCtrl+Q | b | 背面两层按z轴顺时针旋转 | Q | B | 正面两层按z轴逆时针旋转 |
LCtrl+E | b' | 背面两层按z轴逆时针旋转 | E | B' | 正面两层按z轴顺时针旋转 |
鼠标操做用的是DXTK
通过修改的Mouse
库
鼠标相关的实现难度远比键盘复杂多了,我主要分三个部分来说:
在此以前,我先讲讲在这个项目加的一点点私货
首先来看效果
这个效果的实现比较简单,如今我使用的是第三人称摄像机。现规定以游戏窗口中心为0偏移点,那么偏离中心作左右移动会产生绕中心以Y轴旋转,而作上下移动产生绕中心以X轴旋转。
相关代码的实现以下:
void GameApp::MouseInput(float dt) { Mouse::State mouseState = mMouse->GetState(); // ... // 获取子类 auto cam3rd = dynamic_cast<ThirdPersonCamera*>(mCamera.get()); // ****************** // 第三人称摄像机的操做 // // 绕物体旋转,添加轻微抖动 cam3rd->SetRotationX(XM_PIDIV2 * 0.6f + (mouseState.y - mClientHeight / 2) * 0.0001f); cam3rd->SetRotationY(-XM_PIDIV4 + (mouseState.x - mClientWidth / 2) * 0.0001f); cam3rd->Approach(-mouseState.scrollWheelValue / 120 * 1.0f); // 更新观察矩阵 mCamera->UpdateViewMatrix(); mBasicEffect.SetViewMatrix(mCamera->GetViewXM()); // 重置滚轮值 mMouse->ResetScrollWheelValue(); // ... }
如今要先判断鼠标点击拾取到哪一个立方体,考虑到咱们能拾取到的立方体都是能够看到的,这也说明它们的深度值确定是最小的。所以,咱们的Rubik::HitCube
函数实现以下:
DirectX::XMINT3 Rubik::HitCube(Ray ray, float * pDist) const { BoundingOrientedBox box(XMFLOAT3(), XMFLOAT3(1.0f, 1.0f, 1.0f), XMFLOAT4(0.0f, 0.0f, 0.0f, 1.0f)); BoundingOrientedBox transformedBox; XMINT3 res = XMINT3(-1, -1, -1); float dist, minDist = FLT_MAX; // 优先拾取暴露在外的立方体(同时也是距离摄像机最近的) for (int i = 0; i < 3; ++i) { for (int j = 0; j < 3; ++j) { for (int k = 0; k < 3; ++k) { box.Transform(transformedBox, mCubes[i][j][k].GetWorldMatrix()); if (ray.Hit(transformedBox, &dist) && dist < minDist) { minDist = dist; res = XMINT3(i, j, k); } } } } if (pDist) *pDist = (minDist == FLT_MAX ? 0.0f : minDist); return res; }
上面的函数会遍历全部的立方体,找出深度最小且拾取到的立方体的索引值,经过pDist
能够返回射线起始点到目标立方体表面的最小距离。这个信息很是有用,稍后咱们会提到。
对了,若是没有拾取到立方体呢?咱们能够利用屏幕空白的地方,在拖动这些地方的时候会带动整个魔方的旋转。
首先给出魔方旋转轴的枚举:
enum RubikRotationAxis { RubikRotationAxis_X, // 绕X轴旋转 RubikRotationAxis_Y, // 绕Y轴旋转 RubikRotationAxis_Z, // 绕Z轴旋转 };
如今让咱们再看一眼魔方:
界面中能够看到魔方的面有+X面,+Y面和-Z面。
在咱们拾取到立方体后,咱们还要根据这两个信息来肯定旋转轴:
这又是一个十分细的问题。其中-X面和-Z面在屏幕上是对称关系,代码实现能够作镜像处理,可是+Y面的操做跟其它两个面又有一些差异。
如今咱们只讨论拾取到立方体索引[2][2][0]
的状况,鼠标落在了该立方体白色的表面上。咱们只是知道鼠标拾取到当前立方体上,那怎么作才能知道它如今拾取的是其中的-Z面呢?
Rubik::HitCube
函数不只返回了拾取到的立方体索引,还有射线击中立方体表面的最短距离。咱们知道-Z面的全部顶点的z值在不产生旋转的状况下都会为-3,所以咱们只须要将获得的 \(t\) 值带入射线方程 \(\mathbf{p}=\mathbf{e}+t\mathbf{d}\) 中,判断求得的 \(\mathbf{p}\) 其中的z份量是否为3,若是是,那说明当前鼠标拾取的是该立方体的-Z面。
接下来就是要讨论用鼠标拖动魔方会产生怎么样的旋转问题了。咱们还须要肯定当前的拖动会让哪一层魔方旋转(或者说绕什么轴旋转)。如下图为例:
上图的X轴和Y轴对应的是屏幕坐标系,坐标轴的原点为我鼠标刚点击时的落点,经过两条虚线,能够将鼠标的拖动方向划分为四个部分,对应魔方旋转的四种状况。其中屏幕坐标系的主+X(-X)拖动方向会使得魔方的+Y面作逆(顺)时针旋转,而屏幕坐标系的主+Y(-Y)拖动方向会使得魔方的+X面作逆(顺)时针旋转。
咱们能够将这些状况进行简单归类,即当X方向的瞬时位移量比Y方向的大时,魔方的+Y面就会绕Y轴进行旋转,反之则是魔方的+X面绕X轴进行旋转。
如今新增了用于记录魔方操做的RubikRotationRecord
类:
struct RubikRotationRecord { RubikRotationAxis axis; // 当前旋转轴 int pos; // 当前旋转层的索引 float dTheta; // 当前旋转的弧度 };
这里先把GameApp
中全部与鼠标操做相关的新增成员先列出来,后面我就再也不重复:
// // 鼠标操做控制 // int mClickPosX, mClickPosY; // 初次点击时鼠标位置 float mSlideDelay; // 拖动延迟响应时间 float mCurrDelay; // 当前延迟时间 bool mDirectionLocked; // 方向锁 RubikRotationRecord mCurrRotationRecord; // 当前旋转记录
核心判断方法以下:
// 判断当前主要是垂直操做仍是水平操做 bool isVertical = abs(dx) < abs(dy); // 当前鼠标操纵的是-Z面,根据操做类型决定旋转轴 if (pos.z == 0 && fabs((ray.origin.z + dist * ray.direction.z) - (-3.0f)) < 1e-5f) { mCurrRotationRecord.pos = isVertical ? pos.x : pos.y; mCurrRotationRecord.axis = isVertical ? RubikRotationAxis_X : RubikRotationAxis_Y; }
pos
为鼠标拾取到的立方体索引。
如今咱们拾取到了索引为[2][2][0]
立方体的+X面,该表面全部顶点的x值在不旋转的状况下为3。当鼠标拖动时的X偏移量比Y的大时,会使得魔方的+Y面绕Y轴作旋转,反之则使得魔方的-X面绕X轴作旋转。
这部分的判断以下:
// 当前鼠标操纵的是+X面,根据操做类型决定旋转轴 if (pos.x == 2 && fabs((ray.origin.x + dist * ray.direction.x) - 3.0f) < 1e-5f) { mCurrRotationRecord.pos = isVertical ? pos.z : pos.y; mCurrRotationRecord.axis = isVertical ? RubikRotationAxis_Z : RubikRotationAxis_Y; }
以前+X面和-Z面在屏幕中是对称的,处理过程基本上差很少。可是处理+Y面的状况又不同了,先看下图:
如今的虚线按垂直和水平方向划分红四个拖动区域。当鼠标在屏幕坐标系拖动时,若是X的瞬时偏移量和Y的符号是一致的(划分虚线的右下区域和左上区域), 魔方的-Z面会绕Z轴旋转;若是异号(划分虚线的左下区域和右上区域),魔方的+X面会绕X轴旋转。
而后就是魔方+Y面的顶点在不产生旋转的状况下y值恒为3,所以这部分的判断逻辑以下:
// 当前鼠标操纵的是+Y面,要判断平移变化量dx和dy的符号来决定旋转方向 if (pos.y == 2 && fabs((ray.origin.y + dist * ray.direction.y) - 3.0f) < 1e-5f) { // 判断异号 bool diffSign = ((dx & 0x80000000) != (dy & 0x80000000)); mCurrRotationRecord.pos = diffSign ? pos.x : pos.z; mCurrRotationRecord.axis = diffSign ? RubikRotationAxis_X : RubikRotationAxis_Z; }
前面咱们一直都是在讨论鼠标拾取到魔方的立方体产生了单层旋转的状况。如今咱们还想让整个魔方进行旋转,能够依靠拖动游戏界面的空白区域来实现,按下图的方式划分红两片区域:
只要在魔方区域外拖动,且水平偏移量比垂直的大,就会产生绕Y轴的旋转。在窗口左(右)半部分产生了主垂直拖动则会绕X(Z)轴旋转。
整个拾取部分的判断以下:
// 找到当前鼠标点击的方块索引 Ray ray = Ray::ScreenToRay(*mCamera, (float)mouseState.x, (float)mouseState.y); float dist; XMINT3 pos = mRubik.HitCube(ray, &dist); // 判断当前主要是垂直操做仍是水平操做 bool isVertical = abs(dx) < abs(dy); // 当前鼠标操纵的是-Z面,根据操做类型决定旋转轴 if (pos.z == 0 && fabs((ray.origin.z + dist * ray.direction.z) - (-3.0f)) < 1e-5f) { mCurrRotationRecord.pos = isVertical ? pos.x : pos.y; mCurrRotationRecord.axis = isVertical ? RubikRotationAxis_X : RubikRotationAxis_Y; } // 当前鼠标操纵的是+X面,根据操做类型决定旋转轴 else if (pos.x == 2 && fabs((ray.origin.x + dist * ray.direction.x) - 3.0f) < 1e-5f) { mCurrRotationRecord.pos = isVertical ? pos.z : pos.y; mCurrRotationRecord.axis = isVertical ? RubikRotationAxis_Z : RubikRotationAxis_Y; } // 当前鼠标操纵的是+Y面,要判断平移变化量dx和dy的符号来决定旋转方向 else if (pos.y == 2 && fabs((ray.origin.y + dist * ray.direction.y) - 3.0f) < 1e-5f) { // 判断异号 bool diffSign = ((dx & 0x80000000) != (dy & 0x80000000)); mCurrRotationRecord.pos = diffSign ? pos.x : pos.z; mCurrRotationRecord.axis = diffSign ? RubikRotationAxis_X : RubikRotationAxis_Z; } // 当前鼠标操纵的是空白地区,则对整个魔方旋转 else { mCurrRotationRecord.pos = 3; // 水平操做是Y轴旋转 if (!isVertical) { mCurrRotationRecord.axis = RubikRotationAxis_Y; } // 屏幕左半部分的垂直操做是X轴旋转 else if (mouseState.x < mClientWidth / 2) { mCurrRotationRecord.axis = RubikRotationAxis_X; } // 屏幕右半部分的垂直操做是Z轴旋转 else { mCurrRotationRecord.axis = RubikRotationAxis_Z; } }
鼠标拖动魔方旋转能够分为三个阶段:鼠标初次点击、鼠标产生拖动、鼠标刚释放。
在鼠标初次点击的时候不必定会产生偏移量,但咱们必需要在这个时候判断鼠标是在作垂直拖动仍是竖直拖动来肯定当前的旋转轴,以限制魔方的旋转。
如今要考虑这样一个状况,我鼠标在初次点击魔方时可能会由于手抖或者鼠标不稳产生了一个如下方向为主的瞬时移动,而后程序判断我如今在作向下的拖动,但实际状况倒是我须要向右方向拖动鼠标,程序却只容许我上下拖动。这就十分尴尬了。
因为鼠标的拖动过程相对程序的运行会比较缓慢,咱们能够给程序加上一个延迟判断。好比说我如今能够根据鼠标初次点击后的0.05s内产生的累计垂直/水平偏移量来判断此时是水平拖动仍是竖直拖动。
此外,一旦肯定这段时间内产生了偏移值,必需要加上方向锁,防止后续又从新判断旋转方向。
这部分代码实现以下:
// 此时未肯定旋转方向 if (!mDirectionLocked) { // 此时未记录点击位置 if (mClickPosX == -1 && mClickPosY == -1) { // 初次点击 if (mMouseTracker.leftButton == Mouse::ButtonStateTracker::PRESSED) { // 记录点击位置 mClickPosX = mouseState.x; mClickPosY = mouseState.y; } } // 仅当记录了点击位置才进行更新 if (mClickPosX != -1 && mClickPosY != -1) mCurrDelay += dt; // 未到达滑动延迟时间则结束 if (mCurrDelay < mSlideDelay) return; // 未产生运动则不上锁 if (abs(dx) == abs(dy)) return; // 开始上方向锁 mDirectionLocked = true; // 更新累积的位移变化量 dx = mouseState.x - mClickPosX; dy = mouseState.y - mClickPosY; // 找到当前鼠标点击的方块索引 Ray ray = Ray::ScreenToRay(*mCamera, (float)mouseState.x, (float)mouseState.y); // ...剩余部分就是上面的代码 }
这部分实现就比较简单了。只要鼠标左键按下,且确认方向锁,就能够进行魔方的旋转。
若是是绕X轴的旋转,鼠标向右移动和向上移动都会产生顺时针旋转。
若是是绕Y轴的旋转,只有鼠标向左移动才会产生顺时针旋转。
若是是绕Z轴的旋转,鼠标向左移动和向上移动都会产生顺时针旋转。
这里的Rotate函数最后一个参数必需要传递true
以告诉内部不要进行预旋转操做。
// 上了方向锁才能进行旋转 if (mDirectionLocked) { // 进行旋转 switch (mCurrRotationRecord.axis) { case RubikRotationAxis_X: mRubik.RotateX(mCurrRotationRecord.pos, (dx - dy) * 0.008f, true); break; case RubikRotationAxis_Y: mRubik.RotateY(mCurrRotationRecord.pos, -dx * 0.008f, true); break; case RubikRotationAxis_Z: mRubik.RotateZ(mCurrRotationRecord.pos, (-dx - dy) * 0.008f, true); break; } }
完成拖动后,须要恢复方向锁和滑动延迟,而且鼠标刚释放时产生的偏移咱们直接丢掉。如今Rotate函数仅用于发送进行预旋转的命令:
// 鼠标左键是否点击 if (mouseState.leftButton) { // ... } // 鼠标刚释放 else if (mMouseTracker.leftButton == Mouse::ButtonStateTracker::RELEASED) { // 释放方向锁 mDirectionLocked = false; // 滑动延迟归零 mCurrDelay = 0.0f; // 坐标移出屏幕 mClickPosX = mClickPosY = -1; // 发送完成指令,进行预旋转 switch (mCurrRotationAxis) { case RubikRotationAxis_X: mRubik.RotateX(mCurrRotationRecord.pos, 0.0f); break; case RubikRotationAxis_Y: mRubik.RotateY(mCurrRotationRecord.pos, 0.0f); break; case RubikRotationAxis_Z: mRubik.RotateZ(mCurrRotationRecord.pos, 0.0f); break; } }
最终鼠标拖动的效果以下:
键盘的效果以下:
回顾一下RubikRotationRecord
类的定义:
struct RubikRotationRecord { RubikRotationAxis axis; // 当前旋转轴 int pos; // 当前旋转层的索引 float dTheta; // 当前旋转的弧度 };
当pos
为0-2时,均为单层魔方的旋转,-1和-2为双层魔方的旋转,3则为整个魔方的旋转。
咱们使用一个栈来记录用户的操做,它放在了GameApp
类中:
std::stack<RubikRotationRecord> mRotationRecordStack;
对于键盘操做来讲特别简单,只须要在每次操做后记录便可:
// 公式x if (mKeyboardTracker.IsKeyPressed(Keyboard::Up)) { mRubik.RotateX(3, XM_PIDIV2); // 此处新增 mRotationRecordStack.push(RubikRotationRecord{ RubikRotationAxis_X, 3, XM_PIDIV2 }); return; }
而鼠标操做是一个连续的过程,而且记录要点以下:
鼠标释放部分通过修改后:
// 鼠标刚释放 if (mMouseTracker.leftButton == Mouse::ButtonStateTracker::RELEASED) { // 释放方向锁 mDirectionLocked = false; // 滑动延迟归零 mCurrDelay = 0.0f; // 坐标移出屏幕 mClickPosX = mClickPosY = -1; // 发送完成指令,进行预旋转 switch (mCurrRotationRecord.axis) { case RubikRotationAxis_X: mRubik.RotateX(mCurrRotationRecord.pos, 0.0f); break; case RubikRotationAxis_Y: mRubik.RotateY(mCurrRotationRecord.pos, 0.0f); break; case RubikRotationAxis_Z: mRubik.RotateZ(mCurrRotationRecord.pos, 0.0f); break; } // 此处新增 // 若此次旋转有意义,记录到栈中 int times = static_cast<int>(round(mCurrRotationRecord.dTheta / XM_PIDIV2)) % 4; if (times != 0) { mCurrRotationRecord.dTheta = times * XM_PIDIV2; mRotationRecordStack.push(mCurrRotationRecord); } // 旋转值归零 mCurrRotationRecord.dTheta = 0.0f; }
上面的那个栈不只能够用来记录用户操做记录,还能够用来存储打乱魔方的操做。即游戏刚开始先给这个栈塞入一堆随机操做,而后每执行一个操做就退栈一次,直到栈空时打乱操做完成,用户能够开始对魔方进行操做,同时这个栈也开始记录用户操做。
GameApp::Shuffle
的操做以下:
void GameApp::Shuffle() { // 清栈 while (!mRotationRecordStack.empty()) mRotationRecordStack.pop(); // 往栈上塞30个随机旋转操做用于打乱 RubikRotationRecord record; srand(static_cast<unsigned>(time(nullptr))); for (int i = 0; i < 30; ++i) { record.axis = static_cast<RubikRotationAxis>(rand() % 3); record.pos = rand() % 4; record.dTheta = XM_PIDIV2 * (rand() % 2 ? 1 : -1); mRotationRecordStack.push(record); } }
这是一个简单的摄像机移动过程,包含的绕Y轴的旋转和镜头的推动。这个动画过程须要根据帧时间间隔作更新。总体动画时间为5s,在没有结束前GameApp::PlayCameraAnimation
会返回false
,完成动画后则返回true
:
bool GameApp::PlayCameraAnimation(float dt) { // 获取子类 auto cam3rd = dynamic_cast<ThirdPersonCamera*>(mCamera.get()); // ****************** // 第三人称摄像机的操做 // mAnimationTime += dt; float theta, dist; theta = -XM_PIDIV2 + XM_PIDIV4 * mAnimationTime * 0.2f; dist = 20.0f - mAnimationTime * 2.0f; if (theta > -XM_PIDIV4) theta = -XM_PIDIV4; if (dist < 10.0f) dist = 10.0f; cam3rd->SetRotationY(theta); cam3rd->SetDistance(dist); // 更新观察矩阵 mCamera->UpdateViewMatrix(); mBasicEffect.SetViewMatrix(mCamera->GetViewXM()); if (fabs(theta + XM_PIDIV4) < 1e-5f && fabs(dist - 10.0f) < 1e-5f) return true; return false; }
注意GameApp::PlayCameraAnimation
绝对不能同GameApp::MouseInput
或者GameApp::KeyInput
共存!
开场动画+打乱效果以下:
至此魔方的应用层就讲述到这里,剩下的逻辑部分实现能够参考源码,本系列教程到这里就结束了。该DX11实现的魔方的功能跟DX9比起来有多的地方,也有少的地方,我的感受不必再增长新的东西。毕竟做为一个游戏来讲,它算是一个合格的做品了。
此外我以为没有必要展开大的篇幅再来说底层的实现,我更但愿的是你能跟着个人DX11教程把底层好好的过一遍,里面有些部分的内容是在龙书里面没有涉及到的。
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 能够一块儿探讨DX11,以及有什么问题也能够在这里汇报。