【ゲーム制作】DirectXでmp4動画を再生する方法|Media Foundationでテクスチャに貼る

記事のアイキャッチ。DirectXでmp4動画を再生する方法 Media Foundationでテクスチャに貼る ゲーム制作

今回は、DirectXで作っているゲームの中でmp4動画を再生する方法について解説していきます。オープニングムービーやカットシーン、背景に流す映像などで必要になるやつです。

「DirectXに動画を再生する機能って無いの?」
「ライブラリを入れずに、Windows標準の機能だけでできない?」
「動画をテクスチャとして板ポリに貼りたいんだけど、どうやるの?」

こんな疑問はありませんか?

結論から言うと、DirectX本体に動画再生機能はありません。やることは「Windows標準のMedia Foundationでmp4をデコードして、出てきた1フレームをDirectXのテクスチャに書き込む」だけです。外部ライブラリは不要で、Windowsに最初から入っている機能だけで完結します。

この記事では、全体の流れ・SourceReaderの使い方・出力フォーマット選びという最大の落とし穴・YUY2からBGRAへの変換・フレームレートの合わせ方・私がハマった失敗まで、実際にDirectX11で動かした画像つきで解説します。読み終えると、自分のゲームにムービー再生を組み込めるようになりますので、ぜひ最後まで読んでください。

全体の流れ|「デコード」と「描画」を切り離して考える

難しく見えますが、やることは「動画ファイルを1枚の画像に変換し続ける係」「その画像をテクスチャとして描く係」の2つに分かれるだけです。前者がMedia Foundation、後者がいつものDirectXです。

mp4をDirectXで再生する流れの図。mp4ファイルからIMFSourceReader、IMFSample、CPUでBGRAに変換、ID3D11Texture2D、ShaderResourceViewを経て板ポリに描画されるまでの6段階

ポイントは動画テクスチャが完成してしまえば、あとは普通のテクスチャと何も変わらないことです。板ポリに貼るのも、UIとして画面いっぱいに出すのも、モデルに貼るのも自由にできます。「動画再生」という特別な仕組みを用意する必要はありません。

ステップ①|Media Foundationを起動してmp4を開く

まずMFStartup()でMedia Foundationを初期化し、IMFSourceReaderを作ります。これがmp4を「読める形」にしてくれる本体です。

▼media_player.cpp
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")

hr = MFStartup(MF_VERSION);
if (FAILED(hr)) return false;
m_mfStarted = true;

// ハードウェアデコードを許可する
IMFAttributes* pAttributes = nullptr;
MFCreateAttributes(&pAttributes, 1);
pAttributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE);

hr = MFCreateSourceReaderFromURL(filePath, pAttributes, &m_pReader);
pAttributes->Release();
if (FAILED(hr)) return false;

リンクするlibが3つ必要なのと、MFStartup()を呼んだら終了時に必ずMFShutdown()を呼ぶのを忘れないでください。MF_READWRITE_ENABLE_HARDWARE_TRANSFORMSは「GPUのデコーダーを使ってよい」という許可で、付けておくとCPU負荷がぐっと下がります。

ステップ②|出力フォーマットを決める(ここが最大の落とし穴)

SourceReaderは「どの形でフレームをよこしてほしいか」を指定できます。DirectXのテクスチャはRGBA系なので、いきなりRGB32でもらえたら一番ラクです。そこで希望順に試していきます。

▼media_player.cpp
const GUID formats[] = {
    MFVideoFormat_RGB32,   // 一番ラク(変換不要)
    MFVideoFormat_YUY2,    // 次点
    MFVideoFormat_NV12,    // H.264 が一番素直に出せる形
};

for (int i = 0; i < 3; ++i) {
    pType->SetGUID(MF_MT_SUBTYPE, formats[i]);
    hr = m_pReader->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, pType);
    if (SUCCEEDED(hr)) {
        // 通ったフォーマットを覚えておく
        formatFound = true;
        break;
    }
}

ここで大事なのは、RGB32を頼んでも通るとは限らないことです。実際に私の環境(Windows 11/H.264のmp4)で試したところ、RGB32は拒否され、YUY2で通りました。つまりYUV系からRGBへの変換コードは、書かずに済ませられないと思っておいたほうがいいです。ここを「RGB32で通る前提」で書いてしまうと、他人の環境で真っ黒な画面になります。

フォーマットが決まったら、実際の映像サイズ・フレームレート・stride(1行あたりのバイト数)を取得します。

▼media_player.cpp
UINT32 width = 0, height = 0;
MFGetAttributeSize(pCurrentType, MF_MT_FRAME_SIZE, &width, &height);

// フレームレート(30/1 のような分数で返ってくる)
UINT32 num = 0, den = 0;
MFGetAttributeRatio(pCurrentType, MF_MT_FRAME_RATE, &num, &den);
m_frameDuration = (num > 0 && den > 0) ? (double)den / (double)num : 1.0 / 30.0;

// stride は「負の値」で返ってくることがある=ボトムアップ(下の行が先)
INT32 stride = 0;
hr = pCurrentType->GetUINT32(MF_MT_DEFAULT_STRIDE, (UINT32*)&stride);
m_isBottomUp = (stride < 0);
m_stride     = (stride < 0) ? (UINT)(-stride) : (UINT)stride;

strideが負というのが独特です。これは「メモリ上では画像の下の行から並んでいる(ボトムアップ)」という意味で、DIB由来の慣習です。符号を見て向きを判断し、絶対値を1行のバイト数として使います。

ステップ③|書き換え可能なテクスチャを用意する

動画は毎フレーム中身が入れ替わるので、テクスチャはD3D11_USAGE_DYNAMICD3D11_CPU_ACCESS_WRITEで作ります。これでCPUから毎フレーム書き込めるようになります。

▼media_player.cpp
D3D11_TEXTURE2D_DESC texDesc = {};
texDesc.Width          = m_videoWidth;
texDesc.Height         = m_videoHeight;
texDesc.MipLevels      = 1;              // 動画にミップは不要
texDesc.ArraySize      = 1;
texDesc.Format         = DXGI_FORMAT_B8G8R8A8_UNORM;   // BGRA の並び
texDesc.SampleDesc.Count = 1;
texDesc.Usage          = D3D11_USAGE_DYNAMIC;
texDesc.BindFlags      = D3D11_BIND_SHADER_RESOURCE;
texDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;

device->CreateTexture2D(&texDesc, nullptr, &m_pTexture);
device->CreateShaderResourceView(m_pTexture, nullptr, &m_pSRV);

フォーマットをDXGI_FORMAT_B8G8R8A8_UNORM(RGBAではなくBが先)にしているのがポイントです。Media Foundationが吐くRGB32はメモリ上でBGRA順なので、こちらに合わせておくと並び替えが不要になります。

ステップ④|1フレーム読んでテクスチャに流し込む

ReadSample()で次のフレームを取り、Map()したテクスチャに書き込んでUnmap()する、という流れです。

▼media_player.cpp
DWORD streamIndex = 0, flags = 0;
LONGLONG timestamp = 0;
IMFSample* pSample = nullptr;

HRESULT hr = m_pReader->ReadSample(
    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
    0, &streamIndex, &flags, &timestamp, &pSample);

// 終端はエラーではなくフラグで来る
if (FAILED(hr) || (flags & MF_SOURCE_READERF_ENDOFSTREAM)) {
    m_isFinished = true;
    if (pSample) pSample->Release();
    return false;
}
if (!pSample) return true;   // 中身が無いこともある

// 複数バッファに分かれていることがあるので 1 本にまとめる
IMFMediaBuffer* pBuffer = nullptr;
pSample->ConvertToContiguousBuffer(&pBuffer);

BYTE* pData = nullptr;
DWORD maxLen = 0, curLen = 0;
pBuffer->Lock(&pData, &maxLen, &curLen);

D3D11_MAPPED_SUBRESOURCE mapped;
hr = context->Map(m_pTexture, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
if (SUCCEEDED(hr)) {
    switch (m_outputFormat) {
    case OutputFormat::RGB32: CopyRGB32(pData, mapped); break;
    case OutputFormat::YUY2:  CopyYUY2(pData, mapped);  break;
    case OutputFormat::NV12:  CopyNV12(pData, mapped);  break;
    default: break;
    }
    context->Unmap(m_pTexture, 0);
}

pBuffer->Unlock();
pBuffer->Release();
pSample->Release();

覚えておきたいのは3点です。①動画の終わりはエラーではなくMF_SOURCE_READERF_ENDOFSTREAMフラグで来る②成功してもpSamplenullptrのことがある(デコーダがまだ吐けないとき)、ConvertToContiguousBuffer()で一本にまとめてから触る。②をreturn falseにしてしまうと、再生が始まってすぐ止まります。

YUY2からBGRAへの変換

YUY2は2ピクセルでY0 U Y1 Vの4バイトという並びです。明るさ(Y)はピクセルごとに持ちますが、色(U・V)は隣り合う2ピクセルで共有しています。人間の目は明るさに比べて色の変化に鈍感なので、そこを間引いてデータ量を減らしているわけです。

▼media_player.cpp
for (UINT x = 0; x < m_videoWidth; x += 2) {
    int Y0 = src[x * 2 + 0];
    int U  = src[x * 2 + 1];
    int Y1 = src[x * 2 + 2];
    int V  = src[x * 2 + 3];

    // BT.601 の係数を 256 倍して整数だけで計算する
    int D = U - 128;
    int E = V - 128;
    int bAdd = ( 516 * D + 128) >> 8;
    int gAdd = (-100 * D - 208 * E + 128) >> 8;
    int rAdd = ( 409 * E + 128) >> 8;

    int C0 = (298 * (Y0 - 16)) >> 8;
    int C1 = (298 * (Y1 - 16)) >> 8;

    auto c = [](int v) -> BYTE {
        return (BYTE)(v < 0 ? 0 : (v > 255 ? 255 : v));
    };

    BYTE* p0 = dst + x * 4;
    p0[0] = c(C0 + bAdd);   // B
    p0[1] = c(C0 + gAdd);   // G
    p0[2] = c(C0 + rAdd);   // R
    p0[3] = 0xFF;           // A

    if (x + 1 < m_videoWidth) {
        BYTE* p1 = p0 + 4;
        p1[0] = c(C1 + bAdd);
        p1[1] = c(C1 + gAdd);
        p1[2] = c(C1 + rAdd);
        p1[3] = 0xFF;
    }
}

係数が298409なのは、BT.601の変換式を256倍して整数に丸めたものだからです。最後に>> 8で256で割って戻しています。小数を一切使わず整数だけで回すための定石で、毎フレーム全ピクセルを回すこの手の処理では効いてきます。Y - 16を引いているのは、放送規格のYが0〜255ではなく16〜235の範囲を使うからです。

ステップ⑤|フレームレートを合わせる

ここをさぼると、ゲームが60fpsなのに30fpsの動画が2倍速で再生されることになります。動画側の1フレームの長さを持っておいて、その分だけ時間がたまったら次の1枚をデコードします。

▼media_player.cpp
bool MediaPlayer::Update(double deltaTime) {
    if (!m_isPlaying || m_isFinished) return false;

    m_elapsed += deltaTime;

    // 動画1フレーム分の時間がたまったら次の1枚へ
    if (m_elapsed >= m_frameDuration) {
        m_elapsed -= m_frameDuration;
        // … ReadSample してテクスチャを更新 …
    }
    return true;
}

m_elapsed = 0ではなくm_elapsed -= m_frameDurationと引き算にしているのが大事です。ゼロクリアにすると余った時間が毎回捨てられて、少しずつ再生が遅れていきます。この辺の考え方はFPS固定とデルタタイムの記事と同じです。

実際に動かしてみた

検証用に、ウィンドウとD3D11デバイスを作って全画面の板ポリに貼るだけの最小デモを組みました。流す動画は自分で生成した640×360の30fpsのmp4です(斜めに流れるカラーバーと動く円、下端に進捗バー)。

DirectX11のウィンドウでmp4動画が再生されている画面。斜めのカラーバーと黒い円が描かれた自作テスト動画が全画面の板ポリゴンに貼られている

初期化時に取れた値と、最後まで再生したときのログがこちらです。

negotiated format = YUY2
video size        = 640x360
stride            = 1280
bottomUp          = false
frameDuration     = 0.033333 sec (30.00 fps)
finished after 6.04 sec wall, 843 rendered frames
exit: wall=6.04 rendered=843

6秒の動画が6.04秒で終わっているので、再生速度は合っています。描画側は843フレーム回っていますが、デコードしたのは180枚だけです。描画レートと動画のフレームレートがきちんと切り離せていることが確かめられます。

【重要】私が実装中にハマった体験談

一番意外だったのが、第一希望のRGB32が通らなかったことです。上のログの1行目のとおり、実際に握らされたのはYUY2でした。

これが怖いのは、エラーにならない点です。SetCurrentMediaType()が失敗して次の候補に進むだけなので、もし「RGB32で来るはず」と決めうちでCopyRGB32()だけ書いていたら、YUY2のデータをRGBだと思って読むことになります。画面は真っ黒にはならず、色が緑とピンクに転んだうえ、映像が横半分に潰れて左右に2つ並ぶという独特な壊れ方をします。YUY2は1ピクセル2バイトなのに4バイトとして読むので、1行分のつもりで読んだ範囲が実際には半行しかない、というのが理由です。

教訓は、希望を出したら必ずGetCurrentMediaType()で「結局何になったのか」を読み直し、その値で分岐することでした。この実装でm_outputFormatを覚えておいてswitchしているのは、そのためです。

もう一つ、strideの符号を見落とすと上下逆さまになります。どのくらい分かりやすい壊れ方をするのか見たかったので、m_isBottomUpの判定をわざと反転させて撮ってみました。

動画の向きの比較。左は正しい向きで進捗バーが画面下にあり、右はbottomUpの判定を誤って上下逆さまになり進捗バーが画面上に来ている

下端にあるはずの黄色い進捗バーが、右では上端に来ています。色は正しいのに向きだけおかしいときは、まずMF_MT_DEFAULT_STRIDEの符号を疑ってください。なお今回のテスト動画では、YUY2でstrideが+1280(=幅×2)のトップダウンでした。

よくある失敗例と対処法

①画面が真っ黒・壊れた模様になる

出力フォーマットを取り違えたときの画面。YUY2のデータをRGB32として読んだため、色が緑とピンクに転び、映像が横半分に潰れて左右に2つ並んで表示されている

実際にCopyYUY2()のかわりにCopyRGB32()を呼ぶようにして再現したのがこの画面です。「色がおかしい」だけでなく「横に潰れて2つ並ぶ」のが、フォーマット取り違えのいちばん分かりやすいサインです。

体験談のとおり、想定していないフォーマットで握らされているパターンが多いです。まず実際に何で通ったのかをログに出しましょう。RGB32・YUY2・NV12の3つを実装しておけば、だいたいの環境をカバーできます。

②横にずれて斜めになる

mapped.RowPitchを使わずに「幅×4」で計算しているのが原因です。Map()が返すRowPitchはGPUの都合で幅×4より大きい(余白が入る)ことがあるので、宛先の行頭は必ず(BYTE*)mapped.pData + y * mapped.RowPitchで求めます。同じ理由で、読み元の行頭には幅ではなくstrideを使います。

③再生が早すぎる・遅すぎる

毎フレーム無条件にReadSample()していると、描画fpsの速さで動画が進んでしまいます。必ずm_frameDurationで間引いてください。逆に、重いシーンでdeltaTimeが動画1フレーム分を大きく超えると、今の実装では1回のUpdateでデコードされるのは1枚だけなので少しずつ遅れます。気になるならifwhileにして追いつかせるか、上限を決めてコマ落ちを許容します。

注意点

  • この実装は映像だけ。音声は別ストリームなので、XAudio2などで別途鳴らして同期を取る
  • MFStartup()を呼んだら必ずMFShutdown()。COMオブジェクトのRelease()も忘れない
  • Map()Unmap()の間は短くする。ここで重い処理をするとGPUを待たせる
  • 幅が奇数の動画やサイズが奇数のNV12では、ループの範囲チェックを省くとはみ出す

まとめ

  • DirectXに動画再生機能は無い。Media Foundationでデコードしてテクスチャに貼るだけ
  • テクスチャはD3D11_USAGE_DYNAMIC+BGRAで作り、毎フレームMapで書き換える
  • 出力フォーマットは希望が通るとは限らない。RGB32・YUY2・NV12を用意して実際の値で分岐する
  • strideの符号=上下の向きRowPitch=宛先の行幅。この2つを決め打ちしない
  • 再生速度は動画側のframeDurationで制御する。描画fpsとは切り離す

仕組みが分かってしまえば、やっているのは「毎フレームテクスチャを書き換えている」だけです。テクスチャになってしまえばしめたもので、シェーダーを通せばモノクロにしたり、モデルに貼ってゲーム内のモニターに映したりも自由にできます。まずは短いmp4を1枚の板に出すところから試してみてください。

関連記事