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)

NameTypeDescription
v_coordsvec2Normalized position within the output, 0.0–1.0
sizevec2Output dimensions in pixels (e.g. 1920.0, 1080.0)

Custom (provided by driftwm)

NameTypeDescription
u_cameravec2Canvas→screen offset in canvas pixels (viewport's top-left)
u_zoomfloatCanvas→screen scale (1.0 = unzoomed, >1 zoomed in, <1 zoomed out)
u_timefloatSeconds 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; where scale controls the feature size (smaller = larger features).
  • Float precision: u_camera can be large (thousands of pixels from origin). If your shader uses mod() or fract() on canvas coords, reduce first: mod(u_camera, period) instead of mod(canvas, period). See extras/wallpapers/dot_grid.glsl for an example. Noise-based shaders using floor()/fract() internally are naturally resilient since the hash functions wrap.
  • Animated shaders: u_time gives seconds since compositor start, enabling time-driven animations. driftwm re-renders every frame when a shader uses u_time, unless animate_fps caps the rate.
  • Zoom-aware shaders: declare uniform float u_zoom; to react to viewport zoom. Common pattern: divide canvas-pixel sizes by u_zoom to 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 const values at the top of your shader. This keeps everything in one file — no config round-trip needed.
  • Shipped examples: extras/wallpapers/ holds dot_grid.glsl alongside static/ (blue_drift, compass_grid, dark_sea, pink_cloud), animated/ (acid_lava, dense_clouds, fast_smoke), and textured/ (mirrored_parallax, ripple). make install copies 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:

NameTypeProvided byDescription
texsampler2DsmithayThe configured image. Sample with texture2D
v_coordsvec2smithayNormalized position within the output, 0.0–1.0
u_texture_sizevec2driftwmImage dimensions in pixels
u_output_sizevec2driftwmViewport dimensions in pixels (= output / zoom)
u_cameravec2driftwmCanvas→screen offset in canvas pixels
u_zoomfloatdriftwmCanvas→screen scale
u_timefloatdriftwmSeconds since compositor start

Notes on the texture path:

NameNote
sizeNot 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.
alphaAlways 1.0 for backgrounds. Transparency comes from the shader's own output alpha plus transparent_shader = true.
cache_shaderNo 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:

KeyEffect
mirror_tiletile mode: mirror-fold the image so a non-seamless edge always meets a reflection.
cache_shaderBake a static camera-only shader to textures and pan those. No effect with transparent_shader or a texture.
transparent_shaderHonor a shader's output alpha (see below).
cache_budget_mbMemory ceiling (MB) for the bake and gigapixel-TIFF chunk caches. Default 128.
animate_fpsFrame-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 (as dot_grid does) stays valid; a raw vec4(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 images
  • swww / wpaperd — animated wallpapers and transitions
  • mpvpaperlive 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:

  • path is 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.