ShaderTips

シェーダーTips

主にUnityシェーダーについての記事を書いています。

リニアワークフロー 概念の説明とUnityにおける実装と設定

視覚のガンマ

下の画像を見てください。

左から右に行くにつれて、白から黒へとグラデーションしており、人間の目で見た輝度はリニア(線形)になっています。 つまり黒の明るさを0、白の明るさを1とすると、中間の明るさは0.5に見えます。

https://cdn-ak.f.st-hatena.com/images/fotolife/h/halya_11/20200210/20200210231541.png リニアワークフロー入門 簡単な説明とその意義 - LIGHT11引用

しかし、実際の輝度は中間の明るさは約0.22です。 見た目と実際の輝度には差があり、人間の目に届くまで約0.45乗されています。

https://cdn-ak.f.st-hatena.com/images/fotolife/h/halya_11/20200210/20200210232617.png リニアワークフロー入門 簡単な説明とその意義 - LIGHT11引用

見た目の輝度と実際の輝度のグラフです。 リニア(直線)ではなく、ガンマカーブと呼ばれる曲線が描かれます。 f:id:Ny_Program:20210819104636p:plain

ディスプレイガンマ

ディスプレイには受け取った輝度を2.2乗してしまう性質があります。 これをガンマ補正と言います。

また、ガンマ補正する数値(2.2)のことをガンマ値と言います。

https://tech.cygames.co.jp/wp-content/uploads/2015/08/2015-08-21_gamma1.png 物理ベースレンダリング -リニアワークフロー編 (1)- | Cygames Engineers' Blog引用

このままだと暗くなってしまうので、予めガンマ値の逆数を累乗(1/2.2=約0.45乗)しておくことで、ガンマ補正を打ち消します。 これをデガンマと言います。

https://tech.cygames.co.jp/wp-content/uploads/2015/08/2015-08-21_gamma2.png 物理ベースレンダリング -リニアワークフロー編 (1)- | Cygames Engineers' Blog引用

リニア色空間

リニア色空間とは中間の明るさが0.5の色空間です。 数値的にリニアですが、物理的には間違えている色空間です。

https://cdn-ak.f.st-hatena.com/images/fotolife/h/halya_11/20200210/20200210231541.png リニアワークフロー入門 簡単な説明とその意義 - LIGHT11引用

sRGB色空間

sRGB色空間とは中間の明るさが約0.22の色空間です。 輝度が1/2.2=約0.45乗されているので、数値的にノンリニアですが、物理的に正しい色空間です。

https://cdn-ak.f.st-hatena.com/images/fotolife/h/halya_11/20200210/20200210232617.png リニアワークフロー入門 簡単な説明とその意義 - LIGHT11引用

デザイナーが見ているモニターはsRGB色空間なので、デザイナーが手書きした画像もsRGB色空間です。

https://4.bp.blogspot.com/-3ShSfgxoPsU/UnyHW6sfF8I/AAAAAAAAaic/qVLrZIciaUE/s800/illustrator.png

sRGB色空間の画像は輝度が1/2.2乗されているので(デガンマされている)、ディスプレイガンマで2.2乗されることで結果的にリニア色空間になります。

https://tech.cygames.co.jp/wp-content/uploads/2015/09/sRGB_Linear.png 物理ベースレンダリング -リニアワークフロー編 (2)- | Cygames Engineers' Blog引用 しかし、人間の目に届く時には視覚のガンマで約0.45乗されます。

エンジニアが意識すること

デザイナーが作成した画像などのsRGB色空間はディスプレイガンマと相殺させることができるという説明をしました。 逆にリニア色空間のライトはディスプレイガンマが掛かって色が暗くなってしまうので、対策をしなければなりません。

また、sRGB色空間とリニア色空間は色空間が違うため、[sRGB色空間の画像カラー * リニア色空間のライトカラー]のように一緒に計算できません。 sRGB色空間をリニア色空間に変換してから、計算する必要があります。 どちらの色空間に合わせても良いのですが、計算しやすいようにリニア色空間にします。 その上で、計算結果をディスプレイガンマと相殺させるために、sRGB色空間に戻す必要があります。 これがリニアワークフローです。

物理ベースレンダリングでどれだけ物理的にライティング計算を行っても、結果を正しく出力できなければ意味がありません。

実装と解説

Unityでリニアワークフローを実装しました。 球体にテクスチャを貼り付け、簡単なライティングを行なっています。 左がガンマワークフロー(従来の何もしない手法)で右がリニアワークフローです。 ガンマワークフローではディスプレイガンマがかかっていて、暗くなっていますが、リニアワークフローではデガンマしているので、暗くなっていません。 f:id:Ny_Program:20210828122335p:plain

以下、シェーダーコードです。

Shader "LinearWorkflow"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Intensity("Intensity", Range(0,1)) = 0
        [Toggle] _IS_LINEAR("Is Linear", Float) = 0
        [Toggle] _USE_FAST("Use Fast", Float) = 0
    }
    
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #pragma shader_feature _ _USE_FAST_ON
            #pragma shader_feature _ _IS_LINEAR_ON

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

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

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                o.normal = UnityObjectToWorldNormal(v.normal);
                return o;
            }

            sampler2D _MainTex;
            half _Intensity;

            #define FLT_EPSILON     1.192092896e-07 // Smallest positive number, such that 1.0 + FLT_EPSILON != 1.0
            
            
            float3 PositivePow(float3 base, float3 power)
            {
                return pow(max(abs(base), float3(FLT_EPSILON, FLT_EPSILON, FLT_EPSILON)), power);
            }


            half3 sRGBToLinear(half3 c)
            {
                half3 linearRGB;
                #ifdef _USE_FAST_ON
                    linearRGB = pow(c,2.2);
                #else 
                    half3 linearRGBLo = c / 12.92;
                    half3 linearRGBHi = PositivePow((c + 0.055) / 1.055, half3(2.4, 2.4, 2.4));
                    linearRGB = (c <= 0.04045) ? linearRGBLo : linearRGBHi;
                #endif
                return linearRGB;
            }

            
            half4 sRGBToLinear(half4 c)
            {
                return half4(sRGBToLinear(c.rgb), c.a);
            }


            half3 LinearTosRGB(half3 c)
            {
                half3 sRGB;
                #ifdef _USE_FAST_ON
                    sRGB = pow(c, 1 / 2.2);
                #else
                    half3 sRGBLo = c * 12.92;
                    half3 sRGBHi = (PositivePow(c, half3(1.0 / 2.4, 1.0 / 2.4, 1.0 / 2.4)) * 1.055) - 0.055;
                    sRGB = (c <= 0.0031308) ? sRGBLo : sRGBHi;
                #endif
                return sRGB;
            }

            half4 LinearTosRGB(half4 c)
            {
                return half4(LinearTosRGB(c.rgb), c.a);
            }


            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 albedo = tex2D(_MainTex, i.uv);
                #if UNITY_COLORSPACE_GAMMA && _IS_LINEAR_ON
                    albedo = sRGBToLinear(albedo);
                #endif
                half lambert = max(0,dot(i.normal,WorldSpaceLightDir(i.vertex))) * _Intensity;
                fixed4 retColor = albedo * lambert;
                #if UNITY_COLORSPACE_GAMMA && _IS_LINEAR_ON
                    retColor = LinearTosRGB(retColor);
                #endif            
                return retColor;
            }
            ENDCG
        }
    }
}
Propertiesの中身

_Intensity ライトの強度

_IS_LINEAR オンにするとリニアワークフローが有効。オフにするとガンマワークフローになります。

_USE_FAST オンにするとリニアワークフローの近似式を使用します。

sRGB色空間 → リニア色空間

紫の枠の部分です。

f:id:Ny_Program:20210828131311p:plain 分かる!リニアワークフローのコンポジット - コンポジゴク

      fixed4 albedo = tex2D(_MainTex, i.uv);
#if UNITY_COLORSPACE_GAMMA && _IS_LINEAR_ON
      albedo = sRGBToLinear(albedo);
#endif

sRGB色空間のテクスチャをリニア色空間に変換しています。

UNITY_COLORSPACE_GAMMAはEdit>ProjectSetting>OtherSetting>Color Spaceで、ガンマ(デフォルト)にすると有効になり、リニアを選択すると無効になります。

f:id:Ny_Program:20210827135855p:plain:w500

コードを載せていますが、実はUnityの設定だけでリニアワークフローは実現できます。

ColorSpaceをリニアにしておき、テクスチャ設定でsRGBにしておけば、UNITY_COLORSPACE_GAMMA内の処理と同じようなことをやってくれます。 f:id:Ny_Program:20210828132127p:plain

今回はリニアワークフローを理解するためにColorSpaceをガンマにした状態でも、リニアワークフローが効くようにします。

            half3 sRGBToLinear(half3 c)
            {
                half3 linearRGB;
                #ifdef _USE_FAST_ON
                    linearRGB = pow(c,2.2);
                #else 
                    half3 linearRGBLo = c / 12.92;
                    half3 linearRGBHi = PositivePow((c + 0.055) / 1.055, half3(2.4, 2.4, 2.4));
                    linearRGB = (c <= 0.04045) ? linearRGBLo : linearRGBHi;
                #endif
                return linearRGB;
            }

            
            half4 sRGBToLinear(half4 c)
            {
                return half4(sRGBToLinear(c.rgb), c.a);
            }

sRGBToLinearの中身です。

sRGB→リニア変換を行なっています。

_USE_FAST_ONでは近似式を使用しています。 引数のカラーに対して、2.2乗しています。

#elseの中身はこちらのリポジトリから引用しました。 ご参考にどうぞ。 github.com

リニア色空間でライティング計算

f:id:Ny_Program:20210828134848p:plain 分かる!リニアワークフローのコンポジット - コンポジゴク

half lambert = max(0,dot(i.normal,WorldSpaceLightDir(i.vertex))) * _Intensity;
fixed4 retColor = albedo * lambert;

リニア色空間でライティングの計算を行います。 ライトはリニア色空間なのでそのまま計算に使います。

リニア色空間→sRGB色空間

f:id:Ny_Program:20210828135215p:plain 分かる!リニアワークフローのコンポジット - コンポジゴク

#if UNITY_COLORSPACE_GAMMA && _IS_LINEAR_ON
      retColor = LinearTosRGB(retColor);
#endif            
      return retColor;

シェーダー最後のフローです。 リニア色空間からsRGB色空間に変換します。 sRGB色空間にしておくことで、ディスプレイガンマがかかった時にリニアになります。

ここでも、ColorSpaceをリニアにすることで、UNITY_COLORSPACE_GAMMA内の処理と同じようなことをやってくれます。

            half3 LinearTosRGB(half3 c)
            {
                half3 sRGB;
                #ifdef _USE_FAST_ON
                    sRGB = pow(c, 1 / 2.2);
                #else
                    half3 sRGBLo = c * 12.92;
                    half3 sRGBHi = (PositivePow(c, half3(1.0 / 2.4, 1.0 / 2.4, 1.0 / 2.4)) * 1.055) - 0.055;
                    sRGB = (c <= 0.0031308) ? sRGBLo : sRGBHi;
                #endif
                return sRGB;
            }

            half4 LinearTosRGB(half4 c)
            {
                return half4(LinearTosRGB(c.rgb), c.a);
            }

LinearTosRGBの中身です。

リニア→sRGB変換を行なっています。

引数のカラーに対して、1/2.2乗しています。

ディスプレイガンマ

f:id:Ny_Program:20210828141311p:plain 分かる!リニアワークフローのコンポジット - コンポジゴク 輝度を2.2乗して出力します。

sRGB色空間で出力したので、リニア色空間になります。

視覚のガンマ

f:id:Ny_Program:20210828141118p:plain 分かる!リニアワークフローのコンポジット - コンポジゴク

最初に紹介し、置き去りにされた視覚のガンマがかかります。

輝度が1/2.2乗されて目に届きます。

普段私たちは視覚のガンマがかかった状態なので、視覚のガンマに対して、デガンマは行いません。

参考

compojigoku.blog.fc2.com tech.cygames.co.jp tech.cygames.co.jp docs.unity3d.com r-ngtm.hatenablog.com

www.slideshare.net

ステンシルテストの各プロパティ解説

今回は、ステンシルテストにおける主要なプロパティである RefReadMaskCompWriteMask について、例とともにわかりやすく解説します。

docs.unity3d.com

Ref(リファレンス値)

Ref は、ステンシルバッファの値と比較する基準値です。
0〜255 の範囲で指定され、内部ではビット単位で扱われます。

Ref = 3;  // 0000 0011(2進表記)

この値と、ステンシルバッファの値が ReadMaskComp の条件に基づいて比較されます。

ReadMask(リードマスク)

ReadMask は、比較に使うビット位置を指定します。
1 のビットのみが比較対象になり、0 のビットは無視されます。

Ref = 3;          // 0000 0011
ReadMask = 1;     // 0000 0001

この例では、最下位ビット(ビット0) のみが比較対象です。
つまり、ステンシルバッファ & 1 == Ref & 1 の条件で比較されます。

Comp(比較条件)

Comp は、Ref とステンシルバッファの値をどのように比較するかを指定します。
代表的な比較条件は以下のとおりです:

  • Equal: 一致する場合に合格
  • NotEqual: 一致しない場合に合格
  • Less: バッファの値が Ref より小さい場合に合格
  • Greater: バッファの値が Ref より大きい場合に合格

例:Equal 条件

Comp = Equal;
Ref = 2;          // 0000 0010
ReadMask = 3;     // 0000 0011

この場合、下位2ビット10 と一致するかどうかで判定されます。

WriteMask(ライトマスク)

WriteMask は、ステンシルテストに合格した場合に書き込むビット位置を指定します。
WriteMask の各ビットが 1 になっている場所のみが書き込み対象となり、他のビットは変更されません。

WriteMask = 2;    // 0000 0010

この場合、Ref のビット 1 のみがステンシルバッファに反映されます。

Unityシェーダーにおける Offset(深度バイアス)の使い方

Offset とは?

OffsetはZファイティングを回避するために深度値にバイアスをかける機能です。 シェーダー内で以下のように記載します。

Offset <factor>, <bias>

docs.unity3d.com

factor と bias の意味と影響

パラメータ 説明 推奨値
factor ポリゴンの角度に応じて深度に加わる値 通常は 0〜2程度
bias 常に深度に加算される固定のオフセット 通常は 0.0001〜0.001 程度

使用例(Unityのシェーダーパス)

Pass {
    Name "DepthPrePass"
    Tags { "LightMode" = "DepthOnly" }
    Offset 1, 0.001
}

この例では、深度値に 0.001 を加えることで、同一深度にある他のジオメトリとの競合を回避します。


Zファイティングは シーンのスケール、使用している深度バッファの精度、カメラのNear/Far設定も影響するので合わせて見直してみるのがいいです。 オブジェクト同士を少し離すのもありです。


LoadAction と StoreAction の最適化について

Unity で用いられる RenderBufferLoadAction/RenderBufferStoreAction(以下、LoadAction/StoreAction と記述します)の動作と、不要なデータ転送を避けるための最適化手法について解説します。


LoadAction(読み込み時の動作)

描画処理を始めるときに、既存のバッファ内容をどのように扱うかを定義します。

説明
Load 既存のバッファ内容を読み込んで使用する。
例:前のフレームやパスの結果を利用したい場合。
DontCare バッファ内容を無視する。
例:上書き前提の描画など、前の内容を利用しない場合。
Clear ※一部のAPIのみ。描画前にバッファを明示的にクリア(初期化)する。
Unityでは CommandBuffer.ClearRenderTarget 等で代用可能。

StoreAction(描画後の保存動作)

描画処理が終わったあとに、バッファ内容をどのように扱うか(保存するかどうか)を定義します。

説明
Store 描画結果をそのまま保存する。
例:次のパスや最終出力で使用する場合。
DontCare 描画結果の保存が不要。
例:中間パスや一時バッファの場合、保存コストを削減するために使用。

LoadAction / StoreAction の使い分け例

シーン LoadAction StoreAction 説明
最初のカメラ描画パス(全画面上書き) DontCare Store 前の内容は不要。描いた内容は最終出力に必要。
Additive系のブレンディングパス Load Store 前の内容を引き継ぎながら加算描画を行う。
シャドウマップ用の一時的な深度バッファ DontCare DontCare 毎フレーム再構築されるため、読み書きの必要がない。
一部だけ描画するマスク合成パス Load Store 未描画領域は前の内容を残す必要がある場合に使用。
最終出力の後処理エフェクト(PostProcess) Load Store 前の描画結果を利用して処理し、そのまま保存。
一時バッファへの出力で使い捨ての場合 DontCare DontCare 中間処理用なので、保存も読み込みも不要。

LoadAction と StoreAction による最適化

Load を使用すると、前のバッファ内容を読み込むため、メインメモリからグラフィックスメモリへのデータ転送が発生します。これにより、必要なデータが高速なグラフィックスメモリで利用可能になります。一方、Store を使用すると、描画結果がグラフィックスメモリからメインメモリに書き戻され、次の描画パスやフレームで再利用できるように保存されます。

これらの操作が頻繁に行われると、メインメモリとグラフィックスメモリ間でのデータ転送が増え、メモリ帯域幅の消費や遅延がパフォーマンス低下を引き起こす可能性があります。
したがって、描画処理において前のデータが不要な場合や、中間処理結果を次回利用しない場合は、DontCare を活用して不要な Load や Store 操作を回避することが有効です。これにより、GPU のメモリ帯域が効率的に使用され、全体のパフォーマンスが向上します。

メインメモリとグラフィックスメモリの簡単な説明

  • メインメモリ
    GPU(またはCPU)の外部にある大容量のメモリ。
    容量は大きいが、アクセス速度はグラフィックスメモリに比べて遅い。

  • グラフィックスメモリ
    GPU内部に搭載された高速なメモリ。
    キャッシュや一時バッファとして利用され、処理中のデータアクセスを高速化する。
    容量はメインメモリに比べて小さい。

【Unity】ComputeShader + Graphics.DrawMeshInstancedIndirectで大量のオブジェクトを描画する

今回はComputeShaderで大量のオブジェクトの座標を計算して、それをGPUインスタンシングを使って描画します。

オブジェクトの移動アルゴリズムBoidアルゴリズムを使用していますが、今回こちらの解説は行いません。 youtu.be

Step1 : ComputeShaderに必要な情報を渡し実行する

Boid.cs

using System;
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.Rendering;
using Random = Unity.Mathematics.Random;

public class Boids : MonoBehaviour
{
    public struct BoidState
    {
        public Vector3 Position;
        public Vector3 Forward;
    }

    [Serializable]
    public class BoidConfig
    {
        public float moveSpeed = 1f;
        // 分離
        [Range(0f, 1f)] public float separationWeight = .5f;
        // 整列
        [Range(0f, 1f)] public float alignmentWeight = .5f;
        // 結合
        [Range(0f, 1f)] public float targetWeight = .5f;

        public Transform boidTarget;
    }

    public int boidCount = 10000;

    public float3 boidExtent = new(32f, 32f, 32f);

    public ComputeShader BoidComputeShader;

    public BoidConfig boidConfig;

    GraphicsBuffer _boidBuffer;
    GraphicsBuffer _argsBuffer;
    int _kernelIndex;

    [SerializeField]
    Mesh mesh;
    [SerializeField]
    Material drawMaterial;

    void Start()
    {
        InitializeArgsBuffer();
        InitializeBoidsBuffer();
    }

    private void InitializeArgsBuffer()
    {
        var args = new uint[] { 0, 0, 0, 0, 0 };

        args[0] = mesh.GetIndexCount(0);
        args[1] = (uint)boidCount;

e        _argsBuffer.SetData(args);
    }

    private void InitializeBoidsBuffer()
    {
        var random = new Random(256);
        var boidArray = new NativeArray<BoidState>(boidCount, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
        for (var i = 0; i < boidArray.Length; i++)
        {
            boidArray[i] = new BoidState
            {
                Position = random.NextFloat3(-boidExtent, boidExtent),
                Forward = math.rotate(random.NextQuaternionRotation(), Vector3.forward),
            };
        }
        // GraphicsBuffer生成
        _boidBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, boidArray.Length, Marshal.SizeOf<BoidState>());
        // boidBufferに値を設定
        _boidBuffer.SetData(boidArray);
    }

    void Update()
    {
        UpdateBoids();
        RenderMesh();
    }

    void UpdateBoids()
    {
        var boidTarget = boidConfig.boidTarget != null
            ? boidConfig.boidTarget.position
            : transform.position;
        BoidComputeShader.SetFloat("deltaTime", Time.deltaTime);
        BoidComputeShader.SetFloat("separationWeight", boidConfig.separationWeight);
        BoidComputeShader.SetFloat("alignmentWeight", boidConfig.alignmentWeight);
        BoidComputeShader.SetFloat("targetWeight", boidConfig.targetWeight);
        BoidComputeShader.SetFloat("moveSpeed", boidConfig.moveSpeed);
        BoidComputeShader.SetVector("targetPosition", boidTarget);
        // ComputeShaderに生成するインスタンスの数をセット
        BoidComputeShader.SetInt("numBoids", boidCount);
        _kernelIndex = BoidComputeShader.FindKernel("CSMain");

        BoidComputeShader.GetKernelThreadGroupSizes(_kernelIndex, out var x, out var y, out var z);

        // ComputeShaderにboidBufferをセット
        BoidComputeShader.SetBuffer(_kernelIndex, "boidBuffer", _boidBuffer);
        // ComputeShaderを実行
        BoidComputeShader.Dispatch(_kernelIndex, (int)(boidCount / x), 1, 1);
    }

    void RenderMesh()
    {
        if (!SystemInfo.supportsInstancing)
        {
            return;
        }
        drawMaterial.SetBuffer("_BoidDataBuffer", _boidBuffer);
        BoidState[] data = new BoidState[10];
        _boidBuffer.GetData(data);
        Debug.Log(data);
        Graphics.DrawMeshInstancedIndirect
        (
            mesh,
            0,
            drawMaterial,
            new Bounds(Vector3.zero, new Vector3(1000.0f, 1000.0f, 1000.0f)),
            _argsBuffer
        );
    }

    void OnDisable()
    {
        _boidBuffer?.Dispose();
        _argsBuffer?.Dispose();
    }
}

InitializeBoidsBufferでComputeShaderに渡すGraphicsBufferを作成しています。 BoidState(位置と進行方向を表す情報)を格納します。 boidBufferと名付けました。

// GraphicsBuffer生成
_boidBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, boidArray.Length, Marshal.SizeOf<BoidState>());
// boidBufferに値を設定
_boidBuffer.SetData(boidArray);

UpdateBoidsで必要な情報やboidBufferをComputeShaderに渡して、実行しています。

// ComputeShaderにboidBufferをセット
BoidComputeShader.SetBuffer(_kernelIndex, "boidBuffer", _boidBuffer);
// ComputeShaderを実行
BoidComputeShader.Dispatch(_kernelIndex, (int)(boidCount / x), 1, 1);

Step2 : ComputeShaderでオブジェクトの座標と進行方向を計算する

ComputeShaderはこちらを使用させていただきました。 github.com 先ほど送ったboidBufferはRWStructuredBufferで宣言されています。 これは読み書き可能なBuffferであることを意味しています。 今回は解説しませんが、Boid.csから送ったboidBufferの情報を使いつつ、計算結果をboidBufferに格納しています。

RWStructuredBuffer<BoidState> boidBuffer;

Step3 : GPUインスタンシングで描画を行う

Boid.cs

using System;
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.Rendering;
using Random = Unity.Mathematics.Random;

public class Boids : MonoBehaviour
{
    public struct BoidState
    {
        public Vector3 Position;
        public Vector3 Forward;
    }

    [Serializable]
    public class BoidConfig
    {
        public float moveSpeed = 1f;
        // 分離
        [Range(0f, 1f)] public float separationWeight = .5f;
        // 整列
        [Range(0f, 1f)] public float alignmentWeight = .5f;
        // 結合
        [Range(0f, 1f)] public float targetWeight = .5f;

        public Transform boidTarget;
    }

    public int boidCount = 10000;

    public float3 boidExtent = new(32f, 32f, 32f);

    public ComputeShader BoidComputeShader;

    public BoidConfig boidConfig;

    GraphicsBuffer _boidBuffer;
    GraphicsBuffer _argsBuffer;
    int _kernelIndex;

    [SerializeField]
    Mesh mesh;
    [SerializeField]
    Material drawMaterial;

    void Start()
    {
        InitializeArgsBuffer();
        InitializeBoidsBuffer();
    }

    private void InitializeArgsBuffer()
    {
        var args = new uint[] { 0, 0, 0, 0, 0 };

        args[0] = mesh.GetIndexCount(0);
        args[1] = (uint)boidCount;

e        _argsBuffer.SetData(args);
    }

    private void InitializeBoidsBuffer()
    {
        var random = new Random(256);
        var boidArray = new NativeArray<BoidState>(boidCount, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
        for (var i = 0; i < boidArray.Length; i++)
        {
            boidArray[i] = new BoidState
            {
                Position = random.NextFloat3(-boidExtent, boidExtent),
                Forward = math.rotate(random.NextQuaternionRotation(), Vector3.forward),
            };
        }
        // GraphicsBuffer生成
        _boidBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, boidArray.Length, Marshal.SizeOf<BoidState>());
        // boidBufferに値を設定
        _boidBuffer.SetData(boidArray);
    }

    void Update()
    {
        UpdateBoids();
        RenderMesh();
    }

    void UpdateBoids()
    {
        var boidTarget = boidConfig.boidTarget != null
            ? boidConfig.boidTarget.position
            : transform.position;
        BoidComputeShader.SetFloat("deltaTime", Time.deltaTime);
        BoidComputeShader.SetFloat("separationWeight", boidConfig.separationWeight);
        BoidComputeShader.SetFloat("alignmentWeight", boidConfig.alignmentWeight);
        BoidComputeShader.SetFloat("targetWeight", boidConfig.targetWeight);
        BoidComputeShader.SetFloat("moveSpeed", boidConfig.moveSpeed);
        BoidComputeShader.SetVector("targetPosition", boidTarget);
        // ComputeShaderに生成するインスタンスの数をセット
        BoidComputeShader.SetInt("numBoids", boidCount);
        _kernelIndex = BoidComputeShader.FindKernel("CSMain");

        BoidComputeShader.GetKernelThreadGroupSizes(_kernelIndex, out var x, out var y, out var z);

        // ComputeShaderにboidBufferをセット
        BoidComputeShader.SetBuffer(_kernelIndex, "boidBuffer", _boidBuffer);
        // ComputeShaderを実行
        BoidComputeShader.Dispatch(_kernelIndex, (int)(boidCount / x), 1, 1);
    }

    void RenderMesh()
    {
        if (!SystemInfo.supportsInstancing)
        {
            return;
        }
        drawMaterial.SetBuffer("_BoidDataBuffer", _boidBuffer);
        BoidState[] data = new BoidState[10];
        _boidBuffer.GetData(data);
        Debug.Log(data);
        Graphics.DrawMeshInstancedIndirect
        (
            mesh,
            0,
            drawMaterial,
            new Bounds(Vector3.zero, new Vector3(1000.0f, 1000.0f, 1000.0f)),
            _argsBuffer
        );
    }

    void OnDisable()
    {
        _boidBuffer?.Dispose();
        _argsBuffer?.Dispose();
    }
}

描画するマテリアルにboidBufferを渡しています。 Graphics.DrawMeshInstancedIndirectは指定したマテリアルをGPUインスタンシングで描画することができます。

Graphics.DrawMeshInstancedIndirect(Mesh instanceMesh, int subMeshIndex, Material instanceMaterial, Vector3 bounds, ComputeBuffer argsBuffer);

Mesh instanceMesh : InstansingしたいMesh

int subMeshIndex : 何番目のSubMeshを使用するか

Material instanceMaterial : InstansingするMeshに適応させるMaterial

Vector3 bounds : 描画するインスタンスを囲む境界ボリューム

GraphicsBuffer argsBuffer : InstancingするためのMesh情報

 void RenderMesh()
    {
        if (!SystemInfo.supportsInstancing)
        {
            return;
        }
        drawMaterial.SetBuffer("_BoidDataBuffer", _boidBuffer);
        BoidState[] data = new BoidState[10];
        _boidBuffer.GetData(data);
        Debug.Log(data);
        Graphics.DrawMeshInstancedIndirect
        (
            mesh,
            0,
            drawMaterial,
            new Bounds(Vector3.zero, new Vector3(1000.0f, 1000.0f, 1000.0f)),
            _argsBuffer
        );
    }

argsBufferはuint型の配列を格納するBufferです。 Meshのインデックスと、Meshの数を持たせます。

    private void InitializeArgsBuffer()
    {
        var args = new uint[] { 0, 0, 0, 0, 0 };

        args[0] = mesh.GetIndexCount(0);
        args[1] = (uint)boidCount;

        _argsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, args.Length, sizeof(uint));
        _argsBuffer.SetData(args);
    }

BoidBufferの情報を使って描画する

Graphics.DrawMeshInstancedIndirec

Boids.shader

Shader "Boids"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _ObjectScale ("Scale", Vector) = (1,1,1,1)
    }
    SubShader
    {
        Pass
        {
            Tags { "RenderType"="Opaque" }
            LOD 200
            
            HLSLPROGRAM
            #pragma instancing_options procedural:setup
            #pragma multi_compile_instancing
            #pragma target 4.5

            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            struct Attributes
            {
                float4 positionOS   : POSITION;
                float3 normalOS     : NORMAL;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct Varyings
            {
                float2 uv                       : TEXCOORD0;
                float4 positionCS               : SV_POSITION;
                half3 data : TEXCOORD1;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            // Boidの構造体
            struct BoidState
            {
                float3 position; // 位置
                float3 forward; // 速度
            };

           #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
                // Boidデータの構造体バッファ
                StructuredBuffer<BoidState> _BoidDataBuffer;
           #endif

            sampler2D _MainTex; // テクスチャ
            half3 _Color;      // カラー
            float3 _ObjectScale; // Boidオブジェクトのスケール

            // オイラー角(ラジアン)を回転行列に変換
            float4x4 eulerAnglesToRotationMatrix(float3 angles)
            {
                float ch = cos(angles.y); float sh = sin(angles.y); // heading
                float ca = cos(angles.z); float sa = sin(angles.z); // attitude
                float cb = cos(angles.x); float sb = sin(angles.x); // bank

                // Ry-Rx-Rz (Yaw Pitch Roll)
                return float4x4(
                ch * ca + sh * sb * sa, -ch * sa + sh * sb * ca, sh * cb, 0,
                cb * sa, cb * ca, -sb, 0,
                -sh * ca + ch * sb * sa, sh * sa + ch * sb * ca, ch * cb, 0,
                0, 0, 0, 1
                );
            }

            // 頂点シェーダ
            Varyings vert(Attributes input, uint instanceID : SV_InstanceID)
            {

                Varyings output = (Varyings)0;
                #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED

                // インスタンスIDからBoidのデータを取得
                BoidState boidData = _BoidDataBuffer[instanceID]; 

                float3 pos = boidData.position.xyz; // Boidの位置を取得
                float3 scl = _ObjectScale;          // Boidのスケールを取得

                // ワールド行列
                float4x4 object2world = (float4x4)0; 
                // スケール値を代入
                object2world._11_22_33_44 = float4(scl.xyz, 1.0);

                // 速度からY軸についての回転を算出
                float rotY = atan2(boidData.forward.x, boidData.forward.z);
                // 速度からX軸についての回転を算出
                float rotX = -asin(boidData.forward.y / (length(boidData.forward.xyz) + 1e-8));
                // オイラー角(ラジアン)から回転行列を求める
                float4x4 rotMatrix = eulerAnglesToRotationMatrix(float3(rotX, rotY, 0));
                // 行列に回転を適用
                object2world = mul(rotMatrix, object2world);
                // 行列に位置(平行移動)を適用
                object2world._14_24_34 += pos.xyz;

                // 頂点を座標変換
                float4 positionWS = mul(object2world, input.positionOS);
                output.positionCS = TransformWorldToHClip(positionWS);
                #endif
                return output;
            }
            
            void setup()
            {
            }

            half4 frag(Varyings input) : SV_Target
            {
                return half4(_Color,1);
            }
            ENDHLSL
        }
    }
}

instancing_options proceduralはGraphics.DrawMeshInstancedIndirectと一緒に使用されます。 setupの部分は任意の関数名を指定できます。

ここで、指定した関数は頂点シェーダーが実行される前に各インスタンスごとに呼ばれます。

公式リファレンスではBufferの回転値から、変換行列を変更しているようですが、今回はsetup関数に処理を入れず頂点シェーダーで行います。

Unity - Scripting API: Graphics.DrawMeshInstancedIndirect

#pragma instancing_options procedural:setup

UNITY_PROCEDURAL_INSTANCING_ENABLEDはGraphics.DrawMeshInstancedIndirect(Graphics.DrawMeshInstancedも)を使用して描画するときに有効になります。

BoidComputeShader.SetBufferで、送られたデータが、StructuredBufferで宣言した_BoidDataBufferに格納されます。

// Boidの構造体
struct BoidState
{
   float3 position; // 位置
   float3 forward; // 速度
};

#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
     // Boidデータの構造体バッファ
     StructuredBuffer<BoidState> _BoidDataBuffer;
#endif

_BoidDataBuffer[instanceID]で、処理中の頂点のインスタンスからBoidDataを取得して、位置や回転の計算しています。

Varyings vert(Attributes input, uint instanceID : SV_InstanceID)
{
    Varyings output = (Varyings)0;
    #ifdef UNITY_PROCEDURAL_INSTANCING_ENABL
    // インスタンスIDからBoidのデータを取得
    BoidState boidData = _BoidDataBuffer[instanceID]; 
・
・
・

参考

edom18.hateblo.jp

www.youtube.com

【Unity】ディザリングを実装してみた

ディザリングとは

一定間隔で穴を空けることで、不透明だけど半透明に見せることができます。 通常の半透明で発生する不具合や、処理負荷の問題が発生しないのが利点です。 youtu.be

穴を開けるパターン

現在描画するピクセルがを開けるか判定する方法の一つとして、しきい値マップという数値の配列を使用します。 ピクセルの位置に対応する閾値マップの値と設定した閾値を比較して、ピクセルの破棄を行うか判定します。

https://wikimedia.org/api/rest_v1/media/math/render/svg/3c62838dbbd378c058444a60b9c803b9bb4ee09c

配列ディザリング - Wikipedia引用

実装

Shader "Dither"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _DitherLevel("DitherLevel", Range(0, 16)) = 1
    }
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 positionCS : SV_POSITION;
                float4 positionSS : TEXCOORD1;
            };
            
            // しきい値マップ
            static const float4x4 pattern =
            {
                0,8,2,10,
                12,4,14,6,
                3,11,1,9,
                15,7,13,565
            };
            static const int PATTERN_ROW_SIZE = 4;

            sampler2D _MainTex;
            sampler2D _DitherTex;
            float _Alpha;
            half _DitherLevel;

            v2f vert (appdata v)
            {
                v2f o;
                o.positionCS = UnityObjectToClipPos(v.positionOS);
                o.positionSS = ComputeScreenPos(o.positionCS);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // ①
                // スクリーン座標
                float2 screenPos = i.positionSS.xy / i.positionSS.w;
                // 画面サイズを乗算して、ピクセル単位に
                float2 screenPosInPixel = screenPos.xy * _ScreenParams.xy;

                // ②
                // ディザリングテクスチャ用のUVを作成
                int ditherUV_x = (int)fmod(screenPosInPixel.x, PATTERN_ROW_SIZE);
                int ditherUV_y = (int)fmod(screenPosInPixel.y, PATTERN_ROW_SIZE);
                float dither = pattern[ditherUV_x, ditherUV_y];
                  
                 // ③
                 // 閾値が0以下なら描画しない
                clip(dither - _DitherLevel);

                //メインテクスチャからサンプリング`
                float4 color = tex2D(_MainTex, i.uv); 
                return color;
            }
            ENDCG
        }
    }
}

ComputeScreenPosで求めた値を正規化すると0〜1のスクリーン座標になります。

そこに_ScreenParams.xy=スクリーンの幅と高さ(ピクセル単位)を乗算することで、0〜スクリーンの幅、高さの値にしています。

詳しくはこちら ny-program.hatenablog.com

しきい値マップは4x4の行列なので①で計算したスクリーン座標を4で除算した余りを求め、しきい値マップの成分を取り出し、それを閾値とします。 これにより、スクリーン上を縦横4x4分割し、分割された単位ごとにしきい値マップの成分が割り当てられることになります。

しきい値マップから取得した閾値よりマテリアルプロパティから設定した閾値が大きければそのピクセルの描画を破棄して穴を開けています。

参考

3dcg-school.pro

【Unity】Compute Shaderの基礎

ComputeShaderとは

GPUで実行するプログラムです 画像処理、ポストエフェクト、物理シミュレーション、アニメーション、水面シミュレーション 、パーティクル演算などCPUよりもGPUの並列計算を使用したい時に使われます。

カーネル

カーネルとは、GPUで実行される関数のことです。

#pragma kernel CSMain

#pragma kernelの後にCSMainという関数名を指定しています。

スレッド

スレッドとは、カーネルを実行する単位で、1スレッドが1カーネルを実行します。コンピュートシェーダーではカーネルを複数のスレッドで並行して同時に実行することができます。

[numthreads(10,8,3)]
void CSMain ()
{
}

スレッド数はカーネルの上に[numthreads(X,Y,Z)]と定義します。 X * Y * Zのスレッドが生成されます。 今回の例では10 * 8 * 3 = 240スレッド生成されます。

スレッドグループ

スレッドグループとは、スレッドを実行する単位で、1スレッドグループで、指定したスレッド数のカーネルを実行します。

int kernelIndex = shader.FindKernel("CSMain");
shader.Dispatch(kernelIndex, 5, 3, 2);

スレッドグループはコンピュートシェーダーを実行する shader.Dispatchの引数で指定します。 第一引数はshader.FindKernelで取得したカーネルのインデックスを指定します。

残りの引数で、スレッドグループ数を指定します。 スレッド数と同じように、5 * 3 * 2 = 30グループとなります。

これは[numthreads(10,8,3)](下の図)で合計240スレッドが指定されたカーネルDispatch(5,3,2)(上の図)で合計30グループのスレッドグループからなるスレッド群を実行している図です。(つまり合計7,200スレッド)

セマンティクス

カーネルの引数にはセマンティクスを指定できます。 上の画像を見ながら、読んでください。

SV_GroupThreadID

実行しているグループ内でのスレッドIDです。

SV_GroupID

実行しているスレッドグループのIDです。

SV_DispatchThreadID

実行しているスレッドが、どのスレッドグループのどのスレッドかを識別できるIDで、以下の式によって求められます。

SV_GroupID * numthreads + SV_GroupThreadID

SV_GroupID * numthreadsで、実行しているスレッドグループまでの合計スレッド数を求め、+ SV_GroupThreadIDで実行しているスレッドIDを加算しています。

SV_GroupIndex

SV_GroupThreadIDを数値を一つにして返します。

Compute Shaderに値を渡す

Compute Shaderで、変数を定義

float hoge;

スクリプトから、値を渡す

shader.SetFloat("hoge", 1.0f);

ComputeShaderにバッファを渡す

Compute Shaderで、バッファを定義

RWがついている型は読み書きが可能な変数です。 ついていない型は読み込みのみです。

struct Data
{
    float3 position;
    floar3 rotation;
};
RWStructuredBuffer<Data> Result;

スクリプトから、バッファを渡す

public struct Data
{
    public Vector3 Position;
    public Vector3 Rotation;
}
Data[] dataList = new Data[100];

// dataListに値を入れる

//GraphicsBuffer作成
GraphicsBuffer buffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,dataList.Length, sizeof(Data)));
// GraphicsBufferにデータを設定
buffer.SetData(dataList);
// ComputeShaderにバッファを渡す
shader.SetBuffer(kernelIndex, "dataList", buffer);

ComputeShaderからバッファを受け取る

スクリプトで、GraphicsBufferのGetDataを使えば簡単に取得できます。

buffer.GetData(dataList);

参考

edom18.hateblo.jp

edom18.hateblo.jp

shitakami.hatenablog.com

【Unity】【URP】LUT (ルックアップテーブル)

カラーグレーディング(Color Grading) とは

画面の色合いを調整するポストエフェクトです。

LUTとは

ルックアップテーブル(Look Up Table)の略称で、入力されたRGBを指定した別のRGBに変換するための情報が格納されてたテクスチャです。 カラーグレーディングで使われます。

URPにおけるカラーグレーディング

設定

Grading Mode

LUTテクスチャのグラフィックフォーマットをLDR/HDRのいずれにするかを指定します。

LUT size

LUTテクスチャのサイズを設定します。設定した値Aに対し「高さA、幅A×A」 サイズのテクスチャが生成されます。

LUTの作成

ColorGradingLutPassでLUTを生成するシェーダーを実行します。 以下はExecute関数です。 各カラーグレーディングの設定をシェーダーに渡し、LUTテクスチャをレンダーターゲットとして描画の実行をしています。

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    var cmd = CommandBufferPool.Get();
    using (new ProfilingScope(cmd, ProfilingSampler.Get(URPProfileId.ColorGradingLUT)))
    {
        // 各カラーグレーディングの設定はVolumeで行います
        // 追加したVolumeを取得します
        var stack = VolumeManager.instance.stack;
        var channelMixer = stack.GetComponent<ChannelMixer>();
        var colorAdjustments = stack.GetComponent<ColorAdjustments>();
        var curves = stack.GetComponent<ColorCurves>();
        var liftGammaGain = stack.GetComponent<LiftGammaGain>();
        var shadowsMidtonesHighlights = stack.GetComponent<ShadowsMidtonesHighlights>();
        var splitToning = stack.GetComponent<SplitToning>();
        var tonemapping = stack.GetComponent<Tonemapping>();
        var whiteBalance = stack.GetComponent<WhiteBalance>();
        // UniversalRenderPipelineAssetで設定したGrading Modeを取得します
        // Grading ModeはLUTテクスチャのグラフィックフォーマットをLDRまたはHDRのどちらにするかを設定します
        ref var postProcessingData = ref renderingData.postProcessingData;
        bool hdr = postProcessingData.gradingMode == ColorGradingMode.HighDynamicRange;
        // UniversalRenderPipelineAssetで設定したLUTsizeを取得
        // 高さ = LUTsize
        int lutHeight = postProcessingData.lutSize;
        // 幅 = LUTsize^2
        int lutWidth = lutHeight * lutHeight;
        // LDRかHDRによってテクスチャフォーマットを変更
        // LDR : R8G8B8A8_UNorm
        // HDR : R16G16B16A16_SFloat サポートされていなければ、B10G11R11_UFloatPack32
        var format = hdr ? m_HdrLutFormat : m_LdrLutFormat;
        // LDRかHDRによってマテリアルを変更します
        // LDR : LutBuilderLdr.shaderが設定されているマテリアル
        // HDR : LutBuilderHdr.shaderが設定されているマテリアル
        var material = hdr ? m_LutBuilderHdr : m_LutBuilderLdr;
        // LUTを一時テクスチャをして生成します
        var desc = new RenderTextureDescriptor(lutWidth, lutHeight, format, 0);
        desc.vrUsage = VRTextureUsage.None; // We only need one for both eyes in VR
        cmd.GetTemporaryRT(m_InternalLut.id, desc, FilterMode.Bilinear);
        // 各カラーグレーディングの設定から、シェーダーパラメータを設定します
        var lmsColorBalance = ColorUtils.ColorBalanceToLMSCoeffs(whiteBalance.temperature.value, whiteBalance.tint.value);
        var hueSatCon = new Vector4(colorAdjustments.hueShift.value / 360f, colorAdjustments.saturation.value / 100f + 1f, colorAdjustments.contrast.value / 100f + 1f, 0f);
        var channelMixerR = new Vector4(channelMixer.redOutRedIn.value / 100f, channelMixer.redOutGreenIn.value / 100f, channelMixer.redOutBlueIn.value / 100f, 0f);
        var channelMixerG = new Vector4(channelMixer.greenOutRedIn.value / 100f, channelMixer.greenOutGreenIn.value / 100f, channelMixer.greenOutBlueIn.value / 100f, 0f);
        var channelMixerB = new Vector4(channelMixer.blueOutRedIn.value / 100f, channelMixer.blueOutGreenIn.value / 100f, channelMixer.blueOutBlueIn.value / 100f, 0f);
        var shadowsHighlightsLimits = new Vector4(
            shadowsMidtonesHighlights.shadowsStart.value,
            shadowsMidtonesHighlights.shadowsEnd.value,
            shadowsMidtonesHighlights.highlightsStart.value,
            shadowsMidtonesHighlights.highlightsEnd.value
        );
        var (shadows, midtones, highlights) = ColorUtils.PrepareShadowsMidtonesHighlights(
            shadowsMidtonesHighlights.shadows.value,
            shadowsMidtonesHighlights.midtones.value,
            shadowsMidtonesHighlights.highlights.value
        );
        var (lift, gamma, gain) = ColorUtils.PrepareLiftGammaGain(
            liftGammaGain.lift.value,
            liftGammaGain.gamma.value,
            liftGammaGain.gain.value
        );
        var (splitShadows, splitHighlights) = ColorUtils.PrepareSplitToning(
            splitToning.shadows.value,
            splitToning.highlights.value,
            splitToning.balance.value
        );
        var lutParameters = new Vector4(lutHeight, 0.5f / lutWidth, 0.5f / lutHeight,
            lutHeight / (lutHeight - 1f));
        material.SetVector(ShaderConstants._Lut_Params, lutParameters);
        material.SetVector(ShaderConstants._ColorBalance, lmsColorBalance);
        material.SetVector(ShaderConstants._ColorFilter, colorAdjustments.colorFilter.value.linear);
        material.SetVector(ShaderConstants._ChannelMixerRed, channelMixerR);
        material.SetVector(ShaderConstants._ChannelMixerGreen, channelMixerG);
        material.SetVector(ShaderConstants._ChannelMixerBlue, channelMixerB);
        material.SetVector(ShaderConstants._HueSatCon, hueSatCon);
        material.SetVector(ShaderConstants._Lift, lift);
        material.SetVector(ShaderConstants._Gamma, gamma);
        material.SetVector(ShaderConstants._Gain, gain);
        material.SetVector(ShaderConstants._Shadows, shadows);
        material.SetVector(ShaderConstants._Midtones, midtones);
        material.SetVector(ShaderConstants._Highlights, highlights);
        material.SetVector(ShaderConstants._ShaHiLimits, shadowsHighlightsLimits);
        material.SetVector(ShaderConstants._SplitShadows, splitShadows);
        material.SetVector(ShaderConstants._SplitHighlights, splitHighlights);
        material.SetTexture(ShaderConstants._CurveMaster, curves.master.value.GetTexture());
        material.SetTexture(ShaderConstants._CurveRed, curves.red.value.GetTexture());
        material.SetTexture(ShaderConstants._CurveGreen, curves.green.value.GetTexture());
        material.SetTexture(ShaderConstants._CurveBlue, curves.blue.value.GetTexture());
        material.SetTexture(ShaderConstants._CurveHueVsHue, curves.hueVsHue.value.GetTexture());
        material.SetTexture(ShaderConstants._CurveHueVsSat, curves.hueVsSat.value.GetTexture());
        material.SetTexture(ShaderConstants._CurveLumVsSat, curves.lumVsSat.value.GetTexture());
        material.SetTexture(ShaderConstants._CurveSatVsSat, curves.satVsSat.value.GetTexture());
        // Tonemapping
        if (hdr)
        {
            material.shaderKeywords = null;
            switch (tonemapping.mode.value)
            {
                case TonemappingMode.Neutral: material.EnableKeyword(ShaderKeywordStrings.TonemapNeutral); break;
                case TonemappingMode.ACES: material.EnableKeyword(m_AllowColorGradingACESHDR ? ShaderKeywordStrings.TonemapACES : ShaderKeywordStrings.TonemapNeutral); break;
                default: break; 
            }
        }
        // XR系処理
        renderingData.cameraData.xr.StopSinglePass(cmd);
        // 描画実行
        cmd.Blit(null, m_InternalLut.id, material);
        // XR系処理
        renderingData.cameraData.xr.StartSinglePass(cmd);
    }
    context.ExecuteCommandBuffer(cmd);
    CommandBufferPool.Release(cmd);
}

Grading Modeの設定(HDRLDR)によって、LutBuilderHdr.shaderLutBuilderLdr.shaderのどちらかが実行されます。

今回はLutBuilderLdr.shaderの処理を見ていきます。

SubShader
{
    Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline"}
    LOD 100
    ZTest Always ZWrite Off Cull Off
    Pass
    {
        Name "LutBuilderLdr"
        HLSLPROGRAM
            // Fullscreeen.hlslに定義されている汎用頂点シェーダー
            #pragma vertex FullscreenVert
            #pragma fragment Frag
        ENDHLSL
    }
}

フラグメントシェーダーの定義

half4 Frag(Varyings input) : SV_Target
{
    // ① LUT生成
    float3 colorLinear = GetLutStripValue(input.uv, _Lut_Params);
    float3 colorLMS = LinearToLMS(colorLinear);
    colorLMS *= _ColorBalance.xyz;
    colorLinear = LMSToLinear(colorLMS);
    float3 colorLog = LinearToLogC(colorLinear);
    colorLog = (colorLog - ACEScc_MIDGRAY) * _HueSatCon.z + ACEScc_MIDGRAY;
    colorLinear = LogCToLinear(colorLog);
    // ②Color AdjustmentsのColor filterを適応
    colorLinear *= _ColorFilter.xyz;
    colorLinear = max(0.0, colorLinear);
    // Split toning
    // As counter-intuitive as it is, to make split-toning work the same way it does in Adobe
    // products we have to do all the maths in gamma-space...
    float balance = _SplitShadows.w;
    float3 colorGamma = PositivePow(colorLinear, 1.0 / 2.2);
    float luma = saturate(GetLuminance(saturate(colorGamma)) + balance);
    float3 splitShadows = lerp((0.5).xxx, _SplitShadows.xyz, 1.0 - luma);
    float3 splitHighlights = lerp((0.5).xxx, _SplitHighlights.xyz, luma);
    colorGamma = SoftLight(colorGamma, splitShadows);
    colorGamma = SoftLight(colorGamma, splitHighlights);
    colorLinear = PositivePow(colorGamma, 2.2);
    // Channel mixing (Adobe style)
    colorLinear = float3(
        dot(colorLinear, _ChannelMixerRed.xyz),
        dot(colorLinear, _ChannelMixerGreen.xyz),
        dot(colorLinear, _ChannelMixerBlue.xyz)
    );
    // Shadows, midtones, highlights
    luma = GetLuminance(colorLinear);
    float shadowsFactor = 1.0 - smoothstep(_ShaHiLimits.x, _ShaHiLimits.y, luma);
    float highlightsFactor = smoothstep(_ShaHiLimits.z, _ShaHiLimits.w, luma);
    float midtonesFactor = 1.0 - shadowsFactor - highlightsFactor;
    colorLinear = colorLinear * _Shadows.xyz * shadowsFactor
        + colorLinear * _Midtones.xyz * midtonesFactor
        + colorLinear * _Highlights.xyz * highlightsFactor;
    // Lift, gamma, gain
    colorLinear = colorLinear * _Gain.xyz + _Lift.xyz;
    colorLinear = sign(colorLinear) * pow(abs(colorLinear), _Gamma.xyz);
    // HSV operations
    float satMult;
    float3 hsv = RgbToHsv(colorLinear);
    {
        // Hue Vs Sat
        satMult = EvaluateCurve(_CurveHueVsSat, hsv.x) * 2.0;
        // Sat Vs Sat
        satMult *= EvaluateCurve(_CurveSatVsSat, hsv.y) * 2.0;
        // Lum Vs Sat
        satMult *= EvaluateCurve(_CurveLumVsSat, Luminance(colorLinear)) * 2.0;
        // Hue Shift & Hue Vs Hue
        float hue = hsv.x + _HueSatCon.x;
        float offset = EvaluateCurve(_CurveHueVsHue, hue) - 0.5;
        hue += offset;
        hsv.x = RotateHue(hue, 0.0, 1.0);
    }
    colorLinear = HsvToRgb(hsv);
    // Global saturation
    luma = GetLuminance(colorLinear);
    colorLinear = luma.xxx + (_HueSatCon.yyy * satMult) * (colorLinear - luma.xxx);
    // YRGB curves
    {
        const float kHalfPixel = (1.0 / 128.0) / 2.0;
        float3 c = colorLinear;
        // Y (master)
        c += kHalfPixel.xxx;
        float mr = EvaluateCurve(_CurveMaster, c.r);
        float mg = EvaluateCurve(_CurveMaster, c.g);
        float mb = EvaluateCurve(_CurveMaster, c.b);
        c = float3(mr, mg, mb);
        // RGB
        c += kHalfPixel.xxx;
        float r = EvaluateCurve(_CurveRed, c.r);
        float g = EvaluateCurve(_CurveGreen, c.g);
        float b = EvaluateCurve(_CurveBlue, c.b);
        colorLinear = float3(r, g, b);
    }
    return half4(saturate(colorLinear), 1.0);
}

①GetLutStripValueはcolor.hlslに定義されています。

UVとparams = (lut_height, 0.5 / lut_width, 0.5 / lut_height, lut_height / lut_height - 1)を引数として受け取っています。

real3 GetLutStripValue(float2 uv, float4 params)
{
    uv -= params.yz;
    real3 color;
    color.r = frac(uv.x * params.x);
    color.b = uv.x - color.r / params.x;
    color.g = uv.y;
    return color * params.w;
}

ぱっと見では分かりにくいので、GetLutStripValueの値をそのまま出力してみます。

half4 Frag(Varyings input) : SV_Target
{
    float3 colorLinear = GetLutStripValue(input.uv, _Lut_Params);
    return half4(colorLinear.r,colorLinear.g,colorLinear.b,1);
}

RGB値 2次元の画像が横に並んでいます。

R値 各画像のX値と一致します。

G値 各画像のY値と一致します。

B値 各画像のインデックス値と一致します。

LUTは入力されたRGBを指定した別のRGBに変換するための情報が格納されてたテクスチャと前述しましたが、変換前のカラーRをX、GをY, Bをインデックスと見立てて、LUTのテクスチャをフェッチすることで、色を変換します。 GetLutStripValueの段階では入力カラーは色が変化しません。 その後の処理でLUTを書き換え、後ほど使用します。

②Color AdjustmentsのColor filterで画面を赤くした際はLUTの色が赤くなっています。

LUTを使ってカラーグレーディング

カラーグレーディングはUberPost.shaderで反映されます。 UberPost.shaderUberという名の通り、色んなポストエフェクトがひとつのシェーダーで実行されます。

SubShader
{
    Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline"}
    LOD 100
    ZTest Always ZWrite Off Cull Off
    Pass
    {
        Name "UberPost"
        HLSLPROGRAM
            // Fullscreeen.hlslに定義されている汎用頂点シェーダー
            #pragma vertex FullscreenVert
            #pragma fragment Frag
        ENDHLSL
    }
}

フラグメントシェーダーの定義

half4 Frag(Varyings input) : SV_Target
{
・
・ // 色んなポストエフェクトの処理// カラーグレーディングの適応
   color = ApplyColorGrading(color, PostExposure, TEXTURE2D_ARGS(_InternalLut, sampler_LinearClamp), LutParams, TEXTURE2D_ARGS(_UserLut, sampler_LinearClamp), UserLutParams, UserLutContribution);
・
・ // 色んなポストエフェクトの処理return half4(color, 1.0);
}

ApplyColorGradingの定義

half3 ApplyColorGrading(half3 input, float postExposure, TEXTURE2D_PARAM(lutTex, lutSampler), float3 lutParams, TEXTURE2D_PARAM(userLutTex, userLutSampler), float3 userLutParams, float userLutContrib)
{
    // Color AdjustmentsのPost Exposure 画面の明るさを調整します 2のn乗が入力カラー値の要素に乗算します
    input *= postExposure;

    // Grading ModeでHDRを選択
    #if _HDR_GRADING
    {
        // ①リニア色空間からLogc色空間に変換
        float3 inputLutSpace = saturate(LinearToLogC(input));
        // ②Lutを使ってカラー変換
        input = ApplyLut2D(TEXTURE2D_ARGS(lutTex, lutSampler), inputLutSpace, lutParams);
        
        // ③Color Look Up(ユーザー定義のLUT)の適応率が0以上
        UNITY_BRANCH
        if (userLutContrib > 0.0)
        {
            input = saturate(input);
            input.rgb = GetLinearToSRGB(input.rgb);
            half3 outLut = ApplyLut2D(TEXTURE2D_ARGS(userLutTex, userLutSampler), input, userLutParams);
            input = lerp(input, outLut, userLutContrib);
            input.rgb = GetSRGBToLinear(input.rgb);
        }
    }

    // Grading ModeでLDRを選択
    #else
    {
        // ToneMapの適応
        input = ApplyTonemap(input);

        // ③Color Look Up(ユーザー定義のLUT)の適応率が0以上
        UNITY_BRANCH
        if (userLutContrib > 0.0)
        {
            input.rgb = GetLinearToSRGB(input.rgb);
            half3 outLut = ApplyLut2D(TEXTURE2D_ARGS(userLutTex, userLutSampler), input, userLutParams);
            input = lerp(input, outLut, userLutContrib);
            input.rgb = GetSRGBToLinear(input.rgb);
        }
        //  ②Grading ModeがLDRの場合はLDRでLutに保存されているので、そのままLutを使ってカラー変換
        input = ApplyLut2D(TEXTURE2D_ARGS(lutTex, lutSampler), input, lutParams);
    }
    #endif

    return input;
}

①LUTは基本リニア色空間ですが、Grading ModeでHDRを選択した場合はLUT作成時にLogC色空間(HDR形式のフォーマット)に変換されてから保存されます。 そのため、入力カラーをLogC色空間に変換しています。

②入力カラーからLUTを使って色変換を行います。 ApplyLut2Dは以下のように定義されています。

real3 ApplyLut2D(TEXTURE2D_PARAM(tex, samplerTex), float3 uvw, float3 scaleOffset)
{
    uvw.z *= scaleOffset.z;
    float shift = floor(uvw.z);
    uvw.xy = uvw.xy * scaleOffset.z * scaleOffset.xy + scaleOffset.xy * 0.5;
    uvw.x += shift * scaleOffset.y;
    uvw.xyz = lerp(
        SAMPLE_TEXTURE2D_LOD(tex, samplerTex, uvw.xy, 0.0).rgb,
        SAMPLE_TEXTURE2D_LOD(tex, samplerTex, uvw.xy + float2(scaleOffset.y, 0.0), 0.0).rgb,
        uvw.z - shift
    );
    return uvw;
}

③Color Look UpというLUTをユーザーが用意して使うための機能の処理です。推奨されていないそうです。