【Unity / iOS】シェーダーのConstantBufferを自作する

に公開

はじめに

シェーダーのConstantBuffer(CBUFFER)は自分で定義することができます。

CBUFFER_START(MyCustomLight)  
float4 _DirectionalLightColor;  
float3 _DirectionalLightDirection;  
CBUFFER_END  

そして、CBUFFERを定義しておくことで、C#から複数のデータをまとめて渡すことができます。

private LightData _myData;

struct LightData  
{  
    public Color lightColor;  
    public Vector3 lightDirection;  
}

public void Setup(ScriptableRenderContext context, CommandBuffer cmd)
{  
	Light light = RenderSettings.sun;  
	LightData lightData = default;
	
	// _DirectionalLightColorの設定
	lightData.lightColor = light.color.linear; 
	
	// _DirectionalLightDirectionの設定
	lightData.lightDirection = -light.transform.forward;

	// シェーダーに渡す
    ConstantBuffer.PushGlobal(cmd, _myData, Shader.PropertyToID("MyCustomLight"));
    
	context.ExecuteCommandBuffer(cmd);
}

今回は、これについて話を掘り下げていきたいと思います。

環境

Unity6000.1.6f1

Universal RP 17.1.0

XCode 16.3 (16E140)

iPhoneX (iOS)

Chapter 1. ConstantBufferを観察する

まず、ConstantBufferへの理解を深めるための実験をしてみましょう。

シェーダー作成

今回はシェーダーに以下のような変数を定義し、iPhoneXで動かした時のメモリを確認してみる事にします。

  • ライト関係のシェーダーパラメータをグローバル定義
  • マテリアルのパラメータは UnityPerMaterial というCBUFFERに含める
float4 _DirectionalLightColor;  
float3 _DirectionalLightDirection;  

CBUFFER_START(UnityPerMaterial)  
float4 _MainTex_ST;  
half4 _BaseColor;  
CBUFFER_END

シェーダーへのパラメータ設定(C#)

Shader.SetGlobal系のメソッドを利用することで、グローバル変数へ値を設定できます。

Light light = RenderSettings.sun;
Shader.SetGlobalColor("_DirectionalLightColor", light.color.linear);
Shader.SetGlobalVector("_DirectionalLightDirection", -light.transform.forward);

CommandBufferを利用することでも、グローバル変数に値が設定できます。

// CommandBufferを使用する場合
Light light = RenderSettings.sun;
cmd.SetGlobalColor("_DirectionalLightColor", light.color.linear);
cmd.SetGlobalVector("_DirectionalLightDirection", -light.transform.forward);
context.ExecuteCommandBuffer(cmd);

1. シェーダー変数を確認してみる (Xcode)

XcodeでGPUキャプチャを行い、3Dモデルの描画を行っている描画コールを確認します。

UnityPerMaterial

UnityPerMaterial という名前のBufferがあることが確認できます。

UnityPerMaterial をダブルクリックすると、UnityPerMaterial の中身を見れます。
UnityPerMaterial の中には _MainTex_ST_BaseColor の値が入っていることがわかります。

CBUFFER_START(UnityPerMaterial)  
float4 _MainTex_ST;  
half4 _BaseColor;  
CBUFFER_END

FGlobals

FGlobalsというバッファがFragmentにバインドされていることもわかります。

fragmentシェーダーからグローバル変数にアクセスすると、FGlobalsというバッファの中に変数が格納されるようです。

FGlobals を確認してみると、先ほどシェーダー内で定義したグローバル変数の値が入っています。

VGlobals

グローバル変数にvertexシェーダーからアクセスした場合、変数の値はVGlobals というバッファの中に入ります。

VGlobalsの中を見ると、行列値やグローバル変数が確認できます。

2. CBufferを自分で作る

CBUFFERは自分で作ることができます。
これも確認してみましょう。

今回は、MyCustomLightという名前のバッファを定義してみます。
この名前は別の名前でも構いません。

+ CBUFFER_START(MyCustomLight)  
float4 _DirectionalLightColor;  
float3 _DirectionalLightDirection;  
+ CBUFFER_END  
  
struct Light {  
    float4 color;  
    float3 direction;  
};

CBufferをシェーダーへ渡す (C#)

ConstantBuffer.PushGlobal を利用して、構造体をシェーダーに送信します。

  • CBUFFERと同じデータを定義したstructを用意して、値を設定する
  • MyCustomLightという名前を指定して、structをGPUへ渡す
private LightData _myData;

struct LightData  
{  
    public Color lightColor;  
    public Vector3 lightDirection;  
}

public void Setup(ScriptableRenderContext context, CommandBuffer cmd)
{  
	Light light = RenderSettings.sun;  
	LightData lightData = default;
	lightData.lightColor = light.color.linear;  
	lightData.lightDirection = -light.transform.forward;
    ConstantBuffer.PushGlobal(cmd, _myData, Shader.PropertyToID("MyCustomLight"));
	context.ExecuteCommandBuffer(cmd);
}

メモリ確認

再度メモリを確認してみます。

先ほどは FGlobals というバッファがありましたが、
FGlobalsは消え、代わりにMyCustomLightというバッファがあることが確認できます。
MyCustomLight は 28 bytes となっており、FGlobals(4MiB)より小さいです。

Chapter1. まとめ

  1. シェーダーの変数をCBUFFER_START(Name) ~ CBUFFER_ENDで囲むと、バッファNameの中に変数が格納される
  2. CBUFFERの中に入れない場合、4MiBのバッファの中に変数が格納される
    1. vertexからアクセスするシェーダー変数は VFlobals に入る
    2. fragmentsからアクセスする変数は FGlobals に入る

Chapter2. パフォーマンス比較

シェーダーのグローバル変数の数が多い場合、
SetGlobal系のメソッドでパラメータを1つずつ渡すより、ConstantBuffer.PushGlobalでパラメータをまとめてシェーダーに渡したほうがパフォーマンスが良くなります。

ここでは、シェーダーのグローバルパラメータが500個あるケースを想定して、
パフォーマンスを計測して比較してみる事にします。

計測にはiPhoneXを使用しました。

検証1 : Shader.SetGlobalFloat vs ConstantBuffer.PushGlobal

以下の2つのケースを比較してみます。

  1. Shader.SetGlobalFloatを500回実行した場合
  2. ConstantBuffer.PushGlobalを実行した場合

Shader.SetGlobalFloat を 500回実行 (C#)

Shader.SetGlobalFloatを500回実行します

const int N = 500;
void SetGlobalParameter()
{
	for (int i = 0; i < N; i++)
	{
		Shader.SetGlobalFloat(_properties.PropertyIds[i], globalFloat);
	}
}

ConstantBuffer.PushGlobal を 実行 (C#)

floatパラメータを500個定義したstructを作成し、
ConstantBuffer.PushGlobalで設定します。

struct MyData
{
    public float _Float0;
    public float _Float1;
    public float _Float2;
    public float _Float3;

	...

    public float _Float496;
    public float _Float497;
    public float _Float498;
    public float _Float499;
}

private MyData _myData;_

void PushConstantBuffer()
{
    _myData._Float0 = globalFloat;  
    _myData._Float1 = globalFloat;  
    _myData._Float2 = globalFloat;  
    _myData._Float3 = globalFloat;
	
	...

	  
	_myData._Float496 = globalFloat;  
	_myData._Float497 = globalFloat;  
	_myData._Float498 = globalFloat;  
	_myData._Float499 = globalFloat;  
	
	ConstantBuffer.PushGlobal(_myData, bufferID);
}

結果

ProfileAnalyzerを利用して、iPhoneX上でのCPU処理時間を見てみました。

  • Shader.SetGlobalFloat を 500回実行したケース : 0.63ms
  • ConstantBuffer.PushGlobal を実行したケース : 0.13 ms

CommandBufferの方が0.5msほど高速という結果になりました (約 4.8 倍高速)

検証2 : CommandBuffer.SetGlobalFloat vs ConstantBuffer.PushGlobal

CommandBufferを利用してシェーダーパラメータを設定した場合の処理負荷の違いも確認してみました。
今回は、レンダーパイプライン上で実行してみる事にします。

CommandBuffer.SetGlobalFloat を 500回実行 (C#)

    void SetGlobalParameter(CommandBuffer cmd)
    {
		for (int i = 0; i < ShaderProperties.N; i++)
		{
			cmd.SetGlobalFloat(_properties.PropertyIds[i], globalFloat);
		}
    }

ConstantBuffer.PushGlobal を 実行 (C#)

CommandBufferを利用して、 ConstantBuffer.PushGlobalを実行します。

struct MyData
{
    public float _Float0;
    public float _Float1;
    public float _Float2;
    public float _Float3;

	...

    public float _Float496;
    public float _Float497;
    public float _Float498;
    public float _Float499;
}

private MyData _myData;_

void PushConstantBuffer(CommandBuffer cmd)
{
	_myData._Float0 = globalFloat;  
	_myData._Float1 = globalFloat;  
	_myData._Float2 = globalFloat;  
	_myData._Float3 = globalFloat;
	
	...
	
	_myData._Float496 = globalFloat;  
	_myData._Float497 = globalFloat;  
	_myData._Float498 = globalFloat;  
	_myData._Float499 = globalFloat;  
	
	ConstantBuffer.PushGlobal(cmd, _myData, bufferID);
}

結果

  • CommandBuffer.SetGlobalFloat を 500回実行したケース : 0.54ms
  • ConstantBuffer.PushGlobal を実行したケース : 0.15 ms

ConstantBufferの方が0.39msほど高速という結果になりました (約 3.5倍高速)

検証3 : CommandBuffer.SetGlobalFloatArray vs ConstantBuffer.PushGlobal (2025/06/11 夜追記)

Floatの配列をシェーダーに渡した時の場合の負荷も見てみました。

private float[] _floatArray = new float[500];

void SetGlobalFloatArray(CommandBuffer cmd)
{
	for (int i = 0; i < ShaderProperties.N; i++)
	{
		_floatArray[i] = globalFloat;
	}
	cmd.SetGlobalFloatArray(ShaderProperties.FloatArray, _floatArray);
}

結果

  • CommandBuffer.SetGlobalFloatArray を 1回実行したケース : 0.02ms
  • ConstantBuffer.PushGlobal を実行したケース : 0.13 ms

SetGlobalFloatArrayの方が0.11msほど高速という結果になりました。

Chapter2. まとめ

  • SetGlobal系のメソッドを大量に呼ぶと、パフォーマンスの低下につながる
  • ConstantBuffer.PushGlobalでパラメータをまとめて設定した方が、パフォーマンスがが良い

2025/06/11 夜追記 : CommandBuffer.SetGlobalFloatArray の方が速い

サンプルコード

Chapter2で使用したコードは以下に置いてます
https://github.com/rngtm/Unity6-ConstantBufferTest

参考記事

SRPでゼロからレンダリングパイプラインを作成するチュートリアルです。
https://catlikecoding.com/unity/tutorials/custom-srp/directional-lights/

GenerateHLSLを利用して、シェーダーコードを生成する記事ですが、
その中でConstantBuffer.PushGlobalについても軽く紹介されてます。
https://tarowork.hatenablog.jp/entry/2022/12/06/231537

Discussion