A rain — or snow — animation over the desktop wallpaper on Wayland. Written in C; the
window is created with the wlr-layer-shell protocol, so it works on
Hyprland, sway and other wlroots-based compositors — see
Compatibility.
There are two drawing backends. GPU (EGL and OpenGL ES 2) is the default
and roughly eight times lighter on a 4K display. CPU (cairo plus a custom
blitter, drawing into shared memory) is the fallback: it takes over
automatically when EGL is unavailable, and --cpu forces it.
By default the surface sits in the bottom layer: above the wallpaper but below every window. Its input region is empty, so pointer clicks and keyboard events pass straight through to the desktop.
- Depth: drops vary in speed, length, thickness and opacity, which produces a parallax effect.
- Wind and gusts: the slant varies smoothly as the sum of two sine waves at different rates.
- Splashes along the bottom edge.
- Snow mode (
--snow): round flakes that fall at a tenth of the speed and drift sideways on their own sine, so no two take the same path down. The wind carries them rather than slanting them, since a flake barely falls. - Multi-monitor support — each output gets its own surface, and HiDPI scaling
is read from the output's
wl_outputscale. - Outputs can be plugged and unplugged while running; surfaces are created and torn down automatically.
- The rain holds still while a fullscreen window covers the output, which lets
the compositor scan that window out directly — see GPU load.
--no-pauseturns this off. - A tray icon switches the rain on and off with one click, where the desktop
provides a tray. Right-clicking it opens a menu to pick rain or snow, toggle
the rain and quit; the mode switches live, with no restart.
--no-trayleaves it out.
What rainhypr needs at runtime is short:
zwlr_layer_shell_v1— required. Without it the program printsthe compositor does not support wlr-layer-shelland exits. This one global decides whether rainhypr runs at all.wl_compositor,wl_shm,wl_output— required, at any version.wl_compositor4 buyswl_surface_damage_buffer; older versions fall back towl_surface_damage.zwlr_foreign_toplevel_manager_v1version 2 or later — optional, and only for the fullscreen pause. Fullscreen is not among the states before version 2. If it is missing the rain runs exactly as usual, it just keeps drawing under fullscreen windows.- EGL and OpenGL ES 2 — optional. Without them the CPU backend takes over on its own.
- A StatusNotifierWatcher on the session bus — optional, and only for the
tray icon. Wayland has no tray protocol of its own; the icon is a D-Bus
object, so it appears wherever a shell implements the watcher (Waybar, KDE,
Quickshell-based shells,
snixembed) and is quietly skipped where none does. The right-click menu is a second object oncom.canonical.dbusmenu, which most but not all hosts drive; where one does not, left click still works.
xdg-shell is not needed at runtime — rainhypr never binds xdg_wm_base.
It is a build-time dependency only, because the layer shell XML refers to
xdg_popup in its get_popup request.
| Compositor | Rain | Fullscreen pause |
|---|---|---|
| Hyprland | yes — tested, 0.55.4 | yes — tested |
| sway, Wayfire, river, labwc, dwl, Cage, hikari | yes, layer shell is native to wlroots | yes, the wlr toplevel protocol ships with it |
| niri, cosmic-comp (Smithay) | yes, both implement layer shell | unverified — both have toplevel protocols of their own, which may or may not include the wlr one |
| KWin / Plasma Wayland | likely, KWin implements layer shell | unlikely, KWin uses its own plasma-window-management |
| Mir-based (Ubuntu Frame, Miriway) | likely, Mir implements layer shell | unverified |
| GNOME / Mutter | no — Mutter does not implement layer shell and does not intend to | — |
| Weston | no — its own weston-desktop-shell instead |
— |
| i3, bspwm, awesome, dwm, xmonad, openbox | no — X11 window managers | — |
Only the Hyprland row is measured; the rest follows from which protocols each compositor implements, so treat the unverified rows as such. The X11 row is not a gap to be filled: rainhypr is a native Wayland client, layer shell has no X11 equivalent, and XWayland does not help because XWayland clients cannot speak Wayland protocols.
Checking a machine takes a second:
wayland-info | grep -E 'layer_shell|foreign_toplevel'zwlr_layer_shell_v1 in the output means the rain will run;
zwlr_foreign_toplevel_manager_v1 at version 2 or 3 means the pause works too.
wayland-info comes from wayland-utils on Arch and Debian/Ubuntu.
Dependencies: a C compiler, pkg-config, wayland-scanner,
wayland-protocols, and the development headers for wayland-client,
wayland-egl, cairo, EGL and OpenGL ES 2.
Arch:
sudo pacman -S base-devel wayland wayland-protocols cairo mesaDebian / Ubuntu:
sudo apt install build-essential pkg-config libwayland-dev libwayland-bin \
wayland-protocols libcairo2-dev libegl-dev libgles-devOn releases older than Debian 11 / Ubuntu 20.04, libegl-dev and libgles-dev
are named libegl1-mesa-dev and libgles2-mesa-dev.
Then:
makeIf a dependency is missing, make says which one and which package provides
it rather than failing on a missing header.
One dependency is optional: the tray icon talks D-Bus through sd-bus, so the
build looks for libsystemd (systemd-devel on Fedora, libsystemd-dev on
Debian/Ubuntu, part of systemd on Arch). Where pkg-config does not find it,
src/tray.c compiles to nothing and the binary is the same as before minus the
icon — nothing else changes and no flag is needed.
The wlr protocol XMLs — layer shell and foreign toplevel management — ship in
protocols/, so no separate wlr-protocols package is needed. xdg-shell.xml
comes from wayland-protocols; the Makefile finds it via pkg-config and falls
back to the usual system prefixes. If it lives somewhere else, point at it
directly:
make WL_PROTO_DIR=/path/to/wayland-protocolsInstalling (optional):
sudo make install # /usr/local/bin/rainhypr./rainhypr| Option | Meaning |
|---|---|
-n, --drops N |
number of drops (default derived from density) |
-D, --density N |
drops per megapixel, default 400 |
-s, --speed N |
speed multiplier, default 1.15 |
-w, --wind N |
wind slant −1…1, default 0.12 |
-g, --gust N |
gust strength, default 0.35 |
-a, --alpha N |
opacity 0…1, default 1.0 |
-c, --color RRGGBB |
drop colour, default B8D1F2 |
-f, --fps N |
frame rate cap, default the output refresh rate; the main lever on GPU load |
-l, --layer NAME |
background, bottom, top or overlay |
-o, --output NAME |
restrict to this output, e.g. HDMI-A-2 |
-S, --no-splash |
no splashes |
--snow |
start in snow instead of rain: slower, drifting, white. Implies -S. Switchable later from the tray menu |
--cpu |
draw in software (the GPU is used by default) |
--scale N |
draw resolution: 1 = lightest, 0 = output scale (--cpu only) |
--damage MODE |
full, tiles or rects — see performance (--cpu only) |
--no-pause |
keep drawing under a fullscreen window |
--no-tray |
no tray icon |
--bench |
measure CPU drawing speed with no compositor involved |
Quit with Ctrl-C or SIGTERM.
# A subtle drizzle
./rainhypr -D 140 -s 0.7 -a 0.55 -w 0.05 -g 0.15
# Downpour with a crosswind
./rainhypr -D 700 -s 1.6 -w 0.45 -g 0.5
# Light load on a large 4K display: the fps cap is what the GPU feels
./rainhypr -f 20 -D 180
# Green "matrix" rain on one monitor only
./rainhypr -c 66FF99 -o DP-1
# Snow
./rainhypr --snow
# A heavy, still snowfall: many flakes, no wind to carry them
./rainhypr --snow -D 900 -w 0 -g 0.05In Hyprland's hyprland.conf:
exec-once = rainhypr
If you use a wallpaper daemon (hyprpaper, swww, swaybg, or your shell's own wallpaper), leave rainhypr in the bottom layer. The stacking order is then correct regardless of startup order.
Measured at 3840×2160 @60 Hz, scale 2, Intel Core Ultra 7 255H (integrated Arc
graphics), at 663 drops (-D 320). Percentages are shares of
one core, and Hyprland's own idle baseline (1.5 %) is included:
| Settings | rainhypr | Hyprland | total |
|---|---|---|---|
| defaults (GPU) | 0.5 % | 4.0 % | 4.5 % |
GPU, --fps 30 |
0.1 % | 4.0 % | 4.1 % |
GPU, -D 800 |
0.6 % | 4.3 % | 5.0 % |
--cpu |
15.3 % | 21.5 % | 37 % |
--cpu --fps 30 |
4.5 % | 4.6 % | 9 % |
--cpu --scale 1 |
2.0 % | 6.1 % | 8 % |
GPU drawing removes both our own rasterisation work and the 33 MB buffer
upload the compositor otherwise does every frame. The number of drops barely
matters there — -D 800 (more than double the density) costs the same.
In CPU mode the useful knobs, in order of effect, are --scale 1 (draws at
logical resolution and lets the compositor scale it up; streaks soften a
little), --fps 30, -D 160 and -S.
The breakdown of CPU drawing alone, with no compositor involved:
./rainhypr --benchThe figures above are CPU time and say nothing about the GPU, where a
full-screen surface is the whole story: the compositor has to blend 3840×2160
pixels over the wallpaper on every refresh, and it cannot scan the wallpaper
out directly while doing so. Measured as GPU render-engine utilisation
(drm-engine-render in /proc/<pid>/fdinfo) on the same display:
| Settings | rainhypr | Hyprland | total |
|---|---|---|---|
| nothing running | — | 5.8 % | 5.8 % |
| defaults (60 fps) | 0.4 % | 23.0 % | 23.4 % |
--fps 30 |
0.3 % | 20.2 % | 20.5 % |
--fps 20 |
0.3 % | 16.6 % | 16.9 % |
--fps 10 |
0.1 % | 12.8 % | 12.9 % |
| defaults, fullscreen window on top | 0.0 % | 1.8 % | 1.8 % |
That last row is what the pause is for. While a fullscreen window covers the
output rainhypr requests no frame callback and commits nothing, so the
compositor can scan that window out directly instead of blending the screen
every refresh: 23 % of the GPU becomes 2 %, less than an idle desktop with no
rain at all. Without the pause (--no-pause) a covered surface costs exactly
as much as a visible one — the compositor has no idea the drops are invisible.
Which window counts is decided over
wlr-foreign-toplevel-management-unstable-v1: fullscreen, not minimised,
activated, and on this output. Activation is in there because the protocol says
nothing about workspaces, and without it a fullscreen window parked on a hidden
workspace would stop the rain on the workspace you are actually looking at. The
trade is that a fullscreen window which loses focus lets the rain resume
underneath it, where it is invisible but no longer free.
Our own drawing is a rounding error, so --fps is the knob that matters — and
it works by leaving the compositor alone rather than by drawing less. Between
two frames rainhypr holds no frame callback and commits nothing, which lets the
compositor skip the whole redraw; a client that keeps a callback outstanding
makes it repaint the output every refresh whether or not anything changed. The
drop count barely moves the number here, the same as in CPU mode.
--damage tells the compositor which part of the surface changed. The options,
measured on the same machine:
| Mode | rainhypr | Hyprland | total |
|---|---|---|---|
full (default) |
15 % | 22 % | 37 % |
tiles |
17 % | 31 % | 48 % |
rects |
9 % | 90 % | 99 % |
Even though the rain touches only about 6 % of the screen area, merging hundreds of small regions is clearly more expensive for Hyprland than one full-screen region. Other compositors may behave differently, which is why the options are configurable.
--damage and --bench apply to CPU mode only; in GPU mode eglSwapBuffers
takes care of updating the surface.
src/rainhypr.h shared types and configuration
src/rainhypr.c Wayland setup, physics and CPU drawing
src/gpu.c EGL and OpenGL ES 2 drawing
protocols/ wlr-layer-shell-unstable-v1.xml
Makefile wayland-scanner code generation and the build
Double buffering: each surface allocates two ARGB8888 buffers from a single
wl_shm_pool, and the wl_buffer.release event says when a buffer may be
drawn into again. Timing comes from wl_surface.frame callbacks, so the
animation stays in step with the display refresh and costs no CPU while the
screen is off.
The straightforward implementation draws every drop as its own cairo stroke with its own gradient. Measured, that cost 19.6 ms per frame, about 30 µs per drop — almost all of it cairo's per-stroke overhead rather than pixel work.
Drops are now quantised into 16 depth buckets. Each bucket is drawn once with cairo into a finished small image, and the drops are copied to the screen by a custom premultiplied-alpha blitter that skips transparent pixels. A frame costs 16 cairo strokes instead of hundreds, and all strokes share one angle because horizontal velocity is always the same fraction of vertical velocity.
On top of that, each buffer remembers where it was drawn into last time, so clearing touches only those regions rather than the whole screen.
Measured effect (3840×2160, 663 drops, -D 320):
| Stage | before | after |
|---|---|---|
| clear | 2.26 ms | 0.22 ms |
| drops | 19.56 ms | 0.90 ms |
| splashes | 0.49 ms | 0.33 ms |
| total | 22.3 ms | 1.44 ms |
The drops go out as a single glDrawArrays call. All 16 depth buckets and the
splash arc live in one texture atlas, drawn once at startup with cairo at four
times the resolution. Drops sit in the atlas upright, and the wind slant is
applied by rotating the quad at the vertex level — which is why the atlas never
has to be rebuilt when the wind changes.
The atlas is premultiplied alpha, so blending is GL_ONE, GL_ONE_MINUS_SRC_ALPHA and opacity arrives as a vertex attribute that the
fragment colour is multiplied by.
Timing still comes from wl_surface.frame callbacks: eglSwapInterval is 0 so
that eglSwapBuffers does not block, and the frame callback is requested
before the swap so that both end up in the same commit.
If EGL is unavailable, the program reports why and switches to CPU drawing. The same happens if EGL surface creation or drawing fails later on: the surfaces move to shared memory mid-run and the rain does not stop.
