Editor-side utilities and structural Prism graph nodes for the shader/graph editor. Defines shared helper shader functions (helpers that emit HLSL/Slang snippets and stage masks), small IR recipes for common transforms (world->view, tangent space conversions, passthrough), and several utility nodes used in the graph UI (Reroute, Comment, Note, Preview, Bypass) with their port and emit logic.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
namespace Editor.Prism.Nodes;
/// <summary>
/// The shared helper functions this package's nodes lower to, plus the small IR recipes several of
/// them build.
/// <para>
/// Helper names are the module-wide deduplication key, so every one of them is defined exactly once,
/// here, and prefixed <c>Prism_</c>. A helper that wraps an engine class — <c>Depth::</c>,
/// <c>Fog::</c>, <c>Light::</c>, <c>EnvMap::</c> — carries a second body for the Slang backend,
/// because a standalone <c>.slang</c> module has no s&box runtime behind it and must still compile.
/// </para>
/// </summary>
internal static class PrismHelpers
{
// ---- depth ------------------------------------------------------------
/// <summary>The value sitting in the depth buffer at a screen position.</summary>
public static readonly HelperFunction SceneDepthRaw = new( "Prism_SceneDepthRaw", ShaderType.Float,
new[] { new HelperParam( "vScreenPosition", ShaderType.Float2 ) } )
{
Hlsl =
"""
float Prism_SceneDepthRaw( float2 vScreenPosition )
{
return Depth::Get( vScreenPosition );
}
""",
Slang =
"""
// A standalone module has no depth chain bound; the far plane is the neutral answer.
float Prism_SceneDepthRaw( float2 vScreenPosition )
{
return 1.0;
}
""",
Stages = StageMask.Pixel
};
/// <summary>The depth buffer remapped into 0..1 across the viewport's depth range.</summary>
public static readonly HelperFunction SceneDepthNormalized = new( "Prism_SceneDepthNormalized",
ShaderType.Float, new[] { new HelperParam( "vScreenPosition", ShaderType.Float2 ) } )
{
Hlsl =
"""
float Prism_SceneDepthNormalized( float2 vScreenPosition )
{
return Depth::GetNormalized( vScreenPosition );
}
""",
Slang =
"""
float Prism_SceneDepthNormalized( float2 vScreenPosition )
{
return 1.0;
}
""",
Stages = StageMask.Pixel
};
/// <summary>Distance from the camera plane to whatever the depth buffer holds, in world units.</summary>
public static readonly HelperFunction SceneDepthLinear = new( "Prism_SceneDepthLinear", ShaderType.Float,
new[] { new HelperParam( "vScreenPosition", ShaderType.Float2 ) } )
{
Hlsl =
"""
float Prism_SceneDepthLinear( float2 vScreenPosition )
{
return Depth::GetLinear( vScreenPosition );
}
""",
Slang =
"""
float Prism_SceneDepthLinear( float2 vScreenPosition )
{
return gPrismEnv.Frame.CameraFar;
}
""",
Stages = StageMask.Pixel
};
/// <summary>The world position the depth buffer holds at a screen position.</summary>
public static readonly HelperFunction SceneDepthWorldPosition = new( "Prism_SceneWorldPosition",
ShaderType.Float3, new[] { new HelperParam( "vScreenPosition", ShaderType.Float2 ) } )
{
Hlsl =
"""
float3 Prism_SceneWorldPosition( float2 vScreenPosition )
{
return Depth::GetWorldPosition( vScreenPosition );
}
""",
Slang =
"""
float3 Prism_SceneWorldPosition( float2 vScreenPosition )
{
return gPrismEnv.Frame.CameraPosition;
}
""",
Stages = StageMask.Pixel
};
// ---- viewport ---------------------------------------------------------
/// <summary>The near end of the viewport's depth range.</summary>
public static readonly HelperFunction ViewportMinZ = new( "Prism_ViewportMinZ", ShaderType.Float,
Array.Empty<HelperParam>() )
{
Hlsl =
"""
float Prism_ViewportMinZ()
{
return g_flViewportMinZ;
}
""",
Slang =
"""
float Prism_ViewportMinZ()
{
return 0.0;
}
"""
};
/// <summary>The far end of the viewport's depth range.</summary>
public static readonly HelperFunction ViewportMaxZ = new( "Prism_ViewportMaxZ", ShaderType.Float,
Array.Empty<HelperParam>() )
{
Hlsl =
"""
float Prism_ViewportMaxZ()
{
return g_flViewportMaxZ;
}
""",
Slang =
"""
float Prism_ViewportMaxZ()
{
return 1.0;
}
"""
};
// ---- spaces -----------------------------------------------------------
/// <summary>Rotate a world-space vector into the surface's tangent basis.</summary>
public static readonly HelperFunction WorldToTangentVector = new( "Prism_WorldToTangent",
ShaderType.Float3,
new[]
{
new HelperParam( "vVectorWs", ShaderType.Float3 ),
new HelperParam( "vNormalWs", ShaderType.Float3 ),
new HelperParam( "vTangentUWs", ShaderType.Float3 ),
new HelperParam( "vTangentVWs", ShaderType.Float3 )
} )
{
Hlsl =
"""
float3 Prism_WorldToTangent( float3 vVectorWs, float3 vNormalWs, float3 vTangentUWs, float3 vTangentVWs )
{
return Vec3WsToTs( vVectorWs, vNormalWs, vTangentUWs, vTangentVWs );
}
""",
Slang =
"""
float3 Prism_WorldToTangent( float3 vVectorWs, float3 vNormalWs, float3 vTangentUWs, float3 vTangentVWs )
{
return mul( PrismTangentBasis( vNormalWs, vTangentUWs, vTangentVWs ), vVectorWs );
}
"""
};
/// <summary>The camera-to-surface direction expressed in tangent space.</summary>
public static readonly HelperFunction TangentViewVector = new( "Prism_TangentViewVector",
ShaderType.Float3,
new[]
{
new HelperParam( "vPositionWs", ShaderType.Float3 ),
new HelperParam( "vNormalWs", ShaderType.Float3 ),
new HelperParam( "vTangentUWs", ShaderType.Float3 ),
new HelperParam( "vTangentVWs", ShaderType.Float3 )
} )
{
Hlsl =
"""
float3 Prism_TangentViewVector( float3 vPositionWs, float3 vNormalWs, float3 vTangentUWs, float3 vTangentVWs )
{
float3 vCameraToPositionDirWs = CalculateCameraToPositionDirWs( vPositionWs );
return Vec3WsToTs( vCameraToPositionDirWs, normalize( vNormalWs ), vTangentUWs, vTangentVWs );
}
""",
Slang =
"""
float3 Prism_TangentViewVector( float3 vPositionWs, float3 vNormalWs, float3 vTangentUWs, float3 vTangentVWs )
{
float3 vCameraToPositionDirWs = PrismSafeNormalize( vPositionWs - gPrismEnv.Frame.CameraPosition );
return mul( PrismTangentBasis( vNormalWs, vTangentUWs, vTangentVWs ), vCameraToPositionDirWs );
}
"""
};
// ---- scene colour -----------------------------------------------------
/// <summary>
/// Sample a full-screen texture at a viewport coordinate.
/// <para>
/// The texture arrives as an argument rather than being named inside the body, because a
/// post-process graph reads <c>g_tColorBuffer</c> while a translucent surface reads
/// <c>g_tFrameBufferCopyTexture</c>, and one helper should serve both.
/// </para>
/// </summary>
public static readonly HelperFunction SampleSceneColor = new( "Prism_SampleSceneColor",
ShaderType.Float4,
new[]
{
new HelperParam( "tScene", ShaderType.Texture2D ),
new HelperParam( "vUv", ShaderType.Float2 )
} )
{
Hlsl =
"""
float4 Prism_SampleSceneColor( Texture2D tScene, float2 vUv )
{
return tScene.SampleLevel( g_sBilinearClamp, saturate( vUv ), 0.0 );
}
""",
Slang =
"""
SamplerState g_sPrismSceneColor;
float4 Prism_SampleSceneColor( Texture2D tScene, float2 vUv )
{
return tScene.SampleLevel( g_sPrismSceneColor, saturate( vUv ), 0.0 );
}
""",
Stages = StageMask.Pixel,
Pure = true
};
// ---- IR recipes -------------------------------------------------------
/// <summary>Transform a world-space position into view space.</summary>
public static IrValue WorldToView( EmitContext ctx, IrValue worldPosition )
{
if ( ctx is null || !worldPosition.IsValid ) return IrValue.Invalid;
var homogeneous = ctx.Construct( ShaderType.Float4, worldPosition, ctx.Const( 1f ) );
var view = ctx.Call( Intrinsic.Mul, ctx.Builtin( Builtin.ViewMatrix ), homogeneous );
return ctx.Swizzle( view, "xyz" );
}
/// <summary>Transform a world-space direction into view space, without translating it.</summary>
public static IrValue WorldToViewDirection( EmitContext ctx, IrValue worldDirection )
{
if ( ctx is null || !worldDirection.IsValid ) return IrValue.Invalid;
var homogeneous = ctx.Construct( ShaderType.Float4, worldDirection, ctx.Const( 0f ) );
var view = ctx.Call( Intrinsic.Mul, ctx.Builtin( Builtin.ViewMatrix ), homogeneous );
return ctx.Swizzle( view, "xyz" );
}
/// <summary>Rotate a world-space position into the surface's tangent basis.</summary>
public static IrValue WorldToTangent( EmitContext ctx, IrValue worldPosition ) =>
WorldToTangentDirection( ctx, worldPosition );
/// <summary>Rotate a world-space direction into the surface's tangent basis.</summary>
public static IrValue WorldToTangentDirection( EmitContext ctx, IrValue worldDirection )
{
if ( ctx is null || !worldDirection.IsValid ) return IrValue.Invalid;
return ctx.Helper( WorldToTangentVector, worldDirection,
ctx.Builtin( Builtin.WorldNormal ),
ctx.Builtin( Builtin.WorldTangentU ),
ctx.Builtin( Builtin.WorldTangentV ) );
}
/// <summary>
/// Read a passthrough input, keeping the value's own type. Falls back to a scalar zero so a
/// disconnected reroute never poisons the graph downstream of it.
/// </summary>
public static IrValue Passthrough( EmitContext ctx, string port )
{
if ( ctx is null ) return IrValue.Invalid;
return ctx.TryIn( port, out var value ) ? value : ctx.Const( 0f );
}
}
// ---------------------------------------------------------------------------------------------------
// Structural nodes.
//
// These carry no shader semantics of their own: they exist so a graph can be read by a human. Two of
// them are load-bearing beyond that — GraphMutations.RerouteTypeId and the graph view's context menu
// both resolve the exact ids "prism.util.reroute" and "prism.util.comment" out of the registry.
// ---------------------------------------------------------------------------------------------------
/// <summary>The palette a comment box or a note is tinted from. Resolved to real colours by the theme.</summary>
public enum PrismNoteColor
{
/// <summary>The theme's neutral panel tint.</summary>
Default,
/// <summary>Red.</summary>
Red,
/// <summary>Orange.</summary>
Orange,
/// <summary>Yellow.</summary>
Yellow,
/// <summary>Green.</summary>
Green,
/// <summary>Blue.</summary>
Blue,
/// <summary>Purple.</summary>
Purple,
/// <summary>Grey.</summary>
Grey
}
/// <summary>
/// A wire waypoint. Carries its input straight through, keeping whatever type arrived.
/// <para>
/// <b>The id <c>prism.util.reroute</c> is load-bearing:</b> <c>GraphMutations.RerouteTypeId</c> and the
/// graph view's "insert reroute" action both look it up by that exact string.
/// </para>
/// </summary>
[NodeInfo( Id = RerouteNode.TypeId, Title = "Reroute", Category = "Utility",
Icon = "linear_scale", Keywords = new[] { "reroute", "pin", "waypoint", "knot", "wire" },
Description = "A waypoint on a wire. Purely cosmetic: it emits nothing of its own and the " +
"optimiser folds it away." )]
[NodeVersion( 1 )]
public sealed class RerouteNode : PrismNode
{
/// <summary>The stable type id. Referenced by <c>GraphMutations.RerouteTypeId</c>.</summary>
public const string TypeId = "prism.util.reroute";
/// <summary>The value passing through.</summary>
[In( "any", Name = "" )] public PortRef In { get; set; }
/// <summary>The same value, unchanged.</summary>
[Out( "any", Name = "" )] public PortRef Out { get; set; }
/// <summary>An optional label drawn beside the waypoint.</summary>
public string Comment { get; set; } = string.Empty;
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
b?.SetFlags( nameof( In ), PortFlags.Passthrough | PortFlags.NoInlineEditor );
b?.SetFlags( nameof( Out ), PortFlags.Passthrough );
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx ) =>
ctx?.Out( nameof( Out ), PrismHelpers.Passthrough( ctx, nameof( In ) ) );
}
/// <summary>
/// A titled box drawn behind a group of nodes.
/// <para>
/// <b>The id <c>prism.util.comment</c> is load-bearing:</b> the graph view's context menu resolves its
/// comment node type by that exact string.
/// </para>
/// </summary>
[NodeInfo( Id = CommentNode.TypeId, Title = "Comment", Category = "Utility",
Icon = "notes", Keywords = new[] { "comment", "group", "frame", "box", "annotate" },
Description = "A titled frame drawn behind the nodes it encloses. Never compiled." )]
[NodeVersion( 1 )]
public sealed class CommentNode : PrismNode
{
/// <summary>The stable type id the graph view resolves its comment node type by.</summary>
public const string TypeId = "prism.util.comment";
/// <summary>The heading drawn along the top of the frame.</summary>
public string Title { get; set; } = "Comment";
/// <summary>Body text drawn under the heading.</summary>
public string Description { get; set; } = string.Empty;
/// <summary>How large the frame is, in scene units.</summary>
public Vector2 Size { get; set; } = new( 480f, 280f );
/// <summary>
/// Which tint the frame is drawn in.
/// <para>
/// Deliberately the framework's own <see cref="CommentColor"/> rather than
/// <see cref="PrismNoteColor"/>: the graph view's comment adapter implements
/// <c>ICommentNode.Color</c> by reading this property by name and casting, so any other enum would
/// read back as the default however it was set.
/// </para>
/// </summary>
public CommentColor Color { get; set; } = CommentColor.Blue;
/// <summary>Draw order. Lower layers sit further back.</summary>
public int Layer { get; set; } = 5;
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
// A comment produces nothing. It has no ports, so nothing can ever demand it either.
}
}
/// <summary>Free-floating text, for a caveat that belongs beside the graph rather than inside it.</summary>
[NodeInfo( Id = "prism.util.note", Title = "Note", Category = "Utility",
Icon = "sticky_note_2", Keywords = new[] { "note", "text", "sticky", "todo", "documentation" },
Description = "A block of text pinned to the canvas. Never compiled." )]
[NodeVersion( 1 )]
public sealed class NoteNode : PrismNode
{
/// <summary>The note's heading.</summary>
public string Title { get; set; } = "Note";
/// <summary>The note's body.</summary>
public string Text { get; set; } = string.Empty;
/// <summary>How large the note is, in scene units.</summary>
public Vector2 Size { get; set; } = new( 260f, 120f );
/// <summary>Which tint the note is drawn in.</summary>
public PrismNoteColor Color { get; set; } = PrismNoteColor.Yellow;
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
// Documentation only.
}
}
/// <summary>
/// Forces a thumbnail of whatever passes through it, without changing the value.
/// <para>
/// Any node can show a preview through <see cref="NodeFlags.Preview"/>; this exists so a value can be
/// inspected mid-wire without disturbing the node that produced it, which matters when that node is a
/// subgraph instance or a custom-code node whose own preview would be ambiguous.
/// </para>
/// </summary>
[NodeInfo( Id = "prism.util.preview", Title = "Preview", Category = "Utility",
Icon = "visibility", Keywords = new[] { "preview", "inspect", "debug", "watch", "thumbnail" },
Description = "Shows a thumbnail of the value passing through it and forwards it unchanged." )]
[NodeVersion( 1 )]
public sealed class PreviewNode : PrismNode
{
/// <summary>Build a preview node with its thumbnail already switched on.</summary>
public PreviewNode()
{
Flags |= NodeFlags.Preview;
}
/// <summary>The value being inspected.</summary>
[In( "any", Name = "" )] public PortRef In { get; set; }
/// <summary>The same value, unchanged.</summary>
[Out( "any", Name = "" )] public PortRef Out { get; set; }
/// <summary>How the thumbnail interprets the value.</summary>
public PrismPreviewChannel Channel { get; set; } = PrismPreviewChannel.Color;
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
b?.SetFlags( nameof( In ), PortFlags.Passthrough | PortFlags.NoInlineEditor );
b?.SetFlags( nameof( Out ), PortFlags.Passthrough );
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx ) =>
ctx?.Out( nameof( Out ), PrismHelpers.Passthrough( ctx, nameof( In ) ) );
}
/// <summary>How a preview thumbnail interprets the value it is showing.</summary>
public enum PrismPreviewChannel
{
/// <summary>Straight to RGB.</summary>
Color,
/// <summary>Repeated across RGB, so a scalar reads as greyscale.</summary>
Grayscale,
/// <summary>Remapped from -1..1 to 0..1, the way a normal map is displayed.</summary>
Normal,
/// <summary>Alpha only.</summary>
Alpha
}
/// <summary>
/// A compile-time switch for taking a branch of the graph out of circuit.
/// <para>
/// Nothing is emitted for the branch that loses: the winning input is the only one demanded, so the
/// whole subtree behind the other one is never traversed. That is what makes this cheaper than a
/// runtime <c>Branch</c> and different from one.
/// </para>
/// </summary>
[NodeInfo( Id = "prism.util.bypass", Title = "Bypass", Category = "Utility",
Icon = "toggle_on", Keywords = new[] { "bypass", "disable", "mute", "switch", "ab", "compare" },
Description = "Picks one of two inputs while authoring. The branch that loses is never compiled." )]
[NodeVersion( 1 )]
public sealed class BypassNode : PrismNode
{
/// <summary>The value used while the node is enabled.</summary>
[In( "any", Name = "In" )] public PortRef In { get; set; }
/// <summary>The value used while the node is bypassed.</summary>
[In( "any", Name = "Bypass" )] public PortRef Bypass { get; set; }
/// <summary>The chosen value.</summary>
[Out( "any", Name = "" )] public PortRef Out { get; set; }
/// <summary>False routes <see cref="Bypass"/> through instead of <see cref="In"/>.</summary>
public bool Enabled { get; set; } = true;
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
b?.SetFlags( nameof( In ), PortFlags.Passthrough | PortFlags.NoInlineEditor );
b?.SetFlags( nameof( Bypass ), PortFlags.Passthrough | PortFlags.NoInlineEditor );
b?.SetFlags( nameof( Out ), PortFlags.Passthrough );
}
/// <inheritdoc/>
public override void OnValidate( ValidationContext ctx )
{
if ( ctx is null || Enabled ) return;
ctx.Info( "This node is bypassed, so everything wired into In is excluded from the shader",
nameof( In ) );
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
if ( ctx is null ) return;
var chosen = Enabled ? nameof( In ) : nameof( Bypass );
var fallback = Enabled ? nameof( Bypass ) : nameof( In );
if ( ctx.TryIn( chosen, out var value ) )
{
ctx.Out( nameof( Out ), value );
return;
}
ctx.Out( nameof( Out ), PrismHelpers.Passthrough( ctx, fallback ) );
}
}