Stop converting between CSS and device pixels as part of rendering - the
display list should be as simple as possible, so convert to DevicePixels
once when constructing the display list.
Add a pre-computed `has_empty_effective_clip` flag on
AccumulatedVisualContext that propagates from parent to child. When a
clip rect or clip path has zero area, all descendant commands are
skipped at display list recording time in `DisplayList::append()`,
so they are never stored or executed.
This allows skipping ~10% of display list commands in the Discord app.
Previously, PaintNestedDisplayList was treated as an opaque command,
printing only its name and rect without showing the nested display
list's contents. This made it impossible to debug painting issues
involving SVG masks/clips, CSS background-clip: text, and iframe
content through display list dumps.
Refactor the command dump loop into a recursive lambda that expands
nested display lists inline with increased indentation.
Per the CSS Overflow spec, overflow properties apply only to block
containers, flex containers, and grid containers — not SVG graphics
elements. Add an `is<SVGPaintable>` check in
`overflow_property_applies()` to return false for SVG inner elements
like `<g>`, `<rect>`, `<path>`.
This doesn't affect `<svg>` elements (which use `SVGSVGPaintable`, a
direct `PaintableBox` subclass) or `<foreignObject>` (which uses
`SVGForeignObjectPaintable`, a `PaintableWithLines` subclass) — both
correctly keep their overflow clips.
This test captures the current (incorrect) behavior where setting
`overflow: hidden` on an SVG `<g>` element produces a clip in the
display list. Per the CSS Overflow spec, overflow properties only apply
to block containers, flex containers, and grid containers — not SVG
graphics elements.
This applies the same pattern used for background-clip: text (commit
f2e6f70fbb).
Results in visible performance improvement in Discord app where
previously, according to profiles, we spent lots of time allocating
surfaces for masks.
relayout_svg_root() clears individual stacking contexts via
reset_for_relayout() but didn't call invalidate_stacking_context_tree().
The viewport's stacking context remained non-null, so
build_stacking_context_tree_if_needed() skipped the rebuild. This caused
foreignObject to lose its stacking context after relayout, breaking SVG
mask application.
This test verifies that SVG mask application on foreignObject is
preserved after partial SVG relayout. Currently the mask is not applied
to the foreignObject content after relayout because the stacking context
tree is not rebuilt.
The stacking context tree rebuild had an optimization that skipped
rebuilding when a property changed between two values that both create
stacking contexts. This is correct for most properties (e.g. opacity
0.5 -> 0.8 doesn't change tree structure), but incorrect for z-index.
During tree construction, elements with z-index 0/auto are placed in
m_positioned_descendants_and_stacking_contexts_with_stack_level_0,
while elements with other z-index values are painted from m_children
(negative at step 3, positive at step 9 per CSS 2.1 Appendix E).
When z-index changed between non-auto values (e.g. 0 -> 10), the
optimization skipped the rebuild, leaving the element in the wrong
list and causing it to be painted from both step 8 and step 9.
This was visible on pages like shopify.com where elements with
transition-all would transition z-index, producing a flood of
"Painting commands are recorded twice for stacking context" messages.
When z-index changes from 0 to a positive value on a positioned element,
the stacking context tree is not rebuilt, causing the element to be
painted twice. This test captures the current (incorrect) behavior.
We don't have a text node in these, so no fragments either. Maintaining
a special case for this situation seems much simpler than reworking
`contenteditable`s to always have a fragment.
Our change to generate spans in PaintableWithLines from fragments also
broke drawing the caret for empty <input>s and <textarea>s, since spans
was empty in that case.
Fix this by moving caret drawing to PaintableWithLines, and only
invoking it if we have a cursor position set.
Previously, absolutely positioned elements jumped directly to their
containing block's accumulated visual context, skipping effects
(opacity, mix-blend-mode, isolation) from intermediate ancestors. Per
CSS spec, these properties create stacking contexts that abspos elements
cannot escape — they only escape scroll containers and overflow clips.
Previously, SkTextBlob was built on every paint in
DisplayListPlayerSkia::draw_glyph_run(), which meant:
- Repeated work when the same display list is painted multiple times
- Glyph arrays were allocated and populated on each paint
Now the blob is built once during display list recording and cached in
GlyphRun.
Move the visual viewport (pinch-to-zoom) transform from a reserved slot
in DisplayList to the AccumulatedVisualContext tree as a root transform
node. Fixed position elements now correctly inherit from this context.
This requires rebuilding the context tree and display list on each zoom
change, but this overhead will be eliminated by future partial context
tree rebuilds.
Effects (opacity, blend mode, filters) must be applied in the parent's
coordinate space, before the element's transform. Previously this was
handled by manually switching to the parent's visual context when
applying effects at paint time.
By adding EffectsData to AccumulatedVisualContext and positioning it
before TransformData in the chain, effects are now naturally applied in
the correct order during display list replay, eliminating the special
case in StackingContext::paint().
For SVG filters that can generate content from empty elements (feFlood,
feImage, feTurbulence), a transparent FillRect command is emitted to
trigger the filter through the same AVC pipeline.
For position:relative/static elements, use visual parent's state
directly instead of containing block's state + intermediate walk.
This reuses existing context nodes, avoids duplicate allocations,
and eliminates the intermediate ancestor vector construction.
This test demonstrates an issue where TransformData/PerspectiveData can
be allocated twice for the same paintable box when a position:relative
element has an inline transformed ancestor.
AccumulatedVisualContext nodes capture a snapshot of the current clip
rect when the tree is built, so we must invalidate whenever the clip
property changes.
This test verifies that modifying the CSS clip property via JavaScript
properly invalidates the accumulated visual context. Currently fails
because the clip rect in the display list remains unchanged after the
style update.
AccumulatedVisualContext nodes capture a snapshot of the current
clip-path when the tree is built, so we must invalidate whenever the
clip-path changes.
Fixes regression introduced in 98afd82
This test verifies that modifying clip-path via JavaScript properly
invalidates the accumulated visual context. Currently fails because the
clip-path in the display list remains unchanged after the style update.
Regression from 98afd82491.
Integrate AccumulatedVisualContext with display list recording and
playback. This is the main commit of the refactoring that delivers the
architectural improvements enabled by AccumulatedVisualContext.
Recording changes:
Each display list command now stores a single
RefPtr<AccumulatedVisualContext> instead of separate scroll_frame_id
and ClipFrame. The recorder simply captures the current accumulated
context when appending commands.
The before_paint()/after_paint() hooks that pushed/popped scroll frame
IDs are replaced by directly setting accumulated_visual_context on the
recorder before painting each element.
Playback changes:
The display list player now uses LCA (Lowest Common Ancestor) based
traversal to switch between visual contexts efficiently. When
transitioning from context A to context B:
1. Find the LCA of A and B in the context tree
2. Pop (restore) states back to the LCA depth
3. Push (save + apply) states from LCA down to B
This approach minimizes redundant save/restore operations. For example,
when rendering siblings that share a common scroll container, the
player keeps that scroll state applied and only switches the divergent
parts of their context chains.
Key deletions:
- Remove translate_by() from all 45 display list commands - commands
are now immutable
- Remove transform/perspective fields from PushStackingContext -
transforms are tracked via AccumulatedVisualContext
- Remove push_scroll_frame_id()/pop_scroll_frame_id() from
DisplayListRecorder
- Remove before_paint()/after_paint() hooks from Paintable
- Merge ApplyOpacity, ApplyCompositeAndBlendingOperator, ApplyFilter
into single ApplyEffects command
Stacking context painting changes:
The StackingContext::paint() method is significantly simplified.
Instead of building a PushStackingContextParams struct with transform
matrices and pushing/popping stacking contexts, it now:
1. Sets the accumulated visual context (which already contains
transforms)
2. Applies effects (opacity, blend mode, filters) if needed
3. Applies clip path if needed
4. Paints the content
5. Restores state
The visual state management that was interleaved throughout the
painting code is now handled uniformly by the context tree.
Adds pinch event handling that adjusts the VisualViewport scale and
offset. VisualViewport's (offset, scale) is then used to construct a
transformation matrix which is applied before display list execution.
Before this change we would emit PushStackingContext/PopStackingContext
display list items regardless of whether the stacking context had any
transform/opacity/clip effects.
Display list size on https://x.com/ladybirdbrowser is reduced from ~2700
to ~800 items.
This fixes an issue where text decorations (e.g. underlines) of text
split across multiple fragments would have unintended 1px gaps.
Gains us 2 WPT passes (imported)
When rounding a CSSPixelRect to a DevicePixelRect, we simply pulled its
width and height through round() and called it a day. Unfortunately this
could negatively affect the rect's perceived positioning.
A rect at { 0.5, 0.0 } with size { 19.5 x 20.0 } should have its right
edge at position 20, but after rounding it would end up at { 1, 0 } with
size { 20 x 20 }, causing its right edge to be at position 21 instead.
Fix this by first rounding the right and bottom edges of the input rect,
and then determining the dimensions by subtracting its rounded position.
Fixes#245.
There are some nuances to creating these wrappers, such as manually
propagating certain text styles that are not inherited by default. We
already have the logic for this in
`NodeWithStyle::create_anonymous_wrapper()`, so reuse that method in our
implementation of the button layout.
Fixes applying certain text styles (such as `text-decoration`) to the
text of a `<button>`.
Output display list dumps with an indentation level to show balanced
commands. It makes it much easier to see what is happening between e.g.
PushStackingContext and PopStackingContext, or SaveLayer and Restore.
Until now, every paint phase of every PaintableBox injected its own
clipping sequence into the display list:
```
before_paint: Save
AddClipRect (1)
...clip rectangles for each containing block with clip...
AddClipRect (N)
paint: ...paint phase items...
after_paint: Restore
```
Because we ran that sequence for every phase of every box, Skia had to
rebuild clip stack `paint_phases * paintable_boxes` times. Worse,
usually most paint phases contribute no visible drawing at all, yet we
still had to emit clipping items because `before_paint()` has no way to
know that in advance.
This change takes a different approach:
- Clip information is now attached as metadata `ClipFrame` to each
DisplayList item.
- `DisplayListPlayer` groups consecutive commands that share a
`ClipFrame`, applying the clip once at the start of the group and
restoring it once at the end.
Going from 10 ms to 5 ms in rasterization on Discord might not sound
like much, but keep in mind that for 60fps we have 16 ms per frame and
there is a lot more work besides display list rasterization we do in
each frame.
* https://discord.com/channels/1247070541085671459/1247090064480014443
- DisplayList items: 81844 -> 3671
- rasterize time: 10 ms -> 5 ms
- record time: 5 ms -> 3 ms
* https://github.com/LadybirdBrowser/ladybird
- DisplayList items: 7902 -> 1176
- rasterize time: 4 ms -> 4 ms
- record time: 3 ms -> 2 ms
It's useful to have tests that dump display list items, so we can more
easily see how changes to the display list recording process affect the
output. Even the small sample test added in this commit shows that we
currently record an unnecessary AddClipRect item for empty paint phases.
For now, the dump doesn't include every single property of an item, but
we can shape it to include more useful information as we iterate on it.