The Scanline Classic Shader

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.

1 Like

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.

3 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

I couldn’t help but notice the high compression levels in your *.jpeg screenshots. You can use IrFanView to set a filesize target of 4095KB and it will calculate the minimum amount of compression required to achieve that target.

1 Like

Thanks. I was just relying on PNG-8 format. I will see next time if JPEG will give a better result.

1 Like

I’ve been playing around with composite on the Mega Drive. I am relying on the Mega Drive timing documented here:

https://archive.ph/gdC4x

I believe most games run in the H320F mode. In order to restore the dot pattern from the RGB we get from the emulator, we need the number of lines per field (262), horizontal frequency (15.980112 kHz for H320F), and the amount of inactive pixels (100 for H320F mode corresponding to 420 total pixels per line). Horizontal frequency affects the ‘angle’ of rainbow artifacts by changing the dot pattern shift per line. In this case there is not supposed to be any shift per line so we see this colorful prismatic effect on NTSC Mega Drives. Lines and inactive pixels affect the distribution and number of dots we get for each pixel.

The monochrome mode of Scanline Classics NTSC shaders allows us to see the restored dot pattern.

There is some debate about how much the rainbow pattern is supposed to be seen through on an actual television. I have seen a lot of examples, both direct captures and controlled photographs, and they show a wide variation. The components that make up video filters for this are prone to aging, degradation, and poor tolerance. Couple that with the number of questionable mods people have applied to hardware makes matching it perfectly a moving target

Starting with the strongest filtering, Y encoding filter set to 1.8 MHz, we see that the rainbow artifacts are virtually nonexistant, but the picture is also very soft. I am using the feedback decoding filter in Scanline Classic for all these screenshots. This cutoff is based on an analysis of a Japanese Mega Drive or Genesis schematic assuming good isolation between Y input and output for the video encoding chip.

If we set the filter to 3.6 MHz, the rainbow effect becomes quite strong:

Without any encoding filters at all, we get this effect:

It’s quite possible that when the Mega Drives were new, the effect wasn’t as strong, but with aging the effect has become more apparent. A setting of anywhere between 1.8 MHz and 3.6 MHz seems reasonable.

Updates are available in dev branch.

5 Likes