test/NTSCEffect.cs
/// <summary>
/// NTSC composite video signal simulation inspired by ntsc-rs.
/// Performs real YIQ modulation/demodulation to produce authentic analog artifacts:
/// rainbow fringing, dot crawl, cross-color, chroma phase noise, and more.
///
/// This differs from VHSEffect (which applies RGB-space tricks) by actually
/// simulating the NTSC signal path: encode chroma onto composite → add noise →
/// decode. Noise in the composite signal becomes rainbow-colored after decoding,
/// exactly like real analog video hardware.
/// </summary>
[Title( "NTSC Signal" )]
[Category( "Post Processing" )]
[Icon( "tv" )]
public sealed class NTSCEffect : BasePostProcess<NTSCEffect>
{
// =====================================================================
// Enums
// =====================================================================
/// <summary>
/// VHS tape speed determines luma/chroma bandwidth and chroma delay.
/// Lower speeds = more recording time but worse quality.
/// </summary>
public enum VHSTapeSpeed
{
/// <summary>No VHS tape speed filtering applied.</summary>
None,
/// <summary>Standard Play — best quality (luma 2.4MHz, chroma 320kHz).</summary>
SP,
/// <summary>Long Play — moderate quality (luma 1.9MHz, chroma 300kHz).</summary>
LP,
/// <summary>Extended Play — worst quality (luma 1.4MHz, chroma 280kHz).</summary>
EP
}
/// <summary>
/// Preset configurations that map to common analog video looks.
/// </summary>
public enum NTSCPreset
{
/// <summary>Use custom settings (no preset override).</summary>
Custom,
/// <summary>Clean over-the-air NTSC broadcast. Minimal artifacts.</summary>
Broadcast,
/// <summary>VHS Standard Play recording. Mild degradation.</summary>
VHS_SP,
/// <summary>VHS Long Play recording. Noticeable degradation.</summary>
VHS_LP,
/// <summary>Badly worn VHS tape. Heavy artifacts and dropouts.</summary>
WornTape
}
// =====================================================================
// Properties — General
// =====================================================================
[Property, Range( 0, 1 ), Group( "General" )]
public float Intensity { get; set; } = 1.0f;
[Property, Group( "General" )]
public NTSCPreset Preset { get; set; } = NTSCPreset.Custom;
// =====================================================================
// Properties — Signal
// =====================================================================
/// <summary>
/// Noise added to the composite signal before decoding.
/// Creates rainbow-colored noise (the decoder interprets it as color).
/// </summary>
[Property, Range( 0, 1 ), Group( "Signal" )]
public float CompositeNoise { get; set; } = 0.3f;
/// <summary>
/// Pre-emphasis sharpening of the composite signal.
/// Boosts high frequencies before noise/degradation (similar to broadcast pre-emphasis).
/// </summary>
[Property, Range( 0, 2 ), Group( "Signal" )]
public float CompositeSharpening { get; set; } = 0.5f;
/// <summary>
/// Random bright speckle noise simulating signal dropouts.
/// </summary>
[Property, Range( 0, 1 ), Group( "Signal" )]
public float Snow { get; set; } = 0.1f;
// =====================================================================
// Properties — Chroma
// =====================================================================
/// <summary>
/// Fixed rotation of all hues. Simulates chroma subcarrier timing error.
/// 0 = no shift, 0.5 = 180° shift (inverted colors).
/// </summary>
[Property, Range( -0.5f, 0.5f ), Group( "Chroma" )]
public float ChromaPhaseError { get; set; } = 0.0f;
/// <summary>
/// Per-scanline random hue jitter. VHS tape timing variations cause
/// slight chroma phase drift on each line.
/// </summary>
[Property, Range( 0, 1 ), Group( "Chroma" )]
public float ChromaPhaseNoise { get; set; } = 0.2f;
/// <summary>
/// Horizontal pixel delay of chroma relative to luma.
/// Simulates chroma/luma timing misalignment.
/// </summary>
[Property, Range( -8, 8 ), Group( "Chroma" )]
public float ChromaDelay { get; set; } = 0.0f;
/// <summary>
/// Vertical chroma blending (averages each line's color with its neighbor).
/// VHS has extremely limited vertical chroma resolution.
/// </summary>
[Property, Range( 0, 1 ), Group( "Chroma" )]
public float ChromaVertBlend { get; set; } = 0.5f;
/// <summary>
/// Random per-scanline chroma dropout. Creates brief horizontal grayscale bands.
/// </summary>
[Property, Range( 0, 1 ), Group( "Chroma" )]
public float ChromaLoss { get; set; } = 0.1f;
// =====================================================================
// Properties — Luma
// =====================================================================
/// <summary>
/// Horizontal blur on the luma channel. Simulates limited luma bandwidth.
/// </summary>
[Property, Range( 0, 1 ), Group( "Luma" )]
public float LumaSmear { get; set; } = 0.3f;
/// <summary>
/// Edge overshoot/ringing from decoder resonant filters.
/// Creates oscillating "ghosts" next to sharp transitions.
/// </summary>
[Property, Range( 0, 1 ), Group( "Luma" )]
public float Ringing { get; set; } = 0.3f;
/// <summary>
/// Grainy brightness noise on the luma channel.
/// </summary>
[Property, Range( 0, 1 ), Group( "Luma" )]
public float LumaNoise { get; set; } = 0.15f;
/// <summary>
/// Random color splotch noise on the chroma channels.
/// I and Q are noised independently, creating random hue shifts.
/// </summary>
[Property, Range( 0, 1 ), Group( "Luma" )]
public float ChromaNoise { get; set; } = 0.2f;
// =====================================================================
// Properties — VHS
// =====================================================================
/// <summary>
/// VHS tape speed. Lower speeds = more blur and larger chroma delay.
/// Set to None to disable VHS-specific filtering.
/// </summary>
[Property, Group( "VHS" )]
public VHSTapeSpeed TapeSpeed { get; set; } = VHSTapeSpeed.LP;
/// <summary>
/// Head switching artifacts at the bottom of the frame.
/// Creates horizontal displacement and snow in a band at the bottom.
/// </summary>
[Property, Range( 0, 1 ), Group( "VHS" )]
public float HeadSwitching { get; set; } = 0.4f;
/// <summary>
/// Per-scanline horizontal displacement from tracking errors.
/// </summary>
[Property, Range( 0, 1 ), Group( "VHS" )]
public float TrackingNoise { get; set; } = 0.2f;
/// <summary>
/// Low-frequency horizontal waviness from physical tape warping.
/// </summary>
[Property, Range( 0, 1 ), Group( "VHS" )]
public float EdgeWave { get; set; } = 0.3f;
/// <summary>
/// VHS deck electronic sharpening to counteract tape blur.
/// </summary>
[Property, Range( 0, 1 ), Group( "VHS" )]
public float VHSSharpen { get; set; } = 0.25f;
// =====================================================================
// Properties — Output
// =====================================================================
/// <summary>
/// Simulate a lower output resolution by snapping to a coarser pixel grid.
/// 0 = native resolution (no downsampling). Common values:
/// 480 = standard NTSC, 240 = typical VHS, 160 = degraded VHS.
/// </summary>
[Property, Range( 0, 480 ), Group( "Output" )]
public float OutputResolution { get; set; } = 0f;
// =====================================================================
// Preset application
// =====================================================================
/// <summary>
/// Apply a preset, overriding all settings with tuned values.
/// Called automatically when Preset property changes.
/// </summary>
private void ApplyPreset( NTSCPreset preset )
{
switch ( preset )
{
case NTSCPreset.Broadcast:
CompositeNoise = 0.1f;
CompositeSharpening = 0.3f;
Snow = 0.02f;
ChromaPhaseError = 0.0f;
ChromaPhaseNoise = 0.05f;
ChromaDelay = 0.0f;
ChromaVertBlend = 0.0f;
ChromaLoss = 0.0f;
LumaSmear = 0.1f;
Ringing = 0.2f;
LumaNoise = 0.05f;
ChromaNoise = 0.05f;
TapeSpeed = VHSTapeSpeed.None;
HeadSwitching = 0.0f;
TrackingNoise = 0.0f;
EdgeWave = 0.0f;
VHSSharpen = 0.0f;
OutputResolution = 480f;
break;
case NTSCPreset.VHS_SP:
CompositeNoise = 0.2f;
CompositeSharpening = 0.5f;
Snow = 0.05f;
ChromaPhaseError = 0.0f;
ChromaPhaseNoise = 0.15f;
ChromaDelay = 0.0f;
ChromaVertBlend = 0.4f;
ChromaLoss = 0.05f;
LumaSmear = 0.2f;
Ringing = 0.25f;
LumaNoise = 0.1f;
ChromaNoise = 0.15f;
TapeSpeed = VHSTapeSpeed.SP;
HeadSwitching = 0.3f;
TrackingNoise = 0.1f;
EdgeWave = 0.2f;
VHSSharpen = 0.25f;
OutputResolution = 240f;
break;
case NTSCPreset.VHS_LP:
CompositeNoise = 0.3f;
CompositeSharpening = 0.5f;
Snow = 0.1f;
ChromaPhaseError = 0.0f;
ChromaPhaseNoise = 0.2f;
ChromaDelay = 0.0f;
ChromaVertBlend = 0.5f;
ChromaLoss = 0.1f;
LumaSmear = 0.3f;
Ringing = 0.3f;
LumaNoise = 0.15f;
ChromaNoise = 0.2f;
TapeSpeed = VHSTapeSpeed.LP;
HeadSwitching = 0.4f;
TrackingNoise = 0.2f;
EdgeWave = 0.3f;
VHSSharpen = 0.25f;
OutputResolution = 240f;
break;
case NTSCPreset.WornTape:
CompositeNoise = 0.6f;
CompositeSharpening = 0.4f;
Snow = 0.3f;
ChromaPhaseError = 0.02f;
ChromaPhaseNoise = 0.5f;
ChromaDelay = 2.0f;
ChromaVertBlend = 0.8f;
ChromaLoss = 0.4f;
LumaSmear = 0.5f;
Ringing = 0.4f;
LumaNoise = 0.3f;
ChromaNoise = 0.4f;
TapeSpeed = VHSTapeSpeed.EP;
HeadSwitching = 0.7f;
TrackingNoise = 0.5f;
EdgeWave = 0.6f;
VHSSharpen = 0.15f;
OutputResolution = 160f;
break;
}
}
// =====================================================================
// Rendering
// =====================================================================
/// <summary>
/// Sets all NTSC shader float attributes via a delegate, allowing the same
/// logic to target either <see cref="RenderAttributes"/> or
/// <see cref="Sandbox.Rendering.CommandList.AttributeAccess"/>.
/// </summary>
private void SetNTSCFloats( Action<string, float> set, float intensity,
float vhsLumaBlur, float vhsChromaBlur, float vhsChromaDelay )
{
set( "intensity", intensity );
set( "compositeNoise", GetWeighted( x => x.CompositeNoise ) );
set( "compositeSharpening", GetWeighted( x => x.CompositeSharpening ) );
set( "snow", GetWeighted( x => x.Snow ) );
set( "chromaPhaseError", GetWeighted( x => x.ChromaPhaseError ) );
set( "chromaPhaseNoise", GetWeighted( x => x.ChromaPhaseNoise ) );
set( "chromaDelay", GetWeighted( x => x.ChromaDelay ) );
set( "chromaVertBlend", GetWeighted( x => x.ChromaVertBlend ) );
set( "chromaLoss", GetWeighted( x => x.ChromaLoss ) );
set( "lumaSmear", GetWeighted( x => x.LumaSmear ) );
set( "ringing", GetWeighted( x => x.Ringing ) );
set( "lumaNoise", GetWeighted( x => x.LumaNoise ) );
set( "chromaNoise", GetWeighted( x => x.ChromaNoise ) );
set( "vhsLumaBlur", vhsLumaBlur );
set( "vhsChromaBlur", vhsChromaBlur );
set( "vhsChromaDelay", vhsChromaDelay );
set( "headSwitching", GetWeighted( x => x.HeadSwitching ) );
set( "trackingNoise", GetWeighted( x => x.TrackingNoise ) );
set( "edgeWave", GetWeighted( x => x.EdgeWave ) );
set( "vhsSharpen", GetWeighted( x => x.VHSSharpen ) );
}
/// <summary>
/// Standard full-resolution render path using BlitMode.WithBackbuffer.
/// Used when OutputResolution is 0 (native resolution).
/// </summary>
private void RenderDirect( float intensity,
float vhsLumaBlur, float vhsChromaBlur, float vhsChromaDelay )
{
SetNTSCFloats( ( k, v ) => Attributes.Set( k, v ), intensity, vhsLumaBlur, vhsChromaBlur, vhsChromaDelay );
var shader = Material.FromShader( "test/shaders/postprocess/pp_ntsc.shader" );
var blit = BlitMode.WithBackbuffer( shader, Sandbox.Rendering.Stage.AfterPostProcess, 350, false );
Blit( blit, "NTSC Signal" );
}
/// <summary>
/// Low-resolution render target path. Creates a temporary render target at the
/// target resolution, runs the NTSC shader at that resolution (so all blur radii,
/// scanline counts, and texel calculations are authentic for the lower res), then
/// upscales the result to the screen using point filtering for a chunky pixel look.
///
/// Pipeline:
/// 1. GrabFrameTexture → captures backbuffer as "ColorBuffer" on the CL
/// 2. SetRenderTarget → low-res RT (viewport = lowW×lowH)
/// 3. Blit NTSC shader → processes at low-res, g_vRenderTargetSize is correct
/// 4. ClearRenderTarget → back to screen
/// 5. Set "ColorBuffer" to the low-res NTSC result
/// 6. Blit upscale shader → point-sampled nearest-neighbor to full screen
/// </summary>
private void RenderWithResolution( float intensity, float outputRes,
float vhsLumaBlur, float vhsChromaBlur, float vhsChromaDelay )
{
var ntscMaterial = Material.FromShader( "test/shaders/postprocess/pp_ntsc.shader" );
var upscaleMaterial = Material.FromShader( "test/shaders/postprocess/pp_upscale.shader" );
// Calculate low-res dimensions preserving the screen aspect ratio
float aspect = Screen.Width / (float)Screen.Height;
int lowH = (int)outputRes;
int lowW = (int)(lowH * aspect);
var cl = new Sandbox.Rendering.CommandList();
// Set all NTSC shader float parameters on the command list's local attributes.
// AttributeAccess.Set accepts the same float/string types as RenderAttributes.
SetNTSCFloats( ( k, v ) => cl.Attributes.Set( k, v ), intensity, vhsLumaBlur, vhsChromaBlur, vhsChromaDelay );
// Grab the current framebuffer — stored as "ColorBuffer" on CL attributes,
// which the NTSC shader reads via g_tColorBuffer < Attribute("ColorBuffer") >.
var frameCopy = cl.Attributes.GrabFrameTexture( "ColorBuffer", false );
// Allocate a low-res temporary render target for NTSC processing.
// Using the CL's pooled RT system ensures proper lifetime management.
var lowResRT = cl.GetRenderTarget( "ntsc_lowres", lowW, lowH,
ImageFormat.RGBA8888, ImageFormat.D16, MultisampleAmount.MultisampleNone, 1 );
// Render NTSC at low resolution.
// SetRenderTarget changes the viewport to lowW×lowH, so the shader's
// g_vRenderTargetSize will be (lowW, lowH). All texel sizes, blur radii,
// scanline calculations, and subcarrier phase are authentic for the target res.
cl.SetRenderTarget( lowResRT );
cl.Blit( ntscMaterial, Attributes );
// Switch ColorBuffer to the low-res NTSC result for the upscale pass.
// AttributeAccess.Set has a ColorTextureRef overload specifically for this.
cl.Attributes.Set( "ColorBuffer", lowResRT.ColorTexture, 0 );
// Return to the screen framebuffer and upscale with point filtering.
// The upscale shader uses nearest-neighbor sampling, which preserves
// the authentic blocky pixel look of low-resolution video output.
cl.ClearRenderTarget();
cl.Blit( upscaleMaterial, Attributes );
// Release pooled handles back to the RT pool
cl.ReleaseRenderTarget( frameCopy );
cl.ReleaseRenderTarget( lowResRT );
InsertCommandList( cl, Sandbox.Rendering.Stage.AfterPostProcess, 350, "NTSC Signal" );
}
public override void Render()
{
float intensity = GetWeighted( x => x.Intensity );
if ( intensity.AlmostEqual( 0.0f ) )
{
return;
}
// Apply preset if one is selected.
// Preset is an enum — access directly, not via GetWeighted (which only works for floats).
if ( Preset != NTSCPreset.Custom )
{
ApplyPreset( Preset );
}
// --- Compute derived VHS tape speed parameters ---
// These map the tape speed enum to concrete blur/delay values
// matching ntsc-rs: SP(2.4MHz/320kHz/4px), LP(1.9MHz/300kHz/5px), EP(1.4MHz/280kHz/6px)
float vhsLumaBlur = 0.0f;
float vhsChromaBlur = 0.0f;
float vhsChromaDelay = 0.0f;
// TapeSpeed is an enum — access directly, not via GetWeighted.
switch ( TapeSpeed )
{
case VHSTapeSpeed.SP:
vhsLumaBlur = 0.2f;
vhsChromaBlur = 0.4f;
vhsChromaDelay = 4.0f;
break;
case VHSTapeSpeed.LP:
vhsLumaBlur = 0.35f;
vhsChromaBlur = 0.5f;
vhsChromaDelay = 5.0f;
break;
case VHSTapeSpeed.EP:
vhsLumaBlur = 0.5f;
vhsChromaBlur = 0.65f;
vhsChromaDelay = 6.0f;
break;
}
// Route to the appropriate render path based on output resolution.
// When > 0, we use a true render target at the target resolution so all
// shader processing (blur, noise, scanlines) is authentic for that res.
float outputRes = GetWeighted( x => x.OutputResolution );
if ( outputRes > 1.0f )
{
RenderWithResolution( intensity, outputRes, vhsLumaBlur, vhsChromaBlur, vhsChromaDelay );
}
else
{
RenderDirect( intensity, vhsLumaBlur, vhsChromaBlur, vhsChromaDelay );
}
}
}