Simulating CRT Scanlines in GLSL Using Sine Waves
This article explains how to simulate horizontal cathode-ray tube (CRT) scanlines in GLSL fragment shaders using periodic trigonometric functions. By mapping vertical texture coordinates or fragment positions to continuous functions like sine or cosine, shaders can mathematically reproduce the alternating bright phosphor tracks and dark gaps characteristic of vintage CRT displays. The following sections break down the underlying mathematical principles, practical GLSL implementation techniques, and methods for tuning scanline intensity and sharpness.
The Principle of CRT Scanline Emulation
Cathode-ray tubes generate an image by steering an electron beam across the screen in horizontal lines. Because the beam has a specific vertical thickness, subtle dark gaps appear between adjacent sweeps, creating the characteristic horizontal scanline pattern.
In computer graphics, screen-space vertical coordinates increase monotonically from the top to the bottom (or bottom to top) of the viewport. Passing these linear vertical coordinates into a periodic trigonometric function generates a repeating, wave-like oscillation between dark and bright regions across the vertical axis.
Mathematical Mapping with Sine and Cosine
The standard sine function outputs values in the range \([-1.0, 1.0]\):
\[\sin(\theta) \in [-1.0, 1.0]\]
To use this output as a color brightness multiplier, the range must be normalized to \([0.0, 1.0]\) using a linear transformation:
\[f(y) = 0.5 \times \sin(y \cdot \omega) + 0.5\]
Where:
- \(y\) is the vertical coordinate
(
uv.yorgl_FragCoord.y). - \(\omega\) is the angular frequency determining the total number of scanlines across the display.
If targeting a specific virtual resolution (for example, 240 scanlines typical of retro video game consoles), \(\omega\) is calculated using the vertical resolution count:
\[\omega = \text{vertical\_resolution} \times 2\pi\]
Basic GLSL Implementation
A minimal implementation computes the sine wave directly from normalized texture coordinates:
uniform sampler2D u_texture;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 baseColor = texture2D(u_texture, uv);
// Number of lines matches the target vertical resolution
float scanlineCount = 240.0;
// Generate normalized periodic wave [0.0, 1.0]
float scanline = sin(uv.y * scanlineCount * 3.14159265 * 2.0) * 0.5 + 0.5;
// Apply scanlines to color
gl_FragColor = vec4(baseColor.rgb * scanline, baseColor.a);
}Controlling Width, Sharpness, and Intensity
A pure sinusoidal wave produces a soft, gradual transition between dark and light bands. Physical CRT scanlines feature wider illuminated regions and sharp, narrow gaps.
Adjusting Line Profile with Powers
Applying a power function alters the curvature of the sine wave. Raising the normalized wave to an exponent shifts the distribution:
// A value > 1.0 compresses the peaks and widens the dark valleys.
// A value < 1.0 broadens the bright regions and narrows the valleys.
scanline = pow(scanline, 0.5);Controlling Scanline Darkness
Multiplying the screen color directly by a zero-reaching wave causes scanline troughs to become pure black, which rarely occurs on real displays due to phosphor glow. An intensity factor linearly interpolates the effect:
float scanlineIntensity = 0.25; // 0.0 = no effect, 1.0 = full effect
float scanlineFactor = mix(1.0, scanline, scanlineIntensity);
vec3 finalColor = baseColor.rgb * scanlineFactor;Screen-Space Stability and Moiré Reduction
Evaluating scanlines using normalized UV coordinates can introduce uneven line widths or Moiré interference patterns if the output display resolution does not scale evenly with the source scanline count.
To achieve uniform scanlines on modern displays, calculations should
bind directly to window pixel coordinates using
gl_FragCoord.y:
// Locks wave peaks directly to physical screen pixels
float screenScanline = sin(gl_FragCoord.y * 3.14159265) * 0.5 + 0.5;When rendering virtual low-resolution assets onto high-resolution viewports, integer scaling or calculating scanlines in the texture's native coordinate space before upsampling ensures that the simulated beam gaps remain uniform and artifact-free.