DirectX11--实现一个3D魔方(3)

前言

(2019/1/9 09:23)上一章我们主要讲述了魔方的旋转,这个旋转真是有毒啊,搞完这个部分搭键鼠操作不到半天应该就可以搭完了吧...

(2019/1/9 21:25)啊,真香


有人发这张图片问我写魔方的目的是不是这个。。。噗

现在光是键鼠相关的代码也搭了400行左右。。其中键盘相关的调用真的是毫无技术可言,重点实现基本上都被鼠标给耽搁了。

章节
实现一个3D魔方(1)
实现一个3D魔方(2)
实现一个3D魔方(3)

Github项目--魔方

对了,在此之前你可以去了解一下我这里所使用的摄像机、碰撞检测、鼠标拾取相关模块的实现:

章节
10 摄像机类
18 使用DirectXCollision库进行碰撞检测
21 鼠标拾取

最后日常安利一波本人正在编写的DX11教程。

DirectX11 With Windows SDK完整目录

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

我就简单提一下键盘的逻辑

键盘操作使用的是DXTK经过修改的Keyboard库。

因为之前说过,Rubik::RotateX函数在响应了来自键盘的输入后,就会进入自动旋转模式,此时的键盘输入将不会响应。因此整个函数实现起来就是这么暴力:

void GameApp::KeyInput()
{
    Keyboard::State keyState = mKeyboard->GetState();
    mKeyboardTracker.Update(keyState);

    //
    // 整个魔方旋转
    //

    // 公式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轴顺时针旋转
Down x' 整个魔方按x轴逆时针旋转
Left y 整个魔方按y轴顺时针旋转
Right y' 整个魔方按y轴逆时针旋转
Pg Up z' 整个魔方按z轴逆时针旋转
Pg Down z 整个魔方按z轴顺时针旋转
-------- ---- ------------------------
LCtrl+I r 右面两层按x轴顺时针旋转
LCtrl+K r' 右面两层按x轴逆时针旋转
LCtrl+J u 顶面两层按y轴顺时针旋转
LCtrl+L u' 顶面两层按y轴逆时针旋转
LCtrl+U f' 正面两层按z轴逆时针旋转
LCtrl+O f 正面两层按z轴顺时针旋转
-------- ---- ------------------------
LCtrl+W l' 左面两层按x轴逆时针旋转
LCtrl+S l 左面两层按x轴顺时针旋转
LCtrl+A d' 底面两层按y轴逆时针旋转
LCtrl+D d 底面两层按y轴顺时针旋转
LCtrl+Q b 背面两层按z轴顺时针旋转
LCtrl+E b' 背面两层按z轴逆时针旋转
-------- ---- ------------------------
I R 右面两层按x轴顺时针旋转
K R' 右面两层按x轴逆时针旋转
J U 顶面两层按y轴顺时针旋转
L U' 顶面两层按y轴逆时针旋转
U F' 正面两层按z轴逆时针旋转
O F 正面两层按z轴顺时针旋转
-------- ---- ------------------------
T M 右面两层按x轴顺时针旋转
G M' 右面两层按x轴逆时针旋转
F E 顶面两层按y轴顺时针旋转
H E' 顶面两层按y轴逆时针旋转
R S' 正面两层按z轴逆时针旋转
Y S 正面两层按z轴顺时针旋转
-------- ---- ------------------------
W L' 右面两层按x轴顺时针旋转
S L 右面两层按x轴逆时针旋转
A D' 顶面两层按y轴顺时针旋转
D D 顶面两层按y轴逆时针旋转
Q B 正面两层按z轴逆时针旋转
E B' 正面两层按z轴顺时针旋转

鼠标逻辑相关的实现

鼠标相关的实现难度远比键盘复杂多了,我主要分三个部分来讲:

  1. 立方体的拾取与判断拾取到的立方体表面
  2. 根据拖动方向判断旋转轴
  3. 鼠标在不同的操作阶段对应的处理

在此之前,我先讲讲在这个项目加的一点点私货

鼠标的轻微抖动效果

首先来看效果

这个效果的实现比较简单,现在我使用的是第三人称摄像机。现规定以游戏窗口中心为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面。

在我们拾取到立方体后,我们还要根据这两个信息来确定旋转轴:

  1. 当前具体是拾取到立方体的哪个面
  2. 当前鼠标的拖动方向

这又是一个十分细的问题。其中-X面和-Z面在屏幕上是对称关系,代码实现可以做镜像处理,但是+Y面的操作跟其它两个面又有一些差别。

鼠标落在立方体的-Z面

现在我们只讨论拾取到立方体索引[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轴进行旋转。

这里先把GameApp中所有与鼠标操作相关的新增成员先列出来,后面我就不再重复:

//
// 鼠标操作控制
//
    
int mClickPosX, mClickPosY;                 // 初次点击时鼠标位置
float mSlideDelay;                          // 拖动延迟响应时间 
float mCurrDelay;                           // 当前延迟时间
bool mDirectionLocked;                      // 方向锁
RubikRotationAxis mCurrRotationAxis;        // 当前鼠标拖动时的旋转轴
int mSlidePos;                              // 当前鼠标拖动的层数索引,3为整个魔方

mSlidePosmCurrRotationAxis用于保留判断旋转轴和层数的结果,以保证后续旋转的一致性。

核心判断方法如下:

// 判断当前主要是垂直操作还是水平操作
bool isVertical = abs(dx) < abs(dy);
// 当前鼠标操纵的是-Z面,根据操作类型决定旋转轴
if (pos.z == 0 && fabs((ray.origin.z + dist * ray.direction.z) - (-3.0f)) < 1e-5f)
{
    mSlidePos = isVertical ? pos.x : pos.y;
    mCurrRotationAxis = isVertical ? RubikRotationAxis_X : RubikRotationAxis_Y;
}

pos为鼠标拾取到的立方体索引。

鼠标落在立方体的+X面

现在我们拾取到了索引为[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)
{
    mSlidePos = isVertical ? pos.z : pos.y;
    mCurrRotationAxis = isVertical ? RubikRotationAxis_Z : RubikRotationAxis_Y;
}

鼠标落在立方体的+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));
    mSlidePos = diffSign ? pos.x : pos.z;
    mCurrRotationAxis = 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)
{
    mSlidePos = isVertical ? pos.x : pos.y;
    mCurrRotationAxis = isVertical ? RubikRotationAxis_X : RubikRotationAxis_Y;
}
// 当前鼠标操纵的是+X面,根据操作类型决定旋转轴
else if (pos.x == 2 && fabs((ray.origin.x + dist * ray.direction.x) - 3.0f) < 1e-5f)
{
    mSlidePos = isVertical ? pos.z : pos.y;
    mCurrRotationAxis = 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));
    mSlidePos = diffSign ? pos.x : pos.z;
    mCurrRotationAxis = diffSign ? RubikRotationAxis_X : RubikRotationAxis_Z;
}
// 当前鼠标操纵的是空白地区,则对整个魔方旋转
else
{
    mSlidePos = 3;
    // 水平操作是Y轴旋转
    if (!isVertical)
    {
        mCurrRotationAxis = RubikRotationAxis_Y;
    }
    // 屏幕左半部分的垂直操作是X轴旋转
    else if (mouseState.x < mClientWidth / 2)
    {
        mCurrRotationAxis = RubikRotationAxis_X;
    }
    // 屏幕右半部分的垂直操作是Z轴旋转
    else
    {
        mCurrRotationAxis = 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 (mCurrRotationAxis)
    {
    case RubikRotationAxis_X: mRubik.RotateX(mSlidePos, (dx - dy) * 0.008f, true); break;
    case RubikRotationAxis_Y: mRubik.RotateY(mSlidePos, -dx * 0.008f, true); break;
    case RubikRotationAxis_Z: mRubik.RotateZ(mSlidePos, (-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(mSlidePos, 0.0f); break;
    case RubikRotationAxis_Y: mRubik.RotateY(mSlidePos, 0.0f); break;
    case RubikRotationAxis_Z: mRubik.RotateZ(mSlidePos, 0.0f); break;
    }
}

最终鼠标拖动的效果如下:

键盘的效果如下:

至此魔方的一些核心实现就讲的差不多了。最后无非就是功能上的堆叠了。到现在写魔方的实现用了2天工时,博客也差不多2天。

这一章也写了快500行内容,比代码还多。

未完待续。。。

Github项目--魔方

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

猜你喜欢

转载自www.cnblogs.com/X-Jun/p/10247018.html