Unityで3Dモデルを画像書き出しする

# はじめに
[アドカレ](https://qiita.com/advent-calendar/2019/qualiarts)10日目の記事になります。

昨日は @oyu さんの [業務でGitHubActionsを取り入れる上で苦労した点](https://qiita.com/iyu/items/87a6f255bc7e5beb9a40) でした。
明日は @sune2 さんの [SpriteのPhysics Shapeを使ってuGUIで自由な形の当たり判定を作る](https://qiita.com/sune2/items/cf9ef9d197b47b2d7a10) です。

3Dゲーム開発を行なっていると、3Dモデルを使ったバナー作成などをクリエイターさんが扱う事があります。
今回はそういった素材用の画像書き出し処理をエンジニアがサポートできるような事を書いていきます。

# 環境
#### Unityバージョン
Unity: 2018.4.13f1

#### Scene構成
3D(モデル)の表示をRenderTextureで2D(UI)上に表示しています。
3Dカメラ表示
<img width="640" alt="スクリーンショット 2019-12-09 1.58.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/752ca840-53e6-cf31-7352-9e5c1c8cfc5b.png">
2Dカメラ表示
<img width="640" alt="スクリーンショット 2019-12-09 2.02.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/a8a57298-185c-ec52-2b42-d658bbe2fde4.png">

# やりたい事
この画面から
<img width="128" alt="スクリーンショット 2019-12-09 1.49.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/1c5d359f-91c7-f722-0c1d-f9928f415ab3.png">
バックグリーンのものと透過画像を書き出します
<img width="128" alt="capture.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/0cbf756a-d98b-eb0a-148e-38368376fbfb.png"><img width="128" alt="capture.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/869ae517-a899-a725-72a2-cc9868962f6c.png">

## 画像書き出し方法
Unityからの画像書き出し方法ですが、ここではスクリプト(RenderTextureの写し)を使用した場合と`UnityRecorder`を使用する場合の2パターンを紹介します。

### 1.スクリプトで画像書き出し
```c#Capture.cs
using System.IO;
using UnityEngine;

public class Capture : MonoBehaviour
{
    /// <summary>
    /// 3D用カメラ
    /// </summary>
    [SerializeField]
    private Camera _3dCamera;

    /// <summary>
    /// 非アクティブにしたい対象(床)
    /// </summary>
    [SerializeField]
    private GameObject _3dObject;

    /// <summary>
    /// 書き出したい背景カラー
    /// </summary>
    [SerializeField]
    private Color _backgroundColor;

    public Texture2D GetCapture()
    {
        // 後から戻せるように背景色を退避
        var bgColor = _3dCamera.backgroundColor;
        // 背景変更する
        _3dCamera.backgroundColor = _backgroundColor;
        // いらないオブジェクトを非表示に
        _3dObject.SetActive(false);
        // 再描画する
        _3dCamera.Render();

        // RenderTextureを取得する
        var targetRt = _3dCamera.targetTexture;
        var width = targetRt.width;
        var height = targetRt.height;

        // 現在使用している描画データを退避
        var currentRt = RenderTexture.active;
        // 一時的に使用するRenderTextureを生成
        var workRt = RenderTexture.GetTemporary(width, height, 0);
        // 描画対象にセット
        RenderTexture.active = workRt;

        // RenderTextureに描画する
        {
            // 背景透過用にclearで塗りつぶす(描画対象範囲が同じなら不要)
            GL.Clear(true, true, Color.clear);
            
            Graphics.Blit(targetRt, workRt);
        }

        // 透過書き出しするのでAlphaも含める
        var format = TextureFormat.ARGB32;
        // Texture2Dに書き出す
        var resultTexture = new Texture2D(width, height, format, false);
        resultTexture.hideFlags = HideFlags.DontSave;
        resultTexture.ReadPixels(new Rect(0, 0, width, height), 0, 0, false);
        resultTexture.Apply();

        // 退避していたRenderTextureを戻す
        RenderTexture.active = currentRt;
        // 生成したRenderTextureを破棄
        RenderTexture.ReleaseTemporary(workRt);

        // 表示を戻す
        _3dCamera.backgroundColor = bgColor;
        _3dObject.SetActive(true);

        return resultTexture;
    }
}
```
流れとしては、
1. 現在の表示設定を退避させる
2. 表示設定を変更し、一時的なRenderTextureを生成
3. 生成したRenderTextureにGLで描画
4. RenderTextureの内容をTexture2Dに写す
5. RenderTextureを破棄し、表示設定を戻す
ということを行なっています。

あとはここにファイル書き出しの処理を追加して、ButtonのOnClickアクションと繋げれば`BackgroundColor`に設定したカラー背景で画像書き出しが出来ます

```c#:Capture.cs
    /// <summary>
    /// 画像の保存フォルダ名
    /// </summary>
    private const string SAVE_FOLDER = "CaptureImages";

    /// <summary>
    /// uGUIのButtonから呼ぶ
    /// </summary>
    public void SaveCapture()
    {
        var tex = GetCapture();
        // 保存する画像名（同じ名前だと上書きされるので、複数取るなら連番にする）
        SaveImage("capture", tex);
    }

    /// <summary>
    /// Textureを画像書き出し
    /// </summary>
    private void SaveImage(string fileName, Texture2D tex)
    {
        if (!Directory.Exists(SAVE_FOLDER))
        {
            // Assetsと同階層（プロジェクトフォルダ直下）に作る
            Directory.CreateDirectory(SAVE_FOLDER);
        }

        var path = Path.Combine(SAVE_FOLDER, fileName);
        // 透過画像を扱うので、pngで保存
        File.WriteAllBytes($"{path}.png", tex.EncodeToPNG());
    }
```
書き出される画像のサイズはTexture2Dのサイズそのままなので、この処理の場合はカメラに設定してあるRenderTextureのサイズで書き出されます。
<img width="190" alt="スクリーンショット 2019-12-09 3.08.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/379034c0-b359-5985-a941-af1be742a2cf.png"><img width="480" alt="スクリーンショット 2019-12-09 3.08.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/6fe4d925-4073-3b75-54e3-051afef9c1ee.png">


ちなみに、RenderTextureからTexture2Dへ写す処理は、Unity2018.2からAsyncGPUReadbackを使うことでも書き出す事ができるようです。
[UniTask](https://github.com/Cysharp/UniTask)を使用して実装した例が以下

```c#
    private async UniTask<Texture2D> GetCaptureAsync()
    {
        var bgColor = _3dCamera.backgroundColor;
        _3dCamera.backgroundColor = _backgroundColor;
        _3dObject.SetActive(false);
        _3dCamera.Render();

        var targetTexture = _3dCamera.targetTexture;
        var width = targetTexture.width;
        var height = targetTexture.height;

        var currentRt = RenderTexture.active;
        var workRt = RenderTexture.GetTemporary(width, height, 0);
        RenderTexture.active = workRt;

        // RenderTextureに描画する
        {
            // 背景透過用にclearで塗りつぶす(描画対象範囲が同じなら不要)
            GL.Clear(true, true, Color.clear);
            
            Graphics.Blit(targetRt, workRt);
        }

        // == ここまで同じ処理 == 

        var request = AsyncGPUReadback.Request(workRt);

        RenderTexture.active = currentRt;
        RenderTexture.ReleaseTemporary(workRt);

        _3dCamera.backgroundColor = bgColor;
        _3dObject.SetActive(true);

        // 非同期待機
        await UniTask.WaitUntil(() => request.done || request.hasError);

        if (request.hasError)
        {
            return null;
        }

        var format = TextureFormat.ARGB32;
        // Texture2Dに書き出す
        var resultTexture = new Texture2D(width, height, format, false);
        resultTexture.hideFlags = HideFlags.DontSave;

        var buffer = request.GetData<Color32>();
        resultTexture.SetPixels32(buffer.ToArray());
        resultTexture.Apply();

        return resultTexture;
    }
```
非同期処理の方が画像生成時に止まる・カクツクといった事が起こりにくいのでこちらの方が良さそうですが、Editorでしか試してないので実機は分かりません。
参考サイト様
・http://edom18.hateblo.jp/entry/2019/02/21/103056
・https://qiita.com/UnagiHuman/items/583219cb0366b758a7fe
### 2.Unity Recorderを使用して画像書き出し
Editorを使用できる場合、Unity2018からPackage ManagerにあるUnity Recorderを使用することで上記画像を簡単に保存する事ができます。（Install方法についてははぶきます）

#### 手順
1. `Add New Recorders`から`Image Sequence`を選択します。
2. `Capture`の対象を`Game View`から`Rnder Texture Asset`に変更します。
3. `RenderTexture`に3Dカメラで設定しているRenderTextureを設定します。
4. `Format`を`JPEG`から`PNG`に変更します（Capture Alphaにチェックを入れなくても透過書き出しできた）。
5. Game Sceneを実行し3DカメラのSolid Colorの値を書き出したい背景色に変更する。
6. `START RECORDING`で指定したPathに保存されます。

以下の状態になっていれば大丈夫だと思います。
![スクリーンショット 2019-12-09 10.48.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/bebb325b-2b84-ace0-3155-decc1e97ca3c.png)

キャプチャと書き出し結果
![Recorder.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/930bb20d-0987-3e49-4feb-298648822b22.gif)
<img width="555" alt="スクリーンショット 2019-12-09 10.55.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/5b18b7df-64df-885a-c0ea-0568f1c8d720.png">

参考サイト様
・http://bibinbaleo.hatenablog.com/entry/2018/10/08/160737#%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88
## おまけ（スクリプト）
### 右下にロゴを追加する
RenderTextureに描画した後、ロゴを上書き描画する

```c#:Capture.cs
    [SerializeField]
    private Texture2D _logo;

    [SerializeField]
    private float _logoScale;

// ~~RenderTexture処理内~~

    Graphics.Blit(targetRt, workRt);

    // ロゴが設定されていればロゴを描画する
    if (_logo != null)
    {
        GL.PushMatrix();
        GL.LoadPixelMatrix(-width * 0.5f, width * 0.5f, height * 0.5f, -height * 0.5f);

        // 右下に描画
        var rect = new Rect(
            width * 0.5f - _logo.width * _logoScale,
            height * 0.5f - _logo.height * _logoScale,
            _logo.width * _logoScale,
            _logo.height * _logoScale
        );
        Graphics.DrawTexture(rect, _logo);

        GL.PopMatrix();
    }

// ~~RenderTexture処理内~~
```
<img width="320" alt="スクリーンショット 2019-12-09 11.12.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/6ce9b720-fc6c-2817-8f0e-117346387eae.png"><img width="280" alt="capture.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/be0df8bf-6e85-14f6-3349-4ca69c66b290.png">
### 画像を縮小する
パターン1. RenderTextureのサイズを小さくする
パターン2. 画像を縮小して再生成する

```c#:Capture.cs
    /// <summary>
    /// 縮小するか
    /// </summary>
    [SerializeField]
    private bool _isThumbnail;

    /// <summary>
    /// 縮小サイズ
    /// </summary>
    [SerializeField]
    private float _thumbnailScale;

    /// <summary>
    /// uGUIのButtonから呼ぶ
    /// </summary>
    public void SaveCapture()
    {
        var tex = GetCapture();
        // 完成画像を縮小指定なら縮小サイズにする
        if (_isThumbnail)
        {
            tex = CreateThumbnailTexture(tex, (int) (tex.width * _thumbnailScale), (int) (tex.height * _thumbnailScale));
        }

        SaveImage("capture", tex);
    }

    /// <summary>
    /// 縮小画像を作成する
    /// </summary>
    public Texture2D CreateThumbnailTexture(Texture2D origin, int thumbWidth, int thumbHeight, Material mat = null)
    {
        var currentRt = RenderTexture.active;

        // 縮小サイズでRenderTextureを作成
        var workRt = RenderTexture.GetTemporary(thumbWidth, thumbHeight, 0);
        RenderTexture.active = workRt;
        
        {
            GL.Clear(true, true, Color.clear);

            Graphics.Blit(origin, workRt);
        }

        // 透過させないならRGB24で
        var fullTex = new Texture2D(thumbWidth, thumbHeight, TextureFormat.RGB24, false);
        fullTex.hideFlags = HideFlags.DontSave;
        fullTex.ReadPixels(new Rect(0, 0, thumbWidth, thumbHeight), 0, 0, false);
        fullTex.Apply();

        RenderTexture.active = currentRt;
        RenderTexture.ReleaseTemporary(workRt);
        return fullTex;
    }
```
### トリミングする
```c#:Capture.cs
// ~~GL処理後~~
        Vector2Int resultSize = new Vector2Int(width, height);
        Vector2 trimStartPosition = new Vector2();
        if (_isTrim)
        {
            resultSize = _trimSize;
            trimStartPosition = _trimStartPosition;
        }

        // 透過書き出しするのでAlphaも含める
        var format = TextureFormat.ARGB32;
        // Texture2Dに書き出す
        var resultTexture = new Texture2D(resultSize.x, resultSize.y, format, false);
        resultTexture.hideFlags = HideFlags.DontSave;
        resultTexture.ReadPixels(new Rect(trimStartPosition.x, trimStartPosition.y, width, height), 0, 0, false);
        resultTexture.Apply();
```
<img width="265" alt="スクリーンショット 2019-12-09 11.31.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/a659f889-dd0c-e24f-6720-96f7f7b43b1e.png"><img width="265" alt="capture.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/95d76468-d5f4-acc3-f93e-4bdd9cf1d3c9.png">

# 最終コード
```c#:Capture.cs
using System.IO;
using UniRx.Async;
using UnityEngine;
using UnityEngine.Rendering;

public class Capture : MonoBehaviour
{
    /// <summary>
    /// 画像の保存フォルダ
    /// Assetsと同階層に作られる
    /// </summary>
    private const string SAVE_FOLDER = "CaptureImages";

    /// <summary>
    /// 3D用カメラ
    /// </summary>
    [SerializeField]
    private Camera _3dCamera;

    /// <summary>
    /// 非アクティブにしたい対象(床)
    /// </summary>
    [SerializeField]
    private GameObject _3dObject;

    /// <summary>
    /// 書き出したい背景カラー
    /// </summary>
    [SerializeField]
    private Color _backgroundColor;

    /// <summary>
    /// 右下に追加するロゴ
    /// </summary>
    [SerializeField]
    private Texture2D _logo;

    /// <summary>
    /// ロゴのスケール
    /// </summary>
    [SerializeField]
    private float _logoScale;

    /// <summary>
    /// 縮小するか
    /// </summary>
    [SerializeField]
    private bool _isThumbnail;

    /// <summary>
    /// 縮小サイズ
    /// </summary>
    [SerializeField]
    private float _thumbnailScale;

    /// <summary>
    /// トリミングするか
    /// </summary>
    [SerializeField]
    private bool _isTrim;

    /// <summary>
    /// トリミングサイズ
    /// </summary>
    [SerializeField]
    private Vector2Int _trimSize;

    /// <summary>
    /// トリミング開始地点（左下基準(0, 0)）
    /// </summary>
    [SerializeField]
    private Vector2 _trimStartPosition;

    /// <summary>
    /// uGUIのButtonから呼ぶ
    /// </summary>
    public void SaveCapture()
    {
        var tex = GetCapture();
        // 完成画像を縮小指定なら縮小サイズにする
        if (_isThumbnail)
        {
            tex = CreateThumbnailTexture(tex, (int) (tex.width * _thumbnailScale), (int) (tex.height * _thumbnailScale));
        }

        SaveImage("capture", tex);
    }

    /// <summary>
    /// RenderTextureからTexture2Dを生成
    /// </summary>
    public Texture2D GetCapture()
    {
        // 後から戻せるように背景色を退避
        var bgColor = _3dCamera.backgroundColor;
        // 背景変更する
        _3dCamera.backgroundColor = _backgroundColor;
        // いらないオブジェクトを非表示に
        _3dObject.SetActive(false);
        // 再描画する
        _3dCamera.Render();

        // RenderTextureを取得する
        var targetRt = _3dCamera.targetTexture;
        var width = targetRt.width;
        var height = targetRt.height;

        // 現在使用している描画データを退避
        var currentRt = RenderTexture.active;
        // 一時的に使用するRenderTextureを生成
        var workRt = RenderTexture.GetTemporary(width, height, 0);
        // 描画対象にセット
        RenderTexture.active = workRt;

        // RenderTextureに描画する
        {
            // 背景透過用にclearで塗りつぶす(描画対象範囲が同じなら不要)
            GL.Clear(true, true, Color.clear);

            Graphics.Blit(targetRt, workRt);

            // ロゴが設定されていればロゴを描画する
            if (_logo != null)
            {
                GL.PushMatrix();
                GL.LoadPixelMatrix(-width * 0.5f, width * 0.5f, height * 0.5f, -height * 0.5f);

                // 右下に描画
                var rect = new Rect(
                    width * 0.5f - _logo.width * _logoScale,
                    height * 0.5f - _logo.height * _logoScale,
                    _logo.width * _logoScale,
                    _logo.height * _logoScale
                );
                Graphics.DrawTexture(rect, _logo);

                GL.PopMatrix();
            }
        }

        Vector2Int resultSize = new Vector2Int(width, height);
        Vector2 trimStartPosition = new Vector2();
        if (_isTrim)
        {
            resultSize = _trimSize;
            trimStartPosition = _trimStartPosition;
        }

        // 透過書き出しするのでAlphaも含める
        var format = TextureFormat.ARGB32;
        // Texture2Dに書き出す
        var resultTexture = new Texture2D(resultSize.x, resultSize.y, format, false);
        resultTexture.hideFlags = HideFlags.DontSave;
        resultTexture.ReadPixels(new Rect(trimStartPosition.x, trimStartPosition.y, width, height), 0, 0, false);
        resultTexture.Apply();

        // 退避していたRenderTextureを戻す
        RenderTexture.active = currentRt;
        // 生成したRenderTextureを破棄
        RenderTexture.ReleaseTemporary(workRt);

        // 表示を戻す
        _3dCamera.backgroundColor = bgColor;
        _3dObject.SetActive(true);

        return resultTexture;
    }

    /// <summary>
    /// 縮小画像を作成する
    /// </summary>
    public Texture2D CreateThumbnailTexture(Texture2D origin, int thumbWidth, int thumbHeight, Material mat = null)
    {
        var currentRt = RenderTexture.active;

        // 縮小サイズでRenderTextureを作成
        var workRt = RenderTexture.GetTemporary(thumbWidth, thumbHeight, 0);
        RenderTexture.active = workRt;

        {
            GL.Clear(true, true, Color.clear);

            Graphics.Blit(origin, workRt);
        }

        // 透過させないならRGB24で
        var fullTex = new Texture2D(thumbWidth, thumbHeight, TextureFormat.RGB24, false);
        fullTex.hideFlags = HideFlags.DontSave;
        fullTex.ReadPixels(new Rect(0, 0, thumbWidth, thumbHeight), 0, 0, false);
        fullTex.Apply();

        RenderTexture.active = currentRt;
        RenderTexture.ReleaseTemporary(workRt);
        return fullTex;
    }

    /// <summary>
    /// Textureを画像書き出し
    /// </summary>
    private void SaveImage(string fileName, Texture2D tex)
    {
        if (!Directory.Exists(SAVE_FOLDER))
        {
            Directory.CreateDirectory(SAVE_FOLDER);
        }

        var path = Path.Combine(SAVE_FOLDER, fileName);
        // 透過画像を扱うので、pngで保存
        File.WriteAllBytes($"{path}.png", tex.EncodeToPNG());
    }

    /// <summary>
    /// 非同期処理パターン
    /// </summary>
    private async UniTask<Texture2D> GetCaptureAsync()
    {
        var bgColor = _3dCamera.backgroundColor;
        _3dCamera.backgroundColor = _backgroundColor;
        _3dObject.SetActive(false);
        _3dCamera.Render();

        var targetRt = _3dCamera.targetTexture;
        var width = targetRt.width;
        var height = targetRt.height;

        var currentRt = RenderTexture.active;
        var workRt = RenderTexture.GetTemporary(width, height, 0);
        RenderTexture.active = workRt;

        // RenderTextureに描画する
        {
            // 背景透過用にclearで塗りつぶす(描画対象範囲が同じなら不要)
            GL.Clear(true, true, Color.clear);

            Graphics.Blit(targetRt, workRt);

            // ロゴが設定されていればロゴを描画する
            if (_logo != null)
            {
                GL.PushMatrix();
                GL.LoadPixelMatrix(-width * 0.5f, width * 0.5f, height * 0.5f, -height * 0.5f);

                // 右下に描画
                var rect = new Rect(
                    width * 0.5f - _logo.width * _logoScale,
                    height * 0.5f - _logo.height * _logoScale,
                    _logo.width * _logoScale,
                    _logo.height * _logoScale
                );
                Graphics.DrawTexture(rect, _logo);

                GL.PopMatrix();
            }
        }

        // == ここまで同じ処理 == 

        var request = AsyncGPUReadback.Request(workRt);

        RenderTexture.active = currentRt;
        RenderTexture.ReleaseTemporary(workRt);

        _3dCamera.backgroundColor = bgColor;
        _3dObject.SetActive(true);

        // 非同期待機
        await UniTask.WaitUntil(() => request.done || request.hasError);

        if (request.hasError)
        {
            return null;
        }

        var format = TextureFormat.ARGB32;
        // Texture2Dに書き出す
        var resultTexture = new Texture2D(width, height, format, false);
        resultTexture.hideFlags = HideFlags.DontSave;

        var buffer = request.GetData<Color32>();
        resultTexture.SetPixels32(buffer.ToArray());
        resultTexture.Apply();

        return resultTexture;
    }
}
```

# まとめ
スクリプトで画像書き出しが出来るようになると、実機で3Dモデルの画像書き出しができるようになります。
もし、デザイナーさんが手動で透過処理をやっている場合は教えてみてはいかがでしょうか。
Unityに不慣れな方がいた場合はモデルキャプチャ用のツールを作ってみると大変喜ばれます。

# ライセンス
© Unity Technologies Japan/UCL