Skip to content

CRT Shader

BiosSystem edited this page May 22, 2026 · 1 revision

CRT Shader

Universal Retro Arcade features a toggleable CRT post-processing effect that emulates the look of classic cathode-ray tube monitors. The effect is hardware-accelerated via WebGL and is implemented using Phaser 4's built-in postFX (post-processing effects) camera pipeline.


Toggling the CRT Shader

Method Action
Keyboard Press Ctrl + Shift + C from the lobby screen
Persistent state The setting is saved in localStorage and restored on next launch

When enabled, the CRT badge will be reflected in the visual output immediately. Disabling it clears all post-processing from the camera pipeline.

Note: The CRT effect is applied to the lobby scene only at the camera level. Individual game scenes inherit this preference and can apply it independently by reading the arcade_crt localStorage key.


Implementation: Phaser 4 PostFX Pipeline

The shader is implemented using Phaser 4's Camera.postFX API — a built-in compositing layer that applies WebGL fragment shaders as full-screen passes after the scene is rendered.

Source Code (LobbyScene.ts)

private applyCrt() {
  // Clear any existing post-processing passes
  this.cameras.main.postFX.clear();

  // 1. Barrel distortion — bends the screen edges inward like a CRT tube
  const barrel = this.cameras.main.postFX.addBarrel(1.02);

  // 2. Vignette — darkens the corners with a radial gradient
  const vignette = this.cameras.main.postFX.addVignette(0.5, 0.5, 0.7);

  // 3. Color matrix — applies sepia tone + oversaturation for phosphor warmth
  const color = this.cameras.main.postFX.addColorMatrix();
  color.sepia();
  color.saturate(2);
}

private toggleCrt() {
  this.isCrtEnabled = !this.isCrtEnabled;
  localStorage.setItem('arcade_crt', this.isCrtEnabled ? 'true' : 'false');

  if (this.isCrtEnabled) {
    this.applyCrt();
  } else {
    this.cameras.main.postFX.clear(); // Remove all FX
  }
}

Shader Effect Breakdown

The CRT effect is a three-pass composite:

Pass 1: Barrel Distortion

Phaser FX: postFX.addBarrel(amount)

Barrel distortion warps the image outward from the center, simulating the curved glass of a CRT screen. The geometry is computed in the vertex shader:

// Conceptual GLSL equivalent of Phaser's barrel FX
vec2 uv = vUv - 0.5;
float r2 = dot(uv, uv);
uv *= 1.0 + amount * r2;
uv += 0.5;
gl_FragColor = texture2D(uTexture, uv);
Parameter Value Effect
amount 1.02 Subtle outward bulge (higher = more distortion)

A value of 1.0 = no distortion; 1.1+ becomes visibly extreme. The project uses 1.02 for a subtle, realistic curve.


Pass 2: Vignette

Phaser FX: postFX.addVignette(x, y, radius)

The vignette applies a radial darkening mask centered on the screen. It simulates the phosphor coating falloff at CRT bezel edges:

// Conceptual GLSL equivalent
float dist = distance(vUv, vec2(cx, cy));
float vignette = smoothstep(radius, radius * 0.5, dist);
gl_FragColor = color * vignette;
Parameter Value Effect
x 0.5 Center X (screen center)
y 0.5 Center Y (screen center)
radius 0.7 Vignette radius (0.0 = fully dark, 1.0 = no effect)

Pass 3: Color Matrix (Sepia + Saturation)

Phaser FX: postFX.addColorMatrix().sepia().saturate(2)

The color matrix post-process applies a 4×4 matrix multiplication to each pixel's RGBA values:

Sepia transformation shifts the color balance toward warm amber tones, as seen on aged CRT phosphors:

// Standard sepia matrix applied per-pixel
r' = (r * 0.393) + (g * 0.769) + (b * 0.189);
g' = (r * 0.349) + (g * 0.686) + (b * 0.168);
b' = (r * 0.272) + (g * 0.534) + (b * 0.131);

Saturation boost (2.0) then re-saturates the sepia result, giving neon green text a vivid phosphor-green glow rather than a desaturated tan.


Scanline Overlay

In addition to the postFX pipeline, the lobby renders a CSS-layer scanline effect as a Phaser Graphics object with a regular pattern of semi-transparent horizontal bars:

private buildScanlines() {
  this.scanlineGfx = this.add.graphics();
  this.scanlineGfx.setAlpha(0.07);   // Very subtle
  this.scanlineGfx.setDepth(100);    // Above game objects, below UI
  this.scanlineGfx.fillStyle(0x000000);

  // Draw 2px black bars every 4px (50% duty cycle scanlines)
  for (let y = 0; y < 480; y += 4) {
    this.scanlineGfx.fillRect(0, y, 640, 2);
  }
}

This scanline overlay is always present (independent of the CRT toggle) and simulates the horizontal line structure of a CRT phosphor grid at low alpha (0.07) so it is barely perceptible.


Shader Parameters Quick Reference

Effect Parameter Current Value Range / Notes
Barrel distortion amount 1.02 1.0 (off) → 1.1+ (extreme)
Vignette center X x 0.5 0.01.0
Vignette center Y y 0.5 0.01.0
Vignette radius radius 0.7 0.0 (full dark) → 1.0 (none)
Color saturation amount 2.0 0.0 (grayscale) → 3.0+ (vivid)
Scanline alpha 0.07 Fixed, always-on
Scanline spacing 4px 2px bar / 2px gap

Performance Notes

  • The postFX pipeline uses WebGL rendering; it has negligible CPU overhead and runs entirely on the GPU.
  • All three passes are composited in a single render call by Phaser 4's batch renderer.
  • On devices without WebGL support (rare; Tauri's WebView2/WebKit always has WebGL 2.0), Phaser falls back to Canvas renderer and postFX effects are silently disabled.
  • Mobile devices (Android APK) support the full shader stack — tested on Mali-G76 and Adreno 618 GPUs.

Modifying the Shader

To tweak shader parameters, edit the applyCrt() method in src/scenes/LobbyScene.ts:

private applyCrt() {
  this.cameras.main.postFX.clear();

  // Adjust barrel amount (try 1.05 for more curvature)
  this.cameras.main.postFX.addBarrel(1.02);

  // Adjust vignette radius (try 0.5 for a stronger shadow)
  this.cameras.main.postFX.addVignette(0.5, 0.5, 0.7);

  const color = this.cameras.main.postFX.addColorMatrix();
  color.sepia();
  color.saturate(2); // Try 3.0 for hyper-vivid phosphors
}

For custom GLSL shaders, you can extend Phaser 4's PostFXPipeline class and register it with the game's renderer — see Phaser 4 Custom Pipeline docs.


Back to Home

Clone this wiki locally