Repository navigation
Shaders
A theme can bring two kinds of shader of its own, small programs the GPU runs for every pixel it draws: a screen effect's, which draws the whole screen from a picture of it, and a background effect's, which draws behind the menus. This page covers how OSD/OS runs them, the rules every shader follows, what each kind is given, compiling with qsb, what happens when a shader can't be used, four complete worked examples, and the theme template's two shaders explained line by line. The selector effects and the transitions are OSD/OS's own: a theme names them, but can't replace them.
| A screen effect's shader | A background effect's shader | |
|---|---|---|
In theme.json
|
"screen": { "shader": "tube.frag.qsb", "animate": true } |
"background": { "shader": "plasma.frag.qsb", "area": "foot" } |
| What it draws | The whole screen: OSD/OS draws the screen into a picture and gives it to the shader as source; the shader says what colour each point gets |
Itself only, over the window's ground and under the menus; what it leaves clear shows the ground |
| How often it runs | For every screen pixel, each time the screen is drawn | For every art pixel of its area, each time the screen is drawn |
| What it is given |
source, resolution, px, time, the six screen numbers, the four text numbers, ink, paper
|
size, origin, time, ink, paper
|
| Its clock | Counts with "animate": true
|
Always counts while it shows |
| What it replaces | OSD/OS's screen shader, which also draws the text effects | OSD/OS's background effect |
Settings can still overrule either: Screen Effect or Background Effect set to a name puts OSD/OS's in place of the theme's, and Off turns it off (Themes).
flowchart LR
J["theme.json<br/>effects.screen.shader<br/>effects.background.shader"] --> A["AppCore reads the theme:<br/>each shader a .qsb in its folder"]
A --> S["Main.qml: the screen drawn into a picture,<br/>then through a ShaderEffect<br/>running the screen shader"]
A --> B["Every OsdGround (the window's, a dialog's):<br/>a BackgroundFx, a ShaderEffect<br/>at art-pixel size, scaled up"]
S --> G["Qt Quick takes the variant for<br/>its graphics API from the .qsb<br/>and the GPU runs it"]
B --> G
-
The screen shader is the
layer.effectof the item that holds everything OSD/OS draws (Main.qml): while a screen or text effect is in force, that item is drawn into a picture (its layer), and the picture is drawn to the screen through aShaderEffectrunning your shader. A video played inside the window is outside that item, and never passes through it. -
The background shader is drawn by BackgroundFx.qml: a
ShaderEffectone pixel per art pixel, scaled up without smoothing. Every ground laid on the screen has one: the window's, and each dialog's. -
The values come from properties of the
ShaderEffectwith the same names as the shader's uniforms: Qt Quick copies each property into the uniform of its name, every time it changes.
-
It is written in GLSL 4.40 for Qt Shader Tools: it starts with
#version 440.qsbtranslates it for each graphics API. -
It reads the point it runs for,
layout(location = 0) in vec2 qt_TexCoord0;, from (0, 0) at the top left of what it draws to (1, 1) at the bottom right, and writes its colour,layout(location = 0) out vec4 fragColor;. -
Its uniforms are one block at binding 0 that starts with
mat4 qt_Matrixandfloat qt_Opacity, in that order:layout(std140, binding = 0) uniform buf { mat4 qt_Matrix; float qt_Opacity; // then any of OSD/OS's names, in any order: only those it uses };
Qt's own vertex shader reads
qt_Matrixfrom the start of the block. With anything before it, the effect draws nothing at all, and nothing in the log says why. -
After those two come any of the names OSD/OS sets, in any order, each with its type (below). Leave out what you don't use. A name OSD/OS doesn't set stays 0, and Qt logs
ShaderEffect: 'speed' does not have a matching property. -
A screen shader reads the screen from
layout(binding = 1) uniform sampler2D source;. A background shader has no picture to read: don't declare one. -
Its colour is premultiplied: red, green and blue already multiplied by alpha,
vec4(color * alpha, alpha). Multiply the result byqt_Opacity, as every Qt Quick shader does. -
It is compiled into a
.qsb, for every graphics API, and the theme names the.qsb(below).
| QML property | GLSL type | The names |
|---|---|---|
size, point
|
vec2 |
resolution, size, origin
|
real |
float |
px, time and every number |
color |
vec4: red, green, blue, alpha, each 0 to 1 |
ink, paper
|
| the screen's picture | sampler2D |
source |
| Name | Type | What it is |
|---|---|---|
source |
sampler2D, binding 1 |
The screen as OSD/OS drew it: the menus on their ground with the background and selector effects, the boot screen, the screen saver, the pointer. Never a video |
resolution |
vec2 |
The screen, in its pixels: 640 × 480, 720 × 576, 1920 × 1080 |
px |
float |
Screen pixels per art pixel, a pixel of a 240-line picture: the screen's height ÷ 240, rounded down (2 at 480 and 576 lines, 3 at 720, 4 at 1080) |
time |
float |
Seconds. It counts while the theme's screen effect has "animate": true, and otherwise only while something else moves (a background effect, noise, a moving text effect). It starts over at 3600 |
scanlines, curvature, glow, bleed, noise, vignette
|
float |
The theme's screen numbers, 0 to 1, 0 for one left out |
rainbow, shimmer, flicker, inkGlow
|
float |
The text effect's numbers, Settings' or the theme's (inkGlow is the text's glow), 0 for none |
ink, paper
|
vec4 |
The colours in force: the scheme's colour (primary) and its background (surface). With OSD Background: Off, the lighter of the two and black |
-
The numbers are yours to use or not, and to give meanings of their own: the hold roll below reads
scanlines, and nothing else. - It runs whenever the theme's screen effect has a shader, even with every number 0, where there is a GPU and the effects aren't resting.
- It draws the whole screen: write an opaque colour, alpha 1.
-
Pixels and art pixels:
qt_TexCoord0 * resolutionis the point in screen pixels, and that/ pxin art pixels;floor()of it is the art pixel. Work in whole art pixels and the menus stay crisp. -
It replaces OSD/OS's screen shader, text effects and all. Settings → Text Effect's numbers still reach your shader, as
rainbow,shimmer,flickerandinkGlow, but they show only if your shader draws them (example 4).
| Name | Type | What it is |
|---|---|---|
size |
vec2 |
The area it draws, in art pixels |
origin |
vec2 |
Where that area's top left is on the screen, in art pixels |
time |
float |
Seconds, counting while it shows. It starts over at 3600 |
ink, paper
|
vec4 |
The colours in force, as for the screen |
-
One pixel per art pixel. The shader is drawn
sizepixels big, then scaled up bypxwithout smoothing:floor(qt_TexCoord0 * size)is the art pixel in the area, and addingoriginmakes it the art pixel on the screen. -
The area is the window inside its frame, or, with
"area": "foot", from the window's top down to the screen's foot; with OSD Background Full or Off, the whole screen (Effects). -
Every ground draws it again: the window's, and each dialog's (a question, the on-screen keyboard, an info screen). All of them are given the same
sizeandorigin, so a shader that works from the screen's art pixel,floor(qt_TexCoord0 * size) + origin, draws the same picture under a dialog as beside it, carrying on seamlessly. -
Clear where it draws nothing:
fragColor = vec4(0.0)leaves the ground showing. Keep what it draws faint (an alpha of 0.3 to 0.5), so the menus read over it. -
Its clock always runs while it shows;
"animate"is not needed.
After every change, compile the shader into the .qsb that theme.json names:
qsb --glsl "100 es,120,150" --hlsl 50 --msl 12 -o plasma.frag.qsb plasma.frag| Option | Makes the shader for |
|---|---|
--glsl "100 es,120,150" |
OpenGL ES (the Raspberry Pi) and desktop OpenGL (Linux, SteamOS) |
--msl 12 |
Metal (macOS) |
--hlsl 50 |
Direct3D, which OSD/OS doesn't use; harmless |
| always, without an option | SPIR-V, for Vulkan |
They are the same variants OSD/OS's own shaders are compiled with. A .qsb holds them all, so it can be made on any computer and copied to any other.
On an error, qsb prints the GLSL compiler's message with the line, exits with 1, and writes nothing; an earlier .qsb of that name stays as it was:
QSpirvCompiler: Failed to parse shader
Shader baking failed: ERROR: warp.frag:38: '' : syntax error, unexpected FOR, expecting COMMA or SEMICOLON
ERROR: 1 compilation errors. No code generated.
qsb comes with Qt Shader Tools.
| On | Get it | Run it as |
|---|---|---|
| Raspberry Pi OS, Debian, Ubuntu | sudo apt install qt6-shader-baker |
/usr/lib/qt6/bin/qsb |
| macOS, Qt from Qt's online installer | Add Qt Shader Tools (under Additional Libraries) | ~/Qt/<version>/macos/bin/qsb |
| macOS, Homebrew |
brew install qt, which includes Qt Shader Tools |
find it with find "$(brew --prefix qt)/" -name qsb
|
| SteamOS | — | Compile on another computer and copy the .qsb
|
A .qsb is written in its qsb's format. Qt reads files of its own format and older ones, but not newer: compile with the Qt OSD/OS runs on, or an older one. The template's shaders are compiled with qsb 6.4 (format 6), which every Qt OSD/OS runs on reads: the releases are built with Qt 6.4 (Raspberry Pi) and 6.7 (macOS, the AppImage), and on Raspberry Pi OS the app runs with the system's own Qt. Debian 12's and Ubuntu 24.04's qt6-shader-baker is qsb 6.4.
qsb -d shows what a file holds: its format, its variants, and its uniform block, with each name, type and offset, to check against the tables above:
$ /usr/lib/qt6/bin/qsb -d warp.frag.qsb
Stage: Fragment
QSB_VERSION: 6
Has 6 shaders: (unordered list)
Shader 0: GLSL 120 [Standard]
Shader 1: GLSL 150 [Standard]
Shader 2: SPIR-V 100 [Standard]
Shader 3: GLSL 100 es [Standard]
Shader 4: MSL 12 [Standard]
Shader 5: HLSL 50 [Standard]
…
| What is wrong | What the log says | What is drawn |
|---|---|---|
The file isn't there, isn't inside the theme's folder, or isn't a .qsb
|
[AppCore] theme my-theme: its screen shader, "tube.frag.qsb", is not a qsb file in its folder (or background shader) |
Screen: OSD/OS's shader, with the theme's numbers. Background: nothing |
| The file isn't a compiled shader, or comes from a newer Qt | Qt's ShaderEffect: Failed to deserialize QShader from …, then qml: [Effect] file:///…/tube.frag.qsb can't be used, OSD/OS's own in its place (a background's: … can't be used) |
Screen: OSD/OS's shader, with the theme's numbers. Background: nothing |
It was compiled without the variant this Qt draws with (no --glsl on the Pi, no --msl on a Mac) |
Qt's own complaint that the file has no code for its graphics API (with OpenGL, No GLSL shader code found …), then Failed to build graphics pipeline state; no [Effect] line |
Nothing. A screen shader takes the menus with it |
Its uniform block doesn't start with qt_Matrix and qt_Opacity
|
Nothing | Nothing. A screen shader takes the menus with it |
| It declares a name OSD/OS doesn't set | ShaderEffect: 'speed' does not have a matching property |
It works, that value 0 |
- A theme's screen shader that failed is tried again when the theme is chosen again, after another.
-
A screen shader that leaves the menus unreadable leaves Settings unreadable too: move the theme out of
themesover SSH and restart OSD/OS (Themes). -
A shader changed under the same file name stays as it was until OSD/OS restarts: a shader already loaded stays in memory. To try changes without restarting, compile each to a new file name (
warp2.frag.qsb), changetheme.jsonto match, and choose the theme again.
Where to read the log: Themes.
Each comes with what it takes in its theme's effects (in theme.json). Save the source in the theme's folder, compile it there with the command above, and choose the theme (again).
A screen effect: every twelve seconds, a band rolls down the picture in three seconds, the lines in it pulled sideways like a set losing its horizontal hold, a little brighter as they tear; and the theme's scanlines all the time.
{ "screen": { "shader": "holdroll.frag.qsb", "animate": true, "scanlines": 0.3 } }holdroll.frag:
#version 440
// A screen effect: every twelve seconds a band rolls down the picture, the
// lines in it pulled sideways like a set losing its horizontal hold, and the
// theme's scanlines all the time. It moves: its theme.json says "animate": true.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec2 resolution; // the screen, in its pixels
float px; // screen pixels to an art pixel
float time; // seconds, counting while "animate" is true
float scanlines; // the theme's "scanlines", 0 to 1
};
layout(binding = 1) uniform sampler2D source;
void main()
{
vec2 uv = qt_TexCoord0;
// Which line of art pixels this is: a whole line moves together.
float line = floor(uv.y * resolution.y / px);
// The band, an eighth of the screen either side of its middle, rolls from
// above the top to below the foot in the first 3 seconds of every 12.
float cycle = mod(time, 12.0);
float middle = -0.25 + 1.5 * cycle / 3.0;
float band = (1.0 - smoothstep(0.0, 0.125, abs(uv.y - middle))) * step(cycle, 3.0);
// Each line in the band pulled its own way, up to 6 art pixels, in whole
// art pixels so the letters stay crisp.
float pull = floor(sin(line * 0.9 + time * 20.0) * 6.0 * band + 0.5);
uv.x += pull * px / resolution.x;
// Off the picture's edge, black, as on a tube.
float inside = step(0.0, uv.x) * step(uv.x, 1.0);
vec3 color = texture(source, uv).rgb * inside;
// The torn band a little brighter.
color *= 1.0 + 0.15 * band;
// Scanlines: the foot of every line of art pixels darker.
color *= 1.0 - scanlines * 0.7 * step(0.5, fract(qt_TexCoord0.y * resolution.y / px));
fragColor = vec4(clamp(color, 0.0, 1.0), 1.0) * qt_Opacity;
}-
linenumbers the lines of art pixels, so that a whole line moves as one and the letters keep their shape. -
cycleruns from 0 to 12 seconds over and over; 12 divides 3600, so the roll keeps its beat when the clock starts over. In the first 3 seconds,middlegoes from a quarter of the screen's height above its top to a quarter below its foot, andbandis 1 at the band's middle, fading to 0 an eighth of the screen away. The rest of the time it is 0, and the picture is as it is. -
pullis a different sideways shift for each line, changing quickly withtime, rounded to whole art pixels (px / resolution.xis an art pixel across, in the picture's coordinates). -
insideblacks out what is pulled in from beyond the picture's edges. - One read of
sourceper pixel: it costs about as much as plain scanlines.
A background effect: stars streaming out of the middle of the window as if it were flying through them, slow and dim at first, then faster, brighter and drawn out into streaks near the edges.
{ "background": { "shader": "warp.frag.qsb" } }warp.frag:
#version 440
// A background effect: stars streaming out of the middle of the window as if
// it flew through them, slow and dim at first, then faster, brighter and
// drawn out into streaks near its edges. On art pixels, in the scheme's colour.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec2 size; // the area it draws, in art pixels
vec2 origin; // where that is on the screen, in art pixels
float time; // seconds
vec4 ink; // the colour scheme's colour
};
// A number from 0 to 1 for a point, without sin(): the Pi's GPU keeps its
// precision.
float grain(vec2 p)
{
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main()
{
// This art pixel's middle, from the area's middle (every copy of the
// effect, a dialog's too, has the same area, so the same middle).
vec2 p = floor(qt_TexCoord0 * size) + origin;
vec2 d = p + 0.5 - (origin + size * 0.5);
float r = length(d);
float a = atan(d.y, d.x);
float reach = length(size) * 0.5;
float light = 0.0;
for (int i = 0; i < 2; ++i) {
float layer = float(i);
// The circle cut into slices, one star flying out along each.
float slices = 72.0 + 48.0 * layer;
float k = floor((a / 6.2831853 + 0.5) * slices);
float along = (k + 0.5) / slices * 6.2831853 - 3.1415927;
// How far this pixel is from the slice's middle line, in art pixels.
float off = abs(sin(a - along)) * r;
// How far out the star is: 0 at the middle, 1 at the edge, a trip
// every 3 to 7 seconds; faster the further out, as stars rush past.
float rate = 0.14 + 0.2 * grain(vec2(k, 7.0 + layer));
float f = fract(time * rate + grain(vec2(k, 3.0 + layer)));
float head = f * f * reach;
float tail = 0.5 + 7.0 * f * f;
if (off < 0.5 && r <= head && r > head - tail)
light = max(light, 0.2 + 0.7 * f);
}
vec3 color = mix(ink.rgb, vec3(1.0), 0.3);
fragColor = vec4(color * light, light) * qt_Opacity;
}-
d,randaare the art pixel's way, distance and angle from the area's middle. -
The slices: the circle is cut into 72 slices, then 120 for a second layer of stars, each with one star flying out along its middle line.
offis how far the pixel is from that line; under half an art pixel, it is on it. -
The star's trip:
fgoes from 0 to 1 at the slice's ownrate(one trip in 3 to 7 seconds) and from its own starting point (grain, so they don't all start together). Its distance,head, isfsquared times the way to the corner: slow near the middle, rushing at the edges, where its streak,tail, is up to 7.5 art pixels long and its light at its brightest. -
grain()makes a number from 0 to 1 for a point with arithmetic only: hashes built onsin()lose their precision on some GPUs, the Pi's among them. OSD/OS's own shaders use the same one. - The stars are the scheme's colour, 30% lighter, at 20% to 90% opacity, premultiplied.
A background effect for "area": "foot": a floor of grid lines running to a horizon two fifths of the way up, scrolling towards you, as on a synthwave record's sleeve. Its lines are an art pixel wide, in the scheme's colour, fading into the distance.
{ "background": { "shader": "grid.frag.qsb", "area": "foot" } }grid.frag:
#version 440
// A background effect for "area": "foot": a floor of grid lines running to a
// horizon and scrolling towards you, as on a synthwave record's sleeve. Its
// lines are an art pixel wide, in the scheme's colour, fading into the distance.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec2 size; // the area, in art pixels: the window's top to the screen's foot
vec2 origin; // its top left on the screen, in art pixels
float time; // seconds
vec4 ink; // the colour scheme's colour
};
// The floor's rows, counted from the horizon and coming towards you, at a
// point `below` art pixels under it.
float rowAt(float below)
{
return 48.0 / below + time * 1.5;
}
// The lines running to the horizon, counted from its middle, at a point `x`
// art pixels across from the middle and `below` under it.
float lineAt(float x, float below)
{
return x * 3.0 / below;
}
// Whether a whole number lies between two values: a line crosses there.
bool crosses(float a, float b)
{
return floor(min(a, b)) != floor(max(a, b));
}
void main()
{
vec2 p = floor(qt_TexCoord0 * size) + origin;
// The horizon two fifths of the way up from the area's foot, the lines
// meeting in its middle.
float horizon = floor(origin.y + size.y * 0.6);
float x = p.x - (origin.x + size.x * 0.5);
float below = p.y - horizon;
float light = 0.0;
if (below == 0.0) {
light = 0.6; // the horizon itself
} else if (below > 0.0) {
// Lit when a row or a line passes through this art pixel: between its
// top and its foot, or between any two of its corners. Rows only
// from where they are two art pixels apart or more.
bool row = below >= 10.0 && crosses(rowAt(below), rowAt(below + 1.0));
bool line = crosses(lineAt(x, below), lineAt(x + 1.0, below + 1.0))
|| crosses(lineAt(x + 1.0, below), lineAt(x, below + 1.0));
if (row || line)
// Gone near the horizon, where they would crowd together.
light = 0.45 * smoothstep(12.0, 56.0, below);
}
fragColor = vec4(ink.rgb * light, light) * qt_Opacity;
}-
Perspective in one division. A row of the floor
belowart pixels under the horizon is48 / belowrows into the distance; addingtime * 1.5moves every row towards you, a row and a half a second. A line to the horizonxart pixels across from the middle isx * 3 / belowlines out. -
Lines an art pixel wide: rather than testing how close a pixel is to a line, the shader asks whether a line passes through the art pixel, a whole number lying between the values at its edges (
crosses). For the rows, between its top and its foot; for the lines to the horizon, between opposite corners, so that a shallow line far out to the side stays unbroken. -
The distance: rows nearer than two art pixels apart (
belowunder 10) are left out, and everything fades in from 12 to 56 art pixels under the horizon, where the lines would crowd into a blur. -
The clock: when
timestarts over at 3600, every row moves by 5400 rows, a whole number, so nothing jumps. - With
"area": "foot"the area runs from the window's top to the screen's foot, so the floor fills the lowest two fifths of it, from below the window up into it. With"area": "window"it would sit in the window's lower part instead.
A screen shader of your own takes the place of OSD/OS's, which is the one that draws the text effects (Rainbow, Shimmer, Glow, Flicker). This one draws them as OSD/OS's does, from the same numbers, so Settings → Text Effect and the theme's text keep working; its own look is only scanlines, where yours goes.
{
"text": "Rainbow",
"screen": { "shader": "keeptext.frag.qsb", "scanlines": 0.3 }
}keeptext.frag:
#version 440
// A screen effect of one's own that keeps Settings → Text Effect working:
// the text effects are drawn by OSD/OS's own screen shader, which this one
// replaces, so it draws them itself, as OSD/OS's does (shaders/effects.frag).
// Its own look here is only scanlines: put yours where it says.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec2 resolution;
float px;
float time;
float scanlines;
float rainbow; // the text effect's numbers, 0 to 1
float shimmer;
float flicker;
float inkGlow;
vec4 ink;
vec4 paper;
};
layout(binding = 1) uniform sampler2D source;
float grain(vec2 p)
{
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec3 hue(float h)
{
return clamp(abs(fract(h + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0) - 1.0, 0.0, 1.0);
}
// How much of the ink a colour is: 0 the paper, 1 the ink.
float inkOf(vec3 c)
{
vec3 axis = ink.rgb - paper.rgb;
return clamp(dot(c - paper.rgb, axis) / max(dot(axis, axis), 0.0001), 0.0, 1.0);
}
// The text effects, on what is drawn in the two colours only.
vec3 textEffects(vec3 color, vec2 uv)
{
vec2 art = px / resolution;
float t = inkOf(color);
float drawn = 1.0 - smoothstep(0.06, 0.2, length(color - mix(paper.rgb, ink.rgb, t)));
vec3 newInk = ink.rgb;
if (rainbow > 0.0) {
vec2 cell = floor(uv / art);
newInk = mix(newInk, mix(hue(fract(cell.x * 0.006 + cell.y * 0.003 - time * 0.15)), vec3(1.0), 0.15), rainbow);
}
if (shimmer > 0.0) {
float d = uv.x + uv.y * 0.35 - (fract(time / 3.5) * 1.8 - 0.4);
newInk = mix(newInk, vec3(1.0), (1.0 - smoothstep(0.0, 0.06, abs(d))) * shimmer * 0.85);
}
if (flicker > 0.0) {
float f = grain(vec2(floor(time * 12.0), 7.31));
float dip = f > 0.9 ? 1.0 : (f > 0.82 ? 0.5 : 0.0);
newInk = mix(paper.rgb, newInk, 1.0 - flicker * (0.75 * dip + 0.06 * (0.5 + 0.5 * sin(time * 45.0))));
}
if (inkGlow > 0.0) {
vec2 g = art * 1.5;
float around = (inkOf(texture(source, uv + vec2(g.x, 0.0)).rgb) + inkOf(texture(source, uv - vec2(g.x, 0.0)).rgb)
+ inkOf(texture(source, uv + vec2(0.0, g.y)).rgb) + inkOf(texture(source, uv - vec2(0.0, g.y)).rgb)) * 0.25;
t = max(t, t + (around - t) * inkGlow * 0.8);
}
return mix(color, paper.rgb + (newInk - paper.rgb) * t, drawn);
}
void main()
{
vec2 uv = qt_TexCoord0;
vec3 color = texture(source, uv).rgb;
color = textEffects(color, uv);
// Your own look here; this one's is scanlines.
color *= 1.0 - scanlines * 0.7 * step(0.5, fract(uv.y * resolution.y / px));
fragColor = vec4(clamp(color, 0.0, 1.0), 1.0) * qt_Opacity;
}-
inkOf()says how far a colour lies from the paper towards the ink, 0 to 1.drawnis 1 for colours on the line between the two (the text, the lines, the bars, a letter's soft edge) and 0 for anything else, a picture or a flame, which is left as it is. -
textEffects()changes the ink, not the picture: the rainbow's hues, the shimmer's white glint, the flicker's dips towards the paper; then it redraws each drawn pixel with the new ink, as much of it as the pixel had. Glow raises that amount where there is more ink round about. - No
"animate"is needed here: Rainbow, Shimmer and Flicker keep the clock running themselves, as they do for OSD/OS's shader. A shader that moves on its own needs"animate": true. - Copy
textEffects()and its three helpers into a shader of your own, call it on the colour you read fromsource, and the text effects keep working with it.
The template's background effect: a plasma, the old demos' favourite, drifting slowly behind the menus in the scheme's colour, faint and dithered as an old computer would draw it. OSD/OS has it built in too, as Settings → Background Effect's Plasma (shaders/bg-plasma.frag).
#version 440
// The template theme's background effect: a plasma, the old demos' favourite,
// drifting slowly behind the menus in the colour scheme's colour. Faint, so
// the menus stay easy to read, on art pixels, and dithered as an old
// computer would: a pixel is lit or not, more of them where the plasma is
// brighter.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec2 size; // the area it draws, in art pixels
vec2 origin; // where that is on the screen, in art pixels
float time; // seconds
vec4 ink; // the colour scheme's colour
vec4 paper; // its background
};
// A 4×4 ordered dither's threshold for an art pixel, 0 to 1.
float bayer2(vec2 a)
{
a = floor(a);
return fract(dot(a, vec2(0.5, a.y * 0.75)));
}
float bayer4(vec2 a)
{
return bayer2(0.5 * a) * 0.25 + bayer2(a);
}
void main()
{
// The screen's art pixel, the same wherever on the screen this is
// drawn: a dialog's ground (OsdGround) shows the same plasma as the
// window's under it.
vec2 p = floor(qt_TexCoord0 * size) + origin;
float t = time * 0.4;
vec2 centre = vec2(160.0, 120.0) + 60.0 * vec2(sin(t * 0.7), cos(t * 0.9));
float v = sin(p.x * 0.05 + t) + sin(p.y * 0.07 - t * 1.3)
+ sin((p.x + p.y) * 0.04 + t * 0.6) + sin(length(p - centre) * 0.08 - t);
// The plasma's brightness here, 0 to 1, and a pixel lit or not by it.
float level = 0.5 + 0.125 * v;
float lit = step(bayer4(p), level * level * 0.5);
float alpha = 0.4 * lit;
fragColor = vec4(ink.rgb * alpha, alpha) * qt_Opacity;
}| Lines | What they do |
|---|---|
#version 440 |
GLSL 4.40, what qsb compiles from |
in vec2 qt_TexCoord0, out vec4 fragColor
|
The point it runs for, 0 to 1 across and down its area, and the colour it writes |
uniform buf { … } |
The block: qt_Matrix and qt_Opacity first, then the names it uses, size, origin, time, ink and paper (declared, but not used) |
bayer2() |
A 2 × 2 ordered dither: from whether the art pixel's column and row are odd or even, a threshold of 0, 0.5, 0.75 or 0.25, the four spread as evenly as can be |
bayer4() |
A 4 × 4 one, built from two: the 2 × 2 pattern at half the size, a quarter as strong, added to it at full size, for 16 thresholds from 0 to 15/16 |
vec2 p = floor(qt_TexCoord0 * size) + origin; |
The art pixel, on the screen: the same point gets the same plasma under a dialog's ground as under the window's |
float t = time * 0.4; |
Its own clock, slowed to drift |
vec2 centre = … |
A point wandering round (160, 120), the middle of a 320 × 240 picture, up to 60 art pixels away, at two speeds across and down |
float v = … |
The plasma: four waves added, across, down, slanted, and in rings round the wandering point, each moving at its own speed; from -4 to 4 |
float level = 0.5 + 0.125 * v; |
That as a brightness from 0 to 1 |
step(bayer4(p), level * level * 0.5) |
The dither: the pixel is lit where its threshold is below the brightness, squared to keep the dark parts dark, and halved so that at most half the pixels light |
float alpha = 0.4 * lit; |
A lit pixel at 40%, so the menus read over it |
fragColor = vec4(ink.rgb * alpha, alpha) * qt_Opacity; |
The scheme's colour, premultiplied; clear where the pixel is unlit |
The template's screen effect: a picture tube's phosphor stripes, a hum bar rolling slowly down the picture, and the theme's scanlines. Its comments call it the skin template's and mention skin.json, from before themes had their own template: it is the theme template's, named in theme.json.
#version 440
// The skin template's own effect: a picture tube's phosphor stripes, a hum
// bar rolling slowly down the picture, and the effect's scanlines.
//
// After a change, compile it again into the .qsb that skin.json names:
//
// qsb --glsl "100 es,120,150" --hlsl 50 --msl 12 -o tube.frag.qsb tube.frag
//
// qsb comes with Qt Shader Tools: qt6-shader-baker on Debian and Raspberry
// Pi OS, in the bin folder of a Qt from Qt's installer.
// The point of the screen this runs for, 0 to 1 across and down, and the
// colour it shows there.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
// What OSD/OS gives a shader. Every one starts with qt_Matrix and qt_Opacity,
// in that order; after them come any of the rest, by name, in any order:
// those it uses.
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec2 resolution; // the screen, in its pixels
float px; // screen pixels to an art pixel, a pixel of a 240-line picture
float time; // seconds, counting up while the effect's "animate" is true
float scanlines; // the effect's "scanlines", 0 to 1
};
// The screen as OSD/OS drew it.
layout(binding = 1) uniform sampler2D source;
void main()
{
vec3 color = texture(source, qt_TexCoord0).rgb;
vec2 pixel = qt_TexCoord0 * resolution;
// Phosphor stripes: the screen's columns of pixels red, green and blue in
// turn, a little brighter all over to make up for it.
float stripe = mod(floor(pixel.x), 3.0);
vec3 mask = vec3(stripe < 0.5 ? 1.0 : 0.7, abs(stripe - 1.0) < 0.5 ? 1.0 : 0.7, stripe > 1.5 ? 1.0 : 0.7);
color *= mask * 1.2;
// A hum bar: a band a little brighter, rolling down the picture once
// every 20 seconds.
float fromBar = abs(fract(qt_TexCoord0.y - time * 0.05) - 0.5);
color *= 1.0 + 0.08 * smoothstep(0.35, 0.5, fromBar);
// Scanlines: the foot of every line of art pixels darker.
color *= 1.0 - scanlines * 0.7 * step(0.5, fract(pixel.y / px));
fragColor = vec4(clamp(color, 0.0, 1.0), 1.0) * qt_Opacity;
}| Lines | What they do |
|---|---|
uniform buf { … } |
qt_Matrix and qt_Opacity, then only what it uses: resolution, px, time, scanlines
|
uniform sampler2D source |
The screen as OSD/OS drew it, at binding 1 |
vec3 color = texture(source, qt_TexCoord0).rgb; |
The screen's colour at this point |
vec2 pixel = qt_TexCoord0 * resolution; |
The point in screen pixels |
float stripe = mod(floor(pixel.x), 3.0); |
Which of three columns this screen pixel is in: 0, 1 or 2 |
vec3 mask = …; color *= mask * 1.2; |
A phosphor stripe: column 0 keeps all its red and 70% of its green and blue, column 1 its green, column 2 its blue; then 20% brighter all over, to make up for what the mask takes |
float fromBar = abs(fract(qt_TexCoord0.y - time * 0.05) - 0.5); |
Where this point is from the hum bar: the bar is at the point whose fract() is 0, which moves down a twentieth of the screen a second, so it comes round once every 20 seconds; fromBar is 0.5 on the bar and 0 half a screen away |
color *= 1.0 + 0.08 * smoothstep(0.35, 0.5, fromBar); |
Up to 8% brighter within 15% of the screen's height of the bar |
color *= 1.0 - scanlines * 0.7 * step(0.5, fract(pixel.y / px)); |
Scanlines: the lower half of every line of art pixels (every second screen line at 480 lines) darkened by up to 70% |
fragColor = vec4(clamp(color, 0.0, 1.0), 1.0) * qt_Opacity; |
Kept within 0 to 1, opaque |
The template's theme.json gives it "animate": true, so that the hum bar rolls, and "scanlines": 0.4. Its text numbers, shimmer and glow, reach it as shimmer and inkGlow but aren't read, so the template's text effect doesn't show: the fourth example shows how to draw it.
-
Start from OSD/OS's own. The repository's shaders take the same names:
effects.fragis the screen and text effects,bg-matrix.frag,bg-fire.frag,bg-stars.frag,bg-snow.frag,bg-flag.fragandbg-plasma.fragthe backgrounds. Copy one into your theme, change it, compile it. Compiled into a theme with"area": "foot",bg-fire.fragburns from the screen's foot instead of the window's. -
Think in art pixels. Snap to them with
floor(), draw in the scheme's colour, and shade with a dither rather than with transparency, and an effect looks like the rest of the OSD. -
Make random numbers without
sin(). Thegrain()hash above keeps its precision on the Pi's GPU. -
Keep it light. A screen shader runs for every screen pixel each time the screen is drawn, about 2 million at 1080p and 307,200 at 640 × 480, and a moving effect draws the screen about thirty times a second. Each read of
sourceand each turn of a loop counts. -
Mind the hour.
timestarts over at 3600. A period that divides 3600 (1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 60 seconds) keeps its beat across it. - Try it on the TV. What reads well on a monitor may not on a CRT, and the Pi's GPU is slower than a computer's.
-
Themes: the
theme.jsona shader goes in, and making a theme - Effects: OSD/OS's own effects, their numbers, and what they cost
- Skins: the window a background effect is drawn in
- Building from source: Qt Shader Tools in a build
- Troubleshooting
Getting started
The system
Modules
- All modules
- Local Files
- Plex
- Jellyfin
- Emby
- YouTube
- Netflix and Prime Video
- Playlists
- NFC Reader
- Weather
- Arcade
- High Scores
- Scripts
- Ambient:Mode
Making it yours
Help and development