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