catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

Hololensで3Dモデルをレンダリングする[C++/UWP]

HoloLens実機で入出力回りをいじって色々と体感しようと試行錯誤です。かたりぃなです。 2017/02/06 追記:HoloLensからファイルピッカーを開くときの注意事項

この記事の意義

まずHoloLensを使ってプログラミングをしていく前にDirectXC++/cxを使う意義について整理したいと思います。

まず一番の目的は将来私自身が振り返った時、どこまで試したかを明確化しておいたほうが効率的だというのがあります。

次に現時点ではhololensでDirectXを扱った情報原が非常に少ないということです。 DirectX11でのWindowsプログラミングの情報は書籍でもWebでも充実していて、Windows上で3Dプログラミングをやりたいって人はそれで充分だと思います。 しかしC++/cxでのDirectX開発(UWP)となってくると情報が非常に少なくて、厳しいです。 というわけで、もし今後C++/cxでDirectX使ってHoloLensアプリを作りたいという人がいたとき、私の試行錯誤の記録が何かの参考になればと思っています。

では開発に入ります。 今回も開発ツールはVisual Studio 2015です。

2017RCが出ているので試してはみたいのですが、NuGetに2017対応のBoostが入ってないように見えたのでしばらく放置します。

言語はC++/cxです。

ジェスチャーを受け取る

Visual Studio2015でHoloLens用DirectXプロジェクトを生成すると、空間ジェスチャーの入力ハンドラが生成されます。 これは普通のUWPとかでは生成されないものです。実装はContent/SpatialInputHandler(.cpp/.h)です。 このクラス自身はジェスチャーをラッチするという単純な実装です。 具体的には

  • ジェスチャーを受け取って状態を保持する
  • 状態を取り出されたらクリアする

ラッチしたジェスチャーを取り出すのはアプリのMain(プロジェクト名にMain.cpp/Main.hサフィックスをつけたファイル)でやっていました。 ここのupdate関数(フレームごとに入力拾って立方体回してレンダリングする関数)でこんなのやってました。

#ifdef DRAW_SAMPLE_CONTENT
    // Check for new input state since the last frame.

    SpatialInteractionSourceState^ pointerState = m_spatialInputHandler->CheckForInput();
    if (pointerState != nullptr)
    {
        // When a Pressed gesture is detected, the sample hologram will be repositioned
        // two meters in front of the user.
        m_spinningCubeRenderer->PositionHologram(
            pointerState->TryGetPointerPose(currentCoordinateSystem)
            );
    }
#endif

単純に入力があったら、その視線の先に立方体を移動してあげるみたいです。 HoloLensエミュレータDirectXプロジェクトテンプレートをビルドしたものを放り込んで右クリックしたときに立方体がついてきていたのはこのルーチンがあったからなんですね。 今すぐにはジェスチャーによって何かするみたいな処理はやらない(というかやり方がよくわからない)ので、とりあえずここを間借りすることにしましょう。

UWPアプリでファイルを開く

3Dモデルのロード実験の方法としてはいくつか考えられます。

  • アプリにアセットとして組み込んでしまう
  • 都度ユーザーに選択してもらう

実験だけならどちらでもいい気がしますが、表示する3Dモデルの切り替えくらいできたほうが楽しいと思うので、後者でいきます。 前者も必要に迫られたらやると思います。 とりあえずユーザーがエアタップしたら先のコードが走ることはわかったので、書き換えます。

    SpatialInteractionSourceState^ pointerState = m_spatialInputHandler->CheckForInput();
    if (pointerState != nullptr)
    {
        auto openPicker = ref new FileOpenPicker();

        std::wstring cpath;
        openPicker->ViewMode = PickerViewMode::List;

        // ピクチャーライブラリーが起動時の位置
        // その他候補はPickerLocationIdを参照
        // http://msdn.microsoft.com/en-us/library/windows/apps/windows.storage.pickers.pickerlocationid
        openPicker->SuggestedStartLocation = PickerLocationId::PicturesLibrary;
        openPicker->FileTypeFilter->Append(".pmx");

        create_task(openPicker->PickSingleFileAsync()).then([&cpath](StorageFile^ file) {
            // 選択されたファイルに対してごにょごにょする
        });
    }

ちなみにこのコードを実行しようとすると、OneDrive入れてねと表示されるので、ストアから入れておきます。 理由は謎ですが今は目的のファイルさえ読めれば何でもいいです。 それにOneDrive経由のほうがテスト用のデータを投げ込むのラクになるので。 ファイルピッカーについての注意はFAQに載っていました。 同様の質問はStackOverFlowで出ていました(C#の場合)。 c# - Filepicker for Hololens: List available filepickers? - Stack Overflow

要約すると

  • HoloLensのファイルピッカーは「最初にインストールしたファイルストレージを扱うためのアプリ」を使って実現されている
  • ファイルピッカーとして使うアプリの切り替えはできない
  • つまり後から別の使いやすいエクスプローラを入れても、それを使うように切り替えたりはできない
  • OneDriveお勧め

だそうです。 今はこういう仕様とのことです。まあ開発者版だから仕方ないですね。

ファイルから読む

UWPでのファイル読み込みはMSが公開しているサンプルから適当なものを拝借しました。

https://github.com/Microsoft/Windows-universal-samples.git

さっきのファイルピッカーで選択されたファイルを全部バッファに読み込むまでのコードです。

     create_task(openPicker->PickSingleFileAsync()).then([this](StorageFile^ file) {
            this->path = file->Path;
            return FileIO::ReadBufferAsync(file);
        }).then([this](task<IBuffer^> task) {
            auto buffer = task.get();
            auto reader = DataReader::FromBuffer(buffer);
            auto length = buffer->Length;
            auto loaded_buffer = reader->ReadBuffer(length);
            parse_(this->path->Data(), loaded_buffer);
        });

ファイルのパスをthisに保持しているのはデバッグ用です。 また今回は魅力的なモデルが多数公開されているMMDを使うのですが、どうもテクスチャのイメージファイルのパスが相対パスで記述されているものもあるみたいなので、いったんここでとっておくようにしています。 ちなみにここで拾ったpathをC/C++の標準関数で開けないかと試してみましたが、「アクセス権がない」といわれて開かせてくれません。 OneDrive経由のファイルだからなのか、このあたりはよくわかりません。

boostをインストール

Nuget経由でboostをインストールします。わざわざビルドしなくて済むのいいですね。 boostは簡易な機能であればヘッダだけで使えるのですが、今後のことも考えてライブラリまとめて取り込んでおきます。 スクリーンショットで選択されているものをインストールすれば依存関係の解決でヘッダもついてきます。 boostを使う理由は後述。 f:id:Catalina1344:20170205130208p:plain

どうしてboost?

ライブラリのインターフェースとして「ストリームやファイルから読み込んで処理する」みたいなのが多々あります。 今回使おうとしているパーサなどもこういったインターフェースです。 ファイルの読み取りはUWPの機能を使って実装したので、あとはインターフェースの橋渡しをしてあげれば話は簡単になります。 (ライブラリ側には手を入れなくていいし、ファイルアクセスはUWPの枠組みで行える) この橋渡しをするために、basic_ivectorstreamというクラスを使うことにしました。 このクラスを使えばc++コンテナをistreamとして扱えるようになります。便利ですね。 というわけで、プログラムの構造はこうなりました。

  1. UWPのランタイムで提供される機能を使ってファイルの内容を読み取る
  2. UWPのバッファのアドレスをCOM経由で取り出す
  3. std::vectorとしてバッファを準備して、そこにデータをコピー
  4. Boost::basic_ivectorstreamでstd::vectorをラップする
  5. ラップしたデータをistreamとしてライブラリに渡す(ここではMMDパーサ)

すごく回りくどいことになりましたが、いったん何かを作りたいのでtodoコメントだけ入れておきます。 ちなみにComPtrを使うにはwrl/client.hが必要です。

 // IBufferオブジェクトからC++標準のistreamを作りたい
    Object^ obj = buffer;
    Microsoft::WRL::ComPtr<IInspectable> insp(reinterpret_cast<IInspectable*>(obj));
    Microsoft::WRL::ComPtr<IBufferByteAccess> bufferByteAccess;
    DX::ThrowIfFailed(insp.As(&bufferByteAccess));
    byte* pmx_raw_data_ptr = nullptr;
    DX::ThrowIfFailed(bufferByteAccess->Buffer(&pmx_raw_data_ptr));

    // COMインターフェイス経由で得たアドレスを使ってvectorを生成したい
    // いい方法が思いつかないのでコピーで済ませる : todo改善ポイント
    std::vector<char>    pmx_raw_data;
    pmx_raw_data.resize(buffer->Length);
    CopyMemory(&pmx_raw_data[0], pmx_raw_data_ptr, buffer->Length);

    boost::interprocess::basic_ivectorstream<std::vector<char> >  input_vector_stream(pmx_raw_data);

これで、以降はinput_vector_streamをistreamとして読めるようになります。 中身にはもちろんファイルから読み込んだデータが入っているので、istreamを扱うパーサライブラリさんとboostさんが連携して適切に処理してくれます。 改善ポイントは多々ありますが、それは追々やっていきます。 mmdのファイル中にテクスチャ画像ファイルへの参照パスが含まれていますが、今はおいておきます。

DirectXを叩く準備をする

やっと最低限の下地が整いました。 DirectX11に対してパースした3Dモデルのデータを投げ込んでいきます。 VisualStudioテンプレートで生成したコードではSpinningCubeRendererクラスがDirectXでのレンダリングを担当しています。 #if DRAW_SAMPLE_CONTENTで切り替えられているので、レンダリングするコンテンツにあわせた最適なクラスを作れという意思表示でしょう。 しかし今回は「どういった感じなのか」という感触をつかみつつ、何かをレンダリングするのが目的なので、このクラスを直接変更していくことにします。

DirectXで3Dダリングするために必要なもの

初期化周りはややこしいので放置します。幸いなことに別クラスで面倒見てくれていますし。 CreateDeviceDependentResourcesに記述されているものを順に整理します。

  1. プログラマブルシェーダ
  2. シェーダーへの入力レイアウト
  3. 3Dモデルの姿勢を表す変換行列
  4. 立方体の頂点情報(xyz座標,rgbカラー)
  5. 頂点情報からポリゴンを作るためのインデックス指標

mmdファイルをパースして取り出した情報を設定する対象は4と5です。 生ポリゴンをレンダリングするだけならこれで充分です。

できればテクスチャ貼りたいところですが、VisualStudioのDirectXテンプレートではシェーダーが受け取る頂点フォーマットにテクスチャ座標が含まれていないうえ、そもそもテクスチャサンプラとかの初期化も入っていないので、大工事になってしまいます。

年度末の道路工事をやるとしても部分的にやっていったほうが全面通行止め箇所も少なくて通りやすい=安心できるので、テクスチャはシェーダー含めて今後やっていきたいと思います。

ファイルから頂点とインデックスを取り出す

作業を細分化して話は簡単になりました。3DオブジェクトのジオメトリのみDirectXに与えればいいわけですね。 やってみましょう。

まずファイルから読み込んだ頂点とインデックスを適当にクラスメンバに保存しておきます。変数名がなんかアレですがMSのサンプルの名前付け規則に合わせました。

型ですがDirectXテンプレートのデフォルトの頂点シェーダに合わせるようにしています。 頂点型がfloatなのは良いとしても、インデックス型がunsinged shortって不安ですね。 しかし幸いなことに手元の3Dモデルではindexの上限が65535(=0xFFFF)なので助かりました。(古いフォーマットからコンバートされたものなのだろうか。。。)

// SpinningCubeRenderer.h
namespace HolographicApp1
{
    // This sample renderer instantiates a basic rendering pipeline.
    class SpinningCubeRenderer
    {
    private:
        std::vector<float>           m_vertics;
        std::vector<unsigned short> m_indics;
// SpinningCubeRenderer.cpp
    const int elements_of_vertex = 6;
    auto vertex_num = reader.total_vertex_num();
    m_vertics.resize(vertex_num * elements_of_vertex);

    float scale = 0.01f;
    for (size_t i = 0; i < reader.total_vertex_num(); i++) {
        auto r = reader.get_vertex(i);
        m_vertics[i*elements_of_vertex + 0] = r.position[0] * scale;
        m_vertics[i*elements_of_vertex + 1] = r.position[1] * scale;
        m_vertics[i*elements_of_vertex + 2] = r.position[2] * scale;

        m_vertics[i*elements_of_vertex + 3] = 1.0f;
        m_vertics[i*elements_of_vertex + 4] = 1.0f;
        m_vertics[i*elements_of_vertex + 5] = 1.0f;
    }

まずscaleについてですが、そのままのスケールで表示してしまうと画面(実機では視界)からはみ出す巨人が出てきてしまいました。 xyz全体にとりあえず適当なスケールかけておきます。

次にフォーマットです。 このデータ列の塊をDirectX経由でGPUに転送するわけですから、頂点シェーダーの読み取るフォーマットに合わせておかないと大変です。 VisualStudioが生成したDirectXのテンプレートに含まれているシェーダー入力では頂点フォーマットはxyz,rgbの6要素からなります。 xyzにファイルから取り出した頂点の座標を入れていきます。カラー情報今は無視したいので1.0でも設定しておきます。 0.0にしてしまうと何も表示されないことになってしまいます。 これはHoloLensが光を投影するという原理であるための制約ですね。たぶん。(黒い光は出せませんよね。。。) PC向けUWPアプリなら背景色を黒以外に設定しておけば0.0でも見えるとは思います。

次にインデックス

 auto index_num = reader.total_indics_num();
    m_indics.resize(index_num*3);

    auto face_num = reader.total_face_num();
    for (size_t i = 0;i < face_num; i++) {
        auto face = reader.get_face(i);
        auto* face_buf = &m_indics[i * 3];
        face_buf[0] = face[0];
        face_buf[1] = face[1];
        face_buf[2] = face[2];
    }

今回使用したファイルパーサのインデックスの出力は「3つのインデックスで1つの三角形ポリゴンを表現する(TRIANGLE_LIST)」ので、こうなります。 どちらの例も本来なら適当な構造体を定義してそれの配列とするべきでしょうけれど、まあこれで一旦やってみましょう。 構造体作るとアライメント考えないといけませんし。

つくった頂点とインデックス情報をDirectXのバッファに書き込みます。いわゆるGPUのVRAMに転送ですね。 まず頂点バッファ

     D3D11_SUBRESOURCE_DATA vertexBufferData = { 0 };
        vertexBufferData.pSysMem = &this->m_vertics[0];
        vertexBufferData.SysMemPitch = 0;
        vertexBufferData.SysMemSlicePitch = 0;
        const CD3D11_BUFFER_DESC vertexBufferDesc(sizeof(float) * this->m_vertics.size(), D3D11_BIND_VERTEX_BUFFER);
        DX::ThrowIfFailed(
            m_deviceResources->GetD3DDevice()->CreateBuffer(
                &vertexBufferDesc,
                &vertexBufferData,
                &m_vertexBuffer
            )
        );

次にインデックスバッファ

     m_indexCount = m_indics.size();

        D3D11_SUBRESOURCE_DATA indexBufferData = { 0 };
        indexBufferData.pSysMem = &m_indics[0];
        indexBufferData.SysMemPitch = 0;
        indexBufferData.SysMemSlicePitch = 0;
        CD3D11_BUFFER_DESC indexBufferDesc(sizeof(unsigned short) * m_indics.size(), D3D11_BIND_INDEX_BUFFER);
        DX::ThrowIfFailed(
            m_deviceResources->GetD3DDevice()->CreateBuffer(
                &indexBufferDesc,
                &indexBufferData,
                &m_indexBuffer
            )
        );

m_indexCountはクラスのメンバ変数として最初から定義されていたのですが、実際のレンダリング時にこれを使っていました。

    // Draw the objects.
    context->DrawIndexedInstanced(
        m_indexCount,   // Index count per instance.
        2,              // Instance count.
        0,              // Start index location.
        0,              // Base vertex location.
        0               // Start instance location.
        );

というわけでこれで生ポリゴンを出す準備は整いました。

動かしてみる

最低限のポリゴンレンダリングのコードを書いたので動かしてみました。 実機で動かしても同様の結果が得られました。

実機で動画とってアップしようかと思いましたが、机の上どころか部屋が散らかりすぎていてアップロードするのはキツイです。 エミュレータスクリーンショットです。

f:id:Catalina1344:20170205185659p:plain

シルエットはきちんと表示されていますね。まずはOKですね。

課題

実機で動かしてみて、とりあえず机の上に置いてみました。フィギュアみたいです。 VRもそうですがとりあえずお約束としてまずは覗き込みますよね。みえ。 見えたけど、内側から見るとスカートが見えなくなってしまいました。どうやらこのモデルは外側からしかポリゴン貼ってないようです。 AR/VRでアプリを作るときはカリングモードの設定とモデルの作り方に要注意ですね。

また、Hololensのデモやアプリの実装を見ていると、現状のソフトウェアではまだまだ機能が足りないなと思う部分があります。 たとえば表示されているホログラムと実世界のつながり。

Hololensは現実世界の空間マッピングを行ってマッピング情報をもとに適切な位置にホログラムを表示できるのが基本です。 いろいろ触ってみて感じたのは「表示されているホログラムを掴みたい」です。 ホログラムを掴むためには表示されているホログラムの情報を単純に空間マッピングに書き戻せばできそうですが、それってHololensのマシンスペックで解決できるレベルなのかどうか少々不安です。 「掴む」ことができないならマーカー型ARみたくマーカー置けば似たようなことはできそうな気はします。

今後の展望

週末2日間でそれっぽいところまでできました。 あとはテクスチャを張ってモーション再生してあげれば楽しそうです。(やっとUnity組に追いつける!)

いくつか気になる点があるので次はこのあたりから手を付ける予定です。 1. MMDパーサがテクスチャ読み込もうとしてエラー吐いている 2. モデルの配置位置が固定になってしまう

まず1。今の仕組みだとエラー吐いて当然だと思います。OneDriveのファイルをダウンロードしたパスからテクスチャを読もうとするので、ファイルのダウンロードが終わっている保障はどこにもありません。というかダウンロード命令出していません。 ファイル操作まわりをもっとラクに扱えるように整理したいですね。

次に2。今回の実装でジェスチャの機能を殺している(エアタップされたら読み込むモデルを選択する)ので、このあたりも使い方を調べて適切なUIを作りたいですね。