Editor/Prism/Nodes/OutputNodes.cs

Editor Prism shader output nodes. Defines abstract PrismOutputNode that manages which input ports become rooted/assigned, compares authored literals against engine stock defaults, and three concrete terminal nodes: SurfaceOutputNode, UnlitOutputNode and PostProcessOutputNode. Also exposes SurfaceOutputs helpers that reference material field metadata.

using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Core;
using Editor.Prism.Model;

namespace Editor.Prism.Nodes;

// ---------------------------------------------------------------------------------------------------
// Terminal nodes.
//
// Every one of these implements IPrismOutputNode, so the compiler never has to guess which input
// belongs to which shader stage. Their input port ids are exactly the field names in
// SboxMaterialBinding.Fields — that is the contract between this file and the backend, and renaming a
// port here silently stops writing a material field.
//
// A port is only rooted when it is connected, or when its literal differs from the value the engine's
// own prologue already wrote. That is what keeps the generated shader honest: the stock defaults stay
// visible in the output instead of being buried under nine redundant assignments.
// ---------------------------------------------------------------------------------------------------

/// <summary>
/// Shared behaviour for a terminal node: root collection, the stock-default comparison and the
/// vertex/pixel split.
/// </summary>
public abstract class PrismOutputNode : PrismNode, IPrismOutputNode
{
	/// <inheritdoc/>
	public abstract ShaderDomain Domain { get; }

	/// <summary>
	/// The value the engine's prologue already assigned to a field, so an untouched literal does not
	/// generate an assignment. Return null when the field has no stock value.
	/// </summary>
	protected virtual object StockValue( PortId port ) => null;

	/// <inheritdoc/>
	public IEnumerable<StageRoot> Roots()
	{
		foreach ( var input in Inputs )
		{
			if ( input is null ) continue;
			if ( ( input.Flags & PortFlags.Hidden ) != 0 ) continue;
			if ( !ShouldWrite( input ) ) continue;

			yield return new StageRoot( StageFor( input ), Id, input.Id, TargetFor( input ) );
		}
	}

	/// <summary>Which stage produces this input's value.</summary>
	protected virtual ShaderStage StageFor( InputPort port )
	{
		if ( port is null ) return ShaderStage.Pixel;

		if ( string.Equals( port.Id.Value, GraphCompiler.PositionOffsetTarget, StringComparison.Ordinal ) )
		{
			return ShaderStage.Vertex;
		}

		return string.Equals( port.Group, "Vertex", StringComparison.OrdinalIgnoreCase )
			? ShaderStage.Vertex
			: ShaderStage.Pixel;
	}

	/// <summary>The name the backend writes this input into. Defaults to the port id.</summary>
	protected virtual string TargetFor( InputPort port ) => port?.Id.Value;

	/// <summary>
	/// True when this input has to produce an assignment: it is wired to something, or the user typed a
	/// literal that is not what the engine already wrote.
	/// </summary>
	protected bool ShouldWrite( InputPort port )
	{
		if ( port is null ) return false;
		if ( Graph is not null && Graph.TryGetIncomingEdge( Id, port.Id, out _ ) ) return true;

		var stock = StockValue( port.Id );
		if ( stock is null ) return false;

		var authored = Authored( port );
		if ( authored is null ) return false;

		return !SameValue( authored, stock );
	}

	/// <summary>
	/// The literal this port will actually be emitted with.
	/// <para>
	/// The port's own slot wins over the bound property, because that is the order the emitter reads
	/// them in. Reading them the other way round would let this node decide to write a field and then
	/// write a different value into it.
	/// </para>
	/// </summary>
	object Authored( InputPort port )
	{
		if ( port.InlineValue is not null ) return port.InlineValue;

		var bound = port.Def?.InlineValueProperty;

		return string.IsNullOrEmpty( bound ) ? null : Serialization.NodeProperties.Get( this, bound );
	}

	/// <summary>
	/// Compare an authored literal against a stock default. Both arrive boxed and may disagree about
	/// their exact shape — a <c>Color</c> against a <c>Vector3</c>, say — so they are compared through
	/// the same four-component form the compiler lowers them to.
	/// </summary>
	static bool SameValue( object authored, object stock )
	{
		if ( !NodeEmitter.TryReadConstant( authored, ShaderType.Float4, out _, out var a ) ) return false;
		if ( !NodeEmitter.TryReadConstant( stock, ShaderType.Float4, out _, out var b ) ) return false;

		return Near( a.X, b.X ) && Near( a.Y, b.Y ) && Near( a.Z, b.Z ) && Near( a.W, b.W );
	}

	static bool Near( double a, double b ) => Math.Abs( a - b ) <= 1e-6;
}

/// <summary>
/// The lit surface terminal — the node a new graph starts with.
/// <para>
/// Its inputs are the writable fields of the engine's <c>Material</c> struct, in the order the
/// generated shader assigns them. Everything downstream of it lands in
/// <c>ShadingModelStandard::Shade</c>, which is what buys clustered lighting, shadows, indirect
/// lighting, SSAO, SSR, decals, fog, alpha-to-coverage and the tools visualisations for free.
/// </para>
/// </summary>
[NodeInfo( Id = SurfaceOutputNode.TypeId, Title = "Material", Category = "Output",
	Icon = "tonality", Keywords = new[] { "output", "material", "result", "master", "pbr", "lit", "surface" },
	Description = "The lit surface output. Everything wired here goes through the engine's standard " +
		"shading model." )]
[NodeVersion( 1 )]
public sealed class SurfaceOutputNode : PrismOutputNode
{
	/// <summary>The stable type id. The default terminal for a new shader graph.</summary>
	public const string TypeId = "prism.output.surface";

	/// <summary>Base colour, linear.</summary>
	[In( "float3", Name = "Albedo", Order = 10 )] public PortRef Albedo { get; set; }

	/// <summary>Tangent-space normal. Converted to world space before shading.</summary>
	[In( "float3", Name = "Normal", Order = 20 )] public PortRef Normal { get; set; }

	/// <summary>Microfacet roughness.</summary>
	[In( "float", Name = "Roughness", Order = 30 )] public PortRef Roughness { get; set; }

	/// <summary>Dielectric to conductor blend.</summary>
	[In( "float", Name = "Metalness", Order = 40 )] public PortRef Metalness { get; set; }

	/// <summary>Baked occlusion multiplier.</summary>
	[In( "float", Name = "Ambient Occlusion", Order = 50 )] public PortRef AmbientOcclusion { get; set; }

	/// <summary>Coverage. Drives alpha test and translucency.</summary>
	[In( "float", Name = "Opacity", Order = 60 )] public PortRef Opacity { get; set; }

	/// <summary>Additive emissive colour.</summary>
	[In( "float3", Name = "Emission", Order = 70 )] public PortRef Emission { get; set; }

	/// <summary>Light transmitted through the surface.</summary>
	[In( "float3", Name = "Transmission", Order = 80 )] public PortRef Transmission { get; set; }

	/// <summary>Where the per-instance tint applies.</summary>
	[In( "float", Name = "Tint Mask", Order = 90 )] public PortRef TintMask { get; set; }

	/// <summary>World-space displacement applied in the vertex program, before clip space is derived.</summary>
	[In( "float3", Name = "Position Offset", Group = "Vertex", Order = 100 )]
	public PortRef PositionOffset { get; set; }

	/// <summary>Base colour used when <see cref="Albedo"/> is unconnected.</summary>
	[InlineValue( nameof( Albedo ) )] public Color DefaultAlbedo { get; set; } = Color.White;

	/// <summary>Normal used when <see cref="Normal"/> is unconnected. Tangent-space identity.</summary>
	[InlineValue( nameof( Normal ) )] public Vector3 DefaultNormal { get; set; } = new( 0f, 0f, 1f );

	/// <summary>Roughness used when <see cref="Roughness"/> is unconnected.</summary>
	[InlineValue( nameof( Roughness ) )] public float DefaultRoughness { get; set; } = 1f;

	/// <summary>Metalness used when <see cref="Metalness"/> is unconnected.</summary>
	[InlineValue( nameof( Metalness ) )] public float DefaultMetalness { get; set; }

	/// <summary>Occlusion used when <see cref="AmbientOcclusion"/> is unconnected.</summary>
	[InlineValue( nameof( AmbientOcclusion ) )] public float DefaultAmbientOcclusion { get; set; } = 1f;

	/// <summary>Coverage used when <see cref="Opacity"/> is unconnected.</summary>
	[InlineValue( nameof( Opacity ) )] public float DefaultOpacity { get; set; } = 1f;

	/// <summary>Emission used when <see cref="Emission"/> is unconnected.</summary>
	[InlineValue( nameof( Emission ) )] public Color DefaultEmission { get; set; } = Color.Black;

	/// <summary>Transmission used when <see cref="Transmission"/> is unconnected.</summary>
	[InlineValue( nameof( Transmission ) )] public Color DefaultTransmission { get; set; } = Color.Black;

	/// <summary>Tint mask used when <see cref="TintMask"/> is unconnected.</summary>
	[InlineValue( nameof( TintMask ) )] public float DefaultTintMask { get; set; } = 1f;

	/// <inheritdoc/>
	public override ShaderDomain Domain => ShaderDomain.Surface;

	/// <inheritdoc/>
	protected override object StockValue( PortId port ) => port.Value switch
	{
		nameof( Albedo ) => Color.White,
		nameof( Normal ) => new Vector3( 0f, 0f, 1f ),
		nameof( Roughness ) => 1f,
		nameof( Metalness ) => 0f,
		nameof( AmbientOcclusion ) => 1f,
		nameof( Opacity ) => 1f,
		nameof( Emission ) => Color.Black,
		nameof( Transmission ) => Color.Black,
		nameof( TintMask ) => 1f,
		_ => null
	};

	/// <inheritdoc/>
	public override void OnValidate( ValidationContext ctx )
	{
		if ( ctx?.Graph is not ICompilableGraph graph ) return;

		if ( graph.Domain != ShaderDomain.Surface )
		{
			ctx.Warn( $"This graph's domain is {graph.Domain}, but the Material output only means " +
				"something on a surface graph", null, DiagnosticCode.StageViolation );
		}

		if ( graph.ShadingModel == ShadingModel.Unlit )
		{
			ctx.Info( "This graph is unlit, so Normal, Roughness, Metalness, Ambient Occlusion and " +
				"Transmission are ignored — use the Unlit output instead", null );
		}
	}

	/// <inheritdoc/>
	public override void Emit( EmitContext ctx )
	{
		// Nothing. The compiler pulls each root through the emitter instead of having the terminal push
		// it, which is what lets one value feed both the vertex and the pixel program without being
		// emitted twice.
	}
}

/// <summary>
/// The unlit surface terminal. No lighting, no tools visualisation, no fog — the authored colour is
/// what reaches the frame buffer.
/// </summary>
[NodeInfo( Id = UnlitOutputNode.TypeId, Title = "Unlit", Category = "Output",
	Icon = "brightness_3", Keywords = new[] { "output", "unlit", "emissive", "flat", "result", "master" },
	Description = "The unlit surface output. Albedo plus Emission is returned directly, with no " +
		"shading model in between." )]
[NodeVersion( 1 )]
public sealed class UnlitOutputNode : PrismOutputNode
{
	/// <summary>The stable type id.</summary>
	public const string TypeId = "prism.output.unlit";

	/// <summary>The colour returned to the frame buffer.</summary>
	[In( "float3", Name = "Color", Order = 10 )] public PortRef Albedo { get; set; }

	/// <summary>Added on top of the colour.</summary>
	[In( "float3", Name = "Emission", Order = 20 )] public PortRef Emission { get; set; }

	/// <summary>Coverage.</summary>
	[In( "float", Name = "Opacity", Order = 30 )] public PortRef Opacity { get; set; }

	/// <summary>World-space displacement applied in the vertex program.</summary>
	[In( "float3", Name = "Position Offset", Group = "Vertex", Order = 40 )]
	public PortRef PositionOffset { get; set; }

	/// <summary>Colour used when <see cref="Albedo"/> is unconnected.</summary>
	[InlineValue( nameof( Albedo ) )] public Color DefaultAlbedo { get; set; } = Color.White;

	/// <summary>Emission used when <see cref="Emission"/> is unconnected.</summary>
	[InlineValue( nameof( Emission ) )] public Color DefaultEmission { get; set; } = Color.Black;

	/// <summary>Coverage used when <see cref="Opacity"/> is unconnected.</summary>
	[InlineValue( nameof( Opacity ) )] public float DefaultOpacity { get; set; } = 1f;

	/// <inheritdoc/>
	public override ShaderDomain Domain => ShaderDomain.Surface;

	/// <inheritdoc/>
	protected override object StockValue( PortId port ) => port.Value switch
	{
		nameof( Albedo ) => Color.White,
		nameof( Emission ) => Color.Black,
		nameof( Opacity ) => 1f,
		_ => null
	};

	/// <inheritdoc/>
	public override void OnValidate( ValidationContext ctx )
	{
		if ( ctx?.Graph is not ICompilableGraph graph ) return;
		if ( graph.ShadingModel != ShadingModel.Lit ) return;

		ctx.Warn( "This graph's shading model is Lit, so the generated shader will still run the " +
			"standard shading model over the unlit output; set the shading model to Unlit",
			null, DiagnosticCode.InvalidBlock );
	}

	/// <inheritdoc/>
	public override void Emit( EmitContext ctx )
	{
		// Pulled, not pushed. See SurfaceOutputNode.Emit.
	}
}

/// <summary>
/// The full-screen post-process terminal.
/// <para>
/// A post-process graph runs over a full-screen triangle with the previous frame bound as
/// <c>g_tColorBuffer</c>, so the usual surface inputs — normals, roughness, displacement — have no
/// meaning here and are not offered.
/// </para>
/// </summary>
[NodeInfo( Id = PostProcessOutputNode.TypeId, Title = "Post Process", Category = "Output",
	Icon = "filter", Keywords = new[] { "output", "post process", "screen", "effect", "result", "master" },
	Description = "The post-process output. The colour written here replaces the frame buffer." )]
[NodeVersion( 1 )]
public sealed class PostProcessOutputNode : PrismOutputNode
{
	/// <summary>The stable type id.</summary>
	public const string TypeId = "prism.output.postprocess";

	/// <summary>The colour written to the frame buffer.</summary>
	[In( "float3", Name = "Color", Order = 10 )] public PortRef Albedo { get; set; }

	/// <summary>How much of the new colour survives blending.</summary>
	[In( "float", Name = "Opacity", Order = 20 )] public PortRef Opacity { get; set; }

	/// <summary>Colour used when <see cref="Albedo"/> is unconnected.</summary>
	[InlineValue( nameof( Albedo ) )] public Color DefaultAlbedo { get; set; } = Color.Black;

	/// <summary>Coverage used when <see cref="Opacity"/> is unconnected.</summary>
	[InlineValue( nameof( Opacity ) )] public float DefaultOpacity { get; set; } = 1f;

	/// <inheritdoc/>
	public override ShaderDomain Domain => ShaderDomain.PostProcess;

	/// <inheritdoc/>
	protected override object StockValue( PortId port ) => port.Value switch
	{
		// The prologue writes white; a post-process graph that leaves its colour unwired almost
		// certainly means black, so both are treated as authored and always assigned.
		nameof( Albedo ) => Color.White,
		nameof( Opacity ) => 1f,
		_ => null
	};

	/// <inheritdoc/>
	public override void OnValidate( ValidationContext ctx )
	{
		if ( ctx?.Graph is not ICompilableGraph graph ) return;
		if ( graph.Domain == ShaderDomain.PostProcess ) return;

		ctx.Warn( $"This graph's domain is {graph.Domain}; a post-process output needs the " +
			"PostProcess domain to be rendered over the frame buffer", null, DiagnosticCode.StageViolation );
	}

	/// <inheritdoc/>
	public override void Emit( EmitContext ctx )
	{
		// Pulled, not pushed. See SurfaceOutputNode.Emit.
	}
}

/// <summary>
/// The names the surface terminal writes, exposed so other packages — the node library tree, the
/// preview's channel strip, the inspector — can enumerate them without re-deriving the list.
/// </summary>
public static class SurfaceOutputs
{
	/// <summary>Every writable material field, in the order the generated shader assigns them.</summary>
	public static IReadOnlyList<MaterialField> Fields => SboxMaterialBinding.Fields;

	/// <summary>The vertex-stage target, which is a displacement rather than a material field.</summary>
	public const string PositionOffset = GraphCompiler.PositionOffsetTarget;

	/// <summary>True when a port id names a writable material field.</summary>
	public static bool IsMaterialField( string name ) => SboxMaterialBinding.TryGetField( name, out _ );

	/// <summary>The description of a material field, for a tooltip. Empty when the name is unknown.</summary>
	public static string Describe( string name ) =>
		SboxMaterialBinding.TryGetField( name, out var field ) ? field.Description : string.Empty;
}