Retro CRT Effects: Post-Processing On GPU
This article delves into the fascinating world of emulating retro Cathode Ray Tube (CRT) visual effects using modern GPU post-processing techniques. We'll explore how these effects can be implemented to add a touch of nostalgia to any visualization, mimicking the look and feel of classic displays. This includes implementing scanlines, RGB shadow masks, barrel distortion, vignettes, and color palette reduction, all crucial elements in recreating the retro CRT experience.
Goals of CRT Post-Processing
The primary goals of implementing CRT post-processing effects are to:
- Scanlines: Recreate the distinct horizontal lines that were characteristic of CRT screens. These lines, a result of the electron beam scanning across the phosphor coating, are a key visual element of retro displays.
- RGB Shadow Mask: Simulate the shadow mask (or aperture grille) used in CRT monitors to separate the red, green, and blue phosphors. This mask creates a unique sub-pixel structure that contributes to the CRT look.
- Barrel Distortion (Screen Curvature): Mimic the slight curvature of CRT screens, which can add a subtle but noticeable distortion effect.
- Vignette: Introduce a darkening effect towards the edges of the screen, a common characteristic of older displays.
- Color Palette Reduction: Limit the color palette to match the capabilities of vintage systems like the Atari 128-color palette, providing a more authentic retro aesthetic.
These effects, when combined, can significantly enhance the visual appeal of modern visualizations, providing a nostalgic connection to the past. The implementation leverages shader-based techniques, ensuring that the effects are applied efficiently as a post-processing stage within the GPU pipeline.
Implementation: Multi-Pass Pipeline
The implementation of CRT effects utilizes a multi-pass pipeline approach. This involves rendering the initial visualization (e.g., a Mandelbrot set or a plasma effect) to a framebuffer, and then applying the CRT effects in a subsequent pass. The pipeline can be visualized as follows:
Effect Shader (Mandelbrot, Plasma, etc.)
│
▼
Framebuffer A
│
▼
CRT Shader
│
▼
Framebuffer B
│
▼
Final Output
This multi-pass approach allows for a modular and flexible design, where different effects can be easily added or modified without affecting the core visualization rendering. Framebuffers A and B serve as intermediate rendering targets, allowing the output of one shader pass to be used as the input for the next. This is essential for applying multiple post-processing effects sequentially.
The first pass renders the base visualization to Framebuffer A. The CRT shader then processes the texture from Framebuffer A, applying the various CRT effects, and renders the result to Framebuffer B. Finally, the contents of Framebuffer B are displayed as the final output.
Shader Details: shaders/post/crt.frag
The core of the CRT effect lies in the crt.frag fragment shader. This shader takes the rendered visualization as input and applies several visual transformations to simulate CRT characteristics. The shader code is written in GLSL (OpenGL Shading Language) and is designed to run on the GPU for maximum performance. Let's break down the key components of this shader:
#version 330 core
uniform sampler2D iChannel0; // Input texture
uniform vec2 iResolution;
uniform float iTime;
// CRT parameters
uniform float scanlineIntensity; // 0.0 - 1.0
uniform float curvature; // 0.0 - 0.5
uniform float vignetteStrength; // 0.0 - 1.0
uniform float rgbOffset; // 0.0 - 0.01
in vec2 fragCoord;
out vec4 fragColor;
// Barrel distortion
vec2 distort(vec2 uv) {
uv = uv * 2.0 - 1.0;
float r2 = dot(uv, uv);
uv *= 1.0 + curvature * r2;
return uv * 0.5 + 0.5;
}
void main() {
vec2 uv = distort(fragCoord);
// Check bounds after distortion
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
fragColor = vec4(0.0);
return;
}
// RGB chromatic aberration
float r = texture(iChannel0, uv + vec2(rgbOffset, 0.0)).r;
float g = texture(iChannel0, uv).g;
float b = texture(iChannel0, uv - vec2(rgbOffset, 0.0)).b;
vec3 col = vec3(r, g, b);
// Scanlines
float scanline = sin(uv.y * iResolution.y * 3.14159) * 0.5 + 0.5;
scanline = pow(scanline, 1.5);
col *= mix(1.0, scanline, scanlineIntensity);
// RGB shadow mask (aperture grille)
float mask = mod(floor(uv.x * iResolution.x), 3.0);
vec3 maskColor = vec3(
mask == 0.0 ? 1.0 : 0.7,
mask == 1.0 ? 1.0 : 0.7,
mask == 2.0 ? 1.0 : 0.7
);
col *= mix(vec3(1.0), maskColor, 0.3);
// Vignette
vec2 vigUV = fragCoord * 2.0 - 1.0;
float vignette = 1.0 - dot(vigUV, vigUV) * vignetteStrength;
col *= vignette;
fragColor = vec4(col, 1.0);
}
The shader begins by defining several uniform variables. These are parameters that can be set from the application and allow for customization of the CRT effect. The key uniforms include:
iChannel0: The input texture, representing the rendered visualization.iResolution: The resolution of the input texture.iTime: A time value that can be used for animating effects.scanlineIntensity: Controls the visibility of scanlines.curvature: Determines the amount of barrel distortion.vignetteStrength: Adjusts the intensity of the vignette effect.rgbOffset: Controls the chromatic aberration effect.
Barrel Distortion
The distort function implements the barrel distortion effect. It takes the UV coordinates (normalized texture coordinates) as input and applies a radial distortion, making the image appear as if it were displayed on a curved screen. The curvature parameter controls the amount of distortion. The formula used here is a common approximation for barrel distortion, where the distortion increases with the square of the distance from the center of the screen.
RGB Chromatic Aberration
RGB chromatic aberration is simulated by sampling the input texture at slightly different UV coordinates for the red, green, and blue color channels. This creates a subtle color fringing effect, which is characteristic of older displays. The rgbOffset uniform controls the magnitude of this shift.
Scanlines
Scanlines are created using a sine wave function that varies based on the vertical UV coordinate. The result is then raised to a power to sharpen the scanlines. The scanlineIntensity uniform controls the strength of the scanline effect. The mix function blends the original color with the scanline effect, allowing for fine-tuning of the scanline visibility.
RGB Shadow Mask (Aperture Grille)
The RGB shadow mask is simulated by modulating the color based on the horizontal UV coordinate. The mod function is used to create a repeating pattern, mimicking the sub-pixel structure of CRT displays. The mask is implemented as a series of vertical stripes, where each stripe corresponds to a different color channel. This effect is most visible at higher resolutions, where the individual sub-pixels become more apparent.
Vignette
The vignette effect darkens the corners of the image, creating a sense of depth and focus. This is achieved by calculating the distance from the center of the screen and using this distance to modulate the color. The vignetteStrength uniform controls the intensity of the vignette effect.
Shader Details: shaders/post/palette.frag
Another critical aspect of recreating the retro aesthetic is color palette reduction. Older systems like the Atari had limited color palettes, which contributed to their distinctive visual style. The palette.frag shader implements color quantization, reducing the number of colors in the image to match a specified palette size.
#version 330 core
uniform sampler2D iChannel0;
uniform int paletteSize; // 16, 64, 128, 256
uniform bool dithering;
in vec2 fragCoord;
out vec4 fragColor;
// Bayer 4x4 dither matrix
const mat4 bayer = mat4(
0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0,
12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0,
3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0,
15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0
);
vec3 quantize(vec3 col, float levels) {
return floor(col * levels + 0.5) / levels;
}
void main() {
vec3 col = texture(iChannel0, fragCoord).rgb;
float levels = float(paletteSize) / 4.0; // Rough approximation
if (dithering) {
ivec2 pos = ivec2(mod(gl_FragCoord.xy, 4.0));
float threshold = bayer[pos.x][pos.y];
col += (threshold - 0.5) / levels;
}
col = quantize(col, levels);
fragColor = vec4(col, 1.0);
}
The shader takes the input texture (iChannel0), the desired palette size (paletteSize), and a dithering flag (dithering) as uniforms.
Color Quantization
Color quantization is the process of reducing the number of colors in an image. The quantize function performs this operation by scaling the color components, rounding them to the nearest integer, and then scaling them back. This effectively snaps the colors to the nearest color in the reduced palette.
Dithering
Dithering is a technique used to reduce the appearance of banding artifacts that can occur when reducing the color palette. The shader uses a 4x4 Bayer matrix to introduce a small amount of noise to the color values before quantization. This noise helps to smooth out the transitions between colors, making the reduced palette appear more continuous. The Bayer matrix is a pre-defined matrix that contains threshold values used to determine whether a pixel's color should be adjusted.
Pipeline Manager (core/gl/pipeline.py)
To manage the multi-pass rendering process, a PostProcessPipeline class is used. This class encapsulates the logic for loading shaders, creating framebuffers, and rendering the post-processing passes. The Python code snippet below shows the structure of this class:
class PostProcessPipeline:
def __init__(self, renderer: GLRenderer):
self.renderer = renderer
self.passes = []
self.fbos = []
def add_pass(self, shader_path: str, uniforms: dict = None):
program = self.renderer.load_shader(shader_path)
self.passes.append({'program': program, 'uniforms': uniforms or {}})
self.fbos.append(self.renderer.create_framebuffer())
def render(self, input_texture) -> bytes:
current_tex = input_texture
for i, pass_info in enumerate(self.passes):
self.fbos[i].use()
# Bind input texture
current_tex.use(location=0)
pass_info['program']['iChannel0'].value = 0
# Set uniforms and render
for name, value in pass_info['uniforms'].items():
if name in pass_info['program']:
pass_info['program'][name].value = value
self.renderer.quad_vao.render()
current_tex = self.fbos[i].color_attachments[0]
return self.fbos[-1].read()
The PostProcessPipeline class provides methods for adding post-processing passes (add_pass) and rendering the pipeline (render). The add_pass method loads a shader from a file, creates a framebuffer, and stores the information in the passes and fbos lists. The render method iterates over the passes, binding the input texture, setting uniforms, and rendering a full-screen quad. The output of each pass becomes the input for the next, allowing for a chain of post-processing effects.
CRT Presets
To simplify the configuration of CRT effects, a preset system can be implemented. Presets allow users to quickly select a set of predefined parameters, providing different CRT styles. Here are a few example presets:
| Preset | Scanlines | Curvature | Vignette | Description |
|---|---|---|---|---|
| Subtle | 0.2 | 0.05 | 0.2 | Gentle CRT effect |
| Classic | 0.5 | 0.1 | 0.4 | Standard CRT look |
| Heavy | 0.8 | 0.2 | 0.6 | Strong retro feel |
| Arcade | 0.6 | 0.15 | 0.3 | Arcade cabinet style |
These presets demonstrate how different combinations of parameters can produce a variety of visual effects. Users can switch between presets to quickly achieve the desired retro look.
Acceptance Criteria
To ensure the quality and effectiveness of the CRT post-processing implementation, several acceptance criteria should be met:
- Scanlines render at correct frequency: The scanlines should be clearly visible and evenly spaced.
- Barrel distortion curves screen edges: The curvature effect should be subtle but noticeable, adding to the CRT aesthetic.
- RGB shadow mask visible at high resolutions: The sub-pixel structure of the RGB mask should be visible at higher resolutions.
- Vignette darkens corners smoothly: The vignette effect should fade smoothly towards the edges of the screen.
- Color palette reduction works with dithering: The color quantization should produce visually appealing results, with dithering reducing banding artifacts.
- Effects can be toggled independently: Users should be able to enable or disable individual effects to customize the CRT look.
- Preset system for quick selection: The preset system should allow users to quickly switch between different CRT styles.
- No significant performance impact (< 5% FPS drop): The post-processing effects should not significantly impact performance, ensuring a smooth user experience.
Testing
Thorough testing is essential to ensure that the CRT effects are working correctly and that they meet the acceptance criteria. Here are a couple of test commands to verify the implementation:
# Test CRT on Mandelbrot
python -m atari_style.demos.visualizers.gl_mandelbrot --crt classic
# Test palette reduction
python -m atari_style.demos.visualizers.gl_plasma --palette 128 --dither
The first command tests the CRT effects on a Mandelbrot visualization, using the “classic” preset. The second command tests the color palette reduction on a plasma visualization, using a 128-color palette and dithering.
Conclusion
In conclusion, implementing retro CRT post-processing effects using GPU shaders is a powerful way to add a nostalgic touch to modern visualizations. By carefully simulating the characteristics of CRT displays, it is possible to create a visually appealing and authentic retro experience. The multi-pass pipeline approach, combined with customizable shader parameters and a preset system, provides a flexible and user-friendly solution for achieving various CRT styles.
The techniques discussed in this article, including scanlines, RGB shadow masks, barrel distortion, vignettes, and color palette reduction, are crucial for emulating the classic CRT look. Through thorough testing and adherence to acceptance criteria, developers can ensure that these effects are implemented effectively and efficiently.
For further reading and a deeper dive into graphics programming and shader development, check out The Book of Shaders, a fantastic resource for learning GLSL and shader techniques.