Under the hood

What each piece changes, where I think it could go, how far it is, and what X still limits.

On this page: who owns what, the backlog, late frames on NVIDIA, fewer waits on X, getting the picture right, tried and dropped, what X still limits. Status as of 8 November 2026. The code was written with AI; see a note before you read on.

Who owns what

The work is split by who I think should own each part. That is my view; each project decides for itself.

  • picom: only general mechanisms that know nothing about any window manager: variables from plugins, animation triggers, script outputs, pictures of hidden windows, and how frames are drawn and timed.
  • i3, or another window manager: only what is its own job. So far the animations need no change in i3.
  • The plugins hold what the window manager knows, read from outside: workspaces and monitors (ws-direction, from EWMH properties), and why a window moved (win-cause, from X events and i3). That stays in the plugins.
  • The styles are the look, as plain picom animation scripts.
  • The user owns one driver setting, OpenGL flipping on NVIDIA, and picom's backend: the egl backend gets the GPU priority described below.

The fork numbers its plugin API on its own, from 0.2 to 0.14. If picom takes a call, it numbers it itself, so these numbers are only the fork's.

The backlog

Each item has two sides: what makes it work today, and where it could go. What I'd like to offer picom is the workspace transitions and the fixes: one or two at a time, fixes before features, at picom's pace, starting with the plugin variables, since they decide how plugins work at all. The machinery of the window animations stays in the fork. Much of it exists for one window manager's sake, so whether picom wants it is an open question.

Nothing here goes up until the one before it has been discussed. picom may decline any of it; then it stays in the fork. How the code was written is in a note on the home page.

Status: merged in picom or i3. open posted. ready written and tested, not posted yet. in fork in the fork's install branch. branch built, not in the install branch yet. parked built and kept on a branch, not proposed. dropped tried and let go.

ChangeWorks today throughCould go asStatus
Workspace transitions: the core
Plugins add variables to animation scripts. The base of both plugins. The fork. picom #1550. open
xrender: clip the target, not the source, when copying an area. The fork, or the glx backend. picom #1551. open
Three bugs in script expressions: an unclosed ( crashed picom while reading its config, numbers were folded wrong (2 - 1 gave 3), and a misspelled name with a minus gave a confusing error. The fork. One PR, three commits, each with a test. ready
Scripts get functions: sin, cos, min, max and more. For the 3D styles. The fork. A picom PR, after #1550 and the expression fixes. ready
Scripts can read the state an interrupted animation left, so a change during a change keeps its speed: the value and speed of each variable, a curve that starts at a given speed, and a spring. Every workspace switch style uses it. The fork. A picom PR of three commits, after the functions. ready
Windows in perspective: a blit through a mesh, script outputs for a window's corners, its shadow, blur and frame opacity while turned, depth order, and dimming. For the cube, flip and unfold switches, and the 3D window styles. The fork. Three picom PRs: perspective, with a new backend call for the mesh; then depth order. Dimming stands alone. ready
Workspace transitions: the overview and passing workspaces
Pictures of hidden windows (a rule option keeps a window's last picture while it is hidden), animations a plugin or a D-Bus call starts, a held or paused screen, and an animation that holds its last pose. Plugin plumbing: a wakeup for the plugin's own X connection, which window is drawn at a point (clicks in the overview), and whether picom keeps a hidden window's picture. For switches that pass workspaces, the cube's other sides, and the overview. The fork. Twelve picom PRs, one per part, after the core. If picom takes a plugin call, it numbers it itself. ready
Fixes found along the way
Fix the search for plugins named in the config file. picom. picom #1547. merged
Check the result of an animation's first evaluation. picom. picom #1549. merged
A window mapped again during its hide animation blinks. On NVIDIA it shows the wallpaper for a few frames. picom now keeps the old picture until the window has redrawn. The fork. One PR, with a small change to picom's test runner first. One known gap to close before it could be offered. ready
A move with no script for it ended the window's running animation, so i3 placing a new window cut its open animation short. The fork. One PR with its test. ready
A window whose program closes before i3 takes down its frame lost its rules for a moment, and with them its close animation: about one close in twenty on a test X server. The fork. One PR with its test. ready
Crop edges rounded to the nearest pixel, and a crop that starts left of or above the screen crops its far edges too. The fork. Two small PRs with tests. ready
Frame timing: one late vblank no longer resets the estimate, Present events without a timestamp (NVIDIA) are skipped, and animations advance by when frames are shown. The fork. Two small PRs. ready
Find a plugin by name where packages install plugins. The fork. A follow-up to #1547. ready
With xrender, picom waited for the X server twice for every copy it drew, only to be able to log an error: up to 454 waits per frame with 16 windows on a test X server. It now sends those requests without waiting, and still logs errors: 2 waits per frame. The fork. One small PR. ready
A backend call that binds a window's picture when picom already knows its size, without asking X for it. The base for binding a resized window's picture without stalling the frame; the rest of that sits with the window animations, as it builds on them. See below. The fork. A picom PR with a new optional backend call. ready
Wait for the X server only when it may have drawn since the last wait. See below. The fork. A picom PR. Its test reads the frame timing log, so it goes after that, or gets a test of its own. Then let the GPU do the wait, where the driver can. ready
A high-priority GPU context in the egl backend, so picom's frames don't queue behind a program's drawing. See below. The fork, with backend = "egl". A small picom PR. ready
A frame timing log and a tool that reads it, and what each frame draws. Everything on this page was measured with it. The fork. It is large, so first ask whether picom wants it. ready
Window animations: in the fork, upstream an open question
Outputs for the tile style: the size of a window's picture, a fill and a backdrop, the mean, median or edge colour of its content, its frame kept on a crop, an old picture at its own size, keeping the stacking while windows pass, and a crop that lies in a turned window's plane. The fork. Open. in fork
Bind a resized window's new picture without stalling the frame: name it without waiting, bind it once X has confirmed its size, keep the old one bound meanwhile. See below. The fork. Open. Only its backend call is among the fixes, above. in fork
Knowing when a window's new picture is drawn: drawing done before picom saw the resize counts too, and while an animation shows an old picture, picom asks the program to draw again, in every window inside it. See below. The fork. Open. in fork
An animation that waits: while a script's wait output is set, its time doesn't run, so an open starts when the window has a picture with content. A plugin can make a window's animation wait too, so the windows that make room for an open start with it. The fork. Open. in fork
Plugin calls for window animations: the X events picom reads, renaming a window's move, show or hide, and reading what a window's animation sets. The fork. Open. in fork
Plugin calls added for live resize: a lease on an animation, a mark that a window has drawn, and where an animation will draw a window a moment ahead. Parked with live resize, on a branch of the fork. Not proposed: live resize is parked. parked
Zoom for the tile style, and keeping windows at the same depth in their stacking order. No style uses them now. Parked on a branch of the fork. Not proposed. parked
A picture worker: a thread that binds and frees window pictures, so no frame waits for one. Built and measured: the binds left the frame, but the freeze at the start of a move stayed, because the GPU was the larger cost. See below. A branch of the fork. Not proposed for now. parked
i3
i3's main loop handles the events it reads while flushing, which made one of i3's tests fail now and then. Found along the way; the animations don't need it. Not needed here. i3 #6664. open
A draw_at command and a window::resize event, for live resize. See below. Not used. Not proposed. dropped
The user's setup
Turn NVIDIA's OpenGL flipping off, with monitors at different rates. See below. A driver setting. Nothing to offer: it is how the driver works on X. picom's tracker has the same symptom (#1027). setting
Stays in the plugins
Workspace order, monitors, which way a switch went, and the overview with its clicks (ws-direction). The plugin, from EWMH properties. Stays in the plugin. It works with picom once the API it uses is there. plugin
Why a window moved, and where a window that opens or closes splits from its neighbours (win-cause). The plugin, from X events and i3. Stays in the plugin. plugin

Late frames on NVIDIA

Measured on my machine: NVIDIA's 595 driver, one X screen over two monitors (3440x1440 at 175 Hz, and 1080x1920 at 60 Hz), picom's glx backend, with wezterm and Chrome tiled and moved six times. A frame counts as late when it comes more than 20 ms after the one before.

Binding a window's picture

To draw a window, picom binds its pixmap, the window's picture in the X server, to an OpenGL texture. The texture must hold all drawing done before the bind, so on NVIDIA the bind waits for the program's GPU work. For a fresh pixmap that took 2.4 to 3 ms. After the program drew into it: 5.6 ms for wezterm, 9.6 ms for Chrome, and up to 41 ms. A window that resizes gets a new pixmap at every size.

picom used to name, copy and bind the new pixmap inside its event handler, about 11 ms per size. The fork now names it without waiting, binds it once X has confirmed its size, and keeps the old one bound meanwhile, so nothing is copied. With flipping off, late frames went from 10.4–11.3% to 8.9–10.3%, and the worst gap from 77–89 ms to 55–74 ms.

Keeping two pixmaps bound may look at odds with a rule picom made for NVIDIA in 2019. That rule is about one pixmap with two names; a resized window's new pixmap is a different pixmap. On screen, all 257 pictures bound this way matched the X server's.

The bind still costs what it costs; it only moved. The picture worker was the next try: a thread that binds while the frame is drawn. In a separate test program, it took late frames during binds from 99% to 4%, and the worst gap from 66 to 34 ms. In picom it took the binds out of the frame, but the freeze at the start of a move stayed: the GPU, below, explains why. The worker is parked on a branch.

OpenGL flipping and two refresh rates

With the driver's OpenGL flipping on, picom's buffer swaps waited for the 60 Hz monitor, although picom synced to the 175 Hz one: the blocked swaps ended on a 16.7 ms grid. picom's own vsync setting didn't change that. 170 of 385 swaps blocked (11 ms median), frames came every 16.2 ms (median), and 25.7% were late.

With flipping off (nvidia-settings -n -a AllowFlipping=0): 28 of 613 swaps blocked for more than 2 ms, frames came every 5.7 ms, and 9.9% were late. It is a driver setting for every OpenGL program, so it is the user's choice, not a change for picom.

The GPU, not the bind

Moving Chrome between monitors still froze the first frames of the move: the first frame came 32 to 54 ms after the one before, so the animation, which runs on the clock, skipped that much motion. The picture worker took the binds out of picom's thread (the frame's picture step went from 13–22 ms to under 0.4 ms), yet the freeze stayed. What was left were waits on the GPU, busy with the program's own redraw at its new size.

A GPU context can ask for a higher priority, as Wayland compositors do. GLX has no way to ask; EGL has EGL_IMG_context_priority, which NVIDIA's driver offers and grants. Motion skipped at the start of each move, in ms:

  • glx: 51, 50, 34, 32, 54, 6
  • egl at normal priority: 34, 62, 6, 34, 29, 22, 67, 6
  • egl at high priority: 34, 17, 24, 17, 9, 23

About 20 ms is left, mostly X event handling. The egl backend draws with the same code as glx, so the styles work the same on both.

Fewer waits on X

A benchmark on a virtual X screen changes 1 to 16 windows at once and times every part of picom's frames, and every wait for an answer from X. In the cleanup before publishing, code that waited once per window was changed to wait once per pass:

  • first frame of a change: 23–38 ms down to 3.6–12.6 ms on xrender, 29–54 ms down to 17–31 ms on glx;
  • start of a workspace switch on xrender: 20–32 ms down to 4–14 ms;
  • blocking answers from X in a steady xrender frame: up to 454 at 16 windows, now 2 at any number.

On a virtual screen an answer from X takes microseconds; on a busy real one it can take milliseconds, so the counts carry over and the times only roughly. What is left is picom's own: about 9 to 14 answers per window, waited for one after the other, when windows map or a change starts. Sending those requests first and reading the answers once per pass would be the next fix.

A round trip to X every frame

Every frame, picom sets an X Sync fence, waits on it, and then waits for the X server to answer. That makes sure X has finished the drawing picom is about to use. It isn't timed on its own: what is left of the draw step after its timed parts was 3.8 to 5.8 ms (median) in late frames, and up to 15.8 to 41 ms.

A branch of the fork waits only when an X event came since the last wait. picom learns of all X drawing it uses through events, so the wait still guards the same drawing. The longest wait fell from 26–29 ms to 7–12 ms, and the total wait by 40 to 60%. Late frames stayed the same in the usual scene (9.1%) and fell from 2.4% to 1.3% in a heavy one. Next: let the GPU wait instead of picom, where the driver has GL_EXT_x11_sync_object, so no answer from X is needed.

Getting the picture right

When a window gets a new size, i3 resizes it at once and the program draws its new picture a moment later. Until then picom shows the old picture at its own size. The 3D styles switch to the new one at a moment it can't be seen, a window seen edge on, but only if picom knows the program has drawn. Four ways that went wrong, each found on the recordings and fixed with a test that failed first:

  • A fast program. Chrome drew its new size before picom handled the resize, and that drawing was thrown away with X's own damage from the resize. picom now keeps what came after the resize, and asks the program to draw again while it shows the old picture.
  • A window inside a window. That request went to the program's outer window, but wezterm draws into, and listens on, a window inside it. After it shrinks, wezterm draws only when asked, so it kept its old picture. picom now asks every window inside the program's window, as the X server would.
  • The first picture. A new wezterm's first frames are fully transparent; its content comes about 0.2 s later. The open ran on the clock meanwhile, so half of it was over before anything showed. An open now waits for a picture with content, and the windows that make room wait with it.
  • Covered, not gone. i3 hides the other windows of a workspace while one is fullscreen. picom saw a plain hide and a short fade. win-cause now names those hides and shows, and the styles keep the windows in place under the one that grows over them.

Tried and dropped

Live resize

The idea: during an animation, give each program its real size step by step, so it lays out its content for the size it is shown at, not for the end size from the start. I built it in win-cause, with each program paced by how fast it draws, and with an i3 patch: a draw_at command that draws a window at a given rectangle without changing i3's layout, and a window::resize event. It worked, with tests.

But on X, every size in between costs the program a redraw and picom a new picture bind. That shows as stutter, and as pictures that lag behind the box. One resize at the start, with only the box animating, is much smoother. So live resize is parked on a branch of its own, with the plugin calls it needed, and the i3 patch is not proposed.

Reading the window's frame

Another way to get a resized window's picture: read the window manager's frame with GetImage and bind it later. Each bind then came after the program drew, the slow case above, and late frames didn't improve (23% against 22%). The design above replaced it.

What X still limits

  • X has no signal that a program's GPU work on its window is done. DAMAGE says the X server handled the drawing, not that the GPU finished it.
  • GLX can't bind a pixmap without waiting for that work. A thread can keep the wait out of the frame, but the GPU still does the work in order.
  • Without GL_EXT_x11_sync_object, picom waits for an answer from X before it draws a frame in which X may have drawn.
  • On one X screen, monitors at different rates can share buffer swaps. On my machine, with flipping on, the 60 Hz monitor set the pace.
  • A program resized during an animation redraws at every size, so step-by-step resizing can't be smooth.
  • Nothing tells picom when a program's picture has content. picom looks at the picture itself, for the moment it stops being fully transparent.
  • GLX can't raise the priority of picom's GPU work; EGL can, where the driver offers it.