Editor code that implements subgraph (prismfn) support for the Prism shader graph editor. It defines port metadata (SubgraphPortInfo), caching and loading of .prismfn documents (SubgraphLibrary), thread-local argument binding for inlined instances (SubgraphBindings), IR renaming/rewriting to avoid local name collisions (IrRewriter), and node types: SubgraphInputNode, SubgraphOutputNode and SubgraphInstanceNode which handle port definition, validation, and inlining/compilation of referenced subgraphs into the caller's IR.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
namespace Editor.Prism.Nodes;
// ---------------------------------------------------------------------------------------------------
// Subgraphs.
//
// A .prismfn is a pure function: typed inputs in, typed outputs out. An instance of one is inlined at
// emit time — the referenced document is compiled through its own NodeEmitter against the *same*
// IrModule, and the statements it produced are spliced into the caller. That keeps one module, one
// helper table, one global table and one interpolator budget, which is what makes a subgraph free
// rather than a function call the driver has to inline anyway.
//
// The two things that make splicing safe are done here: every local the subgraph declared is renamed
// with a per-instance prefix so nothing shadows a caller's temp, and a thread-local binding frame
// carries the caller's argument values down to the SubgraphInput nodes.
// ---------------------------------------------------------------------------------------------------
/// <summary>
/// One typed slot on a node's boundary: an input or output of a subgraph, or one row of a
/// custom-code node's port table. Authored data, so every field is mutable and every field is
/// serialized.
/// </summary>
public sealed class SubgraphPortInfo
{
/// <summary>The slot's authored name. Also the port id, after sanitizing.</summary>
public string Name { get; set; } = "Value";
/// <summary>The slot's declared type, spelled the way HLSL spells it.</summary>
public string Type { get; set; } = "float";
/// <summary>What the slot is for. Becomes the port's tooltip.</summary>
public string Description { get; set; }
/// <summary>Sort order on the instance's card.</summary>
public int Order { get; set; }
/// <summary>True when leaving the slot unconnected is an error.</summary>
public bool Required { get; set; }
/// <summary>The slot's default value, packed into up to four components.</summary>
public float[] Default { get; set; }
/// <summary>The port id this slot occupies.</summary>
[JsonIgnore] public string PortId => SubgraphLibrary.Identifier( Name );
/// <summary>The slot's resolved type, falling back to <c>float</c>.</summary>
[JsonIgnore] public ShaderType ResolvedType =>
ShaderType.TryParse( Type, out var type ) && !type.IsVoid ? type : ShaderType.Float;
/// <summary>The default value as the compiler's literal shape.</summary>
[JsonIgnore] public ConstValue DefaultValue
{
get
{
var v = Default ?? Array.Empty<float>();
return new ConstValue(
v.Length > 0 ? v[0] : 0d,
v.Length > 1 ? v[1] : 0d,
v.Length > 2 ? v[2] : 0d,
v.Length > 3 ? v[3] : 0d );
}
}
/// <summary>Copy this slot.</summary>
public SubgraphPortInfo Clone() => new()
{
Name = Name,
Type = Type,
Description = Description,
Order = Order,
Required = Required,
Default = Default is null ? null : (float[])Default.Clone()
};
/// <inheritdoc/>
public override string ToString() => $"{Name} : {Type}";
}
/// <summary>
/// Loads and caches <c>.prismfn</c> documents, and describes their boundary.
/// </summary>
public static class SubgraphLibrary
{
sealed class Entry
{
public PrismGraph Graph;
public string Error;
public DateTime LoadedUtc;
/// <summary>
/// True for a document published through <see cref="Register"/> rather than read from disk.
/// <see cref="Invalidate"/> leaves these alone: an unsaved subgraph has no file to fall back to,
/// so dropping it would turn every instance of it into a broken node.
/// </summary>
public bool Pinned;
}
static readonly Dictionary<string, Entry> s_cache = new( StringComparer.OrdinalIgnoreCase );
static readonly object s_lock = new();
/// <summary>Drop every cached document. Must run on hotload and whenever a subgraph is saved.</summary>
public static void Flush()
{
lock ( s_lock )
{
s_cache.Clear();
}
}
/// <summary>
/// Publish an in-memory document under a path, so instances of it see the live version rather than
/// what is on disk.
/// <para>
/// This is how an open subgraph window keeps every instance of itself up to date while it is being
/// edited — and how a subgraph can be inlined at all before it has ever been saved. Pass null to
/// go back to reading the file.
/// </para>
/// </summary>
public static void Register( string path, PrismGraph graph )
{
if ( string.IsNullOrWhiteSpace( path ) ) return;
var key = Normalize( path );
if ( graph is null )
{
Unregister( key );
return;
}
lock ( s_lock )
{
s_cache[key] = new Entry { Graph = graph, LoadedUtc = DateTime.UtcNow, Pinned = true };
}
}
/// <summary>
/// Drop one cached document, so the next instance that asks for it reloads from disk. A document
/// published through <see cref="Register"/> is left alone — it may never have been saved, so there
/// would be nothing to reload — use <see cref="Unregister"/> to drop one of those.
/// </summary>
public static void Invalidate( string path )
{
if ( string.IsNullOrWhiteSpace( path ) ) return;
lock ( s_lock )
{
if ( s_cache.TryGetValue( Normalize( path ), out var entry ) && entry.Pinned ) return;
s_cache.Remove( Normalize( path ) );
}
}
/// <summary>Drop a cached document even when it was published in memory. Call this when the window
/// that published it closes, so instances go back to reading the file.</summary>
public static void Unregister( string path )
{
if ( string.IsNullOrWhiteSpace( path ) ) return;
lock ( s_lock )
{
s_cache.Remove( Normalize( path ) );
}
}
/// <summary>
/// The document at a content-relative path, or null with a reason. Never throws, and never reads
/// the same file twice in a session unless it was invalidated.
/// </summary>
public static PrismGraph Load( string path, out string error )
{
error = null;
if ( string.IsNullOrWhiteSpace( path ) )
{
error = "No subgraph is selected";
return null;
}
var key = Normalize( path );
lock ( s_lock )
{
if ( s_cache.TryGetValue( key, out var cached ) )
{
error = cached.Error;
return cached.Graph;
}
}
var entry = Read( key );
lock ( s_lock )
{
s_cache[key] = entry;
}
error = entry.Error;
return entry.Graph;
}
static Entry Read( string path )
{
var entry = new Entry { LoadedUtc = DateTime.UtcNow };
var text = PrismLog.Guard<string>( $"Read subgraph '{path}'", () =>
{
var content = Editor.FileSystem.Content;
if ( content is null ) return null;
if ( !content.FileExists( path ) ) return null;
return content.ReadAllText( path );
} );
if ( string.IsNullOrWhiteSpace( text ) )
{
entry.Error = $"Cannot find a subgraph at \"{path}\"";
return entry;
}
var sink = new DiagnosticSink();
var graph = PrismLog.Guard<PrismGraph>( $"Parse subgraph '{path}'", () => PrismSerializer.Read( text, sink ) );
if ( graph is null )
{
entry.Error = $"\"{path}\" is not a readable Prism document";
return entry;
}
graph.AssetPath = path;
entry.Graph = graph;
if ( sink.HasErrors )
{
entry.Error = $"\"{path}\" loaded with errors: " +
string.Join( "; ", sink.All.Where( x => x.Severity == DiagnosticSeverity.Error )
.Select( x => x.Message ).Take( 3 ) );
}
return entry;
}
static string Normalize( string path ) => path.Replace( '\\', '/' ).Trim();
/// <summary>The terminal node of a subgraph document, if it declares one.</summary>
public static SubgraphOutputNode Terminal( PrismGraph graph )
{
if ( graph?.Nodes is null ) return null;
foreach ( var node in graph.Nodes )
{
if ( node is SubgraphOutputNode terminal ) return terminal;
}
return null;
}
/// <summary>The input slots a document exposes, in authored order.</summary>
public static List<SubgraphPortInfo> DescribeInputs( PrismGraph graph )
{
var slots = new List<SubgraphPortInfo>();
if ( graph?.Nodes is null ) return slots;
var found = graph.Nodes.OfType<SubgraphInputNode>()
.Where( x => !string.IsNullOrWhiteSpace( x.InputName ) )
.OrderBy( x => x.PortOrder )
.ThenBy( x => x.InputName, StringComparer.OrdinalIgnoreCase );
var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var input in found )
{
if ( !seen.Add( Identifier( input.InputName ) ) ) continue;
slots.Add( input.Describe() );
}
return slots;
}
/// <summary>The output slots a document exposes, in authored order.</summary>
public static List<SubgraphPortInfo> DescribeOutputs( PrismGraph graph )
{
var terminal = Terminal( graph );
if ( terminal?.Slots is null ) return new List<SubgraphPortInfo>();
return terminal.Slots
.Where( x => x is not null && !string.IsNullOrWhiteSpace( x.Name ) )
.OrderBy( x => x.Order )
.Select( x => x.Clone() )
.ToList();
}
/// <summary>
/// Turn an authored slot name into a legal, stable port id.
/// <para>
/// ASCII only. A subgraph port id becomes a parameter name inside the generated helper function, and
/// the engine's <c>.shader</c> front-end is an ANTLR grammar over ASCII: one accented letter fails the
/// whole file with a mismatched-token dump and no line number at all. <c>char.IsLetterOrDigit</c> is
/// Unicode-aware and would let one straight through, so the ASCII-specific overloads are used.
/// </para>
/// </summary>
public static string Identifier( string name )
{
if ( string.IsNullOrWhiteSpace( name ) ) return "Value";
var text = new System.Text.StringBuilder( name.Length );
foreach ( var c in name )
{
if ( char.IsAsciiLetterOrDigit( c ) ) text.Append( c );
else if ( text.Length > 0 && text[^1] != '_' ) text.Append( '_' );
}
var result = text.ToString().Trim( '_' );
if ( result.Length == 0 ) return "Value";
if ( char.IsAsciiDigit( result[0] ) ) result = "_" + result;
return result;
}
}
/// <summary>
/// The argument values a subgraph instance passes down to the <see cref="SubgraphInputNode"/>s of the
/// document it is inlining.
/// <para>
/// Thread-static and stack-shaped, because a subgraph can instance another subgraph. A frame is pushed
/// only after the caller has finished reading its own inputs, so an outer input demanded on behalf of
/// an inner instance still resolves against the outer frame.
/// </para>
/// </summary>
internal static class SubgraphBindings
{
sealed class Frame : IDisposable
{
public Dictionary<string, IrValue> Values;
public string Path;
public void Dispose()
{
if ( s_stack is null || s_stack.Count == 0 ) return;
s_stack.RemoveAt( s_stack.Count - 1 );
}
}
[ThreadStatic] static List<Frame> s_stack;
/// <summary>Push a frame of argument values. Dispose to pop it.</summary>
public static IDisposable Push( string path, Dictionary<string, IrValue> values )
{
s_stack ??= new List<Frame>();
var frame = new Frame { Values = values, Path = path };
s_stack.Add( frame );
return frame;
}
/// <summary>The value bound to an input name in the innermost frame.</summary>
public static bool TryGet( string name, out IrValue value )
{
value = IrValue.Invalid;
if ( s_stack is null || s_stack.Count == 0 || string.IsNullOrEmpty( name ) ) return false;
var values = s_stack[^1].Values;
return values is not null && values.TryGetValue( name, out value ) && value.IsValid;
}
/// <summary>True when this document is already being inlined further up the stack.</summary>
public static bool IsActive( string path )
{
if ( s_stack is null || string.IsNullOrWhiteSpace( path ) ) return false;
foreach ( var frame in s_stack )
{
if ( string.Equals( frame.Path, path, StringComparison.OrdinalIgnoreCase ) ) return true;
}
return false;
}
/// <summary>How deep the inlining stack currently is.</summary>
public static int Depth => s_stack?.Count ?? 0;
}
/// <summary>
/// Renames the locals a spliced block declared, so an inlined subgraph can never shadow a temp the
/// caller is still using.
/// </summary>
internal static class IrRewriter
{
/// <summary>
/// The rename map for every local declared across a set of blocks.
/// <para>
/// Built across all of them at once, and applied only afterwards, because a value produced in one
/// block can be referenced from another and both have to agree on the new name.
/// </para>
/// </summary>
public static Dictionary<string, string> BuildMap( IEnumerable<IrBlock> blocks, string prefix )
{
var map = new Dictionary<string, string>( StringComparer.Ordinal );
if ( blocks is null || string.IsNullOrEmpty( prefix ) ) return map;
var declared = new HashSet<string>( StringComparer.Ordinal );
foreach ( var block in blocks ) Collect( block, declared );
foreach ( var name in declared ) map[name] = prefix + name;
return map;
}
/// <summary>Rewrite a value produced inside a renamed block so it still names the right local.</summary>
public static IrValue Rewrite( IrValue value, Dictionary<string, string> map )
{
if ( !value.IsValid || map is null || map.Count == 0 ) return value;
var rewritten = Rewrite( value.Expr, map );
return ReferenceEquals( rewritten, value.Expr ) ? value : new IrValue( rewritten, value.Type );
}
static void Collect( IrBlock block, HashSet<string> declared )
{
if ( block is null ) return;
foreach ( var statement in block.Statements )
{
switch ( statement )
{
case IrDecl decl when !string.IsNullOrEmpty( decl.Name ):
declared.Add( decl.Name );
break;
case IrFor loop when !string.IsNullOrEmpty( loop.Var ):
declared.Add( loop.Var );
break;
}
foreach ( var child in Children( statement ) ) Collect( child, declared );
}
}
/// <summary>Apply a rename map to every statement in a block, recursively.</summary>
public static void Apply( IrBlock block, Dictionary<string, string> map )
{
if ( block is null || map is null || map.Count == 0 ) return;
for ( int i = 0; i < block.Statements.Count; i++ )
{
block.Statements[i] = Rewrite( block.Statements[i], map );
}
}
static IrStmt Rewrite( IrStmt statement, Dictionary<string, string> map )
{
switch ( statement )
{
case IrDecl decl:
return decl with { Name = Rename( decl.Name, map ), Init = Rewrite( decl.Init, map ) };
case IrAssign assign:
return assign with
{
Target = Rewrite( assign.Target, map ),
Value = Rewrite( assign.Value, map )
};
case IrIf branch:
Apply( branch.Then, map );
Apply( branch.Else, map );
return branch with { Cond = Rewrite( branch.Cond, map ) };
case IrFor loop:
Apply( loop.Body, map );
return loop with { Var = Rename( loop.Var, map ), Count = Rewrite( loop.Count, map ) };
case IrWhile loop:
Apply( loop.Body, map );
return loop with { Cond = Rewrite( loop.Cond, map ) };
case IrReturn ret:
return ret with { Value = Rewrite( ret.Value, map ) };
case IrExprStmt expr:
return expr with { Value = Rewrite( expr.Value, map ) };
case IrScope scope:
Apply( scope.Body, map );
return scope;
default:
return statement;
}
}
static IEnumerable<IrBlock> Children( IrStmt statement )
{
switch ( statement )
{
case IrIf branch:
if ( branch.Then is not null ) yield return branch.Then;
if ( branch.Else is not null ) yield return branch.Else;
break;
case IrFor loop when loop.Body is not null:
yield return loop.Body;
break;
case IrWhile loop when loop.Body is not null:
yield return loop.Body;
break;
case IrScope scope when scope.Body is not null:
yield return scope.Body;
break;
}
}
static string Rename( string name, Dictionary<string, string> map ) =>
name is not null && map.TryGetValue( name, out var renamed ) ? renamed : name;
static IrExpr Rewrite( IrExpr expr, Dictionary<string, string> map )
{
switch ( expr )
{
case null:
return null;
case IrVar variable:
{
var renamed = Rename( variable.Name, map );
return ReferenceEquals( renamed, variable.Name ) ? variable : variable with { Name = renamed };
}
case IrCall call:
return call with { Args = RewriteAll( call.Args, map ) };
case IrHelperCall call:
return call with { Args = RewriteAll( call.Args, map ) };
case IrBinary binary:
return binary with { L = Rewrite( binary.L, map ), R = Rewrite( binary.R, map ) };
case IrUnary unary:
return unary with { V = Rewrite( unary.V, map ) };
case IrSwizzle swizzle:
return swizzle with { V = Rewrite( swizzle.V, map ) };
case IrConstruct construct:
return construct with { Parts = RewriteAll( construct.Parts, map ) };
case IrCast cast:
return cast with { V = Rewrite( cast.V, map ) };
case IrSelect select:
return select with
{
C = Rewrite( select.C, map ),
A = Rewrite( select.A, map ),
B = Rewrite( select.B, map )
};
case IrIndex index:
return index with { V = Rewrite( index.V, map ), I = Rewrite( index.I, map ) };
case IrMember member:
return member with { V = Rewrite( member.V, map ) };
default:
return expr;
}
}
static IrExpr[] RewriteAll( IrExpr[] items, Dictionary<string, string> map )
{
if ( items is null || items.Length == 0 ) return items;
var result = new IrExpr[items.Length];
for ( int i = 0; i < items.Length; i++ ) result[i] = Rewrite( items[i], map );
return result;
}
}
// ---- boundary nodes -------------------------------------------------------------------------------
/// <summary>
/// One typed input of a subgraph.
/// <para>
/// When the document is instanced, this node produces the value the caller wired into the matching
/// port. When the document is opened on its own it produces its preview input, or its default, so a
/// subgraph can be authored and previewed in isolation.
/// </para>
/// </summary>
[NodeInfo( Id = SubgraphInputNode.TypeId, Title = "Subgraph Input", Category = "Subgraph",
Icon = "input", Keywords = new[] { "subgraph", "input", "argument", "parameter", "function" },
Description = "Declares one input of this subgraph. The name and type become a port on every " +
"instance of it." )]
[NodeVersion( 1 )]
public sealed class SubgraphInputNode : PrismNode
{
/// <summary>The stable type id.</summary>
public const string TypeId = "prism.subgraph.input";
string _inputType = "float";
/// <summary>The input's name. This is what the instance's port is labelled and keyed by.</summary>
public string InputName { get; set; } = "Value";
/// <summary>What the input is for. Becomes the port's tooltip on every instance.</summary>
public string InputDescription { get; set; }
/// <summary>The input's type, spelled the way HLSL spells it.</summary>
public string InputType
{
get => _inputType;
set
{
var text = string.IsNullOrWhiteSpace( value ) ? "float" : value.Trim();
if ( string.Equals( _inputType, text, StringComparison.Ordinal ) ) return;
_inputType = text;
RebuildPorts();
}
}
/// <summary>Where the port sits on the instance's card.</summary>
public int PortOrder { get; set; }
/// <summary>True when an instance must wire something into this input.</summary>
public bool IsRequired { get; set; }
/// <summary>The value an instance uses when nothing is wired in, packed into four components.</summary>
public Vector4 DefaultValue { get; set; }
/// <summary>A value used only while this subgraph is being previewed on its own.</summary>
[In( "any", Name = "Preview" )] public PortRef Preview { get; set; }
/// <summary>The input's value.</summary>
[Out( "any", Name = "Value" )] public PortRef Result { get; set; }
/// <summary>The input's resolved type, falling back to <c>float</c>.</summary>
public ShaderType ResolvedType =>
ShaderType.TryParse( _inputType, out var type ) && !type.IsVoid ? type : ShaderType.Float;
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
if ( b is null ) return;
b.Retype( nameof( Result ), ResolvedType.ToString() );
b.SetFlags( nameof( Preview ), PortFlags.NoInlineEditor );
}
/// <summary>Describe this input as a boundary slot, for an instance's port table.</summary>
public SubgraphPortInfo Describe() => new()
{
Name = InputName,
Type = ResolvedType.ToString(),
Description = InputDescription,
Order = PortOrder,
Required = IsRequired,
Default = new[] { DefaultValue.x, DefaultValue.y, DefaultValue.z, DefaultValue.w }
};
/// <inheritdoc/>
public override void OnValidate( ValidationContext ctx )
{
if ( ctx is null ) return;
if ( string.IsNullOrWhiteSpace( InputName ) )
{
ctx.Error( "A subgraph input needs a name", null, DiagnosticCode.UnresolvedParameter );
return;
}
if ( ctx.Graph is not null && !ctx.Graph.IsSubgraph )
{
ctx.Warn( "Subgraph inputs only mean something inside a .prismfn; in a shader graph this " +
"reads as its default", null, DiagnosticCode.SubgraphUnavailable );
}
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
if ( ctx is null ) return;
var key = SubgraphLibrary.Identifier( InputName );
if ( SubgraphBindings.TryGet( key, out var bound ) )
{
ctx.Out( nameof( Result ), bound );
return;
}
if ( ctx.TryIn( nameof( Preview ), out var preview ) )
{
ctx.Out( nameof( Result ), preview );
return;
}
var type = ResolvedType;
var value = new ConstValue( DefaultValue.x, DefaultValue.y, DefaultValue.z, DefaultValue.w );
ctx.Out( nameof( Result ), ctx.Const( type, value ) );
}
}
/// <summary>
/// The terminal of a subgraph: the set of values it hands back.
/// <para>
/// Its slots are authored rather than fixed, so a subgraph can return anything from a single float to
/// a full material's worth of channels.
/// </para>
/// </summary>
[NodeInfo( Id = SubgraphOutputNode.TypeId, Title = "Subgraph Output", Category = "Subgraph",
Icon = "output", Keywords = new[] { "subgraph", "output", "result", "return", "function" },
Description = "Declares what this subgraph returns. Each slot becomes an output port on every " +
"instance of it." )]
[NodeVersion( 1 )]
public sealed class SubgraphOutputNode : PrismNode, IPrismOutputNode
{
/// <summary>The stable type id.</summary>
public const string TypeId = "prism.subgraph.output";
/// <summary>The values this subgraph returns, in authored order.</summary>
public List<SubgraphPortInfo> Slots { get; set; }
/// <inheritdoc/>
public ShaderDomain Domain => ShaderDomain.Subgraph;
/// <summary>Add a slot and rebuild the port set. Safe to call at any time.</summary>
public SubgraphPortInfo AddSlot( string name = null, string type = "float" )
{
Slots ??= new List<SubgraphPortInfo>();
var slot = new SubgraphPortInfo
{
Name = UniqueName( name ?? "Out" ),
Type = type,
Order = Slots.Count
};
Slots.Add( slot );
RebuildPorts();
return slot;
}
/// <summary>Remove a slot by name and rebuild the port set.</summary>
public bool RemoveSlot( string name )
{
if ( Slots is null || string.IsNullOrWhiteSpace( name ) ) return false;
var id = SubgraphLibrary.Identifier( name );
var removed = Slots.RemoveAll( x => x is not null && SubgraphLibrary.Identifier( x.Name ) == id );
if ( removed == 0 ) return false;
RebuildPorts();
return true;
}
/// <summary>Rebuild the port set after the slot table was edited in place.</summary>
public void SlotsChanged() => RebuildPorts();
string UniqueName( string name )
{
var baseName = string.IsNullOrWhiteSpace( name ) ? "Out" : name.Trim();
var candidate = baseName;
var index = 2;
while ( Slots.Any( x => x is not null &&
string.Equals( x.Name, candidate, StringComparison.OrdinalIgnoreCase ) ) )
{
candidate = $"{baseName}{index++}";
}
return candidate;
}
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
if ( b is null ) return;
var slots = Slots;
if ( slots is null || slots.Count == 0 )
{
b.Input( "Out", "any", string.Empty );
return;
}
var order = 0;
foreach ( var slot in slots.OrderBy( x => x?.Order ?? 0 ) )
{
if ( slot is null || string.IsNullOrWhiteSpace( slot.Name ) ) continue;
if ( b.Has( slot.PortId ) ) continue;
b.Input( slot.PortId, slot.ResolvedType.ToString(), slot.Name,
tooltip: slot.Description, order: order++,
flags: slot.Required ? PortFlags.Required : PortFlags.None );
}
}
/// <inheritdoc/>
public IEnumerable<StageRoot> Roots()
{
foreach ( var input in Inputs )
{
if ( input is null ) continue;
if ( Graph is null || !Graph.TryGetIncomingEdge( Id, input.Id, out _ ) ) continue;
yield return new StageRoot( ShaderStage.Pixel, Id, input.Id, input.Id.Value );
}
}
/// <inheritdoc/>
public override void OnValidate( ValidationContext ctx )
{
if ( ctx?.Graph is null ) return;
if ( !ctx.Graph.IsSubgraph )
{
ctx.Warn( "A subgraph output only means something inside a .prismfn",
null, DiagnosticCode.SubgraphUnavailable );
}
if ( Slots is not null && Slots.Count > 0 ) return;
ctx.Warn( "This subgraph returns nothing; add at least one output slot",
null, DiagnosticCode.NoOutput );
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
// Pulled, not pushed: the instance demands each slot's input directly.
}
}
/// <summary>
/// An instance of a <c>.prismfn</c>.
/// <para>
/// The referenced document is inlined into whatever is compiling this graph. If the file has gone
/// missing the node keeps the ports it last knew about, so every wire attached to it survives and the
/// error is a diagnostic rather than a silent loss of work.
/// </para>
/// </summary>
[NodeInfo( Id = SubgraphInstanceNode.TypeId, Title = "Subgraph", Category = "Subgraph",
Icon = "account_tree", Keywords = new[] { "subgraph", "function", "instance", "prismfn", "reuse" },
Description = "Runs another Prism document inline. Its inputs and outputs become this node's " +
"ports." )]
[NodeVersion( 1 )]
public sealed class SubgraphInstanceNode : PrismNode
{
/// <summary>The stable type id.</summary>
public const string TypeId = "prism.subgraph.instance";
string _subgraphPath;
/// <summary>
/// The content-relative path of the <c>.prismfn</c> this node instances.
/// <para>
/// The <c>Subgraph</c> alias is load-bearing: the graph view sets a dropped asset's path by trying a
/// short list of conventional property names, and <c>Subgraph</c> is the one on that list.
/// </para>
/// </summary>
[FormerlyKnownAs( "Subgraph" )]
public string SubgraphPath
{
get => _subgraphPath;
set
{
var text = string.IsNullOrWhiteSpace( value ) ? null : value.Replace( '\\', '/' ).Trim();
if ( string.Equals( _subgraphPath, text, StringComparison.OrdinalIgnoreCase ) ) return;
_subgraphPath = text;
PrismLog.Guard( "Load subgraph boundary", Reload );
RebuildPorts();
}
}
/// <summary>The title the referenced document carried the last time it was read.</summary>
public string CachedTitle { get; set; }
/// <summary>The input slots the referenced document exposed the last time it was read.</summary>
public List<SubgraphPortInfo> CachedInputs { get; set; }
/// <summary>The output slots the referenced document exposed the last time it was read.</summary>
public List<SubgraphPortInfo> CachedOutputs { get; set; }
/// <summary>Why the referenced document could not be used, or null when it is fine.</summary>
public string LoadError { get; private set; }
/// <summary>True when the referenced document could not be read.</summary>
public bool IsBroken => !string.IsNullOrEmpty( LoadError );
/// <summary>
/// Re-read the referenced document and rebuild the port set from it. Ports whose ids survive keep
/// their connections; ports that disappear leave their edges as visible ghosts.
/// </summary>
public void Refresh()
{
SubgraphLibrary.Invalidate( _subgraphPath );
Reload();
RebuildPorts();
}
/// <summary>Re-read the referenced document without rebuilding ports.</summary>
void Reload()
{
if ( string.IsNullOrWhiteSpace( _subgraphPath ) )
{
LoadError = "No subgraph is selected";
return;
}
var graph = SubgraphLibrary.Load( _subgraphPath, out var error );
LoadError = error;
if ( graph is null ) return;
var inputs = SubgraphLibrary.DescribeInputs( graph );
var outputs = SubgraphLibrary.DescribeOutputs( graph );
// A document that declares nothing is more likely mid-edit than genuinely empty, so the last
// known boundary is kept rather than throwing every wire away.
if ( inputs.Count > 0 || outputs.Count > 0 || CachedInputs is null )
{
CachedInputs = inputs;
CachedOutputs = outputs;
}
if ( !string.IsNullOrWhiteSpace( graph.Meta?.Title ) ) CachedTitle = graph.Meta.Title;
}
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
if ( b is null ) return;
var inputs = CachedInputs;
var outputs = CachedOutputs;
if ( inputs is null && outputs is null && !string.IsNullOrWhiteSpace( _subgraphPath ) )
{
PrismLog.Guard( "Load subgraph boundary", Reload );
inputs = CachedInputs;
outputs = CachedOutputs;
}
var order = 0;
foreach ( var slot in ( inputs ?? new List<SubgraphPortInfo>() ).OrderBy( x => x?.Order ?? 0 ) )
{
if ( slot is null || string.IsNullOrWhiteSpace( slot.Name ) ) continue;
if ( b.Has( slot.PortId ) ) continue;
b.Input( slot.PortId, slot.ResolvedType.ToString(), slot.Name,
tooltip: slot.Description, order: order++,
flags: slot.Required ? PortFlags.Required : PortFlags.None );
}
order = 0;
foreach ( var slot in ( outputs ?? new List<SubgraphPortInfo>() ).OrderBy( x => x?.Order ?? 0 ) )
{
if ( slot is null || string.IsNullOrWhiteSpace( slot.Name ) ) continue;
if ( b.Has( slot.PortId ) ) continue;
b.Output( slot.PortId, slot.ResolvedType.ToString(), slot.Name,
tooltip: slot.Description, order: order++ );
}
}
/// <inheritdoc/>
public override void OnValidate( ValidationContext ctx )
{
if ( ctx is null ) return;
if ( string.IsNullOrWhiteSpace( _subgraphPath ) )
{
ctx.Error( "This node is not pointing at a subgraph", null, DiagnosticCode.SubgraphUnavailable );
return;
}
if ( !IsBroken ) return;
ctx.Error( LoadError, null, DiagnosticCode.SubgraphUnavailable );
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
if ( ctx is null ) return;
PrismLog.Guard( $"Inline subgraph '{_subgraphPath}'", () => Inline( ctx ) );
}
void Inline( EmitContext ctx )
{
if ( ctx is not NodeEmitContext host )
{
ctx.Error( "A subgraph can only be inlined by the Prism compiler" );
Fail( ctx );
return;
}
if ( SubgraphBindings.IsActive( _subgraphPath ) )
{
ctx.Error( $"\"{_subgraphPath}\" instances itself, directly or through another subgraph" );
Fail( ctx );
return;
}
if ( SubgraphBindings.Depth >= MaxDepth )
{
ctx.Error( $"Subgraphs are nested more than {MaxDepth} deep" );
Fail( ctx );
return;
}
var document = SubgraphLibrary.Load( _subgraphPath, out var error );
if ( document is null )
{
ctx.Error( error ?? $"Cannot find a subgraph at \"{_subgraphPath}\"" );
Fail( ctx );
return;
}
var terminal = SubgraphLibrary.Terminal( document );
if ( terminal is null )
{
ctx.Error( $"\"{_subgraphPath}\" has no Subgraph Output node, so it returns nothing" );
Fail( ctx );
return;
}
var prefix = "sg" + Id.Value + "_";
// Read every argument first: an outer subgraph's inputs must still resolve against the outer
// binding frame while this node is collecting them.
//
// Each one is then bound through a local named after *this instance*. That is what keeps the
// splice sound: everything the inlined body can reach is either one of these — whose names no
// temp allocator ever mints — or a local it declared itself, which gets renamed below. Passing
// the caller's own temps straight in would leave names that the rename pass cannot tell apart
// from the subgraph's.
var arguments = new Dictionary<string, IrValue>( StringComparer.OrdinalIgnoreCase );
foreach ( var input in Inputs )
{
if ( input is null ) continue;
if ( !ctx.TryIn( input.Id.Value, out var value ) ) continue;
if ( !value.IsValid || value.Type.IsVoid ) continue;
arguments[input.Id.Value] =
host.Builder.Declare( Id, prefix + "in_" + input.Id.Value, value.Type, value );
}
var stage = ctx.Stage;
var roots = terminal.Inputs
.Where( x => x is not null && document.TryGetIncomingEdge( terminal.Id, x.Id, out _ ) )
.Select( x => new StageRoot( stage, terminal.Id, x.Id, x.Id.Value ) )
.ToArray();
var request = new CompileRequest( document, ctx.Mode )
{
Dialect = host.Emitter?.Request?.Dialect ?? Compiler.Backends.HlslDialect.SboxSlang,
DebugSymbols = host.Emitter?.DebugSymbols ?? false,
EmitComments = false,
OutputName = SubgraphLibrary.Identifier( CachedTitle ?? "subgraph" )
};
var plan = new StagePlanner( document, roots, ctx.Diagnostics )
{
AllowVaryingPromotion = stage == ShaderStage.Pixel
}.Plan();
// The inner emitter shares the outer interpolator budget — a subgraph's varyings are the host
// shader's varyings — but the inlined document's node ids are identical for every instance of
// the same .prismfn. KeyPrefix is what stops two instances from being handed the same register
// and clobbering each other's vertex-side write. It is the same prefix the local rename uses.
var inner = new NodeEmitter( document, host.Module, request, ctx.Diagnostics,
ctx.Backend, plan, host.Emitter?.Varyings )
{
// Composed with the host's own prefix, so nesting scopes the same way the local rename does:
// two instances of an outer subgraph that each contain the same inner one still get four
// distinct keys rather than two.
KeyPrefix = ( host.Emitter?.KeyPrefix ?? string.Empty ) + prefix
};
var results = new Dictionary<PortId, IrValue>();
using ( SubgraphBindings.Push( _subgraphPath, arguments ) )
{
foreach ( var input in terminal.Inputs )
{
if ( input is null ) continue;
var context = new NodeEmitContext( inner, terminal, stage, inner.Builder( stage ) );
results[input.Id] = inner.DemandInput( terminal, input, stage, context );
}
}
var map = Splice( host, inner, stage, prefix );
foreach ( var output in Outputs )
{
if ( output is null ) continue;
ctx.Out( output.Id.Value, results.TryGetValue( output.Id, out var value )
? IrRewriter.Rewrite( value, map )
: IrValue.Invalid );
}
}
/// <summary>
/// Move everything the inlined document emitted into the caller's stage builders, renaming its
/// locals first so nothing can shadow a temp the caller is still holding. Returns the rename map,
/// which the caller has to run its result values through as well.
/// </summary>
static Dictionary<string, string> Splice( NodeEmitContext host, NodeEmitter inner, ShaderStage stage,
string prefix )
{
var blocks = new List<(ShaderStage Stage, IrBlock Block)>();
foreach ( var emitted in ShaderStages.All )
{
if ( emitted == ShaderStage.None ) continue;
var source = inner.Builder( emitted );
if ( source.Root.IsEmpty ) continue;
blocks.Add( (emitted, source.Root) );
}
var map = IrRewriter.BuildMap( blocks.Select( x => x.Block ), prefix );
foreach ( var (emitted, block) in blocks )
{
IrRewriter.Apply( block, map );
// The stage being emitted lands wherever the caller is currently writing; another stage —
// the vertex half of an interpolated value, say — lands at the end of that stage's body.
if ( emitted == stage )
{
foreach ( var statement in block.Statements ) host.Builder.Add( statement );
continue;
}
var target = host.Emitter?.Builder( emitted );
if ( target is null ) continue;
foreach ( var statement in block.Statements ) target.Root.Add( statement );
}
return map;
}
void Fail( EmitContext ctx )
{
foreach ( var output in Outputs )
{
if ( output is null ) continue;
ctx.Out( output.Id.Value, IrValue.Invalid );
}
}
/// <summary>How deep subgraph instancing is allowed to go before it is treated as a mistake.</summary>
public const int MaxDepth = 16;
}