4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

VR向けテクスチャ縮小方式の考察とUnityにおけるArea Averagingの実装

4
Posted at

はじめに

VRChat向けアバターを触っていると、テクスチャのVRAMサイズが気になってきます。
魅せ方をある程度制御できる一般的なリアルタイムレンダリングベースのゲームと、信じられないほど至近距離まで近づくことのできるVRChatとでは、テクスチャに限らず3Dモデル自体に対する考え方が根本的に異なっており、どちらかといえばプリレンダリングベースの映像寄りの考え方に近いものを感じます。
そういう背景から、VRChatにおいては4Kテクスチャはかなり魅力的であり、多用されがちです。

ただ、そういったテクスチャを何枚も使用したアバターが集まると、GPUが悲鳴を上げて苦しい、ということがよく起きる。起きています。(オマエ、PC、ヨワイ!) ←うるせえ!
この問題をなんとかするには、テクスチャ解像度を下げる他ありません。

が、UnityのTexture ImporterResize Algorithmとして選択できる縮小用アルゴリズムは
なんとMitchellBilinearの2種類しかありません。
いやいや、待ってくれ~~、と。
この2択がどうにも気に入らなかったため、画像縮小に適したアルゴリズム「Area Averaging(面積平均)」をUnityでも扱えるようにツールに落とし込みました。
ついでに、フォーマットの最適化も自動で出来るようにしてみました。
各種仕様、挙動の設計はほぼ自分ですが、実装にはClaudeCodeとCodexを使っています。
(今回使用するアルゴリズムは既存のものなので、機械的に対応してしまっても大きな間違いが発生しない。)

GitHub:https://github.com/serasuzuna/serTexDownscaler
Booth:https://sersz7.booth.pm/items/8534698

評価用テクスチャ

各アルゴリズムの評価のために、縮小アルゴリズムの特性を可視化するためのテストパターンを使用した以下の画像を作成しました。

TESTTEXTURE.png

  • SiemensStar(放射状に広がる扇形パターン)は、中心に近づくほど線の間隔が狭くなるため、どの空間周波数でエイリアシングが発生し始めるかを一目で確認できる
  • ZonePlate(同心円状の縞模様)は中心から外側に向かうほど周波数が高くなるパターンで、モアレの出方を確認しやすい
  • CheckerBoard(市松模様)は、隣接画素が白黒で交互に切り替わるナイキスト周波数付近のパターンで、サンプリングの限界を直接テストできる
  • GRID(等間隔の格子パターン)は、縮小後の解像度と格子の周期が噛み合わないときにビート干渉でモアレが発生するため、特定の縮小比での周期構造の保存性をテストできる

これらはいずれも「空間周波数が連続的に、または極端に高い」という特徴を持っており、縮小時に情報が欠落するとモアレやちらつきとして即座に目に見える形で現れます。

髪の毛や布の繊維のテクスチャも同種の高周波成分を含んでいるため、テストパターンでの結果は実用テクスチャの品質にそのまま直結します。

評価方法について

4096pxの元テクスチャと、2048px / 1024pxに縮小した結果を比較します。

縮小後のテクスチャをそのまま並べると解像度が異なるため、細部の比較が難しくなります。
そこで、2048px / 1024pxの縮小結果をNearest Neighborで4096pxへ拡大し、元テクスチャと同じ解像度に揃えたうえで比較します。

ここでNearest Neighborを使うのは、補間による加工を一切入れないためです。

BilinearやBicubicで拡大すると、補間処理によってピクセルが滑らかにつながり、縮小アルゴリズムが生んだ劣化やアーティファクトが見えにくくなります。
Nearest Neighborであれば最近傍のピクセルをそのまま引き伸ばすだけなので、縮小時に何が起きたかをそのまま観察できます。

また、各テクスチャはUnity上でBC7フォーマットに指定されたTexture2DアセットをDumpしたものを使用しています。
Unityのインポートパイプラインを通した後の状態で比較することで、実際のビルド環境に近い条件での評価を行うことができます。

Dump用C#スクリプト
ImportedTextureDump.cs
using System.IO;
using UnityEditor;
using UnityEngine;

public static class ImportedTextureDump
{
    [MenuItem("Tools/Texture/Dump Imported Resized Texture")]
    private static void Dump()
    {
        var tex = Selection.activeObject as Texture2D;
        if (tex == null)
        {
            Debug.LogWarning("Select a Texture2D asset.");
            return;
        }

        string path = AssetDatabase.GetAssetPath(tex);
        var importer = AssetImporter.GetAtPath(path) as TextureImporter;
        if (importer == null)
        {
            Debug.LogWarning("TextureImporter was not found.");
            return;
        }

        bool oldReadable = importer.isReadable;

        importer.isReadable = true;
        importer.SaveAndReimport();

        var imported = AssetDatabase.LoadAssetAtPath<Texture2D>(path);

        Debug.Log($"Imported size: {imported.width}x{imported.height}");
        Debug.Log($"Importer Max Size: {importer.maxTextureSize}");
        Debug.Log($"Imported format: {imported.format}");

        byte[] png = EncodeImportedTextureToPng(imported);

        string dir = Path.GetDirectoryName(path);
        string name = Path.GetFileNameWithoutExtension(path);
        string outPath = Path.Combine(dir, name + "_imported_dump.png").Replace("\\", "/");

        File.WriteAllBytes(outPath, png);
        AssetDatabase.ImportAsset(outPath);

        importer.isReadable = oldReadable;
        importer.SaveAndReimport();

        Debug.Log($"Dumped: {outPath}");
    }

    private static byte[] EncodeImportedTextureToPng(Texture2D source)
    {
        var rt = RenderTexture.GetTemporary(
            source.width,
            source.height,
            0,
            RenderTextureFormat.ARGB32,
            RenderTextureReadWrite.Default);

        RenderTexture oldActive = RenderTexture.active;
        try
        {
            Graphics.Blit(source, rt);
            RenderTexture.active = rt;

            var readable = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false);
            readable.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0);
            readable.Apply(false, false);

            byte[] png = readable.EncodeToPNG();
            Object.DestroyImmediate(readable);
            return png;
        }
        finally
        {
            RenderTexture.active = oldActive;
            RenderTexture.ReleaseTemporary(rt);
        }
    }
}

Unityの縮小アルゴリズムは2種類しかない

UnityのTextureImporterにはTextureResizeAlgorithmという列挙型があります。
選択肢はMitchellとBilinearの2つだけ。
Max Sizeを超えたテクスチャの縮小時に、このどちらかが適用されます。

Mitchell

Mitchell-NetravaliフィルタはBicubic補間の一種で、パラメータB=1/3,C=1/3の組み合わせにより、ボケとリンギングのバランスをとった曲線を持ちます。
滑らかな結果を得やすい反面、縮小倍率が大きいとディテールが溶けてしまいます。

all_m2048.jpg
▲左:元テクスチャ4096px 右:Mitchell2048px

all_m1024.jpg
▲左:元テクスチャ4096px 右:Mitchell1024px

zone_m2048.jpg
▲左:元テクスチャ4096px 右:Mitchell2048px

zone_m1024.jpg
▲左:元テクスチャ4096px 右:Mitchell1024px

check_m2048.jpg
▲左:元テクスチャ4096px 右:Mitchell2048px

check_m1024.jpg
▲左:元テクスチャ4096px 右:Mitchell1024px

grid_m2048.jpg
▲左:元テクスチャ4096px 右:Mitchell2048px

grid_m1024.jpg
▲左:元テクスチャ4096px 右:Mitchell1024px

Bilinear

周囲4画素の線形補間で値を決める方式です。
処理は軽いけど、縮小用途ではサンプル数が少なすぎる印象。
1/4以下に縮小すると、寄与すべきピクセルの大半を無視してしまって、エイリアシングやちらつきの原因になります。

all_b2048.jpg
▲左:元テクスチャ4096px 右:Bilinear2048px

all_b1024.jpg
▲左:元テクスチャ4096px 右:Bilinear1024px

zone_b2048.jpg
▲左:元テクスチャ4096px 右:Bilinear2048px

zone_b1024.jpg
▲左:元テクスチャ4096px 右:Bilinear1024px

check_b2048.jpg
▲左:元テクスチャ4096px 右:Bilinear2048px

check_b1024.jpg
▲左:元テクスチャ4096px 右:Bilinear1024px

grid_b2048.jpg
▲左:元テクスチャ4096px 右:Bilinear2048px

grid_b1024.jpg
▲左:元テクスチャ4096px 右:Bilinear1024px

いやいや、待ってくれ~~

UnityのTexture ImporterにおけるResize Algorithmは、Max Sizeを超えるテクスチャを縮小する際のアルゴリズムとして扱われます。
したがって、基本的には拡大用途ではなく、縮小用途の設定と考えてよいでしょう。
つまり、UnityのTexture Importer上では、MitchellとBilinearは、どちらも縮小用途に使用されるリサンプルフィルタと言えます。

しかし、MitchellもBilinearも縮小向けアルゴリズムではなく、拡大と縮小の両方に使える汎用的なリサンプリングフィルタです

VRChatのアバターでは軽量化対応として、4096→1024や2048→512くらいの縮小は日常茶飯事(諸説)で、この規模の縮小に2方式しか選べないのは正直ムカつく~~!というワケです。
任意のリサンプリングフィルタを使用するためだけに、わざわざペイントツール等を起動し、縮小してインポートして差し替えて……というのも、あまりにも非効率すぎてムカつく~~!

ムカつきまくった結果、Unityで完結できるようにツールを作ることにしました。

Area Averaging(面積平均)で解決する

Area Averagingは、画像縮小に適したアルゴリズムです。
出力画素がカバーするソース領域の全ピクセルを、面積で重み付けして平均する。
やっていることは「出力画素の範囲をきっちり測って、その中身を全部混ぜる」だけなので、考え方はかなり素朴です。

原理

出力画像の各画素を入力画像の座標系に逆写像すると、矩形領域が得られます。
この矩形と重なる入力画素それぞれについて、重なり面積を重みとして色値の加重平均を取る。

4096→1024の場合、出力1画素あたり入力4画素分の幅をカバーする。
境界にまたがる画素には部分的な重みが割り当てられ、すべての入力画素が漏れなく出力に寄与します。
そのため、情報の欠落が原理的に発生しません。

all_a2048.jpg
▲左:元テクスチャ4096px 右:Area Averaging2048px

all_a1024.jpg
▲左:元テクスチャ4096px 右:Area Averaging1024px

zone_a2048.jpg
▲左:元テクスチャ4096px 右:Area Averaging2048px

zone_a1024.jpg
▲左:元テクスチャ4096px 右:Area Averaging1024px

check_a2048.jpg
▲左:元テクスチャ4096px 右:Area Averaging2048px

check_a1024.jpg
▲左:元テクスチャ4096px 右:Area Averaging1024px

grid_a2048.jpg
▲左:元テクスチャ4096px 右:Area Averaging2048px

grid_a1024.jpg
▲左:元テクスチャ4096px 右:Area Averaging1024px

Mitchell/Bilinearとの違い

Mitchellは4×4の近傍をカーネルで重み付けしますが、支持域の外は無視する。
Bilinearは出力画素の中心に最も近い4画素だけ見る。

対してArea Averagingは、出力画素がカバーする領域の全画素を参照する。
縮小比が大きいほど参照範囲は広がって、それに応じて情報が保存されます。

リンギングやオーバーシュートも発生しません。(基本的には。)
ローパスフィルタとして理想的かと言われると議論の余地はあります。
が、テクスチャ縮小の実用ではかなり良い結果が出ると思います。

all_2048.jpg
▲2048px 左:Mitchell 中:Bilinear 右:Area Averaging

all_1024.jpg
▲1024px 左:Mitchell 中:Bilinear 右:Area Averaging

zone_2048.jpg
▲2048px 左:Mitchell 中:Bilinear 右:Area Averaging

zone_1024.jpg
▲1024px 左:Mitchell 中:Bilinear 右:Area Averaging

check_2048.jpg
▲2048px 左:Mitchell 中:Bilinear 右:Area Averaging

check_1024.jpg
▲1024px 左:Mitchell 中:Bilinear 右:Area Averaging

grid_2048.jpg
▲2048px 左:Mitchell 中:Bilinear 右:Area Averaging

grid_1024.jpg
▲1024px 左:Mitchell 中:Bilinear 右:Area Averaging

実際のモデルによる比較

SasacaさんによるSASA-Cafe「Precious School Uniforms」を例として簡単に比較します。
※こちら事前に掲載許可をいただきました。この場を借りて改めてお礼申し上げます。
(2024年12月にリリースしてから現在に至るまで、不具合の修正、及び調整によるアップデートと、対応アバターの追加対応を一生継続している、すごい)

Booth:https://sasaky-shop.booth.pm/items/6399824

選定理由は以下の通り。

  • 布の織り目やプリーツの陰影、チェック柄やライン模様といった空間周波数の高い要素を多く含んだテクスチャを使用している

  • マテリアル数が通常版で6、テクスチャはすべて2K以下と、VRChatアバター衣装として典型的な構成になっている

  • lilToonを使用しており、Diffuse、Normal Map、各種マスクテクスチャが一通り揃っているため、挙動を確認しやすい

また、対応アバターが19体+まるぼでぃあばたーずと非常に多く、実際に広く利用されている衣装であるというのも素晴らしい点です。

「Precious School Uniforms」では、Diffuseの設定が基本的には以下のように設定されています。

  • Max Size:2048
  • Resize Algorithm:Mitchell
  • Format:Automatic(主にDXT1)
  • Compression:Normal Quality

この設定に則って比較を行います。
変更点は「Area Averagingを用いた事前縮小対応」のみ。

01_m.jpg
▲オリジナル(Mitchell)

01_a.jpg
▲Area Averaging

02_m.jpg
▲オリジナル(Mitchell)

02_a.jpg
▲Area Averaging

02_m_b.jpg
▲オリジナル(Mitchell)

02_a_b.jpg
▲Area Averaging

03_m.jpg
▲オリジナル(Mitchell)

03_a.jpg
▲Area Averaging

04_m.jpg
▲オリジナル(Mitchell)

04_a.jpg
▲Area Averaging

以上です。
主観ではありますが、Area Averagingによる結果のほうが比較的ディテールが保たれたまま縮小することに成功している印象を受けます。

ここで、なぜBilinearで比較しないのか?と疑問に思った方もいるかもしれません。
それは、先述したBilinearのデメリットに起因します。

1/4以下に縮小すると、寄与すべきピクセルの大半を無視してしまって、エイリアシングやちらつきの原因になります。

05_b.jpg
▲Bilinear

05_a.jpg
▲Area Averaging

これらは2048pxでの描画結果です。
これだけ見るとBilinearの使用も問題ない気はしてきますが、

06_b.jpg
▲Bilinear

06_a.jpg
▲Area Averaging

1024pxへ縮小した場合、Bilinearでは無視できないレベルのエイリアシング(モアレ)が発生します。
髪の毛や服の繊維といった空間周波数の高いテクスチャでは、適切なアルゴリズムを選ばないとこの手の劣化が顕著に出ます。
一方、Area Averagingは縮小に適したアルゴリズムなので、エイリアシングの発生が少なく、ディテールを保ちつつ比較的安定した結果が得られます。

実装で考えたこと

serTexDownscalerのCore実装(serTexDownscalerCore.cs)で採った設計判断をいくつか書き記します。

分離フィルタ(H→Vの2パス)

2次元のArea Averagingを、水平パスと垂直パスに分離して処理している。
水平パスで各行をsrcW → dstWに縮小し、垂直パスで各列をsrcH → dstHに縮小する。

2次元で一括処理すると計算量がO(srcW × srcH × dstW × dstH)になるところ、分離すれば大幅に減る。
テクスチャ縮小はどうせ矩形なので、分離可能な性質をそのまま使える。

重みの事前計算

出力画素ごとの参照開始位置、タップ数、正規化済み重みをAxisPlanに事前計算している。

serTexDownscalerCore.cs
private sealed class AxisPlan
{
    public int[]   start;    // src の開始インデックス
    public int[]   len;      // タップ数
    public int[]   offset;   // weights 配列内の開始位置
    public float[] weights;  // 全 dst 分の重みをフラット化
}

同じ軸の出力画素はすべて同じプランを共有できるから、計算は軸ごとに1回で済む。
プランはキャッシュしていて、同じ(srcN, dstN)の組に対して再計算は走らない。

Color[]を避ける

UnityのColorは構造体なので、配列アクセスのたびにコピーが走る。
そこでRGBAを4本のfloat[]に分離して、構造体コピーを排除した。
入力の展開時に1回だけColor → floatに変換して、出力時にfloat → Colorに戻す。

Parallel.Forによる並列化

水平パスは行単位、垂直パスは出力行単位でParallel.Forにより並列化している。
各行の処理は独立しているから、ロックなしで並行実行できる。

RGB値はそのまま使う

Diffuseテクスチャは通常sRGB色空間で格納されているが、ここではsRGBからLinearへの変換は行わず、読み取ったRGB値をそのまま縮小処理へ回す。

serTexDownscalerCore.cs
ar += c.r * w;
ag += c.g * w;
ab += c.b * w;
aa += c.a * w;

これにより、Unity上でMax Sizeによって縮小された結果や、一般的な画像編集ソフトで縮小した場合に近い見た目になる。

Normal MapにおけるRGB値は色ではなく方向ベクトルの格納値として扱っているため、ここでは問題にならない。

物理的な正しさと実用上の都合について
画像の色を物理的に正しく処理する場合は、sRGBからLinearへ変換して計算する方法が一般的です。

ただし、Unity の公式ドキュメントでは、Texture ImporterMax SizeによるResize処理がどの色空間で行われるかは明記されていません。

そこで、以下の画像を用意し、それぞれを比較することにしました。

  • Unity上でMax Sizeを適用したテクスチャをDumpした画像(=比較対象)
  • Linear変換を挟んで処理し、sRGB復元した画像A
  • 保存されているRGB値のまま処理を行う画像B

上記検証の結果、Dumpした画像と最も近い結果を示したのは画像Bでした。
このことから、UnityのResize結果はLinear変換を挟んだ処理ではなく、保存されているRGB値を基準に処理した結果と近いことが確認できました

そのため、本ツールでもUnityの挙動に近づける目的で、RGB値をそのまま使用して縮小処理を行っています。

Normal Mapは縮小後に正規化する

Normal Mapは面法線ベクトルを[0,1]の色値にエンコードしている(n = color * 2 - 1)。
Area Averagingで縮小すると、平均されたベクトルの長さが1未満になる。
そのまま使うとライティング計算で輝度が落ちるから、縮小後に正規化が必要になる。

serTexDownscalerCore.cs
float nx = ar * 2f - 1f;
float ny = ag * 2f - 1f;
float nz = ab * 2f - 1f;
float sq = nx*nx + ny*ny + nz*nz;
if (sq < 1e-6f) { nx = 0f; ny = 0f; nz = 1f; }
else
{
    float inv = 1f / Mathf.Sqrt(sq);
    nx *= inv; ny *= inv; nz *= inv;
}

ゼロベクトル付近では(0, 0, 1)(フラットな面法線)にフォールバックしている。
壊れた法線がライティングに波及するくらいなら、フラットにしたほうがまだ安全。

マスクテクスチャの自動判別

VRChatアバターのテクスチャには、DiffuseやNormal Mapのほかに、発光マスク、影マスク、虹彩ハイライトといったマスクテクスチャが大量にあります。
グレースケールで少数の階調に集中するのが特徴で、こういうテクスチャはアセット寸法基準で縮小したほうが適切なことが多いです。

マスクテクスチャはTexture2Dアセット側でMax Sizeを大きく下げられているケースが比較的多く、ファイル元サイズを参照すると制作者が意図的に落とした解像度を無視してしまいます。
そのため、アセット寸法を見るほうが制作者の意図を汲み取りやすいと考えました。

この判別を手動でやるのは面倒なので、ピクセルの平均彩度と輝度ヒストグラムの集中度から自動判別するようにしました。
ほぼ無彩色(平均彩度0.015未満)かつ、上位8階調に85%以上のピクセルが集中していれば「マスク的」と判定して、品質設定を自動でNormal品質(後述)に切り替えます。
ただし、機械的に判別している都合上一部Diffuseが「マスク的」と判断される例があるため、品質設定については自動判別しつつ、ユーザーが変更出来るよう可変としています。
あくまで参考値になるため、過信しすぎないように注意が必要です。

品質設定とCompression設定

品質設定について

テクスチャを縮小するにあたって、テクスチャの種類に応じた3つの品質設定を定めました。

High品質は元サイズの基準にファイル元サイズを使います。
テクスチャファイルが持つ本来の解像度を起点に縮小するため、情報の保存量が最も多いです。
Compression設定はHigh Quality。

Normal品質は元サイズの基準にアセット寸法を使います。
UnityのTextureImporterMax Sizeを下げている場合、ファイル元サイズではなくその設定後の寸法を起点に縮小します。
Compression設定はNormal Quality。

継承は元テクスチャのインポート設定とアセット寸法をそのまま引き継ぎます。
テクスチャファイルが持つ本来の解像度と、実際に設定されているMax Sizeが異なる場合、解像度がMax Size準拠になるように縮小します。
ただし、Crunch圧縮の設定に関しては引き継ぎません。

High品質とNormal品質の違いは「縮小の起点にファイル元サイズを使うか、アセット寸法を使うか」と「Compression設定」で、テクスチャの用途によってどちらが適切かが変わります。
ちなみに、縮小処理自体はテクスチャファイルそのものを参照しています。
そのため、品質の差によって参照する元データが変わるといったことは起きません。

Compression設定について

Compression設定とは、簡潔に説明するとUnityがテクスチャを圧縮する際、FormatがAutomaticの場合にどのフォーマットを選ぶかに関わる設定です。
ちなみに、テクスチャのフォーマット優先度は以下の通りです。
Platform Override(テクスチャ個別設定) > Build Settings > Project Settings
優先度高----------------------------優先度低

Unity 2022.3.22f1初期状態でのAutomaticフォーマットについて、
TextureImporter.GetAutomaticFormat(platform)で叩いて戻り値を確認しました。

Low Normal High
PC(RGB) DXT1: 4 bpp DXT1: 4 bpp BC7: 8 bpp
PC(RGBA) DXT5: 8 bpp DXT5: 8 bpp BC7: 8 bpp
PC(NormalMap) DXT5: 8 bpp DXT5: 8 bpp BC7: 8 bpp
Android(RGB) ETC1: 4 bpp ETC1: 4 bpp ETC1: 4 bpp
Android(RGBA) ETC2: 8 bpp ETC2: 8 bpp ETC2: 8 bpp
Android(NormalMap) ETC1: 4 bpp ETC1: 4 bpp ETC1: 4 bpp
iOS(RGB) ASTC 8x8: 2 bpp ASTC 6x6: 3.56 bpp ASTC 4x4: 8 bpp
iOS(RGBA) ASTC 8x8: 2 bpp ASTC 6x6: 3.56 bpp ASTC 4x4: 8 bpp
iOS(NormalMap) ASTC 8x8: 2 bpp ASTC 6x6: 3.56 bpp ASTC 4x4: 8 bpp

Overrideによる明示的フォーマット指定や、Build Settings側での指定を行わない限り、上記のフォーマットが選ばれます。
この他にも、AndroidではASTC、iOSではPVRTCといった別のフォーマットを選択することも可能です。

Android/iOSに対するデフォルトフォーマットを変更する場合は
Project Settings内のTexture compression formatから変更することになります。
Edit > Project Settings > Player > 各OSタブ >
Other Settings > Rendering > Texture compression format ←ココ

VRCSDKでは、v3.10.3時点で一部のソースコードにこのような記載があります。

VRCSdkControlPanelAvatarBuilder.cs
// If one of the targets is android - we must set the default texture format to ASTC
if (_selectedBuildTargets.Contains(BuildTarget.Android) &&
    EditorUserBuildSettings.androidBuildSubtarget != MobileTextureSubtarget.ASTC)
{
    EditorUserBuildSettings.androidBuildSubtarget = MobileTextureSubtarget.ASTC;
    AssetDatabase.Refresh();
}

雑に説明すると、
VRCSDKによるAndroid向けビルドでは、Overrideを使用しない限りASTCを使用するようにBuild Settingの変更を行う」という処理が行われています。
つまり、Override for AndroidでETC2などを指定するといったことをしない限り、基本的にはASTCが使用されます。

それでは、iOSでは……?

……
VRCSDK側にはiOS向けに明示的なフォーマット指定がない
う~ん。(笑)

これは、各テクスチャのOverrideや、Project Settingsのフォーマット設定に依存する、という状況を意味するはずです。
なので、ASTCとPVRTCどちらかが設定されているか、でビルド時の挙動が変わります。
多分。

ユーザーは果たしてASTCとPVRTCどちらを選択すべきなのか、という疑問に行きつくわけですが……
これは先人の検証によって明らかとなっています。

上記検証を参照すると、PVRTC4bpp(※)とASTC 6x6がほぼ同等のVRAMサイズであるにも関わらず、ASTC 6x6のほうが圧倒的に綺麗な描画が出来ていることが分かります。
よほどの理由がない限り、PVRTCを選択する必要はないと言ってしまっていいでしょう。

※記事内引用「PVRTCが1ピクセルを4ビットで表現している」=PVRTC4bpp

補足:NormalMapに対するフォーマットについて

BCフォーマットには、BC5というNormal Map用途に適したものが用意されています。
RとGの2チャンネルを独立して圧縮する形式です。
Normal Mapは法線ベクトルのX/Y/Zを色値としてエンコードしていますが、正規化済みのベクトルX/YがそろっていればZの値はシェーダー側で復元が可能です。
つまり、厳密にはBチャンネルは不要、ということです。

BC5はチャンネルごとに独立して圧縮するため、通常RGBをまとめて圧縮する際に発生するノイズをかなり抑えることができます。

ただし、先述した表のとおりデフォルト設定では割り振られることはありません。
PC向けNormal mapでのデフォルトフォーマットは、BC5と同じ8bppであるDXT5が割り当てられているため、使用フォーマットをDXT5からBC5へ変更したとしてもVRAMサイズの増加はありません。

VRChatにおける推奨フォーマット表の一例

ここまでの情報を考慮すると、VRChatにおける推奨フォーマットは以下の表のように考えられます。

Low Normal High
PC(RGB) DXT1: 4 bpp DXT1: 4 bpp BC7: 8 bpp
PC(RGBA) BC7: 8 bpp BC7: 8 bpp BC7: 8 bpp
PC(Normal map) BC5: 8 bpp BC5: 8 bpp BC5: 8 bpp
Android(※) ASTC 8x8: 2 bpp ASTC 6x6: 3.56 bpp ASTC 4x4: 8 bpp
iOS ASTC 8x8: 2 bpp ASTC 6x6: 3.56 bpp ASTC 4x4: 8 bpp

※スタンドアロンVR機器を含む

表に関しての異論はまあ……認めます。
なにしろ、VRChatという環境はあまりにも特殊なケースですので。

補足:DXT5とBC7について

この表のPC(RGBA)において、DXT5を使用せず一律でBC7を推奨としている理由は、同一条件下の場合、同一VRAMサイズでありながら、圧縮品質が高いためです。
身近な実例で言えば、BC7の場合、DXT5時に発生するグラデーションのブロックノイズを大幅に抑えることが出来ます。

DXT5vsBC7.jpg
▲1024px縮小時テクスチャ 上:DXT5 下:BC7

BC7にはビルド時の圧縮処理がDXT5に比べて少し重いというデメリットはあります。
ただ、実行環境におけるモデルロード時の挙動などには大きな影響はないので、ここではBC7のメリットを優先することとしています。

TIPS:モデル制作におけるVRAMサイズ最適化の考え方の一例

ここでチップスをひとつまみ……w

PC環境でUnityのAutomatic設定に注目した場合、Alphaチャンネルを含むテクスチャは基本的にDXT5が選ばれます。
DXT5は8bppであり、これはBC7と同等であるため、DXT5からBC7へ変更しても同一条件下であればVRAMサイズは変化しません。
先述の推奨フォーマット一覧で、VRAMサイズが変わらないのであれば、より圧縮品質の高いBC7のほうが好ましい、という結論を述べました。

VRChat向けアバターは、顔のテクスチャに対して頬や鼻といった箇所に薄いグラデーションを使用することが多い印象ですが、そのテクスチャにAlphaチャンネルが含まれていることは意外と少ないです。(諸説)

一方で、目のテクスチャに対しては、Transparent(半透明)やCutout(カットアウト)を使用するために、Alphaチャンネルが含まれていることがあります。

  • 顔のテクスチャに対して、より高品質なBC7フォーマットを使用したい
  • 目のテクスチャ側でAlphaチャンネルを持つように設計している

もしも上記条件が揃っているのであれば、これらのテクスチャを別々に用意するのではなく、一つのテクスチャにまとめることで顔のテクスチャの品質をBC7で確保しつつ、条件によってはVRAMサイズの増加を抑えることも可能です
※ただし、統合後の解像度を不必要に上げないことが前提。

構成例 bpp VRAMサイズ目安(Mipmapあり)
2K DXT1 + 1K BC7 4bpp + 8bpp 約 4 MB
2K DXT1 + 2K BC7 4bpp + 8bpp 約 8 MB
統合後 2K BC7 8bpp 約 5.33 MB
統合後 4K BC7 8bpp 約 21.33 MB

ツールについて

ここまで長々と話したことを、全てツールにやらせます。

serTexDownscalerはUnity Editor拡張として動作します。
Tools > serTexDownscalerメニューから開く。

image.png

できることは以下の通り。

  • テクスチャをArea Averagingで縮小し、PNGで出力する
  • Normal Mapは縮小後に自動でベクトル正規化する
  • 単体縮小と一括縮小に対応する
  • GameObjectをドロップすると、参照テクスチャを収集して一括処理できる
  • マテリアルを複製し、テクスチャ参照を差し替える
  • AnimationClipやAnimatorControllerの複製と参照差し替えにも対応する
  • 割り当て先GameObjectを複製し、元のGameObjectを変更せずに適用できる
  • 簡易設定と詳細設定を切り替えられ、詳細設定では一部のテクスチャだけ個別に調整できる
  • 出力テクスチャのMax Sizeを出力サイズに合わせ、Crunch圧縮を無効化する
  • Compression設定から、PC向けとMobile向けの推奨フォーマットを自動設定する
  • Mobile向け軽量化機能を用意(出力時、ASTCを一段階下げる)
  • PC向けとMobile向けの予想VRAMサイズを表示する
  • AndroidとiOSのTexture compression formatがASTCに設定されていない場合、起動時に確認して切り替えられる

対応する入力形式はPNG、JPEG、BMP、TGA、PSD。
出力は常にPNGで、元テクスチャのフォルダ配下に_serTex/元テクスチャ名_サイズ.pngとして保存します。

個人的に一番気に入っている機能は「PC向けとMobile向けの予想VRAMサイズを表示する」です。
これによって、VRAMサイズの計画的な削減を行うことが出来ます。
(費用対効果の薄い変更はしないようにする、等の取捨選択がしやすくなります。)

実践

実践として、同じく「Precious School Uniforms」を例として挙げさせていただきます。
簡易設定(プリセット)4種と、詳細設定(個別設定)の出力結果を載せます。
※この章ではあくまで出力結果を載せるのみに留まり、結果による考察は行っていません。
 今までの考察から実用までに至った、最終的な結果の参考例としてご覧ください。

base.jpg
▲オリジナル VRAMサイズ:56.2MB
今回例として挙げるのはエク版の「Precious School Uniforms」です。

tex2.jpg
▲簡易設定:texture size / 2 VRAMサイズ:72.3MB
テクスチャファイルの解像度を基準として、テクスチャ解像度を1/2へ縮小したものです。

縮小処理の基準サイズとしてテクスチャファイルの解像度を使用する場合、Texture2DアセットのMax Sizeを無視して処理を行います。
そのため、元のMax Sizeが小さく設定されていた場合、オリジナル版よりもVRAMサイズが増加するケースがあります。

tex4.jpg
▲簡易設定:texture size / 4 VRAMサイズ:18.1MB
テクスチャファイルの解像度を基準として、テクスチャ解像度を1/4へ縮小したものです。

asset2.jpg
▲簡易設定:asset size / 2 VRAMサイズ:16.8MB
Texture2DアセットのMax Sizeを基準として、テクスチャ解像度を1/2へ縮小したものです。

asset4.jpg
▲簡易設定:asset size / 4 VRAMサイズ:4.2MB
Texture2DアセットのMax Sizeを基準として、テクスチャ解像度を1/4へ縮小したものです。

detail.jpg
▲詳細設定:個別調整の例 VRAMサイズ:25.8MB
オリジナル版のVRAMサイズを半分程度にしつつ、質感を極力落とさないことを目標として、筆者が各テクスチャの出力解像度を個別調整したものです。
MatCapテクスチャに対しては、縮小しないよう継承設定として元アセット準拠のものを使用する形にしています。

まとめ

UnityのTextureImporterはMitchellとBilinearの2方式しか提供していません。
大きな縮小比での情報保存に限界があるので、Area Averagingを使用したいと思いました。

Area Averagingは、出力画素がカバーする入力領域の全ピクセルを面積重みで平均する、縮小に適したアルゴリズムです。
原理が単純で、基本的にはリンギングが発生せず、全ての入力情報を出力に反映できます。

実装では分離フィルタと並列化で処理性能を確保して、動作の高速化。
Normal Mapに対しては正規化を施し、不正データの出力を防止。
オリジナル版GameObjectは触らず、アニメーション関連含む完全な複製の実装。
などなど、ユーザーが実際に使用する際にストレスとなる要素を可能な限り排除することを心掛けました。

あとがき

この記事の執筆、及びツール開発のきっかけ(原因)となったポストです。

この記事をすべて読んだ人は分かると思いますが、これは「怒り駆動開発」です。
怒りについては2つありますが、1つはUnityへの怒りです。
もう一方はノーコメントでお願いします。
怒りの正体を知りたい人は、引用先などを見てみると面白いかもしれません。

VRChatにおける最適化として、今回はテクスチャ縮小に注目して色々とまとめてみました。

そもそも、なぜ既存のResize Algorithmを使わずにArea Averagingを使いたいのか? という話ですが、それはひとえにVRChatでは「綺麗さ」が重要視されることが多いためです。
4Kテクスチャが多用されるのも、結局はそこに行きつきます。

Mitchellは荒れないけどボケやすい。
Bilinearはボケないけど荒れやすい。
正直な話、この2種類のアルゴリズムでテクスチャを縮小するのは、VR用途では向いていないと言わざるを得ません。
※通常のゲーム開発であれば、おおよそ問題ないと思います。

ここでのArea Averagingというアルゴリズムは、特別最近出てきたものではありません。
Java AWTのImage.SCALE_AREA_AVERAGINGに古くから存在しており、JDK 1.1、つまり1997年頃には標準APIとして使えるようになっていました。
また、OpenCVではINTER_AREA(旧CV_INTER_AREA)という名前で提供されていて、少なくともOpenCV 1.0時代、2006年前後には既にpixel area relationに基づく縮小向けリサンプリングとして利用可能だったことも確認できます。

それゆえに、縮小向けアルゴリズムであるArea Averagingが、Unityにて選択可能なResize Algorithmとして採用されていないのはなぜなのか?と、理解に苦しむというのが本音です。
これは最新のバージョンであるUnity6であっても健在で、依然として選択可能なアルゴリズムはMitchellとBilinearの2つだけです。
(もちろん、Unity内部の設計判断も関係しているのでしょうが……)

Unityが標準実装してくれていれば、ここまで変なことを考えずに済んだのにな~~!!!!

おわり

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?