Editor service that manages generating, debouncing, writing, compiling and hot-reloading shaders from a Prism graph. It coordinates main-thread model access, workspace scratch files, calls the engine shader compiler, collects and remaps diagnostics, optionally validates Slang output, and publishes compile results to the editor UI.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Sandbox.Engine.Shaders;
using System.Diagnostics;
using System.Text;
namespace Editor.Prism.Toolchain;
/// <summary>
/// Owns the whole "graph changed, show me pixels" loop: generate, diff, write, compile, hot-reload and
/// publish.
/// <para>
/// Four mechanisms keep it fast enough to feel live. Requests are debounced by
/// <see cref="PrismConstants.CompileDebounceMs"/> and coalesced to a single pending slot, so a burst of
/// edits costs one compile. The generated text is byte-compared against the last one that compiled, and
/// an identical shader skips the engine entirely — which is what makes dragging a slider free, because
/// in preview mode literals become uniforms and the text does not change. A structural change cancels
/// the compile already in flight instead of queueing behind it. And preview shaders declare the minimum
/// combo set, because static combos multiply compile time and nothing in the viewport needs the depth
/// or shading-complexity passes.
/// </para>
/// <para>
/// Thread discipline is deliberate and load-bearing. The graph is model state, so it is only ever read
/// on the main thread. The engine compile blocks its calling thread inside a <c>Parallel.ForEach</c>,
/// so it runs on the thread pool. <c>AssetSystem.RegisterFile</c> asserts the main thread, so it is
/// marshalled back. And <c>mat_reloadshaders</c> runs after the asset is up to date and before anyone
/// creates a material, because skipping it binds the previous binary.
/// </para>
/// </summary>
public sealed class ShaderCompileService : IDisposable
{
/// <summary>How long to wait for the asset system to notice a freshly compiled shader.</summary>
const int AssetWaitMs = 5000;
/// <summary>Polling interval while waiting for the asset system.</summary>
const int AssetPollMs = 16;
/// <summary>Programs the engine recognises, in the order its own scanner looks for them.</summary>
static readonly (string Block, string Program)[] s_programs =
{
("VS", "VFX_PROGRAM_VS"),
("PS", "VFX_PROGRAM_PS"),
("GS", "VFX_PROGRAM_GS"),
("CS", "VFX_PROGRAM_CS"),
("PS_RENDER_STATE", "VFX_PROGRAM_PS_RENDER_STATE"),
("RTX", "VFX_PROGRAM_RTX")
};
readonly SemaphoreSlim _gate = new( 1, 1 );
readonly object _state = new();
/// <summary>
/// Serialises every Prism compile that reaches the engine's shader compiler, process-wide. See
/// <c>CompileWithEngine</c> for why this cannot be the per-service gate.
/// </summary>
static readonly SemaphoreSlim s_engineGate = new( 1, 1 );
PrismGraph _pendingGraph;
CompileMode _pendingMode = CompileMode.Preview;
CancellationTokenSource _inFlight;
ulong _inFlightStructure;
int _generation;
int _running;
int _force;
int _released;
volatile bool _disposed;
string _lastText;
IReadOnlyList<Diagnostic> _lastEngineDiagnostics = Array.Empty<Diagnostic>();
static readonly List<WeakReference<ShaderCompileService>> s_live = new();
/// <summary>Create a service with its own scratch session, so two windows never collide.</summary>
public ShaderCompileService( string sessionId = null )
{
Workspace = new TempWorkspace( sessionId );
// A crashed editor leaves its session behind and nobody ever comes back for it.
PrismLog.Guard( "Collecting stale Prism scratch sessions",
() => TempWorkspace.CollectGarbage( PrismConstants.TempSessionLifetimeHours, Workspace.SessionId ) );
lock ( s_live )
{
s_live.RemoveAll( x => !x.TryGetTarget( out _ ) );
s_live.Add( new WeakReference<ShaderCompileService>( this ) );
}
}
/// <summary>
/// Cancel every compile running anywhere in the editor and sweep the scratch folders they were
/// writing into.
/// <para>
/// This is what hotload calls. A compile in flight across an assembly reload is holding a graph, a
/// backend and a set of delegates that are all about to be replaced underneath it, and its callbacks
/// would land on an editor that no longer exists — so it is cancelled rather than left to finish.
/// Held weakly, so a window that was closed without disposing its service cannot keep it alive.
/// </para>
/// </summary>
public static int CancelAll()
{
var targets = new List<ShaderCompileService>();
lock ( s_live )
{
s_live.RemoveAll( x => !x.TryGetTarget( out _ ) );
foreach ( var reference in s_live )
{
if ( reference.TryGetTarget( out var service ) ) targets.Add( service );
}
}
var cancelled = 0;
foreach ( var service in targets )
{
if ( service._disposed ) continue;
PrismLog.Guard( "Cancelling a compile for hotload", service.Cancel );
cancelled++;
}
return cancelled;
}
// ---- contract --------------------------------------------------------
/// <summary>Raised on the main thread when a compile actually starts, after the debounce.</summary>
public event Action Started;
/// <summary>Raised on the main thread with every published result, successful or not.</summary>
public event Action<CompileResult> Completed;
/// <summary>
/// True while a compile is running.
/// <para>
/// Backed by a counter rather than a flag, and moved only inside the try/finally that owns the
/// compile. A stuck "compiling" spinner is one of the few failure modes a user cannot recover from
/// without closing the window, so it has to be impossible for an early return, a cancellation or an
/// exception to leave this set.
/// </para>
/// </summary>
public bool IsCompiling => Volatile.Read( ref _running ) > 0;
/// <summary>The most recent result, or null before the first compile.</summary>
public CompileResult Last { get; private set; }
/// <summary>
/// Path of the last successfully compiled preview shader, relative to <c>Editor.FileSystem.Root</c>.
/// </summary>
public string LastShaderPath { get; private set; }
/// <summary>
/// Debounced and coalesced. Safe to call on every graph edit — twenty edits inside the debounce
/// window cost one compile, and a structural change cancels whatever is already running.
/// </summary>
public void Request( PrismGraph graph, CompileMode mode = CompileMode.Preview )
{
if ( _disposed || graph is null ) return;
var structure = PrismLog.Guard( "Hashing the graph", () => graph.StructureHash, 0UL );
lock ( _state )
{
_pendingGraph = graph;
_pendingMode = mode;
// A value-only change is answered by render attributes, so the compile in flight is still
// the right one. A structural change makes it obsolete before it finishes.
if ( _inFlight is not null && _inFlightStructure != structure )
{
PrismLog.Guard( "Cancelling a superseded compile", () => _inFlight.Cancel() );
}
}
var generation = Interlocked.Increment( ref _generation );
_ = Debounced( generation );
}
/// <summary>
/// Compile right now, skipping the debounce, and hand back the result. Await it — blocking on the
/// returned task from the main thread deadlocks, because part of the work is marshalled back to it.
/// </summary>
public Task<CompileResult> RequestNow( PrismGraph graph, CompileMode mode = CompileMode.Preview,
CancellationToken ct = default )
{
if ( _disposed || graph is null )
{
return Task.FromResult( Fail( "There is nothing to compile" ) );
}
lock ( _state )
{
_pendingGraph = null;
}
Interlocked.Increment( ref _generation );
return Guarded( graph, mode, ct, 0 );
}
/// <summary>Cancel the compile in flight and forget anything waiting behind it.</summary>
public void Cancel()
{
lock ( _state )
{
_pendingGraph = null;
PrismLog.Guard( "Cancelling the shader compile", () => _inFlight?.Cancel() );
}
Interlocked.Increment( ref _generation );
}
/// <summary>
/// Cancel everything, sweep the scratch folder and remove the session.
/// <para>
/// The scratch folder and the gate are only torn down once nothing is inside the compiler. A compile
/// runs on a thread-pool thread and cannot be interrupted synchronously — cancellation is
/// cooperative and the engine's own <c>CompileShader</c> checks it between combos — so freeing the
/// workspace it is writing into, and the semaphore it still holds, is a use-after-free the running
/// compile would report as a spurious failure. When one is still in flight the teardown is handed to
/// it: the compile's own <c>finally</c> calls <see cref="ReleaseResources"/> on the way out.
/// </para>
/// </summary>
public void Dispose()
{
if ( _disposed ) return;
_disposed = true;
Cancel();
lock ( s_live )
{
s_live.RemoveAll( x => !x.TryGetTarget( out var service ) || ReferenceEquals( service, this ) );
}
ReleaseResources();
}
/// <summary>
/// Free the scratch session and the compile gate, but only once no compile is inside them. Called
/// from <see cref="Dispose"/> and again from the last compile to leave after a disposal, so
/// whichever happens second does the work. Idempotent.
/// </summary>
void ReleaseResources()
{
if ( !_disposed ) return;
if ( Volatile.Read( ref _running ) > 0 ) return;
if ( Interlocked.Exchange( ref _released, 1 ) != 0 ) return;
PrismLog.Guard( "Disposing the shader compile service", () =>
{
Workspace?.Dispose();
_gate.Dispose();
} );
}
// ---- knobs -----------------------------------------------------------
/// <summary>The scratch session this service writes into.</summary>
public TempWorkspace Workspace { get; }
/// <summary>Debounce in milliseconds. Zero compiles on the next tick.</summary>
public int DebounceMs { get; set; } = PrismConstants.CompileDebounceMs;
/// <summary>
/// Whether a preview compile declares only the <c>Forward</c> mode. Static combos multiply compile
/// time and the viewport renders one pass, so this is on by default.
/// </summary>
public bool MinimalPreviewCombos { get; set; } = true;
/// <summary>Whether to run <c>mat_reloadshaders</c> after a successful compile. Off only for tests.</summary>
public bool ReloadShaders { get; set; } = true;
/// <summary>
/// Makes the next compile ignore the byte-compare short circuit and go all the way to the engine
/// compiler, then clears itself.
/// <para>
/// Identical text cannot compile differently, so skipping it is normally right. It stops being right
/// when something outside the graph changed — an included file was edited, a texture was reimported,
/// or the user simply does not believe the cached result. That is what Compile ▸ Force Recompile is
/// for, and without this flag it had no way to actually mean it.
/// </para>
/// <para>
/// Setting it also cancels whatever compile is already in flight, because that compile started before
/// the user asked for a fresh one and satisfying the request with it would be a lie. The flag is
/// consumed atomically by exactly one compile, so two windows or two clicks cannot both clear it and
/// leave neither of them actually forced.
/// </para>
/// </summary>
public bool ForceRegenerate
{
get => Volatile.Read( ref _force ) != 0;
set
{
Interlocked.Exchange( ref _force, value ? 1 : 0 );
if ( !value || _disposed ) return;
lock ( _state )
{
PrismLog.Guard( "Cancelling a compile a forced recompile superseded", () => _inFlight?.Cancel() );
}
}
}
/// <summary>Take the force flag, if it is set. Exactly one caller ever sees it as true.</summary>
bool ConsumeForceRegenerate() => Interlocked.Exchange( ref _force, 0 ) != 0;
/// <summary>
/// Whether to also run the emitted Slang module past a validator. Off for the preview loop: it is a
/// second process and Tier 2 never gates rendering.
/// </summary>
public bool ValidateSlang { get; set; }
/// <summary>
/// The Slang validator. Defaults to whatever <see cref="SlangToolchain"/> found, which is
/// <see cref="NullSlangValidator"/> when there is nothing installed.
/// </summary>
public ISlangValidator SlangValidator
{
get => field ??= NullSlangValidator.Instance;
set;
}
/// <summary>Wall time of the last compile, in milliseconds.</summary>
public double LastCompileMs { get; private set; }
/// <summary>True when the last engine compile was skipped because nothing had changed.</summary>
public bool LastWasSkipped { get; private set; }
/// <summary>
/// The preview shader as the runtime resolves it — the argument for <c>mat_reloadshaders</c> and
/// <c>Material.Create</c>. Null until something has compiled.
/// </summary>
public string PreviewShaderPath { get; private set; }
/// <summary>A stable material name for the preview, unique per session.</summary>
public string PreviewMaterialName => $"prism_preview_{Workspace?.SessionId ?? "0"}";
// ---- the loop --------------------------------------------------------
async Task Debounced( int generation )
{
try
{
var delay = Math.Max( 0, DebounceMs );
if ( delay > 0 ) await Task.Delay( delay ).ConfigureAwait( false );
}
catch ( Exception )
{
return;
}
// Somebody asked again while we waited; that request owns the compile.
if ( Volatile.Read( ref _generation ) != generation ) return;
PrismGraph graph;
CompileMode mode;
lock ( _state )
{
graph = _pendingGraph;
mode = _pendingMode;
}
if ( graph is null || _disposed ) return;
await Guarded( graph, mode, CancellationToken.None, generation ).ConfigureAwait( false );
}
async Task<CompileResult> Guarded( PrismGraph graph, CompileMode mode, CancellationToken external,
int generation )
{
var acquired = false;
var entered = false;
CancellationTokenSource cts = null;
try
{
await _gate.WaitAsync( external ).ConfigureAwait( false );
acquired = true;
// Waiting for the gate can take as long as a compile, and by then a newer request may own
// the work. Doing it again would be correct but wasteful.
if ( generation != 0 && Volatile.Read( ref _generation ) != generation ) return Last;
if ( _disposed ) return Last;
cts = external.CanBeCanceled
? CancellationTokenSource.CreateLinkedTokenSource( external )
: new CancellationTokenSource();
lock ( _state )
{
_inFlight = cts;
_inFlightStructure = PrismLog.Guard( "Hashing the graph", () => graph.StructureHash, 0UL );
}
Interlocked.Increment( ref _running );
entered = true;
await OnMainThread( () =>
{
PrismLog.Guard( "Raising ShaderCompileService.Started", () => Started?.Invoke() );
}, cts.Token ).ConfigureAwait( false );
return await Run( graph, mode, cts.Token ).ConfigureAwait( false );
}
catch ( OperationCanceledException )
{
// A cancelled compile must not leave anything behind for the next one to trip over: the
// engine's resource compiler may have been killed halfway through writing its output.
PrismLog.Guard( "Sweeping after a cancelled compile", () => { Workspace?.Sweep(); } );
// A half-written artifact means the text on disk no longer matches what we think compiled,
// so the byte-compare short circuit has to be disarmed or the next compile would skip.
_lastText = null;
return Last;
}
catch ( Exception e )
{
PrismLog.Error( e, "The shader compile service failed" );
PrismLog.Guard( "Sweeping after a failed compile", () => { Workspace?.Sweep(); } );
_lastText = null;
return await Publish( Fail( $"The compile service failed unexpectedly: {e.Message}" ) )
.ConfigureAwait( false );
}
finally
{
if ( entered ) Interlocked.Decrement( ref _running );
lock ( _state )
{
if ( ReferenceEquals( _inFlight, cts ) ) _inFlight = null;
}
cts?.Dispose();
// Released unconditionally. Skipping the release when the service had been disposed under a
// running compile left the semaphore permanently short one count, so every later waiter
// blocked — and disposal is exactly when the gate most needs to drain. The guard swallows
// the ObjectDisposedException in the one case where Dispose won the race.
if ( acquired )
{
PrismLog.Guard( "Releasing the compile gate", () => { _gate.Release(); } );
}
// If the service was disposed while this compile was running, Dispose deliberately left the
// workspace and the gate alone. This is the last compile out, so it does the teardown.
ReleaseResources();
}
}
async Task<CompileResult> Run( PrismGraph graph, CompileMode mode, CancellationToken ct )
{
var timer = Stopwatch.StartNew();
// ---- 1. generate, on the thread that owns the model ----------------
var generated = await OnMainThread( () => Generate( graph, mode, ct ), ct )
.ConfigureAwait( false );
ct.ThrowIfCancellationRequested();
if ( generated is null )
{
return await Publish( Fail( "The graph produced no result" ), timer, ct ).ConfigureAwait( false );
}
var text = generated.ShaderText;
if ( !generated.Ok || string.IsNullOrWhiteSpace( text ) )
{
// Tier 0 already failed, so there is nothing worth handing to the real compiler.
return await Publish( generated, timer, ct ).ConfigureAwait( false );
}
if ( !Workspace.IsValid )
{
return await Publish( With( generated, new[]
{
Diagnostic.Info( DiagnosticCode.CompilerRaw,
"The shader was generated but not compiled",
null, "Prism could not create its scratch folder under .source2/temp, so the engine " +
"compiler was skipped. The generated code is still available in the Code panel." )
} ), timer, ct ).ConfigureAwait( false );
}
// ---- 2. the short circuit ------------------------------------------
var fileName = FileNameFor( mode );
// One-shot, and consumed here rather than by the caller so a forced compile cannot leak into the
// next one and defeat the short circuit for the rest of the session.
if ( ConsumeForceRegenerate() ) _lastText = null;
if ( string.Equals( text, _lastText, StringComparison.Ordinal ) && Workspace.Exists( fileName ) )
{
// Byte-identical output cannot compile differently. Re-attach what the engine said last time
// so the diagnostics panel does not blink empty.
LastWasSkipped = true;
return await Publish( With( generated, _lastEngineDiagnostics ), timer, ct ).ConfigureAwait( false );
}
LastWasSkipped = false;
if ( !Workspace.Write( fileName, text ) )
{
return await Publish( With( generated, new[]
{
Diagnostic.Error( DiagnosticCode.CompilerRaw, "The generated shader could not be written to disk" )
} ), timer, ct ).ConfigureAwait( false );
}
ct.ThrowIfCancellationRequested();
// ---- 3. the real compiler -------------------------------------------
var relative = Workspace.Relative( fileName );
var engine = await CompileWithEngine( relative, fileName, text, generated, ct ).ConfigureAwait( false );
ct.ThrowIfCancellationRequested();
_lastEngineDiagnostics = engine.Diagnostics;
LastWasSkipped = engine.Skipped;
if ( engine.Ok )
{
_lastText = text;
LastShaderPath = relative;
PreviewShaderPath = Workspace.ContentPath( fileName );
await Reload( fileName, ct ).ConfigureAwait( false );
}
else
{
// Never let a failed compile look identical to the next attempt.
_lastText = null;
}
// ---- 4. the optional second opinion ---------------------------------
var slang = await ValidateSlangModule( generated, ct ).ConfigureAwait( false );
var result = With( generated, engine.Diagnostics.Concat( slang ).ToArray(), engine.Ok );
result = WithPreprocessed( result, engine.Preprocessed );
return await Publish( result, timer, ct ).ConfigureAwait( false );
}
/// <summary>Run the Prism compiler. Called on the main thread, because it reads the model.</summary>
CompileResult Generate( PrismGraph graph, CompileMode mode, CancellationToken ct )
{
return PrismLog.Guard( "Generating the shader", () =>
{
var request = GraphCompiler.RequestFor( graph, mode );
if ( request is null ) return null;
request = request with { Cancellation = ct };
// Preview shaders declare the minimum combo set: every extra static combo is another full
// permutation compiled, and the viewport only ever renders the forward pass.
if ( mode == CompileMode.Preview && MinimalPreviewCombos )
{
request = request with
{
Graph = new PreviewGraph( graph, GraphSettings.PreviewModes ),
Targets = new[] { PrismConstants.BackendHlsl }
};
}
return GraphCompiler.Compile( request );
}, null );
}
/// <summary>What the engine's compiler said.</summary>
readonly record struct EngineOutcome( bool Ok, bool Skipped, IReadOnlyList<Diagnostic> Diagnostics )
{
/// <summary>
/// The text the engine actually handed its compiler, per program, with every include pasted in
/// and every combo resolved. This is the only place it exists — the engine keeps it inside the
/// compile results and never writes it anywhere — so it is captured here and carried out to the
/// Code panel's Preprocessed tab.
/// </summary>
public string Preprocessed { get; init; }
}
async Task<EngineOutcome> CompileWithEngine( string relativePath, string fileName, string generatedText,
CompileResult generated, CancellationToken ct )
{
var options = new ShaderCompileOptions
{
// true bypasses the on-disk .vshadercache and turns a lit shader into a multi-second wait.
ForceRecompile = false,
// The editor console is not where a shader editor's diagnostics belong.
ConsoleOutput = false,
SingleThreaded = false
};
ShaderCompile.Results results = null;
string failure = null;
// One Prism compile inside the engine's compiler at a time, across the whole process. The gate
// below is deliberately static: _gate is per service and every PrismSession builds its own, so
// two open documents would otherwise drive `Shader.LoadFromSource` and
// `ShaderCompile.GetSharedContext` — a process-global native handle and a shared per-program
// context — concurrently from two thread-pool threads. Nothing in the managed layer documents
// either as thread-safe, and every engine call site reaches them from the main thread.
await s_engineGate.WaitAsync( ct ).ConfigureAwait( false );
try
{
// Off the UI thread deliberately: ProgramSource.Compile blocks its caller inside a
// Parallel.ForEach, so the engine's own tools freeze for the length of a cold compile.
results = await Task.Run( () =>
EditorUtility.CompileShader( Editor.FileSystem.Root, relativePath, options, ct ), ct )
.ConfigureAwait( false );
}
catch ( OperationCanceledException )
{
throw;
}
catch ( Exception e )
{
PrismLog.Error( e, "The engine shader compiler threw" );
failure = e.Message;
}
finally
{
PrismLog.Guard( "Releasing the engine compile gate", () => { s_engineGate.Release(); } );
}
var diagnostics = new List<Diagnostic>();
var seen = new HashSet<string>( StringComparer.Ordinal );
void Add( Diagnostic diagnostic )
{
if ( diagnostic is null ) return;
// The same COMMON-block error is reported once per program; show it once.
if ( !seen.Add( $"{diagnostic.Severity}|{diagnostic.Code}|{diagnostic.Span}|{diagnostic.Message}" ) ) return;
diagnostics.Add( diagnostic );
}
if ( failure is not null )
{
Add( Diagnostic.Error( DiagnosticCode.CompilerRaw,
"The engine shader compiler failed", null, failure ) );
return new EngineOutcome( false, false, diagnostics );
}
if ( results is null )
{
Add( Diagnostic.Error( DiagnosticCode.CompilerRaw, "The engine shader compiler returned nothing" ) );
return new EngineOutcome( false, false, diagnostics );
}
var programs = results.Programs ?? new List<ShaderCompile.Results.Program>();
// The worst failure mode the engine has: the block header did not parse, so LoadFromSource
// returned false, Programs is empty and the real message went only to the native log.
if ( !results.Success && programs.Count == 0 )
{
Add( CompilerOutputParser.BlockHeaderFailure( fileName ) );
return new EngineOutcome( false, false, diagnostics );
}
var sourceMap = generated?.Artifact( PrismConstants.BackendHlsl )?.SourceMap;
var preprocessed = new StringBuilder();
foreach ( var program in programs )
{
// Captured for every program, including the ones that compiled cleanly: the Preprocessed tab
// is most useful precisely when the generated code looks right and the expansion does not.
if ( !string.IsNullOrEmpty( program?.Source ) )
{
if ( preprocessed.Length > 0 ) preprocessed.AppendLine();
preprocessed.AppendLine( $"// ==== {CompilerOutputParser.Pretty( program.Name )} ====" );
preprocessed.AppendLine( program.Source );
}
if ( program?.Output is not { Count: > 0 } ) continue;
// Every program has its own preprocessed text, so every program has its own line map.
var map = LineDirectiveMap.Build( program.Source, fileName ).Calibrate( generatedText );
var parsed = CompilerOutputParser.Parse( program.Output, fileName );
var stage = CompilerOutputParser.Pretty( program.Name );
foreach ( var diagnostic in map.RemapAll( parsed, sourceMap, fileName ) )
{
Add( string.IsNullOrEmpty( stage )
? diagnostic
: diagnostic with
{
Detail = string.IsNullOrWhiteSpace( diagnostic.Detail )
? $"Reported while compiling {stage}."
: $"{diagnostic.Detail}\nReported while compiling {stage}."
} );
}
}
// On the first failing combo the engine skips every remaining combo and every remaining program,
// so a vertex error hides all the pixel errors. Say so rather than implying the rest is clean.
if ( !results.Success )
{
var compiled = new HashSet<string>( programs.Select( x => x?.Name ?? string.Empty ),
StringComparer.OrdinalIgnoreCase );
var blocker = CompilerOutputParser.Pretty( programs.LastOrDefault( x => x is { Success: false } )?.Name );
foreach ( var (block, program) in s_programs )
{
if ( !DeclaresBlock( generatedText, block ) || compiled.Contains( program ) ) continue;
Add( CompilerOutputParser.ProgramSkipped( program, blocker ) );
}
}
return new EngineOutcome( results.Success, results.Skipped, diagnostics )
{
Preprocessed = preprocessed.Length > 0 ? preprocessed.ToString() : null
};
}
/// <summary>
/// Register the freshly written shader, wait for the asset system to catch up, then reload it. The
/// order matters: creating a material before <c>mat_reloadshaders</c> binds the previous binary.
/// </summary>
async Task Reload( string fileName, CancellationToken ct )
{
if ( !ReloadShaders ) return;
var absolute = Workspace.Absolute( fileName );
var content = Workspace.ContentPath( fileName );
if ( string.IsNullOrWhiteSpace( absolute ) ) return;
var asset = await OnMainThread( () => PrismLog.Guard( "Registering the preview shader",
() => AssetSystem.RegisterFile( absolute ), null ), ct ).ConfigureAwait( false );
if ( asset is not null )
{
var waited = Stopwatch.StartNew();
while ( waited.ElapsedMilliseconds < AssetWaitMs )
{
ct.ThrowIfCancellationRequested();
var ready = await OnMainThread( () => PrismLog.Guard( "Polling the preview shader asset",
() => asset.IsCompiledAndUpToDate, true ), ct ).ConfigureAwait( false );
if ( ready ) break;
await Task.Delay( AssetPollMs, ct ).ConfigureAwait( false );
}
}
await OnMainThread( () =>
{
PrismLog.Guard( "Reloading the preview shader",
() => ConsoleSystem.Run( $"mat_reloadshaders {content}" ) );
}, ct ).ConfigureAwait( false );
}
async Task<IReadOnlyList<Diagnostic>> ValidateSlangModule( CompileResult generated, CancellationToken ct )
{
if ( !ValidateSlang ) return Array.Empty<Diagnostic>();
var validator = SlangValidator;
if ( validator is null || !validator.Available ) return Array.Empty<Diagnostic>();
var slang = generated?.SlangText;
if ( string.IsNullOrWhiteSpace( slang ) ) return Array.Empty<Diagnostic>();
// The prelude has to sit beside the module for `import` to resolve it.
var artifact = generated.Artifact( PrismConstants.BackendSlang );
foreach ( var extra in artifact?.Extra ?? Array.Empty<Compiler.Backends.GeneratedArtifact>() )
{
Workspace.Write( extra.FileName, extra.Text );
}
var moduleFile = $"preview.{PrismConstants.SlangExtension}";
if ( !Workspace.Write( moduleFile, slang ) ) return Array.Empty<Diagnostic>();
var request = new SlangValidationRequest
{
FilePath = Workspace.Absolute( moduleFile ),
DisplayName = moduleFile,
IncludePaths = new[] { Workspace.RootAbsolute },
EntryPoints = new[]
{
new SlangEntryPoint( PrismConstants.EntryPointVertex, ShaderStage.Vertex ),
new SlangEntryPoint( PrismConstants.EntryPointPixel, ShaderStage.Pixel )
}
};
try
{
return await validator.Validate( request, ct ).ConfigureAwait( false );
}
catch ( OperationCanceledException )
{
throw;
}
catch ( Exception e )
{
PrismLog.Error( e, "Slang validation failed" );
return Array.Empty<Diagnostic>();
}
}
// ---- publishing ------------------------------------------------------
async Task<CompileResult> Publish( CompileResult result, Stopwatch timer = null,
CancellationToken ct = default )
{
if ( timer is not null ) LastCompileMs = timer.Elapsed.TotalMilliseconds;
Last = result;
// A service whose window has already closed must not raise Completed. Closing a window while a
// compile is in flight otherwise delivers a result to a torn-down session — and because the
// debounce loop is fire-and-forget, anything the handler throws becomes an unobserved task
// exception rather than a diagnostic.
if ( _disposed ) return result;
await OnMainThread( () =>
{
if ( _disposed ) return;
PrismLog.Guard( "Raising ShaderCompileService.Completed", () => Completed?.Invoke( result ) );
PrismLog.Guard( "Raising the compile-finished editor event",
() => EditorEvent.Run( PrismConstants.EventCompileFinished, LastShaderPath ?? string.Empty ) );
}, ct ).ConfigureAwait( false );
return result;
}
static CompileResult Fail( string message ) =>
CompileResult.Failed( new[] { Diagnostic.Error( DiagnosticCode.CompilerRaw, message ) } );
/// <summary>Append diagnostics to a result, optionally overriding whether it succeeded.</summary>
static CompileResult With( CompileResult result, IReadOnlyList<Diagnostic> extra, bool? ok = null )
{
if ( result is null ) return null;
if ( ( extra is null || extra.Count == 0 ) && ok is null ) return result;
var diagnostics = extra is { Count: > 0 }
? result.Diagnostics.Concat( extra ).ToArray()
: result.Diagnostics;
var succeeded = ( ok ?? result.Ok ) &&
!diagnostics.Any( x => x.Severity == DiagnosticSeverity.Error );
return result with { Diagnostics = diagnostics, Ok = succeeded };
}
/// <summary>
/// Attach the engine's preprocessed text to the HLSL artifact as an extra file.
/// <para>
/// It rides along as a <see cref="GeneratedArtifact"/> rather than as a new field on
/// <see cref="CompileResult"/>, which is a frozen type — and it is marked as not belonging beside
/// the document, because it is a diagnostic view of what the compiler saw, not something anybody
/// wants appearing in their content folder on every save.
/// </para>
/// </summary>
static CompileResult WithPreprocessed( CompileResult result, string preprocessed )
{
if ( result?.Artifacts is null || string.IsNullOrEmpty( preprocessed ) ) return result;
var hlsl = result.Artifact( PrismConstants.BackendHlsl );
if ( hlsl is null ) return result;
var extra = new List<GeneratedArtifact>( hlsl.Extra ?? (IReadOnlyList<GeneratedArtifact>)Array.Empty<GeneratedArtifact>() )
{
new( $"prism.preprocessed.{PrismConstants.HlslExtension}", preprocessed )
{
BesideDocument = false
}
};
var artifacts = new Dictionary<string, BackendEmitResult>( StringComparer.Ordinal );
foreach ( var pair in result.Artifacts ) artifacts[pair.Key] = pair.Value;
artifacts[PrismConstants.BackendHlsl] = hlsl with { Extra = extra };
return result with { Artifacts = artifacts };
}
// ---- helpers ---------------------------------------------------------
/// <summary>The scratch file a mode writes to. One name per mode, overwritten in place.</summary>
static string FileNameFor( CompileMode mode ) => mode switch
{
CompileMode.Thumbnail => $"thumbs.{PrismConstants.ShaderExtension}",
CompileMode.Final => $"final.{PrismConstants.ShaderExtension}",
_ => $"preview.{PrismConstants.ShaderExtension}"
};
/// <summary>
/// True when the generated text opens a program block. This mirrors the engine's own scanner, which
/// only recognises a block when the trimmed line is exactly the keyword.
/// </summary>
static bool DeclaresBlock( string text, string block )
{
if ( string.IsNullOrEmpty( text ) ) return false;
foreach ( var line in text.Split( '\n' ) )
{
if ( string.Equals( line.Trim(), block, StringComparison.Ordinal ) ) return true;
}
return false;
}
/// <summary>Run an action on the main thread and wait for it. Completes inline when already there.</summary>
static Task OnMainThread( Action action, CancellationToken ct = default )
{
var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously );
MainThread.Queue( () =>
{
try
{
action();
completion.TrySetResult();
}
catch ( Exception e )
{
completion.TrySetException( e );
}
} );
return ct.CanBeCanceled ? Until( completion.Task, ct ) : completion.Task;
}
/// <summary>Run a function on the main thread and wait for its result.</summary>
static Task<T> OnMainThread<T>( Func<T> func, CancellationToken ct = default )
{
var completion = new TaskCompletionSource<T>( TaskCreationOptions.RunContinuationsAsynchronously );
MainThread.Queue( () =>
{
try
{
completion.TrySetResult( func() );
}
catch ( Exception e )
{
completion.TrySetException( e );
}
} );
return ct.CanBeCanceled ? Until( completion.Task, ct ) : completion.Task;
}
/// <summary>
/// Wait for a task, but stop waiting when the token is cancelled.
/// <para>
/// Every main-thread hop hands work to a pump this class does not own. During editor shutdown,
/// across a hotload, or in a host with no pump at all, the queued callback may simply never run — and
/// an awaiter with nothing to cancel it never returns, so the compile never leaves the try/finally
/// that owns it and <see cref="IsCompiling"/> stays true for the rest of the session. A spinner the
/// user cannot clear without closing the window is precisely the failure this class promises not to
/// have, so cancelling a compile has to be able to unwedge it from the outside.
/// </para>
/// <para>
/// The queued callback still runs if the pump ever comes back; its result is simply nobody's, which
/// is the correct meaning of a cancelled compile.
/// </para>
/// </summary>
static async Task Until( Task task, CancellationToken ct )
{
var cancelled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously );
using ( ct.Register( () => cancelled.TrySetCanceled( ct ) ) )
{
var winner = await Task.WhenAny( task, cancelled.Task ).ConfigureAwait( false );
await winner.ConfigureAwait( false );
}
}
/// <inheritdoc cref="Until(Task, CancellationToken)"/>
static async Task<T> Until<T>( Task<T> task, CancellationToken ct )
{
var cancelled = new TaskCompletionSource<T>( TaskCreationOptions.RunContinuationsAsynchronously );
using ( ct.Register( () => cancelled.TrySetCanceled( ct ) ) )
{
var winner = await Task.WhenAny( task, cancelled.Task ).ConfigureAwait( false );
return await winner.ConfigureAwait( false );
}
}
/// <summary>
/// A read-only view of a graph with a different mode list, used to give preview compiles the minimum
/// combo set without touching the document the user is editing. Everything else delegates, so the
/// compiler sees the same nodes, edges, parameters and keywords it always does.
/// </summary>
sealed class PreviewGraph : ICompilableGraph
{
readonly ICompilableGraph _inner;
public PreviewGraph( ICompilableGraph inner, IReadOnlyList<string> modes )
{
_inner = inner;
Modes = modes ?? Array.Empty<string>();
}
public IReadOnlyList<string> Modes { get; }
public string DocumentId => _inner.DocumentId;
public bool IsSubgraph => _inner.IsSubgraph;
public IReadOnlyList<PrismNode> Nodes => _inner.Nodes;
public IReadOnlyList<Edge> Edges => _inner.Edges;
public PrismNode FindNode( NodeId id ) => _inner.FindNode( id );
public bool TryGetIncomingEdge( NodeId node, PortId port, out Edge edge ) =>
_inner.TryGetIncomingEdge( node, port, out edge );
public IEnumerable<Edge> GetOutgoingEdges( NodeId node, PortId port ) =>
_inner.GetOutgoingEdges( node, port );
public void OnNodeChanged( PrismNode node, NodeChangeKind kind ) => _inner.OnNodeChanged( node, kind );
public ShaderDomain Domain => _inner.Domain;
public ShadingModel ShadingModel => _inner.ShadingModel;
public SurfaceBlendMode BlendMode => _inner.BlendMode;
public CullMode CullMode => _inner.CullMode;
public bool UsesUv2 => _inner.UsesUv2;
public bool RenderBackfaces => _inner.RenderBackfaces;
public string Title => _inner.Title;
public string Description => _inner.Description;
public IReadOnlyList<IGraphParameter> Parameters => _inner.Parameters;
public IReadOnlyList<IGraphKeyword> Keywords => _inner.Keywords;
public PrismNode OutputNode => _inner.OutputNode;
}
}