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