-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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_crtlocalStorage key.
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.
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
}
}The CRT effect is a three-pass composite:
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.
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) |
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.
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.
| Effect | Parameter | Current Value | Range / Notes |
|---|---|---|---|
| Barrel distortion | amount |
1.02 |
1.0 (off) → 1.1+ (extreme) |
| Vignette center X | x |
0.5 |
0.0–1.0
|
| Vignette center Y | y |
0.5 |
0.0–1.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 |
- 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.
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