Last active
November 25, 2025 15:41
-
-
Save curioustorvald/856bfe83b307653245a4fa5bd98e1f33 to your computer and use it in GitHub Desktop.
Flat screen CRT shader with composite signal simulation for authenticity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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