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.
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;
}
}
}