今回は、DirectXで作っているゲームの中でmp4動画を再生する方法について解説していきます。オープニングムービーやカットシーン、背景に流す映像などで必要になるやつです。
「DirectXに動画を再生する機能って無いの?」
「ライブラリを入れずに、Windows標準の機能だけでできない?」
「動画をテクスチャとして板ポリに貼りたいんだけど、どうやるの?」
こんな疑問はありませんか?
結論から言うと、DirectX本体に動画再生機能はありません。やることは「Windows標準のMedia Foundationでmp4をデコードして、出てきた1フレームをDirectXのテクスチャに書き込む」だけです。外部ライブラリは不要で、Windowsに最初から入っている機能だけで完結します。
この記事では、全体の流れ・SourceReaderの使い方・出力フォーマット選びという最大の落とし穴・YUY2からBGRAへの変換・フレームレートの合わせ方・私がハマった失敗まで、実際にDirectX11で動かした画像つきで解説します。読み終えると、自分のゲームにムービー再生を組み込めるようになりますので、ぜひ最後まで読んでください。
全体の流れ|「デコード」と「描画」を切り離して考える
難しく見えますが、やることは「動画ファイルを1枚の画像に変換し続ける係」と「その画像をテクスチャとして描く係」の2つに分かれるだけです。前者がMedia Foundation、後者がいつものDirectXです。

ポイントは動画テクスチャが完成してしまえば、あとは普通のテクスチャと何も変わらないことです。板ポリに貼るのも、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_DYNAMIC+D3D11_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, ×tamp, &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フラグで来る、②成功してもpSampleがnullptrのことがある(デコーダがまだ吐けないとき)、③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;
}
}係数が298や409なのは、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です(斜めに流れるカラーバーと動く円、下端に進捗バー)。

初期化時に取れた値と、最後まで再生したときのログがこちらです。
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=8436秒の動画が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の判定をわざと反転させて撮ってみました。

下端にあるはずの黄色い進捗バーが、右では上端に来ています。色は正しいのに向きだけおかしいときは、まずMF_MT_DEFAULT_STRIDEの符号を疑ってください。なお今回のテスト動画では、YUY2でstrideが+1280(=幅×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枚だけなので少しずつ遅れます。気になるならifをwhileにして追いつかせるか、上限を決めてコマ落ちを許容します。
注意点
- この実装は映像だけ。音声は別ストリームなので、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枚の板に出すところから試してみてください。

