iTranslated by AI
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.
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.
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.
Finding time alongside my main job is difficult, but I will not give up and will continue game development next year.
Discussion