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.
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 );
}
}