Skip to content

fix(canvas): confine a named shader effect to its own canvas layer#6440

Merged
shipyard-local[bot] merged 2 commits into
mainfrom
fix/shader-effect-layer-crop
Jul 23, 2026
Merged

fix(canvas): confine a named shader effect to its own canvas layer#6440
shipyard-local[bot] merged 2 commits into
mainfrom
fix/shader-effect-layer-crop

Conversation

@shipyard-local

@shipyard-local shipyard-local Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

The bug

A CanvasWidget that is not the whole surface blackens everything painted before it. canvasSetShaderEffect(id, "crt", 0.55) on a small readout screen took the entire host window with it — found in Forge, where one 604×96 canvas erased the app chrome, the plugin panel background and the header.

Cause

SkiaCanvas::save_layer_with_shader_effect handed the SkImageFilters::RuntimeShader straight to push_layer, whose canvas_->saveLayer(&bounds, &layer_paint) treats bounds as a hint once an image filter is attached. A runtime shader reports unbounded output, so the layer grew to the device clip and the restore composited shader output over content the widget never owned.

Only crt exhibits it, because it is the one curated effect that writes opaque pixels where the child is transparent (scanlines + aperture mask). The other five happen to preserve child alpha. Cropping the filter to the layer rect makes containment structural rather than a property every present and future curated effect has to remember to preserve. SkImageFilters::Crop defaults to kDecal, so outside the rect is transparent and surrounding content shows through untouched.

Why the existing tests missed it

Every case in test_canvas_shader_effects.cpp painted the widget at the full surface size ({0,0,kW,kH}, plus {0,0,64,64}) — there was no "outside" to observe. The suite could not have caught this.

Tests

Three cases, each on a real pixel read rather than a smoke check:

  • does not paint outside its own canvas — an offset 32×32 widget over a green field must leave that field green, sampled at six points in every direction, for all six effects. Uses CHECK so a regression names every leaking effect, not just the first.
  • still renders inside an offset canvas — a vignette confined to a 48×48 widget must still darken that widget's own corners relative to its own centre, so an over-tight crop cannot pass silently.
  • layer crop holds under a canvas scale — both properties at 2×. The effect samples in device space while the layer rect is local, so a crop in the wrong coordinate space would pass at 1× and only break under a transform.

Negative control: the first case fails on origin/main (the green field reads (0,0,0) under crt) and passes here.

Verification

pulp-test-canvas-shader-effects 17/17 (2437 assertions). Also green: pulp-test-canvas, -canvas-widget, -canvas-widget-shadow, -canvas-filter-chain, -canvas-capabilities, -canvas-widget-sanitize. Only one call site exists (CanvasWidget::paint).

Verified visually as well as numerically — rendering the same offset crt widget with and without the crop, the interior is pixel-identical across 35,344 sampled pixels (max channel delta 0) while the surrounding field goes from black back to its own color. The effect is unchanged; only its overspill is gone.

Note for consumers

canvasSetShaderEffect is already compiled into shipped Forge binaries and the installed SDK prefix, so both want a rebuild once this lands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QG7KYpNcdprcaDFrtmzUZC


🔎 Provenance

Agent claude
Machine m3
Tab Forge v2
Directory /Volumes/Workshop/Code/pulp-shader-crop
Session c5642b40-0090-4a66-9223-3bcc4f4de820

Resume

claude --resume c5642b40-0090-4a66-9223-3bcc4f4de820

Jump to this tab

cmux surface focus 4D22CE8D-A143-4A13-95CA-A51E871F8D9C

Relaunch (any agent)

cmux surface resume get --surface 4D22CE8D-A143-4A13-95CA-A51E871F8D9C

Restore URLhttps://claude.ai/code/session_01QG7KYpNcdprcaDFrtmzUZC

stamped 2026-07-21 20:33 UTC

danielraffel and others added 2 commits July 22, 2026 22:45
A CanvasWidget that is not the whole surface blackened everything painted
before it. `canvasSetShaderEffect(id, "crt", …)` on a small readout screen took
the entire host window with it — in Forge, the whole app chrome, the plugin
panel background and the header vanished behind one 604x96 canvas.

`save_layer_with_shader_effect` handed the RuntimeShader filter straight to
`push_layer`, whose `saveLayer(&bounds, &paint)` treats bounds as a HINT once an
image filter is attached: a runtime shader reports unbounded output, so the
layer grew to the device clip and the restore composited shader output over
content the widget never owned. Only `crt` showed it, because it is the one
curated effect that writes opaque pixels where the child is transparent (its
scanlines and aperture mask); the other five happen to preserve child alpha.
Cropping the filter to the layer rect makes containment structural instead of a
property every present and future curated effect has to remember to preserve.
`SkImageFilters::Crop` defaults to kDecal, so outside the rect is transparent
and the surrounding content shows through untouched.

The existing suite could not see this: every case painted the widget at the full
surface size (`{0,0,kW,kH}`), where there is no "outside" to observe. Three cases
close that gap, each anchored on a real pixel read rather than a smoke check —
an offset 32x32 widget over a green field must leave that field green for all
six effects; a vignette confined to a 48x48 widget must still darken that
widget's OWN corners, so an over-tight crop cannot pass; and both properties
must hold under a 2x canvas scale, since the effect samples in device space
while the layer rect is local and a crop in the wrong space would pass at 1x
only. The first case fails on origin/main (green field reads 0,0,0 under crt)
and passes here.

Verified visually as well as numerically: rendering the same offset crt widget
with and without the crop, the interior is pixel-identical across 35344 sampled
pixels (max channel delta 0) while the surrounding field goes from black back to
its own color — the effect is unchanged, only its overspill is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QG7KYpNcdprcaDFrtmzUZC
Bloom is the one curated effect whose purpose is to spread light BEYOND its
source, so a hard crop at the layer rect is exactly where this fix could be
wrong. The containment case could not see it: it fills the widget edge-to-edge
with flat grey, which produces no glow to clip. This adds the discriminating
geometry — a bright bar hard against the widget's right edge — and reads both
sides of that boundary.

Rendered and compared, the crop is not taking anything from bloom. Inside the
widget the two renders are pixel-identical (max delta 0 across the widget), and
the glow is still alive right against the edge — the bar still bleeds light into
the dark interior beside it. What the crop removes is entirely outside: a band
~8px wide of UNIFORM (115,255,115) that stopped abruptly rather than falling off
with distance. That is not a glow, it is the bloom kernel sampling out-of-bounds
coordinates within its fixed radius — the same unbounded-output artifact this
change exists to fix. A layer cannot legitimately paint outside itself anyway,
since saveLayer already clips layer content to its bounds.

So bloom needs no headroom and must not get a widened crop; that would restore
the overspill quietly, for the one effect where it would be easiest to excuse.

The same comparison over all six effects at an offset widget shows the crop is a
no-op everywhere except crt: interior delta 0 for every effect, and outside delta
0 for every effect except crt, whose surrounding field goes from black back to
its own colour. All six were rendered and viewed, not only asserted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QG7KYpNcdprcaDFrtmzUZC
@shipyard-local
shipyard-local Bot disabled auto-merge July 23, 2026 06:58
@danielraffel
danielraffel force-pushed the fix/shader-effect-layer-crop branch from 23c9b89 to cd3440e Compare July 23, 2026 06:58
@danielraffel
danielraffel self-requested a review as a code owner July 23, 2026 06:58
@shipyard-local
shipyard-local Bot enabled auto-merge July 23, 2026 07:02
@shipyard-local
shipyard-local Bot added this pull request to the merge queue Jul 23, 2026
Merged via the queue into main with commit fe0a04c Jul 23, 2026
34 of 43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant