Rendering/RetroArcadePostProcess.cs
using System;
using Sandbox.Rendering;
namespace BlockParty;
/// <summary>CRT/arcade treatment for the centered game square. The frame is first downsampled to
/// the arena's native pixel grid, then upscaled back with scanlines, halation, curvature and a
/// vignette — all locked to game pixels, so nothing beats against the sprite art.</summary>
[Title( "Retro Arcade Screen" )]
[Category( "Post Processing" )]
[Icon( "tv" )]
public sealed class RetroArcadePostProcess : BasePostProcess
{
private static readonly Material DownsampleShader = Material.FromShader( "shaders/retro_downsample.shader" );
private static readonly Material CrtShader = Material.FromShader( "shaders/retro_arcade.shader" );
/// <summary>Treat the whole screen (pillarboxes and any UI in them included) instead of just
/// the centered arena square.</summary>
[Property] public bool ApplyToEntireScreen { get; set; }
/// <summary>Rows in the virtual pixel grid the frame is quantized to. Arena.HEIGHT gives the
/// authentic one-scanline-per-art-pixel look; note that anything not on the 240 grid
/// (subpixel motion, UI text) gets snapped to it.</summary>
[Property, Range( 60, 480 )] public int VirtualRows { get; set; } = Arena.HEIGHT;
[Property, Range( 0f, 1f )] public float ScanlineStrength { get; set; } = 0.175f;
[Property, Range( 0f, 0.1f )] public float Curvature { get; set; } = 0.013f;
[Property, Range( 0f, 1f )] public float VignetteStrength { get; set; } = 0.25f;
[Property, Range( 0f, 1f )] public float HalationStrength { get; set; } = 0.25f;
/// <summary>Seconds to fade the quantization out when a stage wants crisp text.</summary>
[Property, Range( 0.01f, 2f )] public float QuantizeFadeOutTime { get; set; } = 0.05f;
/// <summary>Seconds to snap the quantization back in when gameplay resumes.</summary>
[Property, Range( 0.01f, 2f )] public float QuantizeFadeInTime { get; set; } = 0.04f;
/// <summary>Current quantize crossfade (1 = full 240px look, 0 = crisp frame under the same
/// tube). Follows StageBase.WantsQuantize; curvature and halation ride this fade (they smear
/// or jag crisp UI), while scanlines/vignette never fade — except via
/// <see cref="EffectBlend"/>, which takes the whole tube down.</summary>
public float QuantizeBlend { get; private set; } = 1f;
/// <summary>Master crossfade for the entire CRT treatment (1 = full effect, 0 = untouched
/// frame, at which point the passes are skipped). Fades out while the GIF-export overlay is
/// up so its dense controls stay readable.</summary>
public float EffectBlend { get; private set; } = 1f;
private GameManager _manager;
protected override void OnEnabled()
{
_manager = Components.Get<GameManager>( FindMode.InAncestors );
}
protected override void OnUpdate()
{
float target = (_manager?.Stage?.WantsQuantize ?? true) ? 1f : 0f;
float time = target > QuantizeBlend ? QuantizeFadeInTime : QuantizeFadeOutTime;
QuantizeBlend = QuantizeBlend.Approach( target, Time.Delta / MathF.Max( time, 0.01f ) );
float effectTarget = (_manager?.IsGifPreviewActive ?? false) ? 0f : 1f;
float effectTime = effectTarget > EffectBlend ? QuantizeFadeInTime : QuantizeFadeOutTime;
EffectBlend = EffectBlend.Approach( effectTarget, Time.Delta / MathF.Max( effectTime, 0.01f ) );
}
/// <summary>Screen-pixel rect the effect covers (the arena square, or the extended full-screen
/// region). SoftwareCursor uses this to decide where the OS cursor gets replaced.</summary>
public Rect ScreenRegion
{
get
{
ComputeRegion( out float regionLeft, out float regionWidth, out _, out _ );
return new Rect( regionLeft * Screen.Width, 0f, regionWidth * Screen.Width, Screen.Height );
}
}
// The grid is always defined by the arena square (height-by-height, VirtualRows cells tall)
// so the arena stays pixel-perfect. Full-screen mode extends that same grid sideways by a
// whole number of columns, anchored to the square's edges — the outermost column may hang
// partially off-screen rather than ever shrinking or shifting the cells.
private void ComputeRegion( out float regionLeft, out float regionWidth, out int rows, out int cols )
{
float screenWidth = MathF.Max( Screen.Width, 1f );
float screenHeight = MathF.Max( Screen.Height, 1f );
rows = Math.Clamp( VirtualRows, 16, 1080 );
float squareWidth = MathF.Min( screenHeight / screenWidth, 1f );
float cellWidth = squareWidth / rows;
int sideCols = ApplyToEntireScreen
? (int)MathF.Ceiling( (1f - squareWidth) * 0.5f / cellWidth )
: 0;
cols = rows + 2 * sideCols;
regionWidth = cols * cellWidth;
regionLeft = (1f - regionWidth) * 0.5f;
}
public override void Render()
{
if ( Screen.Width <= 0f || Screen.Height <= 0f ) return;
// Fully faded out (GIF-export overlay): every parameter below is zero, making the CRT
// pass a mathematical identity — skip the render work entirely.
if ( EffectBlend <= 0f ) return;
ComputeRegion( out float regionLeft, out float regionWidth, out int rows, out int cols );
var commands = new CommandList( "BlockParty Retro Arcade" );
var frame = commands.Attributes.GrabFrameTexture( "SourceTexture" );
// Extra mips feed the halation halo; the pooled texture is cleared first so gaussian
// mips near the image edge pull in black instead of stale pool contents.
var lowRes = commands.GetRenderTarget( "RetroLowRes", cols, rows,
ImageFormat.Default, ImageFormat.None, MultisampleAmount.MultisampleNone, numMips: 4 );
commands.Clear( lowRes, Color.Black );
commands.Attributes.Set( "SourceTexture", frame.ColorTexture );
commands.Attributes.Set( "RegionLeft", regionLeft );
commands.Attributes.Set( "RegionWidth", regionWidth );
commands.Attributes.Set( "VirtualSize", new Vector2( cols, rows ) );
commands.SetRenderTarget( lowRes );
commands.Blit( DownsampleShader );
commands.GenerateMipMaps( lowRes, Graphics.DownsampleMethod.GaussianBlur );
commands.ClearRenderTarget();
commands.Attributes.Set( "LowResTexture", lowRes.ColorTexture );
commands.Attributes.Set( "ScanlineStrength", ScanlineStrength * EffectBlend );
commands.Attributes.Set( "VignetteStrength", VignetteStrength * EffectBlend );
// Curvature and halation ride the quantize fade; scanlines and the vignette are the
// permanent tube identity. The halo is built from the quantized low-res frame, so on crisp
// screens (menus, help overlay) it reads as a blur/haze around text rather than phosphor
// glow, and the curvature warp lands as duplicated/skipped pixel rows on the point-sampled
// raw frame — jaggy UI edges, the very thing the quantize fade exists to avoid.
commands.Attributes.Set( "Curvature", Curvature * QuantizeBlend * EffectBlend );
commands.Attributes.Set( "HalationStrength", HalationStrength * QuantizeBlend * EffectBlend );
commands.Attributes.Set( "QuantizeBlend", QuantizeBlend * EffectBlend );
commands.Blit( CrtShader );
commands.ReleaseRenderTarget( lowRes );
InsertCommandList( commands, Stage.AfterUI, 9000, "BlockParty Retro Arcade" );
}
}