Recently the algorithms have been blessing me with many impressive shader demos. I was staring at my dull and uninteresting login screen on my Linux computer when it occurred to me that I could use the effects people post at login. So I built toomuch, a greeter that draws the whole login screen with fragment shaders.
This post is about how I made the greeter and the problems I ran into along the way. For example, how do I draw when I have no compositor?
If you just want to play with it, go straight to the playground, where you can write shaders for it in the browser. I built the playground primarily because it’s an easier way to change effects than restarting your computer or running the greeter, and also because it’s easier to show people.
What is a greeter?
We’re going to use greetd to make this simpler. greetd is a login manager daemon. It doesn’t draw anything itself. It starts a program called a greeter, and the greeter’s job is to ask the user who they are, pass that along, and ask greetd to start a session. The two talk over a Unix socket whose path is in $GREETD_SOCK.
The protocol is small. Each message is a 4-byte length in native byte order followed by a JSON payload. The greeter only sends four kinds of request:
/// Sent by the greeter (client) to greetd (server).
#[derive(Deserialize, Serialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Request {
CreateSession { username: String },
PostAuthMessageResponse { response: Option<String> },
StartSession { cmd: Vec<String>, env: Vec<String> },
CancelSession,
}It gets three kinds of response back: success, error, or an auth_message, which is greetd asking a question (usually “Password:”) on behalf of PAM. With serde’s internally tagged enums the JSON falls out for free, and sending a message is just:
pub fn send<T: Serialize>(stream: &mut UnixStream, msg: &T) -> io::Result<()> {
let payload = serde_json::to_vec(msg).expect("serialisation failed");
let len = payload.len() as u32;
stream.write_all(&len.to_ne_bytes())?;
stream.write_all(&payload)
}A login is then: create_session with the username, answer each auth_message until you get success, then start_session with the command to run. Secret and visible prompts get the password, info messages get an empty response, and an error message cancels the session.
I didn’t want to log out of my machine every time I changed a line, so the first commit was a fake greetd (src/bin/greetd.rs). It listens on /tmp/greetd-fake.sock, accepts the password password and pretends to start a session. Paired with a plain terminal greeter (src/bin/greet.rs), it let me get the protocol right before any graphics existed.
How Linux actually draws things
Despite writing a fair bit of OpenGL, this is the part I knew the least about, so here’s an abridged version of what I learnt.
On a normal desktop, nothing you run draws to the screen. Your browser renders into a buffer of its own, usually on the GPU through Mesa’s OpenGL or Vulkan drivers, and hands that buffer to the compositor (your Wayland compositor, or the X server). The compositor stacks every window’s buffer into one final image, and that’s what goes to the kernel:
app (GL/Vulkan via Mesa)
-> renders into its own buffer
compositor (Wayland or X)
-> composites all the buffers into one frame
kernel DRM/KMS (amdgpu, i915, nouveau, ...)
-> points the display engine at that frame
display engine
-> reads it out of memory line by line, every refresh, and sends it to the monitorDRM (Direct Rendering Manager) is the kernel’s GPU subsystem. It manages GPU memory and takes rendering work from userspace. KMS (kernel mode setting) is the part of it that deals with displays: which outputs are on, at what resolution, and which buffer each one shows. Changing what’s on screen is mostly just telling KMS “show this buffer now” and waiting for the next vblank, the gap between refreshes where it can swap without tearing.
Only one process at a time is allowed to do that, the DRM master. On a desktop that’s the compositor. Even the text console you get on Ctrl+Alt+F3 is the kernel drawing into a buffer and asking KMS to show it.
Drawing without a desktop
A greeter runs before anyone has logged in, so there’s no compositor and no window to draw into. toomuch skips the middle of that diagram: it renders with GL into its own buffers, becomes the DRM master and talks to KMS directly. When you log in it exits and gives the screen back, and your session’s compositor takes over.
KMS has a few objects you need to know about:
connector = the output itself (HDMI-A-1, eDP-1, DP-1)
encoder = the block that drives a connector, and decides which CRTCs can feed it
CRTC = the scanout engine: reads a framebuffer and produces the display signal
mode = resolution and refresh timing, like 1920x1080@60CRTC stands for Cathode Ray Tube Controller
The rough sequence is:
- Open
/dev/dri/card*and find a connector that has something plugged in. - Take its first mode (usually the preferred one), then go through its encoders to find a CRTC that can drive it.
- Create a GBM device and surface on the card. GBM allocates buffers that the GPU can render into and the display can scan out from.
- Set up EGL on top of GBM and create an OpenGL ES 2 context.
From there, every frame is: render with GL, swap buffers, lock the buffer GBM just finished, wrap it in a DRM framebuffer, and show it.
pub fn present(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.egl_api.swap_buffers(self.disp, self.surf)?;
let bo = unsafe { LockedFrontBuffer::lock(&self.gbm_surf) }?;
let fb = self.gbm.add_planar_framebuffer(&bo, FbCmd2Flags::MODIFIERS)?;
if self.current_bo.is_none() {
// first frame: modeset
self.gbm.set_crtc(self.crtc_h, Some(fb), (0, 0), &[self.conn_h], Some(self.mode))?;
self.current_bo = Some((fb, bo));
return Ok(());
}
self.gbm.page_flip(self.crtc_h, fb, PageFlipFlags::EVENT, None)?;
wait_for_page_flip(&self.gbm, self.crtc_h)?;
if let Some((old_fb, _old_bo)) = self.current_bo.replace((fb, bo)) {
self.gbm.destroy_framebuffer(old_fb)?;
}
Ok(())
}(Error mapping, logging and the fallback for when add_planar_framebuffer fails are trimmed. More on that below.) The first frame does a full modeset with set_crtc. After that it’s page_flip, which swaps buffers on the next vblank, and we block until the kernel sends the flip event. That doubles as frame pacing: the main loop runs once per refresh without me writing a timer. Holding on to the previous buffer until the flip lands matters too. Release it early and GBM can hand it back for rendering while it’s still on screen.
When the greeter exits, Drop puts the original CRTC config back so you aren’t left staring at a frozen frame.
A few things bit me here:
eglGetDisplaywith a GBM device is folklore. Lots of examples pass agbm_device*straight toeglGetDisplay, and it works on most Mesa drivers. It broke on virgl in my VM. The correct way iseglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_KHR, ...), which tells EGL what kind of native handle it’s getting.- The EGL config has to match what the display can scan out. An EGL config can be perfectly happy rendering in a format the primary plane can’t display. I ended up asking DRM which formats the plane supports and picking the first EGL config whose native visual is in that list.
/dev/dri/card0isn’t guaranteed to be the display. On machines with more than one GPU it might becard1, so toomuch tries each card until one has a connected output. You can also setTOOMUCH_DRM_CARD.EINVALmeans your buffer description is wrong. My first attempt atadd_planar_framebufferwith modifiers just returnedInvalid argument (os error 22). The modifier path is much fussier about format, stride and modifier than the legacy one, and the kernel won’t tell you which part it didn’t like. Printing the buffer object’s width, height, format, stride and modifier helped more than anything else. toomuch now tries the modifier path first and falls back to the legacyadd_framebuffer, which only needs a depth and bits per pixel.
Keyboard input
No compositor also means no keyboard events. Input comes from evdev instead. toomuch scans /dev/input/event*, keeps anything that has A, Z, Enter and Backspace keys (a fairly reliable test for “is a keyboard”), and sets them non-blocking so reading them never stalls a frame.
The keyboard module tracks shift and caps lock, and turns raw key codes into a handful of events: a character, delete, submit, or go back to the previous field. Keymaps are something I’ve quietly ignored. It assumes a US layout.
The login state machine
The login flow itself is a small state machine that knows nothing about graphics or greetd:
pub enum LoginState {
UsernameInput { username: String },
PasswordInput { username: String, password: String },
Authenticating { username: String },
StartingSession { username: String },
Failure { username: String },
Success,
}update takes the state and an event and returns the new state, plus optionally an action for the outside world to perform. Submitting a password moves to Authenticating and returns LoginAction::Authenticate:
(LoginState::PasswordInput { username, password }, LoginEvent::Submit) => {
LoginTransition::change_with_action(
LoginState::Authenticating { username: username.clone() },
LoginAction::Authenticate { username, password },
)
}The main loop hands that action to a worker thread, which does the blocking socket conversation with greetd and sends the result back on a channel. greetd’s answer becomes another event, AuthAccepted or AuthFailure, and goes through the same update.
Keeping it pure paid off twice. It was easy to reason about, and when I later compiled the whole thing to WebAssembly the state machine came along without a single change.
The main loop is then just: drain greetd results, read the keyboard, tick, draw, present.
The shaders
Now the fun part. Each frame goes through up to four passes:
- Background: a fragment shader drawn into an offscreen texture. This is where the orbs, galaxies and whatever else go.
- Effect (optional): a second shader that gets the background as a texture and can do whatever it likes with it.
- Text: the prompt, drawn into the same texture with a font atlas built from font8x8 at startup. Every glyph is 8x8 pixels, scaled up. Very chunky, which is exactly what I wanted.
- Post: a final shader that reads the composited texture and writes to the screen.
The passes get the same set of uniforms: u_time, u_resolution, u_aspect, and the two I’m most pleased with, u_login_state and u_state_time. The state is the state machine’s variant as an integer (0 for username input through to 5 for success), and u_state_time is how long we’ve been in it. That means a shader can react to the login itself.
The default post shader is a CRT: slight barrel distortion, chromatic aberration that grows towards the edges, a cheap glow, scanlines and a vignette. Every so often a burst makes the picture wobble. When u_login_state is the failure state, it adds this:
float failure_hit = 0.0;
if (u_login_state == 4) { failure_hit = exp(-u_state_time * 2.8); }
float glitch_band = floor(uv.y * 48.0);
float glitch_tick = mod(floor(u_time * 18.0), 64.0);
float glitch_gate = step(0.72, hash(glitch_band * 17.0 + glitch_tick));
float glitch_offset = (hash(glitch_band * 61.0 + glitch_tick * 7.0) - 0.5) * 0.09 * failure_hit * glitch_gate;
uv.x += glitch_offset;The screen is cut into 48 horizontal bands, and roughly a quarter of them get shoved sideways by a random amount, re-rolled 18 times a second. failure_hit starts at 1 the moment the login fails and decays to nothing in about a second. Further down, the same value turns up the chromatic aberration and tints everything red. So the screen tears, flashes red and settles back down. It’s a deeply unnecessary amount of work to tell someone they mistyped their password, which is the point.
The effect slot is where the ASCII filter lives. At startup the greeter renders all 95 printable ASCII characters into a strip, sorted by how many pixels each glyph lights up, and passes it in as u_aux_tex. The shader splits the screen into 16x16 cells, samples the background’s brightness at the centre of each cell, and picks the character with the matching density. Dark areas become . and :, bright ones become @. Run it over a moving background and the whole login screen turns into animated ASCII art.
Everything is configured with a TOML file. Each pass takes either a path to a GLSL file or the source inline, and anything you leave out falls back to the built-in defaults:
[background]
path = "/etc/toomuch/galaxy.glsl"
[background.effect]
path = "/etc/toomuch/ascii.glsl"
[input]
font_size = 72
username_prefix = "Hello "
cursor = "_"The prompt text is configurable too. The username and password prefixes and the cursor character all come from [input], so it doesn’t have to greet you with “Hello”.
A playground in the browser
Writing shaders by logging out, failing to log in and squinting at the result is a slow feedback loop. Everything above the display is GL ES 2 through glow, and glow also targets WebGL, so almost all of the crate compiles to WebAssembly unchanged. The DRM, evdev and greetd modules sit behind #[cfg(not(target_arch = "wasm32"))]. A small web.rs swaps in a canvas, browser key events and requestAnimationFrame. The clock was the only other thing that needed a wasm version, because std::time::Instant isn’t available in the browser.
The playground has a text area for each of the three shaders and a render button that recompiles them, with any compile errors shown underneath. The state lives in the URL, so you can share a link to exactly what you’ve made. Presets are stored as a reference to the file and anything you’ve edited is base64’d in, which keeps links to unmodified presets short. It’s built with wasm-pack and deployed to GitHub Pages by an Action on every push to main.
There’s no greetd in the browser, so the playground fakes it. Log in as user with the password password to see the success path, and type anything else to see the glitch.
It also comes with a few background shaders to start from, some of them ported from the demoscene and Shadertoy: Danilo Guanabara’s “Creation”, nimitz’s “Protean clouds” and one of Shane’s, with credit in each file. The effect slot has two presets, the ASCII filter and a Kuwahara filter that makes everything look painted.
Running it for real
Point greetd at it in /etc/greetd/config.toml:
[default_session]
command = "/usr/local/bin/toomuch"
user = "greeter"The greeter user needs access to /dev/dri and /dev/input, which usually means the video and input groups. toomuch reads its config from /etc/toomuch/config.toml and falls back to the built-in shaders if it isn’t there.
What’s missing
Plenty. It’s a toy that happens to log me in.
- The session command is hard-coded to
/bin/zsh. There’s no session picker. - US keyboard layout only, and no Unicode input.
- Only one output. With two monitors, only one of them gets a login screen.
- The config has a background
kindwithcolor,imageandvideooptions, but none of them are wired up yet. It’s always a shader.
But it does log me in, and when I get my password wrong the screen glitches, which was the whole point.