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.
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&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;
}
}