Editor helper that owns a preview material for the editor preview system. It records requested shader paths, normalizes them, defers material creation to the main thread and to a safe editor-frame hook, falls back to stock materials on failure, and exposes events for changes and failures.
using Editor.Prism.Core;
using EngineMaterial = Sandbox.Material;
namespace Editor.Prism.Preview;
/// <summary>
/// Owns the material the preview draws with, and the exact three-step dance that turns a freshly
/// written <c>.shader</c> file into a live material.
/// <para>
/// Three engine rules make this harder than it looks, and all three are handled here so no other file
/// has to know about them. <c>mat_reloadshaders</c> has to run <em>before</em> <c>Material.Create</c>
/// or the engine hands back the previously compiled binary. <c>Material.Create</c> asserts it is on
/// the main thread. And it throws outright if called while the renderer is inside a frame, which is
/// why a request made from a compile callback is parked and drained from the editor frame hook
/// instead of being executed on the spot.
/// </para>
/// <para>
/// While a request is in flight, and after any failure, the subject keeps the stock
/// <c>shader_editor</c> material, so the viewport never goes black or magenta because a compile is a
/// few frames behind the graph.
/// </para>
/// </summary>
public sealed class PreviewMaterialHost : IDisposable
{
/// <summary>The material shown while compiling and whenever a compile fails.</summary>
public const string FallbackMaterial = "materials/core/shader_editor.vmat";
/// <summary>The backdrop material used behind a post-process graph, where the subject is not the shader.</summary>
public const string BackdropMaterial = "materials/dev/reflectivity_50.vmat";
/// <summary>Temp prefix generated shaders are written under, stripped before the engine sees the path.</summary>
public const string TempPrefix = ".source2/temp/";
static readonly List<PreviewMaterialHost> s_live = new();
static readonly List<PreviewMaterialHost> s_frameBuffer = new();
readonly string _sessionId;
string _pendingPath;
EngineMaterial _fallback;
/// <summary>Create a host. The session id only distinguishes generated material names.</summary>
public PreviewMaterialHost( string sessionId = null )
{
_sessionId = Sanitize( string.IsNullOrWhiteSpace( sessionId ) ? PrismConstants.SessionId : sessionId );
lock ( s_live )
{
s_live.Add( this );
}
}
/// <summary>
/// The name every material this host creates is given. Stable for the life of the session, matching
/// <c>ShaderCompileService.PreviewMaterialName</c>.
/// </summary>
public string MaterialName => $"prism_preview_{_sessionId}";
/// <summary>The material currently applied. Falls back to <see cref="Fallback"/>, never null in practice.</summary>
public EngineMaterial Current { get; private set; }
/// <summary>The stock material used while compiling and on failure.</summary>
public EngineMaterial Fallback => _fallback ??= LoadFallback();
/// <summary>The shader path <see cref="Current"/> was created from, or null when it is the fallback.</summary>
public string ShaderPath { get; private set; }
/// <summary>True while a shader path is waiting for a safe moment to become a material.</summary>
public bool IsPending => _pendingPath is not null;
/// <summary>True when the last apply produced a real generated material rather than the fallback.</summary>
public bool HasGeneratedMaterial => Current is not null && ShaderPath is not null;
/// <summary>Raised on the main thread whenever <see cref="Current"/> changes.</summary>
public event Action<EngineMaterial> MaterialChanged;
/// <summary>Raised when a shader path could not be turned into a material. Carries the path.</summary>
public event Action<string> Failed;
/// <summary>
/// Whether <see cref="Flush"/> runs <c>mat_reloadshaders</c> itself before creating the material.
/// <para>
/// True by default, because a caller that wrote a <c>.shader</c> and handed the path straight over
/// would otherwise bind the previous compile. <c>ShaderCompileService</c> already reloads —
/// and does it in the better place, after waiting for the asset system to report the file compiled
/// and up to date — so anything driven from its <c>Completed</c> event turns this off and the
/// console command runs once per compile instead of twice.
/// </para>
/// </summary>
public bool ReloadBeforeCreate { get; set; } = true;
/// <summary>
/// Ask for a compiled shader to become the preview material.
/// <para>
/// Safe from any thread and at any point in the frame: the request is only a recorded path, and the
/// actual reload happens on the main thread outside the render block. Calling this repeatedly
/// coalesces — only the most recent path is ever realised.
/// </para>
/// </summary>
public void Apply( string relativeShaderPath )
{
var path = Normalize( relativeShaderPath );
if ( string.IsNullOrEmpty( path ) )
{
UseFallback();
return;
}
_pendingPath = path;
MainThread.Queue( Flush );
}
/// <summary>Drop back to the stock material, e.g. while a compile is running or after it failed.</summary>
public void UseFallback()
{
_pendingPath = null;
Set( Fallback, null );
}
/// <summary>
/// Swap to the backdrop material a post-process graph is previewed against, where the generated
/// shader is blitted over the frame rather than drawn on the subject.
/// </summary>
public void UseBackdrop()
{
_pendingPath = null;
var backdrop = PrismLog.Guard( "Loading the preview backdrop material",
() => EngineMaterial.Load( BackdropMaterial ), null ) ?? Fallback;
Set( backdrop, null );
}
/// <summary>
/// Realise a parked request if the engine is in a state where that is legal. Called every editor
/// frame; safe and cheap to call by hand.
/// </summary>
public void Flush()
{
var path = _pendingPath;
if ( path is null ) return;
if ( !ThreadSafe.IsMainThread ) return;
// Material.Create throws outright during rendering. Wait for the next frame instead.
if ( PrismLog.Guard( "Checking the render state", () => Graphics.IsActive ) ) return;
_pendingPath = null;
// A stable name per session, matching what ShaderGraph does and what
// ShaderCompileService.PreviewMaterialName already computes. A fresh name per compile bought
// nothing — Material.Create is anonymous by default, so the name does not decide identity — and
// made every material in the session distinct in the debugger for no reason.
var name = MaterialName;
var material = PrismLog.Guard( $"Creating the preview material from '{path}'", () =>
{
// Without this the engine happily hands back the previous compile of the same path. Skipped
// when the compile service has already done it; see ReloadBeforeCreate.
if ( ReloadBeforeCreate ) ConsoleSystem.Run( $"mat_reloadshaders {path}" );
return EngineMaterial.Create( name, path );
}, null );
if ( material is null )
{
Set( Fallback, null );
Failed?.Invoke( path );
return;
}
Set( material, path );
}
/// <summary>Release the host. The materials themselves are engine-owned and are not destroyed here.</summary>
public void Dispose()
{
lock ( s_live )
{
s_live.Remove( this );
}
_pendingPath = null;
MaterialChanged = null;
Failed = null;
}
void Set( EngineMaterial material, string path )
{
if ( Current == material && ShaderPath == path ) return;
Current = material;
ShaderPath = path;
MaterialChanged?.Invoke( material );
}
EngineMaterial LoadFallback()
{
var material = PrismLog.Guard( "Loading the preview fallback material",
() => EngineMaterial.Load( FallbackMaterial ), null );
return material ?? PrismLog.Guard( "Loading the preview backdrop material",
() => EngineMaterial.Load( BackdropMaterial ), null );
}
/// <summary>
/// Turn whatever the compile service handed us into the path the engine's shader system expects:
/// forward slashes, no leading separator, and no <c>.source2/temp</c> prefix — generated shaders
/// are written under the temp root but addressed without it, exactly as the stock editor does.
/// </summary>
public static string Normalize( string path )
{
if ( string.IsNullOrWhiteSpace( path ) ) return null;
var normalized = path.Trim().Replace( '\\', '/' );
while ( normalized.StartsWith( "./", StringComparison.Ordinal ) ) normalized = normalized[2..];
if ( normalized.StartsWith( TempPrefix, StringComparison.OrdinalIgnoreCase ) )
{
normalized = normalized[TempPrefix.Length..];
}
normalized = normalized.TrimStart( '/' );
return normalized.Length == 0 ? null : normalized;
}
static string Sanitize( string value )
{
if ( string.IsNullOrWhiteSpace( value ) ) return "session";
var chars = value.Trim().ToCharArray();
for ( int i = 0; i < chars.Length; i++ )
{
if ( !char.IsLetterOrDigit( chars[i] ) ) chars[i] = '_';
}
return new string( chars );
}
/// <summary>
/// Drains every live host once per editor frame. This is the only place a material is created, and
/// the frame hook is outside the render block, which is precisely what the engine requires.
/// </summary>
[EditorEvent.Frame]
static void OnEditorFrame()
{
// Snapshotted into a reused buffer: this runs every frame for the life of the editor, and the
// list is almost always one element long, so a fresh array per frame is pure garbage.
lock ( s_live )
{
if ( s_live.Count == 0 ) return;
s_frameBuffer.Clear();
s_frameBuffer.AddRange( s_live );
}
for ( int i = 0; i < s_frameBuffer.Count; i++ )
{
s_frameBuffer[i].Flush();
}
s_frameBuffer.Clear();
}
}