UI / FRAMES / COOL-RETRO-TERM
Inside cool-retro-term
cool-retro-term is a terminal emulator by Filippo Scognamiglio that looks like an old cathode-ray-tube monitor. This page runs a web port of it. It uses the app's own shaders, settings maths, built-in profiles and fonts, translated from Qt to WebGL2, so you can see exactly how the effect is built.
The CRT components in the UI gallery's Frames section are EXEPERT's own look-alikes. This is the real renderer, credited to its authors and released under the GPL like the original. The source is linked at the end.
Try it

The terminal runs the real cool-retro-term shaders in WebGL2. Powering on loads its fonts and renderer (about 2 MB); your settings stay in this browser.
Type help once it's on. Right-click for the menu, or open Settings from the title bar.
How a frame is made
Values for Default Amber in a 1024×768 window. Power on the terminal and they follow it instead.
The text is drawn small, then magnified
cool-retro-term does not draw text at screen resolution. It works out a smaller virtual screen and lets the terminal widget (qmltermwidget, a fork of KDE's Konsole) paint into that. Low-resolution fonts are drawn at their native pixel size with antialiasing off, so every glyph pixel stays a hard square.
Terminess is a pixel font, drawn at its native 12 px. The font manager compares how tall a line should be on screen at the current font scaling with the font's own line height. The ratio, 2, is how many screen pixels one virtual pixel becomes. The virtual screen is the window divided by that ratio.
- Virtual resolution
- 512×384
- Screen scaling
- 2×
- Margin
- 8 virtual px
- Grid
- 82 columns × 26 lines
- Cell
- 6×14
- Text layer
- 1024×768
property int textureResolutionScale: appSettings.lowResolutionFont ? Screen.devicePixelRatio : 1
property int margin: appSettings.margin / screenScaling
property int totalWidth: Math.floor(parent.width / (screenScaling * fontWidth))
property int totalHeight: Math.floor(parent.height / screenScaling)
property int rawWidth: totalWidth - 2 * margin
property int rawHeight: totalHeight - 2 * margin
textureSize: Qt.size(width / textureResolutionScale, height / textureResolutionScale)
width: ensureMultiple(rawWidth, Screen.devicePixelRatio)
height: ensureMultiple(rawHeight, Screen.devicePixelRatio)
A ShaderEffectSource then captures that item, margins included, into the text layer. Its texture is 2× the virtual size, and it is set to repeat at the edges, which the curvature step relies on.
ShaderEffectSource{
id: kterminalSource
sourceItem: kterminal
hideSource: true
wrapMode: ShaderEffectSource.Repeat
visible: false
textureSize: Qt.size(kterminal.totalWidth * scaleTexture, kterminal.totalHeight * scaleTexture)
sourceRect: Qt.rect(-kterminal.margin, -kterminal.margin, kterminal.totalWidth, kterminal.totalHeight)
}
Burn-in: the phosphor remembers
A real CRT's phosphor keeps glowing after the beam moves on. cool-retro-term fakes this with a texture that renders into itself. Whenever the terminal repaints, the burn-in shader keeps the brighter of the remembered image (darkened by how long ago it was last stamped) and the new text. The alpha channel marks where the new text won.
void main() {
vec2 coords = qt_TexCoord0;
vec3 txtColor = texture(txt_source, coords).rgb;
vec4 accColor = texture(burnInSource, coords);
float prevMask = accColor.a;
float blurDecay = clamp((burnInLastUpdate - prevLastUpdate) * burnInTime, 0.0, 1.0);
blurDecay = max(0.0, blurDecay - prevMask);
vec3 color = max(accColor.rgb - vec3(blurDecay), txtColor);
float currMask = step(rgb2grey(color), rgb2grey(txtColor));
fragColor = vec4(color, currMask) * qt_Opacity;
}
The layer only updates when the terminal emits imagePainted, and that signal reaches QML one frame after the paint. Each update records the time it happened, and the decay is the time between the last two updates multiplied by burnInTime.
function completelyUpdate() {
let newTime = timeManager.time
if (newTime > lastUpdate) {
prevLastUpdate = lastUpdate
lastUpdate = newTime
}
item.source.scheduleUpdate()
}
function restartBlurSource() {
prevLastUpdate = timeManager.time
lastUpdate = prevLastUpdate
completelyUpdate()
}
In the final pass the remembered image is drawn at 65% and fades as time passes since the last update. With burn-in at 0.3 it takes 0.592 s to disappear.
- burnInTime
- 1.689189
- Fade time
- 0.592 s
- Burn-in layer
- 512×384
Bloom: Qt's FastBlur, six levels deep
The glow around bright text comes from Qt's FastBlur. It never runs a wide Gaussian. Instead it copies the text into a padded texture, then halves it five times. Each halving averages four taps placed slightly off the pixel grid. A final pass blends up to five of the six levels.
The weights come from a single number, lod = √(radius ÷ 64) × 1.2 − 0.2. Each level sits at a centre (0.1, 0.3 … 1.1), and a level contributes in proportion to how close its centre is to lod. Here the radius is 40, set by bloom quality.
Loader {
id: bloomEffectLoader
active: loadBloomEffect
asynchronous: true
width: parent.width * appSettings.bloomQuality
height: parent.height * appSettings.bloomQuality
sourceComponent: FastBlur {
radius: Utils.lint(16, 64, appSettings.bloomQuality)
source: terminal.mainSource
transparentBorder: true
}
}
- Radius
- 40
- lod
- 0.7487
- Weights
- 0 · 0.041 · 0.201 · 0.361 · 0.279 · 0.119
- Levels
- 640×512 → 320×256 → 160×128 → 80×64 → 40×32 → 20×16
The static pass: curvature, colour fringes, reflections
Everything that does not change from frame to frame happens here, so it only runs again when the text or a setting changes. Barrel curvature pushes each coordinate outward by the square of its distance from the centre. Where the curve lands outside the screen, the shader mirrors the coordinate back in. That mirrored image becomes the faint reflection of the text on the bezel.
vec2 txt_coords = qt_TexCoord0;
#if CRT_CURVATURE == 1
vec2 curvatureCoords = distortCoordinates(qt_TexCoord0);
shownDraw = max2(step(vec2(0.0), curvatureCoords) - step(vec2(1.0), curvatureCoords));
isScreen = min2(step(vec2(0.0), curvatureCoords) - step(vec2(1.0), curvatureCoords));
isReflection = shownDraw - isScreen;
txt_coords = curvatureCoords * (-1.0 + 2.0 * step(vec2(0.0), curvatureCoords) - 2.0 * step(vec2(1.0), curvatureCoords));
#endif
RGB shift samples the text twice more, a little to the left and right, and mixes the colour channels unevenly. Bloom is added (capped at 0.5 per channel), and the result is divided by 1 + bloom. The dynamic pass multiplies it back up later, so overall brightness stays the same while the glow spreads.
#if CRT_BLOOM == 1
vec3 bloomOnScreen = bloomColor * isScreen;
finalColor += clamp(bloomOnScreen * bloom * bloomAlpha, 0.0, 0.5);
float bloomScale = 1.0 + max(bloom, 0.0);
finalColor /= bloomScale;
#endif
#if CRT_FRAME_SHININESS == 1
vec3 reflectionColor = mix(bloomColor * bloomAlpha * 2.0, finalColor, frameShininess * 0.5);
finalColor = mix(finalColor, reflectionColor, isReflection);
#endif
finalColor *= screen_brightness;
float noise = rand2(qt_TexCoord0) - 0.5;
finalColor = clamp(finalColor + vec3(noise * 0.025), 0.0, 1.0);
- screenCurvature
- 0.137143
- frameSize
- 0.005714
- rgbShift
- 0
- bloom
- 1.5
- screen_brightness
- 1
- Variant
terminal_static_rgb0_bloom1_curve1_shine1
The frame: a rounded rectangle and a little light
The bezel has no image file. A signed-distance function measures how far each pixel is from a rounded rectangle, in pixels. Four diagonal masks split the bezel into top, bottom, left and right, and each gets its own brightness. The bottom edge, which faces up toward the room's light, is brightest; the sides are at two thirds and the top at one third. Inside the screen, ambient light adds a soft glare that is brightest in the middle.
float distPixels = roundedRectSdfPixels(coords, vec2(0.0), vec2(1.0), screenRadiusPixels);
float frameShadow = (e * 0.66 + w * 0.66 + n * 0.33 + s);
frameShadow *= smoothstep(0.0, edgeSoftPixels * 5.0, distPixels);
float frameAlpha = 1.0 - frameShininess * 0.4;
float inScreen = smoothstep(0.0, edgeSoftPixels, -distPixels);
float alpha = mix(frameAlpha, mix(0.0, 0.3, ambientLight), inScreen);
float glass = clamp(ambientLight * pow(prod2(coords * (1.0 - coords.yx)) * 25.0, 0.5) * inScreen, 0.0, 1.0);
vec3 frameTint = frameColor.rgb * frameShadow;
float noise = rand2(staticCoords * viewportSize) - 0.5;
frameTint = clamp(frameTint + vec3(noise * 0.04), 0.0, 1.0);
vec3 color = mix(frameTint, vec3(glass), inScreen);
fragColor = vec4(color, alpha) * qt_Opacity;
- screenRadius
- 15.6 px
- ambientLight
- 0.3
- frameShininess
- 0.15
- frameColor
- (0.4166, 0.3727, 0.3277, 1)
The dynamic pass: everything that moves
This pass draws the window. Time advances only every 3 frames, which is 20 updates a second at 60 Hz, so the noise has a filmic, stepped feel. The vertex shader reads one noise texel per update. Its green channel sets the flicker brightness and the frequency of the horizontal-sync wobble. Its red channel decides whether a sync glitch fires at all.
vec2 coords = vec2(fract(time / 2.048), fract(time / 1048.576));
vec4 initialNoiseTexel = texture(noiseSource, coords);
vBrightness = 1.0 + (initialNoiseTexel.g - 0.5) * flickering;
float randval = horizontalSyncStrength - initialNoiseTexel.r;
vDistortionScale = step(0.0, randval) * randval * horizontalSyncStrength * horizontalSync;
vDistortionFreq = mix(4.0, 40.0, initialNoiseTexel.g) * step(0.0, horizontalSync);
The fragment shader bends each row sideways for horizontal sync and samples the noise texture, scrolled by time, for static and jitter. It adds the slow bright band of the "glowing line", then layers the burn-in on top. The noise texture is uploaded premultiplied, as Qt uploads every image, so its red channel is already scaled by its alpha. That matters, because red is the value the sync check compares.
vec2 staticCoords = distortCoordinates(qt_TexCoord0);
vec2 coords = qt_TexCoord0;
float dst = sin((coords.y + time) * vDistortionFreq);
coords.x += dst * vDistortionScale;
vec4 noiseTexel = texture(noiseSource, scaleNoiseSize * coords + vec2(fract(time / 0.051), fract(time / 0.237)));
vec2 txt_coords = coords + (noiseTexel.ba - vec2(0.5)) * jitterDisplacement * jitter;
float color = 0.0001;
color += noiseTexel.a * staticNoise * (1.0 - distance * 1.3);
color += randomPass(coords * virtualResolution) * glowingLine;
#if CRT_DISPLAY_FRAME == 1
vec4 frameColor = texture(frameSource, qt_TexCoord0);
color *= (1.0 - frameColor.a);
#endif
vec3 txt_color = texture(screenBuffer, txt_coords).rgb;
float bloomScale = 1.0 + max(bloom, 0.0);
txt_color *= bloomScale;
#if CRT_BURN_IN == 1
vec4 txt_blur = texture(burnInSource, staticCoords);
float blurDecay = clamp((time - burnInLastUpdate) * burnInTime, 0.0, 1.0);
vec3 burnInColor = 0.65 * (txt_blur.rgb - vec3(blurDecay)) * (1.0 - txt_blur.a);
txt_color = max(txt_color, burnInColor);
#endif
Scanline, pixel and sub-pixel rasterization darken the gaps between virtual pixels. They fade in as each virtual pixel grows from 2 to 4 physical pixels, so dense screens don't show moiré. Here the density is 2, so the rasterization intensity is 0.
#if CRT_RASTER_MODE == 1
vec3 pixelHigh = ((1.0 + BRIGHTBOOST) - (0.2 * texel)) * texel;
vec3 pixelLow = ((1.0 - INTENSITY) + (0.1 * texel)) * texel;
vec2 coords = fract(screenCoords * virtualRes) * 2.0 - vec2(1.0);
float mask = 1.0 - abs(coords.y);
vec3 rasterizationColor = mix(pixelLow, pixelHigh, mask);
return mix(texel, rasterizationColor, intensity);
- horizontalSyncStrength
- 0.08
- flickering
- 0.1
- staticNoise
- 0.1
- jitterDisplacement
- (0.0014, 0.0004)
- glowingLine
- 0.04
- Variant
terminal_dynamic_raster0_burn1_frame1_chroma1
property int framesPerUpdate: Math.max(1, appSettings.effectsFrameSkip)
property int _frameCounter: 0
property var frameDriver: FrameAnimation {
running: enableTimer
onTriggered: {
timeManager._frameCounter += 1
if (timeManager._frameCounter >= timeManager.framesPerUpdate) {
time = elapsedTime
timeManager._frameCounter = 0
}
}
}
Colour: from two hex codes to a phosphor
The terminal paints in plain colours. The tint comes at the very end, from just two inputs: a font colour and a background colour. Saturation first pushes the font colour toward white, and the result is stored as a #rrggbb string. Contrast then mixes the two colours toward each other. One small quirk: strToColor divides by 256, not 255, so even #ffffff comes out slightly below 1.0.
property string _backgroundColor: "#000000"
property string _fontColor: "#ff8100"
property string _frameColor: "#ffffff"
property string saturatedColor: Utils.mix(Utils.strToColor(_fontColor), Utils.strToColor("#FFFFFF"), (saturationColor * 0.5))
property color fontColor: Utils.mix(Utils.strToColor(_backgroundColor), Utils.strToColor(saturatedColor), (0.7 + (contrast * 0.3)))
property color backgroundColor: Utils.mix(Utils.strToColor(saturatedColor), Utils.strToColor(_backgroundColor), (0.7 + (contrast * 0.3)))
property color frameColor: Utils.strToColor(_frameColor)
function strToColor(s){
var r = parseInt(s.substring(1,3), 16) / 256;
var g = parseInt(s.substring(3,5), 16) / 256;
var b = parseInt(s.substring(5,7), 16) / 256;
return Qt.rgba(r, g, b, 1.0);
}
In the shader each pixel's brightness picks a point between the background and font colours. Chroma then lets some of the terminal's own colour through, which is why ls colours survive on the colour profiles.
vec3 convertWithChroma(vec3 inColor) {
#if CRT_CHROMA == 1
float grey = rgb2grey(inColor);
float denom = max(grey, 0.0001);
vec3 foregroundColor = mix(fontColor.rgb, inColor * fontColor.rgb / denom, chromaColor);
return mix(backgroundColor.rgb, foregroundColor, grey);
#else
return mix(backgroundColor.rgb, fontColor.rgb, rgb2grey(inColor));
#endif
}
- Font colour
#ff8100- Background
#000000- Saturation, contrast
- 0.2, 0.8
- saturatedColor
#fe8d19- fontColor
#ee8417- backgroundColor
#0f0801
Every setting, as the shaders receive it
Each row is a QML property binding from upstream, evaluated for this frame. Colours arrive premultiplied, and every value is rounded to 32-bit float on the way to the GPU.
| Name | Value | QML binding | Source |
|---|---|---|---|
normalizedWindowScale | 1.142857 | 1024 / ((0.5 * width + 0.5 * height)) | TerminalWindow.qml:47 |
screenResolution | (1024, 768) | Qt.size(terminalWindow.width * Screen.devicePixelRatio * appSettings.windowScaling, terminalWindow.height * Screen.devicePixelRatio * appSettings.windowScaling) | TerminalContainer.qml:39 |
_screenDensity | 2 | Math.min(screenResolution.width / virtualResolution.width, screenResolution.height / virtualResolution.height) | ShaderTerminal.qml:67 |
burnInFadeTime | 1.689189 | 1 / Utils.lint(_minBurnInFadeTime, _maxBurnInFadeTime, burnIn) | BurnInEffect.qml:34 |
loadBloomEffect | 1 | appSettings.bloom > 0 || appSettings._frameShininess > 0 | TerminalContainer.qml:31 |
| Name | Value | QML binding | Source |
|---|---|---|---|
qt_Opacity | 1 | appSettings.windowOpacity * 0.3 + 0.7 | TerminalContainer.qml:34 |
time | 0 | timeManager ? timeManager.time : 0 | ShaderTerminal.qml:107 |
fontColor | (0.932662, 0.517723, 0.091798, 1) | parent.fontColor (appSettings.fontColor) | ShaderTerminal.qml:79 |
backgroundColor | (0.059525, 0.033051, 0.005859, 1) | parent.backgroundColor (appSettings.backgroundColor) | ShaderTerminal.qml:80 |
virtualResolution | (512, 384) | Qt.size(kterminal.totalWidth, kterminal.totalHeight) | ShaderTerminal.qml:101 |
rasterizationIntensity | 0 | Utils.smoothstep(2.0, 4.0, _screenDensity) | ShaderTerminal.qml:105 |
burnInLastUpdate | 0 | burnInEffect.lastUpdate | ShaderTerminal.qml:92 |
burnInTime | 1.689189 | burnInEffect.burnInFadeTime | ShaderTerminal.qml:93 |
burnIn | 0.3 | appSettings.burnIn | ShaderTerminal.qml:91 |
staticNoise | 0.1 | appSettings.staticNoise | ShaderTerminal.qml:97 |
screenCurvature | 0.137143 | appSettings.screenCurvature * appSettings.screenCurvatureSize * terminalWindow.normalizedWindowScale | ShaderTerminal.qml:57 |
glowingLine | 0.04 | appSettings.glowingLine * 0.2 | ShaderTerminal.qml:88 |
chromaColor | 0.2 | appSettings.chromaColor | ShaderTerminal.qml:82 |
jitterDisplacement | (0.0014, 0.0004) | Qt.size(0.007 * jitter, 0.002 * jitter) | ShaderTerminal.qml:96 |
jitter | 0.2 | appSettings.jitter | ShaderTerminal.qml:95 |
horizontalSync | 0.1 | appSettings.horizontalSync | ShaderTerminal.qml:86 |
horizontalSyncStrength | 0.08 | Utils.lint(0.05, 0.35, horizontalSync) | ShaderTerminal.qml:87 |
flickering | 0.1 | appSettings.flickering | ShaderTerminal.qml:85 |
scaleNoiseSize | (2, 1.5) | Qt.size((width * 0.75) / (noiseTexture.width * appSettings.windowScaling * appSettings.totalFontScaling), (height * 0.75) / (noiseTexture.height * appSettings.windowScaling * appSettings.totalFontScaling)) | ShaderTerminal.qml:98 |
frameShininess | 0.15 | appSettings.frameShininess | ShaderTerminal.qml:111 |
frameSize | 0.005714 | appSettings.frameSize * terminalWindow.normalizedWindowScale | ShaderTerminal.qml:58 |
bloom | 1.5 | parent.bloomSource ? appSettings.bloom * 2.5 : 0 | ShaderTerminal.qml:112 |
| Name | Value | QML binding | Source |
|---|---|---|---|
qt_Opacity | 1 | 1 (rendered into frameBuffer, its own layer) | ShaderTerminal.qml:194 |
screenCurvature | 0.137143 | parent.screenCurvature | ShaderTerminal.qml:175 |
rgbShift | 0 | appSettings.rgbShift * (4.0 / width) * appSettings.totalFontScaling | ShaderTerminal.qml:179 |
frameShininess | 0.15 | appSettings.frameShininess | ShaderTerminal.qml:182 |
frameSize | 0.005714 | parent.frameSize | ShaderTerminal.qml:183 |
screen_brightness | 1 | Utils.lint(0.5, 1.5, appSettings.brightness) | ShaderTerminal.qml:181 |
bloom | 1.5 | bloomSource ? appSettings.bloom * 2.5 : 0 | ShaderTerminal.qml:173 |
| Name | Value | QML binding | Source |
|---|---|---|---|
qt_Opacity | 1 | 1 (rendered into its own layer) | ShaderTerminal.qml:147 |
screenCurvature | 0.137143 | appSettings.screenCurvature * appSettings.screenCurvatureSize * terminalWindow.normalizedWindowScale | TerminalFrame.qml:36 |
frameColor | (0.416556, 0.372717, 0.327703, 1) | Utils.mix(Utils.scaleColor(_lightColor, 0.2), _staticFrameColor, 0.125 + 0.750 * ambientLight) | TerminalFrame.qml:30 |
frameSize | 0.005714 | appSettings.frameSize * terminalWindow.normalizedWindowScale | TerminalFrame.qml:40 |
screenRadius | 15.6 | appSettings.screenRadius | TerminalFrame.qml:42 |
viewportSize | (1024, 768) | Qt.size(width / appSettings.windowScaling, height / appSettings.windowScaling) | TerminalFrame.qml:44 |
ambientLight | 0.3 | appSettings.ambientLight | TerminalFrame.qml:46 |
frameShininess | 0.15 | appSettings.frameShininess | TerminalFrame.qml:38 |
| Name | Value | QML binding | Source |
|---|---|---|---|
qt_Opacity | 1 | 1 (rendered into its own layer) | BurnInEffect.qml:61 |
burnInLastUpdate | 0 | burnInEffect.lastUpdate | BurnInEffect.qml:118 |
burnInTime | 1.689189 | burnInFadeTime | BurnInEffect.qml:117 |
prevLastUpdate | 0 | burnInEffect.prevLastUpdate | BurnInEffect.qml:119 |
Pass inspector
Power on the terminal to look inside it. The inspector shows every texture above as the GPU holds it, lets you stop and step the effects clock, and compiles all 56 shader variants.
How we know it matches
cool-retro-term ships its shaders precompiled as Qt .qsb files, and each one also holds a GLSL ES 1.00 version that Qt generated from the same source. Our test suite renders every variant the app can select both ways: 40 dynamic-pass and 16 static-pass variants, plus the frame and burn-in shaders, at three moments in time. Both versions get identical textures and settings, and every pixel comes out identical.
The settings maths is checked against Qt's own source, down to the details that change pixels:
- colours are stored at 16 bits per channel;
- colour uniforms are premultiplied;
- the noise texture is premultiplied with Qt's rounding;
- layer sizes are rounded the way Qt rounds them.
Font metrics come from Qt 6.10 itself.
Where the web port differs
- The shell is simulated. A web page can't start processes, so the terminal runs a small built-in shell with commands chosen to show the effects (
top,matrix,testcard). Terminal emulation underneath is@xterm/headlessinstead of Konsole's engine. Text drawing follows qmltermwidget. - Fonts are drawn by the browser.
- Pixel fonts are drawn eight times larger and reduced to 1-bit masks, because a canvas can't turn off antialiasing.
- Terminess and Gohu use the bitmap glyphs embedded in their font files, as FreeType does.
- Fixedsys Excelsior and Cozette are left out because they ship without a licence.
- Some shortcuts belong to the browser. Ctrl+Shift+T, W, N and Q open and close browser tabs and windows, so here New Tab, Close Tab, New Window and Quit are Alt+Shift+T, W, N and Q. The menus keep the original labels.
- One window. New Window opens the page again. Copy takes the visible screen, because text selection isn't ported yet.
- Settings are saved as you change them, in this browser, rather than when the app quits. The
?profile=Nameand?default-settingsURL parameters stand in for the command-line flags.
Credits and licence
cool-retro-term is © 2013–2025 Filippo Scognamiglio and 65 other contributors, and is free software under the GNU General Public License, version 3 or later. Its terminal widget, qmltermwidget, builds on KDE's Konsole (Lars Doelle, Robert Knight, Dmitry Zagnoyko) and the qtermwidget project (134 contributors). Bloom uses Qt's FastBlur, © The Qt Company, under the LGPL-3.0 / GPL options Qt offers.
The web port is by EXEPERT. It is released under the same licence, GPL-3.0-or-later, as a modified version of cool-retro-term at 1394ce8. Every file names its origin and what was changed. The terminal's About dialog lists everyone who contributed upstream.
The complete source of this port is a download from this site. It is rebuilt with every deploy, so it always matches what runs above. Inside it, NOTICE.md lists the upstream works, what was changed and where each file came from, and fonts/LICENSES.md covers every font.
- Source of this port (.tgz), built from
3979366 - GNU General Public License v3