e.blog

主にUnity/UE周りのことについてまとめていきます

Unityで簡易TexturePackerを実装してみた

はじめに

こちらの記事を参考にUnityで簡易的なTexturePackerを実装してみたのでそのまとめです。

blackpawn.com

ちなみにランダムに生成したものを配置したのがこれ↓

実装しようと思った理由は今実装中の機能に必要になったためです。
今、下の動画のようなスタンプ機能を作っているのですが、プレビュー用には1枚のテクスチャしか渡せないため複数設定されたテクスチャを自前でパッキングして利用したいと思ったのが理由です。

※ ただ、実際のスタンプ実装はまったく別の方法に切り替えたのでテクスチャパックは必要なくなりましたが・・w

今回実装したものは以下のGitHubにアップしてあります。

github.com

概要

今回参考にした記事はライトマップをパックするものを解説しているようです。
ライトマップは特に、ひとつのテクスチャに大量のライト情報をパックして利用するためこうした方法が利用される最適なケースでしょう。

上でも書いたように、今回の実装理由はライトマップと意図は同じで複数のテクスチャをひとつにまとめてシェーダに送りたい、というのが発端です。

実装のフロー

まずはざっくり全体像を概観してみます。
参考にした記事にはコンセプトの説明と図解、そして疑似コードが掲載されています。
今回の実装はその疑似コードを元に実装したものになります。


実装は二分木構造になっていて、最初はルートノードのみが存在します。

そしてテクスチャをパックするたびに、そのテクスチャがピッタリ収まるノードに分割していき、そこへ画像を設定していく、というイメージです。

もう少し具体的なフローにすると、

  1. とあるノードに対して、パックしようとしているテクスチャが収まるかチェックする(だいたいの場合はまったく収まらないか完全に収まるかの2択)
  2. 入る場合かつ完全フィットしていない場合はその領域を、対象画像が収まるサイズとそれ以外の領域に分ける(一度に縦横を分割するのではなく、そのテクスチャの長辺方向にだけ分割するのがポイント
  3. 上記で分割したノードに対して再びフィットするかをチェック(前段で長辺側を区切りに分割しているため、必ず短辺はノードにフィットする)
  4. (2)と同様のチェックを行うと、今度は必然的に短辺側に区切ることになる。(最終的にはそのテクスチャがぴったり収まるサイズに分割される)
  5. 対象画像が完全にフィットするようになったノードに対してImageIDを設定しリーフノード扱いにする
  6. 次のテクスチャを追加する
  7. ルートノードから子ノードを辿り空いているノードImageIDがないノード)を探す
  8. 以後、(1)〜(7)をテクスチャの数だけ繰り返す

図解すると以下のようになります。

f:id:edo_m18:20191222112136j:plain

見てもらうと分かるように、ひとつのノードに対して分割は「2回」発生します。
最初自分が勘違いしていた部分なのですが、テクスチャのサイズに同時に縦横を分割するのではなく、あくまで一度のチェックでは「長辺方向」にだけ分割します。

該当部分のコードを抜粋すると以下のようになります。

float dw = Rectangle.width - image.Width;
float dh = Rectangle.height - image.Height;

if (dw > dh)
{
    Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, image.Width, Rectangle.height);
    Child[1].Rectangle = new Rect(Rectangle.x + image.Width + 1, Rectangle.y, Rectangle.width - image.Width - 1, Rectangle.height);
}
else
{
    Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, Rectangle.width, image.Height);
    Child[1].Rectangle = new Rect(Rectangle.x, Rectangle.y + image.Height + 1, Rectangle.width, Rectangle.height - image.Height - 1);
}

ここでRectangleは確認しているノードの矩形を表していて、imageがパックしようとしている画像を表しています。

そしてそれぞれの幅と高さを引き、どちらが大きいかで分岐しています。

分岐内のコードはほぼ同じで、長辺方向に分割しています。

するとひとつのノードがふたつのノードに分割されます。
そして、プログラムのロジック的には再帰処理的に、分割された片方のノードに対してテクスチャの挿入処理を繰り返します。

すると、自動的に該当テクスチャがぴったりと収まるサイズに分割されたノードが出来上がります。

コードで言うと以下のところが、2回目のチェック時にはdw(あるいはdh)が0となり、前段で分割した方向とは異なる方向が分割方向として採用され、結果、対象テクスチャがぴったり収まるノードができあがる、というわけです。

float dw = Rectangle.width - image.Width;
float dh = Rectangle.height - image.Height;

if (dw > dh)
{
    // Check witch edge is short.
}

コード全容

実装は上記の図の通りにノードを分割していき、パックする位置が決定したらそのノードに対してImageIDを付与しリーフノードとする、という処理をテクスチャ分だけ繰り返すのみです。

あとはコードを見たほうが理解が早いと思うのでコード全体を載せておきます。

public class Node
{
    public Node[] Child = new Node[2];
    public Rect Rectangle;
    private int _imageID = -1;

    private bool _isLeafNode = true;

    private bool CheckFitInRect(IPackImage image)
    {
        bool isInRect = (image.Width <= Rectangle.width) &&
                        (image.Height <= Rectangle.height);
        return isInRect;
    }

    private bool CheckFitPerfectly(IPackImage image)
    {
        bool isSameBoth = (image.Width == Rectangle.width) &&
                            (image.Height == Rectangle.height);
        return isSameBoth;
    }

    public Node Insert(IPackImage image)
    {
        if (!_isLeafNode)
        {
            Node newNode = Child[0].Insert(image);
            if (newNode != null)
            {
                return newNode;
            }

            return Child[1].Insert(image);
        }
        else
        {
            if (_imageID != -1)
            {
                return null;
            }

            if (!CheckFitInRect(image))
            {
                return null;
            }

            if (CheckFitPerfectly(image))
            {
                return this;
            }

            _isLeafNode = false;

            Child[0] = new Node();
            Child[1] = new Node();

            float dw = Rectangle.width - image.Width;
            float dh = Rectangle.height - image.Height;

            if (dw > dh)
            {
                Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, image.Width, Rectangle.height);
                Child[1].Rectangle = new Rect(Rectangle.x + image.Width + 1, Rectangle.y, Rectangle.width - image.Width - 1, Rectangle.height);
            }
            else
            {
                Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, Rectangle.width, image.Height);
                Child[1].Rectangle = new Rect(Rectangle.x, Rectangle.y + image.Height + 1, Rectangle.width, Rectangle.height - image.Height - 1);
            }

            return Child[0].Insert(image);
        }
    }

    public void SetImageID(int imageID)
    {
        _imageID = imageID;
        _isLeafNode = true;
    }

    public int GetImageID()
    {
        return _imageID;
    }

    public Node Find(int imageID)
    {
        if (imageID == _imageID)
        {
            return this;
        }

        if (_isLeafNode)
        {
            return null;
        }

        Node child = Child[0].Find(imageID);
        if (child != null)
        {
            return child;
        }

        child = Child[1].Find(imageID);

        return child;
    }
}

シェーダによる書き込みと読み込み

今回のPackerはシェーダによって書き込みを行っています。
また書き込んだだけでは使えないので、実際に利用する際の読み込み用のシェーダも必要となります。

次はそれらシェーダについて解説します。

シェーダによる書き込み

CPU側から適切にパラメータを設定したのち、シェーダで書き込みます。
C#側の処理は以下のようになっています。

private void Pack(IPackImage image, Rect rect)
{
    Vector4 scaleAndOffset = GetScaleAndOffset(rect);

    _material.SetVector("_ScaleAndOffset", scaleAndOffset);
    _material.SetTexture("_PackTex", image.Texture);

    Graphics.Blit(_current, _next, _material);

    SwapBuffer();
}

_ScaleAndOffsetがパック先テクスチャの「どの位置にどれくらいのサイズで」書き込むかのパラメータで_PackTexが書き込むテクスチャです。

さて、これを利用しているシェーダはどうなっているかというと、

fixed4 frag (v2f i) : SV_Target
{
    float2 puv = i.uv;
    puv -= _ScaleAndOffset.zw;
    puv *= _ScaleAndOffset.xy;

    fixed4 col = tex2D(_MainTex, i.uv);

    if (puv.x < 0.0 || puv.x > 1.0)
    {
        return col;
    }

    if (puv.y < 0.0 || puv.y > 1.0)
    {
        return col;
    }

    return tex2D(_PackTex, puv);
}

フラグメントシェーダによって渡されたUV値(つまりパック用テクスチャ全体のUV値)を、C#側から設定したスケールとオフセットを用いて変換し、それを利用して書き込みを行っています。

UV0より下、あるいは1より上の場合は書き込もうとしているテクスチャの範囲外なので現在のテクスチャの色(※)を返します。

※ ... シェーダによる書き込みは、一度にすべてのテクスチャをパックするのではなく、「ひとつずつ」パックを行っています。その際、ダブルバッファを利用して交互にRenderTexuterを交換しながら書き込んでいるため、上記UV値をはみ出した部分は以前に書き込まれたテクスチャの色がある可能性があるためこうしています。

シェーダによる読み込み

さて、上記まででパックが終わりました。あとはそれを利用して画面に表示する必要があります。
つまりパックされたテクスチャから、該当のテクスチャ部分を読み出す必要があります。

読み出し用のシェーダは以下のようになっています。

fixed4 frag (v2f i) : SV_Target
{
    float2 uv = i.uv;
    uv /= _ScaleAndOffset.xy;
    uv += _ScaleAndOffset.zw;

    uv = clamp(uv, 0.0, 1.0);

    fixed4 col = tex2D(_MainTex, uv);
    return col;
}

書き込み用のシェーダと見比べてもらうと分かりますが、オフセット/スケールの計算が逆になっただけですね。

書き込み時は「オフセットさせて」から「スケールさせる(掛ける)」という手順でしたが、読み込み時は「スケールさせて(割って)」から「オフセットさせる」という手順で復元しています。

最後に

今回の実装では「回転」は考慮していません。
回転すればより良い位置があったとしても、それは考慮していない、ということです。

とはいえ冒頭に載せたようにそこまで隙間が目立つわけではないのでちょっとした利用くらいなら問題ないかなと思っています。

ちなみに読み込み/書き込みした動画がこちら↓

ちょっとした容量削減だったり、レンダリング負荷軽減目的なら意外と使えそうです。