481. GPU layers are placed in paint order, and shown through a hole or read back
Date: 2026-09-24
Status
Accepted. docs/gpu-plan.md’s phase 4, D4 as built, and the rest of D5. It
brings the GpuSurface ADR-0479 moved to this phase, in a different shape:
there is no attach or detach, and no CompositionMode enum.
Context
A GPU layer is something the GPU draws into a window among what the CPU paints:
a canvas3d, a video on the GPU. Phase 3 composites a window with no layers,
and phases 5 and 6 need layers to exist. The plan’s D4 chose how they sit in the
z-order: the layer’s element clears its box in the CPU frame, a hole, and the
compositor draws the layer under the frame, so what was painted after the layer
is above it.
Four things the plan left open had to be settled against the code:
- Who knows the clip. A layer is scissored by the clips it is painted under,
a scroll view’s viewport above all. Blend2D will not hand its clip back, and a
canvaspainter, which is what places a layer, only has the frame. - Partial repaint. A frame that repaints only its damage walks only what the damage touches. A video beside a blinking caret is not painted on the caret’s frames, and places nothing, but it is still on screen. And a video the damage cuts across is painted under a clip that includes the damage, which is not a clip the video is seen through.
- The type split.
:coreplaces layers and cannot name:gpu’sGpuFrame, which is how a layer renders (ADR-0479). - Where a layer renders. A layer with depth and passes of its own cannot draw inside the window’s composite pass.
Decision
:core records opaque layers in paint order and punches their holes; :gpu
renders each into a texture of its own, and either composites that texture under
the frame or reads it back into it.
:core
-
render.GpuContentis an empty interface, an identity.:gpu’sGpuLayerextends it and says how it renders. -
Frame.gpuLayer(content, x, y, width, height)places one, in logical coordinates under the transform in force, and returns false when the frame cannot show it, so the painter paints a fallback. The box goes through the transform to its bounding box in physical pixels, each edge rounded to the nearest pixel. Its scissor is that box cut by the clips in force and the frame. What is placed is arender.GpuPlacement(content, target, scissor), andFrame.gpuPlacements()lists them in paint order. -
The frame mirrors its clip in Java, as it already mirrored its transform (ADR-0390):
clipTo,resetClip,saveandrestorekeep a physical-pixel rectangle beside Blend2D’s. A clip under a rotation is its bounding box, which is what Blend2D clips to as well. -
Frame.repaintOnly(x, y, width, height, body)confinesbodyto the region being repainted. It is a clip as far as pixels go, it outlastsresetClip(Blend2D’s reset returns to the last saved clip, and the region is saved), and it is not in the mirror, so it scissors no layer.RenderTree’s partial paint uses it, and keeps the damage out of the clips its walk sets: the damage is only what the walk culls against. -
render.window.GpuSurfaceis sealed, with two non-sealed kinds, which are the window’s composition mode:Composited: the frame clears the layer’s box to transparent black (SRC_COPY, in physical pixels), and the window’s next present draws the layer there;ReadBack:render(content, size)gives the layer’s pixels, and the frame draws them over the box, replacing what was there as the compositor’s quad would. Heap pixels are copied to direct memory and held until the frame ends, since a threaded context blits after the call returns.
Both are told, once a frame, what is on screen:
placed(layers). -
BackendWindow.gpuSurface()gives the surface for the frame about to be painted;Window.paintasks for it afteracquireFrame. Empty by default, withgoldberry.gpu=off, and with no:gpuon the module path. Asking makes no device. -
Partial repaint keeps layers it did not reach. After each frame the window merges: a layer from the last frame that the damage does not touch is kept, one the damage touches and the frame did not place again is gone, and one placed again is where the frame put it. Paint order is kept on both sides.
-
The seam (
render.composite,:gpualone) gainsCompositor.readback(), aReadbackSurfaceper window, andCompositedWindow.present(frame, damage, layers).
The backends
- sdl3. A composited window gives a
Compositedsurface and passes what the frame placed to its present. A window on the CPU gives the compositor’s read-back surface: a popup, a window with a page in it, one whose claim was refused, and every window undergoldberry.gpu.composite=never. goldberry.gpu.composite=autonow means something: a window is composited from the frame after it first shows a layer, and goes back to its surface two seconds after it last showed one (D3’s hysteresis; leaving costs 6 ms on macOS, a visible hitch at 120 Hz). Until then its layers are read back. Leaving on a timer is not for good, as leaving on a failure is.goldberry.gpu=offis a policy of its own now,OFF, rather thanNEVER: no window is composited and no layer is rendered, whereneverstill reads layers back.- headless windows give a read-back surface when
:gpuis on the module path. Its device needs a video driver the GPU can use:offscreenunder lavapipe,cocoaon macOS. Offscreen.gpu(surface)takes a surface, so a render with a layer in it can have the layer.
:gpu
GpuLayer.render(GpuFrame frame, GpuTexture target), public.targetis aB8G8R8A8_UNORMcolour target exactly the layer’s physical size, the toolkit’s, kept for the layer while it is placed at that size. The layer covers it whole, with passes of its own.GpuLayer.painter(fallback)is the painter that places it, or paintsfallbackwhere it cannot be shown.- A composited present renders every placed layer, in one frame submitted before the composite (one queue runs its submissions in order), then records the composite: clear to black, each layer’s texture drawn 1:1 at its place, scissored, with a replacing pipeline, then the UI texture over them all with premultiplied “over”. With layers on screen a window is composited on every present, damaged or not, since a layer may have changed with no damage at all. Layer work is counted in the present’s submit time.
- A read-back surface renders the layer into the same kind of texture and downloads it: the same pixels, bought with a wait.
- A layer that throws is logged once and shows as black where it throws.
Content that is not a
GpuLayershows as black too. GpuDevice.wrapand a texture’s SDL handle stay package-private in the public package.gpu.renderreaches them throughApiAccess, whichGpuDeviceregisters when it is initialised.
The rules
Written into GpuLayer’s doc, and each one shown by a golden:
- Layers are opaque. A layer replaces what is under it. A translucent one does not show the frame through, in either mode.
- Layers are axis-aligned rectangles of whole pixels. Clips are rectangles too: a rounded clip scissors a layer to its bounding box, and rounded corners are drawn by UI painted over the layer.
- No layer in a group. A frame nested in an
opacitygroup or a promoted layer has no surface, so the layer’s painter paints its fallback there. - Frost blurs the UI only in composited mode (D4, unchanged).
Alternatives considered
attachanddetachon the surface (D5’s sketch). A layer’s presence is a fact about the frame just painted, and the frame already records it in paint order. A second registry would have to agree with it every frame.- Rendering layers straight into the swapchain’s composite pass. It saves a texture per layer, but a layer could then have no depth buffer and no pass of its own, and read-back would need a different path. The texture is what makes the two modes one render.
- Scissoring by the Blend2D clip as it stands. It holds the damage, so a video half inside the damage would show half.
- A full repaint whenever a window shows a layer. It would make the keep-or-drop merge unnecessary, at the cost of repainting the whole UI for every caret blink in a window with a video.
- A
CompositionModeenum beside the surface. The sealed kinds already say it, and an enum could disagree with them.
Consequences
- Phase 4’s exit is met on Metal. Six z-order goldens (a popup over a layer, a layer in a scrolled list at 100% and 150%, two overlapping layers with translucent UI between them, a layer under a rounded clip, and the popup at 200%) are each rendered composited and read back through the production passes. The two ways agree to within two levels in 256, and both match one golden. The lavapipe lane has not run them yet.
- What a layer costs, composited: a texture of its size and a render every
present it is on screen. There is no on-demand cache yet: a static
canvas3dre-renders on a caret’s frame. Phase 5 decides whether it needs one. - What read-back costs: a blocking download per layer per painted frame. It is the mode for what cannot be composited, not the one to choose.
- Untested here: leaving
auto’s composited mode after the hold (it waits two seconds of wall time), and layers under Wayland, X11 and Windows. RenderTreeculls against the damage and clips by the tree alone, which changed no existing golden or test.