CRT with Luminance Preservation

A fairly realistic CRT shader made with the intent of trying to get as close as possible to a real CRT on my 1080p monitor. It features screen curvature, scanlines, various phosphor arrangements, adjustable color misalignment, a sharpness slider, and more!

Screenshots usually don’t do the shader justice, so see the game ‘Dragon Dragon Fire Fire’ (playable in browser) for a good example of it working properly.

There’s a lot going on here, but one of the main underlying principals is luminance preservation, which means that an image passed into the shader will be roughly as bright as the result (and without any significant changes in saturation or contrast either). I do this by making the patterns most visible in the midtones but letting them disappear in highlights.

This all enables me to make artistic decisions about lighting and color for my project that wouldn’t be significantly different for players who turn off the CRT filter.

Note: I have managed to make several optimizations, but this shader still requires a rather high number of texture samples and calculations to get the effect I wanted, so it may impact performance. On the flip side, this shader is meant for retro games rendered at screen height / 4.5, so that might help make up for the cost.

Setup & Explanation:

This requires a texture much smaller than your screen’s resolution (otherwise scanlines get messed up), so the usual “hint_screen_texture” won’t be helpful here, and you should set the “tex” parameter as a ViewportTexture, and connect the texture’s viewport path to a SubViewport which has the camera and all 2D and 3D elements that you want filtered set as children of the SubViewport. Your material should be on a ColorRect that is a sibling of the SubViewport.

It might help to think of your SubViewport like a stage. Child nodes of the SubViewport are like props and actors on the stage. Your ViewportTexture is like a video feed of that stage (and for 3D elements, you need a camera for that video feed to work).

The size of your texture is controlled by your Subviewport. If you’re using a 1080 monitor (the most common) 240 works pretty well as a height. 240 is also the height of most PS1 games. If that resolution is too limiting, check out this alternate version of my shader which has no scanlines and works a lot better for higher resolutions

Before doing a full setup, I’d recommend starting with a mostly blank scene with just a ColorRect with a CRT ShaderMaterial on it, and set the tex parameter as a static test image. You can use this to quickly experiment and see if the shader fits your project.

Quick Setup:

1: Create a ColorRect. Under layout, set Anchors Preset to “Full Rect”.

2: Create a new shader material for the ColorRect, set its shader to my CRT shader.

3: Create a SubViewport (I make it a sibling of the ColorRect). Almost everything else should be a child of the SubViewport.

4: After creating the SubViewport, go back to the material on the ColorRect. Right click the tex parameter and create new ViewportTexture. It will prompt you to select a SubViewport. There should be only the one you made earlier. Select that one.

5: Adjust the size of the ViewportTexture with the SubViewport. The height of this texture determines scanline count. 240 is usually a pretty safe choice.

V2: Reworked the texture sampling and managed to cut down on two samples without any significant loss in quality (I also found this by accident, lol), so hopefully the shader should be a bit more efficient.
Reworked the phosphor masks so that they are built into the shader and selected from a nice dropdown menu rather than having to swap out textures (this also saves another texture sample).

V3: Added a new mask pattern: “Soft Grille”.
Added a new feature “wobble” which is an attempt to capture a slight horizontal shaking that I’ve noticed in real CRTs. It is off by default.
Fixed the “Null” mask pattern which I had broken (whoops!).
Made several small revisions.

V4: Added seperate sliders for mask brightness and scanline brightness. Both can be reduced to perserve CRT details in bright areas at the cost of brightness.
Made several small revisions, fixed a rare bug.

Shader code
// CRT Shader by Harrison Allen
// V4

shader_type canvas_item;

/**
	The input texture that will have the CRT effect applied to it.
	Scanline count will be determined by this texture's height.
	You'll need to use a texture that's roughly screen height / 4.5
	(for instance 240 on a 1080 monitor or 480 on a 4k monitor)
	Else The scanlines won't properly resolve and you're get moiré patters.
	*/
uniform sampler2D tex: filter_linear;


/**
	Set the type of mask this CRT will have.
	Dots: emulates a typical PC CRT monitor.
	Grille: emulates an aperture grille. Good with higher curve values.
	Wide Grille: more suitable for 4k monitors.
	Soft Grille: very close to wide grille, but a little softer.
	Slot mask: this is the pattern found on most TVs, but it can clash with the
	scanlines unless the input texture resolution is halved.
	*/
uniform int mask_type : hint_enum(
	"Dots:1",
	"Aperture Grille:2",
	"Wide Grille:3",
	"Wide Soft Grille:4",
	"Slot Mask:5",
	"Null:0") = 1;

uniform float curve : hint_range(0.0, 0.5) = 0.0;

/**
	Controls how sharp the image is. Low values are fun with dithering, but a
	value of 0.5 will destroy high frequency details and render small text
	illegible. Use with care.
	*/
uniform float sharpness : hint_range(0.5, 1.0) = 0.6666666666666666666666666667;

/**
	Use to offset color channels from each other. I've personally observed this
	effect in real CRTs. It can go in either direction, and some CRTs are better
	aligned than others.
	I'd suggest offsetting this at least a little bit from the default value.
	Note that because of the typical RGB subpixel layout on on LCD, a very small
	positive value will actually align colors better (it depends on
	screen size), and -0.5 is just slightly more misaligned than 0.5, which is
	important if you want to be as misaligned as possible.
	*/
uniform float color_offset : hint_range(-0.5, 0.5) = 0.0;

/**
	Reduce to preserve phosphor mask details in highlights at the cost of
	overall brightness. This should usually be kept at or near 1
	*/
uniform float mask_brightness : hint_range(0, 1) = 1.0;

/**
	Reduce to preserve scanline details in highlights at the cost of
	overall brightness. This should usually be kept at or near 1
	*/
uniform float scanline_brightness : hint_range(0.5, 1.0) = 1.0;

/**
	Raising this value can help reduce Moiré patterns.
	A value of 1 will eliminate scanlines entirely.
	*/
uniform float min_scanline_thickness : hint_range(0.25, 1.0) = 0.5;

/**
	This should be the input texture's height divided by width.
	Only important if curve is used.
	For 16:9, this should be 0.5625.
	*/
uniform float aspect : hint_range(0.5, 1.0) = 0.75;

/**
	This controls slight horizontal shaking. This is set to 0 (off) by default.
	*/
uniform float wobble_strength : hint_range(0.0, 1.0) = 0.0;

varying flat float wobble;

void vertex()
{
	wobble = cos(TIME * TAU * 15.0) * wobble_strength / 8192.0;
}

vec2 warp(vec2 uv, float _aspect, float _curve)
{
	// Centralize coordinates
	uv -= 0.5;

	uv.x /= _aspect;

	// Squared distance from the middle
	float warping = dot(uv, uv) * _curve;

	// Compensate for shrinking
	warping -= _curve * 0.25;

	// Warp the coordinates
	uv /= 1.0 - warping;

	uv.x *= _aspect;

	// Decentralize the coordinates
	uv += 0.5;

	return uv;
}

vec3 linear_to_srgb(vec3 col)
{
	return mix(
		(pow(col, vec3(1.0 / 2.4)) * 1.055) - 0.055,
		col * 12.92,
		lessThan(col, vec3(0.0031318))
	);
}

vec3 srgb_to_linear(vec3 col)
{
	return mix(
		pow((col + 0.055) / 1.055, vec3(2.4)),
		col / 12.92,
		lessThan(col, vec3(0.04045))
	);
}


// Get scanlines from coordinates (returns in linear color)
vec3 scanlines(vec2 uv)
{
	// Set coordinates to match texture dimensions
	uv *= vec2(textureSize(tex, 0));

	// Vertical coordinate scanline samples
	int y = int(uv.y + 0.5) - 1;

	float x = floor(uv.x);

	// Horizontal coordinates for the texture samples
	float ax = x - 2.0;
	float bx = x - 1.0;
	float cx = x;
	float dx = x + 1.0;
	float ex = x + 2.0;

	// Sample the texture at various points
	vec3 upper_a = texelFetch(tex, ivec2(int(ax), y), 0).rgb;
	vec3 upper_b = texelFetch(tex, ivec2(int(bx), y), 0).rgb;
	vec3 upper_c = texelFetch(tex, ivec2(int(cx), y), 0).rgb;
	vec3 upper_d = texelFetch(tex, ivec2(int(dx), y), 0).rgb;
	vec3 upper_e = texelFetch(tex, ivec2(int(ex), y), 0).rgb;

	// Adjust the vertical coordinate for the lower scanline
	y += 1;

	// Sample the texture at various points
	vec3 lower_a = texelFetch(tex, ivec2(int(ax), y), 0).rgb;
	vec3 lower_b = texelFetch(tex, ivec2(int(bx), y), 0).rgb;
	vec3 lower_c = texelFetch(tex, ivec2(int(cx), y), 0).rgb;
	vec3 lower_d = texelFetch(tex, ivec2(int(dx), y), 0).rgb;
	vec3 lower_e = texelFetch(tex, ivec2(int(ex), y), 0).rgb;

	// Convert every sample to linear color
	upper_a = srgb_to_linear(upper_a);
	upper_b = srgb_to_linear(upper_b);
	upper_c = srgb_to_linear(upper_c);
	upper_d = srgb_to_linear(upper_d);
	upper_e = srgb_to_linear(upper_e);

	lower_a = srgb_to_linear(lower_a);
	lower_b = srgb_to_linear(lower_b);
	lower_c = srgb_to_linear(lower_c);
	lower_d = srgb_to_linear(lower_d);
	lower_e = srgb_to_linear(lower_e);

	// The x coordinates of electron beam offsets
	vec3 beam = vec3(uv.x - 0.5);
	beam.r -= color_offset;
	beam.b += color_offset;

	// Calculate weights
	vec3 weight_a = smoothstep(1, 0, (beam - ax) * sharpness);
	vec3 weight_b = smoothstep(1, 0, (beam - bx) * sharpness);
	vec3 weight_c = smoothstep(1, 0, abs(beam - cx) * sharpness);
	vec3 weight_d = smoothstep(1, 0, (dx - beam) * sharpness);
	vec3 weight_e = smoothstep(1, 0, (ex - beam) * sharpness);
	
	// This can be a fun place to raise each weight to some power

	// Mix samples into the upper scanline color
	vec3 upper_col = vec3(
		upper_a * weight_a +
		upper_b * weight_b +
		upper_c * weight_c +
		upper_d * weight_d +
		upper_e * weight_e
	);

	// Mix samples into the lower scanline color
	vec3 lower_col = vec3(
		lower_a * weight_a +
		lower_b * weight_b +
		lower_c * weight_c +
		lower_d * weight_d +
		lower_e * weight_e
	);

	vec3 weight_scaler = vec3(1.0) / (weight_a + weight_b + weight_c + weight_d + weight_e);

	// Normalize weight
	upper_col *= weight_scaler;
	lower_col *= weight_scaler;
	
	// Apply scanline brightness
	upper_col *= scanline_brightness;
	lower_col *= scanline_brightness;
	
	
	// Scanline size (and roughly the apperent brightness of this line)
	vec3 upper_thickness = mix(vec3(min_scanline_thickness), vec3(1.0), upper_col);
	vec3 lower_thickness = mix(vec3(min_scanline_thickness), vec3(1.0), lower_col);
	
	// Vertical sawtooth wave used to generate scanlines
	// Almost the same as fract(uv.y + 0.5), but prevents a rare visual bug
	float sawtooth = (uv.y + 0.5) - float(y);

	vec3 upper_line = vec3(sawtooth) / upper_thickness;
	upper_line = smoothstep(1.0, 0.0, upper_line);

	vec3 lower_line = vec3(1.0 - sawtooth) / lower_thickness;
	lower_line = smoothstep(1.0, 0.0, lower_line);

	// Correct line brightness below min_scanline_thickness
	upper_line *= upper_col / upper_thickness;
	lower_line *= lower_col / lower_thickness;

	// Combine the upper and lower scanlines
	return upper_line + lower_line;
}

vec4 generate_mask(vec2 fragcoord)
{
	switch (mask_type)
	{
		case 1: // Dots
			const vec3 pattern[] = {vec3(1,0,0), vec3(0,1,0), vec3(0,0,1), vec3(0,0,0)};

			ivec2 icoords = ivec2(fragcoord);

			return vec4(pattern[(icoords.y * 2 + icoords.x) % 4], 0.25);
			
		case 2: // Grille
			const vec3 pattern[] = {vec3(0,1,0), vec3(1,0,1)};

			return vec4(pattern[int(fragcoord.x) % 2], 0.5);
			
		case 3: // Wide grille
			const vec3 pattern[] = {
				vec3(1,0,0), vec3(0,1,0), vec3(0,0,1), vec3(0,0,0)};

			return vec4(pattern[int(fragcoord.x) % 4], 0.25);
			
		case 4: // Grille wide soft
			const vec3 pattern[] = {
				vec3(1.0,0.125,0.0),
				vec3(0.125,1.0,0.125),
				vec3(0.0,0.125,1.0),
				vec3(0.125,0.0,0.125)};

			return vec4(pattern[int(fragcoord.x) % 4], 0.3125);
			
		case 5: // Slotmask
			const vec3 pattern[] = {
				vec3(1,0,1), vec3(0,1,0), vec3(1,0,1), vec3(0,1,0),
				vec3(0,0,1), vec3(0,1,0), vec3(1,0,0), vec3(0,0,0),
				vec3(1,0,1), vec3(0,1,0), vec3(1,0,1), vec3(0,1,0),
				vec3(1,0,0), vec3(0,0,0), vec3(0,0,1), vec3(0,1,0)
			};
			
			ivec2 icoords = ivec2(fragcoord) % 4;
			
			return vec4(pattern[icoords.y * 4 + icoords.x], 0.375);
			
		default:
			return vec4(0.5);
	}
}

// Add phosphor mask/grill
vec3 mask(vec3 linear_color, vec2 fragcoord)
{
	// Get the pattern for the mask. Mask.w equals avg. brightness of the mask
	vec4 mask = generate_mask(fragcoord);
	
	// Dim the color if brightness is reduced to preserve mask details
	linear_color *= mix(mask.w, 1.0, mask_brightness);

	// How bright the color needs to be to maintain 100% brightness while masked
	vec3 target_color = linear_color / mask.w;

	// Target color limited to the 0 to 1 range.
	vec3 primary_col = clamp(target_color, 0.0, 1.0);

	// This calculates how bright the secondary subpixels will need to be
	vec3 highlights = target_color - primary_col;
	highlights /= 1.0 / mask.w - 1.0;

	primary_col *= mask.rgb;

	// Add the secondary subpixels
	primary_col += highlights * (1.0 - mask.rgb);

	return primary_col;
}

void fragment()
{
	// Warp UV coordinates
	vec2 warped_coords = warp(UV, aspect, curve);
	
	// Add wobble
	warped_coords.x += wobble;

	// Sample the scanlines
	vec3 col = scanlines(warped_coords);

	// Apply phosphor mask
	col = mask(col, FRAGCOORD.xy);

	// Convert back to srgb
	col = linear_to_srgb(col);

	COLOR.rgb = col;
}
Tags
CRT, Post processing, ps1, psx, realistic, retro, scanlines, tv
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from Harrison Allen

CRT with Luminance Preservation (no scanlines)

Related shaders

CRT with Luminance Preservation (no scanlines)

Color Swap with Hue Variation Preservation

CRT Display Shader (Pixel Mask, Scanlines & Glow) [Godot 4.4.1]

guest

31 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
TankTheta
TankTheta
4 months ago

Hey how does this actually get set up?

TankTheta
TankTheta
3 months ago
Reply to  Harrison Allen

Can you do some sort of walkthrough on this?
I can’t really seem to figure out how to actually get this working. I apologize, subviewports are not really my forte. And it seems difficult trying to find documentation about it

Last edited 3 months ago by TankTheta
definesleep
definesleep
3 months ago
Reply to  Harrison Allen

its my first time using shaders and that explanation helped a lot to start , thanks

bonejackal
bonejackal
3 months ago
Reply to  Harrison Allen

hey, i’m trying to apply this texture to a mesh as a subviewport texture, to make it look like a TV with your shader effect. it seems that i can’t manage to because the subviewport i need to use as my texture is itself rendering a subviewport because of this limitation. is there any way around this? 😩

bonejackal
bonejackal
3 months ago
Reply to  Harrison Allen

ahhhh, it’s more like, i can get it working in 2D, but i’m trying to use the resulting control node as the child of a subviewport that i can apply as a texture to a 3D object because it should just display the 2D control node as-is. but i’m wondering if the chained subviewports is making that an issue.

Kayzori
3 months ago

can I use this for my commercial game ?

bonejackal
bonejackal
3 months ago

this looks incredible, it’s people like you that keep the world turning dude!

blue mue
blue mue
3 months ago

I’m having an issue where the CRT mask extends far past the intended area. It even goes out into the inspector. I’m using the linux version of godot, if that helps?
It’s most apparent with the “aperture grille” mask type, but happens with all choices.

doffu
3 months ago

This is great! I was wondering if you have plans for a version that’s compatible with Godot 4.3? I happen to be using that version and it didn’t work as-is. However, I’ve made the appropriate changes to get it working with that version now.

Here’s the shader code in case you wanted to expand on it, or for other 4.3 users:

// CRT Shader by Harrison Allen
// V4 (Godot 4.3-safe version)

shader_type canvas_item;

// --------- Uniforms ---------
uniform sampler2D tex;                // assign a ViewportTexture (from your SubViewport)
uniform int   mask_type = 0;          // 0=Dots, 1=Aperture Grille, 2=Wide Grille, 3=Wide Soft Grille, 4=Slot Mask, 5=Null
uniform float curve = 0.5;
uniform float sharpness = 0.6666667;
uniform float color_offset = 0.0;
uniform float aspect = 0.75;          // height/width of input (e.g., 0.5625 for 16:9)
uniform float min_scanline_thickness = 0.5;
uniform float wobble_strength = 0.0;


// NEW: separate brightness controls (like V4)
uniform float mask_brightness = 1.0;      // 0..1 (lower to preserve mask detail in brights)
uniform float scanline_brightness = 1.0;  // 0.5..1 (lower to preserve scanline detail in brights)


varying float wobble;


void vertex() {
    // use explicit 2*PI to avoid TAU issues
    wobble = cos(TIME * 6.283185307179586 * 15.0) * wobble_strength / 4000.0;
}


vec2 warp(vec2 uv, float _aspect, float _curve) {
    uv -= 0.5;
    uv.x /= _aspect;


    float warping = dot(uv, uv) * _curve;
    warping -= _curve * 0.25;


    uv /= (1.0 - warping);
    uv.x *= _aspect;
    uv += 0.5;
    return uv;
}


vec3 linear_to_srgb(vec3 col) {
    bvec3 cut = lessThan(col, vec3(0.0031318));
    vec3 a = (pow(col, vec3(1.0 / 2.4)) * 1.055) - 0.055;
    vec3 b = col * 12.92;
    return mix(a, b, cut);
}


vec3 srgb_to_linear(vec3 col) {
    bvec3 cut = lessThan(col, vec3(0.04045));
    vec3 a = pow((col + 0.055) / 1.055, vec3(2.4));
    vec3 b = col / 12.92;
    return mix(a, b, cut);
}


// Get scanlines from coordinates (returns in linear color)
vec3 scanlines(vec2 uv) {
    uv *= vec2(textureSize(tex, 0));


    int y = int(floor(uv.y - 0.5));
    float x = floor(uv.x);


    float ax = x - 2.0;
    float bx = x - 1.0;
    float cx = x;
    float dx = x + 1.0;
    float ex = x + 2.0;


    vec3 upper_a = texelFetch(tex, ivec2(int(ax), y), 0).rgb;
    vec3 upper_b = texelFetch(tex, ivec2(int(bx), y), 0).rgb;
    vec3 upper_c = texelFetch(tex, ivec2(int(cx), y), 0).rgb;
    vec3 upper_d = texelFetch(tex, ivec2(int(dx), y), 0).rgb;
    vec3 upper_e = texelFetch(tex, ivec2(int(ex), y), 0).rgb;


    y += 1;


    vec3 lower_a = texelFetch(tex, ivec2(int(ax), y), 0).rgb;
    vec3 lower_b = texelFetch(tex, ivec2(int(bx), y), 0).rgb;
    vec3 lower_c = texelFetch(tex, ivec2(int(cx), y), 0).rgb;
    vec3 lower_d = texelFetch(tex, ivec2(int(dx), y), 0).rgb;
    vec3 lower_e = texelFetch(tex, ivec2(int(ex), y), 0).rgb;


    upper_a = srgb_to_linear(upper_a);
    upper_b = srgb_to_linear(upper_b);
    upper_c = srgb_to_linear(upper_c);
    upper_d = srgb_to_linear(upper_d);
    upper_e = srgb_to_linear(upper_e);


    lower_a = srgb_to_linear(lower_a);
    lower_b = srgb_to_linear(lower_b);
    lower_c = srgb_to_linear(lower_c);
    lower_d = srgb_to_linear(lower_d);
    lower_e = srgb_to_linear(lower_e);


    vec3 beam = vec3(uv.x - 0.5);
    beam.r -= color_offset;
    beam.b += color_offset;


    vec3 weight_a = smoothstep(1.0, 0.0, (beam - ax) * sharpness);
    vec3 weight_b = smoothstep(1.0, 0.0, (beam - bx) * sharpness);
    vec3 weight_c = smoothstep(1.0, 0.0, abs(beam - cx) * sharpness);
    vec3 weight_d = smoothstep(1.0, 0.0, (dx - beam) * sharpness);
    vec3 weight_e = smoothstep(1.0, 0.0, (ex - beam) * sharpness);


    vec3 upper_col = upper_a * weight_a +
                     upper_b * weight_b +
                     upper_c * weight_c +
                     upper_d * weight_d +
                     upper_e * weight_e;


    vec3 lower_col = lower_a * weight_a +
                     lower_b * weight_b +
                     lower_c * weight_c +
                     lower_d * weight_d +
                     lower_e * weight_e;


    vec3 weight_scaler = vec3(1.0) / (weight_a + weight_b + weight_c + weight_d + weight_e);
    upper_col *= weight_scaler;
    lower_col *= weight_scaler;


    // NEW: apply scanline brightness BEFORE computing thickness/shapes
    upper_col *= scanline_brightness;
    lower_col *= scanline_brightness;


    vec3 upper_thickness = mix(vec3(min_scanline_thickness), vec3(1.0), upper_col);
    vec3 lower_thickness = mix(vec3(min_scanline_thickness), vec3(1.0), lower_col);


    float sawtooth = fract(uv.y + 0.5);


    vec3 upper_line = vec3(sawtooth) / upper_thickness;
    upper_line = smoothstep(1.0, 0.0, upper_line);


    vec3 lower_line = vec3(1.0 - sawtooth) / lower_thickness;
    lower_line = smoothstep(1.0, 0.0, lower_line);


    upper_line *= upper_col / upper_thickness;
    lower_line *= lower_col / lower_thickness;


    vec3 combined = upper_line + lower_line;


    // Keep the darker fallback for very thin lines
    vec3 dark_upper = smoothstep(min_scanline_thickness, 0.0, vec3(sawtooth)) * upper_col;
    vec3 dark_lower = smoothstep(min_scanline_thickness, 0.0, vec3(1.0 - sawtooth)) * lower_col;
    vec3 dark_combined = dark_upper + dark_lower;


    // Use scanline_brightness already applied; return the shaped result
    // (No extra global "brightness" mix anymore)
    // Slight bias toward shaped lines:
    return mix(dark_combined, combined, 0.9);
}


// ----- Small helpers (no arrays/ternaries) -----
vec3 _pick4(int idx, vec3 a, vec3 b, vec3 c, vec3 d) {
    if (idx == 0) return a;
    if (idx == 1) return b;
    if (idx == 2) return c;
    return d;
}


vec3 _slot_color(int idx) {
    // 16-item pattern spelled out explicitly
    if (idx == 0)  return vec3(1,0,1);
    if (idx == 1)  return vec3(0,1,0);
    if (idx == 2)  return vec3(1,0,1);
    if (idx == 3)  return vec3(0,1,0);
    if (idx == 4)  return vec3(0,0,1);
    if (idx == 5)  return vec3(0,1,0);
    if (idx == 6)  return vec3(1,0,0);
    if (idx == 7)  return vec3(0,0,0);
    if (idx == 8)  return vec3(1,0,1);
    if (idx == 9)  return vec3(0,1,0);
    if (idx == 10) return vec3(1,0,1);
    if (idx == 11) return vec3(0,1,0);
    if (idx == 12) return vec3(1,0,0);
    if (idx == 13) return vec3(0,0,0);
    if (idx == 14) return vec3(0,0,1);
    return vec3(0,1,0);
}


vec4 generate_mask(vec2 fragcoord) {
    int mx = mask_type;


    if (mx == 0) {
        // Dots
        ivec2 icoords = ivec2(floor(fragcoord));
        int idx = (icoords.y * 2 + icoords.x) % 4;
        vec3 c = _pick4(idx, vec3(1,0,0), vec3(0,1,0), vec3(0,0,1), vec3(0,0,0));
        return vec4(c, 0.25);
    } else if (mx == 1) {
        // Aperture Grille
        int idx = int(floor(fragcoord.x)) % 2;
        vec3 c = vec3(0.0);
        if (idx == 0) c = vec3(0,1,0);
        else          c = vec3(1,0,1);
        return vec4(c, 0.5);
    } else if (mx == 2) {
        // Wide Grille
        int idx = int(floor(fragcoord.x)) % 4;
        vec3 c = _pick4(idx, vec3(1,0,0), vec3(0,1,0), vec3(0,0,1), vec3(0,0,0));
        return vec4(c, 0.25);
    } else if (mx == 3) {
        // Wide Soft Grille
        int idx = int(floor(fragcoord.x)) % 4;
        vec3 c = _pick4(idx,
            vec3(1.0, 0.125, 0.0),
            vec3(0.125, 1.0, 0.125),
            vec3(0.0, 0.125, 1.0),
            vec3(0.125, 0.0, 0.125)
        );
        return vec4(c, 0.3125);
    } else if (mx == 4) {
        // Slot Mask
        ivec2 ico = ivec2(floor(fragcoord));
        int ix = ico.x % 4;
        int iy = ico.y % 4;
        int idx = iy * 4 + ix;
        vec3 c = _slot_color(idx);
        return vec4(c, 0.375);
    } else {
        // Null
        return vec4(0.5);
    }
}


// Add phosphor mask/grille  (uses mask_brightness like V4)
vec3 mask(vec3 linear_color, vec2 fragcoord) {
    vec4 m = generate_mask(fragcoord);


    // Dim base to preserve mask detail in highlights (1.0 = no dimming)
    float dim = mix(m.w, 1.0, mask_brightness);
    linear_color *= dim;


    // Compute primary + highlights same as V4
    vec3 target_color = linear_color / m.w;
    vec3 primary_col = clamp(target_color, 0.0, 1.0);


    vec3 highlights = target_color - primary_col;
    highlights /= (1.0 / m.w - 1.0);


    primary_col *= m.rgb;
    primary_col += highlights * (1.0 - m.rgb);


    return primary_col;
}


void fragment() {
    vec2 warped_coords = warp(UV, aspect, curve * 0.5);
    warped_coords.x += wobble;


    vec3 col = scanlines(warped_coords);
    col = mask(col, FRAGCOORD.xy);
    col = linear_to_srgb(col);


    COLOR.rgb = col;
}




doffu
3 months ago
Reply to  Harrison Allen

Thanks for pointing that out. I may try making that change. I also wanted to attempt bringing in some ideas I’ve seen other CRT shaders do like a screen warp.

I don’t often make shaders but like to tweak other peoples. I was working on altering a CRT shader I found previously but yours is much better all around, so I’m going to try and merge some of the ideas and try to build my own. With the previous one I was working on, I had added an RGB split optics compensation effect. It’s not realistic to how CRT displays work but the effect is pretty cool, so I’ll try and integrate it as a slider option.

Now that you pointed this out and I’ve researched further, it seems the changes I made weren’t so much to do with the godot version but rather to make it compile in compatibility mode which was giving me issues with hints, arrays, and ternary operators. So changing that aspect of the shader made things work. My mistake.

doffu
3 months ago
Reply to  Harrison Allen

Oh yeah, that would be really cool. I’ll keep following what you’re doing as I’m not sure how you’d go about accomplishing that glass look but it sounds like it could be great.

I’ll reply again once I have something working and you can feel free to pull any additions I come up with into your own shader. What I’m doing might be a little different anyways as I’m trying to keep everything lightweight to work in compatibility mode (I’m trying to develop a retro point-and-click game).

doffu
3 months ago
Reply to  Harrison Allen

Here’s what I came up with. It might be a bit overkill but there may be some concepts in there that you want to pull out and use for your own shader.

trackback

[…] had an adventure of trying to implement the CRT shader that went semi-viral on r/Godot. Despite successfully implementing it, which required some changes […]

tqef
2 months ago

Dude, I need help…
in the shader when I click tex and “new viewport” godot sends me this warning

“Can’t create a ViewportTexture on this resource because it’s not set as local to scene.
Please switch on the ‘local to scene’ property on it (and all resources containing it up to a node).”

I am on godot 4.4

forgottenworkshop
forgottenworkshop
1 month ago

It’d be cool if this could be reconfigured to take advantage of the new BackBufferCopy node.

forgottenworkshop
forgottenworkshop
1 month ago

Oh, nevermind it actually works out-of-the-box with BackBufferCopy; just have to add hint_screen_texture before the filter_linear on tex

JunkBug
JunkBug
1 month ago

Hi im having trouble getting my mouse inputs to work with this. so far i have tried enabling object picking on the subviewport, ignoring mouse on the colorrect, and even putting everything inside a subviewportcontainer

JunkBug
JunkBug
1 month ago
Reply to  JunkBug

i can get input to work if i put it in a subviewportcontainer and use push_input to the subviewport, but inside a subviewportcontainer the shader doesnt show. might be an issue with my computer not sure yet though

randydandy
randydandy
25 days ago

I have a 3D game, where I have a monitor set up. It is a 3D scene, using a subviewport that contains only the control nodes for the monitor scene. I display this, by having a QuadMesh and having it’s Albedo Texture be the Viewport Texture of said viewport.

I am not sure how to set up your shader in this scenario. I tried adding another viewport, I tried adding the ColorRect inside the control elements of my first viewport. Nothing ends up looking as I expect and I keep getting the moire pattern. I would appreciate any help! Thanks 🙂

randydandy
randydandy
25 days ago
Reply to  randydandy

@PS:

When I set it up as follows:

SubViewport1 (the computer ui)

  • control nodes

QuadMesh (renders the above subviewport)
Camera

  • QuadMesh2 (renders the below subviewport)

SubViewPort2

  • ColorRect (has the shader on it)

I can kind of overlay it, but it doesn’t actually apply the shader to the elements, but acts as a sort of overlay – and it renders incorrectly, with lots of vertical lines.