Paint And Compositing Algorithms
Paint order is a tree walk in seven phases, the display list is what the walk records, culling makes raster depend on the visible area, tiles and damage decide what is redrawn, and the compositor is a sorted draw of textured quads. This part is each as an algorithm, with the cost formula that follows: damage area times overdraw times the dearest effect, per layer.
Paint order: a tree walk in seven phases
"z-index: 9999 and it is still underneath. What is the algorithm that decides?"
A depth-first walk of the layout tree in which every stacking context is painted as one unit, in seven phases, with z-index ordering only the positioned children of a single context. An element can never paint above something that is above its stacking context's root, because the context is painted whole, at its root's position in the parent's order. The 9999 is ordering the element among its siblings in a context that is itself underneath.
- The context root's background and borders.
- Child stacking contexts with negative z-index, in ascending order.
- In-flow, non-positioned, block-level descendants' backgrounds and borders (not their content), in tree order.
- Floats.
- In-flow inline content (text, inline backgrounds, replaced elements), in tree order.
- Positioned descendants with
z-index: autoor0(as contexts if they are contexts, else as if they were), in tree order. - Child stacking contexts with positive z-index, in ascending order.
- z-index is relative to the nearest ancestor stacking context. Raising it moves the element among that context's children; it cannot escape the context.
- Creating a context flattens the subtree's z-indexes relative to the outside: adding
opacity: 0.99or atransformto a wrapper makes its children's z-indexes irrelevant to anything outside the wrapper. The common accidental cause of "my dropdown is under the next card". - Phase order beats tree order: a positioned element with z-index 0 (phase 6) paints over later in-flow siblings (phase 3 and 5) regardless of DOM order; a float (phase 4) over block backgrounds (3) but under inline content (5), which is why text wraps around a float and paints on top of its background if they overlap.
- The compositor's layer candidates are the same list. An element that creates a stacking context is a unit; units are what can be lifted to layers (the browser course, part 5).
isolation: isolatecreates a context with no other effect, and is the clean way to scope z-index.
Display lists and culling
The paint walk does not draw; it records. The output is a display list: a flat sequence of drawing commands with bounds. Separating recording from rasterisation lets the engine rasterise on other threads, re-rasterise only what changed, and skip commands that cannot affect the pixels being produced. Skipping is culling, and with a spatial index it makes raster cost depend on what is visible rather than on how long the page is.
- Commands: draw rect, draw rounded rect, draw text run (glyphs and positions), draw image, draw path, save/restore with clip or transform, and nested sub-lists (a picture referencing another picture, used for repeated content and for layers).
- Bounds: each command carries its device-space bounding rect, computed at record time. For a text run, the union of glyph bounds; for a shadow, the rect plus the blur radius.
- Chunks: consecutive commands sharing a layer, transform, clip and effect are grouped. A chunk is the unit assigned to a compositor layer's tiles.
- Size: a few hundred bytes per command; a complex page has tens of thousands. Skia's recorded pictures are the implementation in Chrome.
- Bounds culling: when rasterising a tile, skip every command whose bounds do not intersect the tile. Four compares per command; with an R-tree over bounds, O(log n + k) per tile instead of O(n).
- Occlusion culling: an opaque command that fully covers an earlier command's bounds (within the tile) makes the earlier one invisible; skip it. Done conservatively for axis-aligned opaque rects; a solid page background under a full-size opaque element lets the engine skip the background for those tiles.
- Clip culling: commands entirely outside the current clip are skipped; a
overflow: hiddencontainer with 10,000 children offscreen costs recording but not raster. - Empty-effect elision: transparent fills, zero-size rects, and text runs with no glyphs are dropped at record time.
- Recording is O(elements) regardless; a very long page costs its paint walk even if only the top is visible. (
content-visibility: autoskips the recording of offscreen subtrees too.) - Overdraw: commands that survive culling and overlap each other each draw their pixels; a background under a semi-transparent panel under text is three writes per pixel.
- Expensive effects: blur, backdrop-filter and large shadows are per-pixel costs that culling reduces only by area, not by the per-pixel factor.
Tiling, damage, and what gets redrawn
A layer's content is rasterised into fixed-size tiles so that a change can re-rasterise a small region and a scroll can show already-rastered regions immediately. The region to re-rasterise is the damage: the union of the old and new bounds of everything that changed. The entire economics of repaint follows from the damage rect's size and which layer it lands on.
- Size: 256 × 256 or 512 × 512 device pixels, chosen for GPU texture efficiency and upload granularity. A 1,200 × 2,400 layer is about 50 tiles.
- Priority: tiles are rastered in order of distance from the viewport (visible first, then the scroll-direction margin), on a pool of raster threads. A tile's work is: replay the layer's display list with the tile's clip and culling (chapter 2), into a bitmap, then upload to a GPU texture (or raster directly on the GPU).
- Fallback tiling: a low-resolution copy of the layer (one-eighth scale) so that fast scrolls into unrastered regions show blurry content rather than nothing (checkerboard).
- Memory: each tile is width × height × 4 bytes; 50 tiles at 256² is 13 MB. A page with many large layers runs into the tile memory budget and starts evicting offscreen tiles, which then re-raster on scroll.
- Computed from invalidations: each changed display item contributes its old bounds and its new bounds. Style changes that do not move anything damage one item's rect; layout changes that move content damage every moved item.
- Tiles intersecting the damage are invalidated and re-rastered; others keep their bitmaps.
- Zero damage: a transform or opacity change on a compositor layer. The content is identical; the compositor redraws the existing tiles with the new matrix. This, and only this, is why those properties animate at 60 fps for free.
- Whole-layer damage: a layout shift near the top of a layer, a font swap, a theme toggle. Every tile re-rasters. On a long page, that is the full page's raster even though only a screen is visible, because offscreen tiles in the margin are also invalid and will be needed on the next scroll.
// the cost of a raster, approximately: pixels × (commands touching them) × (per-pixel cost of each) // solid fill: ~0.1 ns/pixel (memset-like) text: ~1 to 5 ns/pixel (glyph blits from the atlas; AA) // image blit: ~0.5 ns/pixel (scaled: more) gradient: ~1 to 2 ns/pixel // box-shadow blur: ~10 to 50 ns/pixel (a separable Gaussian: O(radius) per pixel per pass) // filter: blur() same, over the whole element and its margin backdrop-filter: that, plus reading back what is beneath // border-radius: AA edges are a thin band; the interior is a fill // a 1,200 × 800 viewport is ~1M pixels: a solid repaint is ~0.1 ms; the same with a 20px blur under it is ~20 ms // so the repaint cost is dominated by: area × overdraw × the dearest effect in the stack. the Rendering panel's paint flashing // shows area; GPU overdraw tools show the multiplier; the effect is in your CSS. moving a blurred element to its own layer // turns per-frame raster into a one-time raster plus compositing (the browser course part 5), at the cost of its texture memory.
Compositing: layers as a sorted draw
- Inputs: a tree of layers, each with its tiles (textures), transform, opacity, clip, and scroll offset; plus the latest input (scroll deltas, pinch) applied on the compositor thread without the main thread.
- Property trees: transforms, clips, effects and scroll offsets are stored in separate trees (not on the layer tree) so that a change to one node updates descendants by tree walk, and so that layers can be sorted independently of their DOM nesting.
- Draw order: layers are sorted by their paint-order position (chapter 1), with 3D transforms handled by sorting contexts (a
transform-style: preserve-3dsubtree is sorted by depth). The sort is O(layers log layers); layer counts are tens to hundreds. - Quads: each visible tile becomes a textured quad with its layer's transform and opacity. Occlusion between opaque layers removes quads fully covered by opaque quads drawn above them (the same idea as display list occlusion, at the layer level).
- Draw: the quads are submitted to the GPU in one or a few draw calls per render pass. Effects that need intermediate textures (opacity groups with multiple children, filters, masks, blend modes) create render passes: draw the subtree to a texture, then draw that texture with the effect. Each pass is a full-size texture and a copy.
- Present: the frame is handed to the display at the next vsync.
- Layer count: each layer is textures (memory) and quads (draw work). Hundreds of layers from
will-change: transformon every list item is a known failure: tile memory exhausted, and the sort and draw overhead per frame. - Render passes: an opacity below 1 on a group with overlapping children, a
filter, amix-blend-mode, amask: each forces an offscreen pass over the group's area. Nested ones multiply. An animatedfilter: blur()is a full-area offscreen blur per frame. - Overlap: a composited layer that overlaps non-composited content forces the overlapped content into its own layer too (so draw order can be preserved), which is how one
transformcan create many layers ("layer squashing" limits the damage by merging overlapped content into one layer where possible). - Texture uploads: re-rastered tiles must be uploaded; on integrated GPUs the upload bandwidth is the limit, and a full-layer damage on a 4K display is tens of megabytes per frame.
Measuring paint and composite
| Question | Tool | What to read |
|---|---|---|
| What repainted on this change? | Rendering → Paint flashing | The damage rect, lit green; a whole-layer flash on a small change means a layout shift or a layer-wide invalidation |
| How long did paint and raster take? | Performance → Paint and Rasterize Paint events (main and raster threads) | Paint (recording) time versus raster (pixel) time; raster dominated by area and effects |
| How many layers and why? | Layers panel (More tools) | Each layer's size, memory, and compositing reason; hundreds of layers is a bug |
| Which layer did the change hit? | Layers panel → paint profiler for the layer | The layer's display list and per-command cost |
| Is the animation compositor-only? | Performance: a frame with no Paint, Layout or Recalculate Style; only Composite | Transform and opacity on their own layer; anything else is per-frame raster |
| Is overdraw the cost? | Android GPU overdraw debugging (for WebViews); Skia debugger on a captured picture; reasoning from the stack of translucent layers | Pixels painted several times; stacked translucent backgrounds |
| Are tiles checkerboarding on scroll? | Rendering → Frame Rendering Stats; chrome://tracing cc categories | Tiles not ready in time; raster behind scroll; too little margin or too much layer memory pressure |
| Is a render pass per frame? | Performance with Advanced paint instrumentation; GPU lane | Filters, masks, blend modes, group opacity on animated subtrees |