1. 程式人生 > >DirectX11--實現一個3D魔方(3)

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,以及有什麼問題也可以在這裡彙報。