Editor/Prism/Preview/PreviewMaterialHost.cs

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.

File AccessNetworking
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();
	}
}