Static utility class of constant strings and helpers for generating s&box .shader files used by the Prism shader compiler and writer. It defines block keywords, include names, mode names, shader code snippets, semantic helpers, identifier sanitizers and simple text helpers (quote-safe, dedent) used when emitting shader source.
using System.Text;
namespace Editor.Prism.Compiler.Backends;
/// <summary>
/// Every fixed piece of text a generated s&box <c>.shader</c> is built from.
/// <para>
/// The in-editor Roslyn compiler only ingests <c>.cs</c> files under <c>Editor/</c>, so shader
/// templates cannot live in loose files — they are C# constants, which also makes them hotload-safe
/// and free of any I/O on the compile path.
/// </para>
/// <para>
/// Everything here is verified against the shipped headers in
/// <c>addons/base/Assets/shaders/</c> and <c>core/shaders/</c>. Changing a string here changes what
/// the engine's shader compiler sees, so treat each one as load-bearing.
/// </para>
/// </summary>
public static class SboxShaderTemplates
{
// ---- block keywords ---------------------------------------------------
/// <summary>The optional metadata block.</summary>
public const string BlockHeader = "HEADER";
/// <summary>The render-pass block. Required.</summary>
public const string BlockModes = "MODES";
/// <summary>The material-feature block.</summary>
public const string BlockFeatures = "FEATURES";
/// <summary>The block shared by every program.</summary>
public const string BlockCommon = "COMMON";
/// <summary>Name of the vertex input struct. Aliased onto <c>VS_INPUT</c> by <c>sbox_shared.fxc</c>.</summary>
public const string StructVertexInput = "VertexInput";
/// <summary>Name of the pixel input struct. Aliased onto <c>PS_INPUT</c> by <c>sbox_shared.fxc</c>.</summary>
public const string StructPixelInput = "PixelInput";
/// <summary>The local the vertex entry point receives its input in.</summary>
public const string VertexInputLocal = "v";
/// <summary>The local both entry points use for the pixel input / vertex output struct.</summary>
public const string PixelInputLocal = "i";
/// <summary>
/// The extra vertex entry-point parameter carrying <c>SV_VertexID</c>.
/// <para>
/// <c>common/vertexinput.hlsl</c> declares no vertex-id stream, and adding one to the
/// <c>VertexInput</c> struct would change the input layout every mesh binds against. Taking it as a
/// second entry-point parameter is the shape the engine's own shaders use (<c>sprite_ps.shader</c>,
/// <c>ui_cssbox_batched.shader</c>), costs nothing, and is only emitted for a graph that reads it.
/// </para>
/// </summary>
public const string VertexIdParameter = "nPrismVertexId";
/// <summary>The declaration of <see cref="VertexIdParameter"/> as a vertex entry-point parameter.</summary>
public const string VertexIdParameterDeclaration = "uint " + VertexIdParameter + " : SV_VertexID";
/// <summary>
/// The texture a translucent surface reads the scene behind it from. Declaring it is not enough:
/// the shader also has to set <see cref="FrameBufferCopyFlag"/>, or the engine never fills it.
/// </summary>
public const string FrameBufferCopyTexture = "g_tFrameBufferCopyTexture";
/// <summary>The PS-block boolean that makes the engine produce the frame-buffer copy.</summary>
public const string FrameBufferCopyFlag = "bWantsFBCopyTexture";
/// <summary>The local the lit and unlit surface paths accumulate material properties in.</summary>
public const string MaterialLocal = "m";
// ---- includes ---------------------------------------------------------
/// <summary>The engine macro header. Injected automatically, but named here for completeness.</summary>
public const string IncludeSystem = "system.fxc";
/// <summary>The stock feature set: backfaces, shadow casting, texture filtering, additive blend.</summary>
public const string IncludeFeatures = "common/features.hlsl";
/// <summary>The COMMON-block root. Pulls in <c>system.fxc</c> first, as it must be.</summary>
public const string IncludeShared = "common/shared.hlsl";
/// <summary>Noise, SDF and UV helpers the node library leans on.</summary>
public const string IncludeProcedural = "procedural.hlsl";
/// <summary>The canonical vertex input struct body.</summary>
public const string IncludeVertexInput = "common/vertexinput.hlsl";
/// <summary>The canonical pixel input struct body.</summary>
public const string IncludePixelInput = "common/pixelinput.hlsl";
/// <summary>The VS-block root: <c>ProcessVertex</c> and <c>FinalizeVertex</c>.</summary>
public const string IncludeVertex = "common/vertex.hlsl";
/// <summary>The PS-block root: render state, <c>Material</c> and <c>ShadingModelStandard</c>.</summary>
public const string IncludePixel = "common/pixel.hlsl";
/// <summary><c>TransformNormal</c> and friends. Already reachable through the pixel root.</summary>
public const string IncludeNormalUtils = "common/utils/normal.hlsl";
/// <summary>Post-process COMMON extras.</summary>
public const string IncludePostProcessCommon = "postprocess/common.hlsl";
/// <summary>Post-process colour and blur helpers.</summary>
public const string IncludePostProcessFunctions = "postprocess/functions.hlsl";
// ---- modes ------------------------------------------------------------
/// <summary>The main scene pass.</summary>
public const string ModeForward = "Forward";
/// <summary>The non-scene pass used by compute, post-process and UI shaders.</summary>
public const string ModeDefault = "Default";
/// <summary>The depth / G-buffer prepass.</summary>
public const string ModeDepth = "Depth";
/// <summary>The shading-complexity debug view.</summary>
public const string ModeToolsShadingComplexity = "ToolsShadingComplexity";
/// <summary>Legacy alias for <see cref="ModeForward"/>.</summary>
public const string ModeVrForward = "VrForward";
/// <summary>Engine tools pass. Never generated, only recognised.</summary>
public const string ModeToolsUtil = "ToolsUtil";
/// <summary>The shader <c>ToolsShadingComplexity</c> delegates to.</summary>
public const string ShadingComplexityShader = "tools_shading_complexity.shader";
/// <summary>The modes a lit or unlit surface graph declares.</summary>
public static readonly IReadOnlyList<string> SurfaceModes =
[
ModeForward, ModeDepth, ModeToolsShadingComplexity
];
/// <summary>The modes a post-process graph declares.</summary>
public static readonly IReadOnlyList<string> PostProcessModes = [ModeDefault, ModeForward];
/// <summary>The modes a compute graph declares.</summary>
public static readonly IReadOnlyList<string> ComputeModes = [ModeDefault];
/// <summary>
/// The render passes a graph of this domain declares when it names none of its own. Both the
/// writer and the validator go through here, so they can never disagree about what will be emitted.
/// </summary>
public static IReadOnlyList<string> DefaultModesFor( Core.ShaderDomain domain ) => domain switch
{
Core.ShaderDomain.PostProcess => PostProcessModes,
Core.ShaderDomain.Compute => ComputeModes,
_ => SurfaceModes
};
/// <summary>
/// True when a render pass means anything for a graph of this domain.
/// <para>
/// <c>Depth</c> and <c>ToolsShadingComplexity</c> only exist for geometry that takes part in the depth
/// prepass and the tools visualisations, so declaring them on a full-screen post-process pass asks the
/// engine to render a full-screen triangle into the depth buffer. The domain is authored independently
/// of the pass list, so the writer filters rather than trusting whatever the document happens to hold.
/// </para>
/// </summary>
public static bool IsModeLegalFor( Core.ShaderDomain domain, string mode )
{
if ( string.IsNullOrWhiteSpace( mode ) ) return false;
// A fully spelled-out call carries its own fallback shader and is the author's problem, not ours.
if ( mode.Contains( '(' ) ) return true;
var name = mode.Trim();
return domain switch
{
Core.ShaderDomain.PostProcess => Contains( PostProcessModes, name ),
Core.ShaderDomain.Compute => Contains( ComputeModes, name ),
_ => true
};
static bool Contains( IReadOnlyList<string> modes, string name )
{
foreach ( var mode in modes )
{
if ( string.Equals( mode, name, StringComparison.OrdinalIgnoreCase ) ) return true;
}
return false;
}
}
/// <summary>
/// Render the <c>MODES</c> statement for a mode name. <see cref="ModeToolsShadingComplexity"/> is
/// the only one that takes a fallback shader; everything else is a bare call.
/// </summary>
public static string ModeStatement( string mode )
{
if ( string.IsNullOrWhiteSpace( mode ) ) return null;
mode = mode.Trim();
// Already a full call, e.g. Depth( "depth_only.shader" ) — pass it through.
if ( mode.Contains( '(' ) ) return mode.EndsWith( ";" ) ? mode : mode + ";";
if ( mode == ModeToolsShadingComplexity )
return $"{mode}( \"{ShadingComplexityShader}\" );";
return $"{mode}();";
}
// ---- struct bodies ----------------------------------------------------
/// <summary>
/// The extra vertex streams a Prism surface shader declares on top of
/// <c>common/vertexinput.hlsl</c>. Mirrors the stock graph so meshes bind identically.
/// </summary>
public const string SurfaceVertexInputExtras =
"""
float4 vColor : COLOR0 < Semantic( Color ); >;
""";
/// <summary>
/// The extra interpolants a Prism surface shader declares on top of
/// <c>common/pixelinput.hlsl</c>.
/// <para>
/// TEXCOORD8 and TEXCOORD9 are deliberate: <c>common/pixelinput.hlsl</c> consumes TEXCOORD0, 1, 2,
/// 3, 4, 6, 7 and 11, so 5, 8, 9, 10 and 12 are free below the Prism varying floor of
/// <see cref="Core.PrismConstants.FirstFreeTexcoord"/>. Keeping the fixed extras below that floor
/// leaves the whole 13+ range to <c>VaryingAllocator</c>.
/// </para>
/// </summary>
public const string SurfacePixelInputExtras =
"""
float3 vPositionOs : TEXCOORD8;
float3 vNormalOs : TEXCOORD9;
float4 vTangentUOs_flTangentVSign : TANGENT < Semantic( TangentU_SignV ); >;
float4 vColor : COLOR0;
float4 vTintColor : COLOR1;
""";
/// <summary>The front-face flag, which only exists in the pixel program.</summary>
public const string PixelInputFrontFacing =
"""
#if ( PROGRAM == VFX_PROGRAM_PS )
bool vFrontFacing : SV_IsFrontFace;
#endif
""";
// ---- vertex bodies ----------------------------------------------------
/// <summary>
/// The prologue every surface vertex shader starts with. <c>ProcessVertex</c> does instancing,
/// skinning, tangent decompression and lightmap UVs; everything after it is graph work.
/// </summary>
public const string SurfaceVertexPrologue =
"""
PixelInput i = ProcessVertex( v );
i.vPositionOs = v.vPositionOs.xyz;
i.vColor = v.vColor;
ExtraShaderData_t extraShaderData = GetExtraPerInstanceShaderData( v.nInstanceTransformID );
i.vTintColor = extraShaderData.vTint;
VS_DecodeObjectSpaceNormalAndTangent( v, i.vNormalOs, i.vTangentUOs_flTangentVSign );
""";
/// <summary>
/// The epilogue. <c>FinalizeVertex</c> subtracts the high-precision lighting offset, so every
/// world-space computation has to happen before it.
/// </summary>
public const string SurfaceVertexEpilogue = "return FinalizeVertex( i );";
/// <summary>
/// Recompute clip-space position after a world-space displacement. Emitted by the writer when the
/// module's vertex function wrote to <c>i.vPositionWs</c>.
/// </summary>
public const string VertexPositionResync = "i.vPositionPs.xyzw = Position3WsToPs( i.vPositionWs.xyz );";
/// <summary>
/// The full-screen-triangle vertex prologue for post-process graphs. The UV is smuggled through the
/// world-position interpolant, exactly as the stock generator does, so the pixel stage can read it
/// without an extra interpolant.
/// <para>
/// Split from <see cref="PostProcessVertexEpilogue"/> so the graph's own vertex statements can be
/// written between the two. They used to be one block ending in <c>return i;</c>, which meant a
/// post-process graph emitted a vertex entry function and then discarded its body — while
/// <c>WritePixelInputStruct</c> still declared every varying, so anything crossing the boundary read
/// zeros. It compiled, and it was silently wrong.
/// </para>
/// </summary>
public const string PostProcessVertexPrologue =
"""
PixelInput i = ( PixelInput )0;
i.vPositionPs = float4( v.vPositionOs.xy, 0.0, 1.0 );
i.vPositionWs = float3( v.vTexCoord.xy, 0.0 );
i.vTextureCoords.xy = v.vTexCoord.xy;
""";
/// <summary>Closes the post-process vertex body. See <see cref="PostProcessVertexPrologue"/>.</summary>
public const string PostProcessVertexEpilogue = "return i;";
// ---- pixel bodies -----------------------------------------------------
/// <summary>
/// The lit / unlit surface prologue. Matches the stock generator's defaults so a graph that wires
/// nothing produces the same white dielectric the built-in editor does.
/// <para>
/// <b>The <c>m.Normal</c> line is load-bearing and must not be tidied away.</b> Eight of these
/// assignments restate values <c>Material::Init()</c> has already set, and are kept only so Prism's
/// output does not silently change meaning if the engine's defaults ever move. The normal is
/// different: <c>Material::Init( PixelInput )</c> leaves <c>m.Normal</c> in <i>world</i> space
/// (<c>i.vNormalWs</c>), while <see cref="MaterialShade"/> unconditionally runs it through
/// <c>TransformNormal</c>, which expects <i>tangent</i> space. Resetting it to the tangent-space
/// identity here is what makes a graph that never wires Normal come out with the geometric normal
/// instead of a garbled one.
/// </para>
/// </summary>
public const string MaterialInit =
"""
Material m = Material::Init( i );
m.Albedo = float3( 1.0, 1.0, 1.0 );
m.Normal = float3( 0.0, 0.0, 1.0 );
m.Roughness = 1.0;
m.Metalness = 0.0;
m.AmbientOcclusion = 1.0;
m.TintMask = 1.0;
m.Opacity = 1.0;
m.Emission = float3( 0.0, 0.0, 0.0 );
m.Transmission = float3( 0.0, 0.0, 0.0 );
""";
/// <summary>
/// The lit epilogue. The tangent-to-world conversion has to go through <c>TransformNormal</c>:
/// it flips Y to compensate for the Source 1 tangent convention the tools still generate, and a
/// hand-rolled TBN multiply comes out with inverted green.
/// </summary>
public const string MaterialShade =
"""
m.AmbientOcclusion = saturate( m.AmbientOcclusion );
m.Roughness = saturate( m.Roughness );
m.Metalness = saturate( m.Metalness );
m.Opacity = saturate( m.Opacity );
// The graph authors normals in tangent space; convert to world space for shading.
m.Normal = TransformNormal( m.Normal, i.vNormalWs, i.vTangentUWs, i.vTangentVWs );
// Tools visualisation reads these.
m.WorldTangentU = i.vTangentUWs;
m.WorldTangentV = i.vTangentVWs;
m.TextureCoords = i.vTextureCoords.xy;
return ShadingModelStandard::Shade( m );
""";
/// <summary>The unlit epilogue. No lighting, no tools-vis, no fog — just the authored colour.</summary>
public const string MaterialUnlitReturn =
"""
m.Opacity = saturate( m.Opacity );
return float4( m.Albedo + m.Emission, m.Opacity );
""";
/// <summary>
/// True when a name is an identifier the engine's block parser can read.
/// <para>
/// The <c>.shader</c> front-end is an ANTLR grammar over ASCII. A letter outside that range — the
/// diacritic in a parameter called <c>Größe</c>, say — does not produce a nice error on that line: it
/// fails the whole file with a mismatched-token dump and no line number at all. C#'s own
/// <c>char.IsLetter</c> is Unicode-aware and would happily call it legal, so the check is spelled out.
/// </para>
/// </summary>
public static bool IsAsciiIdentifier( string name )
{
if ( string.IsNullOrEmpty( name ) ) return false;
if ( !char.IsAsciiLetter( name[0] ) && name[0] != '_' ) return false;
foreach ( var c in name )
{
if ( !char.IsAsciiLetterOrDigit( c ) && c != '_' ) return false;
}
return true;
}
/// <summary>
/// The spelling a symbol is emitted with in a <c>.shader</c> file.
/// <para>
/// A name that is already a legal ASCII identifier is returned untouched, so every symbol a user has
/// ever seen keeps its spelling. Anything else has its illegal characters replaced and a short stable
/// suffix appended, because two names that differ only outside the ASCII range — <c>Größe</c> and
/// <c>Grüße</c> — would otherwise collapse onto one uniform and silently drive each other.
/// </para>
/// </summary>
public static string SafeIdentifier( string name )
{
if ( string.IsNullOrEmpty( name ) ) return name;
if ( IsAsciiIdentifier( name ) ) return name;
var text = new System.Text.StringBuilder( name.Length + 9 );
foreach ( var c in name )
{
text.Append( char.IsAsciiLetterOrDigit( c ) || c == '_' ? c : '_' );
}
if ( !char.IsAsciiLetter( text[0] ) && text[0] != '_' ) text.Insert( 0, '_' );
// Deterministic, and deliberately not string.GetHashCode: that is randomised per process, and
// regenerating an unchanged graph has to produce byte-identical text.
return $"{text}_{StableSuffix( name )}";
}
/// <summary>A six-character base-36 FNV-1a digest of a string. Stable across processes and runs.</summary>
static string StableSuffix( string text )
{
const string digits = "0123456789abcdefghijklmnopqrstuvwxyz";
var hash = 2166136261u;
foreach ( var c in text )
{
hash ^= c;
hash *= 16777619u;
}
var suffix = new char[6];
for ( int i = suffix.Length - 1; i >= 0; i-- )
{
suffix[i] = digits[(int)( hash % 36u )];
hash /= 36u;
}
return new string( suffix );
}
/// <summary>
/// The semantic an allocated interpolator is declared with.
/// <para>
/// <see cref="Ir.IrVarying.Slot"/> is the register's index inside Prism's own range, not a TEXCOORD
/// number — the allocator hands out slot 0 and spells it <c>TEXCOORD13</c>. The spelling it produced
/// is authoritative; the fallback only exists for a varying built by hand, and it applies the same
/// floor so that path cannot land on a semantic the engine's own structs already own.
/// </para>
/// </summary>
public static string VaryingSemantic( Ir.IrVarying varying )
{
if ( varying is null ) return null;
if ( !string.IsNullOrEmpty( varying.Semantic ) ) return varying.Semantic;
return $"TEXCOORD{Core.PrismConstants.FirstFreeTexcoord + Math.Max( 0, varying.Slot )}";
}
/// <summary>
/// The index of a <c>TEXCOORD</c> semantic, or -1 when it is not one. Used to check an interpolator
/// against the floor the engine's own structs leave free.
/// </summary>
public static int TexCoordIndex( string semantic )
{
const string prefix = "TEXCOORD";
if ( string.IsNullOrEmpty( semantic ) ) return -1;
if ( !semantic.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) return -1;
return int.TryParse( semantic[prefix.Length..], out var index ) ? index : -1;
}
/// <summary>
/// The symbol the engine binds the previous frame buffer to in a post-process pass. A node that reads
/// scene colour declares this same name, and the block file may only declare it once, so the writer
/// checks the module for it before emitting the boilerplate below.
/// </summary>
public const string PostProcessColorBufferSymbol = "g_tColorBuffer";
/// <summary>
/// The scene-colour texture a post-process graph reads. This exact declaration is what the engine
/// binds the previous frame buffer to.
/// </summary>
public const string PostProcessColorBuffer =
"""
Texture2D g_tColorBuffer < Attribute( "ColorBuffer" ); SrgbRead( true ); >;
""";
// ---- render state -----------------------------------------------------
/// <summary>
/// Blend state for <see cref="Core.SurfaceBlendMode.Multiply"/>. The engine's own block is skipped
/// by defining <c>BLEND_MODE_ALREADY_SET</c> in COMMON, so this has to be complete.
/// </summary>
public const string MultiplyBlendState =
"""
RenderState( BlendEnable, true );
RenderState( SrcBlend, DEST_COLOR );
RenderState( DstBlend, ZERO );
RenderState( BlendOp, ADD );
RenderState( SrcBlendAlpha, ZERO );
RenderState( DstBlendAlpha, ONE );
RenderState( BlendOpAlpha, ADD );
""";
/// <summary>Cull-mode state that honours the stock <c>F_RENDER_BACKFACES</c> feature.</summary>
public const string CullModeFromFeature = "RenderState( CullMode, F_RENDER_BACKFACES ? NONE : DEFAULT );";
/// <summary>Cull-mode state for the preview, switchable without a material recompile.</summary>
public const string CullModePreview =
"""
DynamicCombo( D_RENDER_BACKFACES, 0..1, Sys( ALL ) );
RenderState( CullMode, D_RENDER_BACKFACES ? NONE : BACK );
""";
// ---- compute ----------------------------------------------------------
/// <summary>The parameter list of the generated compute entry point.</summary>
public const string ComputeEntryParameters =
"uint3 vThreadId : SV_DispatchThreadID, uint3 vGroupThreadId : SV_GroupThreadID, uint3 vGroupId : SV_GroupID";
/// <summary>Default thread-group size when the module does not declare one.</summary>
public const string ComputeDefaultNumThreads = "[numthreads( 8, 8, 1 )]";
// ---- text helpers -----------------------------------------------------
/// <summary>
/// Make a string safe to sit inside a double-quoted VFX metadata value. The block parser has no
/// escape syntax at all, so quotes, backslashes and newlines are replaced rather than escaped.
/// </summary>
public static string QuoteSafe( string text )
{
if ( string.IsNullOrEmpty( text ) ) return string.Empty;
var builder = new StringBuilder( text.Length );
foreach ( var c in text )
{
switch ( c )
{
case '"':
case '\\':
builder.Append( '\'' );
break;
case '\r':
break;
case '\n':
case '\t':
builder.Append( ' ' );
break;
default:
builder.Append( char.IsControl( c ) ? ' ' : c );
break;
}
}
return builder.ToString().Trim();
}
/// <summary>
/// Remove the smallest leading whitespace run shared by every non-blank line, so a template can be
/// written at whatever indentation reads best in C# and still land flush-left in the output.
/// </summary>
public static string Dedent( string text )
{
if ( string.IsNullOrEmpty( text ) ) return string.Empty;
var lines = text.Replace( "\r\n", "\n" ).Split( '\n' );
var common = int.MaxValue;
foreach ( var line in lines )
{
if ( line.Trim().Length == 0 ) continue;
var indent = 0;
while ( indent < line.Length && ( line[indent] == '\t' || line[indent] == ' ' ) ) indent++;
if ( indent < common ) common = indent;
}
if ( common is 0 or int.MaxValue ) return string.Join( "\n", lines );
for ( int i = 0; i < lines.Length; i++ )
{
lines[i] = lines[i].Length >= common ? lines[i][common..] : lines[i].TrimStart();
}
return string.Join( "\n", lines );
}
}