42.4 Photoshop-style editors⧉
A retoucher opens a 100-megapixel file, adds a curves adjustment, paints a mask so it affects only the sky, duplicates the background layer twice to try two cleanup strategies, and undoes forty brush strokes. Each of those actions, naively implemented, copies the image: a curve writes every pixel, a duplicate doubles memory, and an undo history keeps every version. The editor stays responsive because none of them copies anything. How it avoids the copies is the architecture of a layer-based editor, and it differs from the raw developer of Chapter 42.3, Lightroom-style raw developers in one respect that changes everything: the user paints. A raw developer's input never changes; a pixel editor's input is the user's own work.
Photoshop defined the model, which the open-source GIMP GIMP and GEGL and Krita Krita and the browser-based Photopea Photopea share. The book's appendix runs the original (Chapter 45.33, Photoshop 1.0, running), which had no layers at all. We read the architecture through gamut, a native editor written alongside this book, whose design documents state each decision with the measurement behind it.
42.4.1 The layer stack⧉
A document is a stack of layers composited bottom to top. Each layer carries pixels, an opacity, a blend mode that says how it combines with what lies below, and optionally a mask. The default mode is Porter–Duff over Porter & Duff 1984: with colors stored premultiplied by alpha (Chapter 19.1, Transparency and compositing), the result of a layer $F$ with coverage $\alpha_F$ over a background $B$ is
In words: keep the layer where it is opaque, show the background through where it is not. Other blend modes replace this rule: multiply darkens, screen lightens, and overlay combines both depending on the background value. Most are defined on encoded rather than linear values, which is why the same blend can look different in editors that composite in different spaces.
Three later additions turned the stack into more than a pile of pixels. An adjustment layer stores an operation, a curve or a hue shift, and applies it to everything below it, so the adjustment stays editable. A layer mask restricts any layer or adjustment to a painted region, which makes local adjustment the same mechanism as global adjustment with a mask (Chapter 8.5, Color point operations). A smart object wraps an embedded image or a whole sub-document so that transforms and filters applied to it can be revised later. Photoshop added layers in 1994, adjustment layers two years later, and smart objects in 2005; each step moved the editor from destructive toward non-destructive editing, the direction raw developers started from.
42.4.2 Tiles⧉
Every serious editor stores layers as tiles, small square blocks, rather than as one array per layer. Tiles make three things cheap. A layer that covers part of an unbounded canvas stores only the tiles it touches. A brush stroke modifies a handful of tiles, so only those need saving for undo. And a view at any position and zoom reads only the tiles it shows, which is what lets an editor open an image larger than memory.
GIMP's GEGL library represents the whole document as a graph of operations over tiled buffers, evaluated on demand for the region being displayed, the demand-driven model of Chapter 42.2, Image processing libraries. The migration shows how hard retrofitting is: GEGL underpinned high-bit-depth editing in GIMP 2.10 in 2018, but non-destructive filters arrived only with GIMP 3.0 in 2025.
42.4.3 Case study: gamut⧉
gamut is a native macOS editor over a portable C++ and Halide core. Its scope is wider than Photoshop's in two directions: images far larger than memory (its targets run to 100 gigapixels) and pixels that are measurements, with calibrations and units, so a readout can be in candelas per square meter or millimeters rather than code values. Its layer kinds include depth, gradient fields and light fields as well as color. Four of its design decisions carry over to any editor.
The document is a graph, shown as a stack⧉
A stack is a linear graph. The moment a mask is another layer's output, or one layer is used twice, the model is a directed acyclic graph whether the program admits it or not; Photoshop's smart objects and clipping masks are ways of expressing graph structure inside a stack. gamut makes the graph the model and the stack a view of it, with a node editor available when the stack cannot show what is going on.
Every node declares what the scheduler needs to evaluate it lazily and in pieces:
- its footprint: for an output rectangle, which input rectangle it reads (a point operation reads the same rectangle, a blur of radius $r$ the rectangle grown by $r$, a global operation such as histogram equalization the whole image);
- its scale class: how its parameters behave at reduced resolution, the same idea as CompPhotoRoom's scale contract;
- its determinism and a cache key built from its parameters, its inputs' keys and the identity of its code or model.
One rule looks minor and is not: a node must not handle the image border itself. If a filter clamps its reads internally, it reports needing no margin, the scheduler hands it a tile with no margin, and it clamps at the tile edge instead of the image edge, producing wrong pixels along every tile boundary with no error anywhere. The scheduler knows where the canvas ends; the node does not.
Copy-on-write tiles make undo and duplication free⧉
Tiles in gamut are 128 by 128 pixels, a size chosen by measurement: at that size an input, an output and an intermediate fit in a processor's cache at once, 256 measured 1.1 to 1.5 times slower, and 64 lost to per-tile overhead. Tiles are immutable. A layer version is a map from tile position to tile identity, and a document version is a map from layers to layer versions. Editing a layer creates new tiles only where pixels changed and a new map that shares everything else.
Undo, layer duplication and snapshots then cost almost nothing. In a measured sequence of 40 brush dabs, the 41 document versions referred to 10,496 tiles but only 406 distinct ones: 1.3 GB of nominal history stored in 51 MB, a sharing factor of about 26. Undoing and redoing all forty took no measurable time, since each is a pointer change.
Stored tiles are half-precision floats in linear light. A 16-bit log encoding looked as if it would compress better; measured, it compressed 8 percent worse and cost 3.6 times as much to encode, because spreading values over the code range is exactly what hurts a delta-predicting compressor.
Destructive edits as a log⧉
A destructive curve on a gigapixel layer writes eight gigabytes, and keeping the original for undo writes eight more. gamut separates what destructive means from what it stores. A destructive operation appends an entry to the layer's operation log; its parameters are frozen and later operations see its result, but no tile is written until a view or an export asks for one. The edit is instant, undo keeps the old tiles, and history stays small.
The log cannot grow forever: replaying a long chain of operations for every tile a view needs becomes visibly slow past about 16 operations. So the log is compacted, flattened into stored tiles, on save, under memory pressure, or past that depth. Compaction costs about 4.5 seconds per gigapixel across all cores, which is why it happens at those moments and never during an edit. Destructive and non-destructive stop being two storage models and become two editability policies over one.
Selections are sparse, and skipping saves the operation⧉
A selection or matte is mostly exactly one inside, exactly zero outside, with a thin band of partial values along the boundary. gamut classifies each tile as all inside, all outside or mixed, and stores bytes only for mixed tiles, so a mask costs memory proportional to its boundary length. An operation under a mask skips all-outside tiles and takes a fast path on all-inside ones.
The measurements corrected the design's first expectation. On a 2048 by 2048 render, a selection covering 10 percent of the image saved 2.3 times in time and 13 times in memory. The time saving is smaller because skipping saves the masked operation, not the rest of the pipeline: an all-outside tile still has to show the layers below it. The memory saving tracks the selection's area; the time saving tracks the masked operation's share of the cost.
42.4.4 Scheduling: pull, cache, fuse⧉
gamut evaluates by demand. A view asks for its tiles, and the scheduler walks the graph backward through each node's footprint, computing what is missing and caching it. It uses the same three drivers as CompPhotoRoom (proxy for drags, tile for 1:1, stream for export), adopted after that project reached them.
Two details of the cache came from failures. Its key is the node, the tile and the pyramid level, never an arbitrary rectangle, because two consumers of one node ask for different rectangles and would never share a result. And the cache must live for the whole request rather than one output tile: a per-tile cache still hits, but it is 2.4 times slower, because each output tile then recomputes the margins its neighbors already computed.
Fusion, evaluating a chain of operations in one pass without storing intermediates, depends on the footprints. Chains of point operations fuse freely, and a run of per-channel curves collapses into one lookup table. Chains of stencils must not be inlined, since each stage multiplies the recomputation of the one before, five times per stage for a five-point stencil; they are computed a few scanlines ahead of their consumer instead (Chapter 29.3, Halide at a high level: decoupling algorithms from schedules). Global operations prepare themselves before the tile loop. Parameters are run-time values rather than compiled constants, so a slider drag never recompiles a kernel.
The proxy has a limit that no scheduler removes. Linear operations commute with downsampling; nonlinear ones do not. A graph containing one tone curve, rendered at proxy scale, differed from the downsampled full-resolution render by 0.021 on average and 0.33 at worst, in linear light. The scale classes keep parameters consistent across resolutions; they cannot make a nonlinear pipeline scale invariant (Chapter 6.5, Resampling and upsampling).
42.4.5 What it buys⧉
With these pieces, cost follows what is on screen rather than the size of the document. Measured on an Apple M4 Max:
| 12 Mpix | 50 Mpix | 1 Gpix | 100 Gpix | |
|---|---|---|---|---|
| a 2560x1440 view, four operations | 2.7 ms | 3.2 ms | 3.5 ms | 2.4 ms |
| a slider drag at proxy | 10 ms | 13 ms | 15 ms | |
| export | 0.03 s | 0.11 s | 3.3 s | 7 min |
The view costs the same at 12 megapixels and at 100 gigapixels because it reads the same number of tiles; export, which must touch every pixel, scales with the image.
The interface follows the same discipline. Every action gamut can take, a menu item, a tool, a batch command, is one entry in a registry that the menus, the command palette, scripts, the Python binding and the C interface all call, so no surface can offer something the others lack, and a refusal ("this region cannot be composed with a homography") reads the same wherever it is met.
42.4.6 Editors in the browser⧉
Photopea runs a layer-based editor with Photoshop's file format entirely in a web page. The browser constrains memory, threads and file access, the same constraints CompPhotoRoom meets (Chapter 42.3, Lightroom-style raw developers), and in exchange the editor needs no installation and never uploads the image. The architecture is unchanged; only the ceilings move.