Files
Decked-Out-Defense/crt_harrison.gdshader
2025-10-27 01:25:15 +11:00

340 lines
9.3 KiB
Plaintext

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