NotesEngineering

How the viewport draws, and why it draws on demand

Metal, a texture per image, and a view that only redraws when something changed. Plus the one place that bit us.

Lumeira draws images with Metal. A JPEG does not need a GPU. A 60-megapixel RAW that you want to zoom and pan like a piece of paper does, because the CPU path stops feeling like paper well before 60 megapixels.

The shape of it

Each image is decoded once, off the main thread, into a bitmap. That bitmap becomes a Metal texture. The view holds one texture at a time (plus the ones the prefetcher has ready, three ahead and two behind), and a single quad with a transform matrix. Zoom, pan, rotate, and flip are all changes to that matrix. Redrawing is one draw call.

The MTKView is set to draw on demand, not at 60 frames a second. A still image that nobody is touching should cost nothing. When the transform or the texture changes, the view asks for one redraw.

Color

The Metal layer has a color space set. This sounds obvious, and we missed it for months. Without it, the layer treats your pixels as whatever the display wants, and a wide-gamut image looked flatter in the main viewport than the same file did in the thumbnail beside it, which goes through AppKit and gets color-managed for free. One line to set the layer's colorspace, and the two match.

The one that bit us

Draw on demand has an edge: if you ask for a redraw at the wrong moment, AppKit can drop the request. We asked for one synchronously, during window activation, when opening an image from another folder while the app was already running. The texture was uploaded. The request was dropped. The user saw the old image, or black.

The fix is dull: request the redraw when the window is actually active, and log every step from "file opened" to "frame presented" so the next time the screen and the model disagree, the log says where they diverged.

Where that leaves things

The renderer is under two thousand lines, the shaders are compiled at runtime from a string so the package builds without an Xcode metallib step, and everything that touches shared state is an actor. If you want to poke at it, the rendering package builds and tests on its own with swift test.