【Unity】疑似HDRレンダリングを実装する

この記事では疑似HDRレンダリングを実装します。
疑似HDRとは、浮動小数点バッファを使わずに実装するHDRレンダリングのことです。

HDRレンダリングの問題点と疑似HDR

HDRレンダリングでは浮動小数点バッファを使いますが、
処理負荷が大きくなり、使用するメモリも多くなります。

light11.hatenadiary.com

そこで、浮動小数点バッファを使わずにHDRレンダリング的なことをやろうというのが疑似HDRの考え方です。

news.mynavi.jp

擬似HDRの考え方

今回紹介するのはスケーリングによる擬似HDRです。

これは、オブジェクトを描画するときにシェーダで計算された値を2で割ったものをLDRテクスチャに格納します。
これにより、本来0〜2の範囲にあった値が0〜1としてLDRのテクスチャに書き込まれます。

そしてポストエフェクトなどでレンダリング結果を使う際には、LDRテクスチャに格納された値を2倍にします。
これにより0〜2の範囲の値をポストエフェクトで扱えます。

最後にポストエフェクトをかけた後の値をまた2で割ってLDRの値を最終結果とします。

この手法は色の精度は落ちますが実装が簡単で使いやすいです。

擬似HDRの実装

上記の考え方に基づいて疑似HDRを実装してみます。

まずオブジェクトを描画するシェーダを書きます。

Shader "FakeHDR" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Color("Color", Color) = (1, 1, 1, 1)
    }
    SubShader {

        Tags { "Queue"="Geometry" "RenderType"="Opaque"}

        Pass
        {
            Tags { "LightMode"="ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fwdbase
            
            #include "UnityCG.cginc"
            #include "AutoLight.cginc"

            struct appdata
            {
                half4 vertex : POSITION;
                half3 normal : NORMAL;
                half2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                half4 pos : SV_POSITION;
                half2 uv : TEXCOORD0;
                half3 normal: TEXCOORD1;
            };

            sampler2D _MainTex;
            half4 _MainTex_ST;
            half4 _LightColor0;
            half4 _Color;
            
            v2f vert (appdata v)
            {
                v2f o = (v2f)0;

                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                o.normal = UnityObjectToWorldNormal(v.normal);
                
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = _Color;
                half3 diff = max(0, dot(i.normal, _WorldSpaceLightPos0.xyz)) * _LightColor0;
                col.rgb *= diff;

                // 2分の1にスケールした値をレンダーターゲットに格納する
                col.rgb /= 2;
                return col;
            }
            ENDCG
        }
    }
    FallBack "Diffuse"
}

diffuseでライティングしているだけの単純なシェーダです。

最終的に計算された値を2分の1にスケールして出力しています。
これにより、本来0~2の範囲の値がレンダーターゲットには0~1として書き込まれます。

次にポストエフェクト用のシェーダを書きます。

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

        Tags { "RenderType"="Opaque" }

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

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

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

            sampler2D _MainTex;
            float4 _MainTex_ST;
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                // 入力テクスチャの値を2倍したものを色として扱う
                col.rgb *= 2;

                // テスト用に1以上の色を出力する
                col.rgb = max(0, col.rgb - 1);
                return col;
            }
            ENDCG
        }
    }
}

フラグメントシェーダで入力テクスチャの値を2倍しています。
これにより、0~1で格納されていた値が本来の0~2の値に戻されます。

今回は1以上の部分を出力しています。
これが描画されれば、HDRの値をポストエフェクトに渡せたことになります。

実装結果

オブジェクト用のシェーダをCubeに適用し、ポストエフェクトを掛けてみます。
まずDirectional LightのIntensityを1にしてみます。

f:id:halya_11:20180806135707p:plain

Gameビューに何も描画されないことが確認できます。
これはLightのIntensityが1であるため、オブジェクトの色も0~1の範囲に収まってしまうためです。

次にLightのIntensityを2にしてみます。

f:id:halya_11:20180806135629p:plain

物体色が1以上の値を示す部分だけ描画され、ポストエフェクトに1よりも大きい値を渡せていることが確認できました。

ARGB2101010テクスチャを使う

上述の方法では精度に問題があるため、精度の高いLDRテクスチャを使うように実装を変更してみます。
RenderTextureFormat.ARGB2101010に対応している場合にはこちらを使うようにします。

docs.unity3d.com

ちなみにこのフォーマットはOpenGL ES3.0では標準らしい(?)です。

light11.hatenadiary.com

ソースコードは下記のようにします。

using UnityEngine;
using UnityEngine.Rendering;

public class FakeHDRCamera : MonoBehaviour
{
    private Camera _camera;
    private Camera _backBufferCamera;
    private RenderTexture _frameBuffer;
    private CommandBuffer _commandBuffer;

    private void Awake() 
    {
        Initialize();
        Setup();
    }

    /// <summary>
    /// 初期化する
    /// </summary>
    private void Initialize()
    {
        _camera = GetComponent<Camera>();
        var backBufferCameraGo = new GameObject("Back Buffer Camera");
        _backBufferCamera = backBufferCameraGo.AddComponent<Camera>();
        _backBufferCamera.cullingMask = 0;
        _backBufferCamera.transform.parent = transform;
        _backBufferCamera.clearFlags = CameraClearFlags.Nothing;
        _backBufferCamera.useOcclusionCulling = false;
        _backBufferCamera.allowHDR = false;
        _backBufferCamera.allowMSAA = false;
        _backBufferCamera.allowDynamicResolution = false;
    }

    private void Setup()
    {
        // メインカメラのRenderTargetをARGB2101010のRenderTextureにする
        if (_frameBuffer != null) {
            _frameBuffer.Release();
            Destroy(_frameBuffer);
            _frameBuffer = null;
        }
        var renderTextureFormat = SystemInfo.SupportsRenderTextureFormat(RenderTextureFormat.ARGB2101010)
            ? RenderTextureFormat.ARGB2101010
            : RenderTextureFormat.ARGB32;
        _frameBuffer = new RenderTexture(Screen.width, Screen.height, 24, renderTextureFormat);
        _frameBuffer.useMipMap = false;
        _frameBuffer.filterMode = FilterMode.Bilinear;
        _frameBuffer.Create();
        _camera.targetTexture = _frameBuffer;

        // バックバッファ描画用のカメラにCommandBufferを設定する
        if (_commandBuffer != null) {
            _backBufferCamera.RemoveCommandBuffer(CameraEvent.AfterEverything, _commandBuffer);
            _commandBuffer = null;
        }
        _commandBuffer = new CommandBuffer();
        _commandBuffer.name = "Blit to back buffer";
        _commandBuffer.Blit((RenderTargetIdentifier)_frameBuffer, BuiltinRenderTextureType.CameraTarget);
        _backBufferCamera.AddCommandBuffer (CameraEvent.AfterEverything, _commandBuffer);
    }
}

これをカメラにアタッチして再生すると対応しているプラットフォームでARGB2101010が使われます。
FrameDebuggerをみるとちゃんとフォーマットが変わっていることが確認できます。

f:id:halya_11:20180806201559p:plain

他の実装方法について

上述の方法では色の精度が1/2に落ちてしまいますが、
これを緩和するために、アルファチャンネルを使う方法があります。

具体的には、RGBの値のうち最大の値の逆数をAチャンネルに保存し、
RGBの値には元のRGBの値にAを掛けたものを格納します。

ただしこの方法はGPUによるブレンディングや補間が効かず、あまり実用的とは言えません。
そのため本記事では単純なスケーリングによる方法のみ紹介しています。

デメリットについて

疑似HDRにはメモリ使用量と処理負荷を抑えつつHDR値を扱えるというメリットがあります。

一方で、今回紹介したように、疑似HDRを実装するにはSkyboxなども含め
すべてのシェーダにスケール処理を加える必要があるというデメリットがあります。

ポストエフェクトにも処理を加える必要がありますが、
Bloomに限って言えば疑似HDRのスケール値に合わせてThresholdを下げることで対応できそうです。

疑似HDRを扱う際はこれらのデメリットを考慮する必要がありそうです。

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com

参考

qiita.com