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.
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.