Skip to content

Instantly share code, notes, and snippets.

@curioustorvald
Last active November 25, 2025 15:41
Show Gist options
  • Select an option

  • Save curioustorvald/856bfe83b307653245a4fa5bd98e1f33 to your computer and use it in GitHub Desktop.

Select an option

Save curioustorvald/856bfe83b307653245a4fa5bd98e1f33 to your computer and use it in GitHub Desktop.
Flat screen CRT shader with composite signal simulation for authenticity
#version 330 core
/*
Copyright 2025 CuriousTorvald
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
// ============================================================================
// CRT + NTSC Composite/S-Video Signal Simulation Shader (Enhanced Version)
// ============================================================================
// Features:
// - Runtime-switchable composite/S-Video mode (no recompilation)
// - Adjustable signal and CRT parameters via uniforms
// - Accurate NTSC color artifact simulation
// - Animated dot crawl effect
// - Trinitron phosphor mask
// ============================================================================
// === UNIFORMS ===
uniform float time = 0.0; // Frame count
uniform vec2 resolution = vec2(640.0, 480.0); // Virtual resolution (e.g., 640x480)
uniform sampler2D u_texture; // Input texture
uniform vec2 flip = vec2(0.0, 0.0); // UV flip control (0,1 = flip Y)
// Signal mode: 0 = S-Video, 1 = Composite
// Can be changed at runtime without recompilation
uniform int signalMode = 1; // Default should be 1 for composite
// Optional adjustable parameters (set reasonable defaults if not provided)
uniform float lumaFilterWidth; // Default: 1.5
uniform float chromaIFilterWidth; // Default: 3.5
uniform float chromaQFilterWidth; // Default: 6.0
uniform float compositeFilterWidth; // Default: 1.5
uniform float phosphorIntensity; // Default: 0.25
uniform float scanlineIntensity; // Default: 0.12
in vec2 v_texCoords;
out vec4 fragColor;
// === CONSTANTS ===
const float PI = 3.14159265358979323846;
const float TAU = 6.28318530717958647692;
// NTSC color subcarrier: 3.579545 MHz
// At 640 pixels for ~52.6µs active video: cycles/pixel ≈ 0.2917
const float CC_PER_PIXEL = 0.2917;
// Filter kernel radius (samples to each side)
const int FILTER_RADIUS = 12;
// === COLOR SPACE CONVERSION ===
// GLSL matrices are column-major
const mat3 RGB_TO_YIQ = mat3(
0.299, 0.596, 0.211, // Column 0: R coefficients for Y,I,Q
0.587, -0.274, -0.523, // Column 1: G coefficients
0.114, -0.322, 0.312 // Column 2: B coefficients
);
const mat3 YIQ_TO_RGB = mat3(
1.000, 1.000, 1.000, // Column 0: Y coefficients for R,G,B
0.956, -0.272, -1.107, // Column 1: I coefficients
0.621, -0.647, 1.704 // Column 2: Q coefficients
);
// === DEFAULT VALUES ===
// Used when uniforms aren't set (value of 0)
float getLumaFilter() {
return lumaFilterWidth > 0.0 ? lumaFilterWidth : 1.5;
}
float getChromaIFilter() {
return chromaIFilterWidth > 0.0 ? chromaIFilterWidth : 3.5;
}
float getChromaQFilter() {
return chromaQFilterWidth > 0.0 ? chromaQFilterWidth : 6.0;
}
float getCompositeFilter() {
return compositeFilterWidth > 0.0 ? compositeFilterWidth : 1.5;
}
float getPhosphorStrength() {
return phosphorIntensity > 0.0 ? phosphorIntensity : 0.25;
}
float getScanlineStrength() {
return scanlineIntensity > 0.0 ? scanlineIntensity : 0.12;
}
// === HELPER FUNCTIONS ===
float gaussianWeight(float x, float sigma) {
return exp(-0.5 * x * x / (sigma * sigma));
}
vec3 sampleTexture(vec2 uv) {
return texture(u_texture, clamp(uv, 0.0, 1.0)).rgb;
}
float calcCarrierPhase(float pixelX, float pixelY, float frameOffset) {
float phase = pixelX * TAU * CC_PER_PIXEL;
phase += pixelY * PI; // 180° per line (from 227.5 cycles/line)
phase += frameOffset;
return phase;
}
float encodeComposite(vec3 rgb, float phase) {
vec3 yiq = RGB_TO_YIQ * rgb;
return yiq.x + yiq.y * cos(phase) + yiq.z * sin(phase);
}
// === COMPOSITE SIGNAL DECODE ===
vec3 decodeComposite(vec2 uv, vec2 texelSize, float basePhase) {
float compFilter = getCompositeFilter();
float iFilter = getChromaIFilter();
float qFilter = getChromaQFilter();
float yAccum = 0.0, iAccum = 0.0, qAccum = 0.0;
float yWeight = 0.0, iWeight = 0.0, qWeight = 0.0;
for (int i = -FILTER_RADIUS; i <= FILTER_RADIUS; i++) {
float offset = float(i);
vec2 sampleUV = uv + vec2(offset * texelSize.x, 0.0);
vec3 srcRGB = sampleTexture(sampleUV);
float samplePhase = basePhase + offset * TAU * CC_PER_PIXEL;
float composite = encodeComposite(srcRGB, samplePhase);
// Low-pass for luma
float yw = gaussianWeight(offset, compFilter);
yAccum += composite * yw;
yWeight += yw;
// Demodulate and filter chroma
float iw = gaussianWeight(offset, iFilter);
float qw = gaussianWeight(offset, qFilter);
iAccum += composite * cos(samplePhase) * 2.0 * iw;
qAccum += composite * sin(samplePhase) * 2.0 * qw;
iWeight += iw;
qWeight += qw;
}
vec3 yiq = vec3(yAccum / yWeight, iAccum / iWeight, qAccum / qWeight);
return YIQ_TO_RGB * yiq;
}
// === S-VIDEO SIGNAL DECODE ===
vec3 decodeSVideo(vec2 uv, vec2 texelSize, float basePhase) {
float yFilter = getLumaFilter();
float iFilter = getChromaIFilter();
float qFilter = getChromaQFilter();
float yAccum = 0.0, iAccum = 0.0, qAccum = 0.0;
float yWeight = 0.0, iWeight = 0.0, qWeight = 0.0;
for (int i = -FILTER_RADIUS; i <= FILTER_RADIUS; i++) {
float offset = float(i);
vec2 sampleUV = uv + vec2(offset * texelSize.x, 0.0);
vec3 srcRGB = sampleTexture(sampleUV);
vec3 yiq = RGB_TO_YIQ * srcRGB;
float samplePhase = basePhase + offset * TAU * CC_PER_PIXEL;
float chromaSignal = yiq.y * cos(samplePhase) + yiq.z * sin(samplePhase);
// Luma is separate - no cross-color
float yw = gaussianWeight(offset, yFilter);
yAccum += yiq.x * yw;
yWeight += yw;
// Chroma demodulation
float iw = gaussianWeight(offset, iFilter);
float qw = gaussianWeight(offset, qFilter);
iAccum += chromaSignal * cos(samplePhase) * 2.0 * iw;
qAccum += chromaSignal * sin(samplePhase) * 2.0 * qw;
iWeight += iw;
qWeight += qw;
}
vec3 yiqOut = vec3(yAccum / yWeight, iAccum / iWeight, qAccum / qWeight);
return YIQ_TO_RGB * yiqOut;
}
// === TRINITRON PHOSPHOR MASK ===
vec3 trinitronMask(vec2 screenPos) {
float strength = getPhosphorStrength();
float outputX = screenPos.x * 2.0; // 2x display scale
float stripe = mod(outputX, 3.0);
float bleed = 0.15;
vec3 mask;
if (stripe < 1.0) {
mask = vec3(1.0, bleed, bleed);
} else if (stripe < 2.0) {
mask = vec3(bleed, 1.0, bleed);
} else {
mask = vec3(bleed, bleed, 1.0);
}
float compensation = 1.0 / (0.333 + 0.667 * bleed);
mask *= compensation * 0.85;
return mix(vec3(1.0), mask, strength);
}
// === SCANLINE MASK ===
float scanlineMask(vec2 screenPos) {
float strength = getScanlineStrength();
float outputY = screenPos.y * 2.0; // 2x display scale
float scanline = sin(outputY * PI);
scanline = scanline * 0.5 + 0.5;
scanline = pow(scanline, 0.4);
return mix(1.0 - strength, 1.0, scanline);
}
// === MAIN ===
void main() {
vec2 uv = v_texCoords;
uv.x = mix(uv.x, 1.0 - uv.x, flip.x);
uv.y = mix(uv.y, 1.0 - uv.y, flip.y);
vec2 texelSize = 1.0 / resolution;
float pixelX = uv.x * resolution.x;
float pixelY = uv.y * resolution.y;
// Frame phase for dot crawl (4-frame cycle)
float framePhase = mod(time, 4.0) * PI * 0.5;
float basePhase = calcCarrierPhase(pixelX, pixelY, framePhase);
// Decode signal based on mode
vec3 rgb;
if (signalMode == 1) {
rgb = decodeComposite(uv, texelSize, basePhase);
} else {
rgb = decodeSVideo(uv, texelSize, basePhase);
}
// CRT display effects
vec2 screenPos = vec2(pixelX, pixelY);
rgb *= trinitronMask(screenPos);
rgb *= scanlineMask(screenPos);
fragColor = vec4(clamp(rgb, 0.0, 1.0), 1.0);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment