Editor/Prism/Model/GraphSettings.cs

GraphSettings is a model describing non-node metadata for a shader document: domain, shading model, blend/cull settings, declared render passes (Modes), backend targets, HLSL dialect, debug/strict flags and misc data. It provides helpers to query and mutate targets/modes, normalize the lists, clone/copy, and compute derived booleans/stage mask.

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

namespace Editor.Prism.Model;

/// <summary>
/// Everything about a document that is not nodes, edges or parameters: what the graph is for, how it
/// resolves to pixels, which render passes it participates in and which artifacts it emits on save.
/// <para>
/// Mutable by design — the Inspector edits it in place through <c>GraphMutations.UpdateSettings</c>,
/// which snapshots the document around the edit so the change is undoable.
/// </para>
/// </summary>
public sealed class GraphSettings
{
	/// <summary>Build settings with the shipped defaults: a lit, opaque, back-face-culled surface shader.</summary>
	public GraphSettings()
	{
		Modes = new List<string>( DefaultModes );
		Targets = new List<string> { PrismConstants.BackendHlsl };
	}

	/// <summary>What the graph is for.</summary>
	public ShaderDomain Domain { get; set; } = ShaderDomain.Surface;

	/// <summary>How a surface graph resolves to pixels.</summary>
	public ShadingModel ShadingModel { get; set; } = ShadingModel.Lit;

	/// <summary>Output blending.</summary>
	public SurfaceBlendMode BlendMode { get; set; } = SurfaceBlendMode.Opaque;

	/// <summary>Triangle culling.</summary>
	public CullMode CullMode { get; set; } = CullMode.Back;

	/// <summary>Declared render passes, e.g. <c>Forward</c>, <c>Depth</c>, <c>ToolsShadingComplexity</c>.</summary>
	public List<string> Modes { get; set; }

	/// <summary>Backend ids emitted on save. Always contains the HLSL backend for a shader graph.</summary>
	public List<string> Targets { get; set; }

	/// <summary>Which HLSL flavour the generated <c>.shader</c> uses.</summary>
	public HlslDialect HlslDialect { get; set; } = HlslDialect.SboxSlang;

	/// <summary>Whether the second UV channel is declared on the vertex input.</summary>
	public bool Uv2 { get; set; }

	/// <summary>Whether back faces are rendered regardless of <see cref="CullMode"/>.</summary>
	public bool RenderBackfaces { get; set; }

	/// <summary>Whether generated code gets descriptive temp names and per-node comments.</summary>
	public bool DebugSymbols { get; set; }

	/// <summary>
	/// Treat every lossy or padding conversion as an error rather than a warning. Teams that want the
	/// type system to be unforgiving turn this on; it never changes what is generated, only severity.
	/// </summary>
	public bool StrictTypes { get; set; }

	/// <summary>Forward-compatibility bag preserved verbatim through a round-trip.</summary>
	public JsonObject X { get; set; }

	/// <summary>True when the blend mode needs <c>S_TRANSLUCENT</c> defined before <c>common/pixel.hlsl</c>.</summary>
	public bool IsTranslucent =>
		BlendMode is SurfaceBlendMode.Translucent or SurfaceBlendMode.Additive or SurfaceBlendMode.Multiply;

	/// <summary>True when the blend mode needs <c>S_ALPHA_TEST</c> defined before <c>common/pixel.hlsl</c>.</summary>
	public bool IsAlphaTest => BlendMode == SurfaceBlendMode.Masked;

	/// <summary>True when the graph produces a full lighting evaluation rather than raw colour.</summary>
	public bool IsLit => Domain == ShaderDomain.Surface && ShadingModel == ShadingModel.Lit;

	/// <summary>The stages the generated shader declares.</summary>
	public StageMask Stages => Domain switch
	{
		ShaderDomain.Compute => StageMask.Compute,
		ShaderDomain.PostProcess => StageMask.VertexPixel,
		_ => StageMask.VertexPixel
	};

	/// <summary>True when the given backend id is in <see cref="Targets"/>.</summary>
	public bool WantsTarget( string backendId )
	{
		if ( string.IsNullOrEmpty( backendId ) ) return false;
		if ( Targets is null ) return false;

		foreach ( var target in Targets )
		{
			if ( string.Equals( target, backendId, StringComparison.OrdinalIgnoreCase ) ) return true;
		}

		return false;
	}

	/// <summary>Add or remove a backend id from <see cref="Targets"/>, keeping the list free of duplicates.</summary>
	public void SetTarget( string backendId, bool wanted )
	{
		if ( string.IsNullOrEmpty( backendId ) ) return;

		Targets ??= new List<string>();
		Targets.RemoveAll( x => string.Equals( x, backendId, StringComparison.OrdinalIgnoreCase ) );

		if ( wanted ) Targets.Add( backendId );
	}

	/// <summary>True when the given render pass is declared.</summary>
	public bool HasMode( string mode ) =>
		Modes is not null && Modes.Any( x => string.Equals( x, mode, StringComparison.OrdinalIgnoreCase ) );

	/// <summary>Add or remove a render pass, keeping the list free of duplicates.</summary>
	public void SetMode( string mode, bool wanted )
	{
		if ( string.IsNullOrEmpty( mode ) ) return;

		Modes ??= new List<string>();
		Modes.RemoveAll( x => string.Equals( x, mode, StringComparison.OrdinalIgnoreCase ) );

		if ( wanted ) Modes.Add( mode );
	}

	/// <summary>
	/// Drop anything that would produce an invalid shader: unknown render passes, unknown backend ids
	/// and an empty target list. Reports what it changed and never throws.
	/// </summary>
	public void Normalize( DiagnosticSink sink = null )
	{
		Modes ??= new List<string>();
		Targets ??= new List<string>();

		for ( int i = Modes.Count - 1; i >= 0; i-- )
		{
			var mode = Modes[i];

			if ( !string.IsNullOrWhiteSpace( mode ) && KnownModes.Contains( mode, StringComparer.OrdinalIgnoreCase ) )
			{
				continue;
			}

			sink?.Warn( DiagnosticCode.SectionReadFailed, $"Unknown render mode '{mode}' was dropped" );
			Modes.RemoveAt( i );
		}

		for ( int i = Targets.Count - 1; i >= 0; i-- )
		{
			var target = Targets[i];

			if ( !string.IsNullOrWhiteSpace( target ) &&
				s_targetAliases.TryGetValue( target, out var canonical ) )
			{
				Targets[i] = canonical;
				continue;
			}

			sink?.Warn( DiagnosticCode.SectionReadFailed, $"Unknown target '{target}' was dropped" );
			Targets.RemoveAt( i );
		}

		Modes = Dedupe( Modes );
		Targets = Dedupe( Targets );

		if ( Modes.Count == 0 ) Modes.AddRange( DefaultModes );
		if ( Targets.Count == 0 ) Targets.Add( PrismConstants.BackendHlsl );
	}

	/// <summary>Deep copy.</summary>
	public GraphSettings Clone() => new()
	{
		Domain = Domain,
		ShadingModel = ShadingModel,
		BlendMode = BlendMode,
		CullMode = CullMode,
		Modes = Modes is null ? new List<string>( DefaultModes ) : new List<string>( Modes ),
		Targets = Targets is null ? new List<string> { PrismConstants.BackendHlsl } : new List<string>( Targets ),
		HlslDialect = HlslDialect,
		Uv2 = Uv2,
		RenderBackfaces = RenderBackfaces,
		DebugSymbols = DebugSymbols,
		StrictTypes = StrictTypes,
		X = X?.DeepClone() as JsonObject
	};

	/// <summary>Copy every field from another instance without replacing the object identity.</summary>
	public void CopyFrom( GraphSettings other )
	{
		if ( other is null ) return;

		Domain = other.Domain;
		ShadingModel = other.ShadingModel;
		BlendMode = other.BlendMode;
		CullMode = other.CullMode;
		Modes = other.Modes is null ? new List<string>( DefaultModes ) : new List<string>( other.Modes );
		Targets = other.Targets is null
			? new List<string> { PrismConstants.BackendHlsl }
			: new List<string>( other.Targets );
		HlslDialect = other.HlslDialect;
		Uv2 = other.Uv2;
		RenderBackfaces = other.RenderBackfaces;
		DebugSymbols = other.DebugSymbols;
		StrictTypes = other.StrictTypes;
		X = other.X?.DeepClone() as JsonObject;
	}

	/// <inheritdoc/>
	public override string ToString() => $"{Domain}/{ShadingModel}/{BlendMode}";

	/// <summary>The render passes a generated shader may declare. Anything else fails the block parser.</summary>
	public static readonly IReadOnlyList<string> KnownModes = new[]
	{
		"Forward", "VrForward", "Default", "Depth", "ToolsShadingComplexity", "ToolsUtil"
	};

	/// <summary>The backend ids a document may target.</summary>
	public static readonly IReadOnlyList<string> KnownTargets = new[]
	{
		PrismConstants.BackendHlsl, PrismConstants.BackendSlang
	};

	/// <summary>
	/// Spellings accepted for a target on read, mapped to the canonical backend id. Documents written
	/// against the design notes use friendly names; the backends are keyed by id.
	/// </summary>
	static readonly Dictionary<string, string> s_targetAliases = new( StringComparer.OrdinalIgnoreCase )
	{
		[PrismConstants.BackendHlsl] = PrismConstants.BackendHlsl,
		["SboxShader"] = PrismConstants.BackendHlsl,
		["Shader"] = PrismConstants.BackendHlsl,
		["Hlsl"] = PrismConstants.BackendHlsl,
		[PrismConstants.BackendSlang] = PrismConstants.BackendSlang,
		["Slang"] = PrismConstants.BackendSlang
	};

	/// <summary>The passes a new surface graph declares.</summary>
	public static readonly IReadOnlyList<string> DefaultModes = new[]
	{
		"Forward", "Depth", "ToolsShadingComplexity"
	};

	/// <summary>The minimal pass set a preview compile declares — combos cost time we do not have.</summary>
	public static readonly IReadOnlyList<string> PreviewModes = new[] { "Forward" };

	static List<string> Dedupe( List<string> values )
	{
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var result = new List<string>( values.Count );

		foreach ( var value in values )
		{
			if ( !seen.Add( value ) ) continue;

			result.Add( value );
		}

		return result;
	}
}