神様は有給消化中です。

技術ネタを書こうかな。時事ネタとか私生活ネタはうけないし。

【Unity】カメラ1つでUI解像度を維持し、3D解像度だけを下げる方法

昔からモバイル端末の解像度は狂気の沙汰としか言えず、あの小さな面積にフルHDとかのディスプレイを積んでいます。
撮った写真を綺麗に見るなら良いのかもしれませんが、1フレーム16msとか33msで描画しないといけないゲームではかなり辛いものがあります。
正直、Androidの解像度合戦がなければ、もっとシェーダーリッチなゲームが世の中に溢れていると思うわけです。
とはいえ世の流れですので、我々エンジニアは工夫を凝らしてよりリッチな見た目が出せるよう日々邁進するわけであります。

上記を踏まえて言いたいことは、
ゲームの解像度落としても良いよね?
ということです。

しかし、UI解像度を720pまで落としてしまうと途端にユーザーにバレてしまいます。
UIは結構解像度の劣化が目立つんですよね。
しかし、3D空間側の解像度を落としてもほとんど目立たず、フィルレートが稼げて美味しいのです。
私の尊敬するエンジニアの1人、Unityの安原さんもUnity 2017 Tokyoで同じことをおっしゃっています。

【Unite 2017 Tokyo】スマートフォンでどこまでできる?3Dゲームをぐりぐり動かすテクニック講座

3D側の解像度だけを下げる

さて、やっと本題です。
やりたいことは、「UI解像度は高く」「3D解像度は低く」レンダリングです。
簡単に言うと3D描画を自前で用意したRenderTextureに描画して、3D描画が終わったらBack BufferにBlitすれば良いことになります。
Google先生に聞いてみると、以下のような記事がヒットしました。
wordpress.notargs.com
この記事の中では、Back BufferにBlitする用のカメラを用意して、そのカメラにCommandBufferをAddすることでRenderTextureをBackBufferに書き込んでいます。
これは、同一カメラでのCommandBufferではAfterImageEffects, AfterEveryThingでのSetRenderTargetでBack Bufferを指定出来ないからだと認識していました。事実私もこの記事を参考にいろいろ試したのでですが指定出来ず、以前の記事ではこの方式で実装しています。
appleorbit.hatenablog.com


しかし、しかしです!
本当に偶然ではあるのですが、以下の記事をみつけました。
Unity 5: AfterImageEffects and AfterEverything Traps – Updated My Journal.

全CameraEvent共通で、SetRenderTarget( -1 )と指定するとBack Bufferがセットされる(RenderTargetIdentifer( int nameID )でそうなっている?)

本当にこのブログの著者様には感謝の念しかありません。
というわけで、書いてみました。
以下のコードで、3D解像度だけを1つのカメラで下げることができます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;


/// <summary>
/// 解像度を変更するコンポーネント
/// </summary>
[RequireComponent(typeof(Camera))]
public class ResolutionConverter : MonoBehaviour
{
    /// <summary>
    /// 解像度
    /// </summary>
    public enum Resolution
    {
        None,
        HD,
        FULLHD
    }

    /// <summary>
    /// 解像度設定
    /// </summary>
    [SerializeField] private Resolution m_Resolution = Resolution.None;

    /// <summary>
    /// ターゲットカメラ
    /// </summary>
    private Camera m_Camera;
    /// <summary>
    /// フレームバッファ
    /// </summary>
    private RenderTexture m_FrameBuffer;
    /// <summary>
    /// コマンドバッファ
    /// </summary>
    private CommandBuffer m_CommandBuffer;

    /// <summary>
    /// 初期化
    /// </summary>
    private void Awake()
    {
        m_Camera = GetComponent<Camera> ();

        Apply (m_Resolution);
    }

    /// <summary>
    /// 適用する
    /// </summary>
    public void Apply(Resolution resolution)
    {
        m_Resolution = resolution;

        if (resolution == Resolution.None) {
            return;
        }

        var size = GetResolutionSize (resolution);
        size = FitScreenAspect (Screen.width, Screen.height, size.x, size.y);
        UpdateFrameBuffer (size.x, size.y, 24);
        UpdateCameraTarget ();
        AddCommand ();
    }


    /// <summary>
    /// フレームバッファの更新
    /// </summary>
    private void UpdateFrameBuffer(int width, int height, int depth, RenderTextureFormat format = RenderTextureFormat.Default)
    {
        if (m_FrameBuffer != null) {
            m_FrameBuffer.Release ();
            Destroy (m_FrameBuffer);
        }

        m_FrameBuffer = new RenderTexture (width, height, depth, format);
        m_FrameBuffer.useMipMap = false;
        m_FrameBuffer.Create ();
    }

    /// <summary>
    /// カメラの描画先を更新
    /// </summary>
    private void UpdateCameraTarget()
    {
        if (m_FrameBuffer != null) {
            m_Camera.SetTargetBuffers (m_FrameBuffer.colorBuffer, m_FrameBuffer.depthBuffer);
        } else {
            m_Camera.SetTargetBuffers (Display.main.colorBuffer, Display.main.depthBuffer);
        }
    }


    /// <summary>
    /// 解像度の実サイズを取得
    /// </summary>
    private Vector2Int GetResolutionSize(Resolution resolution)
    {
        bool isPortrait = Screen.height > Screen.width;

        switch(resolution){
        case Resolution.HD:
            if (isPortrait) {
                return new Vector2Int (720, 1280);
            }
            return new Vector2Int (1280, 720);
        case Resolution.FULLHD:
            if (isPortrait) {
                return new Vector2Int (1080, 1920);
            }
            return new Vector2Int (1920, 1080);
        }

        return new Vector2Int (Screen.width, Screen.height);
    }


    /// <summary>
    /// 解像度の計算
    /// </summary>
    private static Vector2Int FitScreenAspect(int width, int height, int maxWidth, int maxHeight)
    {
        // 解像度以下なら何もしない
        if (width <= maxWidth && height <= maxHeight) {
            return new Vector2Int (width, height);
        }

        if (width > height) {
            float aspect = height / (float)width;
            int w = Mathf.Min (width, maxWidth);
            int h = Mathf.RoundToInt (w * aspect);

            return new Vector2Int (w, h);
        }

        {
            float aspect = width / (float)height;
            int h = Mathf.Min (height, maxHeight);
            int w = Mathf.RoundToInt (height * aspect);

            return new Vector2Int (w, h);
        }
    }

    /// <summary>
    /// コマンドを追加する
    /// </summary>
    private void AddCommand()
    {
        RemoveCommand ();

        // カラーバッファをバックバッファ(画面)に描きこむコマンド
        {
            m_CommandBuffer = new CommandBuffer ();
            m_CommandBuffer.name = "blit to Back buffer";

            m_CommandBuffer.SetRenderTarget (-1);
            m_CommandBuffer.Blit (m_FrameBuffer, BuiltinRenderTextureType.CurrentActive);

            m_Camera.AddCommandBuffer (CameraEvent.AfterEverything, m_CommandBuffer);
        }
    }
    /// <summary>
    /// コマンドを破棄する
    /// </summary>
    private void RemoveCommand()
    {
        if (m_CommandBuffer == null) {
            return;
        }
        if (m_Camera == null) {
            return;
        }

        m_Camera.RemoveCommandBuffer (CameraEvent.AfterEverything, m_CommandBuffer);
        m_CommandBuffer = null;
    }
}

個人的に最近最もテンションの上がった内容でした。

【Unity】無駄なドローコールなしで深度バッファを取得する方法

UnityでDepth Bufferを使用する方法として公式に紹介されているのは、Camera.depthTextureModeを使用する方法です。
しかし、Camera.depthTextureModeを使用すると、UpdateDepthTextureなるレンダリングパスが増えてしまいます。

f:id:appleorbit:20171103050838p:plain

これは公式ページにも記載されている通り、Unityの仕様のようです。
https://docs.unity3d.com/jp/540/Manual/SL-CameraDepthTexture.html

デプステクスチャは、シャドウキャスターのレンダリングに使用するのと同じシェーダー内パスを使用してレンダリングされます(“ShadowCaster” pass type)。


Unity内部で、Shader Replaceを使ってDepth Bufferを書くためだけにドローコールを発行しているためにこの挙動となっています。
理由は不明ですが、おそらくOpenGL2.0世代のモバイル端末はNative Depth Textureをサポートしていない端末があったので、それらに対応するためなのかなと。
ハイエンド系ならMRTで自前で深度バッファ書くだろ的な。

Unityの仕様はどうあれ、Depth Bufferのためにドローコールを発行するとなると描画負荷に無視できないインパクトが出てきます。
そこで、パスを増やさずにDepth Bufferを取得する方法を模索しました。

Depth Bufferを取得する

結論から言うと、Camera.SetTargetBuffersを使用します。

private Camera m_Camera;
private RenderTexture m_ColorBuffer;
private RenderTexture m_DepthBuffer;

private void Start()
{
  m_Camera = GetComponent<Camera> ();

  // |私は深度バッファを取得するためにドローコールを発行しました><|
  //m_Camera.depthTextureMode = DepthTextureMode.Depth;

  // カラーバッファを生成
  m_ColorBuffer = new RenderTexture (Screen.width, Screen.height, 0);
  m_ColorBuffer.Create ();

  // 深度バッファを生成
  m_DepthBuffer = new RenderTexture (Screen.width, Screen.height, 24, RenderTextureFormat.Depth);
  m_DepthBuffer.Create ();

  m_Camera.SetTargetBuffers (m_ColorBuffer.colorBuffer, m_DepthBuffer.depthBuffer);
}


このままだとShaderから使用できないので、CommandBufferを使って任意のタイミングでセットします。
加えて、カメラの描画先がカラーバッファになってしまっているので、Back buffer(画面)に書き戻します。

private void AddCommand()
{
  // 深度バッファをセットするコマンド
  {
    CommandBuffer command = new CommandBuffer ();
    command.name = "Set depth texture";

    command.SetGlobalTexture ("_DepthTexture", m_DepthBuffer);

    m_Camera.AddCommandBuffer (CameraEvent.BeforeImageEffects, command);
  }

  // カラーバッファをバックバッファ(画面)に描きこむコマンド
  {
    CommandBuffer command = new CommandBuffer ();
    command.name = "blit to Back buffer";

    // (注)
    // カメラの書き込み先がRenderTextureなのに、CameraEvent.AfterEverythingのタイミングで
    // CameraTargetがback bufferを示すのは正しいのだろうか・・・
    // 確認バージョン:Unity5.6.1f1
    command.SetRenderTarget (BuiltinRenderTextureType.CameraTarget);
    command.Blit (m_ColorBuffer, BuiltinRenderTextureType.CurrentActive);

    m_Camera.AddCommandBuffer (CameraEvent.AfterEverything, command);
  }
}

これで、_DepthTextureにShaderからアクセスすることが可能になりました。
こんな感じのShaderで、画面に深度バッファが表示できます。

Shader "Test/BlitDepth"
{
  Properties
  {
    _MainTex ("Texture", 2D) = "white" {}
  }
  SubShader
  {
    Cull Off ZWrite Off ZTest Always

    Pass
    {
      CGPROGRAM
      #pragma vertex vert
      #pragma fragment frag
      
      #include "UnityCG.cginc"

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

      struct v2f
      {
        float2 uv : TEXCOORD0;
        float2 uv_depth : TEXCOORD1;
        float4 vertex : SV_POSITION;
      };

      v2f vert (appdata v)
      {
        v2f o;
        o.vertex = UnityObjectToClipPos(v.vertex);
        o.uv = v.uv;
        o.uv_depth = v.uv;
        return o;
      }
      
      sampler2D _MainTex;
      sampler2D _DepthTexture;

      fixed4 frag (v2f i) : SV_Target
      {
        half rawDepth = SAMPLE_DEPTH_TEXTURE(_DepthTexture, i.uv_depth);
        half depth = Linear01Depth(rawDepth);
        return fixed4(rawDepth, rawDepth, rawDepth, 1);
      }
      ENDCG
    }
  }
}

UpdateDepthTextureパスなしでDepth bufferを取得することができました。
カラーバッファを画面に書き戻すコストは増えていますが、ドローコールを重複して発行するコストに比べれば安いとおもいます。
f:id:appleorbit:20171103055104p:plain

【Unity】【uGUI】リストビューに、セルが画面外から差し込まれるアニメーションをつける

uGUIで作ったリストビューに、セルが画面外から差し込まれるようなアニメーションを追加する方法を共有。
最終的にこんな感じになります。
f:id:appleorbit:20160402223716g:plain

続きを読む

【Unity】カメラからどれだけ離れても、最低1ピクセルは表示されるように保障する

通常3Dオブジェクトがカメラから離れすぎると、レンダリングサイズが1ピクセルを下回った時点で画面に描画されなくなり、カメラとの位置関係によって描画が切れたり出たりしてチカチカと表示されることがあります。今回はこの問題の解決方法(レンダリングの最低ピクセル保障)を共有します。

続きを読む

【Unity】マテリアルの描画がシェーダーのQueue順に処理されない原因と対策

自作シェーダーなどを使用してレンダリングを行う場合、レンダリング順序をTagsのQueueで指定しますが、特定の条件でこのレンダリング順序が無視される現象が発生したので共有。

簡潔にまとめだけ

まとめると
現象  :シェーダーで指定したレンダリングキューが無視されることがある
原因  :MaterialのCustom Render Queueに不正値が入っているため
発生条件:自作シェーダーを使用 & 自作シェーダー設定前に、他のシェーダーを割り当てていた場合
対策  :Custom Render Queueに-1を設定すればOK

続きを読む

【Unity】Graphicを継承したクラスでMissingReferenceExceptionが発生する

先日、Graphicを継承したクラスで何故かMissingReferenceExceptionが発生することがあったので共有。
結論から言うと、OnDisableをオーバーライドすると発生する様子。

テスト用にこんなクラスをすると、コンパイル時にMissingReferenceExceptionが発生します。

public class HogeUI : Graphic {
    protected override void OnDisable ()
    {
        /* なんらかの処理 */
    }
}

Exceptionの内容はこんな感じ。HogeUIが破棄されたのにアクセスしていますと。

MissingReferenceException: The object of type 'HogeUI' has been destroyed but you are still trying to access it.
Your script should either check if it is null or you should not destroy the object.
UnityEngine.UI.Graphic.OnRebuildRequested () (at /Users/builduser/buildslave/unity/build/Extensions/guisystem/UnityEngine.UI/UI/Core/Graphic.cs:396)
UnityEngine.UI.GraphicRebuildTracker.OnRebuildRequested () (at /Users/builduser/buildslave/unity/build/Extensions/guisystem/UnityEngine.UI/UI/Core/GraphicRebuildTracker.cs:33)
UnityEngine.CanvasRenderer.RequestRefresh ()


おそらくGraphicクラスのOnDisableで何かしら必要な処理を行っていて、オーバーライドによって走らなくなったために起こっているのかと。

対応は以下のようにしました。

public class HogeUI : Graphic {
    protected override void OnDisable ()
    {
        base.OnDisable(); // 追加

        /* なんらかの処理 */
    }
}


Graphicクラスの関数をオーバーライドする場合は、基本的に親クラスの処理を呼び出したほうがいいのかもしれません。

【Unity】AssetBundle化したPrefabのシェーダー参照が壊れる現象

先日、AssetBundle化したPrefabのシェーダー参照が壊れる現象に遭遇したため解決策を共有。状況としてはこんな感じ。
・自作のUGUIオブジェクトにカスタムシェーダーを適用してPrefab化
・上記Prefabをシーンに追加して実行→正常動作
・上記PrefabをAssetBundleから読みこんで実行→表示が壊れる

FrameDebuggerで確認すると、シェーダー名称は適用したカスタムシェーダーの物になっているが、レンダーステートが明らかにおかしい・・・。

Google先生に聞くと、以下の記事を発見。
NGUIオブジェクトをAssetBundle化すると、シェーダー参照が壊れることがあるらしい。blog.livedoor.jp

古い情報だしNGUIだし、関係ないだろーと思いながらランタイムでシェーダーを再設定するとちゃんと表示できた。何ぞこれ。

private void Awake(){
   Material material = GetComponentInChildren<Material>();
   if(material != null){
      Shader s = Shader.Find("Custom/Hoge");
      if(s){
         material.shader = s;
      }
   }
}

ちなみに、上記のコードを実行した場合、UnitEditorと実機で適用されるシェーダーが変わる。
UnityEditor:/Assets以下に入っているカスタムシェーダー
実機:AssetBundleに入っているカスタムシェーダー
これはShader.Findの検索順に依存していると思われます。

しかし、Shader.Findでシェーダーが見つかるのに参照が壊れる理由が本当に不明。
シェーダーコンパイルやらの関係・・・?うーむ。
何か情報をもっている方がおられたら、コメント欄で教えて下さい。