The Scanline Classic Shader

UPDATE 2025-12-21: Version 6 now available with RetroArch shader package

Features of this shader

  • All-in-one signal processing+CRT shader package
  • Supports RGB, S-Video, Composite, and RF
  • Support for NTSC, PAL, and regional variants like PAL-M (Brazil)
  • Automatic interlacing
  • Parameter system aligns with controls found on TVs/monitors
  • HDRR pipeline that smoothly handles out-of-gamut imaging
  • SDR and Wide Color Gamut presets
  • Three CRT mask types and two bezels
  • Full geometry simulation, including curvature on mask
  • NES/SNES presets available now
  • Generic (any system) and Neo Geo presets will be available later this week
  • Mega Drive and PC Engine presets coming soon
  • Other presets TBA

Recent screenshots:

ad3 ad4

This is official support thread for my Scanline Classic shader. You can find the latest version with instructions here: https://github.com/anikom15/scanline-classic

Below is taken from the documentation, parameter help can be found further down.

Quick start

Preset Overview

Scanline Classic provides a wide range of presets tailored for both consumer and professional video systems. Presets are organized by system and signal type, and are found in the presets folder of your install location. Below is an overview of the available presets:

Consumer Presets

  • sfc.slangp: Super Famicom (Japan) base preset
  • snes.slangp: Super Nintendo (North America) base preset
  • snes-br.slangp: Super Nintendo (Brazil) base preset
  • snes-eu.slangp: Super Nintendo (Europe) base preset

Professional Presets

  • sfc-composite.slangp: Super Famicom (Japan) with composite video signal simulation
  • sfc-rf.slangp: Super Famicom (Japan) with RF signal simulation
  • sfc-rgb.slangp: Super Famicom (Japan) with RGB signal simulation
  • sfc-svideo.slangp: Super Famicom (Japan) with S-Video signal simulation
  • snes-composite.slangp: Super Nintendo (North America) with composite video
  • snes-svideo.slangp: Super Nintendo (North America) with S-Video
  • snes-rf.slangp: Super Nintendo (North America) with RF
  • snes-eu-composite.slangp: Super Nintendo (Europe) with composite video
  • snes-eu-rf.slangp: Super Nintendo (Europe) with RF
  • snes-gb-rf.slangp: Super Nintendo (Game Boy) with RF
  • snes-br.slangp: Super Nintendo (Brazil) professional preset

Each preset is designed to closely match the characteristics of the original hardware and signal path, including colorimetry, geometry, and signal artifacts. Use these as starting points for your own customizations or as reference-quality emulation targets.

For more details on each preset and its intended use, see the corresponding .json file in presetdata/input/consumer/ or presetdata/input/professional/ in the source code distribution.

RetroArch Usage Guidance

For best results with Scanline Classic presets in RetroArch:

  • Set Aspect Ratio to 16:9 in Video > Scaling. This ensures correct geometry for most presets and modern displays.
  • Set Integer Scale to Off. This allows the shader’s geometry and curvature controls to work as intended and avoids unwanted cropping or pillarboxing.
  • After loading a preset, go to Quick Menu > Shaders > Apply Changes to activate it.
  • To save your configuration for future sessions, use Quick Menu > Shaders > Save > Save Game Preset (for per-game) or Save Core Preset (for all games on the current core).

These settings help ensure the presets display as designed and make it easy to recall your preferred look.

Building the Shader Presets

The presets are built dynamically from a Python script, build.py. See external/presetgen for dependency information. After building, the shaders can be found in the out directory.

Usage

Shaders

  • beam-mask.slang: Beam mask simulation for CRT effects.
  • bezel-base.slang, bezel-sdr.slang, bezel-wcg.slang: Bezel overlay shaders for standard dynamic range (SDR) and wide color gamut (WCG) displays.
  • color-base.slang, color-sdr.slang, color-wcg.slang: Color processing shaders for SDR and WCG output.
  • composite-demod.slang, composite-iq.slang, composite-mod.slang, composite-prefilter.slang: Composite video signal simulation and processing.
  • crt-linear.slang: Linear CRT simulation pass.
  • curve.slang: Screen curvature simulation.
  • display-component.slang, display-rgb-bandlimit.slang: Output stage and bandlimiting for component/RGB signals.
  • frame.slang: Frame effects and overlays.
  • iq-demod.slang, iq-filter.slang, iq-noise.slang: I/Q demodulation and noise simulation for analog signals.
  • limiter.slang: Output limiter for signal range.
  • phosphor-chroma.slang, phosphor-luma.slang, phosphor-trichrome.slang: Phosphor decay and color simulation.
  • stock.slang: Stock/utility shader pass.
  • svideo.slang, yc-composite.slang, yc-svideo.slang: S-Video and Y/C signal simulation.
  • sys-component.slang, sys-display-rgb-bandlimit.slang, sys-rgb-amp.slang, sys-rgb-bandlimit.slang, sys-yc.slang: System-level signal and bandlimit simulation.

Parameters

For details about all the parameters available in the shader, see doc/PARAMETERS.md

SDR and WCG Shaders

SDR (Standard Dynamic Range) shaders (e.g., *-sdr.slang) are designed for typical displays with standard color gamuts and brightness. WCG (Wide Color Gamut) shaders (e.g., *-wcg.slang) are optimized for displays that support a wider color space and higher dynamic range, providing richer and more accurate color reproduction. Use the WCG variants if your display supports wide color gamuts (such as DCI-P3 or BT.2020), otherwise use the SDR versions for best compatibility.

Parameters

User Settings

Represents controls accessible to an end user

Common Controls

  • USER_PICTURE - Picture (%): Display ‘Picture’ or ‘Contrast’ control
  • USER_BRIGHTNESS - Brightness (%): Display ‘Brightness’ or ‘Black level’ control
  • USER_SHARPNESS - Sharpness (%): Controls the mixing amount for a twin-peak sharpening circuit

Video Controls

  • USER_COLOR - Color (%): Controls saturation, can be calibrated with blue toggle
  • USER_TINT - Tint (°): Controls tint, can be calibrated with blue toggle; should be set to 0 for PAL

Professional Controls

  • USER_GAMMA - Gamma (0.1): Adjusts the relative gamma by 0.1 increments
  • USER_H_SIZE - Horizontal size (%): Adjusts electronic display width
  • USER_V_SIZE - Vertical size (%): Adjust electronic display height
  • USER_H_POS - Horizontal position (%): Adjusts horizontal center
  • USER_V_POS - Vertical position (%): Adjusts vertical center
  • USER_BLUE_ONLY - Blue toggle (off, on): Sets the display to output only the blue channel for color calibration
  • USER_UNDERSCAN_TOGGLE - Underscan toggle (off, on): Shrinks the picture, allowing the user to see the overscan region

Output Settings

Controls the presentation of the simulated display on the output display

Curvature

  • ZOOM - Viewport zoom (%): Zooms or shrinks the curved display
  • SCREEN_FOCUS - Focus (%): Controls the sharpness of the curvature filter; 50% is recommended

Tone Mapping

  • TONEMAP_TYPE - Tone map output (off, Reinhard, Neutral, ACES): Selects a tonemapping technique; neutral is recommended

Color Correction

  • CHROMATIC_ADAPTATION - Chromatic adaptation (off, Linear Bradford, Zhang-Li, Bradford): Selects the technique for translating the source whitepoint to the display whitepoint; either Linear Bradford or Zhang-Li is recommmended
  • GAMUT_COMPRESSION - Gamut mapping (off, basic, advanced clip, advanced compress): SDR only; selects the technique for translating out of gamut colors to the display gamut; advanced compress is recommended; choose off or basic for speeed

Bezel

  • VIEWPORT_H_POS - Viewport horizontal position: Adjusts horizontal center of the viewport relative to the final display
  • VIWEPORT_V_POS - Viewport vertical position: Adjusts vertical center of the viewport relative to the final display
  • BEZEL_GAIN - Bezel gain (dB): Adjusts brightness of the bezel
  • BEZEL_BIAS - Bezel bias (IRE): Adjusts black level of the bezel

Glow

  • GLOW_WEIGHT - Glow weight: Adjusts the mixing amount of glow upon the bezel
  • GLOW_TEMPERATURE - Glow color temperature: Adjusts bias towards or away from blue (generally, white light appears bluer as it diffuses)
  • GLOW_RADIUS - Glow radius: Affects radius of sample steps for the glow
  • GLOW_DIFFUSION - Glow diffusion: Adjusts how diffuse the glow sampling will be; increasing impacts performance
  • GLOW_COMPRESSION - Glow compression: Lowering this value reduces the saturation of the glow, simulating how light becomes white as it mixes
  • GLOW_FALLOFF - Glow radial falloff: Increasing this value causes the glow to darken more rapidly as it spreads away from the viewport
  • GLOW_VERTICAL_BIAS - Glow vertical bias: Makes the glow from samples arranged above and below the viewport stronger than the horizontal samples

Sevice Settings

Represents controls available either through the service menu, from the back of the unit, or otherwise practically possible for a technician to adjust.

Geometry

  • UNDERSCAN - Underscan (%): The amount of extra space to display when the underscan mode is enabled
  • BEAM_FOCUS - Beam focus (%): Adjusts sharpness of the beam spot
  • S_CORRECTION_H - Horizontal S-correction (%): Electronic polynomial geometry correction
  • S_CORRECTION_V - Vertical S-correction (%): Electronic polynomial geometry correction
  • TRAPEZOIDAL_CORRECTION - Trapezoidal correction (%): Can be used to correct for pincushion, relative to electronic picture
  • CORNER_CORRECTION - Corner correction (%): Tucks in the coners without affecting other parts of the screen
  • MAGNETIC_CORRECTION - Magnetic correction (%): Used to correct for pincushion, relative to the physical screen

Scan Raster

  • DOUBLE_REFRESH - Double refresh rate (eliminates flicker): Some PAL displays worked at 100 Hz instead of 50 Hz. This simulates that effect by eliminating flicker and adjusting the brightness to match with progressive scan mode.
  • SCANLINE_BLANK_WEIGHT - Blank scanline weight (%): Controls how much blank scanlines are darkened. Simulates how different beam spot sizes and TVL can affect blank scanline visibility.

S-Video Input

  • DECODER_SETUP - Setup (off, on): When enabled, the decoder will pull down the luminance level by 7.5 IRE and then normalize gain after processing
  • BW_MODE - Monitor mode (off, BW, Y, C, Y+C): Used for calibration; the technician connects the S-Video cable to a break out and remixes: BW displays a composite signal, Y displays the luminance only, C displays the chroma, Y+C displays both as separate channels
  • DISPLAY_BANDWIDTH_Y - Display bandwidth Y (MHz): Frequency response of the display
  • DISPLAY_BANDWIDTH_C - Display bandwidth C (MHz): Frequency response of the display
  • DISPLAY_CUTOFF_ATTEN_Y - Display cutoff attenuation Y (dB): Frequency response of the display
  • DISPLAY_CUTOFF_ATTEN_C - Display cutoff attenuation C (dB): Frequency response of the display

Composite Input

  • DECODER_SETUP - Setup (off, on): See S-Video Input
  • DECODER_TYPE - Decoder type (YIQ, YPbPr, PAL): Selects the color conevrsion matrix for the input signal; when PAL is selected, the PAL line decoder is used
  • BW_MODE - Monitor mode (off, BW, Y, C, Y+C): Practically the same as the S-Video setting, but represents feeding off the encoder hardware directly
  • COLOR_FILTER_MODE - Filter mode (notch, comb filter, adaptive comb): Selects the composite separation filter technique; notch is recommended for 2D content, adaptive for 3D
  • NOTCH_WIDTH - Luma notch filter width (MHz): Increasing this value reduces color bleeding at the expense of sharpness
  • NOTCH_ATTEN_DB - Luma notch filter attenuation (dB): Increasing this value reduces color bleeding at the expense of sharpness
  • BANDPASS_WIDTH - Chroma bandpass width (MHz): Increasing this value improves color resolution at the expense of introducing artifacts
  • BANDPASS_ATTEN_DB - Chroma bandpass attenuation (dB): Decreasing this value improves color resolution at the expense of introducing artifacts
  • COMB_FILTER_LUMA_ADAPT - Comb filter luma correlation factor: Affects the threshold for determining whether to engage the comb filter on luma channel when the adaptive comb filter is used; higher values require a stronger correlation between lines
  • COMB_FILTER_CHROMA_ADAPT - Comb filter chroma correlation factor: Like the luma correlation factor but for chroma channel
  • COMB_FILTER_2D - Comb filter type (1D, 2D): When 2D is used, the comb filter will analyze the output result with the input for luma restoration; recommended to leave this on 2D
  • DISPLAY_BANDWIDTH_Y - Display bandwidth Y (MHz): See S-Video Input
  • DISPLAY_BANDWIDTH_C - Display bandwidth C (MHz): See S-Video Input
  • DISPLAY_CUTOFF_ATTEN_Y - Display cutoff attenuation Y (dB): See S-Video Input
  • DISPLAY_CUTOFF_ATTEN_C - Display cutoff attenuation C (dB): See S-Video Input

RGB

These controls affect the translation of color to the electron guns.

  • DISPLAY_BIAS_R/G/B - Bias R/G/B (IRE): Adjusts gun level
  • DISPLAY_GAIN_R/G/B - Drive R/G/B (dB): Adjusts gun gain
  • DISPLAY_BANDWIDTH/CUTOFF_ATTEN_R/G/B - Bandwidth / Cutoff attenuation R/G/B (MHz/dB): Adjusts frequency response of R/G/B channels

Factory Settings

These settings represent design parameters that are fixed to the display and cannot be changed after manufacturing.

Geometry

  • ASPECT - Display aspect ratio (4:3, 16:9, 5:4, 16:10): Selects the aspect ratio of the simulated display. Content is adjusted to fit within the frame without distortion; this setting does not affect the content presentation aspect ratio
  • SCREEN_ANGLE_H - Screen angle H (°): The horizontal screen angle of curvature, typically a low angle of no more than 45°
  • SCREEN_ANGLE_V - Screen angel V (°): The vertical screen angle of curvature, use 0 for Trinitron displays

Shadow Mask

  • TVL: Horizontal phosphor triad count
  • MASK_TYPE - Mask type (aperture grille, slot mask, shadow mask): Selects the mask layout, aperture grille is bright, slot mask has medium brightness, shadow mask is the darkest
  • MASK_INTENSITY - Mask intensity (%): Adjusts the strength of the mask effect; recommended to leave at 100 unless brightness is limited
  • MASK_DIFFUSION - Mask diffusion (standard deviations): Adjusts how phosphor dots are blended; it’s preferable to use this to recover brightness. Higher brightness displays can use a lower setting

Colorimetry

  • COLORIMETRY_PRESET - Colorimetry preset (off, SMPTE, Japan, EBU, Rec. 709): Presets colorimetry to the given standard
  • R/G/B_X/Y - Phosphor R/G/B x/y: Selects the chromaticity coordinate for a primary when a preset is not used
  • W_X/Y - White point x/y: Selects the white point chromaticity coordinate

Phosphors

  • PHOSPHOR_MANTISSA/EXPONENT_R/G/B - Phosphor decay mantissa/exponent R/G/B (s/base-10): Selects the phosphor decay time where decay time is mantissa * 10 ^ exponent in seconds
  • PHOSPHOR_HOLD_R/G/B - Phosphos tail hold R/G/B (order): Adjusts the tail strength of the phosphor decay. Higher values results in a longer overall decay

Limiter

  • NTSC_CONVERSION - NTSC conversion matrix (off, on): Enables a conversion matrix to approximate 1953 NTSC colors with limited phosphors; approximates non-standard color decoding on consumer-grade televisions
  • NTSC_CONVERSION_WIGHT - NTSC conversion weight: The level of approximation the display should attempt to correct for when using the NTSC conversion matrix
  • LIMITER_TYPE - Limiter type (soft, hard): Chooses a soft or hard knee for signals that go beyond the compression point (after input gains and biases, before user controls)
  • LIMITER_COMPRESSION_POINT - Compression point (IRE): The IRE level at which the compression knee will begin to be applied. When the hard knee is enabled, this will be the maximum allowed output level.
  • LIMITER_MAX_OUTPUT - Maximum output (IRE): The absolute IRE level that is allowed when the soft knee is used. Levels higher will be clipped.

RF Input

  • IQ_DEMOD_Q_WEIGHT - Vestigial weight: Has a minor effect on restoring luminance after RF demodulation

System Settings

These settings apply to the system, i.e. everything that happens before the signal gets to the simulated display.

RF Modulator

  • USB_BANDWIDTH - Video channel bandwidth (MHz): The upper sideband bandwidth of the RF signal
  • LSB_BANDWIDTH - Vestigial bandwidth (MHz): The lower sideband bandwidth of the RF signal
  • NOISE_MODE - Noise mode (fast, advanced): Fast is AWGN only, advanced has more realistic effects at the expense of performance
  • NOISE_AWGN_DB - AWGN level (dB): Determines the overall noise level of the RF signal
  • NOISE_PHASE_JITTER_DEG - Phase jitter (° RMS): The amount of phase jitter introduced to the RF signal
  • NOISE_GHOST1_DB - Ghost1 level (dB): Controls visibility of multipath ghost; for direct connections, a ghost is unlikely
  • NOISE_GHOST1_PIX - Ghost1 delay (px): Controls the offset of the multipath ghost
  • NOISE_IMPULSE_RATE - Impulse rate (ppm): The frequency of shot noise
  • NOISE_IMPULSE_DB - Impulse level (dB): The level for each shot noise impule

Timing

  • SC_FREQ_MODE - Subcarrier frequency mode (auto, NTSC, PAL, PAL-M, custom): Auto mode attempts to determine from core refresh rate, NTSC, PAL, and PAL-M are fixed to standards, and custom is determined by SC_FREQ
  • SC_FREQ - Custom subcarrier frequency (MHz): See SC_FREQ_MODE
  • PIXEL_CLOCK_MODE - Pixel clock mode (fixed, multiple of subcarrier): Selects whether to treat PIXEL_CLOCK as an absolute value or as a multiple of the subcarrier frequency
  • PIXEL_CLOCK - Pixel clock frequency (MHz) / multiplier - Determines the pixel clock according to PIXEL_CLOCK_MODE, affects how horizontal frequency and line count are determined
  • H_FREQ_MODE - Horizontal frequency mode (standard, pixel clock divisor, custom): Selects how to determine horizontal frequency. Standard chooses a horizontal frequency according to detected video standard, regardless of pixel clock, pixel clock divisor divides the pixel clock by H_FREQ, custom treats H_FREQ as an absolute value
  • H_FREQ - Custom horizontal frequency (kHz) / divisor: Determines the horizontal frequency according to H_FREQ_MODE, affects how vertical lines and vertical frequency are determined
  • V_FREQ_MODE - Vertical sync mode (auto, NTSC, PAL, horizontal frequency divisor, custom): Determines how the vertical sync rate is determined. Auto uses an algorithm based on the reported core refresh to recover exact precision. NTSC and PAL select 59.94 and 50 respectively, horizontal frequency divisor takes the horizontal frequency and divides by V_FREQ, custom is an absolute value
  • V_FREQ - Custom vertical sync rate (Hz) / divisor": Determines the vertical frequency according to V_FREQ_MODE
  • H_BLANK_FUZZ - Horizontal blanknig fuzz factor (%): Sets a factor used to recover the total horizontal pixel timing using fuzzy math. It does not need to be precise.
  • FIELD_ORDER - Field order (even first, odd first): Sets which field is considered first
  • SHORTEN_ODD_FIELD_TIME - NES/SNES field time adjust (off, on): adjusts signal timing according to interlace mode and detected video standard, specifically for the NES and SNES; should be off on any other systems

RGB

These settings are identical to Display RGB but apply at the system level, earlier in the shader pipeline

Component Video

  • YC_MODEL - System color space (YIQ, YPbPr, YUV, YDbDr, YCbCr): Selects the RGB to Y/C conversion matrix
  • SYS_BIAS/GAIN_Y/U/V - Offset/Gain Y/U/V (IRE/dB): Sets the offset and gain for the given channel
  • SYS_BANDWIDTH/CUTOFF_ATTEN_Y/U/V - Bandwidth/Cutoff attenuation Y/U/V (MHz/dB): Controls frequency response of the Y/C components

Encoder

  • ENCODER_SETUP - Setup (off, on): Pushes the luminance level by 7.5 IRE and normalizes gain before encoding
  • PAL - Phase alternating line (off, on): Enables PAL line encoding for phase recovery

Y/C Processing

  • LUMA_LOWPASS_CUTOFF/ATTEN - Y lowpass cutoff/attenuation (MHz/dB): Controls luma frequency response
  • LUMA_NOCH_ENABLE - Y notch filter (off, on): Enables a sysem-level notch filter to reduce color bleeding at the expense of sharpness
  • LUMA_NOCH_WIDTH/ATTEN_DB - Y notch filter width/attenuation (MHz/dB): Controls notch frequency response
  • CHROMA_BANDPASS_WIDTH - Chroma bandpass width / edge attenuation (MHz/dB): Controls chroma separation frequency response; unlike the notch, this is strictly necessary for correct encoding
12 Likes

Updates on what I’ve been working on

I want a shader that can mimic the functionality of Multi-Scan CRTs. It’s a myth that CRTs have no native resolution or can handle any display mode thrown at them, otherwise we would have never needed circuitry to handle NTSC signals on PAL televisions! While CRTs are a lot more flexible than LCDs in how they present a signal, they are in many ways a lot less flexibile and scaleable than LCDs because they usually only operate on a fixed horizontal frequency. For decades that was about 15 kHz, but that started to change in the 16-bit computing era.

One of the pioneers for faster scanning displays was Atari, who used ‘medium res’ monitors in some of their games. The idea behind medium res is simple, take the resolution of a PAL monitor and combine it with the refresh rate of an NTSC monitor. Voila, you have a monitor that can display more lines without the flicker.

If you see a computer system or arcade game that runs at around 384 lines, you know it’s a medium res game. Mac 68ks and the IBM EGA monitor used these resolutions.

Multitasking started to become more important in the mid-80s. 240 and 384 line displays were adequate for single-task workflows, but felt cramped in multi-tasking environments. The easiest way to experience this is to emulate something like a Mac SE/30 and exploring the file system with Finder. Your screen will fill up with windows very quickly. 480 line displays were sorely needed. The Japanese had already solved this problem with 480i modes (because their language requires more pixel-space to render clearly), but this was improved upon by doubling the 15 kHz scan rate to 31 kHz, and thus 480p monitors came about.

But what if we want to display a 15 kHz signal on a 31 kHz monitor? Now the issue of being able to drive multiple signal types with a CRT really needed to be solved. It couldn’t simply be ignored because we needed backwards compatibility with old software designed for ~240 line environment. Video cards also supported more colors on the lower resolutions, which were critical for games. The Japanese (again) had already solved this problem. When a 31 kHz monitor encounters a 15 kHz signal, it doubles the lines and displays the signal with the vertical resolution essentially upscaled as NN. But these monitors were quite expensive. IBM came up with the solution of doing the line-doubling with the video card, so any 31 kHz monitor can be used with the ‘15 kHz’ signals.

Finally it was in the 90s where Multi-Scan capable monitors started to become more common. A CRT monitor would be advertised as supporting a maximum resolution, and the CRT would have built-in circuitry to handle most of the common modes. The CRT could theoretically handle a much larger variety of modes as long as it fell within the configurable horizontal frequency range. Over time, the minimum frequency increased until it capped to 31 kHz, VGA frequency, because VGA compatibility was so critical.

Why does any of this matter for shaders? It doesn’t matter at all for consoles (at least not until we get into HD consoles, at which point CRTs were so rare it’s perhaps not meaningful to have CRT shaders for them). It only matters for computer emulation, and only if we care about the artifacts of a multi-scan monitor. There are three primary complications that multi-scanning has: 1. Scanlines become more visible as lower resolutions are used; 2. the overall brightness of the screen diminishes at lower resolutions (because of 1); and 3. the geometry and positioning of the screen changes depending on the resolution chosen.

1 and 2 are easy enough to implement. We just have to decide on what the ideal resolution of our simulated monitor is. That resolution would not have visible scanlines. When the emulator switches to a mode with a smaller resolution, we would add scanlines. Adding the scanlines would automatically cover 2. 3 is complicated, and every monitor would handle these differently. Actually, it goes back to number 1, because they wouldn’t necessarily cover the same picture area with a lower resolution, and so scanlines might not scale linearly in that respect. Another complication is aspect ratio. Most monitors would just spit everything out in whatever aspect ratio they were designed for, so a 4:3 monitor would distort a 5:4 resolution and vice versa. Late professional monitors (very expensive) could actually handle different aspect ratios and display them correctly.

But 3 is really a non-issue when you consider that by the time Multi-Scan monitors were common, geometry controls on monitors were also common, and usually the user would adjust the picture to fill out the screen to a comfortable (usually we would leave a bit of space to avoid too much curvature). So really it is just 1 that I’m going to focus on.

6 Likes

FYI the Multi-Scan feature has been added and the shader is now at version 4.1. At this point I feel like the shader has all the features needed, but I’ll play with it for a few months to see if I can think of anything else to add.

As a bonus here’s a screenshot of DOS in 80x25 mode on a simulated 1024x768 monitor:

8 Likes

Hey there anikom15,

I read your comment about contrast simulation of a CRT in this other thread (https://forums.libretro.com/t/old-monitor-style-shader-and-overlay-for-dosbox-pure/41535/26 ). That’s definitely an interesting topic. Since you seem to value gamma quality I thought you may find the following about contrast simulation useful.

For simulating the analog CRT “brightness” (black level in analog terms) and “contrast” (brightness in analog terms) of old monitors and TV’s implementing BT. 1886 gamma control seems to do exactly what you want regarding analog contrast, without faking it. Note that Appendix 1 and 2 of the official spec document is what you would want to look into, not the vanilla implementation.

I’ve written a bit about it in the past in another thread and separately from this recently Dogway has implemented it in his grade - update 2023.slang shader.

My post on it here: SpectraCal’s Director of Software Development, Joel Barsotti, argues that the BT. 1886 gamma function (EOTF) more accurately matches legacy CRT technology than previous power law based functions. The argument is that it properly takes into account the legacy “contrast” and “brightness” controls on CRTs.

SpectraCal FAQ on BT. 1886 here: BT.1886 – 10 Questions, 10 Answers

The relevant Rec. ITU-R BT. 1886 document here: Recommendation ITU-R BT.1886 . Be sure to read Appendix 1 and Appendix 2 as that is what you would want to play around with.

Not that you need it, but if you want a quick look at the effect of implementing these analog controls through BT. 1886 EOTF you could try Dogway’s GRADE shader. Here’s his recent implementation forum post: I updated Grade with what I’ve learned in the last 2 years. And his GitHub with his latest RC6 release of the Grade shader: grade - update 2023 RC6.slang

4 Likes

FYI, a bunch of monochrome presets are now available in the repo.

@rafan, thanks for pointing this out. I’m not sure if I want to add this complexity at this point, but I think it will end up being something that gets added to the advanced shader later down the road.

3 Likes

Well, porting this to MAME is on halt because I can’t seem to compile bgfx-tools and I haven’t been able to get in touch with the guy who maintains that. I probably didn’t set up my environment correctly.

I think I will add another parameter which will break compatibility again. I think having a MIN_SCAN_RATE parameter to finely control the line doubler is justified.

An update! But nothing new to try, just plans for the future. Life moves on and unfortunately I do have a fulltime job and lifestyle I have to keep up. But I digress.

After almost a year of using this shader, I am very happy with it. I never got it ported to MAME’s BGFX driver, but I did create a single pass version that can work as a standalone GLSL shader. I’ll provide that as an update sometime soon. This can be used in many emulators that Retroarch doesn’t support, like 86Box.

The big new thing I’m going to work on this year though will be support for colorspaces other than sRGB! My TV supports 10- and 12-bit input and I just got a video card that can support it. My TV also can accept input signals for DCI, Adobe RGB, and BT.2020 (we’ll see if I can find out how much coverage of each color space the TV supports). Here’s a quick overview of what all of that means:

Adobe RGB is the oldest and was originally used by Photoshop to provide a bigger colorspace for photographers to edit their work in. Adobe RGB is mainly supported in expensive professional monitors. It has largely been supplanted by other spaces.

DCI is the color space used by digital cinema equipment. It is famously used by Apple with some slight variations in their iPhone (and maybe some Mac OS X support?). This could be an important space to support for Retroarch on mobile.

BT.2020 is the largest and most recent of the color spaces. It’s really meant to cover as much of our visible color spectrum as possible in order to display content made in either sRGB, DCI, or something else. This will be the one I work on first as this is the current standard for UHD and HDR content.

10- and 12-bit color is also important. sRGB uses 8-bit color, so if you try to use a larger gamut with just 8 bits you’ll get banding. How visible that is is a matter of debate, but 10-bit and 12-bit color provide enough dynamic range to present any of the above color spaces without banding. My display and video card supports both modes and I will test both.

My TV also supports automatically switching colorspace depending on the content providing, but I’m not sure how that will work with Windows and Retroarch. For now, I will have to manually set the colorspace through the shader and through the TV to match. The big question is if Retroarch will support a 10-bit or 12-bit framebuffer. If it doesn’t, I’ll have to modify it to work. This might be tricky to test!

3 Likes

Version 5.0 is ready. This release includes support for other color spaces. You can see the differences in the screenshots below which were made by using an iPhone and a manual camera app. The white balance was fixed to 6500 K with an ISO of 250 and a shutter speed of 120. Obviously not ideal fidelity, but good enough to highlight the limitations of sRGB in the greens and reds. RTINGS.com states that my TV has a coverage of 88% DCI and 64% BT.2020, 100% sRGB and Adobe RGB was not measured. The difference is subtle, however. To my eye, DCI isn’t a good choice, but this may be due to limitations of my TV. DCI overall looks less saturated. BT.2020 and Adobe RGB look good.

It seems 8-bit color is good enough for the Super Nintendo. Banding may become apparent with something higher fidelity, but for now higher-bit color support will be deprioritized in favor of Scanline LCD (which is a misnomer, but I want to keep the brand consistent). Scanline LCD will be a comprehensive shader for LCD displays, including monochrome, reflective, front-lit, back-lit, etc.

More screenshots of the color space modes can be found here: https://postimg.cc/gallery/cN54H3s

5 Likes

Wow! Great photos! These look almost like GPU Screenshots!

I couldn’t find version 5 on Github.

Really nice shader. I searched a long time for a good one to use for PC and Commodore Emulation. For some reason the 86Box Emulator only uses glsl files. For that I have combined your scanline-basic.glsl shader with the curvature of Hyllian’s. Now it is perfect for me to use with 86Box and on my Rpi!

scanline-basic-curvature.glsl
/* Filename: scanline-basic-curvature.glsl

Red Queen: I modified scanline-basic.glsl shader to include Hyllian's screen curvature!

   Copyright (C) 2010 Team XBMC
   Copyright (C) 2011 Stefanos A.
   Copyright (C) 2023 W. M. Martinez
   Copyright (C) 2025 Red Queen

   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.

   This program is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with this program.  If not, see <https://www.gnu.org/licenses/>. */

const float EPS = 1.19209289551e-7;
const float PI = 3.14159265359;
const vec3 BLACK = vec3(0.0, 0.0, 0.0);
const vec3 WHITE = vec3(1.0, 1.0, 1.0);
const vec3 RED = vec3(1.0, 0.0, 0.0);
const vec3 YELLOW = vec3(1.0, 1.0, 0.0);
const vec3 GREEN = vec3(0.0, 1.0, 0.0);
const vec3 BLUE = vec3(0.0, 1.0, 1.0);
const vec3 INDIGO = vec3(0.0, 0.0, 1.0);
const vec3 VIOLET = vec3(1.0, 0.0, 1.0);

float sq(const float x)
{
	return x * x;
}

float cb(const float x)
{
	return x * x * x;
}

float crt_linear(const float x)
{
	return pow(x, 2.4);
}

vec3 crt_linear(const vec3 x)
{
	return vec3(crt_linear(x.r), crt_linear(x.g), crt_linear(x.b));
}

float crt_gamma(const float x)
{
	return pow(x, 1.0 / 2.4);
}

vec3 crt_gamma(const vec3 x)
{
	return vec3(crt_gamma(x.r), crt_gamma(x.g), crt_gamma(x.b));
}

float sdr_linear(const float x)
{
	return x < 0.081 ? x / 4.5 : pow((x + 0.099) / 1.099, 1.0 / 0.45);
}

vec3 sdr_linear(const vec3 x)
{
	return vec3(sdr_linear(x.r), sdr_linear(x.g), sdr_linear(x.b));
}

float sdr_gamma(const float x)
{
	return x < 0.018 ? 4.5 * x : 1.099 * pow(x, 0.45) - 0.099;
}

vec3 sdr_gamma(const vec3 x)
{
	return vec3(sdr_gamma(x.r), sdr_gamma(x.g), sdr_gamma(x.b));
}

float srgb_linear(const float x)
{
	return x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4);
}

vec3 srgb_linear(const vec3 x)
{
	return vec3(srgb_linear(x.r), srgb_linear(x.g), srgb_linear(x.b));
}

float srgb_gamma(const float x)
{
	return x <= 0.0031308 ? 12.92 * x : 1.055 * pow(x, 1.0 / 2.4) - 0.055;
}

vec3 srgb_gamma(const vec3 x)
{
	return vec3(srgb_gamma(x.r), srgb_gamma(x.g), srgb_gamma(x.b));
}

mat3 XYZ_TO_sRGB = mat3(
	 3.2406255, -0.9689307,  0.0557101,
	-1.5372080,  1.8758561, -0.2040211,
	-0.4986286,  0.0415175,  1.0569959);

mat3 colorspace_rgb()
{
	return XYZ_TO_sRGB;
}

vec3 RGB_to_xyY(const float x, const float y, const vec3 Y, const vec3 RGB)
{
	return vec3(x, y, dot(Y, RGB));
}

vec3 xyY_to_XYZ(const vec3 xyY)
{
	float x = xyY.x;
	float y = xyY.y;
	float Y = xyY.z;
	float z = 1.0 - x - y;

	return vec3(Y * x / y, Y, Y * z / y);
}

float kernel(const float x, const float B, const float C)
{
	float dx = abs(x);

	if (dx < 1.0) {
		float P1 = 2.0 - 3.0 / 2.0 * B - C;
		float P2 = -3.0 + 2.0 * B + C;
		float P3 = 1.0 - 1.0 / 3.0 * B;

		return P1 * cb(dx) + P2 * sq(dx) + P3;
	} else if ((dx >= 1.0) && (dx < 2.0)) {
		float P1 = -1.0 / 6.0 * B - C;
		float P2 = B + 5.0 * C;
		float P3 = -2.0 * B - 8.0 * C;
		float P4 = 4.0 / 3.0 * B + 4.0 * C;

		return P1 * cb(dx) + P2 * sq(dx) + P3 * dx + P4;
	} else
		return 0.0;
}

vec4 kernel4(const float x, const float B, const float C)
{
	return vec4(kernel(x - 2.0, B, C),
	            kernel(x - 1.0, B, C),
	            kernel(x, B, C),
	            kernel(x + 1.0, B, C));
}

#pragma parameter TRANSFER_FUNCTION "Transfer function" 1.0 1.0 2.0 1.0 
#pragma parameter COLOR_MODE "Chromaticity mode" 3.0 1.0 3.0 1.0
#pragma parameter LUMINANCE_WEIGHT_R "Red channel luminance weight" 0.2124 0.0 1.0 0.01
#pragma parameter LUMINANCE_WEIGHT_G "Green channel luminance weight" 0.7011 0.0 1.0 0.01
#pragma parameter LUMINANCE_WEIGHT_B "Blue channel luminance weight" 0.0866 0.0 1.0 0.01 
#pragma parameter CHROMA_A_X "Chromaticity A x" 0.630 0.0 1.0 0.001
#pragma parameter CHROMA_A_Y "Chromaticity A y" 0.340 0.0 1.0 0.001
#pragma parameter CHROMA_B_X "Chromaticity B x" 0.310 0.0 1.0 0.001
#pragma parameter CHROMA_B_Y "Chromaticity B y" 0.595 0.0 1.0 0.001
#pragma parameter CHROMA_C_X "Chromaticity C x" 0.155 0.0 1.0 0.001
#pragma parameter CHROMA_C_Y "Chromaticity C y" 0.070 0.0 1.0 0.001
#pragma parameter CHROMA_A_WEIGHT "Chromaticity A luminance weight" 0.2124 0.0 1.0 0.01
#pragma parameter CHROMA_B_WEIGHT "Chromaticity B luminance weight" 0.7011 0.0 1.0 0.01
#pragma parameter CHROMA_C_WEIGHT "Chromaticity C luminance weight" 0.0866 0.0 1.0 0.01
#pragma parameter SCALE_W "Scale white point" 1.0 0.0 1.0 1.0 
#pragma parameter SCAN_TYPE "Scan type" 1.0 1.0 2.0 1.0
#pragma parameter MAX_SCAN_RATE "Maximum active lines" 480.0 1.0 1200.0 1.0
#pragma parameter LINE_DOUBLER "Enable line-doubler" 0.0 0.0 1.0 1.0
#pragma parameter INTER_OFF "Interlace offset" 0.0 0.0 1.0 1.0
#pragma parameter FOCUS "Focus (%)" 0.50 0.0 1.0 0.01
#pragma parameter ZOOM "Viewport zoom" 1.0 0.0 10.0 0.01
#pragma parameter COLOR_SPACE "Output color space" 1.0 1.0 4.0 1.0
#pragma parameter CRT_CURVATURE "CRT-Curvature" 1.0 0.0 1.0 1.0
#pragma parameter CRT_warpX "CRT-Curvature X-Axis" 0.031 0.0 0.125 0.01
#pragma parameter CRT_warpY "CRT-Curvature Y-Axis" 0.041 0.0 0.125 0.01
#pragma parameter CRT_cornersize "CRT-Corner Size" 0.01 0.001 1.0 0.005
#define cornersize CRT_cornersize
#pragma parameter CRT_cornersmooth "CRT-Corner Smoothness" 1000.0 80.0 2000.0 100.0
#define cornersmooth CRT_cornersmooth

#if defined(VERTEX)

#if __VERSION__ >= 130
#define COMPAT_VARYING out
#define COMPAT_ATTRIBUTE in
#define COMPAT_TEXTURE texture
#else
#define COMPAT_VARYING varying
#define COMPAT_ATTRIBUTE attribute
#define COMPAT_TEXTURE texture2D
#endif

#ifdef GL_ES
#define COMPAT_PRECISION mediump
#else
#define COMPAT_PRECISION
#endif

COMPAT_ATTRIBUTE vec4 VertexCoord;
COMPAT_ATTRIBUTE vec4 COLOR;
COMPAT_ATTRIBUTE vec4 TexCoord;
COMPAT_VARYING vec4 COL0;
COMPAT_VARYING vec4 TEX0;

uniform mat4 MVPMatrix;
uniform COMPAT_PRECISION int FrameDirection;
uniform COMPAT_PRECISION int FrameCount;
uniform COMPAT_PRECISION vec2 OutputSize;
uniform COMPAT_PRECISION vec2 TextureSize;
uniform COMPAT_PRECISION vec2 InputSize;

#define vTexCoord TEX0.xy
#define SourceSize vec4(TextureSize, 1.0 / TextureSize)
#define OriginalSize vec4(InputSize, 1.0 / InputSize)
#define OutputSize vec4(OutputSize, 1.0 / OutputSize)

void main()
{
	gl_Position = MVPMatrix * VertexCoord;
	TEX0.xy = TexCoord.xy;
}

#elif defined(FRAGMENT)

#ifdef GL_ES
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
#define COMPAT_PRECISION mediump
#else
#define COMPAT_PRECISION
#endif

#if __VERSION__ >= 130
#define COMPAT_VARYING in
#define COMPAT_TEXTURE texture
out COMPAT_PRECISION vec4 FragColor;
#else
#define COMPAT_VARYING varying
#define FragColor gl_FragColor
#define COMPAT_TEXTURE texture2D
#endif

uniform COMPAT_PRECISION int FrameDirection;
uniform COMPAT_PRECISION int FrameCount;
uniform COMPAT_PRECISION vec2 OutputSize;
uniform COMPAT_PRECISION vec2 TextureSize;
uniform COMPAT_PRECISION vec2 InputSize;
uniform sampler2D Texture;
COMPAT_VARYING vec4 TEX0;

#define Source Texture
#define vTexCoord TEX0.xy

#define SourceSize vec4(TextureSize, 1.0 / TextureSize)
#define OriginalSize vec4(InputSize, 1.0 / InputSize)
#define OutputSize vec4(OutputSize, 1.0 / OutputSize)

#ifdef PARAMETER_UNIFORM
uniform COMPAT_PRECISION float TRANSFER_FUNCTION;
uniform COMPAT_PRECISION float COLOR_MODE;
uniform COMPAT_PRECISION float LUMINANCE_WEIGHT_R;
uniform COMPAT_PRECISION float LUMINANCE_WEIGHT_G;
uniform COMPAT_PRECISION float LUMINANCE_WEIGHT_B;
uniform COMPAT_PRECISION float CHROMA_A_X;
uniform COMPAT_PRECISION float CHROMA_A_Y;
uniform COMPAT_PRECISION float CHROMA_B_X;
uniform COMPAT_PRECISION float CHROMA_B_Y;
uniform COMPAT_PRECISION float CHROMA_C_X;
uniform COMPAT_PRECISION float CHROMA_C_Y;
uniform COMPAT_PRECISION float CHROMA_A_WEIGHT;
uniform COMPAT_PRECISION float CHROMA_B_WEIGHT;
uniform COMPAT_PRECISION float CHROMA_C_WEIGHT;
uniform COMPAT_PRECISION float SCALE_W;
uniform COMPAT_PRECISION float SCAN_TYPE;
uniform COMPAT_PRECISION float MAX_SCAN_RATE;
uniform COMPAT_PRECISION float LINE_DOUBLER;
uniform COMPAT_PRECISION float INTER_OFF;
uniform COMPAT_PRECISION float FOCUS;
uniform COMPAT_PRECISION float ZOOM;
uniform COMPAT_PRECISION float COLOR_SPACE;
uniform COMPAT_PRECISION float CRT_CURVATURE;
uniform COMPAT_PRECISION float CRT_warpX;
uniform COMPAT_PRECISION float CRT_warpY;
uniform COMPAT_PRECISION float CRT_cornersize;
uniform COMPAT_PRECISION float CRT_cornersmooth;
#else
#define TRANSFER_FUNCTION 1.0
#define COLOR_MODE 3.0
#define LUMINANCE_WEIGHT_R 0.2124
#define LUMINANCE_WEIGHT_G 0.7011
#define LUMINANCE_WEIGHT_B 0.0866
#define CHROMA_A_X 0.630
#define CHROMA_A_Y 0.340
#define CHROMA_B_X 0.310
#define CHROMA_B_Y 0.595
#define CHROMA_C_X 0.155
#define CHROMA_C_Y 0.070
#define CHROMA_A_WEIGHT 0.2124
#define CHROMA_B_WEIGHT 0.7011
#define CHROMA_C_WEIGHT 0.0866
#define SCALE_W 1.0
#define SCAN_TYPE 1.0
#define MAX_SCAN_RATE 480.0
#define LINE_DOUBLER 0.0
#define INTER_OFF 0.0
#define FOCUS 0.5
#define ZOOM 1.0
#define COLOR_SPACE 1.0
#define CRT_CURVATURE 1.0 
#define CRT_warpX 0.031 
#define CRT_warpY 0.041
#define CRT_cornersize 0.01 
#define CRT_cornersmooth 1000.0
#endif

const vec2 corner_aspect   = vec2(1.0,  0.75);
vec2 CRT_Distortion = vec2(CRT_warpX, CRT_warpY) * 15.;


float corner(vec2 coord)
{
    coord = (coord - vec2(0.5)) + vec2(0.5, 0.5);
    coord = min(coord, vec2(1.0) - coord) * corner_aspect;
    vec2 cdist = vec2(cornersize);
    coord = (cdist - min(coord, cdist));
    float dist = sqrt(dot(coord, coord));
    
    return clamp((cdist.x - dist)*cornersmooth, 0.0, 1.0);
}


vec2 Warp(vec2 texCoord){
  vec2 curvedCoords = texCoord * 2.0 - 1.0;
  float curvedCoordsDistance = sqrt(curvedCoords.x*curvedCoords.x+curvedCoords.y*curvedCoords.y);

  curvedCoords = curvedCoords / curvedCoordsDistance;

  curvedCoords = curvedCoords * (1.0-pow(vec2(1.0-(curvedCoordsDistance/1.4142135623730950488016887242097)),(1.0/(1.0+CRT_Distortion*0.2))));

  curvedCoords = curvedCoords / (1.0-pow(vec2(0.29289321881345247559915563789515),(1.0/(vec2(1.0)+CRT_Distortion*0.2))));

  curvedCoords = curvedCoords * 0.5 + 0.5;
  return curvedCoords;
}

vec3 Yrgb_to_RGB(mat3 toRGB, vec3 W, vec3 Yrgb)
{
	mat3 xyYrgb = mat3(CHROMA_A_X, CHROMA_A_Y, Yrgb.r,
	                   CHROMA_B_X, CHROMA_B_Y, Yrgb.g,
	                   CHROMA_C_X, CHROMA_C_Y, Yrgb.b);
	mat3 XYZrgb = mat3(xyY_to_XYZ(xyYrgb[0]),
	                   xyY_to_XYZ(xyYrgb[1]),
	                   xyY_to_XYZ(xyYrgb[2]));
	mat3 RGBrgb = mat3(toRGB * XYZrgb[0],
	                   toRGB * XYZrgb[1],
	                   toRGB * XYZrgb[2]);
	return vec3(dot(W, vec3(RGBrgb[0].r, RGBrgb[1].r, RGBrgb[2].r)),
	            dot(W, vec3(RGBrgb[0].g, RGBrgb[1].g, RGBrgb[2].g)),
	            dot(W, vec3(RGBrgb[0].b, RGBrgb[1].b, RGBrgb[2].b)));
} 

vec3 color(sampler2D tex, vec2 uv)
{
	vec3 rgb;
	vec3 Yrgb;

	if (TRANSFER_FUNCTION < 2.0)
		rgb = crt_linear(COMPAT_TEXTURE(tex, uv).rgb);
	else
		rgb = srgb_linear(COMPAT_TEXTURE(tex, uv).rgb);
	if (COLOR_MODE < 2.0) {
		vec3 Wrgb = vec3(LUMINANCE_WEIGHT_R,
		                 LUMINANCE_WEIGHT_G,
		                 LUMINANCE_WEIGHT_B);

		Yrgb.r = dot(Wrgb, rgb);
		Yrgb.g = 0.0;
		Yrgb.b = 0.0;
	} else if (COLOR_MODE < 3.0) {
		vec3 Wrgb = vec3(LUMINANCE_WEIGHT_R,
		                 LUMINANCE_WEIGHT_G,
		                 LUMINANCE_WEIGHT_B);
		Yrgb.r = dot(Wrgb, rgb);
		Yrgb.g = Yrgb.r;
		Yrgb.b = 0.0;
	} else {
		Yrgb.r = rgb.r;
		Yrgb.g = rgb.g;
		Yrgb.b = rgb.b;
	}

	mat3 toRGB = colorspace_rgb();
	vec3 W = vec3(CHROMA_A_WEIGHT, CHROMA_B_WEIGHT, CHROMA_C_WEIGHT);
	vec3 RGB = Yrgb_to_RGB(toRGB, W, Yrgb);

	if (SCALE_W > 0.0) {
		vec3 white = Yrgb_to_RGB(toRGB, W, WHITE);
		float G = 1.0 / max(max(white.r, white.g), white.b);

		RGB *= G;
	}
	return clamp(RGB, 0.0, 1.0); 
}

vec3 pixel(float x, float y, sampler2D tex)
{
	float yThres = (SCAN_TYPE < 2.0 && OriginalSize.y > 1.7 * MAX_SCAN_RATE / 2.0)
	               ? OriginalSize.y / 2.0
	               : OriginalSize.y;
	float scanw;
	int line;

	if (LINE_DOUBLER > 0.0 && OriginalSize.y <= MAX_SCAN_RATE / 2.0 + EPS)
		yThres *= 2.0;
	scanw = max(0.0, 2.0 * (yThres / MAX_SCAN_RATE - 0.5));
	if (SCAN_TYPE < 2.0 && OriginalSize.y > 1.7 * MAX_SCAN_RATE / 2.0) {
		int t = FrameCount % 2;
		line = int(2.0 * yThres * y + float(t));
	} else
		line = int(2.0 * yThres * y);
	line += int(INTER_OFF);

	if (any(lessThan(vec2(x, y), vec2(0.0, 0.0))) ||
	    any(greaterThan(vec2(x, y), vec2(1.0, 1.0))))
		return BLACK;
	else {
		if (line % 2 > 0)
			return mix(BLACK, color(tex, vec2(x, y)), scanw);
		else
			return color(tex, vec2(x, y));
	} 
}

vec3 render(float y, vec4 x, vec4 taps, sampler2D tex)
{
	return pixel(x.r, y, tex) * taps.r +
	       pixel(x.g, y, tex) * taps.g +
	       pixel(x.b, y, tex) * taps.b +
	       pixel(x.a, y, tex) * taps.a;
}

void main()
{
	vec2 uv = vTexCoord.xy;

	uv = (CRT_CURVATURE > 0.5) ? (Warp(uv*TextureSize.xy/InputSize.xy)*InputSize.xy/TextureSize.xy) : uv;

	// Normal coordinates
	uv = 2.0 * (uv - 0.5);
	uv /= ZOOM;
	uv = uv / 2.0 + 0.5;

	vec2 stepxy = 1.0 / SourceSize.xy;

	// Scale vertical for scanlines/interlacing
	if (SCAN_TYPE > 1.0 || OriginalSize.y < 1.7 * MAX_SCAN_RATE / 2.0)
		stepxy.y /= 2.0;	

	vec2 pos = uv + stepxy / 2.0;
	vec2 f = fract(pos / stepxy);

	if (LINE_DOUBLER > 0.0 && OriginalSize.y <= MAX_SCAN_RATE / 2.0 + EPS)
		stepxy.y /= 2.0; 

	float C = -1.0 / 3.0 * sq(FOCUS) + 5.0 / 6.0 * FOCUS;
	float B = 1.0 - 2.0 * C;

	vec4 xtaps = kernel4(1.0 - f.x, B, C);
	vec4 ytaps = kernel4(1.0 - f.y, B, C);

	// Make sure all taps added together is exactly 1.0
	xtaps /= xtaps.r + xtaps.g + xtaps.b + xtaps.a;
	ytaps /= ytaps.r + ytaps.g + ytaps.b + ytaps.a;

	vec2 xystart = (-1.5 - f) * stepxy + pos;
	vec4 x = vec4(xystart.x,
	              xystart.x + stepxy.x,
	              xystart.x + stepxy.x * 2.0,
	              xystart.x + stepxy.x * 3.0);

	vec3 col = vec3(render(xystart.y, x, xtaps, Source) * ytaps.r +
	       render(xystart.y + stepxy.y, x, xtaps, Source) * ytaps.g +
	       render(xystart.y + stepxy.y * 2.0, x, xtaps, Source) * ytaps.b +
	       render(xystart.y + stepxy.y * 3.0, x, xtaps, Source) * ytaps.a);

	col = clamp(col, 0.0, 1.0);
	col = crt_gamma(col);

	FragColor = vec4(col, 1.0);

	FragColor *= (CRT_CURVATURE > 0.5) ? corner(uv*TextureSize.xy/InputSize.xy) : 1.0;

}
#endif
1 Like

I have been away from the forum due to getting married and buying a house. But I can tell everyone I still use the shader every time I run RA! If there are any issues, please let me know.

I recently purchased a VRR capable TV, however, the implementation seems janky, and determining if this is caused by the TV, driver, or RA is hard to determine. If you want the absolute smoothest image, using a custom resolution with your display’s BFI mode turned on is probably the best way, especially if you use any of the interlacing features (PSX in particular; many menu screens are interlaced).

If I have time, I will try to get new pictures of the color modes, as this TV has a wider gamut than my old one.

1 Like

I have a big update to share about a new set of shaders, still work in progress. I ended up implementing NTSC/PAL shaders so that you don’t need to use an external one, with the same design philosophy as the original Scanline Classic shaders. The new shaders will require me to reconfigure the options for the old shaders, and I still need to work on an RF modulator mode. I will also need to redo all the presets, and add new presets for different systems.

Doing NTSC shaders the right way is a challenging endeavor, and the shaders have to be tuned for the specific emulator, especially for systems before the 5th generation and 8-bit computers. This is because those old systems did not output a standards-compliant NTSC/PAL signal. That said, I promise to implement as many presets as I can with the information available to get as accurate a simulation as possible.

For simplicity and speed, each major class of video encoding is in its own shader. I have implemented an RGB bandlimit shader, an S-Video shader, and a composite video shader. I will implement an RF shader (very similar to composite). A component video shader may be practically redundant, and I may do a SECAM one for fun, although AFAIK no actual game hardware ever outputted a true color SECAM signal.

Now for screenshots:

RGB

The RGB filter bandlimits the input to the original Scanline Classic shaders (now called the phosphor and advanced shaders) with a Gaussian lowpass filter. The cutoff frequency is adjustable.

S-Video

The S-Video shader converts the source to Y and C and has adjustable Y and C filters.

Composite (Notch Filter)

The notch filter is the simplest filter for composite video. It preserves vertical resolution and lacks ringing, but has poor clarity. My shader implements notch with Gaussian filters to prevent ringing. The filter is always centered on the color subcarrier (either NTSC or PAL can be set), and the left and right cutoff frequencies are individually adjustable.

Composite (Comb Filter)

The screen shots show a single-line comb filter, although two-line and three-line filters are available. The results I got implementing a two-line and three-line were not very pleasant and may need more work. Comb filters provide reduced crosstalk with at the expense of vertical resolution. I have also introduced a setting to blend the notch and comb filter output together. A comb filter for PAL would operate differently, so I still need to account for that.

Composite (Feedback Filter)

This filter is my own idea, based on the principle of differential signaling. I don’t know if it was ever implemented for CRTs (it would require a line to be sampled digitally), but the output looks similar to video recording devices. After an initial demodulation, a chroma signal is regenerated and this new chroma signal is subtracted from the original composite signal to get luminance. The result is a sharp output but with significant crosstalk and some ringing.

The changes are a work in progress but are publicly available in the dev branch. I hope to finalize this work in the next month or so.

5 Likes

Nice! Analog signal simulation for Retroarch is really advancing quickly in the last few months.

After an initial demodulation, a chroma signal is regenerated and this new chroma signal is subtracted from the original composite signal to get luminance

If I understand what you are doing correctly, this is similar to what I’ve seen in some NTSC decoder datasheets and what I’m doing. I demodulate the chroma out of the composite signal, then low-pass filter it, then remodulate it and subtract it from the composite signal to get the luma.

2 Likes

That’s exactly correct. I found that adding more feedback does not seem to improve the result, so one stage is adequate, and it’s fascinating how accurate an image it can restore. I added two debugging features to the shader which could be useful: there is a monochrome setting that lets you see the underlying dot pattern. The shader calculates the dot pattern using assumptions (as parameters) to determine the amount of time spent per pixel per scanline per field. You can also look at the response of the notch’s low pass and high pass components to tune the notch filter.

2 Likes

There was a lot of information missing, intentionally kept secret or not documented at all in all previous ntsc shaders/filters. I asked a dev of such a filter some info about it some years ago and i was getting some foggy answers that were messing things even more. Like you had to keep a massive array of all 256/320 pixels in a scanline and average them and crap like that. Which could be his way of doing anyway.

I think in some cases you could be right too that C has a uniform bandwidth in modern TVs/encoders, that Q lower one could be a thing in some old broadcasts only. Can’t find a clear answer in any datasheet.

2 Likes

Unfortunately as time goes by, much of the old information is falling off the web. My intention is that the code itself works as a way to document the process and keep knowledge of it alive. What’s good is that there is not a single algorithm that needs to be used. The technology was very flexible in the sense that different techniques lead to the same math, or almost the same math, so there are many ways you could implement these types of shaders and be more or less correct.

A big effect on the perceived ‘rainbow colors’ is the actual subpixel output of the console. Thankfully the NES is very well documented and assuming that same timing for the SNES gave me the results here. It may need some adjustments. One thing to keep in mind is that we can see how the comb filter completely changes how the rainbow artifacts appear. Two TVs would not be guaranteed to have the exact same effect. This lines up with my own experience: different TVs of mine handled composite signals differently. Some were horribly messy and others looked nearly imperceptible to S-Video.

In regards to smaller bandwidth to Q, in practice it doesn’t even matter, especially for old video games that have a strong luminance signal. The difference between 1.3 MHz and 0.6 MHz is so small that it’s almost imperceptible. You can only really notice it by looking only at the chroma signal directly.

I’d also like to say that for me, personally, I have changed in regards to how I see color handling. I have found that the environment has as much, if not more, of an effect on how we see color as the screen itself. For example, the overall brightness of the screen compared to the room effects how we perceive gamma. Our eyes have an incredible ability to adjust perception, and things like color temperature and overall brightness don’t matter that much. A CRT and LCD with identical picture may end up looking different just by virtue of what environment they are in. Therefore, it is more important to optimize the viewing environment first if one is interested in comparing colors at a high precision.

4 Likes

Quick update, I’ve been mainly just tweaking the shader parameters around. I added an encoding filter to mimic behavior on the SNES. However, the filters on the SNES’s video outputs seem too wide to have a visible effect. I also fixed the 2-tap and 3-tap comb filters. They are very blurry. However, I remember TVs with comb filters to be quite blurry. I personally prefer either the feedback filter or the mixed notch and 1-tap comb filter.

Here is a shot of the 2-tap comb filter.

3 Likes

I think notch filters are generally preferable for retro gaming. Comb filters just seem to suck in general with high frequency content, but their properties make them preferable for watching shows/movies/etc.

I think it’s

3d adaptive comb filter > notch filter > other comb filter

1 Like

There is the issue with interlaced 480i too, it will grab 2 lines away as the next one is not available (blank). It should be even worse in 480i. At least i am doing it this way.

Most of these things me, you and everyone doing is assuming, guessing or whatever, as there is no clear answer or documentation. Add the GLSL/SLANG/LCD quirks to the equation. In any case it’s a faithful reproduction of the effect as much as can be done with the minimum info available.

3 Likes