Shader authoring
Write GLSL 450 shaders that PlasmaZones mounts as zone overlays.
Everything here targets the contract exposed by phosphor-shell and phosphor-rendering. The bundled effects under data/shaders/ in the PlasmaZones repo are the most reliable reference implementations.
File layout
A shader lives in its own directory. Two files are required; three more are optional.
my-effect/├── metadata.json # required. Describes the effect.├── effect.frag # required. Main fragment shader.├── zone.vert # optional. Custom vertex shader.├── buffer.frag # optional. Enables multipass (one ping-pong buffer).└── common.glsl # optional. Shader-local helpers (if you don't use the shared one).Bundled effects ship under data/shaders/<id>/ in the PlasmaZones source tree. User-installed effects go under ~/.local/share/plasmazones/shaders/<id>/ and take precedence over bundled ones with the same id.
metadata.json
The settings UI reads metadata.json to build the parameter panel, so everything user-configurable lives here rather than being hard-coded in GLSL.
{ "id": "my-effect", "name": "My Effect", "category": "Cyberpunk", "description": "What this effect looks like and how it reacts.", "author": "Your Name", "version": "1.0",
"fragmentShader": "effect.frag", "vertexShader": "zone.vert",
"wallpaper": false, "multipass": false, "bufferShader": "buffer.frag", "bufferFeedback": false, "bufferScale": 1.0, "bufferWrap": "clamp", "bufferFilter": "linear",
"depthBuffer": false,
"parameters": [ {"id": "speed", "name": "Speed", "group": "Motion", "type": "float", "slot": 0, "default": 1.0, "min": 0.0, "max": 4.0}, {"id": "mixAmt", "name": "Mix", "group": "Appearance", "type": "float", "slot": 1, "default": 0.5, "min": 0.0, "max": 1.0}, {"id": "useGlow", "name": "Glow", "group": "Appearance", "type": "bool", "slot": 2, "default": true}, {"id": "primary", "name": "Primary", "group": "Colors", "type": "color", "slot": 0, "default": "#3B82F6"}, {"id": "accent", "name": "Accent", "group": "Colors", "type": "color", "slot": 1, "default": "#22D3EE"} ]}Top-level fields
| Field | Type | Meaning |
|---|---|---|
id | string | Stable identifier. Defaults to the directory name. |
name | string | Display name in the picker. |
category | string | Grouping in the picker. Bundled effects use 3D, Audio Visualizer, Branded, Cyberpunk, Energy, Organic. Custom values are allowed but aren't translated. |
description | string | One- or two-paragraph description shown under the thumbnail. |
author | string | Shown in the about dialog. |
version | string | Defaults to "1.0". |
fragmentShader | string | Defaults to effect.frag. |
vertexShader | string | Defaults to zone.vert. Supply a custom one only if you need non-default vertex outputs. |
wallpaper | bool | Sample the desktop wallpaper on iChannel0. Default false. |
multipass | bool | Enable multipass rendering via one or more buffer shaders. Default false. |
bufferShader | string | Single-buffer multipass. Relative path to the buffer fragment shader (typically buffer.frag). |
bufferShaders | string[] | Multi-buffer multipass. Takes priority over bufferShader when both are present. Each entry is a relative path to a buffer-shader file. |
bufferFeedback | bool | When true, each buffer samples its own previous-frame output. Turn on for smear / trail / feedback effects. Default false. |
bufferScale | number | Buffer-resolution scale relative to viewport. 0.5 halves buffer cost on both axes. Default 1.0. |
bufferWrap | string | clamp (default), repeat, or mirror. |
bufferWraps | string[] | Per-buffer wrap modes when using bufferShaders. |
bufferFilter | string | linear (default) or nearest. |
bufferFilters | string[] | Per-buffer filter modes when using bufferShaders. |
depthBuffer | bool | Allocate a writeable depth attachment. Required for 3D effects that composite front-to-back. Default false. |
parameters | Array | User-tunable values. See below. |
Parameter fields
| Field | Type | Meaning |
|---|---|---|
id | string | Key the value is stored under. Also used when querying from D-Bus. |
name | string | Label in the settings UI. Falls back to id. |
group | string | Collapsible section name. Common groups: Motion, Appearance, Audio, Colors, Labels, Camera. |
type | string | One of float, int, bool, color, image. Defaults to float. |
slot | number | Index into the per-type array. See slot-pool rules below. |
default | any | Type-appropriate default. Required for float / int. |
min, max | number | Required for float and int. |
use_zone_color | bool | Color parameters only. When true, the shader receives the zone's fill color instead of the user's picked color. Lets palette-aware effects inherit the overlay color scheme. |
wrap | string | Image parameters only. clamp (default), repeat, or mirror. |
Parameter types
| type | Reads in GLSL as | Notes |
|---|---|---|
float | customParams[slot].x | Single scalar. min / max / default required. |
int | int(customParams[slot].x) | Integer slider in the UI. Same slot pool as float. min / max / default required. |
bool | customParams[slot].x > 0.5 | Stored as 0.0 or 1.0. Same slot pool as float. |
color | customColors[slot].rgb | Hex in metadata, linear RGB in shader. Alpha lives in .a. Use use_zone_color: true to inherit from the zone's fill instead. |
image | texture(uTexture0..3, uv) | User-chosen image, bound as a sampler2D at layout bindings 7-10. Include <textures.glsl> for the pre-declared samplers. |
float, int, and bool share the 8-slot customParams pool (0-7). color has its own 16-slot customColors pool (0-15). image slots bind to dedicated sampler units at bindings 7-10 alongside the Shadertoy-style iChannel0..3 at 2-5. Keep slot values contiguous inside each pool starting at 0; the settings UI relies on them lining up with the GLSL array indices.
The
"group"field groups parameters into collapsible sections in the settings UI. Common groups: Motion, Appearance, Audio, Colors, Labels.
Optional includes
Include files live in the global shaders dir and are addressed with #include <...>. All are idempotent (guarded by #ifndef), so including them twice is safe.
| Include | When to include | What you get |
|---|---|---|
<common.glsl> | Always. | The ZoneUniforms block, uZoneLabels sampler, time/zone/noise helpers, SDF helpers, label compositors. |
<multipass.glsl> | When multipass: true. | iChannel0..3 samplers at bindings 2-5 and channelUv() for Y-correct sampling. |
<audio.glsl> | When using audio reactivity. | uAudioSpectrum at binding 6 and a family of getters (audioBar, getBass, getMids, getTreble, getOverall and their soft variants). |
<textures.glsl> | When you declare image parameters. | uTexture0..3 samplers at bindings 7-10. |
<depth.glsl> | When depthBuffer: true. | uDepthBuffer at binding 12 and readDepth(uv). |
#version 450#include <common.glsl>#include <multipass.glsl> // if multipass: true#include <audio.glsl> // if using audio reactivity#include <textures.glsl> // if declaring image parameters#include <depth.glsl> // if depthBuffer: trueGLSL contract
Shaders are Vulkan-flavor GLSL, compiled to SPIR-V at load time and fed through QRhi. The same source runs on OpenGL, Vulkan, and Metal backends. Every fragment shader starts with:
#version 450#include <common.glsl>
layout(location = 0) in vec2 vTexCoord;layout(location = 1) in vec2 vFragCoord;layout(location = 0) out vec4 fragColor;
void main() { vec2 uv = vTexCoord; vec3 color = vec3(0.0); // ... fragColor = vec4(color, 1.0) * qt_Opacity;}vTexCoordis in [0, 1] with Qt's Y-down orientation (Y=0 at top).vFragCoordis in pixels with Y=0 at top.fragColormust be premultiplied alpha; theclampFragColor()helper handles clamping,qt_Opacity, and premultiplication in one call.
Uniform block
Including <common.glsl> pulls in the 672-byte uniform block at binding 0. It's packaged as a single std140 block so you don't declare individual uniforms.
| Field | Type | Meaning |
|---|---|---|
iTime | float | Wrapped time in [0, 1024). Safe to use directly in fract()-like patterns. |
iTimeHi | float | Integer wrap offset. Pair with timeSin() for continuous phase over long uptimes. |
iTimeDelta | float | Seconds since last frame. |
iFrame | int | Monotonic frame counter. |
iResolution | vec2 | Viewport size in pixels. |
iMouse | vec4 | xy = pixels, zw = normalized (0-1). Y-down. |
iDate | vec4 | year, month, day, seconds-since-midnight. |
qt_Matrix | mat4 | MVP matrix. Vertex shaders only. |
qt_Opacity | float | Item opacity. Multiply into final color. |
customParams[8] | vec4[] | User parameters (.x carries the value). |
customColors[16] | vec4[] | User colors, linear RGB in .rgb. |
iChannelResolution[4] | vec2[] | Size of iChannel0..3 in pixels. |
iTextureResolution[4] | vec2[] | Size of user texture bindings 7-10. |
iAudioSpectrumSize | int | Number of audio bars. 0 = audio disabled. |
iFlipBufferY | int | Always 1. Pass to fragCoordFromTexCoord(). |
zoneRects[64] | vec4[] | Per-zone geometry. xyzw = x, y, w, h in pixels. |
zoneFillColors[64] | vec4[] | Per-zone fill RGBA. |
zoneBorderColors[64] | vec4[] | Per-zone border RGBA. |
zoneParams[64] | vec4[] | Per-zone parameters. .x = border width, .y = highlight level, etc. |
zoneCount | int | How many of the 64 zone slots are active. |
highlightedCount | int | How many are currently highlighted. |
Binding 1 is a sampler2D uZoneLabels texture, a strip of per-zone label glyphs rendered off-screen.
Helper functions
Core helpers come from common.glsl. Prefer these over hand-rolling the math.
Time and resolution
// Continuous-phase time. Uses angle addition on (iTimeHi, iTime) so the// result stays stable after iTime wraps. Use for sin/cos animations.float timeSin(float speed);float timeSin(float speed, float offset);float timeCos(float speed);float timeCos(float speed, float offset);
// Resolution-independent pixel scale factor (iResolution.y / 1080).// Multiply pixel-space constants by this so border widths, glow radii,// chromatic aberration read the same on a 4K monitor as on 1080p.float pxScale();
// Convert vTexCoord (Y-down) to pixel-space fragCoord (Y-up canonical).vec2 fragCoordFromTexCoord(vec2 uv);
// Clamp color to [0,1], apply qt_Opacity, and premultiply alpha.// Use for the final fragColor assignment — Qt Quick and Wayland// compositors expect premultiplied alpha (rgb * alpha).vec4 clampFragColor(vec4 color);
// Constantsconst float PI;const float TAU;const float K_TIME_WRAP; // 1024.0Labels (zone numbers / titles)
// uZoneLabels is a texture strip of per-zone glyphs. These helpers// composite it over your background color so zone numbers / titles// read regardless of the effect underneath.vec2 labelsUv(vec2 fragCoord);vec4 compositeLabels(vec4 color, vec2 uv, sampler2D labelsTex);vec4 compositeLabelsWithUv(vec4 color, vec2 fragCoord);Zone helpers
zoneRects[i] is a vec4 with xy / zw normalized 0-1 (fraction of the screen). Multiply by iResolution to get pixels. Zone fill / border colors are straight RGBA.
// Normalized rect → pixel-space position and sizevec2 zoneRectPos(vec4 rect); // rect.xy * iResolutionvec2 zoneRectSize(vec4 rect); // rect.zw * iResolution
// 0..1 UV inside a zone given the fragment's pixel coordinatesvec2 zoneLocalUV(vec2 fragCoord, vec2 rectPos, vec2 rectSize);
// "Alive" vs "dormant" zone state. Highlighted zones animate;// non-highlighted ones are static. Use these to drive intensity,// color saturation, and motion amplitude.float zoneVitality(bool isHighlighted);vec3 vitalityDesaturate(vec3 col, float vitality);float vitalityScale(float dormant, float alive, float vitality);Per-zone arrays available on every call: zoneRects[64], zoneFillColors[64], zoneBorderColors[64], zoneParams[64], with zoneCount active slots and highlightedCount currently lit.
Noise, SDF, and blending
Hashes and noise
// Deterministic hashes — cheap, no texture fetch.float hash11(float n);float hash21(vec2 p);vec2 hash22(vec2 p);
// Smooth value noise.float noise1D(float x);float noise2D(vec2 p);float angularNoise(float angle, float freq, float seed);Signed distance fields
// Rounded box SDF. p: sample pos (centered), b: half-size, r: corner radius.// Positive outside the shape, negative inside.float sdRoundedBox(vec2 p, vec2 b, float r);
// Soft edge for a distance field. borderWidth: transition half-width in// the same units as d.float softBorder(float d, float borderWidth);
// Exponential-falloff glow from an SDF edge.float expGlow(float d, float falloff, float strength);Color and blending
// Standard "source over" alpha blend. Expects straight (non-premultiplied) alpha.vec4 blendOver(vec4 dst, vec4 src);
// Rec.601 luminance.float luminance(vec3 c);
// Pick the first non-black color — useful when a user param may be// unset and you want to fall back to a sensible default.vec3 colorWithFallback(vec3 color, vec3 fallback);Input channels and textures
Three sampler families can bind into a fragment shader, each behind its own optional include. Layout bindings are fixed; no two samplers share a slot.
| Binding | Sampler | Source | Include |
|---|---|---|---|
| 1 | uZoneLabels | Pre-rendered zone label glyphs. | common.glsl |
| 2 | iChannel0 | Desktop wallpaper (when "wallpaper": true) or buffer 0 output in multipass. | multipass.glsl |
| 3 | iChannel1 | Buffer 1 output in multi-buffer multipass. | multipass.glsl |
| 4 | iChannel2 | Buffer 2 output. | multipass.glsl |
| 5 | iChannel3 | Buffer 3 output. | multipass.glsl |
| 6 | uAudioSpectrum | Audio spectrum (1D strip, R = bar value 0-1). | audio.glsl |
| 7-10 | uTexture0..3 | User-chosen images from image-type parameters. | textures.glsl |
| 12 | uDepthBuffer | Depth attachment from the previous pass. | depth.glsl |
Always sample iChannelN via channelUv(), not raw vTexCoord. Buffer textures carry a fixed Y-flip on both OpenGL and Vulkan backends; channelUv() handles it for you.
// Correct: Y-flip handled by the helpervec2 uv = channelUv(0, fragCoordFromTexCoord(vTexCoord));vec4 prev = texture(iChannel0, uv);
// Wrong: skips the Y-flip, content renders upside-downvec4 prev = texture(iChannel0, vTexCoord);Audio spectrum
When CAVA is running and iAudioSpectrumSize > 0, uAudioSpectrum holds a 1D strip with each bar's amplitude in the red channel. Include <audio.glsl> for the full getter family.
| Helper | Returns |
|---|---|
audioBar(int barIndex) | Bar value 0-1. Returns 0 when audio is off or the index is out of range. |
audioBarSmooth(float u) | Linearly interpolated bar value at normalized position u in [0, 1]. |
getBass() / getMids() / getTreble() | Frequency-band averages. Each reads its own slice of the spectrum. |
getOverall() | Average across all bars. |
getBassSoft(), getMidsSoft(), getTrebleSoft(), getOverallSoft() | Temporally smoothed variants. Feed these into visual amplitudes so effects don't jitter on percussion transients. |
Every helper in
<audio.glsl>returns 0 wheniAudioSpectrumSize == 0, so shaders keep rendering sanely when CAVA isn't available.
Multipass
Multipass shaders run one or more buffer shaders before effect.frag each frame and feed their output into iChannel0..3 of the main pass. Used for feedback trails, pre-blurred light accumulation, and anything that needs state from the previous frame.
- Single buffer — set
"multipass": trueand"bufferShader": "buffer.frag". The buffer's output binds toiChannel0. - Multi-buffer — supply
"bufferShaders": ["a.frag", "b.frag", ...]instead. Each buffer's output binds to the matchingiChannelN. Up to four buffers. - Feedback — set
"bufferFeedback": trueand each buffer reads its own previous-frame output on its owniChannelN. Without this, buffers have no access to history. - Scale —
"bufferScale": 0.5halves buffer resolution on each axis, cutting the buffer pass's fragment cost by 4×. - Wrap / filter —
"bufferWrap"controls edge behavior (clamp,repeat,mirror)."bufferFilter"controls magnification (linear,nearest). Use the plural variants (bufferWraps,bufferFilters) for per-buffer control in multi-buffer setups.
// buffer.frag — smeared accumulation of the previous frame (feedback ON)#version 450#include <common.glsl>#include <multipass.glsl>
layout(location = 0) in vec2 vTexCoord;layout(location = 1) in vec2 vFragCoord;layout(location = 0) out vec4 fragColor;
void main() { vec2 uv = channelUv(0, vFragCoord); vec4 prev = texture(iChannel0, uv);
vec3 newColor = /* compute something fresh */; fragColor = vec4(mix(prev.rgb * 0.94, newColor, 0.08), 1.0);}Depth buffer
Set "depthBuffer": true and include <depth.glsl> to sample the previous pass's depth attachment. Depth is written from a second output location in the fragment shader:
#version 450#include <common.glsl>#include <depth.glsl>
layout(location = 0) in vec2 vTexCoord;layout(location = 0) out vec4 fragColor;layout(location = 1) out float oDepth; // depth write
void main() { // Read previous-pass depth to decide whether to composite float prevDepth = readDepth(vTexCoord);
vec3 color = /* ... */; float myDepth = /* 0 (near) .. 1 (far) */;
fragColor = clampFragColor(vec4(color, 1.0)); oDepth = myDepth;}Depth sits in [0, 1] with 0 = near, 1 = far. Phosphor's 3D shaders (Neon City, Nexus Cascade) use it to composite front-to-back without sorting geometry.
Install and hot-reload
Drop the directory under ~/.local/share/plasmazones/shaders/<id>/. PlasmaZones picks it up on next settings open; if the file watcher hot-reload is on (default), GLSL changes re-compile and re-apply within a frame or two. Syntax errors emit a console warning and the last-good shader keeps rendering.
The compiler cache is keyed on (source-hash, target-API), so re-entering the editor after tweaking unrelated parameters doesn't recompile.
Full API: see phosphor-rendering for ShaderEffect and the compiler pipeline, and phosphor-shell for the registry and uniform-extension interface. Reference implementations ship under data/shaders/ in the PlasmaZones source tree.