Code/EmitterModelAttributes.cs

Component that drives shader attributes on a ShaderParticleModelRenderer from a ParticleEmitter over its lifetime. It samples configured float, color, vector and texture maps at a normalized emitter time and writes them into the renderer each frame.

File Access
using System;
using System.Collections.Generic;
using Sandbox;
namespace Sandbox;

[Title( "EmitterModelAttributes" )]
[Category( "Shaders" )]
[Description(
	"Drives the shader render attributes of a ShaderParticleModelRenderer over the lifetime of a ParticleEmitter. " +
	"The animated values are sampled from the emitter's normalized time and pushed into the renderer so every live particle shares them." )]
public sealed class EmitterModelAttributes : Component, Component.ExecuteInEditor
{
	/// <summary>
	/// The renderer whose shader attribute dictionaries get overwritten each frame.
	/// </summary>
	[Property] public ShaderParticleModelRenderer Renderer { get; set; }

	/// <summary>
	/// The emitter whose lifetime (Duration + Delay) drives the normalized time used to sample the curves.
	/// </summary>
	[Property] public ParticleEmitter Emitter { get; set; }

	[Property, Group( "Floats1" )] public Dictionary<String, ParticleFloat> Floats { get; set; }
	[Property, Group( "Colors" )] public Dictionary<String, Gradient> Colors { get; set; }
	[Property, Group( "Floats2" )] public Dictionary<String, Vector2> Floats2 { get; set; }
	[Property, Group( "Floats4" )] public Dictionary<String, Vector4> Floats4 { get; set; }
	[Property, Group( "Textures" )] public Dictionary<String, Texture> Textures { get; set; }
	[Property, Group( "DynamicCombos" )] public Dictionary<String, int> DynamicCombos { get; set; }

	/// <summary>
	/// When true, the emitter's Delay is included so the time stays at 0 during the delay.
	/// </summary>
	[Property] public bool IncludeDelay { get; set; } = true;

	// Looping is driven by the emitter, never by a local flag. In the editor we always
	// loop so the animation previews continuously.
	private bool ShouldLoop => Scene.IsEditor || (Emitter.IsValid() && Emitter.Loop);

	protected override void OnStart()
	{
		Floats ??= new Dictionary<string, ParticleFloat>();
		Colors ??= new Dictionary<string, Gradient>();
		Floats2 ??= new Dictionary<string, Vector2>();
		Floats4 ??= new Dictionary<string, Vector4>();
		Textures ??= new Dictionary<string, Texture>();
		DynamicCombos ??= new Dictionary<string, int>();

		ApplyAttributes();
	}

	protected override void OnUpdate()
	{
		ApplyAttributes();
	}

	/// <summary>
	/// Normalized emitter time in [0,1] accounting for delay and looping.
	/// Driven entirely off the emitter's own playback clock so it can never drift out of
	/// sync, including on restart, playback scrubbing or time scaling.
	/// </summary>
	private float ComputeTime()
	{
		if ( !Emitter.IsValid() )
			return 0f;

		var duration = Emitter.Duration.Evaluate( 0f, 0f );
		if ( duration <= 0f )
			return 0f;

		var delay = IncludeDelay ? Emitter.Delay.Evaluate( 0f, 0f ) : 0f;

		// Emitter.time spans the whole [0, delay + duration] lifetime; the animation only
		// starts once the delay has elapsed.
		var elapsed = Emitter.time - delay;
		if ( elapsed <= 0f )
			return 0f;

		var raw = elapsed / duration;
		return ShouldLoop ? raw % 1f : raw.Clamp( 0f, 1f );
	}

	private void ApplyAttributes()
	{
		if ( !Renderer.IsValid() )
			return;

		// The renderer initializes its dictionaries in its own OnStart; component start
		// order isn't guaranteed, so make sure they exist before we write into them.
		Renderer.Floats ??= new Dictionary<string, ParticleFloat>();
		Renderer.Colors ??= new Dictionary<string, ParticleGradient>();
		Renderer.Floats2 ??= new Dictionary<string, ParticleVector2>();
		Renderer.Floats4 ??= new Dictionary<string, Vector4>();
		Renderer.Textures ??= new Dictionary<string, Texture>();
		Renderer.DynamicCombos ??= new Dictionary<string, int>();

		var t = ComputeTime();

		SetFloatAttributes( t );
		SetColorAttributes( t );
		SetFloat2Attributes();
		SetFloat4Attributes();
		SetTextureAttributes();
		SetDynamicCombos();
	}

	private void SetFloatAttributes( float t )
	{
		foreach ( var pair in Floats )
		{
			Renderer.Floats[pair.Key] = new ParticleFloat { ConstantValue = pair.Value.Evaluate( t, 6211 ) };
		}
	}

	private void SetColorAttributes( float t )
	{
		foreach ( var pair in Colors )
		{
			// Color implicitly converts to a constant ParticleGradient.
			Renderer.Colors[pair.Key] = pair.Value.Evaluate( t );
		}
	}

	private void SetFloat2Attributes()
	{
		foreach ( var pair in Floats2 )
			Renderer.Floats2[pair.Key] = pair.Value;
	}

	private void SetFloat4Attributes()
	{
		foreach ( var pair in Floats4 )
			Renderer.Floats4[pair.Key] = pair.Value;
	}

	private void SetTextureAttributes()
	{
		foreach ( var pair in Textures )
			Renderer.Textures[pair.Key] = pair.Value;
	}

	private void SetDynamicCombos()
	{
		foreach ( var pair in DynamicCombos )
			Renderer.DynamicCombos[pair.Key] = pair.Value;
	}
}