ゲーム制作がんばるブログ

グラフィックやりがちなエンジニアのブログです

Unity6のRenderGraphでコンピュートシェーダー

はじめに

Unity6でRenderGraphになったことによりComputeShaderの実行方法も変わりました。
Unityは開発者向けにRenderGraph用のComputeShaderのサンプルであるComputeRendererFeatureを用意してくれています。
今回はそれを更にシンプルにしてRenderGraphにおけるComputeShaderの例を書きます。

実装

今回は紹介のためただ配列に値を入れるだけの操作になります。

RendererFeature

RendererFeatureに関しては変更はないので割愛します。

RenderPass

宣言部分

ComputeShader cs;
GraphicsBuffer outBuffer;
int[] outputData = new int[64];

GraphicsBufferはUnity2020から登場したGPUにデータを送るクラスです。
docs.unity3d.com

以下のようにして作成します。

outBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 64, sizeof(int));

この時のターゲットで送る型の指定が出来、頂点バッファやストラクチャーバッファなどとして扱えることが出来ます。
docs.unity3d.com

またSetRenderFuncで呼ぶ関数に送るデータ構造であるPassDataを以下のようにします

class PassData
{
    public ComputeShader cs;
    public BufferHandle output;
}

データを送る際や、DisPatchをする際にComputeShaderが必要になるので送れるようにします。
また入力するバッファを設定するためにBufferHandleも用意します

コンストラク

public SampleComputeRenderPass()
{
    outBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 64, sizeof(int));
}

コンストラクタでGraphicsBufferを作成します。

RecordRenderGraph

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
    outBuffer.GetData(outputData);
    Debug.Log($"Output from compute shader: {string.Join(", ", outputData)}");

    BufferHandle outputHandle = renderGraph.ImportBuffer(outBuffer);

    using (var builder = renderGraph.AddComputePass("ComputePass", out PassData passData))
    {
        passData.cs = cs;
        passData.output = outputHandle;

        builder.UseBuffer(passData.output, AccessFlags.Write);
        builder.SetRenderFunc
        (
            static (PassData data, ComputeGraphContext cgContext) =>
            {
                int kernelIndex = data.cs.FindKernel("CSMain");
                cgContext.cmd.SetComputeBufferParam(data.cs, kernelIndex, "outputData", data.output);
                cgContext.cmd.DispatchCompute(data.cs, kernelIndex, 1, 1, 1);
            }
        );
    }
}

outBuffer.GetData(outputData);でデータを取得することが出来ます。
本来はここで取得するようなものでもないですが、動いてるか確認する用で取り出してます(サンプルに倣ってます)。

renderGraph.ImportBuffer(outBuffer);でレンダーグラフにバッファーをインポートします。

builder.UseBuffer(passData.output, AccessFlags.Write);でバッファーがどのように使用されるかを指定します。

data.cs.FindKernel("CSMain");はコンピュートシェーダー側で定義したカーネルのインデックスを検索する機能です。

cgContext.cmd.SetComputeBufferParam(data.cs, kernelIndex, "outputData", data.output);ではComputeShader側にパラメータをセットしてます。
第三引数はComputeShader側の変数名です。

cgContext.cmd.DispatchCompute(data.cs, kernelIndex, 1, 1, 1);で実行します。

Dispose

Unity側のサンプルにはなかったのですが、どうやらちゃんとGraphicsBufferをリリースしないとリークするようです。

public void Dispose()
{
    outBuffer?.Dispose();
    outBuffer = null;
}

実装は以上になります。

RenderPass全体の実装

最後にRenderPassの全体の実装を乗せて終わります。

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.Universal;

public class SampleComputeRenderPass : ScriptableRenderPass
{
    ComputeShader cs;

    GraphicsBuffer outBuffer;

    int[] outputData = new int[64];

    public SampleComputeRenderPass()
    {
        outBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 64, sizeof(int));
    }

    public void Setup(ComputeShader cs)
    {
        this.cs = cs;
    }

    class PassData
    {
        public ComputeShader cs;
        public BufferHandle output;
    }

    public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
    {
        outBuffer.GetData(outputData);
        Debug.Log($"Output from compute shader: {string.Join(", ", outputData)}");

        BufferHandle outputHandle = renderGraph.ImportBuffer(outBuffer, true);

        using (var builder = renderGraph.AddComputePass("ComputePass", out PassData passData))
        {
            passData.cs = cs;
            passData.output = outputHandle;

            builder.UseBuffer(passData.output, AccessFlags.Write);
            builder.SetRenderFunc
            (
                static (PassData data, ComputeGraphContext cgContext) =>
                {
                    int kernelIndex = data.cs.FindKernel("CSMain");
                    cgContext.cmd.SetComputeBufferParam(data.cs, kernelIndex, "outputData", data.output);
                    cgContext.cmd.DispatchCompute(data.cs, kernelIndex, 1, 1, 1);
                }
            );
        }
    }
    
    public void Dispose()
    {
        outBuffer?.Dispose();
        outBuffer = null;
    }
}

RenderingLayerMaskで自作のライトの影響範囲を限定させる

RenderingLayerMask

RenderingLayerMaskとはTagやLayerとは違いライトやシャドウなどで影響するLayerMaskです。

RenderingLayerMask

実装

複数のカスタムポイントライトのデータを送ると想定して簡単に作ってみます。
(この実装はデータの送り方の解説に使ってるだけなので使用する際は工夫して送る頻度を下げたほうが良いです)

カスタムポイントライト

まずはカスタムポイントライトから

using UnityEngine;

public class CustomPointLight : MonoBehaviour
{
    [SerializeField] private RenderingLayerMask _layerMask;
    [SerializeField] private float _radius;
    [SerializeField] private Color _color;
    
    public RenderingLayerMask LayerMask => _layerMask;
    public float Radius => _radius;
    public Color Color => _color;
}

[SerializeField] private RenderingLayerMask _layerMask;
これがRenderingLayerMaskを指定する変数です。

カスタムポイントライトマネージャー

次にこれらライトを管理するカスタムポイントライトマネージャーです。
今回は複数追加できるようにします。

using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;

public class CustomPointLightManager : MonoBehaviour
{
    [SerializeField]
    private List<CustomPointLight> _customPointLights;
    
    private int _globalPositionID = Shader.PropertyToID("_GlobalPosition");
    private int _globalRadiusID = Shader.PropertyToID("_GlobalRadius");
    private int _globalLayerMaskID = Shader.PropertyToID("_GlobalLayerMask");
    private int _globalColorID = Shader.PropertyToID("_GlobalColor");
    
    void Update()
    {
        List<Vector4> positions = new List<Vector4>();
        List<float> radius = new List<float>();
        List<float> layer = new List<float>();
        List<Vector4> color = new List<Vector4>();
        
        foreach (var customPointLight in _customPointLights)
        {
            positions.Add(customPointLight.transform.position);
            radius.Add(customPointLight.Radius);
            layer.Add(UintToFloat(customPointLight.LayerMask.value));
            color.Add(customPointLight.Color);
        }
        
        Shader.SetGlobalVectorArray(_globalPositionID, positions);
        Shader.SetGlobalFloatArray(_globalRadiusID, radius);
        Shader.SetGlobalFloatArray(_globalLayerMaskID, layer);
        Shader.SetGlobalVectorArray(_globalColorID, color);
        
    }
    
    [StructLayout(LayoutKind.Explicit)]
    struct FloatUintUnion
    {
        [FieldOffset(0)]
        public uint UintValue;
        [FieldOffset(0)]
        public float FloatValue;
    }
    
    private static float UintToFloat(uint x)
    {
        return new FloatUintUnion { UintValue = x }.FloatValue;
    }
}

Layerのデータを配列でシェーダーに送る場合はちょっと工夫が必要です。
Layerのvalueはuintですが、シェーダーはintを配列で送ることが出来ません。
なので今回は共用体を用いてfloat配列で送ることにします。

[StructLayout(LayoutKind.Explicit)]
struct FloatUintUnion
{
    [FieldOffset(0)]
    public uint UintValue;
    [FieldOffset(0)]
    public float FloatValue;
}

private static float UintToFloat(uint x)
{
    return new FloatUintUnion { UintValue = x }.FloatValue;
}

共用体は複数の型が同じメモリ領域を共有するデータ構造です。
これによりビットパターンを変更せずに型変換することが出来ます。
キャストでは値の変換が起き、ビットパターンが変わってしまうので注意が必要です。

シェーダー側の実装

まずシェーダー側でこれを使用するには以下の宣言が必要です。

#pragma multi_compile _ _LIGHT_LAYERS

次にメッシュのレイヤーを取得します。

#ifdef _LIGHT_LAYERS
    uint meshRenderingLayers = GetMeshRenderingLayer();
#endif

最後にそのレイヤーと同じかどうかを判定します。
判定するにはIsMatchingLightLayerを使用します。
RenderingLyaerMaskはfloatでデータを送ったのでasuint(_GlobalLayerMask[i]);でuintに戻します。

for(int i = 0; i < MAX_CUSTOM_LIGHT; i++)
{
    #ifdef _LIGHT_LAYERS
        uint layer = asuint(_GlobalLayerMask[i]);
        if (IsMatchingLightLayer(layer, meshRenderingLayers))
    #endif
        {
            // 同レイヤーとなる
        }
}

以上がRenderingLayerMaskの実装になります。

Unityで簡単なレイマーチング

はじめに

RayMarchingとはレイを飛ばし当り判定を取ることによって描画をする手法です。
普通の描画と違い、色々な形や、煙、無限に続くオブジェクトなどをランタイムで自由に作成することが出来ます。
今回はこの方の記事を参考にHLSLで作成してみました。

tips.hecomi.com

頂点シェーダー

まずは頂点シェーダーです。
ワールド座標と透視除算する前の値を用意します。

Varyings vert(Attributes input)
{
    Varyings output = (Varyings)0;
   
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

    VertexPositionInputs vertexPositionInputs = GetVertexPositionInputs(input.positionOS.xyz);
    
    output.positionCS = vertexPositionInputs.positionCS;
    output.positionWS = vertexPositionInputs.positionWS;
    output.positionNDC = vertexPositionInputs.positionNDC;
   
    return output;
}

フラグメントシェーダー

ここからRyaMarchingの本題になります。

レイのベクトルを計算する

まずはレイのベクトルを計算します。
レイはカメラからスクリーン座標のある点に向けて飛ばします。
今回はこのシェーダーを用いたオブジェクトが映るスクリーン座標を求めてそこにレイを飛ばす形になります。

float3 cameraRightVector = UNITY_MATRIX_V[0].xyz;
float3 cameraUpVector = UNITY_MATRIX_V[1].xyz;
float3 cameraForwardVector = -UNITY_MATRIX_V[2].xyz;
float3 cameraForculLength = abs(UNITY_MATRIX_P[1][1]);

float2 ndc = input.positionNDC.xy / input.positionNDC.w;

float2 screenPosition= 2.0 * (ndc - 0.5);
screenPosition.x *= _ScreenParams.x / _ScreenParams.y;

float3 rayDirection= normalize((cameraRightVector * screenPosition.x) + (cameraUpVector * screenPosition.y) + (cameraForwardVector * cameraForculLength));

まずカメラの向きを取得します。

float3 cameraRightVector = UNITY_MATRIX_V[0].xyz;
float3 cameraUpVector = UNITY_MATRIX_V[1].xyz;
float3 cameraForwardVector = -UNITY_MATRIX_V[2].xyz;

nearclipまでの距離を取得します。

// 1/tan(fov/2) nearclipまでの距離を表す
float3 cameraForculLength = abs(UNITY_MATRIX_P[1][1]);

正規化デバイス座標を求め、その座標の範囲を-1~1に変換し、アスペクト比を考慮したスケーリングをします。

// w除算を行う
float2 ndc = input.positionNDC.xy / input.positionNDC.w;
// [0,1] から [-1,1]に変換
float2 screenPosition= 2.0 * (ndc - 0.5);
// 画面のアスペクト比を考慮(Unityは垂直FOVだから垂直基準でX方向をスケーリング)
screenPosition.x *= _ScreenParams.x / _ScreenParams.y;

最後にスクリーンの座標までのベクトルとカメラの向きベクトルの二つのベクトルからレイのベクトルを算出します。

// カメラの基底ベクトルとスクリーン上の座標を線型結合してベクトルを作成
float3 rayDirection= normalize((cameraRightVector * screenPosition.x) + (cameraUpVector * screenPosition.y) + (cameraForwardVector * cameraForculLength));

これでレイのベクトルの計算は終わりです。

最大の長さを算出する

深度値を利用して手前にオブジェクトがある場合は描画しないようレイの最大の長さを算出します。

float2 screenUV = ndc;
float rawDepth = SampleSceneDepth(screenUV);
float sceneDepth = LinearEyeDepth(rawDepth, _ZBufferParams);
float maxLength = sceneDepth / dot(rayDirection, cameraForwardVector);

実際にレイを飛ばす

レイを飛ばす個所です。
ワールド空間でレイは進ませ、ヒットするかどうかの距離関数はローカル座標で計算してます。
開始点はこのシェーダーを用いているオブジェクトのワールド座標です。
DistanceFunctionとは距離関数と呼ばれ、今のレイの座標から一番近い物体の距離を返します。
その距離が十分に近い場合は当たったとみなし探索を終了させます。
当たってない場合はその距離分更に進めて、同じ工程を繰り返します。

float3 rayPosition = input.positionWS.xyz;
float rayLength = length(rayPosition - _WorldSpaceCameraPos);
float distance = 0.0;

[loop]
for (int i = 0; i < _Loop; ++i)
{
    distance= DistanceFunction(rayPosition);
    rayLength += distance;
    rayPosition += rayDirection* distance;

    // 距離が十分近くなったか、長さが最大値を超えたら終了
    if (distance< _MinDistance|| rayLength > maxLength)
    {
        break;
    }
}

// 当たらなかったら描画しない
if (distance> _MinDistance|| rayLength > maxLength)
{
    discard;
}

距離関数

最も近い距離を表す関数です。
例えば球の場合は以下のようになります。

float sphere(float3 pos, float radius)
{
    return length(pos) - radius;
}

原点から半径の長さを引くことにより表面までの距離を取得することが出来ます。
今回はワールド座標でレイを飛ばしているのでローカル座標に戻してから距離関数で計算します。

レイマーチングの面白いところはこのようにするとオブジェクトを加算したりすることも出来ます。

float sphere01 = sphere(localPosition - float3(0, 0, 0), 0.3);
float sphere02 = sphere(localPosition - float3(0, 0.2, 0), 0.3);
return min(sphere01, sphere02);

減算する場合はこのようにします。

float sphere01 = sphere(localPosition - float3(0, 0, 0), 0.3);
float sphere02 = sphere(localPosition - float3(0, 0.2, 0), 0.3);
return max(sphere01, -sphere02);

法線計算をする

各軸を少しずらした値を用いた距離関数の傾きが法線になります。
これは陰関数の勾配が法線に当たることを用いたものになります。
距離関数は陰関数ともいえるので距離関数の微小変化量から法線を算出してます。

float3 GetNormal(float3 pos)
{
    const float d = 0.001;
    return normalize(float3(
        DistanceFunction(pos + float3(  d, 0.0, 0.0)) - DistanceFunction(pos + float3( -d, 0.0, 0.0)),
        DistanceFunction(pos + float3(0.0,   d, 0.0)) - DistanceFunction(pos + float3(0.0,  -d, 0.0)),
        DistanceFunction(pos + float3(0.0, 0.0,   d)) - DistanceFunction(pos + float3(0.0, 0.0,  -d))));
}

コード全体

最後にコード全体です。

Shader "Custom/RayMarching_Simple"
{
    Properties
    {
        _Loop ("Loop", Range(1, 100)) = 30
        _MinDistance ("Minimum Distance", Range(0.001, 0.1)) = 0.01
    }

    SubShader
    {
        Tags
        {
            "RenderType" = "Opaque"
            "RenderPipeline" = "UniversalPipeline"
            "Queue" = "Geometry"
            "IgnoreProjector" = "True"
            "DisableBatching" = "True"
        }
        LOD 100

        Pass
        {
            Cull Off
            Name "ForwardLit"
            Tags { "LightMode" = "UniversalForward" }

            HLSLPROGRAM
            #pragma target 3.5
            #pragma vertex vert
            #pragma fragment frag

            #pragma multi_compile_fog
            #pragma multi_compile_instancing

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"

            CBUFFER_START(UnityPerMaterial)
                int _Loop;
                half _MinDistance;
            CBUFFER_END

            float sphere(float3 pos, float radius)
            {
                return length(pos) - radius;
            }
            
            float DistanceFunction(float3 pos)
            {
                // ワールド座標をローカル座標に変換する
                float3 localPosition = mul(GetWorldToObjectMatrix(), float4(pos, 1.0)).xyz;
                float sphere01 = sphere(localPosition - float3(0, 0, 0), 0.3);
                float sphere02 = sphere(localPosition - float3(0, 0.2, 0), 0.3);
                return max(sphere01, -sphere02);
            }

            float3 GetNormal(float3 pos)
            {
                const float d = 0.001;
                return normalize(float3(
                    DistanceFunction(pos + float3(  d, 0.0, 0.0)) - DistanceFunction(pos + float3( -d, 0.0, 0.0)),
                    DistanceFunction(pos + float3(0.0,   d, 0.0)) - DistanceFunction(pos + float3(0.0,  -d, 0.0)),
                    DistanceFunction(pos + float3(0.0, 0.0,   d)) - DistanceFunction(pos + float3(0.0, 0.0,  -d))));
            }

            struct Attributes
            {
                float4 positionOS : POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD1;
                float4 positionNDC : TEXCOORD2;
                UNITY_VERTEX_INPUT_INSTANCE_ID
                UNITY_VERTEX_OUTPUT_STEREO
            };

            Varyings vert(Attributes input)
            {
                Varyings output = (Varyings)0;
               
                UNITY_SETUP_INSTANCE_ID(input);
                UNITY_TRANSFER_INSTANCE_ID(input, output);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

                VertexPositionInputs vertexPositionInputs = GetVertexPositionInputs(input.positionOS.xyz);
                
                output.positionCS = vertexPositionInputs.positionCS;
                output.positionWS = vertexPositionInputs.positionWS;
                output.positionNDC = vertexPositionInputs.positionNDC;
               
                return output;
            }

            half4 frag(Varyings input) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(input);
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

                float3 cameraRightVector = UNITY_MATRIX_V[0].xyz;
                float3 cameraUpVector = UNITY_MATRIX_V[1].xyz;
                float3 cameraForwardVector = -UNITY_MATRIX_V[2].xyz;
                // 1/tan(fov/2) nearclipまでの距離を表す
                float3 cameraForculLength = abs(UNITY_MATRIX_P[1][1]);

                float2 ndc = input.positionNDC.xy / input.positionNDC.w;

                // [0,1] から [-1,1]に変換
                float2 screenPosition = 2.0 * (ndc - 0.5);
                // 画面のアスペクト比を考慮(Unityは垂直FOVだから垂直基準でX方向をスケーリング)
                screenPosition.x *= _ScreenParams.x / _ScreenParams.y;

                // カメラの基底ベクトルとスクリーン上の座標を線型結合してベクトルを作成
                float3 rayDirection = normalize(
                    (cameraRightVector * screenPosition.x) +
                    (cameraUpVector * screenPosition.y) +
                    (cameraForwardVector * cameraForculLength));

                    float3 rayPosition = input.positionWS.xyz;
                    float rayLength = length(rayPosition - _WorldSpaceCameraPos);
                    float distance = 0.0;

                // 深度による比較
                float2 screenUV = ndc;
                float rawDepth = SampleSceneDepth(screenUV);
                float sceneDepth = LinearEyeDepth(rawDepth, _ZBufferParams);
                float maxLength = sceneDepth / dot(rayDirection, cameraForwardVector);

                // レイマーチング
                [loop]
                for (int i = 0; i < _Loop; ++i)
                {
                    distance = DistanceFunction(rayPosition);
                    rayLength += distance;
                    rayPosition += rayDirection * distance;

                    // 距離が十分近くなったか、長さが最大値を超えたら終了
                    if (distance < _MinDistance || rayLength > maxLength)
                    {
                        break;
                    }
                }

                // 当たらなかったら描画しない
                if (distance > _MinDistance || rayLength > maxLength)
                {
                    discard;
                }
                
                float3 normal = GetNormal(rayPosition);
                Light mainLight = GetMainLight();
               
                half4 color;
                // ハーフランバート
                color.rgb = (dot(normal, mainLight.direction) * 0.5f + 0.5f) * mainLight.color;
                //color.rgb = (saturate(dot(normal, mainLight.direction))) * mainLight.color;
                color.a = 1.0f;

                return color;
            }
            ENDHLSL
        }
    }
}

こんなオブジェクトが作成されるかと思います

頂点シェーダーで計算する場合とフラグメントシェーダーで計算する場合では結果が変わる話

はじめに

今回はこの記事の続きの話です

osushi-soba.hatenablog.com

これを知ってからNDCはComputeNormalizedDeviceCoordinatesWithZで計算していたのですが、どうにもうまくいかないなと思うことがありました。
そして調べていくうちに頂点シェーダーでComputeNormalizedDeviceCoordinatesWithZを使用して計算しているのが問題という点にたどり着きました。
今回は自戒の意味も込めて内容を書きます。

頂点シェーダーは値を線形補完している

頂点シェーダーは各頂点分計算し、フラグメントシェーダーはピクセル数分計算します。
では頂点座標以外の計算はどのように計算しているのでしょうか。
答えは各頂点の結果を線形補完しています。
(2.2)で2(8,8)で4という結果が頂点シェーダーで得られた場合(5,5)の座標では3になるわけです。

頂点シェーダーでComputeNormalizedDeviceCoordinatesWithZを使うとなぜ問題が起きるのか

これを踏まえ頂点シェーダーでComputeNormalizedDeviceCoordinatesWithZを使用するとなぜ問題が起きるの解説します。
ComputeNormalizedDeviceCoordinatesWithZはNDCを計算する関数です。
中身はざっと以下の通りです。

#if UNITY_UV_STARTS_AT_TOP
    // Our world space, view space, screen space and NDC space are Y-up.
    // Our clip space is flipped upside-down due to poor legacy Unity design.
    // The flip is baked into the projection matrix, so we only have to flip
    // manually when going from CS to NDC and back.
    positionCS.y = -positionCS.y;
#endif

    positionCS *= rcp(positionCS.w);
    positionCS.xy = positionCS.xy * 0.5 + 0.5;

これはクリップスペースからwを除算して[0-1]の正規化デバイス座標に変換している関数です。
ここに落とし穴がありました。
頂点シェーダーでwを除算してしまうと補完された際に値がずれてしまうのです。
実際に数値を当てはめて計算してみましょう。

(2.2)でwが2の場合(1,1)
(8,8)でwが4の場合(2,2)
(5,5)の場合線形補完されて(1.5,1.5)になります。

こんどは除算せずにwの値をピクセルシェーダーで除算する場合を考えます。
(2.2)でwが2
(8,8)でwが4
(5,5)の場合線形補完されてwは3
(5/3,5/3) = (1.66…,1.66…)

なんと(1.5,1.5)と(1.66…,1.66…)で値が違います。
これによって結果がうまく出ていませんでした。
実際にシェーダーで表してみると以下のようになります。
左が頂点シェーダーでw除算した場合で右がフラグメントシェーダーでw除算した場合です。
xが0に行くほど赤く、1に行くほど青くしてます。

なんか歪んでますね。

VertexPositionInputsのNDCにはバリバリ使い道がある

前回嘘NDCと言っていたVertexPositionInputsのNDCですが、ちゃんと意味があったんですね。。。
頂点シェーダーでw除算する前の値を計算してフラグメントシェーダーでw除算することによって精度の高い結果が得られることになります。
勉強になりました。

UnityのGetVertexPositionInputsで取得するNDCには罠があるという話

UnityにおいてGetVertexPositionInputsは便利ですよね。
一個呼ぶだけでいろんな座標系の値を取得できます。
しかしこの関数で得られるpositionNDCはなんとNDCではないのです。
???と思う方もいるかもしれませんがこの関数の中身を見ると理由が分かります。

VertexPositionInputs GetVertexPositionInputs(float3 positionOS)
{
    VertexPositionInputs input;
    input.positionWS = TransformObjectToWorld(positionOS);
    input.positionVS = TransformWorldToView(input.positionWS);
    input.positionCS = TransformWorldToHClip(input.positionWS);

    float4 ndc = input.positionCS * 0.5f;
    input.positionNDC.xy = float2(ndc.x, ndc.y * _ProjectionParams.x) + ndc.w;
    input.positionNDC.zw = input.positionCS.zw;

    return input;
}

w除算してません!!!
これは偽NDCです。
実際にはこれを取得した後にwを除算しないといけません。

ちなみに現在非推奨のComputeScreenPosのコメントを見ると

// Deprecated: A confusingly named and duplicate function that scales clipspace to unity NDC range. (-w < x(-y) < w --> 0 < xy < w)
// Use GetVertexPositionInputs().positionNDC instead for vertex shader
// Or a similar function in Common.hlsl, ComputeNormalizedDeviceCoordinatesWithZ()
float4 ComputeScreenPos(float4 positionCS)
{
    float4 o = positionCS * 0.5f;
    o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w;
    o.zw = positionCS.zw;
    return o;
}

A confusingly named and duplicate function that scales clipspace to unity NDC range
クリップスペースをUnity NDC範囲にスケーリングする、紛らわしい名前の重複関数

と書いてます。
公式公認で紛らわしいとのことです。
Use GetVertexPositionInputs().positionNDC instead for vertex shader
とも書いてるのでこれに互換性を持たせるためにわざと除算してないんですかね。
ついでにここに
Or a similar function in Common.hlsl, ComputeNormalizedDeviceCoordinatesWithZ()
と書いてあるComputeNormalizedDeviceCoordinatesWithZ()はしっかりw除算されてます。
名前通りの働きをしてますね。
皆さんもNDCを使用したい場合はComputeNormalizedDeviceCoordinatesWithZ()を使いましょう!

追記

頂点シェーダーでw除算までしてしまうと補完の関係で値がおかしくなってしまうためにこのような形になっているようです。 osushi-soba.hatenablog.com

UnityのUIのパフォーマンスチューニング【ドローコール削減】

はじめに

UnityのUIはいろんな人が触ると思います。
デザイナーさん、エンジニアさん、OTJ中の未経験者さんまで。
そんなUIですが、二つのことに気を付けたらバッチングされてドローコールを削減できます。
今回はそんなドローコールの削減の話とチューニングの仕方を書こうと思います。

バッチングの条件

基本的に二つのことを守ればバッチングされます。
それは
1. 同一のテクスチャを使用する
2. 同一のインスタンスのマテリアルを使用する
この二つに気を付けたらバッチングされます。
また気を付けないといけないのが同一のインスタンスのマテリアルを使用するというところです。
パラメーターを変更したい場合頂点データなどに逃がす必要があります。

同一のテクスチャを使用する

これは基本的に同じSpriteAtlasを使用することになります。
SpriteAtlasとは複数の画像を一枚の画像にまとめる機能です。

docs.unity3d.com

UnityのPackageManagerはUnityRegistryの2DSpriteからインストールすることで導入できます。

SpriteAtlas導入

この機能を使用することで画像を一枚にし、実際使用する際はUnity側で勝手に切り取って使用してくれます。

同一のインスタンスのマテリアルを使用する

これに関しては自作のシェーダーなどでUIをカスタマイズするほど問題が起きやすいものになります。
デフォルトのUIのシェーダーは同一インスタンスのDefault/UIを使用するのでDefault/UIを使用している人が主に気を付けるのはMaskなどでマテリアルが変わっているときの場合のみになります。
しかし自作のシェーダーなどを使用している場合は単一のマテリアルにするにはいろいろ工夫する必要があると思います。
考えられる方法としては
1. エディタ上でMaterialを作成し、そのままセットする
2. 管理クラスを作成し、インスタンスを管理する
3. CanvasMaterialを使用する

この三つになるかと思います。
3に関してはこの方の記事が参考になるかと思います。

qiita.com

チューニング

チューニングの仕方

実際にどう確認してチューニングするかというと、Profilerを使用します。
ProfilerはWindow→Analysis→Profilerにあります。 実際に選択するとこのよう画面が出ると思います。

Profiler画像

UIはProfilerModulesの中にあるUIという項目にチェックがついてたら表示されると思います。

UIProfiler

実際にチューニングをする

テクスチャのチューニング

このようにUIを並べてみました。

まだアトラスは使用してません。
再生してProfilerを見てみましょう。

BatchBreakingReasonにDifferentTextureと書かれてます。
つまり画像が違うためBatchingされないということです。
次に今の画像をアトラス化してみます。

この状態で実行しProfilerで見るとこうなります。

見事にBatchingされましたね。

マテリアルのチューニング

次にUIシェーダーを作成してみて設定してみます。
おにぎりとお寿司に走査線のシェーダーをセットします。
この二つにはシェーダーは同じ、パラメーターも同じ、しかしマテリアルのインスタンスだけ別でセットします。

これでProfilerを見てみるとこうなります。

見事に全部別れました。
BatchBreakingReasonにはDifferentMaterialInstanceと書かれてます。
次におにぎりと寿司を同じマテリアルのインスタンスで動かしてみます。

おにぎりとお寿司はBatchingされ、Defaultのマテリアルを使用しているそばだけBatchingされませんでした。
このようにカスタムシェーダーを使用するとBatchingされなくなるリスクが高まります。
なのでなるべくBatchingされるよう上に書いたような形で同一のインスタンスにする工夫が必要です。

メッシュの交差点を表すポストエフェクト

見本

はじめに

メッシュのアウトラインを表示するエフェクトは世の中に沢山あると思います。
ただそれだけだとメッシュの交差する場所には線が出ず、遠目で見ると絵が溶け込んでしまうことがあると思います。
今回はそんな問題を解消するポストエフェクトを考えようかと思います。

環境

Unity 6000.0.45f1
URP 17.0.4

手法

手段としては画面のノーマルの差分が激しいところを見つけそこを黒塗りします。

ノーマルのテクスチャを出力する

今回は画面のノーマルの出力結果が必要なのでノーマルとカラーを別々に出力します。
やり方はこちらに書いているものと同じです。

osushi-soba.hatenablog.com

ノーマルのテクスチャをもとにソーベルフィルタをかける

今回は差分を出力するためにソーベルフィルタを用います。
ソーベルフィルタとはエッジ抽出をする際に用いられるフィルタ処理で、今回はそれをノーマルに適応して差が大きいところを抽出してみようと思います。
ソーベルフィルタはこの方の記事を参考にしました。

light11.hatenadiary.com

float2 uv = UnityStereoTransformScreenSpaceTex(IN.texcoord);
half4 albedo = SAMPLE_TEXTURE2D_X(_CustomAlbedoTexture, sampler_CustomAlbedoTexture, uv);

float diffU = _CustomNormalTexture_TexelSize.x * _OutlineThick;
float diffV = _CustomNormalTexture_TexelSize.y * _OutlineThick;
half3 col00 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(-diffU, -diffV));
half3 col01 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(-diffU, 0.0));
half3 col02 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(-diffU, diffV));
half3 col10 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(0.0, -diffV));
half3 col12 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(0.0, diffV));
half3 col20 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(diffU, -diffV));
half3 col21 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(diffU, 0.0));
half3 col22 = SAMPLE_TEXTURE2D_X(_CustomNormalTexture, sampler_CustomAlbedoTexture, uv + half2(diffU, diffV));

half3 horizontalColor = 0;
horizontalColor += col00 * -1.0;
horizontalColor += col01 * -2.0;
horizontalColor += col02 * -1.0;
horizontalColor += col20;
horizontalColor += col21 * 2.0;
horizontalColor += col22;

half3 verticalColor = 0;
verticalColor += col00;
verticalColor += col10 * 2.0;
verticalColor += col20;
verticalColor += col02 * -1.0;
verticalColor += col12 * -2.0;
verticalColor += col22 * -1.0;

half3 outlineValue = horizontalColor * horizontalColor + verticalColor * verticalColor;
half judge = step(_OutlineThreshold, outlineValue);
half3 color = lerp(albedo.rgb, _OutlineColor, judge);
return half4(color, albedo.a);

ノーマルの差分が激しいところを抽出し、_OutlineColorで塗ってます。
今回はわかりやすいようにアウトラインはつけなかったですが、アウトラインをつけるとこのポストエフェクトでは対応できない個所を相互に補完できるかなと思います。