Editor/Prism/Compiler/PreviewInstrumentation.cs

Utilities that emit preview/thumbnail instrumentation for generated shaders. Defines an environment for debug channels, builds a node->stage id map from a SourceMap, and produces HLSL/Slang declarations plus switch/case lines that let the preview UI select node values or debug channels at runtime.

Reflection
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Core;
using Editor.Prism.Preview;

namespace Editor.Prism.Compiler;

/// <summary>
/// The leaf expressions a backend can offer the debug-channel tail.
/// <para>
/// Every field is an expression in the backend's own language, or <c>null</c> when the generated
/// shader cannot answer that channel — a post-process graph has no vertex colour, a custom shading
/// model has no material struct, a Slang module only carries the interpolants the graph asked for.
/// A null field emits no case at all, so the channel falls through to the shaded result rather than
/// returning garbage. That fall-through is the contract
/// <see cref="PreviewChannelInfo.NeedsShaderSupport"/> promises the viewport.
/// </para>
/// </summary>
public sealed class PreviewChannelEnvironment
{
	/// <summary>Linear base colour, a 3-component expression.</summary>
	public string Albedo { get; set; }

	/// <summary>Coverage, a scalar expression.</summary>
	public string Opacity { get; set; }

	/// <summary>The tangent-space normal the graph produced, a 3-component expression.</summary>
	public string NormalTangent { get; set; }

	/// <summary>The shading normal in world space, a 3-component expression.</summary>
	public string NormalWorld { get; set; }

	/// <summary>Microfacet roughness, a scalar expression.</summary>
	public string Roughness { get; set; }

	/// <summary>Dielectric-to-conductor blend, a scalar expression.</summary>
	public string Metalness { get; set; }

	/// <summary>Baked occlusion, a scalar expression.</summary>
	public string AmbientOcclusion { get; set; }

	/// <summary>Emissive colour, a 3-component expression.</summary>
	public string Emission { get; set; }

	/// <summary>Transmitted light, a 3-component expression.</summary>
	public string Transmission { get; set; }

	/// <summary>Per-instance tint mask, a scalar expression.</summary>
	public string TintMask { get; set; }

	/// <summary>The first texture coordinate set, a 2-component expression.</summary>
	public string Uv0 { get; set; }

	/// <summary>The second texture coordinate set, a 2-component expression.</summary>
	public string Uv1 { get; set; }

	/// <summary>Interpolated vertex colour, a 3- or 4-component expression.</summary>
	public string VertexColor { get; set; }

	/// <summary>World position, a 3-component expression.</summary>
	public string WorldPosition { get; set; }

	/// <summary>
	/// The 2-component expression whose screen-space derivatives drive the derivative and mip-level
	/// channels. Normally the same expression as <see cref="Uv0"/>.
	/// </summary>
	public string DerivativeSource { get; set; }

	/// <summary>The uniform the channel index is read from.</summary>
	public string ChannelUniform { get; set; } = PrismConstants.ChannelAttribute;

	/// <summary>
	/// World-space distance the world-position channel wraps over. One inch-scale unit cube per
	/// 128 units reads well on a preview mesh without going to banding.
	/// </summary>
	public float WorldPositionPeriod { get; set; } = 128f;

	/// <summary>
	/// The texture size the mip-level channel assumes. Nothing in the shader knows which texture the
	/// user cares about, so a fixed reference makes the readout comparable between graphs.
	/// </summary>
	public float MipReferenceSize { get; set; } = 1024f;

	/// <summary>Mip level the channel's ramp saturates at.</summary>
	public float MipRange { get; set; } = 10f;
}

/// <summary>
/// The preview-only tails a generated shader grows in <see cref="CompileMode.Preview"/> and
/// <see cref="CompileMode.Thumbnail"/>: the per-node stage switch that makes node thumbnails cost one
/// compile instead of N, and the debug-channel switch behind the viewport's channel strip.
/// <para>
/// <b>The stage numbering is a contract, not an implementation detail.</b>
/// <see cref="NodeThumbnailService.BuildStageMap"/> recovers it from the emitted artifact's
/// <see cref="SourceMap"/> by numbering nodes in order of their first generated line, starting at
/// one. <see cref="BuildStageMap(SourceMap)"/> below is that same rule, and the backends run it over
/// the source map of a throw-away first pass so the numbers they bake into the switch are the ones
/// the preview will later compute. Zero always means "show the shader's own output".
/// </para>
/// <para>
/// Instrumentation is only ever added to the pixel entry point, and only for values that live there.
/// A node whose work the stage planner placed in the vertex program has a stage id but no case in the
/// switch: its temp simply does not exist in the pixel stage. Selecting it shows the shaded result
/// rather than a wrong colour, which is the same fall-through rule the channels use.
/// </para>
/// </summary>
public static class PreviewInstrumentation
{
	/// <summary>The uniform the preview writes a node's stage id into.</summary>
	public const string StageIdUniform = PrismConstants.StageIdAttribute;

	/// <summary>The uniform the preview writes the selected debug channel into.</summary>
	public const string ChannelUniform = PrismConstants.ChannelAttribute;

	/// <summary>The stage id that means "no override": show whatever the shader normally produces.</summary>
	public const int NoStage = 0;

	/// <summary>Prefix for the locals the debug-channel tail declares.</summary>
	public const string LocalPrefix = "vPrismDbg";

	/// <summary>True when a compile mode wants the preview instrumentation.</summary>
	public static bool IsEnabled( CompileMode mode ) => mode is CompileMode.Preview or CompileMode.Thumbnail;

	// ---- stage numbering ---------------------------------------------------

	/// <summary>
	/// Number every node that produced generated code, in order of its first generated line, starting
	/// at one.
	/// <para>
	/// This must stay identical to <see cref="NodeThumbnailService.BuildStageMap"/>: the backend bakes
	/// these numbers into the switch and the preview recomputes them from the shipped artifact. If the
	/// two ever disagree, every node thumbnail shows a different node's value — a bug that looks like
	/// a rendering problem and is not one.
	/// </para>
	/// </summary>
	public static IReadOnlyDictionary<NodeId, int> BuildStageMap( SourceMap map )
	{
		var stages = new Dictionary<NodeId, int>();

		if ( map is null ) return stages;

		var next = 1;

		foreach ( var entry in map.Entries )
		{
			if ( !entry.Node.IsValid ) continue;
			if ( stages.ContainsKey( entry.Node ) ) continue;

			stages[entry.Node] = next++;
		}

		return stages;
	}

	/// <summary>
	/// The node-to-stage-id map for a finished compile, recovered from the s&amp;box artifact's source
	/// map. This is what a panel calls to find the value to push into
	/// <see cref="StageIdUniform"/> for the selected node.
	/// </summary>
	public static IReadOnlyDictionary<NodeId, int> StageMap( CompileResult result ) =>
		BuildStageMap( result?.Artifact( PrismConstants.BackendHlsl )?.SourceMap );

	/// <summary>The stage id of one node, or <see cref="NoStage"/> when it produced no code.</summary>
	public static int StageIdOf( IReadOnlyDictionary<NodeId, int> stages, NodeId node ) =>
		stages is not null && node.IsValid && stages.TryGetValue( node, out var id ) ? id : NoStage;

	// ---- declarations ------------------------------------------------------

	/// <summary>
	/// The two uniforms the instrumentation reads, in VFX annotation form.
	/// <para>
	/// Both are attribute-bound, so the preview and the thumbnail rig change what they display with a
	/// single <c>RenderAttributes.Set</c> and no recompile at all. They default to zero, which is the
	/// shaded result, so a shader compiled with instrumentation still renders normally when nothing
	/// pushes them.
	/// </para>
	/// </summary>
	public static IReadOnlyList<string> HlslDeclarations() =>
	[
		$"int {StageIdUniform} < Attribute( \"{StageIdUniform}\" ); Default( 0 ); >;",
		$"int {ChannelUniform} < Attribute( \"{ChannelUniform}\" ); Default( 0 ); >;"
	];

	/// <summary>The same two uniforms as plain Slang module-scope uniforms.</summary>
	public static IReadOnlyList<string> SlangDeclarations() =>
	[
		$"uniform int {StageIdUniform};",
		$"uniform int {ChannelUniform};"
	];

	// ---- the stage switch --------------------------------------------------

	/// <summary>
	/// True when a value can be shown as a colour. Matrices, textures, samplers, buffers and structs
	/// cannot, so they get no case in the switch and fall through to the shaded result.
	/// </summary>
	public static bool CanShow( ShaderType type ) => type.IsScalarOrVector && !type.IsVoid;

	/// <summary>
	/// Widen an expression of any showable type to an opaque <c>float4</c>. Booleans and integers
	/// convert rather than bitcast, so a boolean mask reads as black and white.
	/// <para>
	/// Alpha is always one, including for a four-component value whose own alpha is dropped. A preview
	/// mesh and a thumbnail are both drawn over a transparent background, so honouring a node's alpha
	/// would make half the thumbnails in a graph invisible; the alpha of a surface is what the
	/// <see cref="PreviewChannel.Opacity"/> channel is for.
	/// </para>
	/// </summary>
	public static string AsColor( string expression, ShaderType type )
	{
		if ( string.IsNullOrEmpty( expression ) || !CanShow( type ) ) return null;

		return Math.Clamp( type.Components, 1, 4 ) switch
		{
			1 => $"float4( ( {expression} ).xxx, 1.0f )",
			2 => $"float4( ( {expression} ), 0.0f, 1.0f )",
			3 => $"float4( ( {expression} ), 1.0f )",
			_ => $"float4( ( {expression} ).xyz, 1.0f )"
		};
	}

	/// <summary>
	/// The one-line switch case for a node's value:
	/// <c>if ( g_iPrismStageId == 7 ) return float4( t7, 1.0f );</c>
	/// Returns null when the value cannot be shown, in which case nothing is emitted.
	/// </summary>
	public static string StageCase( int stageId, string expression, ShaderType type )
	{
		if ( stageId <= NoStage ) return null;

		var colour = AsColor( expression, type );

		return colour is null ? null : $"if ( {StageIdUniform} == {stageId} ) return {colour};";
	}

	/// <summary>The comment that introduces the two preview uniforms in the generated pixel program.</summary>
	public static IReadOnlyList<string> Banner() =>
	[
		"// Prism preview instrumentation. Every node's value and every debug channel is reachable from",
		$"// this one shader: {StageIdUniform} picks a node, {ChannelUniform} picks a channel, and zero",
		"// means the shader's own output. Both are render attributes, so switching costs no recompile."
	];

	// ---- the debug channels ------------------------------------------------

	/// <summary>
	/// The debug-channel tail: one <c>if</c> per channel the shader can answer, in the order
	/// <see cref="PreviewChannels"/> declares them, plus the locals the derivative channels need.
	/// <para>
	/// Everything is flat — one statement per line, no nesting — so a caller only has to know how to
	/// write a line, and the same list serves the block-file writer and the Slang emitter. An empty
	/// result means this shader can answer no channel at all and nothing should be written.
	/// </para>
	/// <para>
	/// Channels the engine renders on its own — overdraw, shading complexity, wireframe — are absent
	/// by design: <see cref="PreviewChannels.Apply(PreviewChannel, CameraComponent)"/> serves those
	/// through <c>SceneCameraDebugMode</c> and needs nothing from the generated shader.
	/// </para>
	/// </summary>
	public static IReadOnlyList<string> ChannelLines( PreviewChannelEnvironment env )
	{
		if ( env is null ) return Array.Empty<string>();

		var uniform = string.IsNullOrEmpty( env.ChannelUniform ) ? ChannelUniform : env.ChannelUniform;
		var derivatives = !string.IsNullOrEmpty( env.DerivativeSource );
		var cases = new List<string>();

		Case( PreviewChannel.Albedo, Rgb( env.Albedo ) );
		Case( PreviewChannel.Opacity, Grey( env.Opacity ) );
		Case( PreviewChannel.NormalWorld, Signed( env.NormalWorld ) );
		Case( PreviewChannel.NormalTangent, Signed( env.NormalTangent ) );
		Case( PreviewChannel.Roughness, Grey( env.Roughness ) );
		Case( PreviewChannel.Metalness, Grey( env.Metalness ) );
		Case( PreviewChannel.AmbientOcclusion, Grey( env.AmbientOcclusion ) );
		Case( PreviewChannel.Emission, Rgb( env.Emission ) );
		Case( PreviewChannel.Transmission, Rgb( env.Transmission ) );
		Case( PreviewChannel.TintMask, Grey( env.TintMask ) );
		Case( PreviewChannel.Uv0, Uv( env.Uv0 ) );
		Case( PreviewChannel.Uv1, Uv( env.Uv1 ) );
		Case( PreviewChannel.VertexColor, Rgb( env.VertexColor, ".xyz" ) );
		Case( PreviewChannel.WorldPosition, Wrapped( env.WorldPosition, env.WorldPositionPeriod ) );

		if ( derivatives )
		{
			Case( PreviewChannel.Derivatives,
				$"float4( saturate( abs( {LocalPrefix}Ddx ) * 128.0f ), saturate( length( {LocalPrefix}Ddy ) * 128.0f ), 1.0f )" );

			Case( PreviewChannel.MipLevel,
				$"float4( saturate( {LocalPrefix}Mip / {Number( env.MipRange )} ).xxx, 1.0f )" );
		}

		if ( cases.Count == 0 ) return Array.Empty<string>();

		var lines = new List<string>( cases.Count + 8 )
		{
			"// Prism preview: the debug channels behind the viewport's channel strip.",
			$"// {uniform} is 0 for the shaded result. A channel this shader cannot answer has no case",
			"// here at all, so selecting it keeps showing the shaded result rather than a wrong colour."
		};

		if ( derivatives )
		{
			var reference = Number( env.MipReferenceSize );

			// Screen-space derivatives have to be taken in uniform control flow, so they are computed
			// before the switch rather than inside the two cases that read them.
			lines.Add( $"float2 {LocalPrefix}Ddx = ddx( {env.DerivativeSource} );" );
			lines.Add( $"float2 {LocalPrefix}Ddy = ddy( {env.DerivativeSource} );" );
			lines.Add( $"float2 {LocalPrefix}MipDx = {LocalPrefix}Ddx * {reference};" );
			lines.Add( $"float2 {LocalPrefix}MipDy = {LocalPrefix}Ddy * {reference};" );
			lines.Add( $"float {LocalPrefix}Mip = 0.5f * log2( max( max( dot( {LocalPrefix}MipDx, {LocalPrefix}MipDx ), " +
				$"dot( {LocalPrefix}MipDy, {LocalPrefix}MipDy ) ), 1.0e-8f ) );" );
		}

		lines.AddRange( cases );

		return lines;

		void Case( PreviewChannel channel, string colour )
		{
			if ( string.IsNullOrEmpty( colour ) ) return;

			cases.Add( $"if ( {uniform} == {(int)channel} ) return {colour};" );
		}
	}

	static string Rgb( string expression, string swizzle = null ) =>
		string.IsNullOrEmpty( expression )
			? null
			: $"float4( ( {expression} ){swizzle}, 1.0f )";

	static string Grey( string expression ) =>
		string.IsNullOrEmpty( expression )
			? null
			: $"float4( saturate( {expression} ).xxx, 1.0f )";

	static string Signed( string expression ) =>
		string.IsNullOrEmpty( expression )
			? null
			: $"float4( ( {expression} ) * 0.5f + 0.5f, 1.0f )";

	static string Uv( string expression ) =>
		string.IsNullOrEmpty( expression )
			? null
			: $"float4( frac( {expression} ), 0.0f, 1.0f )";

	static string Wrapped( string expression, float period ) =>
		string.IsNullOrEmpty( expression )
			? null
			: $"float4( frac( ( {expression} ) / {Number( period )} ), 1.0f )";

	/// <summary>
	/// Spell a constant the way both backends spell one, so the generated text stays byte-identical
	/// between regenerations of an unchanged graph.
	/// </summary>
	static string Number( float value ) => HlslIntrinsics.Number( value, ScalarKind.Float );
}