LoginSignup
3

More than 1 year has passed since last update.

posted at

updated at

DirectX11 アプリを Modern C++ で書いてカッコつけてみた話

手元のコードで C++17, C++20 の機能の一部を使用してみました。
※2020.12.17 std::string_viewの節に追記をしました。

アプリ概要

DirectX11 を使って立方体を回転させているプログラムです。

drawcubedemo.gif

書き直す前のコードはおおよそ以下の通りでした。

before

// 描画処理の実装部分を抜粋
bool DXGraphicAPI::CDxGraphic::CreateDeviceAndSwapChain(int w, int h)
{
    DXGI_SWAP_CHAIN_DESC desc = {
        { static_cast<UINT>(w), static_cast<UINT>(h),{ 60, 1 },
        swapchainformat, DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED, DXGI_MODE_SCALING_UNSPECIFIED },
        sampledesc,
        DXGI_USAGE_RENDER_TARGET_OUTPUT | DXGI_USAGE_SHADER_INPUT,
        swapchaincount,
        m_WindowHandle,
        TRUE,
        DXGI_SWAP_EFFECT_DISCARD,
        DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH
    };

    if (FAILED(D3D11CreateDeviceAndSwapChain(
        nullptr,
        D3D_DRIVER_TYPE_HARDWARE,
        0,
        0,
        nullptr,
        0,
        D3D11_SDK_VERSION,
        &desc,
        &swapchain.p,
        &device.p,
        &featurelevel,
        &context)))
        return false;

    return true;
}

/* ========== (中略) ========== */

bool DXGraphicAPI::CDxGraphic::CreateStencilBuffer(int w, int h)
{

    D3D11_TEXTURE2D_DESC texdesc =
    {
        static_cast<UINT>(w), static_cast<UINT>(h), 1, 1,
        DXGI_FORMAT_R24G8_TYPELESS, sampledesc, D3D11_USAGE_DEFAULT, D3D11_BIND_DEPTH_STENCIL | D3D11_BIND_SHADER_RESOURCE
    };

    if (FAILED(device->CreateTexture2D(&texdesc, nullptr, &depthtex))) return false;


    D3D11_DEPTH_STENCIL_VIEW_DESC dsvdesc =
    {
        DXGI_FORMAT_D24_UNORM_S8_UINT, D3D11_DSV_DIMENSION_TEXTURE2D
    };

    if (FAILED(device->CreateDepthStencilView(depthtex, &dsvdesc, &dsv))) return false;

    return true;
}

bool DXGraphicAPI::CDxGraphic::CreateShaderFromCompiledFiles()
{
    auto WideStr2MultiByte = [](const std::wstring& wstr) -> std::string
    {
        size_t size = ::WideCharToMultiByte(CP_OEMCP, 0, wstr.c_str(), -1, nullptr, 0, nullptr, nullptr);
        std::vector<char> buf;
        buf.resize(size);
        ::WideCharToMultiByte(CP_OEMCP, 0, wstr.c_str(), -1, &buf.front(), static_cast<int>(size), nullptr, nullptr);
        std::string ret(&buf.front(), buf.size() - 1);
        return ret;
    };

    std::wstring filepath;
    filepath.resize(MAX_PATH);
    ::GetModuleFileName(nullptr, &filepath.front(), MAX_PATH);
    ::PathRemoveFileSpec(&filepath.front());

    // vertex shader
    std::string csofile = WideStr2MultiByte(filepath);
    csofile.append("\\VertexShader.cso");

/* ========== (以下略) ========== */

これを多少書き直してみました。コード全体はGitHubのリポジトリにあります。

開発環境

  • Windows 10 Pro バージョン 20H2
  • Visual Studio 2019 Preview (Version 16.9.0 Preview 2.0)
  • Visual C++ ビルドツール v142

Visual Studio の設定

図のようにプロジェクトのプロパティページで最新のC++言語標準機能を有効にする必要があります(/std:c++latest)。

propertystdcpp.png

MSVCのC++ 実装状況に関しては下記のサイトが参考になると思います。
- MS公式ページ
- 各コンパイラの実装状況の比較表

std::string_view (std::wstring_view)(C++17) の利用 (追記あり)

注意! コメント欄で指摘があった通り、当初書いたコードは危険な箇所があったので修正いたしました。修正後のコードの方をご覧ください(※冗長な箇所もあったのでそこも書き直しました)。

before
    auto WideStr2MultiByte = [](const std::wstring& wstr) -> std::string
    {
        size_t size = ::WideCharToMultiByte(CP_OEMCP, 0, wstr.c_str(), -1, nullptr, 0, nullptr, nullptr);
        std::vector<char> buf;
        buf.resize(size);
        ::WideCharToMultiByte(CP_OEMCP, 0, wstr.c_str(), -1, &buf.front(), static_cast<int>(size), nullptr, nullptr);
        std::string ret(&buf.front(), buf.size() - 1);
        return ret;
    };
    std::wstring filepath;
    ...
    std::string csofile = WideStr2MultiByte(filepath);
    ...
after(修正前)
    // 注意!ログとして残しています。参考にしないでください。
    // std::wstring_view で文字列の先頭ポインタと長さだけを渡す
    auto WideStr2MultiByte = [](std::wstring_view wstr) -> std::string
    {
        // wstr.data() は C文字列ではないため第4引数を-1とするのは危険(ヌル終端があると仮定してサイズを計算してしまう)
        size_t size = ::WideCharToMultiByte(CP_OEMCP, 0, wstr.data(), -1, nullptr, 0, nullptr, nullptr);
        std::vector<char> buf;
        buf.resize(size);
        ::WideCharToMultiByte(CP_OEMCP, 0, wstr.data(), -1, &buf.front(), static_cast<int>(size), nullptr, nullptr);
        std::string ret(&buf.front(), buf.size() - 1);
        return ret;
    };
    std::wstring filepath;
    ...
    std::string csofile = WideStr2MultiByte(filepath);
    ...
after(修正後)
    // std::wstring_view で文字列の先頭のポインタと長さだけを渡す
    auto WideStr2MultiByte = [](std::wstring_view wstr) -> std::string
    {
        // wstr.data() はC文字列ではない(ヌル終端されていない)ので、第4引数には文字数を渡す
        size_t size = ::WideCharToMultiByte(CP_OEMCP, 0, wstr.data(), wstr.size(), nullptr, 0, nullptr, nullptr);
        std::string ret(size, char());
        ::WideCharToMultiByte(CP_OEMCP, 0, wstr.data(), wstr.size(), ret.data(), static_cast<int>(size), nullptr, nullptr);
        return ret;
    };

    std::wstring filepath;
    filepath.resize(MAX_PATH);
    ::GetModuleFileName(nullptr, filepath.data(), MAX_PATH);
    ::PathRemoveFileSpec(filepath.data());
    // ワイド文字列の長さ(終端のヌルを含まない文字数)にリサイズする
    filepath.resize(wcslen(filepath.data()));

(ワイド文字列を扱っているのでコードでは std::wstring_view を使っていますが、以降の文章では便宜上 std::string_view を用います)

std::string_view は既に作成した文字列を参照するためのクラスです。文字列の先頭のポインタと長さのみを管理するので読み取り専用の変数として関数の引数に利用することができます。
また、const char* , std::string の最小限の共通インターフェイスとしても利用できます。今回はあまり用途がありませんでしたが、例えば、ある関数へ const char* を渡したい場合と const std::string& を渡したい場合の両方があるときに、引数を std::string_view とすることで別途オーバーロード関数を用意する手間も省けるうえに実行時コストも抑えられて便利です。

指示付き初期化 (C++20) の利用

before
    D3D11_RASTERIZER_DESC desc =
    {
        D3D11_FILL_SOLID, D3D11_CULL_NONE, TRUE, 0, 0.0f, 0.0f,
        TRUE, FALSE, FALSE, FALSE
    };
after
    D3D11_RASTERIZER_DESC desc =
    {
        .FillMode = D3D11_FILL_SOLID,
        .CullMode = D3D11_CULL_BACK,
        .FrontCounterClockwise = TRUE,
        .DepthBias = 0,
        .DepthBiasClamp = 0.0f,
        .SlopeScaledDepthBias = 0.0f,
        .DepthClipEnable = TRUE,
        .ScissorEnable = FALSE,
        .MultisampleEnable = FALSE, 
        .AntialiasedLineEnable = FALSE
    };

構造体を従来の様に初期化すると、どのメンバ変数にどの値を入れたのか分かりづらくなり、ミスの原因にもなりやすいです。C++20からは集成体の初期化として、メンバ変数名を指定した初期化ができるようになりました。これでメンバ変数が多い構造体の初期化でも上記の様に読みやすくできます。個人的には C# の書き方に似ているので気に入っています。

std::numbers (C++20)の利用

before
// 数学定数を使うためにはフラグを立てたのちに cmath をインクルードする必要がある
#define _USE_MATH_DEFINES
#include <cmath>
...

    d3dprojmatrix = DirectX::XMMatrixPerspectiveFovRH(static_cast<float>(M_PI / 4.0f), 1.0f * w / h, nearz, farz);
...
after
#include <numbers>
...
    d3dprojmatrix = DirectX::XMMatrixPerspectiveFovRH(std::numbers::pi_v<float> / 4.0f, 1.0f * w / h, nearz, farz);
...

従来は非標準の機能として提供されている cmath のマクロを利用するしかありませんでした(自作という手も無きにしも非ずですが...)。しかもこの cmath は VC++ ではフラグを立てる必要があり、インクルードする順番によってはコンパイルエラーが生じる等、思わぬ落とし穴があります。
C++20からついに標準機能として数学定数が提供され、そのような煩わしさからは解放されるようになりました。また、変数テンプレートとして提供されているので、上記の様に float 型として利用したい場合もキャストを書く必要がなく便利です。

おまけ: モジュール (C++20)

Visual Studio 上では、ソリューションエクスプローラーでプロジェクトを右クリックし、
[追加]->[モジュール]を選ぶことでモジュール用のソースファイル(拡張子はixx)を新規作成できます(下図)。

addmodule.png

ソースコードはこちらのリポジトリに置きました(先程とは別のリポジトリです)。
export module xxx (xxx はモジュール名)の様にモジュールを宣言し、
名前空間やクラス等の前に export キーワードを付けることで、それらを外部で呼び出すことができます。呼び出したい場合は予め import xxx の様にモジュールのインポート宣言をすることで export されている名前空間やクラス等を呼び出すことができます。

exportside
// グローバルモジュールフラグメントの宣言
module;
// ここでヘッダーファイルをインクルードする
#include <vector>
#include <fstream>
/* ========== (中略)  ========= */

// ここまでがグローバルモジュールに属する
// モジュールインターフェイスの宣言
export module dxgraphic;

export namespace DXGraphic
{
    class CDXGraphic
    {
/* ========== (以下略)  ========= */
importside
// モジュールのインポート宣言
import dxgraphic;
// モジュール内でエクスポートされている名前空間・クラスを使用できる
DXGraphic::CDXGraphic g_dxgraphic;

モジュール dxgraphic を宣言した行以降でヘッダーファイルをインクルードした場合、コンパイルエラーが発生します。モジュール内ではODR(翻訳単位において複数の定義を持つことができないという原則)の例外が適用されず、定義が衝突してしまうことが原因のようです。グローバルモジュールではODRの例外が適用されるので、上記の様にグローバルモジュールフラグメントを宣言し、その中でヘッダーファイルをインクルードします。

書き直せなかった箇所

Win32API関数の GetModuleFileName で実行ファイルの完全パスを取得している箇所を std::filesystem で書き換えられるか調べてみましたが、C++20までの仕様においては実行ファイルのパスを取得する標準機能は提供されていないようです。

感想

  • Designed Initialization は積極的に利用したい
  • グラフィックス API を叩く時は float を用いることが多いのでテンプレートとして数学定数が提供されるようになったのはありがたい
  • モジュールに関しては、今回のように一つのファイルで宣言も実装も全部乗せした場合なら問題ないが、宣言と実装でファイルを分ける場合や複数のモジュールを作成する場合は制御が難しそう
  • 日ごろ書いているプログラムをもとに新機能を試した方が hoge とか huga とか書くより練習になる(人に伝わるかどうかは別だが)
  • カッコつけにはならなかった

参考文献

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3