Retro CRT Shader — A post processing effect study

Babylon.js
6 min readNov 11, 2020

--

Think back to those halcyon days when video games came on cartridges, controllers were square, and televisions were big, rounded, and weighed nearly as much as a fully loaded pickup truck. The days when computational speed was measured in megahertz, and when television was fuzzy pixels dancing across curved screens illuminated by cathode rays that may or may not have permanently damaged our eyes. Ahhh the memories…

https://atariage.com/forums/topic/271350-crt-tv-vs-modern-tv-for-retro-games/?do=findComment&comment=3876282

We can’t bring back the past, but through the magic of shaders and math, we can try to emulate some of the artifacts of this bygone era to give your games that warm, fuzzy feeling you know you’ve missed:

Don’t mind the banding, that’s just the aliasing of our scan lines when compressed. Though you sometimes see this effect when real photographs of CRTs are manipulated as well! — https://drigax.github.io/RooftopRampage/

Starting off, TV screens were originally rounded and convex, driven by the limits of display technology at the time. Put briefly, our modern LED televisions are a densly packed matrix of individually controllable lights that we can use to create a moving picture:

https://thumbs.dreamstime.com/b/closeup-led-lights-bulb-diode-led-tv-led-monitor-screen-display-panel-closeup-led-lights-bulb-diode-led-tv-led-111461955.jpg

The precursor to this type of display technology were cathode rays, sequenced beams of electrons that would react with a phosphor coating on the inside of the television glass to create an image:

image borrowed from https://computer.howstuffworks.com/monitor7.htm

Magnetic fields were used to bend these electron beams to position them at different locations on the screen, which was curved to minimize the distortion throughout the picture.

For some more info, I highly recommend checking out Technology Connection’s series of videos on YouTube for more detailed explanation of how CRT televisions work, and a supplement for an in-depth explanation of early color implementations.

Lets start with simulating the low hanging fruit and working our way up. We can simulate the screen curvature by doing a similar process via a pixel, or post processing shader. We can define a mathematical curve, and modify how we sample from our rendered scene texture to the resulting render texture as we apply our process.

With the help of hiulit’s port of knarkowicz’s retro styled shader, we can see that the curvature of a screen can be represented by distorting the UVs using a cubic function expressed as:

outUV = inUV(abs(inUV * 2 — 1) / curvature)² + inUV

Graphed, it may be a bit more intuitive:

1 dimensional view of remapped UVs after applying screen curvature filter.

Our “curvature” parameter can be used to control how extreme we want to deform our texture near the edges, to create the appearance that the image is projected on a rounded surface. We can represent a flatter display with a larger curvature factor. We also choose to add some bounds to this function to represent the “frame” around the television where the computed UV exceeds [0,1].

Implemented in GLSL, we get:

#ifdef GL_ES
precision highp float;
#endif
// Samplers
varying vec2 vUV;
uniform sampler2D textureSampler;
// Parameters
uniform vec2 curvature;

vec2 curveRemapUV(vec2 uv)
{
// as we near the edge of our screen apply greater distortion using a cubic function
uv = uv * 2.0–1.0;
vec2 offset = abs(uv.yx) / vec2(curvature.x, curvature.y);
uv = uv + uv * offset * offset;
uv = uv * 0.5 + 0.5;
return uv;
}
void main(void)
{
vec2 remappedUV = curveRemapUV(vec2(vUV.x, vUV.y));
vec4 baseColor = texture2D(textureSampler, remappedUV);
if (remappedUV.x < 0.0 || remappedUV.y < 0.0 || remappedUV.x > 1.0 || remappedUV.y > 1.0){
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
} else {
gl_FragColor = baseColor;
}
}

We can modify the default Babylon.js playground scene to allow us to add a post process to the end of the rendering pipeline without much hassle:

BABYLON.Effect.ShadersStore["crtFragmentShader"] = `{shader code}`var postProcess = new BABYLON.PostProcess(“CRTShaderPostProcess”, “crt”, [“curvature”], null, 1, camera);postProcess.onApply = function (effect) {    effect.setFloat2(“curvature”, 3.0, 3.0);
}

We can also add some downscaling to the post process to emulate the lower resolutions of these older displays by modifying the post process effect’s parameters:

var postProcess = new BABYLON.PostProcess(“CRTShaderPostProcess”, “crt”, [“curvature”], null, 0.25, camera);

Ok, our screen looks curved, but its a bit too “clean” to be believable. Our modern displays are too contiguous, the pitch, or space between pixels is smaller than our eye’s ability to discern them. This was not quite the case for older screens.

dem pixels. https://dirkdigduggler.files.wordpress.com/2013/04/img_04301.jpg

This is easily simulatable by adding some darkness periodically through the image to represent the pitch between the scan lines:

(0.5 * sin(UV.x * screenSize.x * PI * 2) + 0.5) * 0.9 + 0.1
1-dimensional view of scan line filter over pixel intensity for a 15 pixel wide display.

We use a single period of a sine wave as our periodic function, set to repeat for as many virtual “pixels” we want to represent. The sinusoid peaks now represent the pixel centers, while the troughs represent the centerpoint between two pixels. Our scalars were chosen in order to offset the sinusoid between the bounds of [0.1, 1.0] such that we never fully darken the sampled color, even between our scanlines.

On top of this we can also add another factor, `opacity` to allow for the user to control the intensity of these scan lines in how they darken the screen.

Implemented, we get:

```
vec4 scanLineIntensity(float uv, float resolution, float opacity)
{
float intensity = sin(uv * resolution * PI * 2.0);
intensity = ((0.5 * intensity) + 0.5) * 0.9 + 0.1;
return vec4(vec3(pow(intensity, opacity)), 1.0);
}



void main(void)
{


baseColor *= scanLineIntensity(remappedUV.x, screenResolution.y, scanLineOpacity.x);
baseColor *= scanLineIntensity(remappedUV.y, screenResolution.x, scanLineOpacity.y);

}

You can see the graniness in action, as we edge closer to our retro display:

Brightened for clarity. https://playground.babylonjs.com/frame.html#PPPXW0#12

There’s also a curious vignetting effect we want to add to simulate older, curvier monitors, masking out the sharp edges of our render to help the nostalgia factor a bit.

an oldie, but a goodie. http://www.retroaudiolab.com/tvphoto.htm

We can implement this using an exponent that decreases the pixel intensity the further from the center of the screen you go:

clamp((inUV * 1-inUV) * screenResolution/roundness)^vignetteOpacity, 0.0, 1.0)
Note the steep drop in pixel intensity as we reach the edges and corners.

Implemented, you can see it here:

Brightened for clarity — https://playground.babylonjs.com/frame.html#PPPXW0#13

Now with all these screen darkening effects we’re starting to lose picture quality, lets boost the brightness of the lit pixels overall to help fight these effects applied.

 void main(void) 
{


baseColor *= vec4(vec3(brightness), 1.0);


}

We can also adjust the simulated “resolution” to increase or decrease the aliasing of the scanlines, our final scene should look like:

NOT brightened for clarity :) — https://playground.babylonjs.com/frame.html#PPPXW0#14

Great! We have a pretty convincing CRT emulating shader we can use to really reinforce that “retro” feel in our experiences! Sometimes its fun to take a stroll down memory lane….

That’s the good stuff. — https://drigax.github.io/RooftopRampage/

If you have a cool experience that you want to show off that uses this shader, or added some enhancements to really amp up this shader’s abilities, feel free to post it to our forum! https://forum.babylonjs.com

Till the next time,
Nick Barlow — Babylon.js Team

https://twitter.com/whoisdrigax

--

--

Babylon.js

Babylon.js: Powerful, Beautiful, Simple, Open — Web-Based 3D At Its Best. https://www.babylonjs.com/