【Unity】画像をスクリプトから動的にスライスする

画像を縦横に等間隔でN個に分割(スライス)した場合、左上から右下になるように番号を振って、X=3,Y=6のような位置を指定するのが分かりやすいかと思います。

するとテクスチャーは、原点が左下なので位置計算が必要になります。毎回そのような操作をプログラミングするのは面倒なので上記の切り出しようのメソッドを利用しつつ簡単に使えるようにユーティリティを作成したいと思います。

確認環境

今回の確認環境は以下の通りです。

  • Windows10
  • Unity 2020.14f1

Editor上のみで確認

実装コード

DynamicSliceTextureクラス

単純に画像を切り出すだけの場合以下のメソッドが使えます。

Sprite sp = Sprite.Create(Texture2D texture, Rect rect, Vector2 pivot...);

等間隔に縦横の個数を指定してスライスした場合左上から右下にインデックスのような番号をふってインデックスで画像を指定したりします。すると縦横の個数から切り出す位置の計算が必要になるためその操作を簡単にするためにこのメソッドを利用する形でユーティリティ化しようと思います。

以下のクラスには「Sprite Editor から切り出したときと同じような画像の取得がインデックス指定でできる処理」と、「任意の位置を切り出す処理」の2つを実装しています。また動的に画像をスライスするためのクラスです。テクスチャー自体は編集しないので ScriptableObject で実装しています。

// Odin導入していれば各所コメントインしてもいいかも
//using Sirenix.OdinInspector;
using System;
using UnityEngine;

/// <summary>
/// テクスチャーを動的にスライスするためのスクリプト
/// </summary>
[CreateAssetMenu(menuName = "ScriptableObjects/Texture/DynamicSliceTextureSource")]
public class DynamicSliceTexture : ScriptableObject
{
    //
    // 説明:
    // 画像をスライスするときにSpriteEditorで2000枚とかになると
    // 操作がめちゃくちゃ重くて作業に支障が出るので
    // 実行時に指定のサイズでスライスするようにする
    //

    //
    // Const
    // - - - - - - - - - - - - - - - - - - - -

    // Pivot Center用
    private static readonly Vector2 _center = new Vector2(0.5f, 0.5f);
    // 5桁のゼロ埋め
    private const string _numFormat = "D5";

    //
    // Inspectors
    // - - - - - - - - - - - - - - - - - - - -

    // 対象テクスチャーを縦横に等分割する

    [SerializeField/*, LabelText("対象テクスチャ")*/] Texture2D _sourceTexture;
    [SerializeField/*, MinValue(1), LabelText("横の分割数")*/] int _columns = 1;
    [SerializeField/*, MinValue(1), LabelText("縦の分割数")*/] int _rows = 1;
    [SerializeField] float _pixelsPerUnit = 100;
    // 1枚当たりの画像の大きさ
    [SerializeField/*, ReadOnly*/] int _width;
    [SerializeField/*, ReadOnly*/] int _height;
    [SerializeField/*, ReadOnly*/] int _imageCount = -1;

    //
    // Props
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 要素数を取得します。
    /// </summary>
    public int CellCount => _imageCount;

    /// <summary>
    /// 横の分割数を取得します。
    /// </summary>
    public int Columns => _columns;

    /// <summary>
    /// 縦の分割数を取得します。
    /// </summary>
    public int Rows => _rows;

    /// <summary>
    /// 1ユニットのピクセル数を取得します。
    /// </summary>
    public float PixelsPerUnit => _pixelsPerUnit;

    //
    // Runtime impl
    // - - - - - - - - - - - - - - - - - - - -

    public void OnValidate()
    {
        // 1セルごとの画像サイズ
        _width = 0;
        _height = 0;
        if (_sourceTexture != null)
        {
            _width = _sourceTexture.width / _columns;
            _height = _sourceTexture.height / _rows;
        }

        _imageCount = _columns * _rows;
    }

    //
    // Public Methods
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 指定したインデックスの画像取得します。
    /// </summary>
    /// <remarks>
    /// インデックスは左上から右下に振られる
    /// e.g.
    /// > 0 1 2 3
    /// > 4 5 6 7
    /// > 8 9...
    /// 
    /// 注意:
    /// 動的に生成したSpriteは使用が終了したらDestryを呼ばないとリークする
    /// Sprite sp;
    /// sp.sprite = Getsprite(0);
    /// // こうすると直接代入すると古いSpriteはシーンに残る(=リークしているように見える)
    /// 
    /// Sprite old = sp.sprite;
    /// Destry(old):
    /// sp.sprite = Getsprite(0); // 先に破棄してから入れ替えること
    /// </remarks>
    public Sprite GetSprite(int index, SpriteMeshType meshType = SpriteMeshType.FullRect)
    {
        // 左下が原点(0,0)なのでそのように計算する

        int _index = index;
        if (index > _imageCount)
        {
            _index = index % _imageCount;
        }

        (int x, int y) = GetIndexXY(_index);
        float xpos = x * _width;
        float ypos = (_rows - y - 1) * _height;
        var rect = new Rect(xpos, ypos, _width, _height);
        var sp = Sprite.Create(_sourceTexture, rect, _center, _pixelsPerUnit, 0, meshType);
        sp.name = _index.ToString(_numFormat);

        return sp;
    }

    /// <summary>
    /// 指定した位置の画像を取得します。
    /// </summary>
    public Sprite GetSprite(int x, int y, SpriteMeshType meshType = SpriteMeshType.FullRect)
    {
        if (x > _columns || x < 1 || y > _rows || y < 1)
        {
            string msg1 = $"Value out of range. ";
            string msg2 = $"Range(x:0-{_columns}, y:0-{_rows}), Value(x={x} y={y})";
            throw new ArgumentOutOfRangeException(msg1 + msg2);
        }
        
        return GetSprite(y + x * _columns, meshType);
    }

    /// <summary>
    /// 画像から任意の位置を任意の大きさに切り出して Sprite を取得します。
    /// </summary>
    public Sprite GetSprite(int x, int y, 
                            int width, int height, 
                            float pixelsPerUnit = 100, uint extrude = 0, 
                            SpriteMeshType meshType = SpriteMeshType.FullRect)
    {
        var rect = new Rect(x, y, width, height);
        return Sprite.Create(_sourceTexture, rect, _center, pixelsPerUnit, extrude, meshType);
    }

    /// <summary>
    /// 指定した番号の画像をこのオブジェクトが持っているかどうかを取得します。
    /// true : 存在する / false : 存在しない(=問い合わせても画像が取得できない)
    /// </summary>
    public bool HasImage(int index)
    {
        return index < _imageCount;
    }

    /// <summary>
    /// 指定した <see cref="SpriteRenderer"/> に指定した番号の画像を設定します。
    /// </summary>
    public void ChangeSprite(SpriteRenderer sr, int index = 0)
    {
        Sprite old = sr.sprite; // 古いほうを削除してから新しいのを設定する
        if (old)
        {
            Destroy(old);
        }

        Sprite sp = GetSprite(index);
        sr.sprite = sp;
    }

    /// <summary>
    /// 指定した <see cref="ISpriteSelector"/> 経由で指定した番号の画像を設定します。
    /// </summary>
    public void ChangeSprite(ISpriteSelector selector, int index = 0)
    {
        Sprite old = selector.Sprite;
        if (old)
        {
            Destroy(old);
        }

        Sprite sp = GetSprite(index);
        selector.Sprite = sp;
    }

    // 番号をXとYの成分に分解する
    private (int x, int y) GetIndexXY(int index)
    {
        int x = (index % _columns);
        int y = index / _rows;
        return (x, y);
    }
}

作った画像は特にキャッシュなどはしていないですが、もし最適化するとなったら一度生成したものはキャッシュするほうがよさそうです。

使い方

使い方ですが上記が ScriptableObject なのでまず以下のようにフィールドを設定してインスペクターからオブジェクトを設定しておきます。

public class DynamicSliceTextureTest : MonoBehaviour
{
    // 表示テクスチャー(スライス)
    [SerializeField] DynamicSliceTexture textureSource;

で画像の取得方法は以下のようにするとすべて取得できます。

// 左上から右下に切り出した画像を全部列挙する
int count = this.textureSource.ImageCount;
for (int i = 0; i < count; i++)
{
    Sprite _sp = this.textureSource.GetSprite(i);
    Destroy(_sp);
}

// 任意の位置を切り出す
Sprite _sp = this.textureSource.GetSprite(100, 100, 200, 200);
Destroy(_sp);

1点注意ですが、ここで取得した Sprite は使い終わったら自分で Destroy しない限り解放されないので Sprite.sprite へ設定する前に元の画像を開放しておかないとシーンに大量の Sprite が存在することになるので注意してください。

関連記事

ちなみに Sprite.Create が遅いのは以下記事の通り高速化できます。上記の実装にも導入済みです。

takap-tech.com