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

前言

可以說,魔方跟我的人生也有一定的聯繫。

在高中的學校接觸到了魔方社,那時候的我雖然也能夠還原魔方,可看到大神們總是可以非常快地還原,爲此我也走上了學習高級公式CFOP的坑。當初學習的網站是在魔方小站,不過由於公式太多了,那一年主要也就學會了頂層公式PLL和底二層公式F2L,最好的時候大概30s能夠復原一個魔方,不過後來還是退坑了。

然後到了大學,參加考覈的時候被要求用DirectX9來實現考題規定的遊戲,我選擇了魔方。然後在僅有12天的時間狂肝Direct3D 9,雖然那時候寫的代碼還比較生澀,不過至少實現的效果還是比較滿意的,至少在可玩性上我感覺還不錯,甚至可以用來競速。

Github項目--魔方

這個是DX9魔方的遊玩過程。礙於圖片最大隻能上傳10M,將就一下。

嗯,現在距離這個Demo都已經過去快兩年了,然後電腦應爲一些不可抗因素把系統升到了Win10。然後現在,我居然運行不了所有的DirectX 9遊戲,包括我之前寫的demo也翻車了。不過目前我學DirectX 11斷斷續續也是差不過有兩年了,然後重構的念頭一直在我腦海中迴響。寫了大半年的教程,中間也積累了不少的代碼,用現有的代碼框架應該也可以很快搭建出來吧。現在開始一邊開發一邊記錄自己的思路,所以現在你能看到的魔方也並不是最終版本。

注意:本教程會重點講述一個3D魔方遊戲的實現原理,即便不是用DirectX來進行開發,你也可以根據這裏面的原理在OpenGL,WebGL,Unity3D等地方實現出來。等我把應用層的實現原理講完後,再酌情講一些底層的實現部分

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

順便下面安利一波本人正在編寫的DX11教程。

DirectX11 With Windows SDK完整目錄

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

前期準備

該項目的Direct3D 11源自Windows SDK,注意不是DirectX SDK!這意味着只要你有Visual Studio 2015/2017,只要安裝了C++相關的組件,打開本項目你就可以直接生成出來並運行了。

說實話,即便是個看起來比較簡單的魔方,內部的實現也是比較硬核的。而且因爲是使用DirectX 11寫的,對於正在學習或者想要學習DirectX 11的人來說,你必須要把很多底層的原理給弄懂,所以大多數人可能會偏向於先造一個自己的軟引擎。

以下是對學習DX11的人的基本要求:

  1. 熟悉C++11和少量14,熟悉HLSL語法
  2. 學習過線性代數和3D數學基礎,瞭解渲染管線的流程
  3. 初步瞭解Windows編程
  4. Direct3D 11的初始化
  5. HLSL的編譯,頂點、索引、常量緩衝區的創建、綁定和修改
  6. 紋理映射、紋理數組的加載
  7. 第三人稱攝像機
  8. 碰撞檢測、鼠標拾取
  9. 特效管理框架的實現(本項目不使用Effects11或FX11)

而以下則是對只是想要了解魔方實現的人的基本要求:

  1. 熟悉C++11和少量14
  2. 學習過線性代數和3D數學基礎
  3. 第三人稱攝像機
  4. 碰撞檢測、鼠標拾取

對了,本項目不打算使用光照。

爲了儘可能簡化開發流程,我把之前寫教程實現的大部分模塊都搬過來這裏用了,這樣可以儘可能屏蔽底層實現而讓我更專注於魔方本身的實現。如果要理解這些模塊的功能你仍需要花費大量的時間來學習。

首先列出項目的超長文件結構圖(先不要被嚇跑)。。。

其中從微軟那邊直接搬運過來的模塊如下:

微軟提供的模塊 功能
DirectXTex/DDSTextureLoader DDS紋理加載
DirectXTex/WICTextureLoader WIC相關位圖加載(估計用不上)
DirectXTex/ScreenGrab 截屏保存(估計用不上)
DXTK/Mouse(源碼上有所修改) 鼠標類
DXTK/Keyboard(源碼上有所修改) 鍵盤類

然後是自己之前積累下來的一些模塊,也包括龍書的:

個人或龍書曾經編寫過的模塊 功能
Camera 簡易攝像機
d3dUtil 包含了一些d3d常用的頭文件和個人之前實現過的一些函數
DXTrace 貢獻了HR宏,用於錯誤追蹤
GameTimer 龍書的計時器
Vertex 包含了一些常用的頂點類型
Collision 用於鼠標拾取、碰撞檢測

由於上述代碼都是已經實現好的,所以對我來說裏面的實現現在可以忽略。

而下面這些模塊則是我需要重點進行修改和編寫的

模塊 功能
BasicEffect 特效、常量緩衝區的管理
d3dApp Direct3D和Windows的初始化
GameApp 管理遊戲的邏輯實現部分
Rubik 魔方類

然後基礎遊戲框架使用的本人項目13的d3dAppGameApp。對於一般人來說,你只需要看懂Rubik類,以及GameApp類裏面的遊戲邏輯即可。前面的內容也是重點圍繞這裏面的代碼來展開描述。

預期功能實現

本項目的魔方預期實現的功能和當前進度如下:

  1. 實現魔方的單層旋轉和整體旋轉(已實現)
  2. 提供鍵盤和鼠標操作(以實現)
  3. 攝像機第三人稱視角調整(部分實現)
  4. 檢驗魔方是否完成(未實現)
  5. 計時器、本地排行榜(未實現)
  6. 天空盒(這個隨緣)

魔方的構造

首先,魔方的6個面可以使用下面的枚舉值來確定:

enum RubikFace {
    RubikFace_PosX,     // +X面
    RubikFace_NegX,     // -X面
    RubikFace_PosY,     // +Y面
    RubikFace_NegY,     // -Y面
    RubikFace_PosZ,     // +Z面
    RubikFace_NegZ,     // -Z面
};

這和天空盒指定面的枚舉值是一致的。所謂的+X面你可以理解爲從魔方中心發射一條+X軸的射線所指向的面,注意這是建立在左手座標系的基礎上確定的。

然後,本項目提供了7種魔方紋理的顏色,由先的枚舉值來確定:

enum RubikFaceColor {
    RubikFaceColor_Black,       // 黑色
    RubikFaceColor_Orange,      // 橙色
    RubikFaceColor_Red,         // 紅色
    RubikFaceColor_Green,       // 綠色
    RubikFaceColor_Blue,        // 藍色
    RubikFaceColor_Yellow,      // 黃色
    RubikFaceColor_White        // 白色
};

所謂的黑色是指藏在魔方內部平時看不到的面,但是在魔方旋轉的時候可以看到露出的一部分。

這裏我準備了七張魔方表面的紋理貼圖:

目前立方體結構體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軸旋轉的弧度

};

現在我們不討論Cube::GetWorldMatrix的實現,你可以先默認它返回一個根據pos進行平移的矩陣。

可以看到這個結構體甚至不存放什麼頂點和索引數據,它只記錄一下關鍵的信息。這麼做是方便我判斷魔方是否還原,以及儘可能最簡化魔方的旋轉操作。

然後是魔方類Rubik的初步定義:

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

    // 初始化資源
    void InitResources(ComPtr<ID3D11Device> device, ComPtr<ID3D11DeviceContext> deviceContext);
    // 立即復原魔方
    void Reset();
    // 更新魔方狀態
    void Update();
    // 繪製魔方
    void Draw(ComPtr<ID3D11DeviceContext> deviceContext, BasicEffect& effect);

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

    // 頂點緩衝區,包含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;
};

魔方的索引對應的關係滿足左手座標系,一級、二級、三級索引分別對應X軸、Y軸、Z軸方向上的偏移:

注意我們的魔方中心是始終位於世界座標系的中心的,這樣有利於我們對魔方進行旋轉操作。此外你也可以看到,我將立方體六個正方形表面的24個頂點都同時存放在一個索引緩衝區中,在繪製的時候只需要設置頂點偏移量就可以指定當前繪製哪個面。所有的27個立方體都是依賴於這兩個緩衝區,加上世界矩陣和紋理數組繪製出來的。

當然上面的索引緩衝區實際上也是可以扔掉的,只需要將頂點緩衝區中的頂點次序稍微調整下,然後使用原始拓撲類型D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP即可。正方形面此時頂點按索引的排布如下:

這個類在後續我們還會進行修改。

魔方的初始化

根據上面所給的數據結構,現在我需要初始化的數據有:紋理數組、頂點緩衝區、索引緩衝區、每個立方體的數據。

其中頂點和索引直接在初始化中提供即可。下面是Rubik::InitResources的實現:

void Rubik::InitResources(ComPtr<ID3D11Device> device, ComPtr<ID3D11DeviceContext> deviceContext)
{
    // 初始化紋理數組
    mTexArray = CreateDDSTexture2DArrayFromFile(
        device,
        deviceContext,
        std::vector<std::wstring>{
            L"Resource/Black.dds",
            L"Resource/Orange.dds",
            L"Resource/Red.dds",
            L"Resource/Green.dds",
            L"Resource/Blue.dds",
            L"Resource/Yellow.dds",
            L"Resource/White.dds",
    });

    //
    // 初始化立方體網格模型
    //

    VertexPosTex vertices[] = {
        // +X面
        { XMFLOAT3(1.0f, -1.0f, -1.0f), XMFLOAT2(1.0f, 0.0f) },
        { XMFLOAT3(1.0f, 1.0f, -1.0f), XMFLOAT2(0.0f, 0.0f) },
        { XMFLOAT3(1.0f, 1.0f, 1.0f), XMFLOAT2(0.0f, 1.0f) },
        { XMFLOAT3(1.0f, -1.0f, 1.0f), XMFLOAT2(1.0f, 1.0f) },
        // -X面
        { XMFLOAT3(-1.0f, -1.0f, 1.0f), XMFLOAT2(1.0f, 0.0f) },
        { XMFLOAT3(-1.0f, 1.0f, 1.0f), XMFLOAT2(0.0f, 0.0f) },
        { XMFLOAT3(-1.0f, 1.0f, -1.0f), XMFLOAT2(0.0f, 1.0f) },
        { XMFLOAT3(-1.0f, -1.0f, -1.0f), XMFLOAT2(1.0f, 1.0f) },
        // +Y面
        { XMFLOAT3(-1.0f, 1.0f, -1.0f), XMFLOAT2(1.0f, 0.0f) },
        { XMFLOAT3(-1.0f, 1.0f, 1.0f), XMFLOAT2(0.0f, 0.0f) },
        { XMFLOAT3(1.0f, 1.0f, 1.0f), XMFLOAT2(0.0f, 1.0f) },
        { XMFLOAT3(1.0f, 1.0f, -1.0f), XMFLOAT2(1.0f, 1.0f) },
        // -Y面
        { XMFLOAT3(-1.0f, -1.0f, 1.0f), XMFLOAT2(1.0f, 0.0f) },
        { XMFLOAT3(-1.0f, -1.0f, -1.0f), XMFLOAT2(0.0f, 0.0f) },
        { XMFLOAT3(1.0f, -1.0f, -1.0f), XMFLOAT2(0.0f, 1.0f) },
        { XMFLOAT3(1.0f, -1.0f, 1.0f), XMFLOAT2(1.0f, 1.0f) },
        // +Z面
        { XMFLOAT3(1.0f, -1.0f, 1.0f), XMFLOAT2(1.0f, 0.0f) },
        { XMFLOAT3(1.0f, 1.0f, 1.0f), XMFLOAT2(0.0f, 0.0f) },
        { XMFLOAT3(-1.0f, 1.0f, 1.0f), XMFLOAT2(0.0f, 1.0f) },
        { XMFLOAT3(-1.0f, -1.0f, 1.0f), XMFLOAT2(1.0f, 1.0f) },
        // -Z面
        { XMFLOAT3(-1.0f, -1.0f, -1.0f), XMFLOAT2(1.0f, 0.0f) },
        { XMFLOAT3(-1.0f, 1.0f, -1.0f), XMFLOAT2(0.0f, 0.0f) },
        { XMFLOAT3(1.0f, 1.0f, -1.0f), XMFLOAT2(0.0f, 1.0f) },
        { XMFLOAT3(1.0f, -1.0f, -1.0f), XMFLOAT2(1.0f, 1.0f) },
    };

    // 設置頂點緩衝區描述
    D3D11_BUFFER_DESC vbd;
    ZeroMemory(&vbd, sizeof(vbd));
    vbd.Usage = D3D11_USAGE_IMMUTABLE;
    vbd.ByteWidth = sizeof vertices;
    vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    vbd.CPUAccessFlags = 0;
    // 新建頂點緩衝區
    D3D11_SUBRESOURCE_DATA initData;
    ZeroMemory(&initData, sizeof(initData));
    initData.pSysMem = vertices;
    HR(device->CreateBuffer(&vbd, &initData, mVertexBuffer.ReleaseAndGetAddressOf()));
    

    WORD indices[] = { 0, 1, 2, 2, 3, 0 };
    // 設置索引緩衝區描述
    D3D11_BUFFER_DESC ibd;
    ZeroMemory(&ibd, sizeof(ibd));
    ibd.Usage = D3D11_USAGE_IMMUTABLE;
    ibd.ByteWidth = sizeof indices;
    ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
    ibd.CPUAccessFlags = 0;
    // 新建索引緩衝區
    initData.pSysMem = indices;
    HR(device->CreateBuffer(&ibd, &initData, mIndexBuffer.ReleaseAndGetAddressOf()));

    // 初始化魔方所有面
    Reset();

    // 預先綁定頂點/索引緩衝區到渲染管線
    UINT strides[1] = { sizeof(VertexPosTex) };
    UINT offsets[1] = { 0 };
    deviceContext->IASetVertexBuffers(0, 1, mVertexBuffer.GetAddressOf(), strides, offsets);
    deviceContext->IASetIndexBuffer(mIndexBuffer.Get(), DXGI_FORMAT_R16_UINT, 0);

}

Rubik::Reset用來方便一次性還原魔方,初始化各個立方體的位置:

void Rubik::Reset()
{
    // 初始化魔方中心位置,用六個面默認填充黑色
    for (int i = 0; i < 3; ++i)
        for (int j = 0; j < 3; ++j)
            for (int k = 0; k < 3; ++k)
            {
                mCubes[i][j][k].pos = XMFLOAT3(-2.0f + 2.0f * i,
                    -2.0f + 2.0f * j, -2.0f + 2.0f * k);
                mCubes[i][j][k].rotation = XMFLOAT3();
                memset(mCubes[i][j][k].faceColors, 0, 
                    sizeof mCubes[i][j][k].faceColors);
            }

    // +X面爲橙色,-X面爲紅色
    // +Y面爲綠色,-Y面爲藍色
    // +Z面爲黃色,-Z面爲白色
    for (int i = 0; i < 3; ++i)
        for (int j = 0; j < 3; ++j)
        {
            mCubes[2][i][j].faceColors[RubikFace_PosX] = RubikFaceColor_Orange;
            mCubes[0][i][j].faceColors[RubikFace_NegX] = RubikFaceColor_Red;

            mCubes[j][2][i].faceColors[RubikFace_PosY] = RubikFaceColor_Green;
            mCubes[j][0][i].faceColors[RubikFace_NegY] = RubikFaceColor_Blue;

            mCubes[i][j][2].faceColors[RubikFace_PosZ] = RubikFaceColor_Yellow;
            mCubes[i][j][0].faceColors[RubikFace_NegZ] = RubikFaceColor_White;
        }   

}

Rubik::InitResources中用到了我自己之前編寫的CreateDDSTexture2DArrayFromFile函數,裏面要求傳遞的是dds紋理文件,但是我現在所擁有的魔方貼圖全部都是從畫圖工具弄出來的png格式。爲此,我還需要對紋理進行格式的轉換。

使用dxtex(DirectX Texture Tool)製作DDS紋理

dxtex通常是在你安裝了DirectX SDK後可以找到的,位於Microsoft DirectX SDK\Utilities\bin\x86Microsoft DirectX SDK\Utilities\bin\x64中。沒有安裝該SDK的,你也可以在我的Github中找到:

點此查看

打開dxtex,載入png位圖

然後選擇Format-Change Surface Format,將位圖格式改爲Unsigned 32-bit: A8R8G8B8

緊接着,我們需要給它生成mipmap,否則可能會導致在用大紋理繪製實際較小的部分時,某些傾斜的條紋會因爲採樣而產生類似鋸齒狀條紋:

而且就是開了4倍MSAA都拯救不了這麼強烈的鋸齒感!

點擊Format-Generate Mip Maps,程序自動爲其創建Mipmap。在View選項中你可以通過Smaller Mipmap Level來觀察生成的mipmap。

最後選擇File-Save As,直接另存爲.dds文件即可。

GameApp框架

該框架的流程圖如下:

其中需要我做修改的部分主要落在了GameApp::Init, GameApp::UpdateSceneGameApp::DrawScene上。

GameApp::InitResources方法

該方法隨GameApp::Init調用,用於初始化遊戲所需的資源:

bool GameApp::InitResource()
{
    // 初始化魔方
    mRubik.InitResources(md3dDevice, md3dImmediateContext);
    
    // 初始化特效、着色器資源
    mBasicEffect.SetRenderDefault(md3dImmediateContext);
    mBasicEffect.SetViewMatrix(XMMatrixLookAtLH(
        XMVectorSet(6.0f, 6.0f, -6.0f, 1.0f),
        XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f),
        XMVectorSet(0.0f, 1.0f, 0.0f, 1.0f)
    ));
    mBasicEffect.SetProjMatrix(XMMatrixPerspectiveFovLH(
        XM_PI / 3, AspectRatio(), 1.0f, 1000.0f
    ));
    mBasicEffect.SetTextureArray(mRubik.GetTexArray());
    

    return true;
}

對於mBasicEffect,你現在暫時不需要知道它底層原理,可以先把它當做一個類似於ID3DX11Effect的對象。它可以用於設置默認的渲染模式,以及各項所需的資源給HLSL,包括世界矩陣、觀察矩陣、投影矩陣和紋理數組。

着色器的具體實現這裏我們也先不提,我們把更細節的內容留到後續的章節來講。現在要做的,就是利用現有的框架先把這個魔方給繪製出來。

GameApp::DrawScene方法

目前GameApp::UpdateScene還沒有做任何事情,可以不管。GameApp::DrawScene的實現如下:

void GameApp::DrawScene()
{
    assert(md3dImmediateContext);
    assert(mSwapChain);

    // 使用偏紫色的純色背景
    float backgroundColor[4] = { 0.45882352f, 0.42745098f, 0.51372549f, 1.0f };
    md3dImmediateContext->ClearRenderTargetView(mRenderTargetView.Get(), backgroundColor);
    md3dImmediateContext->ClearDepthStencilView(mDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
    
    // 繪製魔方
    mRubik.Draw(md3dImmediateContext, mBasicEffect);

    // 省略目前沒有作爲的部分...

    HR(mSwapChain->Present(0, 0));
}

然後Rubik::Draw的實現目前如下:

void Rubik::Draw(ComPtr<ID3D11DeviceContext> deviceContext, BasicEffect& effect)
{
    for (int i = 0; i < 3; ++i)
        for (int j = 0; j < 3; ++j)
            for (int k = 0; k < 3; ++k)
            {
                effect.SetWorldMatrix(mCubes[i][j][k].GetWorldMatrix());
                for (int face = 0; face < 6; ++face)
                {
                    effect.SetTexIndex(mCubes[i][j][k].faceColors[face]);
                    effect.Apply(deviceContext);
                    deviceContext->DrawIndexed(6, 0, 4 * face);
                }
            }
}

通過BasicEffect::SetTexIndex我們可以指定當前繪製的立方體面使用的是紋理數組中的哪一個紋理。

每繪製一個立方體中的一個表面,就需要切換一次世界矩陣,並應用所有的變更。

由於我把所有的頂點都放在同一個緩衝區了,只需要在ID3D11DeviceContext::DrawIndexed指定起始頂點的偏移量即可。

最終的效果如下:

目前的開發進度用了我半天時間,然後還有大半天的時間用來寫這篇博客,理論上我稍微爆肝一點可能兩天時間就可以弄出來了吧。雖然表面開發了半天,但爲了這個教程至少也準備了大半年的時間。現在趁這個機會可以好好理順一下自己的開發思路,可能要多花3-4天的時間。目前的項目我已經放到Github中了:

Github項目--魔方

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章