Editor tool service that generates small thumbnail pixmaps for shader graph nodes. It manages a limited cache, schedules per-frame GPU offscreen renders via a SceneWorld/SceneCamera/SceneObject rig, assigns per-node stage ids, and provides request/prioritise/retain/invalidate APIs to the graph view.
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using EngineMaterial = Sandbox.Material;
namespace Editor.Prism.Preview;
/// <summary>
/// Renders one small image per node and hands it to the graph view.
/// <para>
/// <c>INode.Thumbnail</c> has been plumbed through <c>NodeUI.OnPaint</c> in s&box for years and
/// nothing has ever filled it for a shader graph. This does, and it does it for the price of a single
/// compile: the preview build of the shader already carries every node's value behind the
/// <see cref="PrismConstants.StageIdAttribute"/> switch, so a thumbnail is one attribute write and one
/// tiny offscreen render — not a compile, not a render target per node, not a shader variant per node.
/// </para>
/// <para>
/// The rig is a bare <c>SceneWorld</c> with an orthographic <c>SceneCamera</c>, a unit quad wearing
/// the preview material, and enough lighting for a lit surface shader to read. Work is budgeted per
/// frame and prioritised — nodes the user can actually see first — and results are cached against a
/// per-node subtree hash, so a node whose inputs did not change is never rendered twice.
/// </para>
/// <para>
/// <c>SceneCamera.RenderToTexture</c> is internal to the engine and unreachable from an addon, so the
/// public <c>Sandbox.Tools</c> extension <c>RenderToPixmap</c> is used instead. It performs a GPU
/// readback, which is why the per-frame budget defaults low and why nothing here is ever done for a
/// node the user cannot see.
/// </para>
/// </summary>
public sealed class NodeThumbnailService : IDisposable
{
/// <summary>Default thumbnail edge in pixels. Matches <c>PrismTheme.ThumbnailSize</c>.</summary>
public const int DefaultSize = 96;
/// <summary>Thumbnails rendered per editor frame before the rest wait for the next one.</summary>
public const int DefaultBudget = 6;
sealed class Entry
{
public Pixmap Pixmap;
public ulong Hash;
public int StageId;
public bool Dirty = true;
public bool Queued;
public long Touched;
}
static readonly List<NodeThumbnailService> s_live = new();
static readonly List<NodeThumbnailService> s_frameBuffer = new();
readonly Dictionary<NodeId, Entry> _entries = new();
readonly List<NodeId> _queue = new();
SceneWorld _world;
SceneCamera _camera;
SceneObject _quad;
EngineMaterial _material;
IReadOnlyDictionary<NodeId, int> _stageIds = new Dictionary<NodeId, int>();
/// <summary>Create a service. Nothing is allocated on the GPU until the first render.</summary>
public NodeThumbnailService( int size = DefaultSize )
{
Size = Math.Clamp( size, 16, 512 );
lock ( s_live )
{
s_live.Add( this );
}
}
/// <summary>Edge length of every generated thumbnail, in pixels.</summary>
public int Size { get; }
/// <summary>
/// Whether thumbnails are produced at all. Off by default: it is a visible, opt-in feature that
/// costs GPU readbacks, and the window persists the toggle under
/// <see cref="PrismConstants.CookieNodePreviews"/>.
/// </summary>
public bool Enabled { get; set; }
/// <summary>How many thumbnails may be rendered per editor frame.</summary>
public int Budget { get; set; } = DefaultBudget;
/// <summary>
/// The most thumbnails that may be cached at once.
/// <para>
/// This is a real bound, not a request threshold: at 96×96 RGBA a cached image is 36 KB, so an
/// unbounded cache on a graph that is being edited for an hour is tens of megabytes of pixmaps for
/// nodes that no longer exist. When the cache is full the least-recently-touched entry is evicted,
/// and <see cref="Retain"/> drops everything the graph no longer contains.
/// </para>
/// </summary>
public int NodeLimit
{
get => _nodeLimit;
set
{
_nodeLimit = Math.Clamp( value, 1, 4096 );
Trim();
}
}
int _nodeLimit = 128;
long _clock;
/// <summary>Live uniforms pushed to the thumbnail quad. Mirror the viewport's bus into this.</summary>
public PreviewAttributeBus Attributes { get; } = new();
/// <summary>How many thumbnails are waiting to be rendered.</summary>
public int PendingCount => _queue.Count;
/// <summary>How many thumbnails are cached.</summary>
public int Count => _entries.Count;
/// <summary>True when there is nothing left to render.</summary>
public bool IsIdle => _queue.Count == 0;
/// <summary>True when a material and a stage map are both available, so thumbnails can differ per node.</summary>
public bool CanRender => _material is not null && Enabled;
/// <summary>Raised on the main thread once a node's thumbnail has been produced or replaced.</summary>
public event Action<NodeId> ThumbnailReady;
// ---- source ------------------------------------------------------------
/// <summary>
/// Point the service at a freshly compiled preview material. The per-node stage ids are recovered
/// from the artifact's source map: every node that produced generated code gets the index of its
/// first generated line, which is exactly the numbering the preview stage switch uses.
/// </summary>
public void SetSource( EngineMaterial material, CompileResult result )
{
SetSource( material, BuildStageMap( result ) );
if ( result?.PreviewAttributes is { Count: > 0 } ) Attributes.Apply( result.PreviewAttributes );
if ( result?.PreviewTextures is { Count: > 0 } ) Attributes.ApplyTextures( result.PreviewTextures );
}
/// <summary>Point the service at a material and an explicit node-to-stage-id map.</summary>
public void SetSource( EngineMaterial material, IReadOnlyDictionary<NodeId, int> stageIds )
{
_material = material;
_stageIds = stageIds ?? new Dictionary<NodeId, int>();
if ( _quad.IsValid() && _material is not null )
{
PrismLog.Guard( "Applying the thumbnail material", () => _quad.SetMaterialOverride( _material ) );
}
// The shader changed, so every cached image is stale until its hash says otherwise.
foreach ( var entry in _entries.Values ) entry.Dirty = true;
}
// ---- requests ----------------------------------------------------------
/// <summary>The cached thumbnail for a node, or null when there is not one yet.</summary>
public Pixmap Get( NodeId node ) => _entries.TryGetValue( node, out var entry ) ? entry.Pixmap : null;
/// <summary>True when a node has a rendered, up-to-date thumbnail.</summary>
public bool Has( NodeId node ) => _entries.TryGetValue( node, out var entry ) && entry.Pixmap is not null && !entry.Dirty;
/// <summary>
/// Ask for a node's thumbnail. <paramref name="hash"/> should be the node's dependency-subtree hash
/// from <see cref="SubtreeHash"/>; when it matches what produced the cached image, nothing is
/// re-rendered. Pass zero to force a render.
/// </summary>
public void Request( NodeId node, ulong hash = 0 )
{
if ( !node.IsValid ) return;
var fresh = !_entries.TryGetValue( node, out var entry );
if ( fresh )
{
entry = new Entry();
_entries[node] = entry;
}
// Stamped before the trim, so the entry that was just asked for can never be the one evicted.
entry.Touched = ++_clock;
if ( fresh ) Trim();
if ( hash != 0 && entry.Hash == hash && entry.Pixmap is not null )
{
// Same inputs, same shader path: the image cannot have changed.
entry.Dirty = false;
return;
}
entry.Hash = hash;
entry.Dirty = true;
Enqueue( node, entry, false );
}
/// <summary>
/// Drop every cached thumbnail whose node is not in <paramref name="wanted"/>. This is what keeps a
/// long editing session from accumulating images for deleted nodes and for nodes whose preview flag
/// the user has since turned off.
/// </summary>
/// <returns>How many entries were released.</returns>
public int Retain( IEnumerable<NodeId> wanted )
{
if ( wanted is null ) return 0;
var keep = new HashSet<NodeId>();
foreach ( var node in wanted ) keep.Add( node );
var doomed = new List<NodeId>();
foreach ( var node in _entries.Keys )
{
if ( !keep.Contains( node ) ) doomed.Add( node );
}
foreach ( var node in doomed ) Release( node );
return doomed.Count;
}
/// <summary>Evict least-recently-requested entries until the cache is inside its bound.</summary>
void Trim()
{
while ( _entries.Count > _nodeLimit )
{
var oldest = NodeId.None;
var oldestTouch = long.MaxValue;
foreach ( var (node, entry) in _entries )
{
if ( entry.Touched >= oldestTouch ) continue;
oldest = node;
oldestTouch = entry.Touched;
}
if ( !oldest.IsValid ) break;
Release( oldest );
}
}
void Release( NodeId node )
{
if ( !_entries.Remove( node, out var entry ) ) return;
_queue.Remove( node );
entry.Queued = false;
entry.Pixmap = null;
}
/// <summary>Ask for a batch of thumbnails in one go.</summary>
public void Request( IEnumerable<NodeId> nodes )
{
foreach ( var node in nodes ?? Array.Empty<NodeId>() ) Request( node );
}
/// <summary>
/// Ask for a batch of thumbnails, hashing each node's dependency subtree so unchanged nodes are
/// skipped. This is the call the graph view should make after every successful compile.
/// </summary>
public int RequestFor( IPrismGraph graph, IEnumerable<NodeId> nodes )
{
if ( graph is null ) return 0;
var requested = 0;
foreach ( var node in nodes ?? Array.Empty<NodeId>() )
{
Request( node, SubtreeHash( graph, node ) );
requested++;
}
return requested;
}
/// <summary>
/// Move nodes to the front of the render queue. The graph view calls this with whatever is inside
/// the viewport, so what the user is looking at fills in first.
/// </summary>
public void Prioritise( IEnumerable<NodeId> nodes )
{
if ( nodes is null ) return;
var insert = 0;
foreach ( var node in nodes )
{
var index = _queue.IndexOf( node );
if ( index < 0 )
{
if ( !_entries.TryGetValue( node, out var pending ) || !pending.Dirty ) continue;
_queue.Insert( Math.Min( insert++, _queue.Count ), node );
pending.Queued = true;
continue;
}
if ( index == insert )
{
insert++;
continue;
}
_queue.RemoveAt( index );
_queue.Insert( Math.Min( insert++, _queue.Count ), node );
}
}
/// <summary>Mark one node's thumbnail stale.</summary>
public void Invalidate( NodeId node )
{
if ( !_entries.TryGetValue( node, out var entry ) ) return;
entry.Dirty = true;
entry.Hash = 0;
Enqueue( node, entry, false );
}
/// <summary>Mark every thumbnail stale without discarding the images, so nothing flickers to empty.</summary>
public void InvalidateAll()
{
foreach ( var (node, entry) in _entries )
{
entry.Dirty = true;
entry.Hash = 0;
Enqueue( node, entry, false );
}
}
/// <summary>Discard every cached image and the queue.</summary>
public void Clear()
{
_entries.Clear();
_queue.Clear();
}
// ---- rendering ---------------------------------------------------------
/// <summary>
/// Render up to <see cref="Budget"/> queued thumbnails. Driven automatically from the editor frame
/// hook; calling it by hand simply does the next batch early.
/// </summary>
public void Tick()
{
if ( !Enabled || _material is null ) return;
if ( _queue.Count == 0 ) return;
if ( !ThreadSafe.IsMainThread ) return;
// Rendering an offscreen view from inside a render block is not legal.
if ( PrismLog.Guard( "Checking the render state", () => Graphics.IsActive ) ) return;
if ( !EnsureRig() ) return;
var budget = Math.Max( 1, Budget );
while ( budget-- > 0 && _queue.Count > 0 )
{
var node = _queue[0];
_queue.RemoveAt( 0 );
if ( !_entries.TryGetValue( node, out var entry ) ) continue;
entry.Queued = false;
if ( !entry.Dirty ) continue;
if ( !Render( node, entry ) ) break;
entry.Dirty = false;
ThumbnailReady?.Invoke( node );
}
}
/// <summary>Tear the offscreen rig down. Cached pixmaps are released with it.</summary>
public void Dispose()
{
lock ( s_live )
{
s_live.Remove( this );
}
ThumbnailReady = null;
Attributes.Target = null;
PrismLog.Guard( "Destroying the thumbnail rig", () =>
{
_quad?.Delete();
_camera?.Dispose();
_world?.Delete();
} );
_quad = null;
_camera = null;
_world = null;
_material = null;
Clear();
}
bool Render( NodeId node, Entry entry )
{
entry.StageId = _stageIds is not null && _stageIds.TryGetValue( node, out var stage ) ? stage : 0;
entry.Pixmap ??= PrismLog.Guard( "Allocating a thumbnail", () => new Pixmap( Size, Size ), null );
if ( entry.Pixmap is null ) return false;
return PrismLog.Guard( $"Rendering the thumbnail for {node}", () =>
{
var attributes = _quad.Attributes;
attributes.Set( PrismConstants.StageIdAttribute, entry.StageId );
attributes.Set( PrismConstants.ChannelAttribute, 0 );
attributes.Set( PreviewViewport.TimeAttribute, RealTime.Now );
return _camera.RenderToPixmap( entry.Pixmap );
} );
}
bool EnsureRig()
{
if ( _world is not null && _camera is not null && _quad.IsValid() ) return true;
return PrismLog.Guard( "Building the thumbnail rig", () =>
{
_world ??= new SceneWorld();
if ( _camera is null )
{
var extent = PreviewMeshes.Radius;
_camera = new SceneCamera( "Prism Thumbnails" )
{
World = _world,
Ortho = true,
OrthoHeight = extent * 2f,
ZNear = 1f,
ZFar = 4000f,
Position = Vector3.Backward * 500f,
Rotation = Rotation.LookAt( Vector3.Forward ),
BackgroundColor = Color.Transparent,
AmbientLightColor = Color.White * 0.05f,
AntiAliasing = true,
EnablePostProcessing = false
};
// Enough light for a lit surface shader to resolve to something other than black, and a
// cubemap so metals and reflections read.
new ScenePointLight( _world, new Vector3( -260f, 180f, 180f ), 1400f, Color.White * 6f )
.ShadowsEnabled = false;
new ScenePointLight( _world, new Vector3( -260f, -180f, -140f ), 1400f, new Color( 0.55f, 0.7f, 1f ) * 3f )
.ShadowsEnabled = false;
var cubemap = PrismLog.Guard( "Loading the thumbnail envmap",
() => Texture.Load( PreviewScene.DefaultEnvmap ), null );
if ( cubemap is not null )
{
_ = new SceneCubemap( _world, cubemap, BBox.FromPositionAndSize( Vector3.Zero, 20000f ) );
}
}
if ( !_quad.IsValid() )
{
_quad = new SceneObject( _world, PreviewMeshes.Quad, Transform.Zero )
{
Batchable = false
};
_quad.Flags.CastShadows = false;
Attributes.Target = _quad;
}
if ( _material is not null ) _quad.SetMaterialOverride( _material );
return _world is not null && _camera is not null && _quad.IsValid();
} );
}
void Enqueue( NodeId node, Entry entry, bool front )
{
if ( entry.Queued ) return;
entry.Queued = true;
if ( front ) _queue.Insert( 0, node );
else _queue.Add( node );
}
// ---- static helpers ----------------------------------------------------
/// <summary>
/// Recover a node-to-stage-id map from a compile result.
/// <para>
/// The source map records which generated line each node produced, so ordering nodes by their
/// first generated line reproduces exactly the order the preview shader's stage switch numbers
/// them in. Ids are one-based, because zero means "show the shader's own output".
/// </para>
/// </summary>
/// <remarks>
/// Delegated to <see cref="PreviewInstrumentation.StageMap"/> rather than reimplemented. The backend
/// bakes these numbers into the generated switch, so two copies of the numbering rule is two copies
/// of a contract that must agree exactly — and a drift between them would silently show the wrong
/// node's value in the preview rather than fail.
/// </remarks>
public static IReadOnlyDictionary<NodeId, int> BuildStageMap( CompileResult result ) =>
PreviewInstrumentation.StageMap( result );
/// <summary>
/// A stable hash of everything that can change what a node evaluates to: its own structure and
/// values, and those of every node it depends on, plus the shape of the connections between them.
/// Two nodes with the same hash produce the same thumbnail, which is what makes caching correct
/// rather than merely fast.
/// </summary>
public static ulong SubtreeHash( IPrismGraph graph, NodeId node )
{
if ( graph is null || !node.IsValid ) return 0;
return PrismLog.Guard( $"Hashing the subtree of {node}", () =>
{
var hash = Fnv.Offset;
var subtree = GraphQueries.DependencySubtree( graph, node );
foreach ( var id in subtree )
{
var member = graph.FindNode( id );
if ( member is null ) continue;
hash = Fnv.Mix( hash, member.Descriptor?.Id );
hash = Fnv.Mix( hash, NodeProperties.StructuralHash( member ) );
hash = Fnv.Mix( hash, NodeProperties.ValueHash( member ) );
foreach ( var input in member.Inputs )
{
if ( !graph.TryGetIncomingEdge( id, input.Id, out var edge ) ) continue;
hash = Fnv.Mix( hash, input.Id.Value );
hash = Fnv.Mix( hash, edge.FromNode.Value );
hash = Fnv.Mix( hash, edge.FromPort.Value );
}
}
// Zero is the "no hash" sentinel, so never return it by accident.
return hash == 0 ? 1UL : hash;
} );
}
/// <summary>
/// Drives every live service once per editor frame, whether or not a viewport is visible.
/// <para>
/// The snapshot is taken into a reused buffer rather than a fresh array: this runs every frame for
/// the life of the editor, and one allocation per frame for a list that is almost always one element
/// long is exactly the kind of garbage a tool this size should not be producing at idle.
/// </para>
/// </summary>
[EditorEvent.Frame]
static void OnEditorFrame()
{
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].Tick();
}
s_frameBuffer.Clear();
}
}