UnityでRenderTextureのコピーを爆速にしたい件

こんにちは！
株式会社OGIXのエンジニアのR.Kです。
（弊社については最後に紹介があるのでぜひ見てください）

# はじめに
今回はタイトルの通りRenderTextureのコピーを爆速にしたいお話です。
UnityでTextureを使って少し複雑な制作をしたことがある人であれば、
一度はこう思ったことがあるでしょう。
「うわっ…私のテクスチャコピー、遅すぎ…？」
私も最初にテクスチャをコピーする機能を作ってみたときはそう思いました。
今回はこの問題を掘り下げて、最終的に少し違う角度から解決してみたいと思います。

~~また、今回の方法ではTextureではなくRenderTextureに対して
高速にコピーを行えるアプローチを紹介しますのでお間違えの無いようお願いします。~~
**2024/5/14追記分**
ご指摘いただいた内容から、RenderTextureのみならず、
Texture型であれば問題ないコードへ改善しました。
そのため、RenderTextureに限らずTexture2Dでもコピーが可能です。

では、
少し長くなりますが、お付き合いいただけると幸いです。

## 目的
<b>テクスチャを一つにまとめることでレンダリングの最適化を図る</b>

## 環境
<b>Unity　2022.3.3f1</b>
|CPU|Memory|GPU|
|---|---|---|
|i7-14700F|32GB|GeForce RTX 4060|

## 要件
<b>大きいテクスチャに複数の小さいテクスチャの全範囲を合成すること</b>
<b>例として、
2048^2のテクスチャに対して
512^2テクスチャをそれぞれ四隅(任意の場所)にコピーできること</b>

# 最初に実装したこと

#### SetPixels
私が最初に実装したテストコードは以下のようなものでした。

``` CSharp
private CopyTexture(Texture2D src_, Texture2D dst_)
{
    var _colors = src_.GetPixels();
    dst_.SetPixels(_colors);
    dst_.Apply();
}
```

同じサイズのテクスチャをそのままコピーしたい場合であれば
これほどシンプルに記述できます。ありがたいですね。
早速2048*2048のテクスチャに対してエディタで実行してみると…

``` CSharp
0.0336798s
```

衝撃の遅さです。どんなに早くても平均30ms以上かかりました。
60fpsに2フレーム割り込んで処理することがあるほどこの3行に負荷が詰まっています。

#### SetPixel

このままでは範囲コピーを行うという要件も満たせていないので、
範囲指定でテクスチャをコピーするメソッドも試しました。
以下がテストコードです。

<details><summary><b>範囲コピーのテストコード</b></summary>

``` CSharp
private void CopyTexture2D(Texture2D src_, Texture2D dst_, Rect copyRect_)
{
    var _offsetX = (int)copyRect_.x;
    var _offsetY = (int)copyRect_.y;
    var _w = (int)copyRect_.width;
    var _h = (int)copyRect_.height;

    var _colors = new Color[_w * _h];

    for (int y = 0; y < _h; ++y)
    {
        for (int x = 0; x < _w; ++x)
        {
            var _pos = new Vector2Int(x + _offsetX, y + _offsetY);
            _colors[y * _w + x] = src_.GetPixel(_pos.x, _pos.y);
        }
    }

    for (int y = 0; y < _h; ++y)
    {
        for (int x = 0; x < _w; ++x)
        {
            var _pos = new Vector2Int(x + _offsetX, y + _offsetY);
            dst_.SetPixel(_pos.x, _pos.y, _colors[y * _w + x], 0);
        }
    }
    
    dst_.Apply();
}
```
</details>

Rectの範囲だけをコピーするコードとなりました。
実際の目的、要件とは少し違った実装になるのですが、
処理速度計測のために`x 0 y 0 w 2048 h 2048`のフルサイズをコピーしてみます。

``` CSharp
0.8592933s
```

エディタ実行を加味しても平均850msかかりました。
0.85秒画面が停止したら流石に使い物になりません。
原因はなんでしょうか。

# 遅い原因を考える

#### 1つ目の原因
すぐに思いつく原因としてはにサイズに比例して時間がかかることでしょう。
当たり前の話ですが横幅 × 縦幅のColorを変更するのですから
近年のハイエンド端末で遊ぶようなテクスチャサイズ、
4096^2をコピーするようなことがあれば相応の負荷が生じます。

これに対して、
既存のGetPixelsやGetPixels32は大きなサイズのColor構造体配列に一度でアクセスするため、
オーバーヘッドが削減されかなりの高速化が図られています。

こちらのことから、forループでアクセスすることはやめたほうがよさそうです。

#### 2つ目の原因

もう一つの原因としては、
「テクスチャデータがGPU側にも存在していること」です。
テクスチャにはApplyメソッドが存在しますが、
これは[公式リファレンス](https://docs.unity3d.com/ja/2019.4/ScriptReference/Texture2D.Apply.html#:~:text=%E5%89%8A%E9%99%A4%E3%81%97%E3%81%BE%E3%81%99%E3%80%82-,%E8%AA%AC%E6%98%8E,-SetPixel%20%E9%96%A2%E6%95%B0%E3%81%A8)によると、
SetPixel等のメソッドを適応するために存在します。(それ以外にも機能はありますが)

Applyメソッドは高価であり、呼び出す回数は抑えたほうがよいともあります。

また、これらの問題は後のパフォーマンスに影響を与える可能性があります。
具体的にはメモリの問題です。

TextureのインスペクターにはRead/Writeの設定がありますが、
こちらの設定が有効になっていると、
SetPixel等のメソッドを実行するためのCPUからアクセスするための、
全く同じテクスチャをCPU側のメモリに確保します。

そのため、
多数のテクスチャをCPUから編集可能にするとメモリ効率も低下しますし、
編集しない時はメモリを開放するようにしても、
テクスチャデータはものによってはかなり大きいため
Unity GCの確保メモリを肥大化させる可能性があり、
一度確保されたUnity GCのメモリはアプリケーションが終了するまで
確保され続けてしまうため、
後のパフォーマンスに影響を与える可能性があります。
なので、CPUからの書き込みには限度があるという根本的な問題もあります。

## これらのことから

#### SetPixelData
まずは、
GetPixelsに一番近しい方法としてGet/Set PixelDataというメソッドがあるようです。
こちらはGPUにあるピクセルデータにアクセスが可能であるそうで、
より早く処理が終わってくれそうです。
Color32構造体を型引数に指定して時間を測ってみます。
``` CSharp
var _colors = src_.GetPixelData<Color32>(0);
dst_.SetPixelData(_colors, 0);
dst_.Apply();
```
結果は…
``` CSharp
0.0088274s
```
**8ms!** かなりの高速化ができました。
60fpsであれば一回程度であればギリギリ問題にならないでしょう。

#### Graphics.CopyTexture

また、
[公式リファレンス](https://docs.unity3d.com/ja/2019.4/ScriptReference/Texture2D.Apply.html#:~:text=%E5%89%8A%E9%99%A4%E3%81%97%E3%81%BE%E3%81%99%E3%80%82-,%E8%AA%AC%E6%98%8E,-SetPixel%20%E9%96%A2%E6%95%B0%E3%81%A8)ではLoadRawTextureData等のメソッドが存在しますが、
その中で最速に見えるものとして、
**Graphics.CopyTexture** というメソッドが存在します。

Graphics.CopyTextureは何者なのか。
[公式CopyTextureリファレンス](https://docs.unity3d.com/ja/2021.2/ScriptReference/Graphics.CopyTexture.html)によると…
全面コピーや範囲コピー等も行えるコピーメソッドのようです。

かなり要件にあったメソッドですし、
[Unityブログ](https://blog.unity.com/ja/engine-platform/accessing-texture-data-efficiently#:~:text=%E3%81%AB%E3%82%88%E3%82%8B%E3%82%82%E3%81%AE%E3%81%A7%E3%81%99%E3%80%82-,SimpleCopy%20example,-%E3%83%86%E3%82%AF%E3%82%B9%E3%83%81%E3%83%A3%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%8C)によると、
GraphicsメソッドではReanderThreadにプッシュするだけとあり、
CPUのメインスレッドを占有しない代わりにGPUで実行されるようです。

速度にも期待ができますね！

また、
[Unityブログ](https://blog.unity.com/ja/engine-platform/accessing-texture-data-efficiently#:~:text=%E3%81%AB%E3%82%88%E3%82%8B%E3%82%82%E3%81%AE%E3%81%A7%E3%81%99%E3%80%82-,SimpleCopy%20example,-%E3%83%86%E3%82%AF%E3%82%B9%E3%83%81%E3%83%A3%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%8C)の速度一覧に記述されていた一文に
> ・0.00 ms – Graphics.CopyTexture(nonReadableSource, target)

というものがあります。
どうやらNonReadableという状態にすることで
MainThreadの負荷をほぼなくしてくれそうな雰囲気です。

NonReadableにする方法を調べると、
「Read/Writeを無効化する」といった類の内容が出てきます。
[Unityブログ 読み取りについて](https://blog.unity.com/ja/engine-platform/accessing-texture-data-efficiently#:~:text=%E3%83%86%E3%82%AF%E3%82%B9%E3%83%81%E3%83%A3%E3%82%92%E8%AA%AD%E3%81%BF%E5%8F%96%E3%82%8A%E5%8F%AF%E8%83%BD%E3%81%BE%E3%81%9F%E3%81%AF%E4%B8%8D%E5%8F%AF%E3%81%AB%E3%81%99%E3%82%8B)にも
「NonReadableが変わる」とは書いてありませんが、それっぽいことが書いてあります。

こちらの[Texture2DをC#ScriptでReadableに変更するには？](https://qiita.com/Katumadeyaruhiko/items/c2b9b4ccdfe51df4ad4a)を拝見すると、
RenderTextureを作ってGraphics.Blitで転送して…とテクスチャを作り直しているようです。

今回ではTextureを作るのがボトルネックになりそうなので、
とりあえずRead/Writeを無効化してみます。

ついでに[Unite 2017 Tokyo](https://www.slideshare.net/slideshow/unite-2017-tokyounity-cpu/75766971)の
かなり後半のスライドによると
Applyメソッドの第二引数をtrueにすることでNonReadableにマークできるそうなので、
まとめてとりあえず実装してみます。

以下がサンプルコードです。
``` CSharp
var _dstTex = new Texture2D(2048, 2048, TextureFormat.ARGB32, false);
// NonReadableにするにはApplyの第二引数をtrueにする
_dstTex.Apply(false, true);
// 一応ReadableはFalseになっている
Debug.Log(_dstTex.isReadable);

var sw = System.Diagnostics.Stopwatch.StartNew();
Graphics.CopyTexture(m_srcTex, 0, 0, m_dstTex, 0, 0);
sw.Stop();
Debug.Log(sw.Elapsed);
```
ちなみに、
CopyTextureの引数でMipMapを指定していますが
テクスチャの設定によってはこの値指定がないとエラーになる場合があります。
これに関してお話しするとより長くなってしまうため、
ご自分で検索いただけると助かります。

早速時間を計測してみると...
``` CSharp
0.0015201s
```
早い！
平均しても**約1.5ms**程です。
ProfilerからRenderThreadで実行されている処理がないか調べてみます。
![Profiler_CopyTexture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/5296d8fe-301e-069d-9e91-e4e0cc7112f5.png)
うーん...ない？
見つけられない程小さい負荷なのかもしれません。
リファレンスにはMainThreadではすぐ終わるとあるので、
もう少し早くなりそうな気もします。
NonReadableにしてるはず…できてるよね？できてない？
何かNonReadableに関しては条件があるのかもしれません。

しかしながら、
要件を満たす機能も大方揃っている為こちらは便利そうです。

#### だがしかし

現時点でわかることはGraphics.CopyTextureは
「単純なコピーや範囲指定コピーも可能であり他の方法より早い」
ということです。
こちらのメソッドでも十分に思えるのですが、
GraphicsメソッドはCPU上ではほぼ瞬時に完了するとありますし、
コピー時に処理を加えたりできないかな…？なんて思ったりもします。

これより早くする方法はない...そんな雰囲気ですが、
同じようで違う。そんな機能を拡張する方法を探してみます。

# 謎解き編

#### やっと本題
この問題を解決するために使用したのは
**Shader**と**Graphics.Blit**でした。

Shaderでは
- GPUで処理されるのでオーバーヘッドがかからない
- テクスチャのRead / Writeの有効化がいらない
- 大きいデータでも並列して処理されるため時間がさほど変わらない
(スペックの頭打ちによる速度の低下が起きる可能性はある)

などの利点があります。
もちろんデメリットもあり、
- Shaderに記述するコードが最適化されていなければ逆に遅くなる可能性がある
- GPU性能が著しく低く、CPU性能が著しく高い時
テクスチャサイズによってC#コード実装のほうが早い可能性もある
- GPUのメモリサイズによっては大量の大きいテクスチャデータを送ることはよろしくない

また、Graphics.Blitでは、
マテリアルを引数にとり転送先のテクスチャに対し
指定されたマテリアルのShaderで処理を加えることができます。

また、Graphicsメソッドなので、スレッドを占有することはない...はずです。
ちなみにGraphics.Blitには範囲指定コピーのような引数を渡すこともできるのですが、
端が引き延ばされてコピーされたり、チューニングが難しいので取り扱いません。
気になる方はお試ししてみてください。

今回はShaderで範囲コピーする機能を制作していきます。

#### 具体的にどうするの

まずは実装のサンプルコードです。

<details><summary><b>C#コード</b></summary>

``` CSharp
using System;
using System.Runtime.CompilerServices;
using UnityEngine;

public class TextureCopyTool : IDisposable
{
    private Texture m_dstTex;
    private Material m_material;

    private readonly static int HS_SUB_TEX = Shader.PropertyToID("_SubTex");
    private readonly static int HS_UV = Shader.PropertyToID("_UV");

    /// <summary>
    /// 結果のテクスチャとコピー時に処理を行うShaderを持つマテリアルを割り当てる
    /// </summary>
    public TextureCopyTool(Texture dstTex_, Material mat_)
    {
        m_dstTex = dstTex_;
        m_material = mat_;
    }

    /// <summary>
    /// 明示的に参照を破棄
    /// </summary>
    public void Dispose()
    {
        m_dstTex = null;
        m_material = null;
    }
    
    
    /// <summary>
    /// コピーを行うメソッド
    /// </summary>
    /// <param name="srcTex">対象へコピーしたいテクスチャ</param>
    /// <param name="uv">対象へコピーする範囲をUV指定 0.0f-1.0f</param>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Copy(Texture srcTex, Vector4 uv, RenderTextureFormat format_ = RenderTextureFormat.ARGB32)
    {
        // パラメータを追加した場合こちらに追加する
        m_material.SetTexture(HS_SUB_TEX, srcTex);
        m_material.SetVector(HS_UV, uv);

        var _tempBuffer = RenderTexture.GetTemporary(
            m_dstTex.width,
            m_dstTex.height,
            0,
            format_,
            RenderTextureReadWrite.Default
        );

        Graphics.Blit(m_dstTex, _tempBuffer, m_material, -1);
        Graphics.CopyTexture(_tempBuffer, m_dstTex);
        RenderTexture.ReleaseTemporary(_tempBuffer);
    }
}
```

</details>

<details><summary><b>Shaderコード</b></summary>

``` HLSL
Shader "Custom/TextureCopyToolShader"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
        _UV("UV",Vector) = (0.25,0.25,0.25,0.25)
        _SubTex("Texture", 2D) = "black" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct Attribute
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct Varying
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _UV;
            sampler2D _SubTex;

            Varying vert(Attribute v)
            {
                Varying o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag(Varying i) : SV_Target
            {
                // サブテクスチャのUVを計算 _UVの範囲に_SubTexが一枚全面表示されるように
                // 縦横でスケールが違うテクスチャの場合ここを修正する必要がある可能性がある
                float2 subUV = clamp((i.uv - _UV.xy) / _UV.zw, 0, 1);

                // サンプリング
                fixed4 mainColor = tex2D(_MainTex, i.uv);
                fixed4 subColor = tex2D(_SubTex, subUV);
                
                // 指定されているUVがコピーしたいテクスチャのUV範囲内かをチェック
                fixed withinBounds =
                    step(_UV.x, i.uv.x)
                    * step(_UV.y, i.uv.y)
                    * step(i.uv.x, _UV.x + _UV.z)
                    * step(i.uv.y, _UV.y + _UV.w);

                // 範囲内ならSuvTexを、範囲外ならMainTexをサンプルして返す
                return withinBounds * subColor + (1 - withinBounds) * mainColor;
            }
            ENDHLSL
        }
    }
}
```

</details>

#### C#
C#側での実装は、
Graphics.Blitを使用したマテリアルを介して
一時的なRentderTextureへの転送を行い、
Graphics.CopyTextureによる結果の受け取りを行います。

一連の流れとしては
- Copyメソッドでコピーを行う
引数にはコピーされるテクスチャ、コピー先に塗られる範囲をUV(0~1)で指定
- 引数で渡された値を(マテリアルの)Shaderに設定
- Graphics.Blitで転送しながら(マテリアルの)Shaderを通す
- 一時テクスチャにコピー結果があるのでCopyTextureで元のテクスチャに結果を戻す
- 不要になったらDisposeで明示的に参照を破棄

このような感じです。
突き詰めればかなりシンプルな内容ですし、
まだ速度に改善の余地があるコードですので参考程度にご活用ください。

#### Shader

続いてShaderです。
HLSLで書いていますが、URPを導入していない環境でも問題なく動作します。

通常のShaderと違う点としては
パラメータに **UV(Vector)** と **SubTex(Texture)** があることと、
**frag関数** にコピーの為の処理が詰まっていることです。

C#コードではパラメータの設定とコピーだけをさせたため、
こちらでは実際にコピーをしたときに結果にどの色が残るかを計算しています。

一連の流れとしては、
- コピーしたいテクスチャのUVを渡されたパラメータと元のテクスチャから計算する
(今回の用途では基本下地となるほうが大きいスケールとなるので割合計算が必要)
- MainTexは通常のi.uvで、コピーしたいテクスチャは先ほどのUVで色をサンプリング
- i.uvの値がコピーされる範囲か否かを0 or 1で取得
- 色と0 or 1の値を掛け合わせてコピー範囲であればコピーしたいテクスチャの色を、
範囲外であれば元々のテクスチャの色を返す

こんな感じでしょうか。
Shader内の処理は並列処理を前提としているので説明が難しいですが、
やっていること自体は難しくないため、比較的改変は用意だと思います。

#### グラフにしてみた

実際にコピーを行うメソッドで相互に作用するデータの動きは概ねこんな感じです。
![Chart.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/cb59fc6c-6791-5f59-c352-a6efb64e9ad8.png)

#### どうやって使うのさ

早速使いたいのでサンプルコードで試しましょう。

ここまで二つのコードを紹介しましたが、内容がよくわからない方は
コピペでcsファイルとshaderファイルに丸々はりつけていただければ問題ありません。

1. 先ほどのTextureCopyShaderを使うために新規マテリアルを作成して適応します
特にパラメータを変更する必要はありません。

2. コピーを実行するスクリプトを用意します

<details><summary><b>コピー実行サンプル</b></summary>

``` CSharp
using UnityEngine;
using UnityEngine.UI;

public class HogeFugaPiyoCopy : MonoBehaviour
{
    [SerializeField] private Texture2D m_srcTex;
    [SerializeField] private Material m_material;
    [SerializeField] private RawImage m_viewDstTexRawImg;

    private void Awake()
    {
        var _dstTex = new Texture2D(m_srcTex.width, m_srcTex.height, TextureFormat.ARGB32, false);
        var _copyTool = new TextureCopyTool(_dstTex, m_material);
        _copyTool.Copy(m_srcTex, new Vector4(0.5f, 0.0f, 0.5f, 1.0f));
        _copyTool.Dispose();
        m_viewDstTexRawImg.texture = _dstTex;
    }
}
```

</details>

一応説明ですが、
TextureCopyToolのコンストラクタで、
- コピーを受ける下地となるTexture
- コピー時に使用するShaderを持ったマテリアル

を割り当てます。
対象へコピーしたいテクスチャと x, y, w, h のUV値を割り当ててコピー実行。
不要になった時は明示的にDisposeメソッドを呼びだします。
たったこれだけです。

独自のShaderを使用したマテリアルを差し替えやすい形で紹介するために
コンストラクタでマテリアルを渡したりコピーメソッドでパラメータに値を渡しています。

より柔軟に変更したい場合やアセットに残らない形でマテリアルを使いたい場合、
別途変更していただいてお使いください。

# さっさと測定しようぜ

#### まず計測

今回は単純なコピーの速度を比較したいため、以下の様にして計測します。
``` CSharp
var _dstTex = new Texture2D(m_srcTex.width, m_srcTex.height, TextureFormat.ARGB32, false);
var _copyTool = new TextureCopyTool(_dstTex, m_material);
var sw = System.Diagnostics.Stopwatch.StartNew();
_copyTool.Copy(m_srcTex, new Vector4(0.5f, 0.0f, 0.5f, 1.0f));
sw.Stop();
Debug.Log(sw.Elapsed);
_copyTool.Dispose();
m_viewDstTexRawImg.texture = _dstTex;
```
また、計測するのは最初に実装したことの章で紹介したコードと条件をそろえるため、
2048^2のテクスチャを一度全面コピーします。
結果は…
``` CSharp
0.0000488s
```
**0.05ms!?**
大体平均しても**約0.04ms~0.05ms**程です。
Graphics.CopyTextureを処理内容に含めているのにあまりにも早い。
GetTemporaryが効いてるのでしょうか。

また、サイズ違いも調査したところ
512^2のサイズでは**約0.0375ms**であったため、
転送命令もサイズに比例して少し負荷がかかるのかもしれません。

先ほどのGraphics.CopyTextureでは
> メインスレッド上ではほぼ瞬時に完了します。

という効果はあまり感じませんでしたが、
この速度は間違いなくRenderThreadに投げていることが原因で
MainThreadの負荷が軽減されているという事でしょう。
次にRenderThreadでの実行速度も調べてみます。

![スクリーンショット 2024-05-14 103859.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/abf71cf0-daf8-9bbf-022a-7780dd438289.png)

お！
Graphics.Blitの文字がしっかり確認できます。
また、HogeFugaPiyoCopyの処理時間も全体で**0.19ms**とかなり早いこともわかります。

また、実行していないフレームと比べて以下の事が確認できました。
- Gfx.DrawDynamicが一つ増えていること
- Gfx.SetRenderTargetの時間が伸びていること
(もしかしたら他にもあるかもしれません。)

しかしこれらの処理も私の環境ではミリ秒より小さいマイクロ秒単位で
**4~5ms**しかかかっていないためほとんど負荷になっていません。
Blitの命令と合わせても0.035ms程でC#コードより早いレベルです。

#### 一旦グラフで整理

さて、長くなりましたが
ここまでの全体コピー速度を測った結果をまとめた結果が以下のグラフとなります。

![RectSpeedGraph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/065ec1bb-9fb4-a318-8999-26296cda6cd0.png)
SetPixelだけは桁が違いすぎるため今回は省いています。

RenderThreadに処理が逃げているので当然ではありますが、
SetPixel(0.85s)よりも約17000倍、
SetPixels(0.033s)より660倍。
もっと早かったSetPixelData(8ms)ですら160倍の差がつくCPUの処理時間です。

しかし、今回の要件は全体コピーだけができる機能では足りていません。
本来の目的のため、別の状態も試してみたいところです。

#### 範囲コピーを複数回検証

以下の様な画像の状況を用意しました。
![スクリーンショット 2024-05-14 123704.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/844e59f4-e161-28a7-660d-54b583a0da6f.png)

この画像はスクリプトからグラデーションのかかっている画像を複数生成して、
それぞれ
- SetPixel
- CopyTexture
- shaderの範囲指定コピー

を実行している結果です。

左がSetPixel、真ん中がCopyTexture、右がShaderによるものです。
処理結果としてはどれも同じように範囲コピーを行う成果を得られていることがわかります。

処理時間も用意しました。内容としては以下です。
- 512^2  に対して128^2  のテクスチャを16回コピー
- 1024^2 に対して256^2  のテクスチャを16回コピー
- 4096^2 に対して1024^2 のテクスチャを16回コピー
それぞれSetPixel、CopyTexture、Shaderで用意しています。

結果は…!
|| 512^2 << 128^2 * 16 | 1024^2 << 256^2 * 16 | 4096^2 << 1024^2 * 16 |
|---|---|---|---|
|SetPixel|0.25 ms|0.95 ms|200.0 ms|
|CopyTexture|0.15 ms|0.55 ms|7.6 ms|
|Shader|0.095 ms|0.095 ms|0.95 ms|

あれ？
内部実装でCopyTextureを呼んでいるのにShaderが早い？

#### 何かがおかしい

そんなはずは…と思い、
NonReadableの条件を何か満たしているから早いのかとあたりを付けて
やり方を変えてみました。
<details><summary>変更後コード</summary>

``` CSharp
private void CopyTextureCopy(Texture2D[] texs_)
{
    var result_ = new RenderTexture(m_mainTexSize, m_mainTexSize, 1);

    for (int y = 0; y < 4; ++y)
    {
        for (int x = 0; x < 4; ++x)
        {
            Graphics.CopyTexture(texs_[y * 4 + x], 0, 0,
                0, 0,
                ITEM_TEX_SIZE, ITEM_TEX_SIZE, result_, 0, 0,
                x * ITEM_TEX_SIZE, y * ITEM_TEX_SIZE);
        }
    }

    m_imageCopyTexture.texture = result_;
}

private void CopyToolCopy(Texture2D[] texs_)
{
    var result_ = new RenderTexture(m_mainTexSize, m_mainTexSize, 1);

    var _copyTex = new TextureCopyTool(result_, m_material);

    for (int y = 0; y < 4; ++y)
    {
        for (int x = 0; x < 4; ++x)
        {
            _copyTex.Copy(texs_[y * 4 + x], new(x * 0.25f, y * 0.25f, 0.25f, 0.25f));
        }
    }

    m_imageCopyTool.texture = result_;

    _copyTex.Dispose();
}
```

</details>

結果をRenderTextureで受け取る形にしてみました。
理由としては、
CopyToolの内部実装は一時RenderTextureを元のテクスチャに戻す処理だったので
もしかするとCopyTextureは
RenderTextureに対して実行すると早いのかと思ったためです。
また、先ほどの
> こちらの[Texture2DをC#ScriptでReadableに変更するには？](https://qiita.com/Katumadeyaruhiko/items/c2b9b4ccdfe51df4ad4a)を拝見すると、
RenderTextureを作ってGraphics.Blitで転送して…とテクスチャを作り直しているようです。

でもGetTemporayを呼びだしているところなどから
RenderTextureに対して処理したほうがよい結果になるのではと推測しました。

とりあえず実行してみましょう。

|| 512^2 << 128^2 * 16 | 1024^2 << 256^2 * 16 | 4096^2 << 1024^2 * 16 |
|---|---|---|---|
|CopyTexture|0.03 ms|0.03 ms|0.03 ms|
|Shader|0.095 ms|0.095 ms|0.95 ms|

**!?**
**はやっ！**
**16回も処理**して**0.03ms**だと？

Copyメソッドの度に
内部的にBlit、CopyTexture、GetTemp、ReleaseTempを実装しているため
今回紹介しているShaderの方法は遅れを取っています。

全体としてテクスチャのサイズに対しては、
比例してSetPixelでは時間が大幅増加していますが
RenderTextureであれば
Shader、CopyTextureは大きさにかかわらず大体一定の時間がかかっていますね…
ここら辺はGraphicsメソッドの恩恵といえるのでしょうか。

#### RenderThreadを見てみる

RenderThreadの内容はどうなっているのか見てみます。

こちらはCopyTextureの画像です。
![スクリーンショット 2024-05-14 114502.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/3e6d9fda-b2fd-ec99-4b43-a23ea3899161.png)
こちらもぱっと見は処理が見つかりません。
早すぎて見つからないのでしょうか？

こちらはShaderを用いた紹介コードでの画像です。
![スクリーンショット 2024-05-14 114559.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/c9763484-36fd-f38b-b6ec-a1d4082f7d24.png)
真ん中に何か見えますね。

![スクリーンショット 2024-05-14 115010.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/f6ea76a8-e117-f19e-e1f9-f522e8c7e045.png)
Blit命令が回数分呼びだされているみたいです。

#### 範囲コピーをグラフに

わかりやすくグラフにしました。
SetPixelの900msのグラフは大きすぎるので、ここでは表示していません。

![RectCopy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565804/91df65f2-c2c6-dc0a-5844-e86c8b7ba8cb.png)

#### 分かったこと

ここまでのことから、
- RenderTextureをコピーしたい
- 全面コピーを行いたい
- コピーしたいものがコピー先より小さい

などの場合はCopyTextureを適切に使うと爆速であること。

- RenderTexutre以外をコピーしたい
- RenderTextureじゃなくてもコピー時に処理を加えたい
- コピー時に色の反転がしたい
- コピーしたいものがコピー先より大きい　(スケールしてコピー)

などの場合はBlitをマテリアルと適切に使うと爆速であること。

が分かりました。

Shaderの方法で一度に複数コピーを行うのであれば
まとめてコピーするメソッドを用意したほうがより速度面で有利になりそうです。
また、Texture2Dでも紹介している方法でなら速度感は担保されそうです。
まだまだ改良の余地がありそうですね。

#### NonReadableに関して

NonReadableに関しては、
また分かったことがありましたら追記します。

> こちらの[Texture2DをC#ScriptでReadableに変更するには？](https://qiita.com/Katumadeyaruhiko/items/c2b9b4ccdfe51df4ad4a)を拝見すると、
RenderTextureを作ってGraphics.Blitで転送して…とテクスチャを作り直しているようです。

この方法ではコピーの時間よりテクスチャを生成する時間のほうが大きい場合があるため、
私の印象としてはこちらの方法は小さいTexture2Dがある場合に使う…という感じです。

基本的にはRenderTextureに対してコピーするようにして、
どうしてもTexture2Dで結果を受けたいときは処理後にTexture2Dにコピーして受け取る。
そんな方法が妥協点ではないでしょうか。

#### 一旦まとめ

一通り調べ終えて、
要件と目的は達成する方法や手段は少なからず揃ったといっていいでしょう。

実際にこれを制作した目的であるプロジェクトでも、
ここで紹介しているコードと理解のもとにコピー機能を制作し利用しています。

# ほかになにか

#### 改造したいとき
今回紹介しているコードでは、
コピーを行うという一点に絞っているため、
画像の合成等はできず結果を上書きしてしまいます。
例えば、
- コピーしたいものが半透明で、下地の色と加算合成した結果が欲しい
- 色を反転してコピーする
- R値のみ抜き出してコピー
といった機能が欲しければShaderを書き換える必要があります。

また、
目的や機能が絞られていて速度等のチューニングが必要な場合は、
より洗練したコードで実装することをオススメします。

#### これが何の役に立つの

ここまで紹介した内容に類する実装では
- リアルタイムお絵描きでペンに独自の様々なマスクを用いて塗る
- Splatoonの様にインクマスク画像を用いて任意のUVに塗る
- 複雑な形状のマップに弾痕や血痕のマスク画像を付ける
- GTAのようなキャラクターへのタトゥーやメイク等の演出を与える

等があります。

これらの実装はDecalという投影機能などが用いられることなどもあるのですが、
インクや弾痕等の非常に多くの跡が残るもので、
ステージリセットのタイミング以外で消えることがない等の跡であれば
Decalでは非常に描画負荷が高価になり、モバイル端末では顕著にその差が現れます。

そこでShaderを用いてテクスチャに画像をコピーや合成する方法をとることで
高速化、最適化を行い、
かつShaderを変更することで特殊な計算を簡単に付与することができます。

#### 余談

定かではない噂話程度ですが、
本家Splatoonでも3DマップからUVを計算しテクスチャに塗る実装らしいと言われています。
この話が本当であれば
Switch等でSplatoonがヌルヌル動いている秘訣というのも
ShaderやGraphicデバイスへの命令による高速化が一つ鍵になっているでしょう。


# まとめ

今回はコピー爆速化と題し、
Shaderによるコピーの実装を紹介やUnityのコピーメソッドについて紹介しました。

Shaderは入口こそとっつき辛く、私も勉強中ではあるのですが、
最近ではShaderGraph等のノードベースでShaderを作ることができますので、
Shaderを経験されたことがない方は、
一度ShaderGraphで作ってみることをオススメします。

長くなりましたが、ここまで読んでくださった方はお付き合いありがとうございました！
少しでも制作の参考になっていましたら幸いです。

# 一緒に働く仲間を募集しています！
株式会社OGIXでは一緒に働いてくれる仲間を募集しています！
エンタメ制作集団としてゲームのみならず、未来を見据えたエンタメコンテンツの開発を行っています。

事業拡大に伴い、エンジニアさんを大募集しています。
興味のある方は下記リンクから弊社のことをぜひ知っていただき応募してもらえると嬉しいです。
▼会社について
https://www.wantedly.com/companies/company_6473754/about
▼代表インタビュー
https://www.wantedly.com/companies/company_6473754/post_articles/443064
▼東京オフィスの応募はこちら
https://www.wantedly.com/projects/1468324
▼新潟オフィスの応募はこちら
https://www.wantedly.com/projects/1468155

# 修正項目

2024/5/14
@taqu様のご指摘を受け以下の項目を追加、修正しました。
@taqu様、誠ありがとうございます。
- Graphics.CopyTextureに関する話について
- Graphics.CopyTextureに関する速度比較
- GraphicsメソッドはRenderThreadに積まれる話について
- 実装コードの改善
- その他事実に即した内容