PixelScreen.cs
using Sandbox.Rendering;

namespace Sandbox;

/// <summary>
/// Software-rendered pixel buffer that draws to the screen via the camera HUD.
///
/// Design: Every frame, game code writes into a <see cref="Color32"/> array through
/// the drawing API (SetPixel, DrawLine, etc.). At the end of each frame the dirty
/// buffer is uploaded to a GPU texture and rendered as a letterboxed quad with
/// point-sampled filtering so each logical pixel maps to a crisp block on screen.
///
/// Ported from the Unity3D pixelshitter framework — stripped down to just the
/// rendering core with no entity/scene/sprite systems.
/// </summary>
[Title( "Pixel Screen" )]
[Category( "Rendering" )]
[Icon( "grid_on" )]
public sealed class PixelScreen : Component
{
	// ── Singleton convenience ────────────────────────────────────────────
	// Lets any code reach the active pixel screen without wiring up references.
	// Set in OnEnabled / cleared in OnDisabled so it always reflects the live instance.
	public static PixelScreen Instance { get; private set; }

	// ── Configurable properties ──────────────────────────────────────────

	/// <summary>
	/// Width of the pixel buffer in logical pixels.
	/// Default 160 gives an exact 16:9 ratio with the 90-high buffer.
	/// </summary>
	[Property, Range( 1, 1024 )]
	public int PixelWidth { get; set; } = 160;

	/// <summary>
	/// Height of the pixel buffer in logical pixels.
	/// Default 90 gives an exact 16:9 ratio with the 160-wide buffer.
	/// </summary>
	[Property, Range( 1, 1024 )]
	public int PixelHeight { get; set; } = 90;

	/// <summary>
	/// Camera offset applied when writing pixels.
	/// All <see cref="SetPixel"/>/<see cref="AddPixel"/> calls are shifted by this
	/// unless <see cref="DrawInGUISpace"/> is true.
	/// </summary>
	[Property]
	public PixelPoint CameraPos { get; set; }

	/// <summary>
	/// When true, pixel coordinates are treated as screen-relative (HUD mode)
	/// and the camera offset is ignored.
	/// </summary>
	[Property]
	public bool DrawInGUISpace { get; set; }

	/// <summary>
	/// Screen shake intensity in pixels. Decays toward zero each frame.
	/// Set this to a positive value to trigger a shake effect.
	/// </summary>
	[Property]
	public float ShakeAmount { get; set; }

	/// <summary>
	/// Rate at which <see cref="ShakeAmount"/> decays per second.
	/// </summary>
	[Property]
	public float ShakeDecay { get; set; } = 8f;

	// ── Computed properties ──────────────────────────────────────────────

	/// <summary>
	/// How many screen pixels each logical pixel occupies.
	/// Matches the integer scale used by the letterboxed renderer to avoid sub-pixel seams.
	/// </summary>
	public int PixelSize
	{
		get
		{
			if ( PixelWidth <= 0 || PixelHeight <= 0 ) return 1;
			int scaleX = (int)(Screen.Width / PixelWidth);
			int scaleY = (int)(Screen.Height / PixelHeight);
			return Math.Max( 1, Math.Min( scaleX, scaleY ) );
		}
	}

	/// <summary>
	/// Direct read access to the backing pixel array.
	/// Length is <c>PixelWidth * PixelHeight</c>.
	/// </summary>
	public ReadOnlySpan<Color32> Pixels => _pixels;

	/// <summary>
	/// Writable access to the raw pixel buffer. Use with care —
	/// callers must set <see cref="MarkDirty"/> after bulk writes.
	/// Needed by the grid-transposing effect which rearranges pixel regions each frame.
	/// </summary>
	public Color32[] GetPixelsWritable() => _pixels;

	/// <summary>
	/// Mark the pixel buffer as dirty so it gets re-uploaded to the GPU next frame.
	/// Call this after directly modifying the array returned by <see cref="GetPixelsWritable"/>.
	/// </summary>
	public void MarkDirty()
	{
		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Reallocate the pixel buffer and GPU texture to match the current
	/// <see cref="PixelWidth"/> and <see cref="PixelHeight"/>.
	/// Call this after changing the resolution at runtime.
	/// </summary>
	public void ResizeBuffer() => InitBuffer();

	// ── Internal state ───────────────────────────────────────────────────

	private Color32[] _pixels;
	private Texture _texture;
	private bool _dirty;

	// Material using our custom point-filtered shader (nearest-neighbor sampling).
	// Built-in Material.UI.Basic hardcodes anisotropic filtering in the shader and
	// can't be overridden via render attributes, so we load our own variant.
	private Material _pointMaterial;

	// Cached clear colour so we can skip re-filling when the caller clears
	// to the same colour repeatedly (common pattern: clear every frame).
	private Color32 _lastClearColor;
	private bool _bufferIsUniformColor;

	// Reusable scratch buffer for ShiftPixels — avoids a per-call heap allocation.
	private Color32[] _shiftBuffer;

	// Cached letterbox rect — recomputed only when the screen dimensions change.
	private float _cachedScreenW;
	private float _cachedScreenH;
	private Rect _cachedLetterboxRect;

	// ── Lifecycle ────────────────────────────────────────────────────────

	protected override void OnEnabled()
	{
		Instance = this;
		InitBuffer();
	}

	protected override void OnDisabled()
	{
		if ( Instance == this )
			Instance = null;

		_texture?.Dispose();
		_texture = null;
		_pixels = null;
	}

	protected override void OnUpdate()
	{
		HandleInput();
		ApplyShake();
		UploadAndDraw();
	}

	/// <summary>
	/// When true, enables the built-in click-to-paint demo that sets the pixel
	/// under the cursor to a random colour on left-click. Disable this when
	/// using your own input handling.
	/// </summary>
	[Property]
	public bool EnableClickToPaint { get; set; }

	/// <summary>
	/// Built-in click-to-paint demo: left-click sets the pixel under the cursor
	/// to a random colour. Only active when <see cref="EnableClickToPaint"/> is true.
	/// </summary>
	private void HandleInput()
	{
		if ( !EnableClickToPaint )
			return;

		if ( !Input.Down( "attack1" ) )
			return;

		PixelPoint px = GetMouseScreenPosition();
		Color32 randomColor = new Color32(
			(byte)Game.Random.Next( 0, 256 ),
			(byte)Game.Random.Next( 0, 256 ),
			(byte)Game.Random.Next( 0, 256 ),
			255
		);

		// Temporarily draw in GUI space so the screen-relative mouse
		// position maps directly to the pixel buffer.
		bool wasGui = DrawInGUISpace;
		DrawInGUISpace = true;
		SetPixel( px.X, px.Y, randomColor );
		DrawInGUISpace = wasGui;
	}

	// ── Drawing API ──────────────────────────────────────────────────────

	/// <summary>
	/// Set a single pixel to an opaque colour (no blending).
	/// </summary>
	public void SetPixel( int x, int y, Color32 color )
	{
		TransformCoords( ref x, ref y );

		if ( !InBounds( x, y ) )
			return;

		// Y is flipped because our coordinate system has Y=0 at the bottom,
		// but GPU textures (and the s&box Texture.Update call) expect Y=0 at the top.
		int index = (PixelHeight - 1 - y) * PixelWidth + x;
		_pixels[index] = color;
		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Alpha-composite <paramref name="color"/> on top of the existing pixel (Porter-Duff over).
	/// </summary>
	public void AddPixel( int x, int y, Color32 color )
	{
		TransformCoords( ref x, ref y );

		if ( !InBounds( x, y ) )
			return;

		int index = (PixelHeight - 1 - y) * PixelWidth + x;
		_pixels[index] = PixelUtils.AddColors( color, _pixels[index] );
		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Subtractive blend: subtract <paramref name="color"/> RGB from the existing pixel.
	/// </summary>
	public void SubtractPixel( int x, int y, Color32 color )
	{
		TransformCoords( ref x, ref y );

		if ( !InBounds( x, y ) )
			return;

		int index = (PixelHeight - 1 - y) * PixelWidth + x;
		_pixels[index] = PixelUtils.SubtractColors( color, _pixels[index] );
		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Fill the entire buffer with a solid colour.
	/// Optimised: skips the fill if the buffer is already this colour.
	/// </summary>
	public void Clear( Color32 color )
	{
		// Most frames clear to the same colour (usually black). Skip the Array.Fill
		// entirely when the buffer is already uniform and the colour hasn't changed.
		if ( _bufferIsUniformColor && _lastClearColor.r == color.r
			&& _lastClearColor.g == color.g && _lastClearColor.b == color.b
			&& _lastClearColor.a == color.a )
		{
			return;
		}

		Array.Fill( _pixels, color );
		_lastClearColor = color;
		_bufferIsUniformColor = true;
		_dirty = true;
	}

	/// <summary>
	/// Additively tint every pixel in the buffer by <paramref name="color"/>.
	/// </summary>
	public void AddColor( Color32 color )
	{
		for ( int i = 0; i < _pixels.Length; i++ )
		{
			ref Color32 px = ref _pixels[i];
			px.r = (byte)Math.Min( 255, px.r + color.r );
			px.g = (byte)Math.Min( 255, px.g + color.g );
			px.b = (byte)Math.Min( 255, px.b + color.b );
		}

		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Subtractively tint every pixel by <paramref name="color"/>.
	/// </summary>
	public void SubtractColor( Color32 color )
	{
		for ( int i = 0; i < _pixels.Length; i++ )
		{
			ref Color32 px = ref _pixels[i];
			px.r = (byte)Math.Max( 0, px.r - color.r );
			px.g = (byte)Math.Max( 0, px.g - color.g );
			px.b = (byte)Math.Max( 0, px.b - color.b );
		}

		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Alpha-composite <paramref name="color"/> onto every pixel.
	/// Useful for fade-in / fade-out effects.
	/// </summary>
	public void AddPixels( Color32 color )
	{
		if ( color.a == 0 )
			return; // Nothing to blend.

		if ( color.a == 255 )
		{
			// Fully opaque overlay — equivalent to a clear.
			Array.Fill( _pixels, color );
			_lastClearColor = color;
			_bufferIsUniformColor = true;
			_dirty = true;
			return;
		}

		// Precompute the source alpha factors once — they're constant across all pixels.
		// Calling PixelUtils.AddColors in the loop would recompute these 24,576 times.
		float srcA    = color.a / 255f;
		float srcInvA = 1f - srcA;
		float srcR    = color.r * srcA;
		float srcG    = color.g * srcA;
		float srcB    = color.b * srcA;

		for ( int i = 0; i < _pixels.Length; i++ )
		{
			ref Color32 px = ref _pixels[i];
			float dstA = px.a / 255f;
			float outA = srcA + dstA * srcInvA;

			if ( outA <= 0f )
			{
				px = new Color32( 0, 0, 0, 0 );
				continue;
			}

			float invOutA = 1f / outA;
			px.r = (byte)((srcR + px.r * dstA * srcInvA) * invOutA);
			px.g = (byte)((srcG + px.g * dstA * srcInvA) * invOutA);
			px.b = (byte)((srcB + px.b * dstA * srcInvA) * invOutA);
			px.a = (byte)(outA * 255f);
		}

		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Draw a line using Bresenham's algorithm.
	/// </summary>
	public void DrawLine( int x0, int y0, int x1, int y1, Color32 color )
	{
		// Bresenham's line algorithm.
		// When the line is "steep" (taller than wide) we transpose X↔Y so the
		// driving axis is always X, then transpose back when writing each pixel.
		bool steep = Math.Abs( y1 - y0 ) > Math.Abs( x1 - x0 );

		if ( steep )
		{
			PixelUtils.Swap( ref x0, ref y0 );
			PixelUtils.Swap( ref x1, ref y1 );
		}

		// Ensure left-to-right iteration so the loop increments x.
		if ( x0 > x1 )
		{
			PixelUtils.Swap( ref x0, ref x1 );
			PixelUtils.Swap( ref y0, ref y1 );
		}

		int dx = x1 - x0;
		int dy = Math.Abs( y1 - y0 );
		int error = dx / 2;
		int yStep = y0 < y1 ? 1 : -1;
		int y = y0;

		for ( int x = x0; x <= x1; x++ )
		{
			if ( steep )
				SetPixel( y, x, color );
			else
				SetPixel( x, y, color );

			error -= dy;
			if ( error < 0 )
			{
				y += yStep;
				error += dx;
			}
		}
	}

	/// <summary>
	/// Scroll the pixel buffer by <paramref name="offset"/>, wrapping pixels around the edges.
	/// Used internally for screen shake.
	/// </summary>
	public void ShiftPixels( PixelPoint offset )
	{
		if ( offset.X == 0 && offset.Y == 0 )
			return;

		// Reuse a pre-allocated scratch buffer to avoid a per-call heap allocation.
		if ( _shiftBuffer is null || _shiftBuffer.Length != _pixels.Length )
			_shiftBuffer = new Color32[_pixels.Length];

		Array.Copy( _pixels, _shiftBuffer, _pixels.Length );

		for ( int srcY = 0; srcY < PixelHeight; srcY++ )
		{
			for ( int srcX = 0; srcX < PixelWidth; srcX++ )
			{
				// Single modulo + conditional add — cheaper than the double-modulo
				// pattern and correct for any offset magnitude.
				int dstX = (srcX + offset.X) % PixelWidth;
				if ( dstX < 0 ) dstX += PixelWidth;

				int dstY = (srcY + offset.Y) % PixelHeight;
				if ( dstY < 0 ) dstY += PixelHeight;

				_pixels[dstY * PixelWidth + dstX] = _shiftBuffer[srcY * PixelWidth + srcX];
			}
		}

		_dirty = true;
		_bufferIsUniformColor = false;
	}

	/// <summary>
	/// Read a single pixel from the buffer at the given world coordinates.
	/// Returns transparent black if the coordinates are out of bounds.
	/// Respects camera offset / GUI-space the same way write operations do.
	/// </summary>
	public Color32 GetPixel( int x, int y )
	{
		TransformCoords( ref x, ref y );

		if ( !InBounds( x, y ) )
			return new Color32( 0, 0, 0, 0 );

		int index = (PixelHeight - 1 - y) * PixelWidth + x;
		return _pixels[index];
	}

	// ── Input helpers ────────────────────────────────────────────────────

	/// <summary>
	/// Convert the current mouse screen position to pixel-buffer coordinates.
	/// Returns a <see cref="PixelPoint"/> in the range [0, PixelWidth) x [0, PixelHeight).
	/// </summary>
	public PixelPoint GetMouseScreenPosition()
	{
		// Account for the letterbox offset so pixel 0,0 maps to the
		// actual left/bottom edge of the rendered quad, not the screen edge.
		Rect quad = CalculateLetterboxRect();

		float relX = (float)(Mouse.Position.x - quad.Left);
		float relY = (float)(Mouse.Position.y - quad.Top);

		int mx = (int)(relX / quad.Width * PixelWidth);
		int my = (int)(relY / quad.Height * PixelHeight);

		// Flip Y so 0 is at the bottom.
		my = PixelHeight - 1 - my;

		mx = Math.Clamp( mx, 0, PixelWidth - 1 );
		my = Math.Clamp( my, 0, PixelHeight - 1 );

		return new PixelPoint( mx, my );
	}

	/// <summary>
	/// Convert mouse screen position to world pixel coordinates (adds camera offset).
	/// </summary>
	public PixelPoint GetMouseWorldPosition()
	{
		PixelPoint screen = GetMouseScreenPosition();
		return screen + CameraPos;
	}

	// ── Internals ────────────────────────────────────────────────────────

	/// <summary>
	/// Allocate the pixel buffer and create the GPU texture.
	/// </summary>
	private void InitBuffer()
	{
		_pixels = new Color32[PixelWidth * PixelHeight];
		Array.Fill( _pixels, new Color32( 0, 0, 0, 255 ) );

		_texture?.Dispose();
		_texture = Texture.Create( PixelWidth, PixelHeight )
			.WithDynamicUsage()
			.WithName( "pixel_screen" )
			.Finish();

		_shiftBuffer = null; // Will be reallocated at the new size on next use.
		_cachedScreenW = 0f; // Invalidate letterbox cache.

		_dirty = true;
		_bufferIsUniformColor = true;
		_lastClearColor = new Color32( 0, 0, 0, 255 );
	}

	/// <summary>
	/// Apply camera offset to coordinates (unless drawing in GUI space).
	/// </summary>
	// Converts world-space pixel coordinates to screen-space by subtracting
	// the camera offset. In GUI space the camera is ignored so HUD elements
	// are always drawn relative to the top-left of the pixel buffer.
	private void TransformCoords( ref int x, ref int y )
	{
		if ( DrawInGUISpace )
			return;

		x -= CameraPos.X;
		y -= CameraPos.Y;
	}

	private bool InBounds( int x, int y )
	{
		return x >= 0 && x < PixelWidth && y >= 0 && y < PixelHeight;
	}

	/// <summary>
	/// Decay and apply screen shake by shifting the pixel buffer randomly.
	/// </summary>
	private void ApplyShake()
	{
		if ( ShakeAmount <= 0f )
			return;

		int range = (int)MathF.Ceiling( ShakeAmount );
		int sx = Game.Random.Next( -range, range + 1 );
		int sy = Game.Random.Next( -range, range + 1 );
		ShiftPixels( new PixelPoint( sx, sy ) );

		ShakeAmount -= ShakeDecay * Time.Delta;
		if ( ShakeAmount < 0f )
			ShakeAmount = 0f;
	}

	/// <summary>
	/// Upload the pixel buffer to the GPU texture (if dirty) and draw it as a
	/// letterboxed quad with point-sampled filtering through the camera's HUD.
	/// </summary>
	private void UploadAndDraw()
	{
		if ( _texture is null || _pixels is null )
			return;

		// Only re-upload when something wrote to the buffer this frame.
		// Skipping the GPU upload when nothing changed saves significant bandwidth
		// — Texture.Update copies the entire pixel array across the PCIe bus.
		if ( _dirty )
		{
			_texture.Update( _pixels.AsSpan() );
			_dirty = false;
		}

		CameraComponent camera = Scene?.Camera;
		if ( camera is null )
			return;

		// Lazily create the point-filtered material from our custom shader.
		// This shader is identical to ui_basic but samples with g_sPointClamp
		// instead of g_sAniso, giving crisp nearest-neighbor pixel rendering.
		_pointMaterial ??= Material.FromShader( "shaders/ui_basic_point.shader" );

		Rect screenRect = CalculateLetterboxRect();

		CommandList list = camera.Hud.list;
		list.Attributes.Set( "Texture", _texture );
		list.DrawQuad( screenRect, _pointMaterial, Color.White );
	}

	/// <summary>
	/// Computes a centered rectangle that fits the pixel buffer into the screen
	/// while maintaining a 1:1 pixel aspect ratio. Remaining areas are letterboxed.
	/// </summary>
	private Rect CalculateLetterboxRect()
	{
		float screenW = Screen.Width;
		float screenH = Screen.Height;

		if ( screenW == _cachedScreenW && screenH == _cachedScreenH )
			return _cachedLetterboxRect;

		float bufferAspect = (float)PixelWidth / PixelHeight;
		float screenAspect = screenW / screenH;

		float drawW, drawH;

		if ( screenAspect > bufferAspect )
		{
			// Screen is wider than buffer — pillarbox (bars on left/right).
			drawH = screenH;
			drawW = screenH * bufferAspect;
		}
		else
		{
			// Screen is taller than buffer — letterbox (bars on top/bottom).
			drawW = screenW;
			drawH = screenW / bufferAspect;
		}

		// Snap to integer multiples of logical pixel size so each pixel
		// maps to the same number of screen pixels (no sub-pixel seams).
		int scale = Math.Max( 1, (int)Math.Min( drawW / PixelWidth, drawH / PixelHeight ) );
		drawW = PixelWidth * scale;
		drawH = PixelHeight * scale;

		// Floor offsets to integer screen pixels so the quad edge aligns
		// exactly on a screen pixel boundary — prevents sub-pixel misalignment
		// that causes uneven gaps at the edges of the pixel grid.
		int offsetX = (int)((screenW - drawW) * 0.5f);
		int offsetY = (int)((screenH - drawH) * 0.5f);

		_cachedScreenW = screenW;
		_cachedScreenH = screenH;
		_cachedLetterboxRect = new Rect( offsetX, offsetY, drawW, drawH );
		return _cachedLetterboxRect;
	}
}