Editor tool code that builds a comprehensive set of Prism shader fixtures, writes them as .shader files and optionally runs the engine's ShaderCompiler.exe over them to validate generated shader text against the engine compiler. It programmatically constructs many PrismGraph variants (empty, lit/unlit, blend modes, node-per-node, combined graphs, subgraphs, parameter coverage, awkward names, custom HLSL snippets, vertex-only cases, etc.), emits shader text via the GraphCompiler, writes files, batches calls to ShaderCompiler.exe, parses its console output and reports passes/failures.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Nodes;
using Editor.Prism.Serialization;
using System.IO;
using System.Text;
namespace Editor.Prism;
// ---------------------------------------------------------------------------------------------------
// Engine validation.
//
// Everything else in Prism proves the generated text against Prism's own model of the world. This file
// proves it against the engine's actual shader compiler: it regenerates a fixture set covering every
// domain, every blend mode and every registered node type, writes real .shader files, and — when the
// engine's ShaderCompiler.exe can be found — runs the whole set through it and reports what came back.
//
// The fixtures are deliberately built in code rather than loaded from disk. A fixture that lives in a
// file rots the moment a port is renamed; one that is built through NodeRegistry cannot.
// ---------------------------------------------------------------------------------------------------
/// <summary>What happened to one fixture during an engine-validation run.</summary>
public sealed class ShaderFixtureResult
{
/// <summary>The fixture's name, which is also the stem of the file it was written to.</summary>
public string Name { get; init; }
/// <summary>One line describing what this fixture is there to prove.</summary>
public string Description { get; init; }
/// <summary>
/// The absolute path the generated <c>.shader</c> was written to, if it was written. After a run that
/// compiled, only the files that failed are still there: the ones that passed are cleaned up.
/// </summary>
public string Path { get; set; }
/// <summary>How many characters of shader text the fixture generated.</summary>
public int Length { get; set; }
/// <summary><c>pass</c>, <c>FAIL</c> or <c>skip</c>.</summary>
public string Status { get; set; } = "skip";
/// <summary>The compiler's own words when this fixture failed, or a note when it was skipped.</summary>
public string Detail { get; set; } = string.Empty;
/// <summary>True when the fixture generated text and the engine compiled it.</summary>
public bool Passed => Status == "pass";
/// <summary>True when the fixture either failed to generate or failed to compile.</summary>
public bool Failed => Status == "FAIL";
/// <inheritdoc/>
public override string ToString() =>
$"{Status,-4} {Name}{( string.IsNullOrEmpty( Detail ) ? "" : " " + Detail )}";
}
/// <summary>
/// Puts everything Prism generates through the engine's real shader compiler.
/// <para>
/// <see cref="RunEngineValidation( string )"/> regenerates the whole fixture set — an empty graph, one
/// per domain, one per blend mode, a keyword graph, a subgraph graph, every registered node type on its
/// own and every registered node type at once — writes each as a <c>.shader</c> into the given folder,
/// and then runs <c>ShaderCompiler.exe</c> over exactly those files. The report it returns names every
/// failure with the compiler's own output.
/// </para>
/// <para>
/// The engine's compiler enumerates <c>*.shader</c> from its working directory and matches non-flag
/// arguments against the absolute path of each file it finds, skipping anything under a dot-folder, a
/// <c>download</c> folder or a <c>templates</c> folder. So the output folder has to sit inside the
/// engine install for the run to see it, which is what <see cref="DefaultOutputDirectory"/> gives you.
/// </para>
/// </summary>
public static class PrismValidation
{
/// <summary>The folder name, under the engine install, the menu action writes its fixtures to.</summary>
public const string DefaultFolderName = "prism_validate";
/// <summary>Where the engine keeps its shader compiler, relative to the install root.</summary>
public const string CompilerRelativePath = "bin/managed/ShaderCompiler.exe";
/// <summary>Content-relative path the fixture set publishes its throwaway subgraph under.</summary>
const string FixtureSubgraphPath = "prism/internal/validation_fixture.prismfn";
/// <summary>Content-relative path of the throwaway subgraph that instances the one above.</summary>
const string FixtureNestedSubgraphPath = "prism/internal/validation_fixture_nested.prismfn";
/// <summary>How long to let one batch of the engine's compiler run, in milliseconds.</summary>
const int CompilerTimeoutMs = 30 * 60 * 1000;
/// <summary>
/// How many characters of file paths one compiler invocation is allowed to carry. Windows caps a
/// command line at 32k, so this could be far higher — it is deliberately not, because
/// <see cref="CompilerTimeoutMs"/> applies per batch and the whole batch is lost when it expires.
/// Roughly a hundred shaders per invocation keeps every batch far inside the timeout on a slow
/// machine, at the cost of a few extra process starts on a fast one.
/// </summary>
const int MaxCommandLine = 8000;
/// <summary>
/// Regenerate the fixture set into <paramref name="outputDirectory"/>, put it through the engine's
/// shader compiler and return the report. Never throws.
/// </summary>
public static string RunEngineValidation( string outputDirectory ) =>
RunEngineValidation( outputDirectory, true );
/// <summary>
/// Regenerate the fixture set, optionally compiling it, and return the report. Never throws.
/// </summary>
/// <param name="outputDirectory">Where the <c>.shader</c> files go. Created if it does not exist.</param>
/// <param name="compile">
/// When true, and the engine's <c>ShaderCompiler.exe</c> can be found, every generated file is put
/// through it, and everything that compiled is then deleted again — only the failures are left on
/// disk to be read. When false, the run stops after generation and the whole set is left in place.
/// </param>
public static string RunEngineValidation( string outputDirectory, bool compile )
{
var report = new StringBuilder();
report.AppendLine( "Prism engine validation" );
report.AppendLine( "=======================" );
report.AppendLine();
var directory = string.IsNullOrWhiteSpace( outputDirectory ) ? DefaultOutputDirectory : outputDirectory;
var results = new List<ShaderFixtureResult>();
try
{
results.AddRange( Generate( directory ) );
}
catch ( Exception e )
{
report.AppendLine( "Generation threw: " + e );
return report.ToString();
}
var generated = results.Count( x => !string.IsNullOrEmpty( x.Path ) );
report.AppendLine( $"output {directory}" );
report.AppendLine( $"fixtures {results.Count} built, {generated} written" );
var compiled = false;
if ( compile )
{
var compiler = FindShaderCompiler();
if ( string.IsNullOrEmpty( compiler ) )
{
report.AppendLine( "compiler not found — generation only" );
}
else
{
report.AppendLine( $"compiler {compiler}" );
compiled = PrismLog.Guard( "Prism: run the engine shader compiler",
() => RunCompiler( compiler, results, report ) );
}
}
else
{
report.AppendLine( "compiler skipped by request" );
}
var passed = results.Count( x => x.Passed );
var failed = results.Count( x => x.Failed );
report.AppendLine();
report.AppendLine( "----------------------------------------" );
report.AppendLine( $"{results.Count} fixtures: {passed} passed, {failed} failed, " +
$"{results.Count - passed - failed} skipped" );
report.AppendLine();
foreach ( var entry in results.Where( x => x.Failed ) )
{
report.AppendLine( $"FAIL {entry.Name} ({entry.Description})" );
foreach ( var line in ( entry.Detail ?? string.Empty ).Split( '\n' ) )
{
if ( string.IsNullOrWhiteSpace( line ) ) continue;
report.AppendLine( " " + line.TrimEnd( '\r' ) );
}
}
// Without a compiler run every fixture is trivially unreported, and three hundred lines saying so
// would bury the ones that matter.
if ( compiled )
{
foreach ( var entry in results.Where( x => !x.Passed && !x.Failed ) )
{
report.AppendLine( $"skip {entry.Name} {entry.Detail}" );
}
}
return report.ToString();
}
/// <summary>
/// Build every fixture, compile each one through <see cref="GraphCompiler"/> and write the generated
/// <c>.shader</c> into <paramref name="outputDirectory"/>. Nothing is run through the engine here.
/// </summary>
public static IReadOnlyList<ShaderFixtureResult> Generate( string outputDirectory )
{
NodeRegistry.EnsureBuilt();
var directory = string.IsNullOrWhiteSpace( outputDirectory ) ? DefaultOutputDirectory : outputDirectory;
var results = new List<ShaderFixtureResult>();
PrismLog.Guard( "Prism: prepare the validation folder", () =>
{
Directory.CreateDirectory( directory );
// A stale fixture from a previous run would be compiled again and reported as a failure of a
// node that no longer generates it, so the folder starts empty every time.
foreach ( var stale in Directory.EnumerateFiles( directory, "*.shader*" ) ) File.Delete( stale );
} );
PrismLog.Guard( "Prism: publish the validation subgraphs", () =>
{
SubgraphLibrary.Register( FixtureSubgraphPath, BuildFixtureSubgraph() );
SubgraphLibrary.Register( FixtureNestedSubgraphPath, BuildNestedFixtureSubgraph() );
} );
try
{
foreach ( var fixture in Fixtures() )
{
results.Add( Emit( directory, fixture.Name, fixture.Description, fixture.Graph, fixture.Mode ) );
}
}
finally
{
PrismLog.Guard( "Prism: retract the validation subgraphs", () =>
{
SubgraphLibrary.Unregister( FixtureNestedSubgraphPath );
SubgraphLibrary.Unregister( FixtureSubgraphPath );
} );
}
return results;
}
/// <summary>
/// The folder the menu action writes to: <c><engine>/prism_validate</c>, which is inside the
/// engine install so the engine's own compiler can see it.
/// </summary>
public static string DefaultOutputDirectory
{
get
{
var root = EngineRoot();
return string.IsNullOrEmpty( root )
? System.IO.Path.Combine( System.IO.Path.GetTempPath(), DefaultFolderName )
: System.IO.Path.Combine( root, DefaultFolderName );
}
}
// ---- generation -------------------------------------------------------
/// <summary>Compile one fixture and write its shader text out.</summary>
static ShaderFixtureResult Emit( string directory, string name, string description, PrismGraph graph,
CompileMode mode )
{
var entry = new ShaderFixtureResult { Name = name, Description = description };
if ( graph is null )
{
entry.Detail = "the fixture could not be built";
return entry;
}
CompileResult result;
try
{
result = GraphCompiler.Compile( graph, mode );
}
catch ( Exception e )
{
entry.Status = "FAIL";
entry.Detail = "generation threw: " + e.GetType().Name + ": " + e.Message;
return entry;
}
if ( result is null || !result.Ok )
{
entry.Status = "FAIL";
entry.Detail = result is null
? "the compiler returned nothing"
: string.Join( " | ", result.Diagnostics
.Where( x => x.Severity == DiagnosticSeverity.Error )
.Select( x => $"{x.Code} {x.Message}" )
.Distinct( StringComparer.Ordinal )
.Take( 3 ) );
return entry;
}
var text = result.ShaderText;
if ( string.IsNullOrWhiteSpace( text ) )
{
entry.Status = "FAIL";
entry.Detail = "the s&box backend produced no text";
return entry;
}
// Normalised, because the engine's compiler matches the paths it is handed against the paths it
// enumerated by ordinal string comparison: a forward slash anywhere silently matches nothing.
var path = System.IO.Path.GetFullPath(
System.IO.Path.Combine( directory, name + "." + PrismConstants.ShaderExtension ) );
if ( !PrismLog.Guard( $"Prism: write '{name}'", () => File.WriteAllText( path, text ) ) )
{
entry.Status = "FAIL";
entry.Detail = "the file could not be written";
return entry;
}
entry.Path = path;
entry.Length = text.Length;
entry.Detail = string.Empty;
return entry;
}
/// <summary>Every fixture, in the order the report lists them.</summary>
static IEnumerable<Fixture> Fixtures()
{
Fixture Case( string name, string description, PrismGraph graph, CompileMode mode = CompileMode.Final ) =>
new( name, description, graph, mode );
yield return Case( "empty", "the document File ▸ New produces: one terminal, nothing wired", EmptyGraph() );
yield return Case( "lit_minimal", "a constant colour driving Albedo", MinimalLitGraph() );
yield return Case( "lit_full", "every material field driven at once", FullLitGraph() );
yield return Case( "unlit", "the unlit terminal on an unlit graph", UnlitGraph() );
yield return Case( "translucent", "a translucent surface", BlendGraph( SurfaceBlendMode.Translucent ) );
yield return Case( "masked", "an alpha-tested surface", BlendGraph( SurfaceBlendMode.Masked ) );
yield return Case( "additive", "an additive surface", BlendGraph( SurfaceBlendMode.Additive ) );
yield return Case( "multiply", "a multiply-blended surface, whose blend state Prism owns",
BlendGraph( SurfaceBlendMode.Multiply ) );
yield return Case( "postprocess", "the post-process terminal on a post-process graph", PostProcessGraph() );
yield return Case( "vertex_offset", "a world-space displacement in the vertex program", VertexOffsetGraph() );
yield return Case( "keyword_feature", "a two-value feature branching the pixel program",
KeywordGraph( ComboKind.Feature, 2 ) );
yield return Case( "keyword_static", "a static combo branching the pixel program",
KeywordGraph( ComboKind.Static, 2 ) );
yield return Case( "keyword_dynamic", "a dynamic combo branching the pixel program",
KeywordGraph( ComboKind.Dynamic, 2 ) );
yield return Case( "keyword_multivalue", "a four-value feature, which is a combo box not a checkbox",
KeywordGraph( ComboKind.Feature, 4 ) );
yield return Case( "subgraph", "an inlined subgraph instance", SubgraphGraph() );
yield return Case( "parameters", "one blackboard parameter of every type the material UI can show",
ParameterGraph() );
yield return Case( "custom_code", "a hand-written HLSL snippet with declared ports", CustomCodeGraph() );
yield return Case( "awkward_names", "names and descriptions full of everything the block grammar hates",
AwkwardNameGraph() );
yield return Case( "strict_hlsl", "the full lit graph in the portable HLSL 2021 dialect",
Dialect( FullLitGraph(), HlslDialect.StrictHlsl2021 ) );
yield return Case( "backfaces", "a two-sided surface with culling off", Backfaces( FullLitGraph() ) );
yield return Case( "preview_lit_full", "the full lit graph as the preview compiles it",
FullLitGraph(), CompileMode.Preview );
yield return Case( "varyings", "vertex-only values read from the pixel stage, so they interpolate",
VaryingGraph() );
yield return Case( "scene_color_translucent", "a translucent surface reading the frame-buffer copy",
SceneColorSurfaceGraph() );
foreach ( var type in NodeRegistry.Types )
{
var id = type?.Descriptor?.Id;
if ( string.IsNullOrEmpty( id ) ) continue;
if ( id.StartsWith( "prism.internal.selftest", StringComparison.Ordinal ) ) continue;
var probe = PrismLog.Guard( $"Prism: instantiate '{id}'", type.Create );
if ( probe is null ) continue;
var graph = PrismLog.Guard( $"Prism: build a fixture for '{id}'", () => NodeGraph( id, probe ) );
if ( graph is null ) continue;
yield return Case( "node_" + id.Replace( '.', '_' ), id, graph );
}
yield return Case( "preview_unlit", "the unlit graph as the preview compiles it",
UnlitGraph(), CompileMode.Preview );
yield return Case( "preview_postprocess", "the post-process graph as the preview compiles it",
PostProcessGraph(), CompileMode.Preview );
yield return Case( "preview_varyings", "interpolated values with the preview instrumentation on top",
VaryingGraph(), CompileMode.Preview );
yield return Case( "all_nodes", "every registered node type in one graph", CombinedGraph( false ) );
yield return Case( "strict_all_nodes", "every node type in the portable HLSL 2021 dialect",
Dialect( CombinedGraph( false ), HlslDialect.StrictHlsl2021 ) );
yield return Case( "all_outputs", "every output of every registered node type in one graph",
CombinedGraph( true ) );
yield return Case( "all_nodes_vertex", "every vertex-capable node driving a vertex displacement",
VertexCombinedGraph() );
yield return Case( "preview_all_nodes", "every node type as the preview compiles it",
CombinedGraph( false ), CompileMode.Preview );
yield return Case( "thumbnail_all_nodes", "every node type as the thumbnail pass compiles it",
CombinedGraph( false ), CompileMode.Thumbnail );
}
/// <summary>One fixture: a document, what it proves, and the mode it is compiled in.</summary>
readonly record struct Fixture( string Name, string Description, PrismGraph Graph, CompileMode Mode );
/// <summary>
/// An empty document targeting both backends. Only the <c>.shader</c> reaches the engine, but asking
/// for the Slang module too means a fixture that cannot be lowered to Slang fails here rather than
/// silently the first time somebody ticks that target on a real graph.
/// </summary>
static PrismGraph NewGraph( ShaderDomain domain = ShaderDomain.Surface,
ShadingModel shading = ShadingModel.Lit, SurfaceBlendMode blend = SurfaceBlendMode.Opaque )
{
var graph = new PrismGraph();
graph.Meta.Title = "Prism Validation";
graph.Meta.Description = "Generated by PrismValidation.RunEngineValidation().";
graph.Settings.Domain = domain;
graph.Settings.ShadingModel = shading;
graph.Settings.BlendMode = blend;
graph.Settings.SetTarget( PrismConstants.BackendHlsl, true );
graph.Settings.SetTarget( PrismConstants.BackendSlang, true );
graph.IsDirty = false;
return graph;
}
static PrismGraph EmptyGraph()
{
var graph = NewGraph();
Add( graph, SurfaceOutputNode.TypeId, 400f, 0f );
return graph;
}
static PrismGraph MinimalLitGraph()
{
var graph = NewGraph();
var colour = graph.AddNode( new ConstantNode
{
Kind = PrismConstantKind.Color,
ColorValue = new Color( 0.8f, 0.35f, 0.2f ),
Position = new Vector2( 0f, 0f )
} );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 400f, 0f );
Wire( graph, colour, "Out", terminal, "Albedo" );
return graph;
}
static PrismGraph FullLitGraph()
{
var graph = NewGraph();
var roughness = graph.AddParameter( new Parameter( "Roughness", ShaderType.Float )
{
Default = 0.4f,
Ui = new ParameterUi { Control = UiControl.Slider, Min = 0f, Max = 1f, Group = "Surface", Order = 10 }
} );
var uv = Add( graph, "prism.input.texcoord", -640f, 0f );
var albedo = Add( graph, "prism.texture.sample2d", -360f, 0f );
var normal = Add( graph, "prism.texture.sampleNormal", -360f, 240f );
var rough = Add( graph, ParameterReferenceNode.TypeId, -360f, 480f );
var metal = graph.AddNode( new ConstantNode { ScalarValue = 0.25f, Position = new Vector2( -360f, 600f ) } );
var occlusion = graph.AddNode( new ConstantNode { ScalarValue = 0.9f, Position = new Vector2( -360f, 680f ) } );
var emission = graph.AddNode( new ConstantNode
{
Kind = PrismConstantKind.Color,
ColorValue = new Color( 0.1f, 0.05f, 0f ),
Position = new Vector2( -360f, 760f )
} );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 400f, 0f );
if ( rough is not null ) NodeProperties.Set( rough, "Parameter", roughness.Id );
Wire( graph, uv, "UV", albedo, "UV" );
Wire( graph, uv, "UV", normal, "UV" );
Wire( graph, albedo, "RGBA", terminal, "Albedo" );
Wire( graph, albedo, "A", terminal, "Opacity" );
Wire( graph, normal, "Normal", terminal, "Normal" );
Wire( graph, rough, "Out", terminal, "Roughness" );
Wire( graph, metal, "Out", terminal, "Metalness" );
Wire( graph, occlusion, "Out", terminal, "AmbientOcclusion" );
Wire( graph, emission, "Out", terminal, "Emission" );
return graph;
}
static PrismGraph UnlitGraph()
{
var graph = NewGraph( ShaderDomain.Surface, ShadingModel.Unlit );
var uv = Add( graph, "prism.input.texcoord", -320f, 0f );
var colour = Add( graph, "prism.channel.append", -80f, 0f );
var terminal = Add( graph, UnlitOutputNode.TypeId, 400f, 0f );
Wire( graph, uv, "UV", colour, "A" );
Wire( graph, colour, "Out", terminal, "Albedo" );
return graph;
}
static PrismGraph BlendGraph( SurfaceBlendMode blend )
{
var graph = NewGraph( ShaderDomain.Surface, ShadingModel.Lit, blend );
var uv = Add( graph, "prism.input.texcoord", -640f, 0f );
var sample = Add( graph, "prism.texture.sample2d", -360f, 0f );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 400f, 0f );
Wire( graph, uv, "UV", sample, "UV" );
Wire( graph, sample, "RGBA", terminal, "Albedo" );
Wire( graph, sample, "A", terminal, "Opacity" );
return graph;
}
static PrismGraph PostProcessGraph()
{
var graph = NewGraph( ShaderDomain.PostProcess );
var scene = Add( graph, "prism.input.sceneColor", -320f, 0f );
var invert = Add( graph, "prism.color.invert", -80f, 0f );
var terminal = Add( graph, PostProcessOutputNode.TypeId, 400f, 0f );
Wire( graph, scene, "RGBA", invert, "In" );
Wire( graph, invert, "Out", terminal, "Albedo" );
return graph;
}
static PrismGraph VertexOffsetGraph()
{
var graph = NewGraph();
var normal = Add( graph, "prism.input.worldNormal", -640f, 0f );
var time = Add( graph, "prism.input.time", -640f, 160f );
var wave = Add( graph, "prism.math.sin", -400f, 160f );
var offset = Add( graph, "prism.math.multiply", -160f, 0f );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 400f, 0f );
Wire( graph, normal, "Result", offset, "A" );
Wire( graph, time, "Result", wave, "In" );
Wire( graph, wave, "Out", offset, "B" );
Wire( graph, offset, "Out", terminal, "PositionOffset" );
return graph;
}
/// <summary>
/// Every value the engine only computes per vertex, read from the pixel stage. Each one forces the
/// stage planner to move the read into the vertex program and the varying allocator to hand out an
/// interpolator, which is the one part of the pipeline no other fixture reaches: the surface
/// fixtures never leave the pixel stage and the vertex fixture never comes back out of it.
/// </summary>
static PrismGraph VaryingGraph()
{
var graph = NewGraph();
var terminal = Add( graph, SurfaceOutputNode.TypeId, 900f, 0f );
var accumulator = new Accumulator( graph );
var row = 0;
foreach ( var id in new[] { "prism.input.objectOrigin", "prism.input.instanceId", "prism.input.vertexId" } )
{
var node = Add( graph, id, 0f, row++ * 200f );
if ( node?.Outputs.FirstOrDefault() is not { } source ) continue;
accumulator.Add( new PortRef( node.Id, source.Id ) );
}
var value = accumulator.Resolve();
if ( value.IsValid && terminal is not null )
{
graph.Connect( value, new PortRef( terminal.Id, new PortId( "Albedo" ) ) );
// The same value in both stages: the vertex program keeps its own copy and the pixel program
// reads the interpolated one, which is a different lowering from either on its own.
graph.Connect( value, new PortRef( terminal.Id, new PortId( GraphCompiler.PositionOffsetTarget ) ) );
}
return graph;
}
/// <summary>
/// A translucent surface reading the scene behind it. That takes the frame-buffer copy rather than the
/// post-process colour buffer, and the shader has to ask the engine to fill it.
/// </summary>
static PrismGraph SceneColorSurfaceGraph()
{
var graph = NewGraph( ShaderDomain.Surface, ShadingModel.Lit, SurfaceBlendMode.Translucent );
var scene = Add( graph, "prism.input.sceneColor", -320f, 0f );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 400f, 0f );
Wire( graph, scene, "Result", terminal, "Albedo" );
return graph;
}
/// <summary>Recompile a fixture in a different HLSL dialect.</summary>
static PrismGraph Dialect( PrismGraph graph, HlslDialect dialect )
{
if ( graph is not null ) graph.Settings.HlslDialect = dialect;
return graph;
}
/// <summary>Turn a fixture two-sided, which changes both the render state and the normal flip.</summary>
static PrismGraph Backfaces( PrismGraph graph )
{
if ( graph is null ) return null;
graph.Settings.RenderBackfaces = true;
graph.Settings.CullMode = CullMode.None;
return graph;
}
static PrismGraph KeywordGraph( ComboKind kind, int values )
{
var graph = NewGraph();
var keyword = graph.AddKeyword( new Keyword( "PRISM_VALIDATE", kind )
{
Group = "Validation",
Values = values <= 2
? null
: Enumerable.Range( 0, values ).Select( x => "Mode " + x ).ToList()
} );
var branch = Add( graph, KeywordBranchNode.TypeId, 240f, 0f );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 560f, 0f );
if ( branch is not null ) NodeProperties.Set( branch, "Keyword", keyword.Id );
// Both branches carry real work — a texture sample on one side, a noise field on the other — so
// the helper functions and sampler states each side needs have to be declared where both the
// taken and the discarded branch can see them. A branch between two literals proves none of that.
var lit = Add( graph, "prism.texture.sample2d", -480f, -160f );
var dark = Add( graph, "prism.noise.simplex", -480f, 160f );
if ( lit is not null ) FeedInputs( graph, lit );
if ( dark is not null ) FeedInputs( graph, dark );
Reduce( graph, lit, "RGBA", branch, "On", 0 );
Reduce( graph, dark, null, branch, "Off", 1 );
Wire( graph, branch, "Out", terminal, "Albedo" );
return graph;
}
/// <summary>
/// One blackboard parameter of every shape the material UI can show, each actually referenced so the
/// backend declares it. Parameter declarations are where the VFX annotation grammar bites, and a
/// malformed one takes the whole file down before a single line of HLSL is looked at.
/// </summary>
static PrismGraph ParameterGraph()
{
var graph = NewGraph();
var terminal = Add( graph, SurfaceOutputNode.TypeId, 900f, 0f );
var accumulator = new Accumulator( graph );
var row = 0;
void Fold( PortRef produced ) => accumulator.Add( produced );
PrismNode Reference( Parameter parameter )
{
var node = Add( graph, ParameterReferenceNode.TypeId, 0f, row * 120f );
if ( node is null ) return null;
NodeProperties.Set( node, "Parameter", graph.AddParameter( parameter ).Id );
row++;
return node;
}
void Numeric( Parameter parameter )
{
var node = Reference( parameter );
if ( node is null ) return;
Fold( new PortRef( node.Id, new PortId( "Out" ) ) );
}
Numeric( new Parameter( "Scalar", ShaderType.Float )
{
Default = 0.5f,
Ui = new ParameterUi { Control = UiControl.Slider, Min = 0f, Max = 2f, Step = 0.05f, Group = "Numbers", Order = 10 }
} );
Numeric( new Parameter( "Pair", ShaderType.Float2 )
{
Default = new Vector2( 0.25f, 0.75f ),
Ui = new ParameterUi { Control = UiControl.Vector, Group = "Numbers", Order = 20 }
} );
Numeric( new Parameter( "Triple", ShaderType.Float3 )
{
Default = new Vector3( 0.1f, 0.2f, 0.3f ),
Ui = new ParameterUi { Control = UiControl.Vector, Group = "Numbers", Order = 30 }
} );
Numeric( new Parameter( "Tint", ShaderType.Float4 )
{
Default = new Color( 0.9f, 0.6f, 0.3f, 1f ),
Ui = new ParameterUi { Control = UiControl.Color, Group = "Colour", Order = 10 }
} );
Numeric( new Parameter( "Toggle", ShaderType.Bool )
{
Default = true,
Ui = new ParameterUi { Control = UiControl.Toggle, Group = "Switches", Order = 10 }
} );
Numeric( new Parameter( "Steps", ShaderType.Int )
{
Default = 3,
Ui = new ParameterUi { Control = UiControl.Slider, Min = 1f, Max = 8f, Group = "Switches", Order = 20 }
} );
Numeric( new Parameter( "Mode", ShaderType.Int )
{
Default = 1,
Ui = new ParameterUi
{
Control = UiControl.Dropdown,
Group = "Switches",
Order = 30,
Options = new List<string> { "First", "Second", "Third" }
}
} );
// A parameter bound to a render attribute is pushed from code rather than authored in the
// material editor, and the engine rejects UI metadata on one, so this exercises the other path.
Numeric( new Parameter( "Pushed", ShaderType.Float )
{
Default = 0.5f,
AttributeName = "PrismPushed",
Ui = new ParameterUi { Control = UiControl.Slider, Min = 0f, Max = 1f, Group = "Runtime" }
} );
// ---- resources ---------------------------------------------------
var albedoMap = Reference( new Parameter( "Albedo Map", ShaderType.Texture2D )
{
Default = new TextureValue { Path = "materials/default/default_color.tga", ColorSpace = "Srgb" },
Ui = new ParameterUi { Control = UiControl.Texture, Group = "Textures", Order = 10 }
} );
var cubeMap = Reference( new Parameter( "Environment", ShaderType.TextureCube )
{
Default = new TextureValue { Path = "materials/default/default_cube.vtex", ColorSpace = "Srgb" },
Ui = new ParameterUi { Control = UiControl.Texture, Group = "Textures", Order = 20 }
} );
var sampler = Add( graph, "prism.texture.samplerState", 0f, row++ * 120f );
var sample = Add( graph, "prism.texture.sample2d", 280f, 0f );
var uv = Add( graph, "prism.input.texcoord", 0f, row++ * 120f );
Wire( graph, albedoMap, "Out", sample, "Texture" );
Wire( graph, sampler, "Sampler", sample, "Sampler" );
Wire( graph, uv, "UV", sample, "UV" );
Fold( sample is null ? PortRef.None : new PortRef( sample.Id, new PortId( "RGBA" ) ) );
var cube = Add( graph, "prism.texture.sampleCube", 280f, 240f );
var direction = Add( graph, "prism.input.worldNormal", 0f, row++ * 120f );
Wire( graph, cubeMap, "Out", cube, "Texture" );
Wire( graph, direction, "Result", cube, "UV" );
Fold( cube is null ? PortRef.None : new PortRef( cube.Id, new PortId( "RGBA" ) ) );
var value = accumulator.Resolve();
if ( value.IsValid && terminal is not null )
{
graph.Connect( value, new PortRef( terminal.Id, new PortId( "Albedo" ) ) );
}
return graph;
}
/// <summary>
/// A document whose author was not thinking about the block grammar: quotes and newlines in the
/// description, a parameter name that starts with a digit, two names that collide once they are
/// sanitised, punctuation and non-ASCII letters throughout, and a keyword spelled without its prefix.
/// Every one of those has to come out as a legal identifier or a correctly escaped string, and the
/// engine's parser fails the whole file with no line number when one does not.
/// </summary>
static PrismGraph AwkwardNameGraph()
{
var graph = NewGraph();
graph.Meta.Title = "Awkward \"Quoted\" Title";
graph.Meta.Description = "A description with \"quotes\", a\ttab, a newline\nand a trailing backslash \\";
var terminal = Add( graph, SurfaceOutputNode.TypeId, 900f, 0f );
var accumulator = new Accumulator( graph );
var row = 0;
foreach ( var name in new[] { "50% Rough", "Ünïcode Näme", "Base Color", "Base Color", " ", "Out" } )
{
var node = Add( graph, ParameterReferenceNode.TypeId, 0f, row * 120f );
if ( node is null ) continue;
var parameter = graph.AddParameter( new Parameter( name, ShaderType.Float )
{
Default = 0.5f,
Ui = new ParameterUi
{
Control = UiControl.Slider,
Min = 0f,
Max = 1f,
Group = "Awkward \"Group\"",
Order = row,
Tooltip = "A tooltip with \"quotes\" and a , comma"
}
} );
NodeProperties.Set( node, "Parameter", parameter.Id );
accumulator.Add( new PortRef( node.Id, new PortId( "Out" ) ) );
row++;
}
var keyword = graph.AddKeyword( new Keyword( "awkward keyword!", ComboKind.Feature )
{
Group = "Awkward \"Group\""
} );
var branch = Add( graph, KeywordBranchNode.TypeId, 560f, 0f );
var value = accumulator.Resolve();
if ( branch is not null && value.IsValid )
{
NodeProperties.Set( branch, "Keyword", keyword.Id );
graph.Connect( value, new PortRef( branch.Id, new PortId( "On" ) ) );
value = new PortRef( branch.Id, new PortId( "Out" ) );
}
if ( value.IsValid && terminal is not null )
{
graph.Connect( value, new PortRef( terminal.Id, new PortId( "Albedo" ) ) );
}
return graph;
}
/// <summary>A hand-written HLSL snippet with declared ports, which the backend inlines verbatim.</summary>
static PrismGraph CustomCodeGraph()
{
var graph = NewGraph();
var code = new CustomCodeNode
{
FunctionName = "PrismValidationSnippet",
Position = new Vector2( 0f, 0f ),
InputPorts = new List<SubgraphPortInfo>
{
new() { Name = "Value", Type = "float3" },
new() { Name = "Scale", Type = "float" }
},
OutputPorts = new List<SubgraphPortInfo>
{
new() { Name = "Result", Type = "float3" }
},
Hlsl = "Result = normalize( Value + 1e-4f ) * Scale;"
};
// The port table was assigned wholesale rather than through AddInput/AddOutput, so the ports it
// describes do not exist until the node is told to rebuild them.
code.PortsChanged();
graph.AddNode( code );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 400f, 0f );
FeedInputs( graph, code );
Wire( graph, code, "Result", terminal, "Albedo" );
return graph;
}
/// <summary>
/// Every node that can run in the vertex stage, folded into a vertex displacement. The vertex program
/// is the half of the shader the pixel-stage fixtures never touch, and a builtin that only lowers
/// correctly in the pixel stage only shows up here.
/// </summary>
static PrismGraph VertexCombinedGraph()
{
var graph = NewGraph();
var terminal = Add( graph, SurfaceOutputNode.TypeId, 900f, 0f );
if ( terminal is null ) return graph;
var accumulator = new Accumulator( graph );
var row = 0;
foreach ( var type in NodeRegistry.Types )
{
var id = type?.Descriptor?.Id;
if ( string.IsNullOrEmpty( id ) ) continue;
if ( id.StartsWith( "prism.internal.selftest", StringComparison.Ordinal ) ) continue;
var probe = PrismLog.Guard( $"Prism: instantiate '{id}'", type.Create );
if ( probe is null || probe is IPrismOutputNode ) continue;
// Anything that declares a stage requirement not covering the vertex stage belongs to the
// pixel-side fixtures; the point here is the nodes that claim they work in both.
if ( probe is IStageConstrained { RequiredStages: not StageMask.None } constrained &&
!constrained.RequiredStages.Contains( ShaderStage.Vertex ) ) continue;
if ( probe is SubgraphInstanceNode instance && string.IsNullOrEmpty( instance.SubgraphPath ) )
{
instance.SubgraphPath = FixtureSubgraphPath;
instance.Refresh();
}
var source = probe.Outputs.FirstOrDefault();
if ( source is null || source.EffectiveType.IsObject ) continue;
probe.Position = new Vector2( 0f, row++ * 200f );
graph.AddNode( probe );
BindBlackboard( graph, id, probe );
FeedInputs( graph, probe );
accumulator.Add( new PortRef( probe.Id, source.Id ) );
}
var value = accumulator.Resolve();
if ( value.IsValid )
{
graph.Connect( value, new PortRef( terminal.Id, new PortId( GraphCompiler.PositionOffsetTarget ) ) );
}
return graph;
}
/// <summary>
/// Three subgraph instances at once: the same function twice, and a second function that instances
/// the first. Inlining renames locals per instance, so a collision only appears when one document
/// inlines the same body more than once or inlines a body that itself inlines something.
/// </summary>
static PrismGraph SubgraphGraph()
{
var graph = NewGraph();
var uv = Add( graph, "prism.input.texcoord", -640f, 0f );
var append = Add( graph, "prism.channel.append", -400f, 0f );
var terminal = Add( graph, SurfaceOutputNode.TypeId, 700f, 0f );
Wire( graph, uv, "UV", append, "A" );
var accumulator = new Accumulator( graph );
var row = 0;
foreach ( var path in new[] { FixtureSubgraphPath, FixtureSubgraphPath, FixtureNestedSubgraphPath } )
{
var instance = new SubgraphInstanceNode
{
SubgraphPath = path,
Position = new Vector2( -160f, row++ * 200f )
};
graph.AddNode( instance );
instance.Refresh();
Wire( graph, append, "Out", instance, "Value" );
var produced = instance.Outputs.FirstOrDefault();
if ( produced is null ) continue;
accumulator.Add( new PortRef( instance.Id, produced.Id ) );
}
var value = accumulator.Resolve();
if ( value.IsValid && terminal is not null )
{
graph.Connect( value, new PortRef( terminal.Id, new PortId( "Albedo" ) ) );
}
return graph;
}
/// <summary>
/// The smallest document that forces one node type through the whole compiler: every input fed a live
/// value of exactly the right type, the first output reduced to a <c>float3</c> and wired into a real
/// terminal. Mirrors the node sweep, because a fixture that exercises a node differently from the
/// sweep would report a different set of failures.
/// </summary>
static PrismGraph NodeGraph( string id, PrismNode probe )
{
if ( probe is SubgraphInstanceNode bare && string.IsNullOrEmpty( bare.SubgraphPath ) )
{
bare.SubgraphPath = FixtureSubgraphPath;
bare.Refresh();
}
if ( probe is SubgraphOutputNode ) return null;
if ( probe.Inputs.Count == 0 && probe.Outputs.Count == 0 ) return null;
var output = probe as IPrismOutputNode;
var domain = output?.Domain ?? ShaderDomain.Surface;
var shading = id == UnlitOutputNode.TypeId ? ShadingModel.Unlit : ShadingModel.Lit;
var graph = NewGraph( domain, shading );
graph.AddNode( probe );
BindBlackboard( graph, id, probe );
FeedInputs( graph, probe );
if ( output is not null ) return graph;
var source = probe.Outputs.FirstOrDefault();
if ( source is null ) return null;
var reference = new PortRef( probe.Id, source.Id );
if ( source.EffectiveType.IsObject && !BridgeResource( graph, reference, source.EffectiveType, out reference ) )
{
return null;
}
var sink = graph.AddNode( new PrismTestSinkNode { Position = new Vector2( 480f, 0f ) } );
if ( graph.Connect( reference, new PortRef( sink.Id, new PortId( nameof( PrismTestSinkNode.Value ) ) ) ) is null )
{
return null;
}
var terminal = Add( graph, SurfaceOutputNode.TypeId, 760f, 0f );
if ( terminal is null ) return null;
var vertexOnly = probe is IStageConstrained constrained &&
constrained.RequiredStages != StageMask.None &&
!constrained.RequiredStages.Contains( ShaderStage.Pixel );
var target = new PortId( vertexOnly ? GraphCompiler.PositionOffsetTarget : "Albedo" );
if ( terminal.FindInput( target ) is null ) target = terminal.Inputs.First().Id;
graph.Connect( new PortRef( sink.Id, new PortId( nameof( PrismTestSinkNode.Out ) ) ),
new PortRef( terminal.Id, target ) );
return graph;
}
/// <summary>
/// One document containing every node type at once. Helper functions, module globals, sampler states
/// and interpolator slots are all shared per module, so a collision only shows up here.
/// </summary>
static PrismGraph CombinedGraph( bool everyOutput )
{
var graph = NewGraph();
var terminal = Add( graph, SurfaceOutputNode.TypeId, 900f, 0f );
if ( terminal is null ) return graph;
var accumulator = new Accumulator( graph );
var row = 0;
foreach ( var type in NodeRegistry.Types )
{
var id = type?.Descriptor?.Id;
if ( string.IsNullOrEmpty( id ) ) continue;
if ( id.StartsWith( "prism.internal.selftest", StringComparison.Ordinal ) ) continue;
var probe = PrismLog.Guard( $"Prism: instantiate '{id}'", type.Create );
if ( probe is null || probe is IPrismOutputNode ) continue;
if ( probe is IStageConstrained { RequiredStages: not StageMask.None } constrained &&
!constrained.RequiredStages.Contains( ShaderStage.Pixel ) ) continue;
if ( probe is SubgraphInstanceNode instance && string.IsNullOrEmpty( instance.SubgraphPath ) )
{
instance.SubgraphPath = FixtureSubgraphPath;
instance.Refresh();
}
if ( probe.Outputs.Count == 0 ) continue;
var sources = ( everyOutput ? probe.Outputs : probe.Outputs.Take( 1 ) )
.Where( x => x is not null && !x.EffectiveType.IsObject )
.ToArray();
if ( sources.Length == 0 ) continue;
probe.Position = new Vector2( 0f, row++ * 200f );
graph.AddNode( probe );
BindBlackboard( graph, id, probe );
FeedInputs( graph, probe );
foreach ( var source in sources )
{
accumulator.Add( new PortRef( probe.Id, source.Id ) );
}
}
var value = accumulator.Resolve();
if ( value.IsValid )
{
graph.Connect( value, new PortRef( terminal.Id, new PortId( "Albedo" ) ) );
}
return graph;
}
/// <summary>
/// The node values are combined with, and its three ports.
/// <para>
/// Addition, deliberately, and not multiplication. A product is annihilated by a single zero: one
/// node out of three hundred emitting <c>float3( 0, 0, 0 )</c> is enough for the optimizer to
/// correctly fold the whole combination to a constant, and the fixture then compiles a shader that
/// does nothing while still reporting a pass. A sum can lose one term to constant folding; it can
/// never lose the rest.
/// </para>
/// </summary>
const string FoldNodeType = "prism.math.add";
/// <summary>The left operand's port on <see cref="FoldNodeType"/>.</summary>
static readonly PortId FoldLeft = new( "A" );
/// <summary>The right operand's port on <see cref="FoldNodeType"/>.</summary>
static readonly PortId FoldRight = new( "B" );
/// <summary>The combined value on <see cref="FoldNodeType"/>.</summary>
static readonly PortId FoldResult = new( "Out" );
/// <summary>
/// The values a fixture has produced, waiting to be combined into the single value its terminal reads.
/// <para>
/// Two things here matter more than they look. Each value is first reduced to a <c>float3</c> through
/// a sink, so outputs of any shape can meet on one port. And they are combined as a balanced tree,
/// not a running chain: the emitter walks a graph by demanding each input recursively, so a three
/// hundred deep chain of folds is a three hundred deep call stack, while a tree over the same values
/// is nine deep. Nothing a fixture wires up may be eliminated as dead code before the backend sees
/// it, and nothing may cost more stack than the graph itself does.
/// </para>
/// </summary>
sealed class Accumulator
{
readonly PrismGraph _graph;
readonly List<PortRef> _values = new();
int _row;
/// <summary>Start accumulating values in a document.</summary>
public Accumulator( PrismGraph graph ) => _graph = graph;
/// <summary>
/// Reduce one produced value and keep it. A value that cannot be attached is reported rather than
/// silently dropped — a fixture that quietly stopped covering something would keep passing while
/// proving less than it claims.
/// </summary>
public void Add( PortRef produced )
{
if ( _graph is null || !produced.IsValid ) return;
var sink = _graph.AddNode( new PrismTestSinkNode { Position = new Vector2( 320f, _row++ * 120f ) } );
if ( _graph.Connect( produced,
new PortRef( sink.Id, new PortId( nameof( PrismTestSinkNode.Value ) ) ) ) is null )
{
PrismLog.Warn( $"Prism validation: a fixture could not reduce {Describe( _graph, produced )} " +
"— that value is not covered." );
_graph.RemoveNode( sink );
return;
}
_values.Add( new PortRef( sink.Id, new PortId( nameof( PrismTestSinkNode.Out ) ) ) );
}
/// <summary>
/// Combine everything kept into one port, or <see cref="PortRef.None"/> when nothing was kept.
/// Consumes the accumulator: calling it twice would build the tree twice.
/// </summary>
public PortRef Resolve()
{
if ( _values.Count == 0 ) return PortRef.None;
var level = _values;
while ( level.Count > 1 )
{
var merged = new List<PortRef>( ( level.Count + 1 ) / 2 );
for ( int i = 0; i < level.Count; i += 2 )
{
merged.Add( i + 1 < level.Count ? Join( level[i], level[i + 1] ) : level[i] );
}
level = merged;
}
return level[0];
}
/// <summary>Combine two values into one.</summary>
PortRef Join( PortRef left, PortRef right )
{
var fold = PrismValidation.Add( _graph, FoldNodeType, 560f, _row++ * 120f );
if ( fold is null )
{
PrismLog.Warn( $"Prism validation: '{FoldNodeType}' is not registered, so a fixture has no " +
"way to keep more than one value alive." );
return left;
}
var a = _graph.Connect( left, new PortRef( fold.Id, FoldLeft ) ) is not null;
var b = _graph.Connect( right, new PortRef( fold.Id, FoldRight ) ) is not null;
if ( a && b ) return new PortRef( fold.Id, FoldResult );
PrismLog.Warn( "Prism validation: a fixture could not combine two reduced values, so one of " +
"them is not covered." );
_graph.RemoveNode( fold );
return a ? left : right;
}
}
/// <summary>Name a port the way a reader of the log would recognise it.</summary>
static string Describe( PrismGraph graph, PortRef port )
{
var node = graph?.FindNode( port.Node );
return node is null
? port.ToString()
: $"'{node.Descriptor?.Id ?? node.GetType().Name}.{port.Port}' ({node.FindPort( port.Port )?.EffectiveType.Hlsl ?? "?"})";
}
/// <summary>A throwaway one-in one-out subgraph, so the instance node has something real to inline.</summary>
static PrismGraph BuildFixtureSubgraph()
{
var graph = new PrismGraph( true );
graph.Meta.Title = "Prism Validation Function";
graph.Settings.SetTarget( PrismConstants.BackendHlsl, true );
var input = new SubgraphInputNode
{
InputName = "Value",
InputType = "float3",
Position = new Vector2( 0f, 0f )
};
var scale = graph.AddNode( new ConstantNode
{
Kind = PrismConstantKind.Float3,
Vector3Value = new Vector3( 0.5f, 0.25f, 0.125f ),
Position = new Vector2( 0f, 160f )
} );
var multiply = NodeRegistry.Create( "prism.math.multiply" );
var terminal = new SubgraphOutputNode { Position = new Vector2( 480f, 0f ) };
graph.AddNode( input );
if ( multiply is not null )
{
multiply.Position = new Vector2( 240f, 0f );
graph.AddNode( multiply );
}
graph.AddNode( terminal );
var slot = terminal.AddSlot( "Out", "float3" );
var source = new PortRef( input.Id, new PortId( nameof( SubgraphInputNode.Result ) ) );
if ( multiply is not null )
{
graph.Connect( source, new PortRef( multiply.Id, new PortId( "A" ) ) );
graph.Connect( new PortRef( scale.Id, new PortId( "Out" ) ),
new PortRef( multiply.Id, new PortId( "B" ) ) );
source = new PortRef( multiply.Id, new PortId( "Out" ) );
}
graph.Connect( source, new PortRef( terminal.Id, new PortId( slot.PortId ) ) );
graph.IsDirty = false;
return graph;
}
/// <summary>A subgraph that instances another subgraph, so inlining is exercised two levels deep.</summary>
static PrismGraph BuildNestedFixtureSubgraph()
{
var graph = new PrismGraph( true );
graph.Meta.Title = "Prism Validation Function (nested)";
graph.Settings.SetTarget( PrismConstants.BackendHlsl, true );
var input = new SubgraphInputNode
{
InputName = "Value",
InputType = "float3",
Position = new Vector2( 0f, 0f )
};
var inner = new SubgraphInstanceNode
{
SubgraphPath = FixtureSubgraphPath,
Position = new Vector2( 240f, 0f )
};
var terminal = new SubgraphOutputNode { Position = new Vector2( 480f, 0f ) };
graph.AddNode( input );
graph.AddNode( inner );
inner.Refresh();
graph.AddNode( terminal );
var slot = terminal.AddSlot( "Out", "float3" );
var produced = inner.Outputs.FirstOrDefault();
graph.Connect( new PortRef( input.Id, new PortId( nameof( SubgraphInputNode.Result ) ) ),
new PortRef( inner.Id, new PortId( "Value" ) ) );
if ( produced is not null )
{
graph.Connect( new PortRef( inner.Id, produced.Id ),
new PortRef( terminal.Id, new PortId( slot.PortId ) ) );
}
graph.IsDirty = false;
return graph;
}
// ---- fixture plumbing -------------------------------------------------
/// <summary>Create a registered node type and place it. Returns null when the type is not registered.</summary>
static PrismNode Add( PrismGraph graph, string typeId, float x, float y )
{
var node = NodeRegistry.Create( typeId );
if ( node is null ) return null;
node.Position = new Vector2( x, y );
return graph.AddNode( node );
}
/// <summary>
/// Connect two ports. A port that has been renamed out from under a fixture is reported rather than
/// silently dropped — a fixture that quietly stopped wiring anything up would keep passing while
/// proving nothing.
/// </summary>
static void Wire( PrismGraph graph, PrismNode from, string fromPort, PrismNode to, string toPort )
{
if ( graph is null || from is null || to is null )
{
PrismLog.Warn( $"Prism validation: a fixture could not wire '{fromPort}' to '{toPort}' — " +
"one of the nodes is not registered" );
return;
}
if ( from.FindOutput( new PortId( fromPort ) ) is null )
{
PrismLog.Warn( $"Prism validation: '{from.Descriptor?.Id}' has no output '{fromPort}'" );
return;
}
if ( to.FindInput( new PortId( toPort ) ) is null )
{
PrismLog.Warn( $"Prism validation: '{to.Descriptor?.Id}' has no input '{toPort}'" );
return;
}
graph.Connect( new PortRef( from.Id, new PortId( fromPort ) ), new PortRef( to.Id, new PortId( toPort ) ) );
}
/// <summary>
/// Reduce a node's output to a <c>float3</c> through a sink and wire it into one named input, so two
/// sources of different shapes can meet on a port that demands a single type.
/// </summary>
static void Reduce( PrismGraph graph, PrismNode from, string fromPort, PrismNode to, string toPort, int row )
{
if ( graph is null || from is null || to is null ) return;
var source = string.IsNullOrEmpty( fromPort )
? from.Outputs.FirstOrDefault()
: from.FindOutput( new PortId( fromPort ) );
if ( source is null ) return;
var sink = graph.AddNode( new PrismTestSinkNode { Position = new Vector2( -160f, row * 200f ) } );
if ( graph.Connect( new PortRef( from.Id, source.Id ),
new PortRef( sink.Id, new PortId( nameof( PrismTestSinkNode.Value ) ) ) ) is null ) return;
graph.Connect( new PortRef( sink.Id, new PortId( nameof( PrismTestSinkNode.Out ) ) ),
new PortRef( to.Id, new PortId( toPort ) ) );
}
/// <summary>Give a node that references the blackboard something real to reference.</summary>
static void BindBlackboard( PrismGraph graph, string id, PrismNode node )
{
switch ( id )
{
case "prism.parameter.ref":
{
var parameter = graph.AddParameter( new Parameter( "Validation Value", ShaderType.Float3 )
{
Default = new Vector3( 0.25f, 0.5f, 0.75f ),
Ui = new ParameterUi { Control = UiControl.Color, Group = "Validation" }
} );
NodeProperties.Set( node, "Parameter", parameter.Id );
break;
}
case "prism.parameter.keyword":
case "prism.parameter.keywordValue":
{
var keyword = graph.AddKeyword( new Keyword( "VALIDATE", ComboKind.Feature ) { Group = "Validation" } );
NodeProperties.Set( node, "Keyword", keyword.Id );
break;
}
}
}
/// <summary>Wire a live value of exactly the right type into every input the node has.</summary>
static void FeedInputs( PrismGraph graph, PrismNode node )
{
var row = 0;
foreach ( var input in node.Inputs.ToArray() )
{
var type = FeedTypeFor( input );
if ( type.IsVoid ) continue;
var source = graph.AddNode( new PrismTestValueNode
{
ValueType = type.Hlsl,
Position = new Vector2( -320f, row * 120f )
} );
row++;
graph.Connect( new PortRef( source.Id, new PortId( nameof( PrismTestValueNode.Out ) ) ),
new PortRef( node.Id, input.Id ) );
}
}
/// <summary>The type a fixture feeds an input. Opaque resource ports are left for the node to resolve.</summary>
static ShaderType FeedTypeFor( InputPort input )
{
var declared = input.Def.DeclaredType ?? "float";
if ( input.Def.IsGeneric )
{
return string.Equals( declared, TypeRules.TypeVarScalar, StringComparison.Ordinal )
? ShaderType.Float
: ShaderType.Float3;
}
var type = input.Def.FixedType;
return type.IsObject || type.IsStruct || type.IsVoid ? ShaderType.Void : type;
}
/// <summary>Route an opaque resource output into something that consumes it.</summary>
static bool BridgeResource( PrismGraph graph, PortRef source, ShaderType type, out PortRef bridged )
{
bridged = source;
if ( !type.IsTexture && !type.IsSampler ) return false;
if ( type.IsTexture && type.Object != ObjectKind.Texture2D ) return false;
if ( type.Object == ObjectKind.SamplerComparisonState ) return false;
var sampler = Add( graph, "prism.texture.sample2d", 240f, 0f );
if ( sampler is null ) return false;
var portId = new PortId( type.IsSampler ? "Sampler" : "Texture" );
if ( sampler.FindInput( portId ) is null ) return false;
FeedInputs( graph, sampler );
if ( graph.Connect( source, new PortRef( sampler.Id, portId ) ) is null ) return false;
var result = sampler.Outputs.FirstOrDefault( x => x.EffectiveType.IsNumeric );
if ( result is null ) return false;
bridged = new PortRef( sampler.Id, result.Id );
return true;
}
// ---- the engine's compiler --------------------------------------------
/// <summary>
/// Run <c>ShaderCompiler.exe</c> over exactly the files this run generated and fold its output back
/// into the fixture results.
/// </summary>
static void RunCompiler( string compiler, List<ShaderFixtureResult> results, StringBuilder report )
{
var pending = results.Where( x => !string.IsNullOrEmpty( x.Path ) ).ToList();
if ( pending.Count == 0 ) return;
var root = EngineRoot();
if ( string.IsNullOrEmpty( root ) )
{
report.AppendLine( "compiler the engine root could not be resolved — generation only" );
return;
}
// Every path goes on the command line, and Windows caps that at 32k characters, so the run is
// split into batches that comfortably clear the limit. The compiler processes one file at a time
// in any case, so batching costs nothing beyond a second of start-up per batch.
var batch = new List<ShaderFixtureResult>();
var length = 0;
foreach ( var entry in pending )
{
if ( batch.Count > 0 && length + entry.Path.Length + 3 > MaxCommandLine )
{
RunBatch( compiler, root, batch, report );
batch = new List<ShaderFixtureResult>();
length = 0;
}
batch.Add( entry );
length += entry.Path.Length + 3;
}
if ( batch.Count > 0 ) RunBatch( compiler, root, batch, report );
// Anything the compiler never mentioned did not get compiled at all, which is a finding in its
// own right rather than a pass.
foreach ( var entry in pending.Where( x => x.Status == "skip" ) )
{
entry.Detail = "the engine's compiler never reported on this file";
}
// The compiled binaries are the by-product, not the artifact: the answer this run wanted was
// whether they could be produced. Leaving three hundred .shader_c files in the engine install
// would be litter, and the next run forces a recompile anyway.
//
// The sources of everything that passed go with them. They sit inside the engine install, where a
// later bare `ShaderCompiler.exe` would pick all three hundred of them up and compile them again;
// a fixture that passed has already told this run everything it had to say. Anything that did not
// pass stays on disk, because that is the file the reader of this report will want to open.
PrismLog.Guard( "Prism: clear the compiled validation output", () =>
{
var kept = 0;
foreach ( var entry in pending )
{
var compiled = entry.Path + "_c";
if ( File.Exists( compiled ) ) File.Delete( compiled );
if ( !entry.Passed )
{
kept++;
continue;
}
if ( File.Exists( entry.Path ) ) File.Delete( entry.Path );
}
// And the folder itself, when nothing was left in it. The output directory defaults to a
// `prism_validate` folder inside the engine install, and a run that proves everything should
// leave the install exactly as it found it — an empty directory is still a trace.
if ( kept == 0 )
{
var directory = System.IO.Path.GetDirectoryName( pending.FirstOrDefault()?.Path );
if ( !string.IsNullOrEmpty( directory ) && Directory.Exists( directory ) &&
Directory.GetFileSystemEntries( directory ).Length == 0 )
{
Directory.Delete( directory );
}
}
report.AppendLine( kept == 0
? "artifacts removed — every fixture compiled, and the output folder with them"
: $"artifacts {kept} unproven .shader kept for inspection, the rest removed" );
} );
}
/// <summary>Run one batch of fixtures through the compiler and fold its output back into them.</summary>
static void RunBatch( string compiler, string root, List<ShaderFixtureResult> batch, StringBuilder report )
{
var arguments = new StringBuilder( "-f" );
foreach ( var entry in batch ) arguments.Append( " \"" ).Append( entry.Path ).Append( '"' );
var info = new System.Diagnostics.ProcessStartInfo
{
FileName = compiler,
Arguments = arguments.ToString(),
WorkingDirectory = root,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
var output = new StringBuilder();
using ( var process = System.Diagnostics.Process.Start( info ) )
{
if ( process is null )
{
report.AppendLine( "compiler could not be started — generation only" );
return;
}
process.OutputDataReceived += ( _, e ) => { if ( e.Data is not null ) output.AppendLine( e.Data ); };
process.ErrorDataReceived += ( _, e ) => { if ( e.Data is not null ) output.AppendLine( e.Data ); };
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if ( !process.WaitForExit( CompilerTimeoutMs ) )
{
PrismLog.Guard( "Prism: stop the engine shader compiler", process.Kill );
report.AppendLine( $"compiler timed out over {batch.Count} fixtures" );
return;
}
}
Attribute( output.ToString(), batch );
}
/// <summary>
/// Split the compiler's console output into per-file sections and mark each fixture pass or fail.
/// <para>
/// The compiler prints <c>(i/N) <relative path></c> before each file and either
/// <c>Compiled successfully</c> or <c>Compile failed.</c> after it, with any diagnostics in between.
/// </para>
/// </summary>
static void Attribute( string output, List<ShaderFixtureResult> pending )
{
var byFile = new Dictionary<string, ShaderFixtureResult>( StringComparer.OrdinalIgnoreCase );
foreach ( var entry in pending )
{
byFile[System.IO.Path.GetFileName( entry.Path )] = entry;
}
ShaderFixtureResult current = null;
var detail = new StringBuilder();
void Close()
{
if ( current is null ) return;
if ( current.Status == "FAIL" ) current.Detail = detail.ToString().Trim();
current = null;
detail.Clear();
}
foreach ( var raw in output.Split( '\n' ) )
{
var line = raw.TrimEnd( '\r' );
var trimmed = line.Trim();
if ( trimmed.StartsWith( "(", StringComparison.Ordinal ) && trimmed.Contains( ')' ) &&
trimmed.EndsWith( "." + PrismConstants.ShaderExtension, StringComparison.OrdinalIgnoreCase ) )
{
Close();
var name = System.IO.Path.GetFileName( trimmed[( trimmed.IndexOf( ')' ) + 1 )..].Trim() );
if ( byFile.TryGetValue( name, out var match ) )
{
current = match;
current.Status = "pass";
}
continue;
}
if ( current is null ) continue;
if ( trimmed.StartsWith( "Compile failed", StringComparison.OrdinalIgnoreCase ) )
{
current.Status = "FAIL";
continue;
}
if ( trimmed.StartsWith( "Compiled successfully", StringComparison.OrdinalIgnoreCase ) ||
trimmed.StartsWith( "Skipped, up to date", StringComparison.OrdinalIgnoreCase ) )
{
continue;
}
if ( trimmed.Length > 0 ) detail.AppendLine( trimmed );
}
Close();
// The compiler ends by listing the absolute path of everything that failed. That list is the
// authoritative answer, so it wins over the per-file parse above rather than merely agreeing
// with it — a diagnostic printed on a thread of its own must never read as a pass.
foreach ( var raw in output.Split( '\n' ) )
{
var trimmed = raw.Trim();
if ( !trimmed.EndsWith( "." + PrismConstants.ShaderExtension, StringComparison.OrdinalIgnoreCase ) ) continue;
var match = pending.FirstOrDefault( x =>
string.Equals( x.Path, trimmed, StringComparison.OrdinalIgnoreCase ) );
if ( match is null ) continue;
match.Status = "FAIL";
if ( string.IsNullOrWhiteSpace( match.Detail ) ) match.Detail = "the compiler reported this file as failed";
}
}
/// <summary>The engine install root, or null when it cannot be resolved.</summary>
static string EngineRoot()
{
var mounted = PrismLog.Guard<string>( "Prism: resolve the engine root",
() => Editor.FileSystem.Root?.GetFullPath( "/" ) );
if ( !string.IsNullOrEmpty( mounted ) && Directory.Exists( mounted ) ) return TrimSeparator( mounted );
var probe = PrismLog.Guard<string>( "Prism: probe for the engine root",
() => System.AppContext.BaseDirectory );
for ( var directory = probe; !string.IsNullOrEmpty( directory ); )
{
if ( File.Exists( System.IO.Path.Combine( directory, CompilerRelativePath ) ) ) return TrimSeparator( directory );
var parent = System.IO.Path.GetDirectoryName( TrimSeparator( directory ) );
if ( string.Equals( parent, directory, StringComparison.OrdinalIgnoreCase ) ) break;
directory = parent;
}
var environment = PrismLog.Guard<string>( "Prism: read PRISM_SBOX_ROOT",
() => System.Environment.GetEnvironmentVariable( "PRISM_SBOX_ROOT" ) );
return string.IsNullOrEmpty( environment ) || !Directory.Exists( environment )
? null
: TrimSeparator( environment );
}
/// <summary>The engine's shader compiler, or null when it is not there.</summary>
static string FindShaderCompiler()
{
var root = EngineRoot();
if ( string.IsNullOrEmpty( root ) ) return null;
var path = System.IO.Path.Combine( root, CompilerRelativePath );
return File.Exists( path ) ? path : null;
}
static string TrimSeparator( string path ) =>
string.IsNullOrEmpty( path )
? path
: PrismLog.Guard( "Prism: normalise a path", () => System.IO.Path.GetFullPath( path ), path )
.TrimEnd( '/', '\\' );
// ---- entry points -----------------------------------------------------
/// <summary>
/// Regenerate the fixture set, run it through the engine's shader compiler and put the report on the
/// clipboard and in the console.
/// <para>
/// Three hundred shaders take minutes, not seconds, so the run happens off the editor's thread and
/// the report arrives when it is done. Only the clipboard write comes back to the main thread.
/// </para>
/// </summary>
[Menu( "Editor", "Prism/Developer/Validate Shaders Against Engine", "verified", Priority = 102 )]
public static void RunFromMenu()
{
var directory = DefaultOutputDirectory;
PrismLog.Info( $"Prism: validating the generated shaders against the engine. " +
$"Fixtures go to {directory}; this takes several minutes." );
_ = Task.Run( () =>
{
var report = PrismLog.Guard( "Prism: engine validation",
() => RunEngineValidation( directory ), "Engine validation could not be run." );
PrismLog.Info( report );
PrismLog.Guard( "Prism: copy the validation report",
() => MainThread.Queue( () => PrismSelfTest.CopyReport( report, "engine validation" ) ) );
} );
}
/// <summary>Run the engine validation from the developer console: <c>prism_validate</c>.</summary>
[ConCmd( "prism_validate" )]
public static void RunFromConsole() => RunFromMenu();
}