Editor/UpgradedShaderGraph/Nodes/UniversalNodes/VectorTransform.cs

Editor UI and shader graph node for a Vector Transform node in the Shader Graph Extras package for Universal. Defines a ShaderNode (SGEVectorTransformNode) that emits HLSL function calls to convert vectors between Tangent/Object/World/View spaces for Position, Normal and Direction modes, and a custom inspector control (SGEVectorTransformOutputSpaceControlWidget) that filters OutputSpace enum options based on the node Mode, plus a popup menu widget class for entries.

File AccessNative Interop
namespace Editor.ShaderGraphExtras.Nodes;

[Title( "SGE - Vector Transform" ), Category( "Shader Graph Extras - Universal" ), Icon( "transform" )]
public sealed class SGEVectorTransformNode : ShaderNode
{
	public enum SGEVectorTransformInputSpace
	{
		Tangent,
		Object,
		World
	}

	public enum SGEVectorTransformOutputSpace
	{
		Tangent,
		Object,
		World,
		View
	}

	public enum SGEVectorTransformMode
	{
		Position,
		Normal,
		Direction
	}

	[Hide, JsonIgnore]
	private SGEVectorTransformMode _lastMode;

	public override void OnFrame()
	{
		base.OnFrame();

		// Auto-correct OutputSpace when Mode changes
		if ( _lastMode != Mode )
		{
			_lastMode = Mode;

			if ( Mode == SGEVectorTransformMode.Direction )
			{
				// Direction mode only supports View output
				OutputSpace = SGEVectorTransformOutputSpace.View;
			}
			else if ( OutputSpace == SGEVectorTransformOutputSpace.View )
			{
				// Position/Normal modes don't support View, switch to World
				OutputSpace = SGEVectorTransformOutputSpace.World;
			}
		}
	}

	[Input( typeof( Vector3 ) )]
	[Hide]
	public NodeInput Input { get; set; }

	public SGEVectorTransformInputSpace InputSpace { get; set; } = SGEVectorTransformInputSpace.Tangent;
	public SGEVectorTransformOutputSpace OutputSpace { get; set; } = SGEVectorTransformOutputSpace.World;
	public SGEVectorTransformMode Mode { get; set; } = SGEVectorTransformMode.Normal;

	[Output( typeof( Vector4 ) )]
	[Hide]
	public NodeResult.Func Output => ( GraphCompiler compiler ) =>
	{
		var input = compiler.Result( Input );

		var worldSpacePosition = compiler.IsVs ? "i.vPositionWs" : "i.vPositionWithOffsetWs.xyz + g_vHighPrecisionLightingOffsetWs.xyz";
		var worldSpaceNormal = "i.vNormalWs";
		var worldSpaceTangentU = "i.vTangentUWs";
		var worldSpaceTangentV = "i.vTangentVWs";

		var objectSpacePosition = "i.vPositionOs";
		var objectSpaceNormal = "i.vNormalOs.xyz";
		var objectSpaceTangentU = "i.vTangentUOs_flTangentVSign.xyz";
		var objectSpaceTangentV = "cross( i.vNormalOs.xyz, i.vTangentUOs_flTangentVSign.xyz ) * i.vTangentUOs_flTangentVSign.w";

		compiler.RegisterInclude( "shaders/HLSL/Functions/FUNC-vector_transform.hlsl" );

		NodeResult nodeResult = new NodeResult();

		switch ( Mode )
		{
			case SGEVectorTransformMode.Position:
				switch ( InputSpace )
				{
					case SGEVectorTransformInputSpace.Tangent:
						switch ( OutputSpace )
						{
							case SGEVectorTransformOutputSpace.Tangent:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGETangentToTangentPosition( {input} )" );
								break;
							case SGEVectorTransformOutputSpace.Object:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGETangentToObjectPosition( {input}, {objectSpaceNormal}, {objectSpaceTangentU}, {objectSpaceTangentV} )" );
								break;
							case SGEVectorTransformOutputSpace.World:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGETangentToWorldPosition( {input}, {worldSpaceNormal}, {worldSpaceTangentU}, {worldSpaceTangentV} )" );
								break;
						}
						break;
					case SGEVectorTransformInputSpace.Object:
						switch ( OutputSpace )
						{
							case SGEVectorTransformOutputSpace.Tangent:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEObjectToTangentPosition( {input}, {objectSpaceNormal}, {objectSpaceTangentU}, {objectSpaceTangentV} )" );
								break;
							case SGEVectorTransformOutputSpace.Object:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEObjectToObjectPosition( {input} )" );
								break;
							case SGEVectorTransformOutputSpace.World:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEObjectToWorldPosition( {input}, {objectSpacePosition}, {worldSpacePosition} )" );
								break;
						}
						break;
					case SGEVectorTransformInputSpace.World:
						switch ( OutputSpace )
						{
							case SGEVectorTransformOutputSpace.Tangent:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEWorldToTangentPosition( {input}, {worldSpaceNormal}, {worldSpaceTangentU}, {worldSpaceTangentV} )" );
								break;
							case SGEVectorTransformOutputSpace.Object:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEWorldToObjectPosition( {input}, {worldSpacePosition}, {objectSpacePosition} )" );
								break;
							case SGEVectorTransformOutputSpace.World:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEWorldToWorldPosition( {input} )" );
								break;
						}
						break;
				}
				break;

			case SGEVectorTransformMode.Normal:
				switch ( InputSpace )
				{
					case SGEVectorTransformInputSpace.Tangent:
						switch ( OutputSpace )
						{
							case SGEVectorTransformOutputSpace.Tangent:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGETangentToTangentNormal( {input} )" );
								break;
							case SGEVectorTransformOutputSpace.Object:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGETangentToObjectNormal( {input}, {objectSpaceNormal}, {objectSpaceTangentU}, {objectSpaceTangentV} )" );
								break;
							case SGEVectorTransformOutputSpace.World:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGETangentToWorldNormal( {input}, {worldSpaceNormal}, {worldSpaceTangentU}, {worldSpaceTangentV} )" );
								break;
						}
						break;
					case SGEVectorTransformInputSpace.Object:
						switch ( OutputSpace )
						{
							case SGEVectorTransformOutputSpace.Tangent:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEObjectToTangentNormal( {input}, {objectSpaceNormal}, {objectSpaceTangentU}, {objectSpaceTangentV} )" );
								break;
							case SGEVectorTransformOutputSpace.Object:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEObjectToObjectNormal( {input} )" );
								break;
							case SGEVectorTransformOutputSpace.World:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEObjectToWorldNormal( {input}, {worldSpaceNormal}, {worldSpaceTangentU}, {worldSpaceTangentV}, {objectSpaceNormal}, {objectSpaceTangentU}, {objectSpaceTangentV} )" );
								break;
						}
						break;
					case SGEVectorTransformInputSpace.World:
						switch ( OutputSpace )
						{
							case SGEVectorTransformOutputSpace.Tangent:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEWorldToTangentNormal( {input}, {worldSpaceNormal}, {worldSpaceTangentU}, {worldSpaceTangentV} )" );
								break;
							case SGEVectorTransformOutputSpace.Object:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEWorldToObjectNormal( {input}, {worldSpaceNormal}, {worldSpaceTangentU}, {worldSpaceTangentV}, {objectSpaceNormal}, {objectSpaceTangentU}, {objectSpaceTangentV} )" );
								break;
							case SGEVectorTransformOutputSpace.World:
								nodeResult = new NodeResult( NodeResultType.Vector3, $"SGEWorldToWorldNormal( {input} )" );
								break;
						}
						break;
				}
				break;

			case SGEVectorTransformMode.Direction:
				switch ( InputSpace )
				{
					case SGEVectorTransformInputSpace.Tangent:
						nodeResult = new NodeResult( NodeResultType.Color, $"SGETangentToViewDirection( {input}, {worldSpaceNormal}, {worldSpaceTangentU}, {worldSpaceTangentV} )" );
						break;
					case SGEVectorTransformInputSpace.Object:
						nodeResult = new NodeResult( NodeResultType.Color, $"SGEObjectToViewDirection( {input}, {objectSpacePosition}, {worldSpacePosition} )" );
						break;
					case SGEVectorTransformInputSpace.World:
						nodeResult = new NodeResult( NodeResultType.Color, $"SGEWorldToViewDirection( {input} )" );
						break;
				}
				break;
		}

		return nodeResult;
	};
}

/// <summary>
/// Custom enum editor for SGEVectorTransformOutputSpace that shows View only when Mode is Direction
/// </summary>
[CustomEditor( typeof( SGEVectorTransformNode.SGEVectorTransformOutputSpace ) )]
public class SGEVectorTransformOutputSpaceControlWidget : ControlWidget
{
	SGEVectorTransformNode node;
	EnumDescription enumDesc;
	PopupWidget _menu;

	public override bool IsControlActive => base.IsControlActive || _menu.IsValid();
	public override bool IsControlButton => true;
	public override bool IsControlHovered => base.IsControlHovered || _menu.IsValid();

	public SGEVectorTransformOutputSpaceControlWidget( SerializedProperty property ) : base( property )
	{
		node = property.Parent?.Targets?.FirstOrDefault() as SGEVectorTransformNode;

		var propertyType = property.PropertyType;
		Cursor = CursorShape.Finger;
		Layout = Layout.Row();
		Layout.Spacing = 2;
		enumDesc = EditorTypeLibrary.GetEnumDescription( propertyType );
	}

	private bool IsEntrySupported( EnumDescription.Entry entry )
	{
		if ( node is null )
			return entry.Browsable;

		if ( !entry.Browsable )
			return false;

		// View is only available when Mode is Direction
		if ( entry.Name == nameof( SGEVectorTransformNode.SGEVectorTransformOutputSpace.View ) )
		{
			return node.Mode == SGEVectorTransformNode.SGEVectorTransformMode.Direction;
		}

		// Tangent, World, Object are only available when Mode is NOT Direction
		return node.Mode != SGEVectorTransformNode.SGEVectorTransformMode.Direction;
	}

	protected override void PaintControl()
	{
		if ( enumDesc is null )
			return;

		var value = SerializedProperty.GetValue<long>( 0 );
		var color = IsControlHovered ? Theme.Blue : Theme.TextControl;
		var rect = LocalRect.Shrink( 8, 0 );

		var e = enumDesc.GetEntry( value );

		if ( !string.IsNullOrEmpty( e.Icon ) )
		{
			Paint.SetPen( color.WithAlpha( 0.5f ) );
			var i = Paint.DrawIcon( rect, e.Icon, 16, TextFlag.LeftCenter );
			rect.Left += i.Width + 8;
		}

		Paint.SetPen( color );
		Paint.DrawText( rect, e.Title ?? "Unset", TextFlag.LeftCenter );
		Paint.DrawIcon( rect, "Arrow_Drop_Down", 17, TextFlag.RightCenter );
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		if ( e.LeftMouseButton && !_menu.IsValid() )
		{
			OpenMenu();
		}
	}

	void ToggleValue( EnumDescription.Entry e )
	{
		SerializedProperty.SetValue( e.IntegerValue );
	}

	void OpenMenu()
	{
		if ( enumDesc is null )
			return;

		_menu = new PopupWidget( null );
		_menu.Layout = Layout.Column();
		var menuWidth = ScreenRect.Width;
		_menu.MinimumWidth = menuWidth;
		_menu.MaximumWidth = menuWidth;

		var scroller = _menu.Layout.Add( new ScrollArea( this ), 1 );
		scroller.Canvas = new Widget( scroller )
		{
			Layout = Layout.Column(),
			VerticalSizeMode = SizeMode.CanGrow | SizeMode.Expand,
			MaximumWidth = menuWidth
		};

		foreach ( var o in enumDesc )
		{
			if ( !IsEntrySupported( o ) )
				continue;

			var b = scroller.Canvas.Layout.Add( new SGEOutputSpaceMenuOption( o, SerializedProperty ) );
			b.MouseLeftPress = () =>
			{
				ToggleValue( o );
				_menu.Update();
				_menu.Close();
			};
		}

		_menu.Position = ScreenRect.BottomLeft;
		_menu.Visible = true;
		_menu.AdjustSize();
		_menu.ConstrainToScreen();
		_menu.OnPaintOverride = () =>
		{
			Paint.SetBrushAndPen( Theme.ControlBackground );
			Paint.DrawRect( Paint.LocalRect, 0 );
			return true;
		};
	}
}

file class SGEOutputSpaceMenuOption : Widget
{
	EnumDescription.Entry info;
	SerializedProperty property;

	public SGEOutputSpaceMenuOption( EnumDescription.Entry e, SerializedProperty p ) : base( null )
	{
		info = e;
		property = p;

		Layout = Layout.Row();
		Layout.Margin = 8;
		VerticalSizeMode = SizeMode.CanGrow;

		if ( !string.IsNullOrWhiteSpace( e.Icon ) )
		{
			Layout.Add( new IconButton( e.Icon ) { Background = Color.Transparent, TransparentForMouseEvents = true, IconSize = 18 } );
		}

		Layout.AddSpacingCell( 8 );
		var c = Layout.AddColumn();
		var title = c.Add( new Label( e.Title ) );
		title.SetStyles( $"font-size: 12px; font-weight: bold; font-family: {Theme.DefaultFont}; color: white;" );

		if ( !string.IsNullOrWhiteSpace( e.Description ) )
		{
			var desc = c.Add( new Label( e.Description.Trim( '\n', '\r', '\t', ' ' ) ) );
			desc.WordWrap = true;
			desc.MinimumHeight = 1;
			desc.VerticalSizeMode = SizeMode.CanGrow;
		}
	}

	bool HasValue()
	{
		var value = property.GetValue<long>( 0 );
		return value == info.IntegerValue;
	}

	protected override void OnPaint()
	{
		if ( Paint.HasMouseOver || HasValue() )
		{
			Paint.SetBrushAndPen( Theme.Blue.WithAlpha( HasValue() ? 0.3f : 0.1f ) );
			Paint.DrawRect( LocalRect.Shrink( 2 ), 2 );
		}
	}
}