Prism graph compiler entry point. Validates a Prism graph, resolves parameters and keywords into IR module metadata, runs type solving and stage planning, emits IR, optimizes it, and invokes shader backends to produce artifacts.
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using System.Diagnostics;
using System.Text;
namespace Editor.Prism.Compiler;
/// <summary>
/// One blackboard parameter, as the compiler needs to see it.
/// <para>
/// The concrete <c>Parameter</c> class lives in the model layer; this is the slice of it the compiler
/// depends on, declared here so the compiler never has to reference the model's concrete types.
/// </para>
/// </summary>
public interface IGraphParameter
{
/// <summary>Stable id nodes reference.</summary>
ParamId Id { get; }
/// <summary>Authored display name. Also the basis of the emitted symbol name.</summary>
string Name { get; }
/// <summary>The parameter's type.</summary>
ShaderType Type { get; }
/// <summary>Numeric default.</summary>
ConstValue DefaultValue { get; }
/// <summary>Asset-path default, for textures.</summary>
string DefaultAsset { get; }
/// <summary>Render-attribute name, when the parameter is driven at runtime.</summary>
string AttributeName { get; }
/// <summary>True when a texture's contents are sRGB encoded.</summary>
bool Srgb { get; }
/// <summary>Material-UI metadata.</summary>
UiHints Ui { get; }
}
/// <summary>One graph keyword, as the compiler needs to see it.</summary>
public interface IGraphKeyword
{
/// <summary>Stable id nodes reference.</summary>
ParamId Id { get; }
/// <summary>The combo name, e.g. <c>F_PUDDLES</c>.</summary>
string Name { get; }
/// <summary>Feature, static or dynamic.</summary>
ComboKind Kind { get; }
/// <summary>The values the combo can take.</summary>
IReadOnlyList<string> Values { get; }
/// <summary>Index of the default value.</summary>
int Default { get; }
/// <summary>Group heading in the material editor.</summary>
string Group { get; }
}
/// <summary>
/// Everything the compiler needs from a document beyond <see cref="IPrismGraph"/>.
/// <para>
/// The concrete graph class belongs to the model layer and is written by a different package, so the
/// compiler consumes it through this interface instead. Every member has a sane fallback: a graph that
/// only implements <see cref="IPrismGraph"/> still compiles, it just uses defaults and finds its
/// output node by structure rather than by declaration.
/// </para>
/// </summary>
public interface ICompilableGraph : IPrismGraph
{
/// <summary>What the graph is for.</summary>
ShaderDomain Domain { get; }
/// <summary>How a surface graph resolves to pixels.</summary>
ShadingModel ShadingModel { get; }
/// <summary>Output blending.</summary>
SurfaceBlendMode BlendMode { get; }
/// <summary>Triangle culling.</summary>
CullMode CullMode { get; }
/// <summary>Declared render passes, e.g. <c>Forward</c>, <c>Depth</c>.</summary>
IReadOnlyList<string> Modes { get; }
/// <summary>True when the second UV channel is used.</summary>
bool UsesUv2 { get; }
/// <summary>True when back faces are rendered.</summary>
bool RenderBackfaces { get; }
/// <summary>Document title, written into the shader header.</summary>
string Title { get; }
/// <summary>Document description, written into the shader header.</summary>
string Description { get; }
/// <summary>Blackboard parameters, in authored order.</summary>
IReadOnlyList<IGraphParameter> Parameters { get; }
/// <summary>Keywords, in authored order.</summary>
IReadOnlyList<IGraphKeyword> Keywords { get; }
/// <summary>The terminal node, or null to let the compiler find it.</summary>
PrismNode OutputNode { get; }
}
/// <summary>
/// Implemented by a terminal node so it can declare exactly what the shader has to produce and in
/// which stage. A node that does not implement it still works — the compiler falls back to treating
/// every connected input as a pixel-stage root.
/// </summary>
public interface IPrismOutputNode
{
/// <summary>What this output makes the graph.</summary>
ShaderDomain Domain { get; }
/// <summary>The values this output demands, in emission order.</summary>
IEnumerable<StageRoot> Roots();
}
/// <summary>
/// The single entry point into the Prism compiler.
/// <para>
/// The pipeline is seven stages, and every one of them is a pure function of its input plus a
/// diagnostic sink: <b>validate</b> the graph's structure, <b>resolve</b> parameters and keywords into
/// module declarations, <b>solve</b> types by unification, <b>plan</b> stages and interpolators,
/// <b>emit</b> IR by demand-driven traversal, <b>optimise</b> the IR, then hand the finished module to
/// each requested <b>backend</b>. Stages one to six are backend-agnostic and run once, which is why
/// emitting both an s&box <c>.shader</c> and a portable <c>.slang</c> module costs almost nothing
/// beyond the second text serialization.
/// </para>
/// <para>
/// Nothing here throws for user error. A malformed graph produces diagnostics and whatever artifacts
/// were still reachable, never an exception out of a public API.
/// </para>
/// </summary>
public static class GraphCompiler
{
/// <summary>The struct instance vertex-stage writes and pixel-stage interpolator reads go through.</summary>
public const string PixelInputVariable = NodeEmitter.PixelInputVariable;
/// <summary>The struct type vertex-stage writes and pixel-stage interpolator reads go through.</summary>
public const string PixelInputStruct = NodeEmitter.PixelInputStruct;
/// <summary>The struct instance a surface graph's pixel-stage results are written into.</summary>
public const string MaterialVariable = "m";
/// <summary>The struct type a surface graph's pixel-stage results are written into.</summary>
public const string MaterialStruct = "Material";
/// <summary>The root target every vertex graph understands: an offset applied to the vertex position.</summary>
public const string PositionOffsetTarget = "PositionOffset";
/// <summary>
/// The world-position field of the pixel-input struct, which is where a vertex position offset is
/// actually applied. Backends translate this spelling: the s&box backend emits it verbatim, the
/// Slang backend aliases it to <c>WorldPosition</c>.
/// </summary>
public const string WorldPositionField = "vPositionWs";
static IReadOnlyList<IShaderBackend> s_backends;
/// <summary>Compile a graph with the backends the request asks for.</summary>
public static CompileResult Compile( CompileRequest request ) => Compile( request, DefaultBackends() );
/// <summary>
/// Compile a document straight from the model, with every knob taken from its own
/// <c>GraphSettings</c>: which backends to run, which HLSL dialect, whether to keep debug symbols.
/// <para>
/// This is the overload the editor calls. Building a <see cref="CompileRequest"/> by hand and
/// forgetting to copy <c>Settings.Targets</c> across is how a graph that asked for a <c>.slang</c>
/// module silently stops producing one, so the wiring lives here rather than at every call site.
/// </para>
/// </summary>
public static CompileResult Compile( PrismGraph graph, CompileMode mode = CompileMode.Final ) =>
Compile( RequestFor( graph, mode ) );
/// <summary>
/// Build the compile request a document implies. Returns null for a null graph.
/// <para>
/// The structure and value hashes are copied in so a caller can compare this request against the one
/// that produced the artifacts it is holding and skip the work entirely.
/// </para>
/// </summary>
public static CompileRequest RequestFor( PrismGraph graph, CompileMode mode = CompileMode.Final )
{
if ( graph is null ) return null;
var settings = graph.Settings ?? new GraphSettings();
// Normalising here means the friendly spellings a hand-edited document may carry ("SboxShader",
// "Slang") are canonicalised to backend ids before anything tries to match a backend against them.
settings.Normalize();
var targets = settings.Targets is { Count: > 0 }
? settings.Targets.ToArray()
: new[] { PrismConstants.BackendHlsl };
return new CompileRequest( graph, mode )
{
Targets = targets,
Dialect = settings.HlslDialect,
DebugSymbols = settings.DebugSymbols,
EmitComments = mode is CompileMode.Final or CompileMode.Preview,
OutputName = OutputNameFor( graph ),
StructureHash = graph.StructureHash,
ValueHash = graph.ValueHash
};
}
/// <summary>The file-stem a document generates under: its asset name, else its title, else the default.</summary>
static string OutputNameFor( PrismGraph graph )
{
var fromAsset = string.IsNullOrWhiteSpace( graph.AssetPath )
? null
: System.IO.Path.GetFileNameWithoutExtension( graph.AssetPath );
var candidate = !string.IsNullOrWhiteSpace( fromAsset ) ? fromAsset : graph.Meta?.Title;
if ( string.IsNullOrWhiteSpace( candidate ) ) return "prism_shader";
var sb = new StringBuilder( candidate.Length );
foreach ( var c in candidate )
{
sb.Append( char.IsLetterOrDigit( c ) ? char.ToLowerInvariant( c ) : '_' );
}
var name = sb.ToString().Trim( '_' );
if ( name.Length == 0 ) return "prism_shader";
if ( char.IsDigit( name[0] ) ) name = "_" + name;
return name;
}
/// <summary>Compile a graph against an explicit backend set. This is the one that does the work.</summary>
public static CompileResult Compile( CompileRequest request, IReadOnlyList<IShaderBackend> backends )
{
var diagnostics = new DiagnosticSink();
if ( request is null )
{
diagnostics.Error( DiagnosticCode.NoOutput, "There is nothing to compile" );
return CompileResult.Failed( diagnostics.All );
}
try
{
return Run( request, backends ?? Array.Empty<IShaderBackend>(), diagnostics );
}
catch ( OperationCanceledException )
{
return CompileResult.Failed( diagnostics.All, request );
}
catch ( Exception e )
{
// A compiler bug is still a diagnostic, never a crash in the editor.
PrismLog.Error( e, "Prism compiler failed" );
diagnostics.Report( new Diagnostic( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
$"The compiler failed unexpectedly: {e.Message}", e.ToString(), null, null ) );
return CompileResult.Failed( diagnostics.All, request );
}
}
static CompileResult Run( CompileRequest request, IReadOnlyList<IShaderBackend> backends,
DiagnosticSink diagnostics )
{
var total = Stopwatch.StartNew();
var analysis = Stopwatch.StartNew();
var graph = request.Graph;
var compilable = graph as ICompilableGraph;
// ---- 1. validate --------------------------------------------------
if ( !Validate( graph, diagnostics, out var output ) )
{
return CompileResult.Failed( diagnostics.All, request );
}
request.Cancellation.ThrowIfCancellationRequested();
// ---- 2. resolve ---------------------------------------------------
var module = new IrModule();
ConfigureMetadata( module, compilable, output, request );
ResolveParameters( module, compilable, diagnostics );
ResolveKeywords( module, compilable, diagnostics );
request.Cancellation.ThrowIfCancellationRequested();
// ---- 3. types -----------------------------------------------------
var solution = TypeSolver.Solve( graph, diagnostics );
request.Cancellation.ThrowIfCancellationRequested();
// ---- 4. stages ----------------------------------------------------
var roots = CollectRoots( graph, output ).ToArray();
if ( roots.Length == 0 )
{
diagnostics.Warn( DiagnosticCode.NoOutput,
$"'{NodeEmitter.Describe( output )}' has nothing connected, so this graph produces no output",
GraphRef.ForNode( output.Id ) );
}
var planner = new StagePlanner( graph, roots, diagnostics )
{
AllowVaryingPromotion = module.Meta.Domain is ShaderDomain.Surface or ShaderDomain.Subgraph
};
var plan = planner.Plan();
var varyings = new VaryingAllocator();
analysis.Stop();
request.Cancellation.ThrowIfCancellationRequested();
// ---- 5. emit ------------------------------------------------------
var emit = Stopwatch.StartNew();
var backend = backends.FirstOrDefault( x => request.WantsTarget( x.Id ) ) ?? backends.FirstOrDefault();
var emitter = new NodeEmitter( graph, module, request, diagnostics, backend, plan, varyings );
EmitRoots( emitter, module, roots, output, request );
varyings.CopyTo( module );
request.Cancellation.ThrowIfCancellationRequested();
// ---- 6. optimise --------------------------------------------------
var optimizerOptions = IrOptimizerOptions.Default with
{
KeepComments = request.EmitComments || request.DebugSymbols
};
var optimized = IrOptimizer.Optimize( module, optimizerOptions, diagnostics );
emit.Stop();
request.Cancellation.ThrowIfCancellationRequested();
// ---- 7. backends --------------------------------------------------
var backendTime = Stopwatch.StartNew();
var artifacts = new Dictionary<string, BackendEmitResult>( StringComparer.Ordinal );
foreach ( var target in backends )
{
if ( target is null ) continue;
if ( !request.WantsTarget( target.Id ) ) continue;
var options = new BackendEmitOptions( request.Mode, request.Dialect, request.DebugSymbols,
request.EmitComments, string.Empty )
{
OutputName = request.OutputName
};
var produced = PrismLog.Guard( $"Backend '{target.Id}'",
() => target.Emit( module, options, diagnostics ), null );
if ( produced is null )
{
diagnostics.Error( DiagnosticCode.BackendUnsupported,
$"The {target.DisplayName} backend produced nothing" );
produced = BackendEmitResult.Empty( target.Id, target.FileExtension );
}
artifacts[target.Id] = produced;
}
foreach ( var wanted in request.Targets ?? Array.Empty<string>() )
{
if ( artifacts.ContainsKey( wanted ) ) continue;
diagnostics.Warn( DiagnosticCode.BackendUnsupported,
$"No backend is registered for target '{wanted}'" );
}
backendTime.Stop();
total.Stop();
var stats = new CompileStats
{
NodeCount = emitter.NodesEmitted,
StatementCount = emitter.StatementCount,
TempCount = emitter.TempCount,
OptimizedAway = optimized.Total + emitter.CseHits,
GlobalCount = module.Globals.Count,
VaryingCount = module.Varyings.Count,
HelperCount = module.Helpers.Count,
AnalysisMs = analysis.Elapsed.TotalMilliseconds,
EmitMs = emit.Elapsed.TotalMilliseconds,
BackendMs = backendTime.Elapsed.TotalMilliseconds,
TotalMs = total.Elapsed.TotalMilliseconds
};
var everything = diagnostics.All;
var ok = !everything.Any( x => x.Severity == DiagnosticSeverity.Error );
// The solver's real output is already on the ports themselves — the emitter read it through
// Port.EffectiveType — so the solution object exists for callers that want the per-edge
// conversions to draw on the wires.
_ = solution;
return new CompileResult( ok, artifacts, everything, emitter.PreviewAttributes, stats )
{
Module = module,
Request = request,
PreviewTextures = emitter.PreviewTextures
};
}
// ---- 1. validation ----------------------------------------------------
static bool Validate( IPrismGraph graph, DiagnosticSink diagnostics, out PrismNode output )
{
output = null;
if ( graph is null )
{
diagnostics.Error( DiagnosticCode.NoOutput, "There is no graph to compile" );
return false;
}
var nodes = graph.Nodes ?? Array.Empty<PrismNode>();
if ( nodes.Count == 0 )
{
diagnostics.Error( DiagnosticCode.NoOutput, "This graph has no nodes" );
return false;
}
var seen = new HashSet<NodeId>();
foreach ( var node in nodes )
{
if ( node is null ) continue;
if ( !seen.Add( node.Id ) )
{
diagnostics.Error( DiagnosticCode.DuplicateNodeId,
$"Two nodes share the id '{node.Id}'", GraphRef.ForNode( node.Id ) );
}
}
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null ) continue;
if ( !edge.IsValid )
{
diagnostics.Warn( DiagnosticCode.DanglingEdge, "A connection is missing one of its ends",
GraphRef.ForEdge( edge.Id ) );
continue;
}
var from = graph.FindNode( edge.FromNode );
var to = graph.FindNode( edge.ToNode );
if ( from is null || to is null )
{
diagnostics.Warn( DiagnosticCode.DanglingEdge,
$"Connection {edge} references a node that is not in this document",
GraphRef.ForEdge( edge.Id ) );
continue;
}
if ( from.FindOutput( edge.FromPort ) is null )
{
diagnostics.Warn( DiagnosticCode.DanglingEdge,
$"'{NodeEmitter.Describe( from )}' has no output called '{edge.FromPort}'",
GraphRef.ForEdge( edge.Id ) );
}
if ( to.FindInput( edge.ToPort ) is null )
{
diagnostics.Warn( DiagnosticCode.DanglingEdge,
$"'{NodeEmitter.Describe( to )}' has no input called '{edge.ToPort}'",
GraphRef.ForEdge( edge.Id ) );
}
}
foreach ( var node in nodes )
{
if ( node is null ) continue;
var scoped = diagnostics.Scoped( GraphRef.ForNode( node.Id ) );
PrismLog.Try( $"Validating '{NodeEmitter.Describe( node )}'",
() => node.OnValidate( new ValidationContext( node, graph, scoped ) ),
scoped, DiagnosticCode.NodeEmitFailed, GraphRef.ForNode( node.Id ) );
}
output = FindOutputNode( graph );
if ( output is null )
{
diagnostics.Error( DiagnosticCode.NoOutput,
"This graph has no output node, so there is nothing to generate" );
return false;
}
return true;
}
/// <summary>
/// Find the terminal node. A graph that declares one wins; failing that, a node that implements
/// <see cref="IPrismOutputNode"/>; failing that, the structural definition of an output — a node
/// with inputs, no outputs and nothing downstream of it.
/// </summary>
public static PrismNode FindOutputNode( IPrismGraph graph )
{
if ( graph is null ) return null;
if ( graph is ICompilableGraph compilable && compilable.OutputNode is not null ) return compilable.OutputNode;
var nodes = graph.Nodes ?? Array.Empty<PrismNode>();
var declared = nodes
.Where( x => x is IPrismOutputNode )
.OrderByDescending( x => ConnectedInputs( graph, x ) )
.ThenBy( x => x.Id )
.FirstOrDefault();
if ( declared is not null ) return declared;
var terminal = nodes
.Where( x => x is not null && x.Outputs.Count == 0 && x.Inputs.Count > 0 )
.OrderByDescending( x => ConnectedInputs( graph, x ) )
.ThenBy( x => x.Id )
.FirstOrDefault();
if ( terminal is not null ) return terminal;
return nodes
.Where( x => x is not null && x.Inputs.Count > 0 && !HasOutgoing( graph, x ) )
.OrderByDescending( x => ConnectedInputs( graph, x ) )
.ThenBy( x => x.Id )
.FirstOrDefault();
}
static int ConnectedInputs( IPrismGraph graph, PrismNode node )
{
if ( node is null ) return 0;
var count = 0;
foreach ( var input in node.Inputs )
{
if ( graph.TryGetIncomingEdge( node.Id, input.Id, out _ ) ) count++;
}
return count;
}
static bool HasOutgoing( IPrismGraph graph, PrismNode node )
{
foreach ( var output in node.Outputs )
{
if ( graph.GetOutgoingEdges( node.Id, output.Id ).Any() ) return true;
}
return false;
}
// ---- 2. resolution ----------------------------------------------------
static void ConfigureMetadata( IrModule module, ICompilableGraph graph, PrismNode output, CompileRequest request )
{
var meta = module.Meta;
meta.Name = string.IsNullOrWhiteSpace( request.OutputName ) ? "prism_shader" : request.OutputName;
meta.DebugSymbols = request.DebugSymbols;
if ( output is IPrismOutputNode declared ) meta.Domain = declared.Domain;
if ( graph is null ) return;
meta.Domain = graph.Domain;
meta.ShadingModel = graph.ShadingModel;
meta.BlendMode = graph.BlendMode;
meta.CullMode = graph.CullMode;
meta.UsesUv2 = graph.UsesUv2;
meta.RenderBackfaces = graph.RenderBackfaces;
meta.Description = graph.Description;
if ( !string.IsNullOrWhiteSpace( graph.Title ) ) meta.Description ??= graph.Title;
foreach ( var mode in graph.Modes ?? Array.Empty<string>() )
{
if ( string.IsNullOrWhiteSpace( mode ) ) continue;
if ( meta.Modes.Contains( mode ) ) continue;
meta.Modes.Add( mode );
}
}
static void ResolveParameters( IrModule module, ICompilableGraph graph, DiagnosticSink diagnostics )
{
if ( graph?.Parameters is null ) return;
foreach ( var parameter in graph.Parameters )
{
if ( parameter is null ) continue;
var name = ParameterSymbol( parameter );
if ( string.IsNullOrEmpty( name ) ) continue;
var declaration = new GlobalDecl( name, parameter.Type, KindFor( parameter.Type ) )
{
Parameter = parameter.Id,
AttributeName = parameter.AttributeName,
Default = parameter.Type.IsNumeric ? parameter.DefaultValue : null,
DefaultAsset = parameter.DefaultAsset,
Srgb = parameter.Srgb,
Ui = parameter.Ui
};
var existing = module.FindGlobal( name );
if ( existing is null )
{
module.Globals.Add( declaration );
continue;
}
if ( !existing.ConflictsWith( declaration ) ) continue;
diagnostics.Error( DiagnosticCode.GlobalCollision,
$"Two parameters both resolve to the symbol '{name}'", null,
$"{existing} vs {declaration}" );
}
}
static void ResolveKeywords( IrModule module, ICompilableGraph graph, DiagnosticSink diagnostics )
{
if ( graph?.Keywords is null ) return;
foreach ( var keyword in graph.Keywords )
{
if ( keyword is null || string.IsNullOrWhiteSpace( keyword.Name ) ) continue;
if ( module.Meta.Combos.Any( x => x.Name == keyword.Name ) )
{
diagnostics.Warn( DiagnosticCode.GlobalCollision,
$"Two keywords are both called '{keyword.Name}'; the second one was skipped" );
continue;
}
var values = keyword.Values is { Count: > 0 } ? keyword.Values : new List<string> { "0", "1" };
module.Meta.Combos.Add( new ComboDecl( keyword.Name, keyword.Kind, values,
Math.Clamp( keyword.Default, 0, values.Count - 1 ), keyword.Group ) );
module.Meta.Capabilities.Add( Capability.Combos );
}
}
static GlobalKind KindFor( ShaderType type )
{
if ( type.IsSampler ) return GlobalKind.Sampler;
if ( type.IsTexture ) return type.IsWritable ? GlobalKind.RwTexture : GlobalKind.Texture;
if ( type.IsBuffer ) return type.IsWritable ? GlobalKind.RwBuffer : GlobalKind.Buffer;
return GlobalKind.Uniform;
}
/// <summary>
/// The symbol name a blackboard parameter is emitted under, in the engine's hungarian convention:
/// <c>g_flRoughness</c>, <c>g_vTint</c>, <c>g_tBaseColor</c>. Nodes that reference a parameter must
/// derive the same name, so this is the one definition of it — <c>Parameter.UniformName</c> calls
/// straight through to here.
/// <para>
/// The name comes from <see cref="IGraphParameter.Name"/>, never from
/// <see cref="IGraphParameter.AttributeName"/>. Those are different things: the symbol is what the
/// shader declares, the attribute name is the separate runtime channel the declaration is annotated
/// with (<c>Attribute( "Roughness" );</c>), and conflating them lets one parameter be renamed for the
/// material UI and silently take a different uniform with it.
/// </para>
/// </summary>
public static string ParameterSymbol( IGraphParameter parameter )
{
if ( parameter is null ) return null;
var prefix = SymbolPrefix( parameter.Type );
if ( !string.IsNullOrWhiteSpace( parameter.Name ) ) return prefix + Identifier( parameter.Name );
return prefix + Identifier( parameter.Id.ToString() );
}
/// <summary>
/// The hungarian prefix the engine's shaders use for a declaration of a given type. One table, shared
/// by the compiler, the model layer and both backends.
/// </summary>
public static string SymbolPrefix( ShaderType type )
{
if ( type.IsTexture ) return "g_t";
if ( type.IsSampler ) return "g_s";
if ( type.IsBuffer ) return "g_b";
if ( type.IsMatrix ) return "g_mat"; // g_matTransform, not g_mTransform
if ( type.IsBoolean ) return "g_b";
if ( type.IsIntegral ) return "g_n";
if ( type.IsVector ) return "g_v";
return "g_fl";
}
static string Identifier( string text )
{
if ( string.IsNullOrWhiteSpace( text ) ) return "param";
var sb = new StringBuilder( text.Length );
var upper = true;
foreach ( var c in text )
{
if ( char.IsLetterOrDigit( c ) )
{
sb.Append( upper ? char.ToUpperInvariant( c ) : c );
upper = false;
}
else
{
upper = true;
}
}
if ( sb.Length == 0 ) return "param";
if ( char.IsDigit( sb[0] ) ) sb.Insert( 0, '_' );
return sb.ToString();
}
// ---- 4. roots ---------------------------------------------------------
/// <summary>
/// The values the shader has to produce. A terminal node that implements
/// <see cref="IPrismOutputNode"/> declares them; otherwise every input that has something to read
/// becomes a pixel-stage root, except the well-known vertex ones.
/// </summary>
public static IEnumerable<StageRoot> CollectRoots( IPrismGraph graph, PrismNode output )
{
if ( output is null ) yield break;
if ( output is IPrismOutputNode declared )
{
var roots = PrismLog.Guard( "Collecting output roots", () => declared.Roots()?.ToArray(), null );
if ( roots is not null )
{
foreach ( var root in roots )
{
if ( root.Stage == ShaderStage.None ) continue;
yield return root.Node.IsValid ? root : root with { Node = output.Id };
}
yield break;
}
}
foreach ( var input in output.Inputs )
{
if ( ( input.Flags & PortFlags.Hidden ) != 0 ) continue;
if ( !graph.TryGetIncomingEdge( output.Id, input.Id, out _ ) ) continue;
var stage = IsVertexTarget( input ) ? ShaderStage.Vertex : ShaderStage.Pixel;
yield return new StageRoot( stage, output.Id, input.Id, input.Id.Value );
}
}
static bool IsVertexTarget( InputPort port )
{
if ( string.Equals( port.Id.Value, PositionOffsetTarget, StringComparison.OrdinalIgnoreCase ) ) return true;
return string.Equals( port.Group, "Vertex", StringComparison.OrdinalIgnoreCase );
}
// ---- 5. emission ------------------------------------------------------
static void EmitRoots( NodeEmitter emitter, IrModule module, IReadOnlyList<StageRoot> roots, PrismNode output,
CompileRequest request )
{
var stages = StageMask.None;
foreach ( var root in roots ) stages |= root.Stage.ToMask();
// A rasterised shader always has both halves, whether or not the graph rooted anything in them:
// the vertex stage still has to run so interpolators can be written, and the pixel stage still
// has to exist so the engine has something to bind.
if ( module.Meta.Domain is ShaderDomain.Surface or ShaderDomain.PostProcess )
{
stages |= StageMask.VertexPixel;
}
var results = new Dictionary<ShaderStage, List<(StageRoot Root, IrValue Value)>>();
foreach ( var stage in stages.Stages() )
{
var produced = new List<(StageRoot, IrValue)>();
foreach ( var root in roots )
{
if ( root.Stage != stage ) continue;
var node = emitter.Graph?.FindNode( root.Node ) ?? output;
var port = node?.FindInput( root.Port );
var value = port is null
? IrValue.Invalid
: emitter.DemandInput( node, port, stage, new NodeEmitContext( emitter, node, stage, emitter.Builder( stage ) ) );
if ( !value.IsValid ) continue;
produced.Add( (root, value) );
}
results[stage] = produced;
}
// The vertex stage may have grown statements while the pixel stage was being emitted, because a
// varying pulls its source back across the boundary. Build the functions only once everything
// has settled.
foreach ( var stage in stages.Stages() )
{
var builder = emitter.Builder( stage );
WriteResults( builder, module, stage, results.TryGetValue( stage, out var list ) ? list : null, output,
emitter?.Diagnostics );
var function = CreateEntryPoint( stage, module );
function.Body.AddRange( builder.Root.Statements );
// An empty geometry or compute entry point is dead weight; an empty graphics one is not.
if ( function.Body.IsEmpty && stage is ShaderStage.Geometry or ShaderStage.Compute ) continue;
module.Functions.Add( function );
}
module.Meta.Stages = stages;
_ = request;
}
/// <summary>
/// Write each root's value into the place the backend expects to find it.
/// <para>
/// Vertex-stage results and interpolator writes both target the pixel-input struct instance, which
/// is what the engine's own vertex shader shape does: <c>PixelInput i = ProcessVertex( v ); …;
/// return FinalizeVertex( i );</c>. Pixel-stage results target the material struct a lit shader
/// hands to its shading model, except when the graph produces the final colour itself, where the
/// single result is returned directly.
/// </para>
/// </summary>
static void WriteResults( IrBuilder builder, IrModule module, ShaderStage stage,
IReadOnlyList<(StageRoot Root, IrValue Value)> results, PrismNode output, DiagnosticSink diagnostics )
{
if ( results is null || results.Count == 0 ) return;
var origin = output?.Id ?? NodeId.None;
if ( stage == ShaderStage.Vertex )
{
var target = builder.Var( ShaderType.Struct( PixelInputStruct ), PixelInputVariable );
foreach ( var (root, value) in results )
{
// A position offset is a displacement, not a field: the engine's convention is to add it
// to the world position already computed by ProcessVertex, and the backend recomputes clip
// space afterwards because it sees the write to vPositionWs.
if ( string.Equals( root.Target, PositionOffsetTarget, StringComparison.OrdinalIgnoreCase ) )
{
var position = builder.Member( ShaderType.Float3, target, WorldPositionField );
var offset = Fit( builder, value, ShaderType.Float3 );
builder.Assign( origin, position,
builder.Binary( ShaderType.Float3, BinaryOp.Add, position, offset ) );
continue;
}
builder.Assign( origin, builder.Member( value.Type, target, root.Target ), value );
}
return;
}
var direct = module.Meta.ShadingModel == ShadingModel.Custom ||
module.Meta.Domain is ShaderDomain.PostProcess or ShaderDomain.Compute;
if ( direct && stage == ShaderStage.Pixel )
{
var composed = ComposeDirectTarget( builder, results );
if ( composed.IsValid )
{
builder.Return( origin, composed );
return;
}
// Nothing composable came back — every root was invalid, or a custom shading model rooted a
// target that is not a colour. Falling through to the material path from here writes
// `m.<Target>` against the engine's Material struct, which for a post-process or compute
// shader does not exist at all and for a custom target has no such field: a raw DXC error on
// a generated line, which is the one failure mode the source map exists to prevent. Return
// opaque black and say so instead.
diagnostics?.Error( DiagnosticCode.NoOutput,
"This graph produces its own final colour, but nothing usable reached the output",
results.Count > 0 ? GraphRef.ForNode( results[0].Root.Node ) : null,
"A post-process, compute or custom-shading-model graph returns a value directly rather " +
"than filling in a Material. Connect something to the output node's colour input." );
builder.Return( origin, builder.Const( ShaderType.Float4, new ConstValue( 0, 0, 0, 1 ) ) );
return;
}
var material = builder.Var( ShaderType.Struct( MaterialStruct ), MaterialVariable );
foreach ( var (root, value) in results )
{
// The material's fields have fixed types. An output node is free to declare a polymorphic port
// and let the solver pick something wider, so fit the value to the field here rather than
// handing the backend an assignment no shader compiler will accept.
var fitted = SboxMaterialBinding.TryGetField( root.Target, out var field )
? Fit( builder, value, field.Type )
: value;
builder.Assign( origin, builder.Member( fitted.Type, material, root.Target ), fitted );
}
}
/// <summary>
/// Compose the pixel entry point's return value for a graph that produces its final colour itself —
/// a post-process graph, or a surface graph with a custom shading model.
/// <para>
/// The entry point is declared <c>float4 MainPs( … ) : SV_Target0</c>, so whatever the graph rooted
/// has to arrive as a <c>float4</c>. A post-process output declares its colour as a <c>float3</c> and
/// its coverage as a separate <c>float</c>, and those are exactly the two roots that have to be
/// recombined here: returning the colour on its own emits <c>return float3( … );</c> from a
/// <c>float4</c> function, which is a hard compile error, and letting the pair fall through to the
/// material-struct path is worse still, because a post-process shader has no material.
/// </para>
/// </summary>
static IrValue ComposeDirectTarget( IrBuilder builder, IReadOnlyList<(StageRoot Root, IrValue Value)> results )
{
var colour = IrValue.Invalid;
var alpha = IrValue.Invalid;
foreach ( var (root, value) in results )
{
if ( !value.IsValid ) continue;
if ( IsAlphaTarget( root.Target ) )
{
if ( !alpha.IsValid ) alpha = value;
}
else if ( !colour.IsValid )
{
colour = value;
}
}
// Coverage on its own is not a colour. Fitting it splats to grey, which is at least visible and
// obviously wrong, rather than failing to compile.
if ( !colour.IsValid ) return alpha.IsValid ? Fit( builder, alpha, ShaderType.Float4 ) : IrValue.Invalid;
// A graph that produced a float4 and said nothing about coverage already carries its own alpha.
if ( !alpha.IsValid ) return Fit( builder, colour, ShaderType.Float4 );
return builder.Construct( ShaderType.Float4,
Fit( builder, colour, ShaderType.Float3 ), Fit( builder, alpha, ShaderType.Float ) );
}
/// <summary>Whether a root's target names the coverage channel rather than a colour.</summary>
static bool IsAlphaTarget( string target ) =>
string.Equals( target, "Opacity", StringComparison.OrdinalIgnoreCase ) ||
string.Equals( target, "Alpha", StringComparison.OrdinalIgnoreCase );
/// <summary>
/// Convert a value to the type a fixed destination expects. Only scalars and vectors are adjusted;
/// anything else is passed through, because a mismatch there is a real error the solver already
/// reported and silently reshaping it would only hide it.
/// </summary>
static IrValue Fit( IrBuilder builder, IrValue value, ShaderType target )
{
if ( !value.IsValid || target.IsVoid ) return value;
if ( value.Type == target ) return value;
if ( !value.Type.IsScalarOrVector || !target.IsScalarOrVector ) return value;
// Through the shared lowering, so a conversion emitted here is bit-for-bit the same IR that
// NodeEmitContext.Coerce emits for the same pair of types and hash-conses against it. This used
// to lower a narrowing as one CastKind.Truncate, which the HLSL backend renders as a mask only
// when the scalars already match and otherwise as `( float3 )v` — a different expression for the
// same conversion, one line apart in the same function.
var converted = IrConversions.Emit( builder, value, target );
// The solver already reported anything genuinely unconvertible; passing the value through keeps
// that one diagnostic rather than adding a second, less specific one here.
return converted.IsValid ? converted : value;
}
static IrFunction CreateEntryPoint( ShaderStage stage, IrModule module )
{
var name = stage.EntryPoint() ?? "Main";
if ( stage == ShaderStage.Vertex )
{
return new IrFunction( name, ShaderType.Void )
{
Stage = stage,
IsEntryPoint = true,
ReturnStruct = PixelInputStruct,
Pure = false
};
}
if ( stage == ShaderStage.Pixel )
{
return new IrFunction( name, ShaderType.Float4 )
{
Stage = stage,
IsEntryPoint = true,
ReturnSemantic = "SV_Target0",
Pure = false
};
}
_ = module;
return new IrFunction( name, ShaderType.Void )
{
Stage = stage,
IsEntryPoint = true,
Pure = false
};
}
// ---- backends ---------------------------------------------------------
/// <summary>
/// Every backend in this assembly with a public parameterless constructor, ordered by id so a
/// compile is reproducible. Discovered by reflection, so a backend written by another package
/// registers itself simply by existing.
/// </summary>
public static IReadOnlyList<IShaderBackend> DefaultBackends()
{
if ( s_backends is not null ) return s_backends;
var found = new List<IShaderBackend>();
PrismLog.Guard( "Discovering shader backends", () =>
{
foreach ( var type in typeof( GraphCompiler ).Assembly.GetTypes() )
{
if ( type.IsAbstract || type.IsInterface || type.IsGenericTypeDefinition ) continue;
if ( !typeof( IShaderBackend ).IsAssignableFrom( type ) ) continue;
if ( type.GetConstructor( Type.EmptyTypes ) is null ) continue;
var instance = PrismLog.Guard( $"Creating backend {type.Name}",
() => Activator.CreateInstance( type ) as IShaderBackend, null );
if ( instance is not null ) found.Add( instance );
}
} );
s_backends = found.OrderBy( x => x.Id, StringComparer.Ordinal ).ToArray();
return s_backends;
}
/// <summary>Drop the backend cache. Must run on hotload — the instances hold the outgoing assembly.</summary>
public static void FlushBackends() => s_backends = null;
}