Shader authoring
Write GLSL 450 shaders that PlasmaZones mounts as zone overlays.
Everything here targets the contract exposed by phosphor-shaders (the registry, base UBO, and zone-uniform extension) and phosphor-rendering (the ShaderNodeRhi render path and GLSL → SPIR-V compiler). 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 (a "pack"). One file is required; the rest are optional.
my-effect/├── metadata.json # required. Describes the effect.├── effect.frag # required. Main fragment shader.├── zone.vert # optional. Custom vertex shader (else the shared one is used).├── buffer.frag # optional. A multipass buffer pass.├── preview.png # optional. Thumbnail for the picker.└── helpers.glsl # optional. Pack-local includes.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 (search paths are scanned low-to-high priority, last writer wins). Every .frag / .vert / .glsl / .json in a pack is watched for hot-reload.
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, "bufferShaders": ["buffer.frag"], "bufferFeedback": false, "bufferScale": 1.0, "bufferWrap": "clamp", "bufferFilter": "linear",
"depthBuffer": false,
"parameters": [ {"id": "speed", "name": "Speed", "group": "Motion", "type": "float", "default": 1.0, "min": 0.0, "max": 4.0}, {"id": "mixAmt", "name": "Mix", "group": "Appearance", "type": "float", "default": 0.5, "min": 0.0, "max": 1.0}, {"id": "useGlow", "name": "Glow", "group": "Appearance", "type": "bool", "default": true}, {"id": "primary", "name": "Primary", "group": "Colors", "type": "color", "default": "#3B82F6"}, {"id": "accent", "name": "Accent", "group": "Colors", "type": "color", "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 | Optional. Falls back to the shared zone.vert; supply one only if you need non-default vertex outputs. |
wallpaper | bool | Supply the desktop wallpaper as uWallpaper (binding 11). Default false. |
multipass | bool | Master switch for multipass rendering. Must be paired with at least one buffer shader. Default false. |
bufferShaders | string[] | The buffer passes, run in order before the main pass (up to four). Each output binds to the matching iChannelN. Preferred form. |
bufferShader | string | Legacy single-buffer form (defaults to buffer.frag). Prefer bufferShaders. |
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, clamped to [0.125, 1.0]. 0.5 halves buffer cost on both axes. Default 1.0. |
bufferWrap | string | clamp (default) or repeat. |
bufferWraps | string[] | Per-buffer wrap modes when using bufferShaders. |
bufferFilter | string | linear (default), nearest, or mipmap. |
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. |
presets | object | Named bundles of parameter values (name → {paramId: value}) the user can pick from the panel. |
parameters | Array | User-tunable values. See below. |
Parameter fields
| Field | Type | Meaning |
|---|---|---|
id | string | Key the value is stored under, and the name you read it by in GLSL (p_<id>). Must be a valid GLSL identifier. 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 | Optional. Explicit index into the per-type pool. Omit it and the registry auto-assigns the next free lane in declaration order. 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) or repeat. |
Parameter types
Each parameter gets a #define p_<id> macro injected after #version, so you read parameters by name rather than indexing raw arrays. The macro expands to the right pool accessor for the type:
| type | Read in GLSL as | Notes |
|---|---|---|
float | p_<id> (a float) | Single scalar. min / max / default required. |
int | int(p_<id>) | Integer slider in the UI. Same scalar pool as float. min / max / default required. |
bool | p_<id> > 0.5 | Stored as 0.0 or 1.0. Same scalar pool as float. |
color | p_<id>.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(p_<id>, uv) | User-chosen image, bound as a sampler2D at bindings 7-10. Include <textures.glsl> for the sampler units. |
Behind the macros, parameters pack into three independent pools: float / int / bool share the 8-slot customParams pool (32 scalar lanes, .xyzw); color has its own 16-slot customColors pool; image binds to dedicated sampler units 7-10. You normally never touch the slot numbers — let the registry auto-assign them and read by name. Set an explicit slot only when a fixed binding matters (and keep values contiguous from 0 inside each pool).
Unset colors read as black.
colorWithFallback(p_x.rgb, fallback)returns a sensible default when a user color is left empty.
Optional includes
Include files live in the shared shaders dir and are addressed with #include <...>. All are idempotent (guarded by #ifndef), so including them twice is safe. <common.glsl> is injected for you on the pZone / pImage entrypoints; add the rest by hand.
| Include | When to include | What you get |
|---|---|---|
<common.glsl> | Always (auto-injected for pZone / pImage). | The ZoneUniforms block, ZoneCtx struct, uZoneLabels sampler, time/zone/noise helpers, SDF helpers, label compositors, clampFragColor. |
<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. |
<wallpaper.glsl> | When wallpaper: true. | uWallpaper at binding 11. |
<depth.glsl> | When depthBuffer: true. | uDepthBuffer at binding 12 and readDepth(uv). |
GLSL contract
Shaders are Vulkan-flavor GLSL #version 450, compiled to SPIR-V at load time and fed through QRhi, so the same source runs on the OpenGL, Vulkan, and Metal backends. You write one of three entrypoints. For the first two, the harness injects the version directive, #include <common.glsl>, the vertex inputs, the colour output, and a generated main() — you only add any extra includes (audio, multipass, textures, depth, wallpaper) yourself.
pZone — per-zone (preferred)
Return the colour for one zone; the generated main() loops over every active zone, fills a ZoneCtx, alpha-composites your results back-to-front, and finishes with clampFragColor. Return straight (non-premultiplied) RGBA — the harness premultiplies.
vec4 pZone(ZoneCtx z) { vec2 uv = zoneLocalUV(z.fragCoord, zoneRectPos(z.rect), zoneRectSize(z.rect));
vec3 color = mix(z.fillColor.rgb, p_primary, p_mixAmt * timeSin(p_speed)); float a = z.fillColor.a;
return vec4(color, a); // straight alpha; harness composites + premultiplies}ZoneCtx carries everything about the zone being shaded:
struct ZoneCtx { int index; // zone index, 0 .. zoneCount-1 vec2 fragCoord; // pixel coordinate, Y=0 at top vec4 rect; // normalized 0-1 xy / zw — see zone helpers vec4 fillColor; // zone fill RGBA vec4 borderColor; // zone border RGBA vec4 params; // x=border radius, y=border width, z=highlight, w=zone number bool isHighlighted; // this zone is currently highlighted};pImage — full-frame
Shade the whole overlay in one pass (visualizers, wallpaper-style effects, and every buffer pass). The generated main() is just fragColor = clampFragColor(pImage(vFragCoord));:
vec4 pImage(vec2 fragCoord) { vec2 uv = fragCoord / iResolution; vec3 color = vec3(0.0); // ... draw across the full viewport, read zoneRects[i] as needed ... return vec4(color, 1.0);}void main — escape hatch
If your source defines void main() (directly or via an include), it passes through unchanged. You then declare the inputs and output yourself and call clampFragColor on the way out. Use this only when you need full control (a custom vertex stage, an unusual output layout):
#version 450#include <common.glsl>
layout(location = 0) in vec2 vTexCoord; // [0,1] UV, Qt Y-down (Y=0 at top)layout(location = 1) in vec2 vFragCoord; // pixels, Y=0 at toplayout(location = 0) out vec4 fragColor;
void main() { vec3 color = vec3(0.0); // ... fragColor = clampFragColor(vec4(color, 1.0));}vTexCoordis in [0, 1] with Qt's Y-down orientation (Y=0 at top).vFragCoordis in pixels with Y=0 at top.clampFragColor()clamps the colour, folds inqt_Opacity, and premultiplies alpha. Qt Quick and Wayland compositors expect premultiplied output. ThepZone/pImageharness applies it for you.
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 | Seconds, wrapped into [0, 1024) for fp32 precision. Safe to use directly. |
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 | Render size in pixels (physical pixels for zone overlays). |
zoneCount | int | How many of the 64 zone slots are active. |
highlightedCount | int | How many zones are currently highlighted. |
iMouse | vec4 | xy = pixels, zw = normalized (0-1). Y-down. |
iDate | vec4 | year, month, day, seconds-since-midnight. |
qt_Matrix | mat4 | Scene transform. Vertex shaders only. |
qt_Opacity | float | Item opacity. Folded in by clampFragColor. |
customParams[8] | vec4[] | Scalar parameter pool (read by name via p_<id>). |
customColors[16] | vec4[] | Color parameter pool, 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. The Y-flip it signals is applied unconditionally inside fragCoordFromTexCoord() / channelUv(); you never read or pass it. |
zoneRects[64] | vec4[] | Per-zone geometry. xy / zw = position / size, normalized 0-1. |
zoneFillColors[64] | vec4[] | Per-zone fill RGBA. |
zoneBorderColors[64] | vec4[] | Per-zone border RGBA. |
zoneParams[64] | vec4[] | Per-zone parameters. .x = border radius, .y = border width, .z = highlight, .w = zone number. |
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.// The pZone / pImage harness calls this for you; void-main shaders call it// on the final fragColor assignment.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] (and ZoneCtx.rect) 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. In a pZone shader the active zone arrives as ZoneCtx; the arrays let a pImage shader reach any zone.
Noise, SDF, and blending
Hashes and noise
// Deterministic hashes — cheap, no texture fetch, identical across backends.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
Sampler families 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 | Buffer 0 output in multipass. | multipass.glsl |
| 3 | iChannel1 | Buffer 1 output. | 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 |
| 11 | uWallpaper | Desktop wallpaper (when "wallpaper": true). | wallpaper.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. There is no metadata flag — the user enables audio capture globally in settings, and every helper returns 0 when it's off.
| 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. |
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. Buffer passes are pImage shaders (or hand-written main()).
- Buffers — set
"multipass": trueand"bufferShaders": ["a.frag", "b.frag", ...]. Each buffer's output binds to the matchingiChannelN. Up to four buffers. ("bufferShader": "buffer.frag"is the legacy single-buffer form.) - 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×. Clamped to[0.125, 1.0]. - Wrap / filter —
"bufferWrap"controls edge behavior (clamp,repeat)."bufferFilter"controls sampling (linear,nearest,mipmap). Use the plural variants (bufferWraps,bufferFilters) for per-buffer control.
// buffer.frag — smeared accumulation of the previous frame (feedback ON)vec4 pImage(vec2 fragCoord) { vec2 uv = channelUv(0, fragCoord); vec4 prev = texture(iChannel0, uv);
vec3 newColor = /* compute something fresh */ vec3(0.0); return 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, so a depth effect uses the void main() form to declare it:
#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 = vec3(0.0); // ... float myDepth = 0.0; // 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, Voxel Terrain) use it to composite front-to-back without sorting geometry.
Install, validate, 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. Compile errors surface through the shaderCompilationFinished(id, success, error) signal and the last-good shader keeps rendering.
To catch errors before loading, run the offline validator over an overlay pack — the same check the build runs:
plasmazones-shader-validate --overlay ~/.local/share/plasmazones/shaders/my-effectThe p_<id> defines are generated at load, so a GLSL language server won't resolve them while you edit. Emit a sidecar with every define and #include it near the top of effect.frag — the loader skips this include at runtime, so it never ships or affects the compiled shader:
plasmazones-shader-validate --emit-preamble ~/.local/share/plasmazones/shaders/my-effect# writes my-effect/p_generated.glsl#include "p_generated.glsl" // editor-only; the loader skips it at runtimePoint your language server (glslls / glsl-language-server) at /usr/share/plasmazones/shaders/shared so <common.glsl> resolves too. Re-run --emit-preamble whenever you change parameters; p_generated.glsl is git-ignored.
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 ShaderNodeRhi and the compiler pipeline, and phosphor-shaders for the registry, base UBO, and zone-uniform extension. Reference implementations ship under data/shaders/ in the PlasmaZones source tree.