Editor/Prism/Compiler/Backends/SboxShaderWriter.cs

Writer for producing a complete s&box VFX .shader file from an IR module. It orchestrates block ordering, emits includes, defines, structs, vertex/pixel/compute blocks, combos, render state, and optional preview instrumentation by delegating expression/body emission to HlslEmitter and HlslSourceBuilder.

File Access
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;

namespace Editor.Prism.Compiler.Backends;

/// <summary>
/// Wraps the HLSL an <see cref="HlslEmitter"/> produces in a complete s&amp;box VFX
/// <c>.shader</c> file.
/// <para>
/// A <c>.shader</c> is not plain HLSL: it is a block language the engine's native front-end parses
/// before anything reaches a compiler. The block order, the placement of the blend defines relative
/// to <c>common/pixel.hlsl</c>, and the exact spelling of the annotation grammar all decide whether
/// the result renders correctly, renders wrongly, or fails to parse with no diagnostic at all.
/// </para>
/// </summary>
public sealed class SboxShaderWriter
{
	readonly HlslSourceBuilder _builder;
	readonly HlslEmitter _emitter;
	readonly IReadOnlyList<HelperFunction> _helpers;

	// Non-null only while writing an instrumented build. It doubles as the "is this the real pass"
	// flag, which is what keeps the throw-away numbering pass from instrumenting itself.
	IReadOnlyDictionary<NodeId, int> _stageIds;

	/// <summary>Prepare to write one module.</summary>
	public SboxShaderWriter( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )
	{
		Module = module;
		Options = options ?? BackendEmitOptions.Default;
		Diagnostics = diagnostics ?? new DiagnosticSink();

		_builder = new HlslSourceBuilder( Options.Indent, Options.NewLine );
		_emitter = new HlslEmitter( Module, Options, Diagnostics );
		_helpers = _emitter.OrderedHelpers();
	}

	/// <summary>The module being written.</summary>
	public IrModule Module { get; }

	/// <summary>Emission options.</summary>
	public BackendEmitOptions Options { get; }

	/// <summary>Where problems go.</summary>
	public DiagnosticSink Diagnostics { get; }

	ModuleMetadata Meta => Module.Meta;

	ShaderDomain Domain => Meta.Domain;

	bool IsSurface => Domain is ShaderDomain.Surface or ShaderDomain.PostProcess;

	/// <summary>Write the module as a complete <c>.shader</c> file, with its source map.</summary>
	public BackendEmitResult Write()
	{
		if ( Module is null )
		{
			Diagnostics.Error( DiagnosticCode.InvalidBlock, "There is no module to write." );
			return BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );
		}

		if ( Domain == ShaderDomain.Subgraph )
		{
			Diagnostics.Error( DiagnosticCode.SubgraphUnavailable,
				"A subgraph has no shader of its own.", null,
				"Subgraphs are inlined into the graph that instances them; only a shader or post-process graph produces a .shader file." );
			return BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );
		}

		if ( !VfxBlockValidator.Validate( Module, Diagnostics ) )
		{
			// The engine's block parser reports these only to the native log, with an empty program
			// list and no line numbers. Refusing to write is far kinder than letting that happen.
			return BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );
		}

		if ( PreviewInstrumentation.IsEnabled( Options.Mode ) && Domain != ShaderDomain.Compute )
		{
			// A node's stage id is its rank among the nodes appearing in the finished artifact's source
			// map, so it cannot be known until the file has been written once. Writing it twice is far
			// cheaper and far safer than predicting that order: the throw-away pass costs one more text
			// generation and its diagnostics are discarded, because the real pass reports exactly the
			// same set. Adding the instrumentation never changes the order — every line it writes is
			// attributed either to a node that already appeared above it, or to nothing at all.
			var probe = new SboxShaderWriter( Module, Options, new DiagnosticSink() );

			probe.WriteBlocks();

			_stageIds = PreviewInstrumentation.BuildStageMap( probe._builder.SourceMap );
		}

		WriteBlocks();

		var text = _builder.ToString();
		var map = _builder.SourceMap;

		map.File = $"{Options.OutputName}.{PrismConstants.ShaderExtension}";

		return new BackendEmitResult( text, PrismConstants.ShaderExtension, map, Array.Empty<GeneratedArtifact>() )
		{
			BackendId = PrismConstants.BackendHlsl,
			LineCount = _builder.LineCount
		};
	}

	/// <summary>Write every block of the file, in the order the engine's parser expects them.</summary>
	void WriteBlocks()
	{
		WriteHeaderBlock();
		WriteModesBlock();
		WriteFeaturesBlock();
		WriteCommonBlock();

		if ( Domain != ShaderDomain.Compute )
		{
			WriteVertexInputStruct();
			WritePixelInputStruct();
			WriteVertexBlock();
			WritePixelBlock();
		}
		else
		{
			WriteComputeBlock();
		}
	}

	/// <summary>Write a module straight to text, for callers that only want the string.</summary>
	public static string WriteText( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics ) =>
		new SboxShaderWriter( module, options, diagnostics ).Write().Text;

	// ---- HEADER -----------------------------------------------------------

	void WriteHeaderBlock()
	{
		_builder.Write( SboxShaderTemplates.BlockHeader );
		_builder.Open();

		var description = string.IsNullOrWhiteSpace( Meta.Description )
			? $"{Meta.Name} — generated by {PrismConstants.ProductName}"
			: Meta.Description;

		_builder.Write( $"Description = \"{SboxShaderTemplates.QuoteSafe( description )}\";" );
		_builder.Write( $"DevShader = {( Options.Mode == CompileMode.Final ? "false" : "true" )};" );
		_builder.Write( $"Version = {HeaderVersion()};" );

		if ( Options.DebugSymbols ) _builder.Write( "DebugInfo = true;" );

		_builder.Close();
		_builder.Blank();
	}

	int HeaderVersion()
	{
		var version = Meta.Version;

		if ( string.IsNullOrWhiteSpace( version ) ) return 1;
		if ( int.TryParse( version, out var whole ) && whole > 0 ) return whole;

		var dot = version.IndexOf( '.' );

		if ( dot > 0 && int.TryParse( version[..dot], out var major ) && major > 0 ) return major;

		return 1;
	}

	// ---- MODES ------------------------------------------------------------

	void WriteModesBlock()
	{
		_builder.Write( SboxShaderTemplates.BlockModes );
		_builder.Open();

		foreach ( var mode in DeclaredModes() )
		{
			var statement = SboxShaderTemplates.ModeStatement( mode );

			if ( !string.IsNullOrEmpty( statement ) ) _builder.Write( statement );
		}

		_builder.Close();
		_builder.Blank();
	}

	IReadOnlyList<string> DefaultModes() => SboxShaderTemplates.DefaultModesFor( Domain );

	/// <summary>
	/// The render passes this file actually declares.
	/// <para>
	/// The domain and the pass list are authored independently, so a graph switched to PostProcess after
	/// the fact still carries the surface passes. Declaring <c>Depth()</c> on a full-screen pass asks the
	/// engine to render a full-screen triangle into the depth buffer, and a post-process material invoked
	/// through the standard path needs <c>Default()</c> whether or not the document remembered it — so a
	/// non-surface domain starts from its own pass set and only then takes whatever the document adds.
	/// </para>
	/// </summary>
	IReadOnlyList<string> DeclaredModes()
	{
		var declared = Meta.Modes.Count > 0 ? Meta.Modes : DefaultModes();
		var modes = new List<string>( declared.Count + 2 );

		bool Has( string mode ) => modes.Any( x => string.Equals( x, mode, StringComparison.OrdinalIgnoreCase ) );

		if ( Domain != ShaderDomain.Surface ) modes.AddRange( DefaultModes() );

		foreach ( var mode in declared )
		{
			if ( string.IsNullOrWhiteSpace( mode ) || Has( mode ) ) continue;

			if ( !SboxShaderTemplates.IsModeLegalFor( Domain, mode ) )
			{
				Diagnostics.Info( DiagnosticCode.InvalidBlock,
					$"Render pass '{mode}' means nothing to a {Domain} graph and was not declared." );

				continue;
			}

			modes.Add( mode );
		}

		if ( modes.Count == 0 ) modes.AddRange( DefaultModes() );

		return modes;
	}

	// ---- FEATURES ---------------------------------------------------------

	void WriteFeaturesBlock()
	{
		if ( Domain == ShaderDomain.Compute ) return;

		_builder.Write( SboxShaderTemplates.BlockFeatures );
		_builder.Open();
		_builder.Write( $"#include \"{SboxShaderTemplates.IncludeFeatures}\"" );

		foreach ( var combo in Meta.Combos )
		{
			if ( combo is null || combo.Kind != ComboKind.Feature ) continue;

			_builder.Write( FeatureStatement( combo ) );
		}

		_builder.Close();
		_builder.Blank();
	}

	static string FeatureStatement( ComboDecl combo )
	{
		var group = string.IsNullOrWhiteSpace( combo.Group ) ? "Features" : SboxShaderTemplates.QuoteSafe( combo.Group );
		var values = combo.Values ?? Array.Empty<string>();

		// A two-value feature whose labels say nothing beyond "off" and "on" is a checkbox in the material
		// editor. Spelling those labels out explicitly turns it into a two-item combo box instead, which
		// is the wrong control for a boolean, so the bare range is emitted for the conventional pairs.
		if ( values.Count < 2 || IsPlainToggle( values ) ) return $"Feature( {combo.Name}, 0..1, \"{group}\" );";

		var labels = new List<string>( values.Count );

		for ( int i = 0; i < values.Count; i++ )
		{
			var label = values[i] ?? string.Empty;
			var separator = label.IndexOf( '=' );

			if ( separator >= 0 ) label = label[( separator + 1 )..];

			labels.Add( $"{i}=\"{SboxShaderTemplates.QuoteSafe( label.Trim().Trim( '"' ) )}\"" );
		}

		return $"Feature( {combo.Name}, 0..{values.Count - 1} ( {string.Join( ", ", labels )} ), \"{group}\" );";
	}

	/// <summary>
	/// True when a two-value combo's labels carry no information a checkbox does not already convey.
	/// </summary>
	static bool IsPlainToggle( IReadOnlyList<string> values )
	{
		if ( values.Count != 2 ) return false;

		var off = ( values[0] ?? string.Empty ).Trim().Trim( '"' );
		var on = ( values[1] ?? string.Empty ).Trim().Trim( '"' );

		foreach ( var (a, b) in s_toggleLabels )
		{
			if ( string.Equals( off, a, StringComparison.OrdinalIgnoreCase ) &&
				string.Equals( on, b, StringComparison.OrdinalIgnoreCase ) )
			{
				return true;
			}
		}

		return false;
	}

	static readonly (string Off, string On)[] s_toggleLabels =
	[
		("Off", "On"), ("0", "1"), ("False", "True"), ("No", "Yes"), ("Disabled", "Enabled")
	];

	// ---- COMMON -----------------------------------------------------------

	void WriteCommonBlock()
	{
		_builder.Write( SboxShaderTemplates.BlockCommon );
		_builder.Open();

		if ( Domain == ShaderDomain.Compute )
		{
			// A compute program has no render state, no material and no pixel input; the shipped
			// compute shaders include nothing but the macro header.
			_builder.Write( $"#include \"{SboxShaderTemplates.IncludeSystem}\"" );
			WriteModuleIncludes();
			_builder.Close();
			_builder.Blank();
			return;
		}

		// Everything that steers render state has to be defined BEFORE common/pixel.hlsl pulls in
		// sbox_pixel.fxc, which reads S_TRANSLUCENT and S_ALPHA_TEST at include time. Defining them
		// afterwards silently produces opaque render state, which is the single most common way a
		// generated transparent shader comes out wrong.
		var blend = Meta.BlendMode;
		var alphaTest = blend == SurfaceBlendMode.Masked;
		var translucent = blend is SurfaceBlendMode.Translucent or SurfaceBlendMode.Additive or SurfaceBlendMode.Multiply;

		WriteDefine( "S_ALPHA_TEST", alphaTest ? "1" : "0" );
		WriteDefine( "S_TRANSLUCENT", translucent ? "1" : "0" );

		if ( blend == SurfaceBlendMode.Additive ) WriteDefine( "S_ADDITIVE_BLEND", "1" );

		if ( blend == SurfaceBlendMode.Multiply )
		{
			// Multiply is not one of the engine's built-in blend paths, so we take ownership of the
			// blend state and write it ourselves in the pixel block.
			WriteDefine( "BLEND_MODE_ALREADY_SET", "1" );
		}

		if ( Meta.UsesUv2 || Domain == ShaderDomain.Surface ) WriteDefine( "S_UV2", "1" );

		if ( Meta.ShadingModel == ShadingModel.Unlit && Domain == ShaderDomain.Surface )
		{
			WriteDefine( "S_UNLIT", "1" );
		}

		_builder.Blank();
		_builder.Write( $"#include \"{SboxShaderTemplates.IncludeShared}\"" );

		if ( IsSurface ) _builder.Write( $"#include \"{SboxShaderTemplates.IncludeProcedural}\"" );

		WriteModuleIncludes();

		_builder.Close();
		_builder.Blank();
	}

	/// <summary>
	/// Emit the module's includes plus every include its helpers asked for, deduplicated and in a
	/// stable order. Folding the helper includes in here means a helper that needs a header still
	/// compiles even if nothing upstream remembered to register it on the module.
	/// </summary>
	void WriteModuleIncludes()
	{
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		foreach ( var include in Module.Includes ) WriteInclude( include );

		foreach ( var helper in _helpers )
		{
			foreach ( var include in helper.Includes ?? Array.Empty<string>() ) WriteInclude( include );
		}

		void WriteInclude( string include )
		{
			if ( string.IsNullOrWhiteSpace( include ) ) return;
			if ( IsImplicitInclude( include ) ) return;
			if ( !seen.Add( include ) ) return;

			_builder.Write( $"#include \"{include}\"" );
		}
	}

	void WriteDefine( string name, string value )
	{
		_builder.Write( $"#ifndef {name}" );
		_builder.Write( $"#define {name} {value}" );
		_builder.Write( "#endif" );
	}

	static bool IsImplicitInclude( string include ) =>
		include is SboxShaderTemplates.IncludeShared or SboxShaderTemplates.IncludeProcedural or
			SboxShaderTemplates.IncludeSystem or SboxShaderTemplates.IncludePixel or
			SboxShaderTemplates.IncludeVertex or SboxShaderTemplates.IncludeFeatures or
			SboxShaderTemplates.IncludeVertexInput or SboxShaderTemplates.IncludePixelInput;

	// ---- structs ----------------------------------------------------------

	void WriteVertexInputStruct()
	{
		_builder.Write( $"struct {SboxShaderTemplates.StructVertexInput}" );
		_builder.Open();
		_builder.Write( $"#include \"{SboxShaderTemplates.IncludeVertexInput}\"" );

		if ( Domain == ShaderDomain.Surface ) _builder.WriteBlock( SboxShaderTemplates.SurfaceVertexInputExtras );

		WriteUserStructFields( SboxShaderTemplates.StructVertexInput );

		_builder.Close( ";" );
		_builder.Blank();
	}

	void WritePixelInputStruct()
	{
		_builder.Write( $"struct {SboxShaderTemplates.StructPixelInput}" );
		_builder.Open();
		_builder.Write( $"#include \"{SboxShaderTemplates.IncludePixelInput}\"" );

		if ( Domain == ShaderDomain.Surface )
		{
			_builder.WriteBlock( SboxShaderTemplates.SurfacePixelInputExtras );
		}

		foreach ( var varying in Module.Varyings )
		{
			if ( varying is null ) continue;

			var semantic = SboxShaderTemplates.VaryingSemantic( varying );

			_builder.Write(
				$"{HlslBackend.Interpolation( varying.Interpolation )}{varying.Type.Hlsl} {varying.Name} : {semantic};",
				varying.Origin );
		}

		WriteUserStructFields( SboxShaderTemplates.StructPixelInput );

		if ( Domain == ShaderDomain.Surface ) _builder.WriteBlock( SboxShaderTemplates.PixelInputFrontFacing );

		_builder.Close( ";" );
		_builder.Blank();
	}

	void WriteUserStructFields( string name )
	{
		var structure = Module.FindStruct( name );

		if ( structure is null ) return;

		foreach ( var include in structure.Includes )
		{
			if ( string.IsNullOrWhiteSpace( include ) ) continue;
			if ( IsImplicitInclude( include ) ) continue;

			_builder.Write( $"#include \"{include}\"" );
		}

		foreach ( var field in structure.Fields )
		{
			if ( field is null ) continue;

			var semantic = string.IsNullOrEmpty( field.Semantic ) ? string.Empty : $" : {field.Semantic}";

			_builder.Write(
				$"{HlslBackend.Interpolation( field.Interpolation )}{field.Type.Hlsl} {field.Name}{semantic};" );
		}
	}

	// ---- VS ---------------------------------------------------------------

	void WriteVertexBlock()
	{
		_emitter.Stage = ShaderStage.Vertex;

		_builder.Write( ShaderStage.Vertex.BlockName() );
		_builder.Open();

		if ( Domain == ShaderDomain.Surface )
		{
			_builder.Write( $"#include \"{SboxShaderTemplates.IncludeVertex}\"" );
			_builder.Blank();
		}

		WriteCombos( ShaderStage.Vertex );
		SboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Vertex );
		_emitter.WriteHelpers( _builder, ShaderStage.Vertex, _helpers );
		_emitter.WriteFunctions( _builder, ShaderStage.Vertex );

		var entry = Module.EntryPoint( ShaderStage.Vertex );

		// SV_VertexID has no stream in common/vertexinput.hlsl, so it rides in as a second entry-point
		// parameter — and only when the graph reads it, so every other shader keeps the stock signature.
		var parameters = $"{SboxShaderTemplates.StructVertexInput} {SboxShaderTemplates.VertexInputLocal}";

		if ( UsesBuiltin( entry?.Body, Builtin.VertexId ) )
		{
			parameters += $", {SboxShaderTemplates.VertexIdParameterDeclaration}";
		}

		_builder.Write(
			$"{SboxShaderTemplates.StructPixelInput} {PrismConstants.EntryPointVertex}( {parameters} )" );
		_builder.Open();

		if ( Domain == ShaderDomain.PostProcess )
		{
			_builder.WriteBlock( SboxShaderTemplates.PostProcessVertexPrologue );

			// The graph's own vertex statements go here, not nowhere. GraphCompiler.EmitRoots builds a
			// real vertex entry for a post-process domain and the pixel input struct declares every
			// varying, so dropping the body left every interpolated value reading zero.
			if ( entry is not null )
			{
				_builder.Blank();
				WriteStatements( entry.Body );
			}

			_builder.Blank();
			_builder.Write( SboxShaderTemplates.PostProcessVertexEpilogue );
		}
		else
		{
			_builder.WriteBlock( SboxShaderTemplates.SurfaceVertexPrologue );
			_builder.Blank();

			if ( entry is not null )
			{
				WriteStatements( entry.Body );

				if ( WritesWorldPosition( entry.Body ) )
				{
					_builder.Write( SboxShaderTemplates.VertexPositionResync );
				}

				_builder.Blank();
			}

			_builder.Write( SboxShaderTemplates.SurfaceVertexEpilogue );
		}

		_builder.Close();
		_builder.Close();
		_builder.Blank();
	}

	/// <summary>
	/// True when the vertex program moved the world-space position, so clip space has to be recomputed
	/// before <c>FinalizeVertex</c> subtracts the high-precision offset.
	/// </summary>
	static bool WritesWorldPosition( IrBlock block )
	{
		foreach ( var statement in IrWalk.Statements( block ) )
		{
			if ( statement is IrAssign assign && TouchesWorldPosition( assign.Target ) ) return true;
		}

		return false;
	}

	/// <summary>True when any expression anywhere in a block reads a particular environment value.</summary>
	static bool UsesBuiltin( IrBlock block, Builtin id )
	{
		foreach ( var statement in IrWalk.Statements( block ) )
		{
			foreach ( var expression in IrWalk.Expressions( statement ) )
			{
				if ( Reads( expression, id ) ) return true;
			}
		}

		return false;
	}

	static bool Reads( IrExpr expr, Builtin id )
	{
		if ( expr is null ) return false;

		foreach ( var node in IrExprUtil.Walk( expr ) )
		{
			if ( node is IrBuiltinRef reference && reference.Id == id ) return true;
		}

		return false;
	}

	static bool TouchesWorldPosition( IrExpr target )
	{
		foreach ( var node in IrExprUtil.Walk( target ) )
		{
			if ( node is IrMember member && member.Field == "vPositionWs" ) return true;
		}

		return false;
	}

	// ---- PS ---------------------------------------------------------------

	void WritePixelBlock()
	{
		_emitter.Stage = ShaderStage.Pixel;

		_builder.Write( ShaderStage.Pixel.BlockName() );
		_builder.Open();
		_builder.Write( $"#include \"{SboxShaderTemplates.IncludePixel}\"" );

		if ( Domain == ShaderDomain.PostProcess )
		{
			_builder.Write( $"#include \"{SboxShaderTemplates.IncludePostProcessCommon}\"" );
			_builder.Write( $"#include \"{SboxShaderTemplates.IncludePostProcessFunctions}\"" );
		}

		_builder.Blank();

		WriteCombos( ShaderStage.Pixel );
		WriteRenderState();

		// The colour buffer is boilerplate for a post-process pass, but a node that reads it declares it
		// too — and Slang rejects the second declaration outright rather than merging them, so a graph
		// that actually sampled the frame buffer used to fail to compile. The node's declaration wins:
		// it carries the node's own sRGB and attribute metadata, and it is emitted with the rest of the
		// module globals a few lines below.
		if ( Domain == ShaderDomain.PostProcess &&
			Module.FindGlobal( SboxShaderTemplates.PostProcessColorBufferSymbol ) is null )
		{
			_builder.WriteBlock( SboxShaderTemplates.PostProcessColorBuffer );
			_builder.Blank();
		}

		SboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Pixel );
		WriteInstrumentationDeclarations();
		_emitter.WriteHelpers( _builder, ShaderStage.Pixel, _helpers );
		_emitter.WriteFunctions( _builder, ShaderStage.Pixel );

		_builder.Write(
			$"float4 {PrismConstants.EntryPointPixel}( {SboxShaderTemplates.StructPixelInput} {SboxShaderTemplates.PixelInputLocal} ) : SV_Target0" );
		_builder.Open();

		var entry = Module.EntryPoint( ShaderStage.Pixel );
		var returned = SboxMaterialBinding.EndsWithReturn( entry?.Body );

		if ( !returned ) SboxMaterialBinding.WritePixelPrologue( _builder, Module );

		if ( entry is not null )
		{
			WritePixelBody( entry.Body );
			_builder.Blank();
		}

		if ( !returned ) WriteChannelTail();

		if ( !returned ) SboxMaterialBinding.WritePixelEpilogue( _builder, Module, _emitter );

		_builder.Close();
		_builder.Close();
		_builder.Blank();
	}

	void WriteRenderState()
	{
		var wrote = false;

		// The engine only fills the frame-buffer copy for a material that asks for it, and the ask is a
		// PS-block attribute rather than anything a node can declare. Emitting it here means a graph that
		// reads scene colour gets a filled texture instead of last frame's stale contents.
		if ( WantsFrameBufferCopy() )
		{
			_builder.Write( $"BoolAttribute( {SboxShaderTemplates.FrameBufferCopyFlag}, true );" );
			wrote = true;
		}

		if ( Meta.BlendMode == SurfaceBlendMode.Multiply )
		{
			_builder.WriteBlock( SboxShaderTemplates.MultiplyBlendState );
			wrote = true;
		}

		if ( Options.Mode is CompileMode.Preview or CompileMode.Thumbnail )
		{
			// The preview toggles backface rendering without recompiling the material.
			_builder.WriteBlock( SboxShaderTemplates.CullModePreview );
			wrote = true;
		}
		else
		{
			var cull = Meta.RenderBackfaces ? CullMode.None : Meta.CullMode;

			switch ( cull )
			{
				case CullMode.None:
					_builder.Write( "RenderState( CullMode, NONE );" );
					break;

				case CullMode.Front:
					_builder.Write( "RenderState( CullMode, FRONT );" );
					break;

				default:
					_builder.Write( SboxShaderTemplates.CullModeFromFeature );
					break;
			}

			wrote = true;
		}

		if ( wrote ) _builder.Blank();
	}

	/// <summary>True when the module reads the frame-buffer copy and must therefore request it.</summary>
	bool WantsFrameBufferCopy()
	{
		if ( Module?.Globals is null ) return false;

		foreach ( var global in Module.Globals )
		{
			if ( global is null ) continue;

			if ( string.Equals( global.Name, SboxShaderTemplates.FrameBufferCopyTexture, StringComparison.Ordinal ) )
				return true;
		}

		return false;
	}

	// ---- CS ---------------------------------------------------------------

	void WriteComputeBlock()
	{
		_emitter.Stage = ShaderStage.Compute;

		_builder.Write( ShaderStage.Compute.BlockName() );
		_builder.Open();

		WriteCombos( ShaderStage.Compute );
		SboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Compute );
		_emitter.WriteHelpers( _builder, ShaderStage.Compute, _helpers );
		_emitter.WriteFunctions( _builder, ShaderStage.Compute );

		var entry = Module.EntryPoint( ShaderStage.Compute );
		var threads = entry?.Attributes.FirstOrDefault( x => x?.StartsWith( "[numthreads", StringComparison.OrdinalIgnoreCase ) == true );

		_builder.Write( threads ?? SboxShaderTemplates.ComputeDefaultNumThreads );
		_builder.Write( $"void {PrismConstants.EntryPointCompute}( {SboxShaderTemplates.ComputeEntryParameters} )" );
		_builder.Open();

		if ( entry is not null ) WriteStatements( entry.Body );

		_builder.Close();
		_builder.Close();
		_builder.Blank();
	}

	// ---- statements -------------------------------------------------------

	/// <summary>
	/// Write a block's statements.
	/// <para>
	/// Everything <see cref="HlslEmitter"/> already knows how to write is handed straight back to it,
	/// character for character. This layer exists for the one statement the emitter cannot see —
	/// <see cref="IrPreprocessorIf"/>, which lowers to directives rather than to an expression — and
	/// the block-carrying statements are reproduced here only so that a preprocessor branch nested
	/// inside a loop or a conditional still reaches this writer.
	/// </para>
	/// </summary>
	void WriteStatements( IrBlock block )
	{
		if ( block is null ) return;

		foreach ( var statement in block.Statements ) WriteStatement( statement );
	}

	void WriteStatement( IrStmt statement )
	{
		if ( statement is null ) return;

		var previous = _emitter.CurrentOrigin;

		switch ( statement )
		{
			case IrPreprocessorIf guard:
				_emitter.CurrentOrigin = guard.Origin;
				WritePreprocessorIf( guard );
				break;

			case IrIf branch:
				_emitter.CurrentOrigin = branch.Origin;
				_builder.Write( $"if ( {_emitter.Expression( branch.Cond )} )", branch.Origin );
				WriteBraced( branch.Then, branch.Origin );

				if ( branch.Else is { IsEmpty: false } )
				{
					_builder.Write( "else", branch.Origin );
					WriteBraced( branch.Else, branch.Origin );
				}

				break;

			case IrFor loop:
				_emitter.CurrentOrigin = loop.Origin;

				var counter = string.IsNullOrEmpty( loop.Var ) ? "n" : loop.Var;

				_builder.Write(
					$"for ( int {counter} = 0; {counter} < ( int )( {_emitter.Expression( loop.Count )} ); {counter}++ )",
					loop.Origin );
				WriteBraced( loop.Body, loop.Origin );
				break;

			case IrWhile loop:
				_emitter.CurrentOrigin = loop.Origin;
				_builder.Write( $"while ( {_emitter.Expression( loop.Cond )} )", loop.Origin );
				WriteBraced( loop.Body, loop.Origin );
				break;

			case IrScope scope:
				_emitter.CurrentOrigin = scope.Origin;
				WriteBraced( scope.Body, scope.Origin );
				break;

			default:
				_emitter.WriteStatement( _builder, statement );
				break;
		}

		_emitter.CurrentOrigin = previous;
	}

	void WriteBraced( IrBlock block, NodeId origin )
	{
		_builder.Open( origin );
		WriteStatements( block );
		_builder.Close( origin: origin );
	}

	/// <summary>
	/// Write a preprocessor branch as real <c>#if</c> / <c>#else</c> / <c>#endif</c> directives, so
	/// only the taken side ever reaches the compiler.
	/// <para>
	/// This is what a static combo is supposed to cost. A run-time <c>select</c> evaluates both sides
	/// and pays for the texture samples and the loops in the one that was never wanted; a
	/// preprocessor branch deletes them.
	/// </para>
	/// </summary>
	void WritePreprocessorIf( IrPreprocessorIf guard )
	{
		var directive = IrPreprocessor.OpenDirective( guard.Condition );

		if ( string.IsNullOrEmpty( directive ) )
		{
			// A condition we cannot spell must not become a directive the preprocessor rejects, because
			// a preprocessor error has no line we can map back to a node. Folding both sides in keeps
			// the shader compiling and costs only the exclusion.
			_emitter.Report( DiagnosticSeverity.Warning, DiagnosticCode.InvalidBlock,
				"A compile-time branch had no usable combo condition, so both of its sides were emitted.",
				"Bind the node to a graph keyword. Without one there is nothing for the preprocessor to test, and the branch cannot be resolved at compile time." );

			WriteStatements( guard.Then );
			WriteStatements( guard.Else );

			return;
		}

		_builder.Write( directive, guard.Origin );
		WriteStatements( guard.Then );

		if ( guard.HasElse )
		{
			_builder.Write( IrPreprocessor.ElseDirective, guard.Origin );
			WriteStatements( guard.Else );
		}

		_builder.Write( IrPreprocessor.EndDirective, guard.Origin );
	}

	// ---- preview instrumentation ------------------------------------------

	/// <summary>
	/// Declare the two preview uniforms. They are attribute-bound and default to zero, so a shader
	/// built with instrumentation still renders normally until something pushes them, and switching
	/// what the viewport displays costs one attribute write rather than a recompile.
	/// </summary>
	void WriteInstrumentationDeclarations()
	{
		if ( _stageIds is null ) return;

		foreach ( var line in PreviewInstrumentation.Banner() ) _builder.Write( line );
		foreach ( var line in PreviewInstrumentation.HlslDeclarations() ) _builder.Write( line );

		_builder.Blank();
	}

	/// <summary>
	/// Write the pixel entry's body, interleaving the preview stage switch between statements.
	/// <para>
	/// The test sits next to the temp it reads rather than in a tail at the end of the function, and
	/// that is not a stylistic choice: a temp bound inside a loop or a branch has gone out of scope by
	/// the time the function ends. One test per node, after the last statement that node produced, so
	/// what the switch returns is the node's result rather than an intermediate.
	/// </para>
	/// </summary>
	void WritePixelBody( IrBlock body )
	{
		if ( body is null ) return;

		if ( _stageIds is null )
		{
			WriteStatements( body );
			return;
		}

		var statements = body.Statements;
		var cases = StageCases( statements );

		for ( int i = 0; i < statements.Count; i++ )
		{
			WriteStatement( statements[i] );

			if ( !cases.TryGetValue( i, out var line ) ) continue;

			_builder.Write( line, statements[i].Origin );
		}
	}

	/// <summary>
	/// The switch case for each top-level statement that ends a node's contribution to the pixel
	/// program. Nodes whose work the stage planner put in the vertex program, and values that cannot
	/// be shown as a colour at all, produce no case — selecting one of those shows the shaded result
	/// rather than a wrong one.
	/// </summary>
	Dictionary<int, string> StageCases( IReadOnlyList<IrStmt> statements )
	{
		var last = new Dictionary<NodeId, int>();

		for ( int i = 0; i < statements.Count; i++ )
		{
			if ( statements[i] is not IrDecl decl || !decl.Origin.IsValid ) continue;
			if ( !PreviewInstrumentation.CanShow( decl.Type ) ) continue;

			last[decl.Origin] = i;
		}

		var cases = new Dictionary<int, string>();

		foreach ( var (origin, index) in last )
		{
			if ( statements[index] is not IrDecl decl ) continue;

			var line = PreviewInstrumentation.StageCase(
				PreviewInstrumentation.StageIdOf( _stageIds, origin ), decl.Name, decl.Type );

			if ( line is not null ) cases[index] = line;
		}

		return cases;
	}

	/// <summary>
	/// Write the debug-channel tail, just before the shading epilogue so the material the graph filled
	/// in is still in scope and still unclamped.
	/// </summary>
	void WriteChannelTail()
	{
		if ( _stageIds is null ) return;

		var lines = PreviewInstrumentation.ChannelLines( ChannelEnvironment() );

		if ( lines.Count == 0 ) return;

		foreach ( var line in lines ) _builder.Write( line );

		_builder.Blank();
	}

	/// <summary>
	/// What this shader can answer about itself, channel by channel. A null entry means the generated
	/// shader has no honest expression for that channel — no material struct under a custom shading
	/// model, no vertex colour outside a surface graph — and the channel is then simply absent, which
	/// the viewport reads as "keep showing the shaded result".
	/// </summary>
	PreviewChannelEnvironment ChannelEnvironment()
	{
		var surface = Domain == ShaderDomain.Surface;
		var input = SboxShaderTemplates.PixelInputLocal;
		var material = SboxMaterialBinding.UsesMaterial( Module );

		return new PreviewChannelEnvironment
		{
			Albedo = MaterialField( material, "Albedo" ),
			Opacity = MaterialField( material, "Opacity" ),
			NormalTangent = MaterialField( material, "Normal" ),

			// The graph authors the normal in tangent space; the same conversion the shading epilogue
			// performs is what makes this channel comparable with the engine's own normal debug view.
			NormalWorld = material && surface
				? $"TransformNormal( {SboxShaderTemplates.MaterialLocal}.Normal, {input}.vNormalWs, {input}.vTangentUWs, {input}.vTangentVWs )"
				: null,

			Roughness = MaterialField( material, "Roughness" ),
			Metalness = MaterialField( material, "Metalness" ),
			AmbientOcclusion = MaterialField( material, "AmbientOcclusion" ),
			Emission = MaterialField( material, "Emission" ),
			Transmission = MaterialField( material, "Transmission" ),
			TintMask = MaterialField( material, "TintMask" ),

			Uv0 = $"{input}.vTextureCoords.xy",
			Uv1 = $"{input}.vTextureCoords.zw",
			VertexColor = surface ? $"{input}.vColor" : null,
			WorldPosition = surface
				? HlslIntrinsics.BuiltinExpression( Builtin.WorldPosition, ShaderStage.Pixel, Domain )
				: null,

			DerivativeSource = $"{input}.vTextureCoords.xy"
		};
	}

	/// <summary>The expression for one material field, or null when this shader has no material.</summary>
	static string MaterialField( bool material, string name ) =>
		material && SboxMaterialBinding.TryGetField( name, out var field ) ? field.Reference : null;

	// ---- combos -----------------------------------------------------------

	void WriteCombos( ShaderStage stage )
	{
		var wrote = false;

		foreach ( var combo in Meta.Combos )
		{
			if ( combo is null ) continue;

			switch ( combo.Kind )
			{
				case ComboKind.Feature:
					// A feature is only visible to a program through a static combo bound to it.
					_builder.Write( $"StaticCombo( {StaticNameFor( combo.Name )}, {combo.Name}, Sys( ALL ) );" );
					break;

				case ComboKind.Static:
					_builder.Write( $"StaticCombo( {combo.Name}, 0..{ComboMaximum( combo )}, Sys( ALL ) );" );
					break;

				default:
					_builder.Write( $"DynamicCombo( {combo.Name}, 0..{ComboMaximum( combo )}, Sys( ALL ) );" );
					break;
			}

			wrote = true;
		}

		if ( wrote ) _builder.Blank();
	}

	static int ComboMaximum( ComboDecl combo )
	{
		var count = combo.Values?.Count ?? 0;

		return count < 2 ? 1 : count - 1;
	}

	/// <summary>The static-combo symbol a feature is bound to: <c>F_PUDDLES</c> becomes <c>S_PUDDLES</c>.</summary>
	public static string StaticNameFor( string featureName )
	{
		if ( string.IsNullOrEmpty( featureName ) ) return "S_UNNAMED";

		return featureName.StartsWith( "F_", StringComparison.Ordinal )
			? "S_" + featureName[2..]
			: "S_" + featureName;
	}
}