MipMapを可視化しよう

  • 10
    Like
  • 0
    Comment

 Unreal Engine 4 アドベントカレンダー201613日目担当の@dgtanakaです。
 普段ブログとか書かないんで不慣れですが、頑張って書きます。テーマはMipMapです。

MipMapってなに?

 改めて説明するほどの事でもないですが、MipMapは描画するテクスチャの縮小版テクスチャをあらかじめ用意しておいて、描画するサイズに応じて切り替えることで小さく描画されるテクスチャのクオリティと描画負荷を改善する手法です。大きなテクスチャでも小さく表示されるときは縮小されるので、あらかじめ縮小されたものを何段階か用意して一番近いサイズのものを描画するわけです。
 ハードウェア(GPU)でサポートされているので、描画時に自動的に適応されます。ちょっと勘違いされがちなのは、LOD(Level of Detail)と同様にカメラからの距離で切り替わるのではなく、実際に描画される際の大きさ、具体的には描画バッファに対するUVの密度で切り替わります。
 どのレベルのMipMapテクスチャを使用して描画するかはシェーダーで明示的に指定することも可能なので、別の目的に使用されることもあります。代表的な例ではリフレクション用のキューブマップで、ミップマップレベルごとにラフネスが異なる場合の反射用テクスチャを割り当てて置いて、反射する物体の表面のラフネスの値で使い分けるといったものです。ラフネスが荒いと反射のディテールが失われるのでラフネスが高くなるほど解像度の低いMipMapになってちょうど良かったりします。
 MipMapは元のテクスチャの縦横1/2にしたテクスチャを再帰的に作成します。128x128のテクスチャなら、64x64,32x32,16x16,8x8,4x4,2x2,1x1と、1ドットサイズになるまで縮小していきます。この仕様のため、MipMapは2の累乗サイズのテクスチャにしか適用できません。

UE4でのMipMap

 UE4ではテクスチャ画像をインポートした場合に、自動的にMipMapが作成されます。インポートしたテクスチャアセットをダブルクリックするとTextureEditorが開きますが、そこで色々設定を変更することができます。
 MipMapの有無や縮小の仕方などはMipGenSettingsの設定やTextureGroupの種類で変わります。詳しくはUE4のドキュメントを御覧ください。

MipMapがどうなってるか見えるようにしたい

 深く考えなければMipMapは自動で生成され勝手に反映されています。しかし、ここで疑問が生じます。「実際にはどのレベルのMipMapが描画されているの?」
 この疑問にある程度答えてくれるのが、View画面の設定の"Mesh UV Densities Accuracy"です。View画面の「ライティング」をクリックして"Optimization viewmodes"から選択します。MeshのUVの密度を色分けしてくれるので、なるべく白になるように調整すると良いのですが、具体的にどのMipが表示されているかまではよくわかりません。

BluePrint OfficeのMesh UV Densitiesの表示。地面以外はだいたいダメって言われてますねw
BPOffice02.JPG

UE4を改造してみよう

 もっと直感的にどういうふうにMipMapが選択されているのかを見てみたいと思いました。以前使用していた自作エンジンにはそんな機能があったのですが、UE4にはありません。無いけどどうしても見たいと思い、方法を考えました。
 自作エンジンではPhotoshopで各レベルのMipMapを違う色にしたテクスチャを作成し、MipMapを持ったDDSにエクスポートすることで作成していました。そのテクスチャをゲーム内のアセットのテクスチャと入れ替えて描画すれば何番のMipMapで描画されているかがひと目でわかる仕組み。しかし、UE4では2DのDDSフォーマットのテクスチャをインポートできません(*1)。そこでエンジンを改造し、計算で作成するのではなくインポートした画像から取り込んでMipMapを構築するようにしたいと思います。
MipMap.png
こんな画像からMipMap付きテクスチャを作成します。縦横同じサイズで2の累乗サイズのみ対応とします。

(*1)前述のリフレクション用キューブマップのためかと思われますが、キューブマップのDDSはインポートできます。

改造の手順

 ここからは改造の手順です。エンジンの機能を改造する際のコツを少し交えて解説しましょう。UE4のバージョンは4.14.0ですが、もっと前のバージョンでもほとんど変わりません。
 UE4ではテクスチャのMipMap設定をTextureEditor(テクスチャアセットをダブルクリックすると開く画面)で設定するのでTextureEditorのMip Gen Settingsの項目を拡張しようと思います。
MipGenSettings.PNG

 既存の機能を拡張する際はその機能の同じような項目を検索して定義されているソースを探し、その項目の実装を真似しながら拡張するのが楽だし確実です。
 今回はMip Gen Settingsを拡張するので、同じMip Gen Settingsの項目のひとつであるFromTextureGroupでソース検索かけます。するとEngine/Source/Runtime/Engine/Classes/Engine/TextureDefines.hにTextureMipGenSettingsというenumが見つかります。ここに追加します。

UENUM()
enum TextureMipGenSettings
{
    /** Default for the "texture". */
    TMGS_FromTextureGroup UMETA(DisplayName="FromTextureGroup"),
<中略>
    TMGS_Blur4 UMETA(DisplayName="Blur4"),
    TMGS_Blur5 UMETA(DisplayName="Blur5"),
    TMGS_BuildFromSourceImage UMETA(DisplayName="BuildFromSourceImage"), <- これを追加
    TMGS_MAX,

TMGS_BuildFromSourceImageという項目を追加しました。
MipGenSettingsを"BuildFromSourceImage"にするとSourceImageからMipMapを構築するようにします。

 実際の処理をどこに入れるかですが、UE4のエディタでプロパティを変更した場合は、対象になるアセットのクラスのPostEditChangePropertyが呼ばれます。アセットエディタ系の拡張をする際には知っておくと便利です。今回はテクスチャのプロパティなので、UTexture2D::PostEditChangePropertyを調べます。
ざっと処理を追うとテクスチャの場合はインポート時のオリジナル画像(SourceImage)がuassetファイル内に保存されていて、パラメーターが変更された場合や、プラットフォームごとのテクスチャ形式に変換する際にはSourceImageから変換されて実際のテクスチャが構築されます。
 PostEditChangePropertyに2の累乗サイズじゃないテクスチャはMipMap無しとみなす処理があるので、BuildFromSouceImageの場合は例外としてそのまま通すようにします。

Engine/Srouce/Runtime/Engine/Private/Texture2D.cpp

void UTexture2D::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
#if WITH_EDITORONLY_DATA
    if (!Source.IsPowerOfTwo() && (PowerOfTwoMode == ETexturePowerOfTwoSetting::None))
    {
        // このif文を追加
        if (MipGenSettings != TMGS_BuildFromSourceImage || !FMath::IsPowerOfTwo(Source.SizeY) || Source.SizeX < (Source.SizeY + Source.SizeY / 2))
        {
            // Force NPT textures to have no mipmaps.
            MipGenSettings = TMGS_NoMipmaps;
            NeverStream = true;
        }
        // ここまで
    }

 この後の処理はSuper::PostEditChangeProperty(PropertyChangedEvent)で親クラスに引き継がれます。なのでUTexture::PostEditChangePropertyを見ていきます。ちょっと複雑になってくるのでDebug Editorでビルドしてデバッガでステップ実行しながら調べるのも良いでしょう。

    if( !DeferCompressionWasEnabled && (PropertyChangedEvent.ChangeType & EPropertyChangeType::Interactive) == 0 )
    {
        // Update the texture resource. This will recache derived data if necessary
        // which may involve recompressing the texture.
        UpdateResource();
    }

 UTexture::PostEditChangePropertyの中でUpdateResourceでテクスチャを再構築しています。UpdateResouceの内部はけっこう複雑で、毎回テクスチャの再構築をしなくても良いようにキャッシュ処理が含まれています。DerivedDataCacheに保存されたキャッシュの照合などまじめに解説すると長くなるので割愛します。
 最終的にはEngine\Source\Developer\TextureCompressor\Private\TextureCompressorModule.cppのBuildTextureにたどり着きます。ここにBuildTextureMipsという分かりやすい名前の関数があります。この中のMipレベル数の計算にコードを追加します。

    {
        const FImage& FirstSourceMipImage = InSourceMips[0];
        int32 TargetTextureSizeX = FirstSourceMipImage.SizeX;
        int32 TargetTextureSizeY = FirstSourceMipImage.SizeY;

        //ここから追加、テクスチャからサイズとMipLevel数算出
    if (BuildSettings.MipGenSettings == TMGS_BuildFromSourceImage)
        {
            int32 imgSize = TargetTextureSizeX; <-元画像の横幅
            int32 mipSize = TargetTextureSizeY; <-作りたいテクスチャの縦横
            TargetTextureSizeX = TargetTextureSizeY; <- 実際作成されるのは縦横同じサイズになる
            // SourceImageから作成できるMip数を求める
            NumOutputMips = 0;
            while (imgSize >= mipSize)
            {
                imgSize -= mipSize;
                mipSize /= 2;
                NumOutputMips += 1;
            }
        }
        //ここまで
        bool bPadOrStretchTexture = false;

        const int32 PowerOfTwoTextureSizeX = FMath::RoundUpToPowerOfTwo(TargetTextureSizeX);
        const int32 PowerOfTwoTextureSizeY = FMath::RoundUpToPowerOfTwo(TargetTextureSizeY);

        //さらにここから追加 Power of Two Modeを変更したときにクラッシュするので
        if (BuildSettings.MipGenSettings != TMGS_BuildFromSource)
        {
        //スキップさせる

            switch (static_cast<const ETexturePowerOfTwoSetting::Type>(BuildSettings.PowerOfTwoMode))
            {
            case ETexturePowerOfTwoSetting::None:
                break;

            case ETexturePowerOfTwoSetting::PadToPowerOfTwo:
                bPadOrStretchTexture = true;
                TargetTextureSizeX = PowerOfTwoTextureSizeX;
                TargetTextureSizeY = PowerOfTwoTextureSizeY;
                break;

            case ETexturePowerOfTwoSetting::PadToSquarePowerOfTwo:
                bPadOrStretchTexture = true;
                TargetTextureSizeX = TargetTextureSizeY = FMath::Max<int32>(PowerOfTwoTextureSizeX, PowerOfTwoTextureSizeY);
                break;

            default:
                checkf(false, TEXT("Unknown entry in ETexturePowerOfTwoSetting::Type"));
                break;
            }

        //ここから追加
        }
        //ここまで

 同じ関数の後半、実際にMipMap生成の処理を追加します。通常はここからMip0をベースに縮小処理をしてMipChainを構築する処理が呼ばれるのですが、BuldFormSouceImageの場合はSourceImageから切り出すため、縮小処理は呼ばずにここで直接構築します。

        OutMipChain.Empty(NumOutputMips);

        //ここからMipMapつくるコードを挿入
        if (BuildSettings.MipGenSettings == TMGS_BuildFromSourceImage)
        {
            // SourceImageを浮動小数点フォーマットに変換しながらコピーします
            const FImage& Image = SourceMips[0];
            ERawImageFormat::Type MipFormat = ERawImageFormat::RGBA32F;
            FImage tempSource;
            Image.CopyTo(tempSource, MipFormat, EGammaSpace::Linear);

            int32 srcWidth = Image.SizeY;
            int32 srcHeight = srcWidth;
            int32 mipStart = 0;
            // このループでSourceImageから各Mipを取り出してMipMapLevelを作成します
            for (int32 MipIndex = 0; MipIndex < NumOutputMips; ++MipIndex)
            {
                FImage* Mip = new(OutMipChain) FImage();
                Mip->Init(srcWidth, srcHeight, MipFormat, EGammaSpace::Linear);
                FLinearColor* TargetPtr = (FLinearColor*)Mip->RawData.GetData();
                FLinearColor* SourcePtr = (FLinearColor*)tempSource.RawData.GetData() + mipStart;
                for (int32 Y = 0; Y < Mip->SizeY; ++Y)
                {
                    FMemory::Memcpy(TargetPtr, SourcePtr, Mip->SizeX * sizeof(FLinearColor));
                    SourcePtr += tempSource.SizeX;
                    TargetPtr += Mip->SizeX;
                }

                // カラー補正などを反映
                AdjustImageColors(*Mip, BuildSettings);
                // 次のMipへ移動
                mipStart += srcWidth;
                srcWidth >>= 1;
                srcHeight >>= 1;
            }
            //この後の処理は不要なので戻ります
            return true;
        }
        //ここまで

        // Copy over base mips.
        check(StartMip < SourceMips.Num());
        int32 CopyCount = SourceMips.Num() - StartMip;

 浮動小数点フォーマットで構築しましたが、このあとのEngine内の処理がプラットフォームに適した形に変換、圧縮をしてくれます。

 これで基本的な実装はできたのですが、あと1点修正します。TextureEditorではテクスチャの縦横をSourceImageのサイズで描画するのでこのままでは横長に表示されます。BuildFromSourceImageの例外処理を追加しましょう。
 ソースはEngine/Source/Editor/TextureEditor/Private/TextureEditorToolkit.cppです。

void FTextureEditorToolkit::CalculateTextureDimensions( uint32& Width, uint32& Height ) const
{
    uint32 ImportedWidth = Texture->Source.GetSizeX();
    uint32 ImportedHeight = Texture->Source.GetSizeY();

<中略>

    // catch if the Width and Height are still zero for some reason
    if ((Width == 0) || (Height == 0))
    {
        Width = 0;
        Height= 0;

        return;
    }

    // ここから BuildFromSouceImageの場合は強制的に縦横同サイズに。ちょっと手抜きっぽい
    if (Texture->MipGenSettings == TMGS_BuildFromSourceImage)
    {
        Width = Height;
    }
    // ここまで

 わりと雑な実装なので細かい問題がありそうな気がしますが、とりあえずこれで改造は完了です。作成しておいたテクスチャをインポートしてみましょう。
SourceImage.JPG

インポート直後は2の累乗サイズでは無いので強制的にNoMipmapsになっています。
Mip Gen SettingsをBuildFromSourceに変更すると

BuildFrom.JPG

512x512のMipMap数8のテクスチャとして認識されています。

BuildFrom3.JPG

 ミップレベルを変更するとちゃんと取り込まれています。

 これでMipLevelごとの画像を意図した画像にしたテクスチャが作れるようになりました。早速レベルに置いてどんなふうに見えるか見てみましょう。

レベルに置いてみてみよう

mipsample01.jpg
 作成したテクスチャをマテリアルに設定して、テンプレートのThirdPersonに適当に設定してみました。こんな感じになります。

mipsample02.PNG
カメラを引くと低いミップレベルに切り替わっていきます。

mipsample03.PNG
壁に割り当てたテクスチャがこれだけ離れてもMip0のままになっています。実際のゲームでこんなアセットがあるとちょっと困りものです。

mipsample04.PNG
 キューブを置いてみました。プレイヤーが近づいてもMip2(三枚目)しか使用されてません。右の壁は拡大されて伸びてるのも気になります。
 もしこのキューブがこれ以上画面に近づかないオブジェクトだった場合、Mip0とMip1は使用されることが無いままメモリを専有します。アーティストが作成した状態の画像が使われずに縮小画像だけが使われるという不幸な状況になるかもしれません。

 異なるMipMap間は補完処理が入るので実際のゲームではどのMipが描画されているかはほとんどわかりません。TextureEditorでTextureGroupをEffectsにするとフィルタがPointになるので境界がはっきりわかるようになります。

mipsample05.PNG

 同じ球の中でもけっこうMipがばらついてるのがわかります。UVの密度が極あたりでは詰まってるのと視点との角度によっても変わるのでこんな感じになります。

デバッグViewも実装してみた

 MipMapを調べるのにいちいちテクスチャを変更するのは面倒なので、どうせなら一括で切り替えるDebugViewModeが欲しくなります。

先程の"Mesh UV Densities Accuracy"の元画面です。
BPOffice01.JPG

追加した"Mip Map Resoultion"の画面。
BPOffice03.JPG

 とりあえず実装してみました。15くらいのソースの変更とシェーダーの追加が必要なので、この記事でソースまでは紹介しきれないのはご容赦ください。とりあえずなのでまだまだ機能も足りないですし。あるていど形になったらGitHubなどで公開したいなと思います。
 どのアセットにでも同じサイズのMipMap可視化テクスチャでは問題あるのでその辺の選択とかMipフィルタの切り替えとかの機能が必要ですし、遠近感とかなくなるんで何がなんだかわからなくなるのはどうにかしたいですね。

 それでもそれなりに感触は掴めるかなと。"Mesh UV Densities Accuracy"の画面と見比べてみるとわかりやすいですが、壁や天井のアセットのUV密度がバラバラですね。BluePrint Officeを勝手に悪い見本みたいに扱ってごめんなさいm(_ _)m

というわけでMipMapのお話

 MipMapを可視化したかったのは、アーティストに適切なテクスチャサイズを設定する指針にしてもらいたいからでした。MipMapの仕組みと実体を理解していないととにかく高い解像度のテクスチャで解決しようという傾向があります。
 極論を言えば1920x1080のゲームには2048x2048以上のテクスチャは必要無いはずです。画面サイズより高い解像度を表示することは無いからです。しかし油断をするといつの間にか4096x4096のテクスチャが多用されていたりします。その結果メモリを食うし描画は重くなるしロードも遅くなります。そして一番サイズのでかいMip0はほとんど描画されないというケースもあります。
 テクスチャ解像度を決める基本は「そのテクスチャが画面のどれくらいの領域に描画されるのか」だと思っています。カメラからの距離ではなく、対象となるテクスチャがマップされたポリゴンのサイズに対するUVの密度でどのMipが表示されるのか決まります。この仕組を頭に入れておくと無駄に大きなテクスチャを減らせられるのでは無いでしょうか。
 カメラが寄っても画面の1/4程度の大きさにしかならないオブジェクトなら512x512で十分のはずです。ただしテクスチャアトラスの場合は話が変わってきますが。
 MipMapをなんとなく理解していたつもりでも実際にゲームに適用する際には考えないといけない事が多いです。テクスチャストリーミングも絡んできますし。
 解像度を変えるとMipMap選択もかわるので、今後4Kコンテンツにも対応となるとまたこれが頭が痛いです。試しにView画面のサイズを大きくしたり小さくしたりしてみると、カメラは同じなのにMipMapが変化するのがわかります。

まとめ

 MipMapについてアーティストに説明しても実際にモノを見せないとなかなか納得してもらえません。自分でも実際に表示してみないと自信を持って説明できません。そんなわけでMipMapの可視化に挑戦してみました。
 このテクスチャインポートの仕組みは他にも応用できると思います。前述のリフレクションですが、平面のリフレクションならキューブマップでなくても2Dテクスチャで十分です。その際のラフネス変化をMipMapにしてインポートするとか、小さくなるとハッチングが入ったテクスチャに切り替わるなどの面白い効果に使うとか。

 僕の記事は以上です。明日はruyoさんで、「何か書きます。多分VR関連です」です。

おまけ

エンジンの改造でerumや関数名などを検索するときにEntrian Source Searchがあると凄く便利です。Visual Assist Xも良いですが、Entrian Souce Searchの方が安くてお手軽だし、検索だけに絞ると速くて便利です。