Concepts — Paint, Display Items, and Property Trees

Phase 3 · Spec area §14. Prerequisites: bi-07, bi-08.


1. Why a Principal Engineer needs this

1. "Paint" is where the folk model is most wrong. Most engineers believe paint means "drawing pixels." It does not: Blink's paint stage produces a display list — a recording of drawing commands — and rasterisation into pixels happens later, elsewhere, possibly on another thread or process. Once you know this, "why did my change cause repaint but not relayout, and why was it still cheap?" becomes answerable.

2. Property trees are the data structure that makes compositing tractable, and they are the bridge between this module and bi-10. Understanding them is what lets you predict whether an effect can be handled by the compositor alone.

3. Hit testing lives here. Every "why did my click land on the wrong element" question is a paint-order and hit-test question, and paint order is not DOM order.


2. Mental Model

2.1 Paint produces a recording, not pixels

Layout (fragments + geometry)
   |
   v  PRE-PAINT: build/update property trees, compute paint invalidation
   |
   v  PAINT: walk in paint order, emit display items
   |
   v  Display list, grouped into paint chunks
   |
   v  COMMIT to the compositor            <-- bi-10 takes over here
   |
   v  RASTER (tiles) -> GPU -> pixels

Paint is cheap relative to raster because it is recording, not drawing. A display item is "draw this rect with this paint," not a bitmap.

2.2 Paint order is not DOM order

Paint order is defined by the CSS painting model: stacking contexts, z-index, positioning, and the specified ordering of backgrounds, floats, inlines, and positioned descendants. A stacking context is an atomic unit — its descendants cannot be interleaved with content outside it, which is exactly what makes z-index inside a stacking context local.

Properties that create stacking contexts (opacity < 1, transform, filter, will-change, isolation, and others) therefore change structure, not just appearance. This is why adding opacity: 0.999 "fixes" a z-index bug — and why doing so is a smell rather than a fix.

2.3 Property trees

Rather than baking transforms, clips and effects into each display item, Blink maintains separate trees:

  • Transform tree — the transform hierarchy.
  • Clip tree — clipping regions.
  • Effect tree — opacity, filters, masks, blend modes.
  • Scroll tree — scrollable regions and their offsets.

Each paint chunk references nodes in these trees. The payoff is decisive:

If a change only alters a property-tree node, the display list does not need to be re-recorded, and rasterised tiles do not need to be redrawn. The compositor can apply the new value to already-rastered content.

That is the entire mechanical basis for "animate transform and opacity, not top and width." It is not folklore and it is not about GPUs being fast — it is that those two properties are representable as property-tree changes, so the pipeline can skip paint and raster.

2.4 Paint invalidation

Like style and layout, paint is invalidated rather than recomputed eagerly. Pre-paint walks the tree, updates property trees, and determines what must be repainted. The interesting question — and the lab — is what scope a given change invalidates, and which changes invalidate nothing at all.

2.5 Hit testing

Hit testing walks the painted representation in reverse paint order to find the topmost element at a point. Consequences: pointer-events, overlapping stacking contexts, and transforms all affect hit testing, and the answer can differ from what the DOM tree suggests. Compositor-side hit testing also matters for scroll (bi-10), because the compositor must decide without the main thread whether it can handle an input.


3. Under the Hood

ConcernWhat to look for
Paint entry, painters per box typecore/paint/
Display items and the display listdisplay item + list types in platform/graphics/ and core/paint/
Paint chunkschunk types alongside the display list
Property treesproperty-tree node types; pre-paint tree builder
Paint invalidationthe paint invalidator
Hit testinghit-test request/result types

core/paint/README.md (~30 KB) is the authoritative description; read it rather than inferring the architecture from code.


3.5 Deep dive: PrePaint does two jobs

core/paint/README.md names the phase precisely. PrePaintTreeWalk walks the whole layout tree, from the root FrameView, across frame boundaries, in-order — and the README explains why in-order matters: it lets the walk efficiently compute DOM-order hierarchy such as the parent containing block.

It has exactly two goals:

  1. Paint invalidation — mark what must be painted differently from the cached painting.
  2. Building paint property trees — transform, clip, effect, scroll.

Paint invalidation, mechanically

Before PrePaint, objects are marked as needing invalidation checking by style change, layout change, compositing change, and so on. PrePaint then traverses marked subtrees in pre-order and invalidates the display item clients that would generate different display items.

The machinery: a root PaintInvalidatorContext is created for the LayoutView; each visited object gets one derived from its parent's, tracking the painting layer that will initiate its painting. PaintInvalidator initialises the context and calls LayoutObject::InvalidatePaint(), which dispatches to a type-specific invalidator such as BoxPaintInvalidator.

Notice the shape. Mark-then-walk-then-invalidate is the same two-phase structure as style invalidation (bi-07: features → pending invalidations → push) and dirty-layout tracking (bi-08). Three subsystems, one pattern: record cheaply during mutation, resolve precisely once per frame. If you take a single architectural idea from the rendering pipeline, take that one — it is what makes the whole thing incremental.


3.6 Deep dive: display items, chunks, and the PaintController

Paint walks the PhysicalFragment tree (bi-08's output) in paint order and produces display items via static painter classes such as BoxFragmentPainter, appending to a PaintController.

Two facts from the README that carry a lot of weight:

  • There is only one PaintController for the entire LocalFrameView. Painting is not per-element bookkeeping; it is one list for the frame.
  • The controller segments the display item list into PaintChunks: sequential display items that share a common property tree state.

That second definition is the one to memorise, because it explains why property trees exist at all:

display item, display item, display item   ← same transform/clip/effect  ─┐
display item, display item                 ← different clip              ─┤ chunks
display item                               ← different transform         ─┘

A chunk is precisely "a run of drawing that shares the same answers to where, clipped by what, and with what effect." Change a transform node and you change a chunk's property state, not its contents — so nothing has to be re-recorded and nothing re-rastered. That is the entire transform/opacity fast path, stated in terms of the data structure.

Two layers of paint caching

The README describes both, and they operate at different granularities:

LayerMechanismSkips
Display item cachingif a painter would create a DrawingDisplayItem identical to last time, reuse itone item
Subsequence cachingSubsequenceRecorder in PaintLayerPainter::PaintContents() records all items in a scope; if the layer would produce identical items, reuse the whole runan entire layer

Subsequence caching is the interesting one: it is a memoisation of a subtree's paint output, keyed on "nothing that affects this layer changed." Same idea as layout-result caching (bi-08) and computed in a reactivity system (fw-03) — and the same failure mode if the key is incomplete.

You have now seen result-caching-keyed-on-complete-inputs in three engine subsystems. When fw-03 asks you to reason about computed invalidation, that is not an analogy; it is the same problem at a different scale.


3.7 Deep dive: what creates a stacking context

Paint order is not DOM order, and the list of things that create a stacking context is longer than most engineers expect. A non-exhaustive but practical list:

  • position other than static with a z-index other than auto
  • position: fixed or sticky (always)
  • opacity less than 1
  • transform, scale, rotate, translate, perspective other than none
  • filter, backdrop-filter, mask, clip-path other than none
  • mix-blend-mode other than normal
  • isolation: isolate
  • will-change naming any property that would create one
  • contain: paint, contain: layout, content-visibility other than visible
  • flex/grid items with z-index other than auto
  • view-transition-name other than none

The practical consequence: many properties applied for purely visual reasons change paint structure. opacity: 0.99 "fixing" a z-index bug is the canonical example — it works because it creates a stacking context, which is a structural change disguised as an aesthetic one.

The debugging heuristic: when z-index "doesn't work," the element is almost always being compared against siblings inside a stacking context you did not know existed. Find the nearest ancestor with any property from that list.


3.8 Deep dive: pixel snapping and why edges look wrong

The paint README has a whole section on pixel snapping and bluriness, which tells you it is a recurring source of bugs.

Layout works in LayoutUnit, a fixed-point type with sub-pixel precision (1/64 px). Paint must eventually produce device pixels. The gap between them produces:

  • Blurry text or borders when a box lands on a fractional device pixel — most visible on non-integer devicePixelRatio (1.25, 1.5) which is extremely common on Windows laptops.
  • Off-by-one seams between adjacent boxes when each is snapped independently and they round in different directions.
  • A 1px line that renders as 2px of grey rather than 1px of black.

Why sub-pixel layout at all? Because integer layout accumulates error across many boxes: 100 boxes each rounded up by 0.4px is a 40px drift. Sub-pixel layout keeps the positions accurate and snaps only at paint time.

The practical guidance:

  • Snapping happens at paint, so investigating "why is this blurry" means looking at the paint-time transform, not the CSS.
  • A fractional transform: translate() on an ancestor moves everything below onto fractional positions — this is why an animation can make an entire subtree blurry mid-flight and crisp at rest.
  • will-change: transform promotes to a layer that may be rastered at a fixed scale, which is a separate cause of blurriness during scaling animations.

3.9 Deep dive: hit testing, and the compositor's copy

Hit testing walks the painted representation in reverse paint order to find the topmost element at a point. Everything that affects paint order affects hit testing, including transforms, which is why a visually-moved element is clickable in its new location.

The part that is easy to miss: the compositor needs to hit test too. When input arrives (bi-10), the compositor thread must decide without the main thread whether it can handle the event — for example, whether the point is inside a scroller with no blocking listeners. So paint produces hit test data for the compositor alongside display items.

Two consequences:

  • A non-passive listener on a large region degrades the compositor's ability to answer, which is the mechanism behind bi-10's scroll advice.
  • Elements with pointer-events: none are excluded from hit test regions, which is why it is an effective (if blunt) fix for an invisible overlay eating clicks.

3.10 The property trees, enumerated

Four trees, each answering one question about a paint chunk:

TreeQuestionChanged by
Transformwhere is it?transform, scroll offsets, device scale
Clipwhat is it clipped to?overflow, clip-path, border-radius clipping
Effecthow is it composited?opacity, filter, mask, mix-blend-mode
Scrollwhich scroller moves it?scroll containers

They are separate trees, not one tree of composed state, and that separation is the design. A scroll changes one node in the scroll tree; a fade changes one node in the effect tree. If these were baked into display items, every scroll would re-record the world.

Recall bi-07's data: only 4 properties declare invalidate: ["compositing"], and 5 declare transform-data/transform-other. The set of genuinely cheap-to-animate properties is small, specific, and enumerable — not a vibe.


4. Anti-Patterns

"Paint = pixels." It is a recording.

Animating top/left/width/height. Forces layout → paint → raster every frame.

will-change everywhere. It creates stacking contexts and compositing layers, which cost memory and can reduce performance. It is a hint with a real price.

Assuming z-index is global. It is scoped to the stacking context.

Debugging click-target bugs in the DOM inspector alone. The answer is usually in paint order or a transformed ancestor.


5. Trade-offs

Property trees vs baking properties into display items. Separate trees add indirection and a whole subsystem to maintain, and they are what makes cheap compositor-only updates possible. This is one of the clearest "complexity that pays for itself" cases in the renderer — and the right §46 entry for this module.

Record-then-raster vs draw directly. Recording allows the raster to happen off the main thread, at a different scale, and to be reused. It costs an intermediate representation and the machinery to invalidate it.

More compositing layers vs fewer. Each layer avoids repaint but costs memory and composition time. There is no universally right answer, which is why the browser uses heuristics — and why author hints like will-change can make things worse.


6. Lab — mini-browser M10–M11

  1. Display list. Walk your layout tree in paint order and emit display items ({type, rect, color, …}). Do not draw yet.
  2. Stacking contexts. Implement z-index and at least one property that creates a stacking context. Prove ordering with a test case that changes when you remove the stacking-context rule.
  3. Property trees. Add a transform tree. Represent a translated subtree as a node reference rather than by baking coordinates into items.
  4. Raster. Draw the display list to a canvas.
  5. The payoff experiment. Animate a subtree two ways: (a) by changing layout position and re-recording, (b) by changing only a transform node. Measure both. Report how much of the pipeline each skips.
  6. Hit testing. Implement reverse-paint-order hit testing and find a case where it disagrees with DOM order.

Deliverable: the stage-5 measurement, plus a written statement of exactly which stages were skipped in case (b) and why they could be skipped.


7. Failure Lab

  1. Bake transforms into display items. Re-run the stage-5 experiment. Show that the cheap path is now impossible. This is the strongest possible argument for property trees.
  2. Break paint order. Paint in DOM order instead. Find markup that renders wrong.
  3. Invalidate too little. Skip paint invalidation for a property that needs it; produce a stale-pixels bug.
  4. Layer explosion. Put will-change: transform on 5,000 elements. Measure memory. Explain why the "optimisation" lost.

8. Debugging Exercise

  1. DevTools: enable paint flashing and layer borders. Find one change that repaints and one that does not.
  2. Compare a top-animated element with a transform-animated one in a Perfetto trace. Name the stages present in one and absent in the other.
  3. Find a hit-test bug you can only explain via stacking contexts.
  4. In the checkout, find where a property-tree-only change avoids repaint. Quote the condition.

9. References

  • CSS 2 Appendix E (painting order), CSS Positioned Layout, CSS Transforms, CSS Filter Effects, CSS Compositing and Blending.
  • third_party/blink/renderer/core/paint/README.md — primary.
  • Life of a Pixel.

10. Principal Engineer Review

  1. Explain to a senior engineer why transform animations are cheap, without saying "GPU."

  2. Property trees add a whole subsystem. Reconstruct the argument that justified them, and name what would be impossible without them.

  3. will-change is a hint with a cost. Write the guidance you would give your organisation — specific enough to act on, without becoming a rule people cargo-cult.

  4. Paint produces a display list rather than pixels. Name three capabilities this enables.

  5. A designer wants a blur-heavy UI. Predict the pipeline consequences, and say what you would measure before agreeing.

  6. Stacking contexts make z-index local. Argue this is good design; then describe the most common way it confuses engineers and how you would teach around it.

  7. Hit testing walks paint order, not DOM order. Give a realistic accessibility or UX bug this causes, and how you would detect it systematically.

  8. The browser decides compositing layers heuristically. Argue for exposing full manual control to authors; then argue against. What do you actually want?

  9. Your app drops frames only while scrolling on low-end Android. Enumerate paint- and raster-side causes, and the order you would investigate them.

  10. You are asked whether a design system should ban opacity transitions on large surfaces. Answer with mechanism, and state what evidence would change your answer.