Editor/Prism/Compiler/GraphCompiler.cs

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.

Reflection
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&amp;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&amp;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;
}