Writing background shaders
August 11, 2026 · View on GitHub
driftwm renders the canvas background using a GLSL fragment shader. You can write your own to replace the default dot grid.
Tip
Looking for ready-made shaders, or want to share your own? Browse the Gallery.
Your first shader
A background shader returns a color for every pixel of the output. The smallest one paints a flat color:
precision mediump float;
const vec3 BG = vec3(0.07, 0.07, 0.09);
void main() {
gl_FragColor = vec4(BG, 1.0);
}
Save it as ~/shaders/my_bg.glsl and point the config at it:
[background]
type = "shader"
path = "~/shaders/my_bg.glsl"
driftwm watches the config file, so saving it applies the background. The shader
is re-read from disk on every config reload, so after editing the .glsl itself,
trigger one:
touch ~/.config/driftwm/config.toml
Note
Shaders are GLSL ES 1.0 — smithay prepends #version 100, so don't add a
version directive of your own. Open with precision mediump float;, or
highp for noise.
How it works
The shader runs once per pixel every frame the viewport changes. It receives the pixel's position and the viewport's camera offset, and outputs a color. The result covers the entire output behind all windows.
Inputs
Built-in (provided by smithay)
| Name | Type | Description |
|---|---|---|
v_coords | vec2 | Normalized position within the output, 0.0–1.0 |
size | vec2 | Output dimensions in pixels (e.g. 1920.0, 1080.0) |
Custom (provided by driftwm)
| Name | Type | Description |
|---|---|---|
u_camera | vec2 | Canvas→screen offset in canvas pixels (viewport's top-left) |
u_zoom | float | Canvas→screen scale (1.0 = unzoomed, >1 zoomed in, <1 zoomed out) |
u_time | float | Seconds since compositor start |
All three are optional — declare only the ones your shader uses.
v_coords * size gives screen-local pixel coordinates (top-left = 0,0).
Adding u_camera converts to canvas coordinates — this is how the background
scrolls with the viewport. Without u_camera, the shader is fixed to the
screen and doesn't scroll. By default features defined in canvas pixels
grow/shrink with zoom, same as windows; u_zoom lets you change that
relationship if you want (e.g. divide a feature's size by u_zoom to keep
it screen-sized regardless of zoom level).
Output
Set gl_FragColor to an RGBA vec4:
gl_FragColor = vec4(color, 1.0);
The alpha component (the 1.0 above) is ignored by default — backgrounds are
composited opaque. To make a shader output its own transparency, set
transparent_shader = true (see Transparent backgrounds).
Example: hue shift across the canvas
Uses u_camera so the gradient scrolls with the viewport:
precision mediump float;
varying vec2 v_coords;
uniform vec2 size;
uniform vec2 u_camera;
void main() {
vec2 canvas = (v_coords * size + u_camera) * 0.001;
vec3 col = vec3(
sin(canvas.x) * 0.5 + 0.5,
sin(canvas.y) * 0.5 + 0.5,
0.5
);
gl_FragColor = vec4(col, 1.0);
}
Tips
- Canvas coords: The standard pattern is
vec2 canvas = (v_coords * size + u_camera) * scale;wherescalecontrols the feature size (smaller = larger features). - Float precision:
u_cameracan be large (thousands of pixels from origin). If your shader usesmod()orfract()on canvas coords, reduce first:mod(u_camera, period)instead ofmod(canvas, period). Seeextras/wallpapers/dot_grid.glslfor an example. Noise-based shaders usingfloor()/fract()internally are naturally resilient since the hash functions wrap. - Animated shaders:
u_timegives seconds since compositor start, enabling time-driven animations. driftwm re-renders every frame when a shader usesu_time, unlessanimate_fpscaps the rate. - Zoom-aware shaders: declare
uniform float u_zoom;to react to viewport zoom. Common pattern: divide canvas-pixel sizes byu_zoomto keep features the same screen size at any zoom level (e.g.DOT_RADIUS / u_zoom). - Colors as constants: Define colors, spacing, and other tunables as
GLSL
constvalues at the top of your shader. This keeps everything in one file — no config round-trip needed. - Shipped examples:
extras/wallpapers/holdsdot_grid.glslalongsidestatic/(blue_drift,compass_grid,dark_sea,pink_cloud),animated/(acid_lava,dense_clouds,fast_smoke), andtextured/(mirrored_parallax,ripple).make installcopies them to$(PREFIX)/share/driftwm/wallpapers/—/usr/local/share/driftwm/wallpapers/by default,/usr/share/driftwm/wallpapers/from a distro package.
Sampling an image (textured shaders)
A type = "shader" background can sample a single image by adding a texture
path. driftwm loads the image and binds it to the shader's tex sampler:
[background]
type = "shader"
path = "~/shaders/scroll_image.glsl"
texture = "~/Pictures/tile.png"
Adding a texture compiles the shader as a texture shader, whose input set is:
| Name | Type | Provided by | Description |
|---|---|---|---|
tex | sampler2D | smithay | The configured image. Sample with texture2D |
v_coords | vec2 | smithay | Normalized position within the output, 0.0–1.0 |
u_texture_size | vec2 | driftwm | Image dimensions in pixels |
u_output_size | vec2 | driftwm | Viewport dimensions in pixels (= output / zoom) |
u_camera | vec2 | driftwm | Canvas→screen offset in canvas pixels |
u_zoom | float | driftwm | Canvas→screen scale |
u_time | float | driftwm | Seconds since compositor start |
Notes on the texture path:
| Name | Note |
|---|---|
size | Not provided — use u_output_size, which carries the same value (viewport pixels). |
textureSize() | Not in GLSL ES 1.0 — the image's resolution arrives as u_texture_size. You need it to turn canvas pixels into texel UVs. |
alpha | Always 1.0 for backgrounds. Transparency comes from the shader's own output alpha plus transparent_shader = true. |
cache_shader | No effect — the shader-bake cache can't sample a runtime texture, so textured shaders always render live. |
Tile the image at the canvas position so it scrolls with the viewport:
precision highp float;
varying vec2 v_coords;
uniform sampler2D tex;
uniform vec2 u_camera;
uniform vec2 u_output_size;
uniform vec2 u_texture_size;
void main() {
vec2 canvas = v_coords * u_output_size + mod(u_camera, u_texture_size);
vec2 uv = fract(canvas / u_texture_size); // fract() tiles it infinitely
gl_FragColor = texture2D(tex, uv);
}
See extras/wallpapers/textured/ripple.glsl, which animates a watery
distortion over the tiled image.
Configuring the background
[background] accepts a type and, for the source-bearing types, a path.
Five types are supported:
# Built-in dot grid — the default when [background] is absent (no path).
[background]
type = "default"
# Procedural GLSL shader — scrolls with the canvas
[background]
type = "shader"
path = "~/shaders/my_bg.glsl"
# Optional: bind an image the shader can sample via `tex`
# (see "Sampling an image" above)
# texture = "~/Pictures/tile.png"
# Image tiled across the canvas (scrolls with the camera)
[background]
type = "tile"
path = "~/Pictures/tile.png"
# Single image fixed to the viewport (does not scroll or zoom).
# Cheapest mode: zero per-frame uniform updates.
[background]
type = "wallpaper"
path = "~/Pictures/wallpaper.png"
# No built-in background (no path).
[background]
type = "none"
The wallpaper mode scales the image to cover the output while preserving its
aspect ratio, centering and cropping any overflow.
Other [background] keys, described in full in the
config reference:
| Key | Effect |
|---|---|
mirror_tile | tile mode: mirror-fold the image so a non-seamless edge always meets a reflection. |
cache_shader | Bake a static camera-only shader to textures and pan those. No effect with transparent_shader or a texture. |
transparent_shader | Honor a shader's output alpha (see below). |
cache_budget_mb | Memory ceiling (MB) for the bake and gigapixel-TIFF chunk caches. Default 128. |
animate_fps | Frame-rate cap for u_time shaders. Default 0 = every output frame. |
When cache_shader is safe
Baking renders the shader once into a texture and pans that texture, so a heavy
static shader ends up costing about what an image costs. It is only correct for
a shader that slides rigidly with the camera — u_camera used once, at full
scale, as the only camera term:
vec2 canvas = v_coords * size + u_camera; // pan shifts the image 1:1
Parallax (u_camera * factor) bakes wrong: the texture pans 1:1 no matter what
factor the shader applied. Shaders reading u_time or u_zoom are never baked
and always render live, so the flag costs nothing there.
Transparent backgrounds
By default driftwm composites the background as fully opaque, a fast path that
skips blending and skips redrawing anything beneath it. The background sits
above any wlr-layer-shell Background-layer surface, so making it
see-through lets an external wallpaper engine (a QuickShell or swaybg setup,
say) show through while the built-in background stays on top — to drop the
built-in background entirely instead, use type = "none" (below).
Two ways to opt in, depending on background type:
Images (tile / wallpaper) — automatic. If the PNG carries an alpha
channel with any transparent pixels, driftwm honors it: transparent areas blend
to whatever's below. A fully opaque image keeps the fast path. No config needed.
# Dots-with-transparent-gaps PNG tiled as a spatial reference over a live
# wallpaper engine running on the Background layer — gaps show the engine.
[background]
type = "tile"
path = "~/Pictures/dots.png"
Shaders (type = "shader") — not autodetected; opt in with
transparent_shader = true to honor the shader's output alpha:
[background]
type = "shader"
path = "~/shaders/dot_grid.glsl"
transparent_shader = true
Then output a low (or zero) alpha where you want the layer below to show:
// Opaque dots over a transparent field — the gaps reveal what's underneath.
const vec4 BG_COLOR = vec4(0.0, 0.0, 0.0, 0.0); // transparent
const vec4 DOT_COLOR = vec4(1.0, 1.0, 1.0, 1.0); // opaque
Notes:
- Premultiplied alpha — compositing is premultiplied, so output
vec4(rgb * a, a). Mixing two valid premultiplied colors (asdot_griddoes) stays valid; a rawvec4(rgb, 0.5)would fringe too bright. - Cost — transparency costs a blend every frame plus a repaint of whatever sits below, so turn it on only when something is actually behind.
External wallpaper engines (type = "none")
type = "none" renders no built-in background at all, so whatever sits on the
wlr-layer-shell Background layer becomes the wallpaper — letting you use a
standard Wayland wallpaper daemon instead of driftwm's shader/image modes:
swaybg— static imagesswww/wpaperd— animated wallpapers and transitionsmpvpaper— live video wallpapers (mpv on a layer surface)
Launch the daemon yourself (e.g. from autostart); driftwm just gets out of the
way. With nothing on the Background layer, you'll see the clear color (black).
Notes:
pathis ignored for this type.- A live video wallpaper damages the whole screen every frame, so it repaints continuously (the same cost profile as an animated shader).
Reloading after edits
driftwm reloads the config automatically when the file changes, and re-reads the shader from disk on every reload. Bind the reload action to pick up shader edits without editing the config:
[keybindings]
"mod+shift+c" = "reload-config"
Without a keybinding, touching the config file has the same effect:
touch ~/.config/driftwm/config.toml
If the shader can't be read or fails to compile, driftwm falls back to the
built-in dot grid and reports the reason on the error bar
(background shader: compile error: …). A dot grid after an edit means the
shader was rejected, not that the config was ignored. The error clears on the
next reload that succeeds.