Editor/Prism/Preview/PreviewAttributeBus.cs

Editor-side helper that caches and pushes preview shader uniforms to a SceneObject's RenderAttributes. Stores values (floats, vectors, ints, bools, textures, combos) in a dictionary, replays them when the target changes, applies compiler-produced defaults, loads textures via Texture.Load with a small cache, and writes attributes to the GPU-side block.

File AccessNative Interop
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;

namespace Editor.Prism.Preview;

/// <summary>
/// The live-uniform channel between the editor and the preview shader.
/// <para>
/// In <see cref="CompileMode.Preview"/> the compiler turns every literal and every blackboard
/// parameter into a named uniform bound to a render attribute, and records it in
/// <c>CompileResult.PreviewAttributes</c>. Pushing a value through this bus updates the frame
/// immediately with no recompile at all, which is the entire reason dragging a slider in Prism is
/// smooth. Only a structural change — topology, types, keywords, domain — forces the compiler to run
/// again.
/// </para>
/// <para>
/// Every write goes through a dictionary <em>indexer</em>. The built-in editor's equivalent helper
/// uses <c>Dictionary.Add</c> and therefore throws the second time the same uniform is pushed, which
/// is a crash waiting for the first person to drag a slider twice. Values are also cached, because a
/// scene object carries its attributes per object: swapping the preview mesh creates a new object
/// with an empty attribute block, and <see cref="Reapply"/> puts everything back.
/// </para>
/// </summary>
public sealed class PreviewAttributeBus
{
	enum Slot
	{
		Float,
		Vector2,
		Vector3,
		Vector4,
		Int,
		Bool,
		Texture,
		Combo
	}

	readonly record struct Entry( Slot Kind, Vector4 Value, Texture Texture );

	readonly Dictionary<string, Entry> _values = new( StringComparer.Ordinal );

	SceneObject _target;

	/// <summary>
	/// The object attributes are pushed to. Assigning a new object replays every cached value onto it,
	/// so a mesh swap never silently drops the graph's uniforms.
	/// </summary>
	public SceneObject Target
	{
		get => _target;
		set
		{
			if ( ReferenceEquals( _target, value ) ) return;

			_target = value;

			Reapply();
		}
	}

	/// <summary>The attribute block being written to, or null when there is no target.</summary>
	public RenderAttributes Attributes => _target.IsValid() ? _target.Attributes : null;

	/// <summary>How many uniforms are currently cached.</summary>
	public int Count => _values.Count;

	/// <summary>Every cached uniform name, in no particular order.</summary>
	public IEnumerable<string> Names => _values.Keys;

	/// <summary>Raised after any change, so a status line can show how many uniforms are live.</summary>
	public event Action Changed;

	// ---- typed writes ------------------------------------------------------

	/// <summary>Push a scalar.</summary>
	public void Set( string name, float value ) =>
		Store( name, new Entry( Slot.Float, new Vector4( value, 0, 0, 0 ), null ) );

	/// <summary>Push a two-component vector.</summary>
	public void Set( string name, Vector2 value ) =>
		Store( name, new Entry( Slot.Vector2, new Vector4( value.x, value.y, 0, 0 ), null ) );

	/// <summary>Push a three-component vector.</summary>
	public void Set( string name, Vector3 value ) =>
		Store( name, new Entry( Slot.Vector3, new Vector4( value.x, value.y, value.z, 0 ), null ) );

	/// <summary>Push a four-component vector.</summary>
	public void Set( string name, Vector4 value ) =>
		Store( name, new Entry( Slot.Vector4, value, null ) );

	/// <summary>Push a colour. Stored as a four-component vector, which is what the shader declares.</summary>
	public void Set( string name, Color value ) =>
		Store( name, new Entry( Slot.Vector4, new Vector4( value.r, value.g, value.b, value.a ), null ) );

	/// <summary>Push an integer.</summary>
	public void Set( string name, int value ) =>
		Store( name, new Entry( Slot.Int, new Vector4( value, 0, 0, 0 ), null ) );

	/// <summary>Push a boolean.</summary>
	public void Set( string name, bool value ) =>
		Store( name, new Entry( Slot.Bool, new Vector4( value ? 1 : 0, 0, 0, 0 ), null ) );

	/// <summary>Push a texture. A null texture is stored so the slot is reset rather than left stale.</summary>
	public void SetTexture( string name, Texture value ) =>
		Store( name, new Entry( Slot.Texture, default, value ) );

	/// <summary>
	/// Push a combo. Only works when the combo was actually compiled into the shader; the caller is
	/// responsible for triggering a recompile when it was not.
	/// </summary>
	public void SetCombo( string name, int value ) =>
		Store( name, new Entry( Slot.Combo, new Vector4( value, 0, 0, 0 ), null ) );

	/// <summary>Push a boolean combo.</summary>
	public void SetCombo( string name, bool value ) => SetCombo( name, value ? 1 : 0 );

	// ---- compiler interop --------------------------------------------------

	/// <summary>
	/// Push one uniform the compiler declared, choosing the write that matches its declared type.
	/// Object-typed attributes are skipped — a texture has no numeric default and is pushed by the
	/// parameter panel through <see cref="SetTexture"/> instead.
	/// </summary>
	public bool Set( PreviewAttribute attribute )
	{
		if ( attribute is null || string.IsNullOrEmpty( attribute.Name ) ) return false;

		return Set( attribute.Name, attribute.Type, attribute.Value );
	}

	/// <summary>Push a raw typed constant under a name. Returns false for a type with no attribute form.</summary>
	public bool Set( string name, ShaderType type, ConstValue value )
	{
		if ( string.IsNullOrEmpty( name ) ) return false;

		if ( type.IsObject ) return false;

		if ( type.IsBoolean )
		{
			Set( name, value.X != 0 );
			return true;
		}

		if ( type.IsMatrix ) return false;

		if ( type.IsIntegral && type.Components <= 1 )
		{
			Set( name, (int)Math.Round( value.X ) );
			return true;
		}

		switch ( Math.Clamp( type.Components, 1, 4 ) )
		{
			case 1:
				Set( name, (float)value.X );
				return true;

			case 2:
				Set( name, new Vector2( (float)value.X, (float)value.Y ) );
				return true;

			case 3:
				Set( name, new Vector3( (float)value.X, (float)value.Y, (float)value.Z ) );
				return true;

			default:
				Set( name, value.ToVector4() );
				return true;
		}
	}

	/// <summary>
	/// Replace the cache with the uniforms a compile produced and push them all. Returns how many were
	/// written. Cached values that survive under the same name keep whatever the user last dragged them
	/// to, so a recompile does not snap every slider back to its authored default.
	/// </summary>
	public int Apply( IEnumerable<PreviewAttribute> attributes, bool preserveExisting = true )
	{
		var written = 0;
		var previous = preserveExisting ? new Dictionary<string, Entry>( _values, StringComparer.Ordinal ) : null;

		_values.Clear();

		foreach ( var attribute in attributes ?? Array.Empty<PreviewAttribute>() )
		{
			if ( attribute is null || string.IsNullOrEmpty( attribute.Name ) ) continue;

			if ( previous is not null && previous.TryGetValue( attribute.Name, out var kept ) )
			{
				Store( attribute.Name, kept );
				written++;

				continue;
			}

			if ( Set( attribute ) ) written++;
		}

		Changed?.Invoke();

		return written;
	}

	/// <summary>
	/// Load and push the texture slots a preview compile could not bake for itself, and report how many
	/// landed.
	/// <remarks>
	/// Textures are loaded once per path and cached for the life of the editor: <c>Texture.Load</c> hits
	/// the asset system, and a recompile happens on every structural edit. A path that will not load
	/// leaves <c>Texture.White</c> in the slot rather than nothing, because a white sampler previews as
	/// "texture missing" while an unbound one previews as black and reads as "the graph is broken".
	/// </remarks>
	/// </summary>
	public int ApplyTextures( IEnumerable<PreviewTexture> textures )
	{
		var written = 0;

		foreach ( var texture in textures ?? Array.Empty<PreviewTexture>() )
		{
			if ( texture is null || string.IsNullOrEmpty( texture.Name ) ) continue;

			SetTexture( texture.Name, Resolve( texture.Asset ) );
			written++;
		}

		if ( written > 0 ) Changed?.Invoke();

		return written;
	}

	static readonly Dictionary<string, Texture> s_assets = new( StringComparer.OrdinalIgnoreCase );

	static Texture Resolve( string asset )
	{
		if ( string.IsNullOrWhiteSpace( asset ) ) return Texture.White;

		lock ( s_assets )
		{
			if ( s_assets.TryGetValue( asset, out var cached ) ) return cached ?? Texture.White;
		}

		var loaded = PrismLog.Guard( $"Loading preview texture '{asset}'", () => Texture.Load( asset ), null );

		if ( loaded is null )
		{
			PrismLog.Warn( $"Prism could not load '{asset}' for the preview; the slot shows white. " +
				"The shipped shader is unaffected — it bakes the image through CreateInputTexture." );
		}

		lock ( s_assets )
		{
			s_assets[asset] = loaded;
		}

		return loaded ?? Texture.White;
	}

	/// <summary>Drop the loaded-texture cache. Call when assets change on disk or on hotload.</summary>
	public static void FlushAssets()
	{
		lock ( s_assets )
		{
			s_assets.Clear();
		}
	}

	// ---- maintenance -------------------------------------------------------

	/// <summary>Forget one uniform. The GPU-side value is left as it was; nothing can un-set it.</summary>
	public bool Remove( string name )
	{
		if ( string.IsNullOrEmpty( name ) ) return false;
		if ( !_values.Remove( name ) ) return false;

		Changed?.Invoke();

		return true;
	}

	/// <summary>Forget every uniform and clear the target's attribute block.</summary>
	public void Clear()
	{
		_values.Clear();

		var attributes = Attributes;

		if ( attributes is not null ) PrismLog.Guard( "Clearing preview attributes", attributes.Clear );

		Changed?.Invoke();
	}

	/// <summary>Push every cached uniform onto the current target. Called automatically on a mesh swap.</summary>
	public void Reapply()
	{
		var attributes = Attributes;

		if ( attributes is null ) return;

		PrismLog.Guard( "Re-pushing preview attributes", () =>
		{
			foreach ( var (name, entry) in _values )
			{
				Push( attributes, name, entry );
			}
		} );
	}

	/// <summary>Read back a cached uniform as a four-component vector.</summary>
	public bool TryGetVector4( string name, out Vector4 value )
	{
		if ( !string.IsNullOrEmpty( name ) && _values.TryGetValue( name, out var entry ) && entry.Kind != Slot.Texture )
		{
			value = entry.Value;
			return true;
		}

		value = default;
		return false;
	}

	/// <summary>Read back a cached texture uniform.</summary>
	public bool TryGetTexture( string name, out Texture value )
	{
		if ( !string.IsNullOrEmpty( name ) && _values.TryGetValue( name, out var entry ) && entry.Kind == Slot.Texture )
		{
			value = entry.Texture;
			return true;
		}

		value = null;
		return false;
	}

	void Store( string name, Entry entry )
	{
		if ( string.IsNullOrEmpty( name ) ) return;

		// Indexer, never Add: the same uniform is pushed on every single frame of a slider drag.
		_values[name] = entry;

		var attributes = Attributes;

		if ( attributes is null ) return;

		// Hand-rolled rather than PrismLog.Guard: this runs several times per frame while a slider is
		// being dragged, and the guard's interpolated message would allocate a string every time.
		try
		{
			Push( attributes, name, entry );
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, $"Pushing preview attribute '{name}' failed" );
		}
	}

	static void Push( RenderAttributes attributes, string name, Entry entry )
	{
		switch ( entry.Kind )
		{
			case Slot.Float:
				attributes.Set( name, entry.Value.x );
				break;

			case Slot.Vector2:
				attributes.Set( name, new Vector2( entry.Value.x, entry.Value.y ) );
				break;

			case Slot.Vector3:
				attributes.Set( name, new Vector3( entry.Value.x, entry.Value.y, entry.Value.z ) );
				break;

			case Slot.Vector4:
				attributes.Set( name, entry.Value );
				break;

			case Slot.Int:
				attributes.Set( name, (int)MathF.Round( entry.Value.x ) );
				break;

			case Slot.Bool:
				attributes.Set( name, entry.Value.x != 0f );
				break;

			case Slot.Texture:
				attributes.Set( name, entry.Texture ?? Texture.White );
				break;

			case Slot.Combo:
				attributes.SetCombo( name, (int)MathF.Round( entry.Value.x ) );
				break;
		}
	}
}