Rendering/PlayerSpriteOutline.cs

A Scene component that draws a one-texture-pixel outline around a player's current body sprite. It tracks either a Player or a SpriteRenderer source, builds a thin quad slightly expanded by one texel around the sprite each update, and renders it via a custom shader as an overlay with depth.

Native Interop
using System.Collections.Generic;
using Sandbox;

namespace BlockParty;

/// <summary>Draws a one-texture-pixel outline around the player's current body frame.</summary>
public sealed class PlayerSpriteOutline : Component
{
	readonly RenderAttributes _attributes = new();
	readonly List<Vertex> _vertices = new( 6 );
	SceneCustomObject _sceneObject;
	Material _material;
	Player _player;
	SpriteRenderer _source;
	Color _tint = Color.Transparent;

	public Color Tint
	{
		get => _tint;
		set => _tint = value;
	}

	public void Setup( Player player )
	{
		_player = player;
		_source = null;
	}

	public void Setup( SpriteRenderer source )
	{
		_player = null;
		_source = source;
	}

	protected override void OnEnabled()
	{
		_material ??= Material.FromShader( "shaders/player_sprite_outline.shader" );
		_sceneObject = new SceneCustomObject( Scene.SceneWorld )
		{
			Transform = global::Transform.Zero,
			RenderLayer = SceneRenderLayer.OverlayWithDepth,
			RenderOverride = OnRender,
		};
		_sceneObject.Flags.IsOpaque = false;
		_sceneObject.Flags.IsTranslucent = true;
		_sceneObject.Flags.CastShadows = false;
	}

	protected override void OnDisabled()
	{
		_sceneObject?.Delete();
		_sceneObject = null;
	}

	protected override void OnUpdate()
	{
		RebuildGeometry();
	}

	void RebuildGeometry()
	{
		_vertices.Clear();
		SpriteRenderer body = _source ?? _player?.BodySpriteRenderer;
		if ( _tint.a <= 0f || body is null || !body.Enabled || !body.GameObject.Active ) return;

		Texture texture = body.Texture;
		if ( texture is null || texture == Texture.Transparent ) return;
		texture.MarkUsed( ushort.MaxValue );
		_attributes.Set( "MainTexture", texture );

		Vector3 center = body.GameObject.WorldPosition;
		Vector3 scale = body.GameObject.WorldScale;
		float aspect = texture.Width / (float)Math.Max( 1, texture.Height );
		float width = MathF.Abs( body.Size.x * scale.x * MathF.Min( aspect, 1f ) );
		float height = MathF.Abs( body.Size.y * scale.y / MathF.Max( aspect, 1f ) );
		// Expand geometry and UVs together so one source texel remains one world-space game pixel.
		float pixelWidth = width / Math.Max( 1, texture.Width );
		float pixelHeight = height / Math.Max( 1, texture.Height );
		float left = center.x - width * 0.5f - pixelWidth;
		float right = center.x + width * 0.5f + pixelWidth;
		float bottom = center.y - height * 0.5f - pixelHeight;
		float top = center.y + height * 0.5f + pixelHeight;
		// OverlayWithDepth draws after the shared sprite batch, while the player's exact Z keeps the
		// contour behind the depth-writing obstacle and arena-wall layers.
		float z = center.z;

		float texelU = 1f / Math.Max( 1, texture.Width );
		float texelV = 1f / Math.Max( 1, texture.Height );
		float leftU = body.FlipHorizontal ? 1f + texelU : -texelU;
		float rightU = body.FlipHorizontal ? -texelU : 1f + texelU;
		float bottomV = body.FlipVertical ? -texelV : 1f + texelV;
		float topV = body.FlipVertical ? 1f + texelV : -texelV;

		Vertex V( float x, float y, float u, float v )
			=> new( new Vector3( x, y, z ), new Vector4( u, v, 0f, 0f ), _tint );

		_vertices.Add( V( left, bottom, leftU, bottomV ) );
		_vertices.Add( V( right, bottom, rightU, bottomV ) );
		_vertices.Add( V( right, top, rightU, topV ) );
		_vertices.Add( V( left, bottom, leftU, bottomV ) );
		_vertices.Add( V( right, top, rightU, topV ) );
		_vertices.Add( V( left, top, leftU, topV ) );
		_sceneObject.Transform = new Transform( new Vector3( center.x, center.y, z ) );
	}

	void OnRender( SceneObject sceneObject )
	{
		SpriteRenderer body = _source ?? _player?.BodySpriteRenderer;
		if ( _vertices.Count == 0 || _material is null || body is null || !body.Enabled ) return;
		Graphics.Draw( _vertices, _vertices.Count, _material, _attributes, Graphics.PrimitiveType.Triangles );
	}
}