Editor/Prism/Compiler/Backends/HlslIntrinsics.cs

Backend helper class that maps the compiler IR intrinsics, builtins, literals, swizzles and object-transform helpers to HLSL text. It decides spelling, resource-method vs free-function form, stage-based lowers (implicit LOD to SampleLevel), emits calls and literals, provides fallbacks, normalises swizzles and returns helper HLSL snippets for object transforms.

Native Interop
using System.Globalization;
using System.Text;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;

namespace Editor.Prism.Compiler.Backends;

/// <summary>
/// The HLSL spelling of everything the IR can express: intrinsics, builtins, literals and swizzles.
/// <para>
/// This is the only place in the backend that knows what a canonical operation is actually called on
/// the s&amp;box target. Nodes emit <see cref="Intrinsic"/> ids; this table turns them into text,
/// picks the free-function or texture-method form, substitutes stage-legal replacements, and reports
/// the shader model an operation needs so the compiler can say
/// <em>"WaveMatch requires SM 6.5; s&amp;box compiles at SM 6.0 (Vulkan)"</em> instead of letting the
/// user find out from a raw compiler line.
/// </para>
/// </summary>
public static class HlslIntrinsics
{
	/// <summary>The shader model the engine actually compiles at.</summary>
	public static ShaderModel Target => ShaderModel.Target;

	// ---- naming -----------------------------------------------------------

	/// <summary>
	/// The HLSL spelling of a canonical operation. Falls back to the catalogue name, which is already
	/// the HLSL spelling for every operation that has one.
	/// </summary>
	public static string Spelling( Intrinsic id ) => id switch
	{
		Intrinsic.AndFn => "and",
		Intrinsic.OrFn => "or",
		Intrinsic.Select => "select",
		Intrinsic.TextureSize => "GetDimensions",
		_ => IntrinsicCatalog.Name( id )
	};

	/// <summary>
	/// True when the operation is a member of a texture or buffer object rather than a free function,
	/// so it is spelled <c>tex.Sample( s, uv )</c> and the first argument is the object.
	/// </summary>
	public static bool IsResourceMethod( Intrinsic id ) => id is
		Intrinsic.Sample or Intrinsic.SampleLevel or Intrinsic.SampleBias or Intrinsic.SampleGrad or
		Intrinsic.SampleCmp or Intrinsic.SampleCmpLevelZero or
		Intrinsic.Gather or Intrinsic.GatherRed or Intrinsic.GatherGreen or Intrinsic.GatherBlue or
		Intrinsic.GatherAlpha or Intrinsic.GatherCmp or
		Intrinsic.Load or
		Intrinsic.CalculateLevelOfDetail or Intrinsic.CalculateLevelOfDetailUnclamped;

	/// <summary>True when the operation returns nothing and can only appear as a statement.</summary>
	public static bool IsVoid( Intrinsic id ) => IntrinsicCatalog.Get( id ).Shape == IntrinsicShape.Void;

	// ---- gating -----------------------------------------------------------

	/// <summary>
	/// True when the operation exists on the s&amp;box compile target. <paramref name="required"/>
	/// receives the minimum shader model either way, so the caller can name it in a diagnostic.
	/// </summary>
	public static bool IsAvailable( Intrinsic id, out ShaderModel required )
	{
		var info = IntrinsicCatalog.Get( id );
		required = info.MinShaderModel;

		return required <= Target;
	}

	/// <summary>
	/// Substitute a stage-legal replacement for an operation that cannot run in
	/// <paramref name="stage"/>.
	/// <para>
	/// The only substitutions that preserve meaning are the implicit-LOD samplers: outside the pixel
	/// stage there are no screen-space derivatives, so <c>Sample</c> and <c>SampleBias</c> become
	/// <c>SampleLevel( ..., 0 )</c>. Derivatives, <c>clip</c> and attribute evaluation have no
	/// substitute and must be reported as errors.
	/// </para>
	/// </summary>
	public static bool TryLowerForStage( Intrinsic id, ShaderStage stage, out Intrinsic lowered )
	{
		lowered = id;

		if ( stage.HasDerivatives() ) return false;
		if ( IntrinsicCatalog.IsLegalIn( id, stage ) ) return false;

		switch ( id )
		{
			case Intrinsic.Sample:
			case Intrinsic.SampleBias:
				lowered = Intrinsic.SampleLevel;
				return true;

			case Intrinsic.SampleCmp:
				lowered = Intrinsic.SampleCmpLevelZero;
				return true;

			default:
				return false;
		}
	}

	// ---- call emission ----------------------------------------------------

	/// <summary>
	/// Render a call to a canonical operation.
	/// <para>
	/// Handles the free-function and texture-method forms, inserts the explicit mip level when an
	/// implicit-LOD sampler has been lowered for a non-pixel stage, and routes
	/// <see cref="Intrinsic.TextureSize"/> through the engine's <c>TextureDimensions*</c> helpers
	/// because the raw <c>GetDimensions</c> form has out parameters and is not an expression.
	/// </para>
	/// </summary>
	public static string Call( Intrinsic id, IReadOnlyList<string> args, IReadOnlyList<ShaderType> argTypes,
		ShaderStage stage, HlslDialect dialect )
	{
		args ??= Array.Empty<string>();
		argTypes ??= Array.Empty<ShaderType>();

		if ( id == Intrinsic.TextureSize ) return TextureDimensions( args, argTypes );

		var lowered = TryLowerForStage( id, stage, out var replacement );
		var effective = lowered ? replacement : id;
		var arguments = lowered ? AdjustForLowering( id, effective, args ) : args;

		if ( IsResourceMethod( effective ) && arguments.Count >= 1 )
		{
			var receiver = arguments[0];
			var rest = new string[arguments.Count - 1];

			for ( int i = 1; i < arguments.Count; i++ ) rest[i - 1] = arguments[i];

			return $"{receiver}.{Spelling( effective )}({Arguments( rest )})";
		}

		// Slang's `and`/`or` accept vectors; strict HLSL 2021 does too, but the operator form reads
		// better for scalars and avoids relying on a 2021-only builtin where it is not needed.
		if ( dialect == HlslDialect.StrictHlsl2021 && arguments.Count == 2 &&
			 effective is Intrinsic.AndFn or Intrinsic.OrFn &&
			 argTypes.Count == 2 && argTypes[0].IsScalar && argTypes[1].IsScalar )
		{
			var symbol = effective == Intrinsic.AndFn ? "&&" : "||";
			return $"( {arguments[0]} {symbol} {arguments[1]} )";
		}

		return $"{Spelling( effective )}({Arguments( arguments )})";
	}

	static IReadOnlyList<string> AdjustForLowering( Intrinsic from, Intrinsic to, IReadOnlyList<string> args )
	{
		// Sample( t, s, uv[, offset] )      -> t.SampleLevel( s, uv, 0.0[, offset] )
		// SampleBias( t, s, uv, bias[, o] ) -> t.SampleLevel( s, uv, 0.0[, offset] )
		if ( to != Intrinsic.SampleLevel ) return args;

		// The texture, the sampler and the coordinate carry over unchanged; the bias, if there was one,
		// is dropped because there is no mip chain selection to bias outside the pixel stage.
		var result = new List<string>( args.Count + 1 );

		for ( int i = 0; i < args.Count && i < 3; i++ ) result.Add( args[i] );

		while ( result.Count < 3 ) result.Add( "0.0f" );

		result.Add( "0.0f" );

		var tail = from == Intrinsic.SampleBias ? 4 : 3;

		for ( int i = tail; i < args.Count; i++ ) result.Add( args[i] );

		return result;
	}

	static string TextureDimensions( IReadOnlyList<string> args, IReadOnlyList<ShaderType> argTypes )
	{
		var texture = args.Count > 0 ? args[0] : "0";
		var mip = args.Count > 1 ? args[1] : "0";
		var kind = argTypes.Count > 0 ? argTypes[0].Object : ObjectKind.Texture2D;

		var helper = kind switch
		{
			ObjectKind.Texture1D => "TextureDimensions1D",
			ObjectKind.Texture3D => "TextureDimensions3D",
			ObjectKind.TextureCube => "TextureDimensionsCube",
			ObjectKind.TextureCubeArray => "TextureDimensionsCubeArray",
			ObjectKind.Texture2DArray => "TextureDimensions2DArray",
			_ => "TextureDimensions2D"
		};

		return $"{helper}( {texture}, {mip} )";
	}

	static string Arguments( IReadOnlyList<string> args )
	{
		if ( args is null || args.Count == 0 ) return string.Empty;

		var builder = new StringBuilder();

		builder.Append( ' ' );

		for ( int i = 0; i < args.Count; i++ )
		{
			if ( i > 0 ) builder.Append( ", " );
			builder.Append( args[i] );
		}

		builder.Append( ' ' );

		return builder.ToString();
	}

	// ---- builtins ---------------------------------------------------------

	/// <summary>
	/// The expression an environment-provided value lowers to <em>in a given stage</em>.
	/// <para>
	/// This is the whole reason nodes never see HLSL: world position is <c>i.vPositionWs</c> in the
	/// vertex stage but <c>i.vPositionWithOffsetWs + g_vHighPrecisionLightingOffsetWs</c> in the pixel
	/// stage, because <c>FinalizeVertex</c> subtracts a camera-relative offset for precision. Getting
	/// that wrong is a compile error at best and a silent world-space bug at worst.
	/// </para>
	/// <para>
	/// Returns <c>null</c> when the value has no representation in this stage or domain; the caller
	/// reports a diagnostic and substitutes <see cref="Fallback"/>.
	/// </para>
	/// </summary>
	public static string BuiltinExpression( Builtin id, ShaderStage stage, ShaderDomain domain )
	{
		var vs = stage == ShaderStage.Vertex;
		var ps = stage == ShaderStage.Pixel;
		var cs = stage == ShaderStage.Compute;

		switch ( id )
		{
			// -- geometry
			case Builtin.WorldPosition:
				if ( cs ) return null;
				return vs
					? "i.vPositionWs.xyz"
					: "( i.vPositionWithOffsetWs.xyz + g_vHighPrecisionLightingOffsetWs.xyz )";

			case Builtin.ObjectPosition:
				if ( cs || domain != ShaderDomain.Surface ) return null;
				return vs ? "v.vPositionOs.xyz" : "i.vPositionOs.xyz";

			case Builtin.ObjectOrigin:
				if ( !vs || domain != ShaderDomain.Surface ) return null;
				return "mul( GetTransformMatrix( v.nInstanceTransformID, v.nBoneIndex.x ), float4( 0.0, 0.0, 0.0, 1.0 ) )";

			// Scale is the length of each column of the object-to-world linear part. That is exact in
			// the vertex stage, where the real matrix exists. The pixel stage only ever sees the
			// orthonormalised tangent frame, whose scale the vertex program already normalised away, so
			// there is nothing honest to return there and the caller reports it.
			case Builtin.ObjectScale:
				if ( !vs || domain != ShaderDomain.Surface ) return null;
				return $"{ObjectScaleFunction}( {SboxShaderTemplates.VertexInputLocal} )";

			case Builtin.WorldNormal:
				return cs ? null : "i.vNormalWs.xyz";

			case Builtin.ObjectNormal:
				if ( cs || domain != ShaderDomain.Surface ) return null;
				return "i.vNormalOs.xyz";

			case Builtin.WorldTangentU:
				return cs ? null : "i.vTangentUWs.xyz";

			case Builtin.WorldTangentV:
				return cs ? null : "i.vTangentVWs.xyz";

			case Builtin.ObjectTangentU:
				if ( cs || domain != ShaderDomain.Surface ) return null;
				return "i.vTangentUOs_flTangentVSign.xyz";

			// -- vertex attributes
			case Builtin.VertexColor:
				if ( cs || domain != ShaderDomain.Surface ) return null;
				return "i.vColor";

			case Builtin.TintColor:
				if ( cs || domain != ShaderDomain.Surface ) return null;
				return "i.vTintColor";

			case Builtin.TexCoord0:
				return cs ? null : "i.vTextureCoords.xy";

			case Builtin.TexCoord1:
				return cs ? null : "i.vTextureCoords.zw";

			// -- screen space
			case Builtin.ClipPosition:
				if ( cs ) return null;
				return vs ? "i.vPositionPs" : "i.vPositionSs";

			case Builtin.ScreenUv:
				if ( cs ) return null;
				return vs
					? "CalculateViewportUv( i.vPositionPs.xy )"
					: "CalculateViewportUv( i.vPositionSs.xy )";

			case Builtin.PixelPosition:
				return ps ? "i.vPositionSs.xy" : null;

			case Builtin.FragmentDepth:
				return ps ? "i.vPositionSs.z" : null;

			case Builtin.ViewDirection:
				if ( cs ) return null;
				return vs
					? "CalculatePositionToCameraDirWs( i.vPositionWs.xyz )"
					: "CalculatePositionToCameraDirWs( i.vPositionWithOffsetWs.xyz + g_vHighPrecisionLightingOffsetWs.xyz )";

			// -- view constants, available everywhere
			case Builtin.CameraPosition: return "g_vCameraPositionWs";
			case Builtin.CameraForward: return "g_vCameraDirWs";
			case Builtin.CameraNear: return "g_flNearPlane";
			case Builtin.CameraFar: return "g_flFarPlane";
			case Builtin.ViewportSize: return "g_vViewportSize";
			case Builtin.ViewportInvSize: return "g_vInvViewportSize";
			case Builtin.ViewportOffset: return "g_vViewportOffset";
			case Builtin.Time: return "g_flTime";

			case Builtin.SunDirection: return "g_DirectionalLightDirection.xyz";
			case Builtin.SunColor: return "g_DirectionalLightColor.rgb";

			case Builtin.ViewMatrix: return "g_matWorldToView";
			case Builtin.ProjectionMatrix: return "g_matViewToProjection";
			case Builtin.ViewProjectionMatrix: return "g_matWorldToProjection";

			// TODO(WP-5): the engine exposes no per-frame delta or frame counter in
			// PerViewConstantBuffer_t; a graph that wants them should declare a render attribute.
			case Builtin.DeltaTime:
			case Builtin.FrameCount:
				return null;

			// The engine builds the object transform from the instancing table while the vertex is
			// being transformed and never publishes it as a global, so there is one expression per
			// stage: the real matrix in the vertex program, and a reconstruction from the interpolated
			// tangent frame in the pixel program. See ObjectTransformVertexHelpers.
			case Builtin.ObjectToWorld:
				if ( cs || domain != ShaderDomain.Surface ) return null;
				return vs
					? $"{ObjectToWorldFunction}( {SboxShaderTemplates.VertexInputLocal} )"
					: $"{ObjectToWorldFunction}( {SboxShaderTemplates.PixelInputLocal} )";

			case Builtin.WorldToObject:
				if ( cs || domain != ShaderDomain.Surface ) return null;
				return vs
					? $"{WorldToObjectFunction}( {SboxShaderTemplates.VertexInputLocal} )"
					: $"{WorldToObjectFunction}( {SboxShaderTemplates.PixelInputLocal} )";

			case Builtin.IsFrontFace:
				if ( domain != ShaderDomain.Surface ) return null;
				return ps ? "i.vFrontFacing" : "true";

			case Builtin.InstanceId:
				if ( !vs || domain != ShaderDomain.Surface ) return null;
				return "v.nInstanceTransformID";

			// common/vertexinput.hlsl declares no vertex-id stream, so the s&box writer takes it as an
			// extra SV_VertexID parameter on MainVs — but only for a graph that actually reads it, which
			// is why the pixel stage still has to route it through a varying.
			case Builtin.VertexId:
				return vs ? SboxShaderTemplates.VertexIdParameter : null;

			// -- compute
			case Builtin.DispatchThreadId: return cs ? "vThreadId" : null;
			case Builtin.GroupThreadId: return cs ? "vGroupThreadId" : null;
			case Builtin.GroupId: return cs ? "vGroupId" : null;

			default:
				return null;
		}
	}

	/// <summary>
	/// A neutral, well-typed value to substitute when something cannot be expressed. Never a silent
	/// wrong answer — the caller always attaches a diagnostic alongside it.
	/// </summary>
	public static string Fallback( ShaderType type )
	{
		if ( type.IsVoid ) return "0";
		if ( type.IsMatrix ) return type.Rows == type.Cols ? Identity( type ) : $"( {type.Hlsl} )0";
		if ( type.IsObject ) return "0";

		return Literal( type, ConstValue.Zero );
	}

	static string Identity( ShaderType type )
	{
		var builder = new StringBuilder();

		builder.Append( type.Hlsl ).Append( "( " );

		for ( int r = 0; r < type.Rows; r++ )
		{
			for ( int c = 0; c < type.Cols; c++ )
			{
				if ( r > 0 || c > 0 ) builder.Append( ", " );
				builder.Append( r == c ? "1.0f" : "0.0f" );
			}
		}

		builder.Append( " )" );

		return builder.ToString();
	}

	// ---- object transform -------------------------------------------------

	/// <summary>Name of the generated object-to-world helper.</summary>
	public const string ObjectToWorldFunction = "PrismObjectToWorld";

	/// <summary>Name of the generated world-to-object helper.</summary>
	public const string WorldToObjectFunction = "PrismWorldToObject";

	/// <summary>Name of the generated object-scale helper.</summary>
	public const string ObjectScaleFunction = "PrismObjectScale";

	/// <summary>
	/// The vertex-stage object transform helpers.
	/// <para>
	/// s&amp;box publishes no object-to-world global. The transform lives in the instancing table and
	/// <c>GetTransformMatrix</c> is the only way to it, which is why every shipped shader that needs it
	/// (<c>bark</c>, <c>foliage</c>, <c>gizmo_line</c>, <c>terrain_brush</c>) reads it in the vertex
	/// program from <c>v.nInstanceTransformID</c>. These three helpers wrap that, widen the affine
	/// <c>float3x4</c> to the <c>float4x4</c> the IR's type says a transform matrix is, and derive the
	/// inverse and the scale from it. All three are exact.
	/// </para>
	/// </summary>
	public const string ObjectTransformVertexHelpers =
		"""
		// The engine keeps the object transform in the instancing table rather than a constant, so the
		// vertex program is the only place it exists. GetTransformMatrix returns the affine 3x4; the
		// fourth row of the equivalent 4x4 is the implicit ( 0, 0, 0, 1 ).
		float4x4 PrismObjectToWorld( VertexInput v )
		{
			float3x4 mAffine = GetTransformMatrix( v.nInstanceTransformID );

			return float4x4( mAffine[0], mAffine[1], mAffine[2], float4( 0.0, 0.0, 0.0, 1.0 ) );
		}

		// The transform is affine, so the cofactor inverse of its linear part is exact and far cheaper
		// than a general 4x4 inverse. The three cross products are the columns of the adjugate, which is
		// why the assembled matrix is transposed.
		float4x4 PrismWorldToObject( VertexInput v )
		{
			float4x4 mObjectToWorld = PrismObjectToWorld( v );
			float3x3 mLinear = float3x3( mObjectToWorld[0].xyz, mObjectToWorld[1].xyz, mObjectToWorld[2].xyz );
			float3 vTranslation = float3( mObjectToWorld[0].w, mObjectToWorld[1].w, mObjectToWorld[2].w );

			float3 vCofactorX = cross( mLinear[1], mLinear[2] );
			float3 vCofactorY = cross( mLinear[2], mLinear[0] );
			float3 vCofactorZ = cross( mLinear[0], mLinear[1] );
			float flDeterminant = dot( mLinear[0], vCofactorX );
			float flRcpDeterminant = abs( flDeterminant ) > 1.0e-12 ? 1.0 / flDeterminant : 0.0;

			float3x3 mInverse = transpose( float3x3( vCofactorX, vCofactorY, vCofactorZ ) * flRcpDeterminant );
			float3 vInverseTranslation = -mul( mInverse, vTranslation );

			return float4x4(
				float4( mInverse[0], vInverseTranslation.x ),
				float4( mInverse[1], vInverseTranslation.y ),
				float4( mInverse[2], vInverseTranslation.z ),
				float4( 0.0, 0.0, 0.0, 1.0 ) );
		}

		// Scale is the length of each column of the linear part, which survives any rotation.
		float3 PrismObjectScale( VertexInput v )
		{
			float4x4 mObjectToWorld = PrismObjectToWorld( v );
			float3x3 mColumns = transpose( float3x3( mObjectToWorld[0].xyz, mObjectToWorld[1].xyz, mObjectToWorld[2].xyz ) );

			return float3( length( mColumns[0] ), length( mColumns[1] ), length( mColumns[2] ) );
		}
		""";

	/// <summary>
	/// The pixel-stage object transform helpers.
	/// <para>
	/// Nothing interpolates a matrix to the pixel program and there is no room to: a
	/// <c>float4x4</c> would cost four of the twelve interpolator slots a graph has. What the pixel
	/// program does have is the same tangent frame expressed twice — in object space through
	/// <c>vNormalOs</c> / <c>vTangentUOs_flTangentVSign</c>, and in world space through
	/// <c>vNormalWs</c> / <c>vTangentUWs</c> / <c>vTangentVWs</c> — plus the same position expressed
	/// twice. Two orthonormal frames and a point determine a rigid transform exactly, so that is what
	/// is rebuilt.
	/// </para>
	/// <para>
	/// <b>The limit is scale.</b> The vertex program normalises the world-space frame, so the
	/// reconstruction recovers rotation and translation exactly and always reports unit scale. That is
	/// why <see cref="Builtin.ObjectScale"/> deliberately has no pixel-stage expression: an
	/// interpolated frame simply does not carry the answer, and returning 1 would be a silent lie.
	/// </para>
	/// </summary>
	public const string ObjectTransformPixelHelpers =
		"""
		// No matrix is interpolated, so the transform is rebuilt from the object-space and world-space
		// tangent frames the vertex program already passes through. Exact for rotation and translation;
		// scale was normalised away in the vertex program, so this always reports unit scale.
		float4x4 PrismObjectToWorld( PixelInput i )
		{
			float3 vTangentOs = i.vTangentUOs_flTangentVSign.xyz;
			float3 vBitangentOs = cross( i.vNormalOs.xyz, vTangentOs ) * i.vTangentUOs_flTangentVSign.w;

			float3x3 mObjectFrame = float3x3( vTangentOs, vBitangentOs, i.vNormalOs.xyz );
			float3x3 mWorldFrame = float3x3( i.vTangentUWs.xyz, i.vTangentVWs.xyz, i.vNormalWs.xyz );
			float3x3 mRotation = mul( transpose( mWorldFrame ), mObjectFrame );

			float3 vPositionWs = i.vPositionWithOffsetWs.xyz + g_vHighPrecisionLightingOffsetWs.xyz;
			float3 vTranslation = vPositionWs - mul( mRotation, i.vPositionOs.xyz );

			return float4x4(
				float4( mRotation[0], vTranslation.x ),
				float4( mRotation[1], vTranslation.y ),
				float4( mRotation[2], vTranslation.z ),
				float4( 0.0, 0.0, 0.0, 1.0 ) );
		}

		// The reconstructed rotation is orthonormal, so its inverse is its transpose.
		float4x4 PrismWorldToObject( PixelInput i )
		{
			float4x4 mObjectToWorld = PrismObjectToWorld( i );
			float3x3 mRotation = float3x3( mObjectToWorld[0].xyz, mObjectToWorld[1].xyz, mObjectToWorld[2].xyz );
			float3 vTranslation = float3( mObjectToWorld[0].w, mObjectToWorld[1].w, mObjectToWorld[2].w );

			float3x3 mInverse = transpose( mRotation );
			float3 vInverseTranslation = -mul( mInverse, vTranslation );

			return float4x4(
				float4( mInverse[0], vInverseTranslation.x ),
				float4( mInverse[1], vInverseTranslation.y ),
				float4( mInverse[2], vInverseTranslation.z ),
				float4( 0.0, 0.0, 0.0, 1.0 ) );
		}
		""";

	/// <summary>
	/// The object transform helper bodies a stage needs, or null when that stage has none. Only the
	/// surface domain has an object space at all; a post-process or compute program has no mesh.
	/// </summary>
	public static string ObjectTransformHelpers( ShaderStage stage, ShaderDomain domain )
	{
		if ( domain != ShaderDomain.Surface ) return null;

		return stage switch
		{
			ShaderStage.Vertex => ObjectTransformVertexHelpers,
			ShaderStage.Pixel => ObjectTransformPixelHelpers,
			_ => null
		};
	}

	// ---- literals ---------------------------------------------------------

	/// <summary>
	/// Spell a literal. Formatting is round-trip exact and culture invariant, because
	/// byte-identical regeneration of an unchanged graph is what makes the "text changed?"
	/// short-circuit before recompiling reliable.
	/// </summary>
	public static string Literal( ShaderType type, ConstValue value )
	{
		if ( type.IsMatrix )
		{
			// ConstValue holds four components; a real matrix literal cannot round-trip through it.
			return $"( {type.Hlsl} ){Number( value.X, type.Scalar )}";
		}

		var components = Math.Clamp( type.Components, 1, 4 );

		if ( components == 1 ) return Number( value[0], type.Scalar );

		var builder = new StringBuilder();

		builder.Append( type.Hlsl ).Append( "( " );

		for ( int i = 0; i < components; i++ )
		{
			if ( i > 0 ) builder.Append( ", " );
			builder.Append( Number( value[i], type.Scalar ) );
		}

		builder.Append( " )" );

		return builder.ToString();
	}

	/// <summary>Spell one scalar component of a literal.</summary>
	public static string Number( double value, ScalarKind kind )
	{
		switch ( kind )
		{
			case ScalarKind.Bool:
				return value != 0 ? "true" : "false";

			case ScalarKind.Int:
				return ( (long)Math.Round( ClampFinite( value, int.MinValue, int.MaxValue ) ) )
					.ToString( CultureInfo.InvariantCulture );

			case ScalarKind.UInt:
				return ( (ulong)Math.Round( ClampFinite( value, 0, uint.MaxValue ) ) )
					.ToString( CultureInfo.InvariantCulture ) + "u";

			case ScalarKind.Double:
				return Decimalise( ClampFinite( value, double.MinValue, double.MaxValue )
					.ToString( "R", CultureInfo.InvariantCulture ) );

			default:
				var single = (float)ClampFinite( value, float.MinValue, float.MaxValue );
				return Decimalise( single.ToString( "R", CultureInfo.InvariantCulture ) ) + "f";
		}
	}

	static double ClampFinite( double value, double min, double max )
	{
		if ( double.IsNaN( value ) ) return 0;
		if ( double.IsPositiveInfinity( value ) ) return max;
		if ( double.IsNegativeInfinity( value ) ) return min;

		return Math.Clamp( value, min, max );
	}

	static string Decimalise( string text )
	{
		if ( text.IndexOf( '.' ) >= 0 || text.IndexOf( 'E' ) >= 0 || text.IndexOf( 'e' ) >= 0 ) return text;

		return text + ".0";
	}

	// ---- swizzles ---------------------------------------------------------

	/// <summary>
	/// Normalise a swizzle mask to the <c>xyzw</c> set. HLSL rejects a mask that mixes
	/// <c>xyzw</c> with <c>rgba</c>, and a node that built one from two sources would otherwise fail
	/// in the compiler rather than here.
	/// </summary>
	public static string NormalizeSwizzle( string mask )
	{
		if ( string.IsNullOrEmpty( mask ) ) return string.Empty;

		var builder = new StringBuilder( mask.Length );

		foreach ( var c in mask )
		{
			builder.Append( char.ToLowerInvariant( c ) switch
			{
				'x' or 'r' or 's' => 'x',
				'y' or 'g' or 't' => 'y',
				'z' or 'b' or 'p' => 'z',
				'w' or 'a' or 'q' => 'w',
				_ => 'x'
			} );
		}

		return builder.ToString();
	}

	/// <summary>The leading <c>.xyzw</c> mask for a component count, e.g. 3 gives <c>xyz</c>.</summary>
	public static string LeadingMask( int components ) => Math.Clamp( components, 1, 4 ) switch
	{
		1 => "x",
		2 => "xy",
		3 => "xyz",
		_ => "xyzw"
	};

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

	/// <summary>
	/// The naming prefix the engine's own shaders and the built-in generator use for a type. Offered
	/// so parameter lowering can mint conventional names; the backend never renames a declaration it
	/// is handed, because <see cref="IrGlobalRef"/> already refers to it.
	/// </summary>
	public static string SuggestedPrefix( ShaderType type ) => GraphCompiler.SymbolPrefix( type );

	/// <summary>
	/// The HLSL type a module-level declaration is written with.
	/// <para>
	/// Read-only textures default their element type to <c>float4</c>, but writable textures and every
	/// buffer flavour <em>require</em> the template argument, so it is supplied here rather than left
	/// to produce a bare <c>RWTexture2D</c> the compiler rejects.
	/// </para>
	/// </summary>
	public static string DeclarationType( GlobalDecl decl )
	{
		if ( decl is null ) return "float";

		var type = decl.Type;

		if ( type.IsObject )
		{
			return RequiresElementType( type.Object ) ? $"{type.Hlsl}<float4>" : type.Hlsl;
		}

		return decl.Kind switch
		{
			GlobalKind.Buffer => $"StructuredBuffer<{type.Hlsl}>",
			GlobalKind.RwBuffer => $"RWStructuredBuffer<{type.Hlsl}>",
			GlobalKind.RwTexture => $"RWTexture2D<{type.Hlsl}>",
			_ => type.Hlsl
		};
	}

	/// <summary>True when an opaque resource type cannot be spelled without a template argument.</summary>
	public static bool RequiresElementType( ObjectKind kind ) => kind is
		ObjectKind.Buffer or ObjectKind.StructuredBuffer or ObjectKind.Texture2DMS or
		ObjectKind.RWBuffer or ObjectKind.RWStructuredBuffer or
		ObjectKind.RWTexture2D or ObjectKind.RWTexture3D;
}