【ゲーム制作】ミップマップとは?|遠くのテクスチャがちらつく原因と仕組み

記事のアイキャッチ。【ゲーム制作】ミップマップとは? 遠くのテクスチャがちらつく原因と仕組み ゲーム制作

今回は、3Dゲームのテクスチャで必ず出てくる「ミップマップ」とは何かについて解説していきます。

「遠くの床がチカチカ・ザラザラして汚いのはなぜ?」
「ミップマップって、ようは画像を小さくしたものを持っておくだけ?」
「フィルタをリニアや異方性にしたのに、見た目が全然変わらないんだけど…」

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

ミップマップは、ひとことで言うと「あらかじめ半分ずつ縮小した画像を用意しておいて、遠くを描くときは小さいほうを使う」仕組みです。たったこれだけで、遠景のちらつき(エイリアシング)がほぼ消えます。

この記事では、ちらつきが起きる理由・ミップチェーンの中身・メモリがどれだけ増えるか・DirectXでの生成コード・ハマりどころまで、実際にDirectXで動かした比較画像つきで解説します。読み終えると、自分のプロジェクトの遠景が汚い原因を一発で切り分けられるようになりますので、ぜひ最後まで読んでください。

まずは見比べてみる|同じ床、違うのはミップマップだけ

百聞は一見にしかずなので、先に結果を見てください。次の画像はまったく同じチェッカー模様の床を左右に並べたものです。モデルも、カメラも、シェーダーも、サンプラーの設定も同じで、違いは「テクスチャにミップマップを作ったかどうか」だけです。

同じチェッカー模様の床を左右に並べた比較。左はミップマップなしで遠景がちらついてノイズ状に崩れ、右はミップマップありで奥に向かって滑らかにぼけていく

左(ミップマップなし)は、中距離から先で模様が崩れて別の縞模様が浮き出ていて、いちばん奥は完全にノイズです。右(ミップマップあり)は、奥に行くほど素直にぼけて、最後は平均的な色に落ち着いています。実際にゲームを動かすと、左はカメラが少し動くだけで模様がザワザワと沸き立つように暴れます。これがいわゆる「テクスチャのちらつき」です。

なぜちらつくのか|1ピクセルにテクセルが何十個も詰まっている

原因は情報量の詰め込みすぎです。手前の床は、テクスチャの1ドット(テクセル)が画面の何ピクセルにも引き伸ばされて描かれます。ここは問題ありません。

ところが遠くの床は逆で、画面のたった1ピクセルの中に、テクスチャのテクセルが何十個・何百個ぶんも収まってしまいます。本来ならその何十個の平均色を出すべきなのに、GPUはそのうち1点だけを拾って色にしています

どの1点が当たるかはカメラの角度でコロコロ変わるので、白いマスを拾ったり黒いマスを拾ったりして、結果があの暴れ方になるわけです。拾い方が雑なのが問題であって、テクスチャが悪いわけではありません。

ミップマップの正体|半分ずつ縮めた画像を全部持っておく

解決策はシンプルで、「何十個の平均を毎回計算するのは重いから、平均を先に計算した画像を用意しておこう」という発想です。これがミップマップです。

元画像を縦横それぞれ半分に縮めた画像を作り、それをさらに半分に…と1×1になるまで繰り返した一式を「ミップチェーン」と呼びます。今回使った512×512のUVチェッカー画像から作ると、こうなります。

512×512のUVチェッカー画像から生成したミップマップの連鎖。L0=512×512からL9=1×1まで半分ずつ縮小した10枚が並んでいる

L0が元画像、L1が256×256、L2が128×128…と続き、L9で1×1になります。縮小するときに2×2の4ピクセルを平均して1ピクセルにしているので、L3あたりの1ドットには元画像の64ピクセルぶんの平均が、すでに焼き込まれている状態です。

あとはGPUが描画時に「このピクセルはどれくらい縮んでいるか」を自動で判定して、ちょうどいいレベルを選んで読むだけです。重い平均計算を、実行時ではなく読み込み時に前払いしていると考えると分かりやすいと思います。

メモリはどれだけ増える?|答えは「約1.33倍」

「画像を何枚も持つなんてメモリがもったいない」と思うかもしれませんが、実はそこまで増えません。縦横を半分にすると面積は4分の1になるので、全体の容量は次のような等比級数になります。

1 + 1/4 + 1/16 + 1/64 + … = 4/3

つまり元画像の約 1.33 倍(+33%)で、ミップチェーン全部が入ります。見た目の改善と、さらには遠景で小さい画像を読むことによるテクスチャキャッシュの効きやすさを考えれば、+33%は十分に安い買い物です。遠い物を描くときに毎回フルサイズの画像をなめて回るより、小さい画像を読むほうが速いのです。

DirectXでの実装|DirectXTexでミップチェーンを作る

自作のDirectX11サンプルでは、画像の読み込みにDirectXTexを使っています。元々のコードは読んだ画像をそのままビューにしているだけだったので、間にGenerateMipMaps()を挿すだけでミップマップ対応になります。

▼texture.cpp
TexMetadata  metadata;
ScratchImage image;
LoadFromWICFile(filename.c_str(), WIC_FLAGS_NONE, &metadata, image);

if (generate_mipmap && metadata.mipLevels <= 1) {
    ScratchImage mipChain;

    // 第4引数の 0 は「1x1になるまで全レベル作れ」の意味
    if (SUCCEEDED(GenerateMipMaps(
            image.GetImages(), image.GetImageCount(), metadata,
            TEX_FILTER_DEFAULT, 0, mipChain))) {
        image    = std::move(mipChain);
        metadata = image.GetMetadata();   // mipLevels が 1 → 10 に増える
    }
}

// 全レベルを渡してSRVを作る
CreateShaderResourceView(device,
    image.GetImages(), image.GetImageCount(), metadata, &srv);

ポイントはGetImageCount()です。ミップなしならここが1、ミップありなら512×512の場合は10になります。SRVに何枚渡したかがそのまま「ミップがあるかどうか」になるので、迷ったらこの値を見るのが一番確実です。

そして読む側(サンプラー)も、ミップを使う設定になっている必要があります。

▼renderer.cpp
D3D11_SAMPLER_DESC samplerDesc;
ZeroMemory(&samplerDesc, sizeof(samplerDesc));

// MIP が入っているフィルタを選ぶ(異方性はMIP込み)
samplerDesc.Filter        = D3D11_FILTER_ANISOTROPIC;
samplerDesc.MaxAnisotropy = 16;

samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;

samplerDesc.MipLODBias = 0;                      // +でぼかし、−でシャープに
samplerDesc.MinLOD     = 0;
samplerDesc.MaxLOD     = D3D11_FLOAT32_MAX;      // 0 にするとミップ禁止と同じ

device->CreateSamplerState(&samplerDesc, &samplerState);

D3D11_FILTER_MIN_MAG_MIP_LINEARD3D11_FILTER_ANISOTROPICのように名前に「MIP」が入っているフィルタを使います。またMaxLOD0にしてしまうと、せっかく作ったミップを使わずにL0だけを見るので、ミップなしと同じ結果になります。

中距離の部分を拡大してみると、何が起きているかがはっきり分かります。

中距離部分の拡大比較。左はモアレとジャギーで模様が壊れ、右は縞が整然と残ったまま滑らかに減衰している

左はマス目が残ったり消えたりして元の模様とは別の模様(モアレ)になってしまっています。右は縞の並びが崩れずに残ったまま、奇麗に滑らかになっています。

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

自主制作のDirectXサンプルでこの比較を作ろうとしたとき、最初にぶつかったのが「サンプラーはちゃんと異方性フィルタにしてあるのに、遠景がちらつく」という状態でした。

コードを見るとD3D11_FILTER_ANISOTROPICMaxAnisotropy = 16MaxLOD = D3D11_FLOAT32_MAXと、教科書どおりの設定になっています。なのに効いていない。

原因は読み込み側でミップマップを一度も作っていなかったことでした。LoadFromWICFile()でPNGを読んでそのままCreateShaderResourceView()に渡していたので、テクスチャのmipLevelsはずっと1。つまりGPUは「ミップを使え」と言われても、使えるミップが1枚しかないので常にL0を読むしかなかったということです。

ミップマップは「作る側」と「読む側」の両方が揃って初めて効くので、サンプラーの設定だけ見て「やってあるはず」と判断しないこと。今は読み込み直後にGetImageCount()を確認するようにしています。

もう一つ、この作例のために同じPNGを「ミップあり」と「ミップなし」の2通りで読み分けようとしたときに、別のバグが見つかりました。テクスチャ管理側に「同じファイル名なら再利用する」キャッシュ処理があったのに、登録時にファイル名を保存し忘れていたのです。比較対象が空文字列なのでヒットするはずがなく、同じ画像を何回でも二重に読み込んでいました。動作していたので今まで気づかなかっただけで、「キャッシュが効いていない」タイプのバグはエラーにならないので地味に恐いなと思いました。

ミップマップのよくある失敗例と対処法

①フィルタを変えても見た目が変わらない

体験談のとおり、そもそもミップが生成されていないパターンが圧倒的に多いです。読み込み直後のGetImageCount()metadata.mipLevels1になっていないか確認しましょう。サンプラー側のMaxLOD0になっているケースも同じ結果になります。

②遠景がぼやけすぎてのっぺりする

床のように浅い角度で見る面で起きやすい現象です。普通の(等方性の)ミップ選択は「縦と横の縮み具合の大きいほう」に合わせてしまうので、実際より小さいレベルを選んでボケます。対策は異方性フィルタリング(D3D11_FILTER_ANISOTROPICを使うこと。今回の右側の画像が奇麗に見えているのも、これのおかげです。微調整したいときはMipLODBiasを少しマイナスにするとシャープ寄りになります(やりすぎるとちらつきが戻ってくるのでほどほどに)。

③レンダーターゲットに描いた絵だけミップがない

ファイルから読んだテクスチャと違い、自分で描画したレンダーターゲットは描くたびに中身が変わるので、ミップを自動では作ってくれません。作成時にMipLevels = 0D3D11_RESOURCE_MISC_GENERATE_MIPSを指定し、描き終わったあとでGenerateMips()を呼ぶ必要があります。ポストエフェクトでブルームをかけるときなどに必須です。

注意点

  • 作る側と読む側の両方が揃って初めて効く(フィルタ設定だけでは不十分)
  • メモリは約+33%だけ。見た目と速度の見返りに対して十分安い
  • UIやドット絵など等倍で貼るだけの画像にはミップは不要(ボケる原因になる)
  • 浅い角度の床や壁には異方性フィルタリングをセットで使う

まとめ

  • 遠景のちらつきは1ピクセルにテクセルが詰まりすぎて、1点しか拾えていないのが原因
  • ミップマップは平均をあらかじめ計算しておいた縮小画像の一式
  • 容量は1 + 1/4 + 1/16 + … = 4/3約+33%しか増えない
  • DirectXならDirectXTexのGenerateMipMaps()+MIP入りフィルタの2点セット
  • 「効いてない」と思ったらまずmipLevelsが1のままじゃないかを疑う

ミップマップは、仕組みを知ってしまえば「先に縮小版を作っておく」だけのとても素直な技術です。それでいて見た目への効果は今回の画像のとおり圧倒的なので、自分のプロジェクトで遠景が汚いなと感じたら、まずここを疑ってみてください。

関連記事