iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💡

Synchronizing ScriptableObjects to Shaders

に公開

I'm tang3cko, a game development beginner who started Unity seriously in my 30s.

Alongside my own game development, I'm creating a ScriptableObject-based library. There was a time when I wanted a mechanism to automatically synchronize variables managed in SOs (hereafter, VariableSO) with Shaders, so I'd like to introduce that implementation.

Here is a demo video using this mechanism.

https://youtu.be/cd5MqZszb1s

Background

When I wanted to pass runtime values to a Shader, I always thought it was a hassle to write bridge code every time.
It only takes a few lines, but as the game grows, it gets scattered everywhere and becomes difficult to manage.

In my other work (not Unity), I often have to do similar things, and sometimes I find myself envious of macOS's Unified Memory.

Traditional Approach

When you want to reference game state in a Compute Shader or custom shader, you might write code like this.

public class PlayerEffectController : MonoBehaviour
{
    [SerializeField] private ComputeShader computeShader;
    [SerializeField] private Material effectMaterial;
    [SerializeField] private Player player;

    void Update()
    {
        // Manually synchronize every frame
        Shader.SetGlobalVector("_PlayerPosition", player.transform.position);
        Shader.SetGlobalFloat("_PlayerHealth", player.health);
    }
}

This works, but there are a few points of concern:

  • Effect and game logic become tightly coupled
  • Synchronization code gets scattered everywhere
  • Even if multiple shaders use the same value, you still need to write sync code somewhere

Especially as the project grows, it's easy to lose track of where a specific value is being synchronized.

What I Wanted to Achieve

Ideally, I wanted to have a structure like the following for the VariableSO.

FloatVariableSO (PlayerHealth)
├── Value: 75.0
└── ☑ GPU Sync Enabled
    └── GPU Property Name: _PlayerHealth

Just by checking a box in the Inspector, the value is automatically passed to the Shader. The Shader side just reads it without knowing anything.

// Reads it without knowing where it came from
float playerHealth = _PlayerHealth;

Of course, I have some reservations about defining them as global variables, but I decided it's acceptable for this use case and implemented it.

Let's Implement It

Basic Mechanism

This time, I'll use Shader.SetGlobalXXX(), just like the bridge code. To avoid having to prepare bridge code individually, I made it so that this is called automatically whenever the VariableSO's value changes.

public abstract class VariableSO<T> : VariableSO
{
    [Header("GPU Sync")]
    [SerializeField] private bool gpuSyncEnabled;
    [SerializeField] private string gpuPropertyName;

    public T Value
    {
        get => value;
        set
        {
            if (!EqualityComparer<T>.Default.Equals(this.value, value))
            {
                this.value = value;
                RaiseValueChangedEvent(value);

                if (gpuSyncEnabled && Application.isPlaying)
                    SyncValueToGPU();
            }
        }
    }

    // Implement Shader.SetGlobal~ according to the type in derived classes
    public abstract void SyncValueToGPU();

    private void OnEnable()
    {
        // Initial sync at the start of Play Mode
        if (gpuSyncEnabled && Application.isPlaying)
            SyncValueToGPU();
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        // For previewing in the Editor
        if (gpuSyncEnabled && !string.IsNullOrEmpty(gpuPropertyName))
            SyncValueToGPU();
    }
#endif
}

Implementation per Type

Since the method to be called differs depending on the type, we override it in the derived classes. Below is an example of what that looks like.

public class FloatVariableSO : VariableSO<float>
{
    public override void SyncValueToGPU()
    {
        Shader.SetGlobalFloat(GPUPropertyName, Value);
    }
}

public class Vector3VariableSO : VariableSO<Vector3>
{
    public override void SyncValueToGPU()
    {
        Shader.SetGlobalVector(GPUPropertyName, new Vector4(Value.x, Value.y, Value.z, 0f));
    }
}

public class ColorVariableSO : VariableSO<Color>
{
    public override void SyncValueToGPU()
    {
        Shader.SetGlobalColor(GPUPropertyName, Value);
    }
}

Supported Types

Since it is difficult to support all types, I decided to support only the following types in this implementation.

Variable Type Shader Method HLSL Type
FloatVariableSO SetGlobalFloat float
IntVariableSO SetGlobalInt int
Vector2VariableSO SetGlobalVector float4 (uses xy)
Vector3VariableSO SetGlobalVector float4 (uses xyz)
ColorVariableSO SetGlobalColor float4
BoolVariableSO SetGlobalInt int (0/1)

As for why String/Long/Double are not supported:

They cannot be handled by the GPU, or there is no point in handling them.

Type Reason
String Strings cannot be used in shaders
Long Shader integers are basically 32-bit
Double Shader floats are 32-bit (though some GPUs support 64-bit, it's not common)

Demo

To verify the functionality implemented this time, I created a demo where 10,000 particles (mote = light dust) float around the player.

https://youtu.be/cd5MqZszb1s

Composition

In this case, although inefficient, I used two types of particles to confirm that they can be referenced from both Shaders and Shuriken.

  • Mote (Light Dust)
    • Compute Shader + Fragment Shader + DrawMeshInstancedIndirect
    • Spring-damper physics simulation
    • Glow effect upon player approach
  • Ripple
    • Shuriken ParticleSystem + Custom shader
    • Ring-shaped effect occurring at the player's feet

Both share _RippleColor via GPU Sync. When the color is changed in the UI, the colors of both Mote and Ripple change simultaneously, and other parameters are also updated dynamically.

VariableSOs Used

The variables defined for this demo and where they are referenced are organized as follows:

Variable Type Usage
_PlayerPosition Vector3VariableSO Mote (Compute/Fragment)
_RippleColor ColorVariableSO Mote (Fragment), Ripple (Shuriken)
_MoteCount IntVariableSO Mote (Compute)
_CullDistance FloatVariableSO Mote (Compute)
_RepulsionRadius, _SpringStiffness, _Damping, etc. FloatVariableSO Mote (Compute)

Architecture

When parameters are changed via the UI, they are automatically reflected in the shader through the VariableSO. Even without writing any bridge code at all, because the bridge code is built into the Variable itself, it becomes relatively easy to use the same variables in Shaders.

Shader-side Code

In the Compute Shader, you simply declare them as global properties.

// Global properties: Automatically synchronized by VariableSO's GPU Sync feature
float4 _PlayerPosition;     // Vector3VariableSO
float _CullDistance;        // FloatVariableSO
float _RepulsionRadius;     // FloatVariableSO
float _SpringStiffness;     // FloatVariableSO
int _MoteCount;             // IntVariableSO

[numthreads(128, 1, 1)]
void UpdateMotes(uint3 id : SV_DispatchThreadID)
{
    // Calculate repulsion force using _PlayerPosition
    float3 toMote = motes[id.x].position - _PlayerPosition.xyz;
    float distToPlayer = length(toMote);

    if (distToPlayer < _RepulsionRadius)
    {
        // Repulsion processing...
    }
}

The same applies to the custom shader for Shuriken (Ripple).

// Global property: Automatically synchronized from ColorVariableSO
float4 _RippleColor;

float4 frag(Varyings input) : SV_Target
{
    // Ring shape calculation...

    // Get color via GPU Sync
    float3 color = _RippleColor.rgb * _Intensity;
    return float4(color, alpha);
}

On the C# side, simply referencing the VariableSO with SerializeField is enough, and no synchronization code is required.

[Header("VariableSO - Simulation Parameters")]
[SerializeField] private IntVariableSO moteCountVariable;
[SerializeField] private FloatVariableSO cullDistanceVariable;
[SerializeField] private FloatVariableSO repulsionRadiusVariable;
// ... Omitted

// No synchronization code in DispatchCompute()
// VariableSO automatically calls Shader.SetGlobalXXX()を呼んでくれる

Performance

Shader.SetGlobalXXX() is a lightweight operation, so calling it every frame is not an issue.

Since it is set to be called only when the value changes, the actual frequency of calls is even lower.

Use Cases

I think it can be used in situations like these:

Player Position Linked Effects

Vector3VariableSO (PlayerPosition)
├── ☑ GPU Sync Enabled
└── GPU Property Name: _PlayerPos

Shader: Particles swirl around _PlayerPos

Visualizing Area-of-Effect (AoE) Attacks

Vector3VariableSO (AoECenter)  → _AoECenter
FloatVariableSO (AoERadius)    → _AoERadius

Shader: Make the area within _AoERadius from _AoECenter glow red

Time-Based Effects

FloatVariableSO (DangerLevel)  → _DangerLevel

Shader: Turn the screen red according to _DangerLevel

Conclusion

Automatic synchronization from ScriptableObject to Shader was easier to implement than I expected.

This feature is planned to be included in v2.0.0 of a package I'm creating called Reactive SO (formerly Event Channels).
It's published on the Asset Store, so please check it out if you're interested.

https://assetstore.unity.com/packages/tools/game-toolkits/event-channels-339926

Finding time alongside my main job is difficult, but I will not give up and will continue game development next year.

Discussion