Skip to content

fix(render): match browser box-shadow falloff and premultiplied alpha - #32

Open
leshchenko1979 wants to merge 1 commit into
Macawls:mainfrom
leshchenko1979:fix/shadow-blur
Open

leshchenko1979 wants to merge 1 commit into
Macawls:mainfrom
leshchenko1979:fix/shadow-blur

Conversation

@leshchenko1979

Copy link
Copy Markdown
Contributor

What

Three defects in the PNG shadow path, all measured against Chromium.

1. The blur radius was the CSS value itself. A CSS blur radius b is a Gaussian with σ = b/2; four box passes of radius r have variance 4(r²+r)/3. Passing b straight through made every shadow too tight, and even the correctly derived radius left ~2 % of pixels wrong at blur 48 — the calibration constant k = 1.2 is what closes it. shadowBlurRadius() now does both steps. Scratch-canvas padding follows the real spread (2*radius+2) instead of a guessed blur*3.

2. blendOver wrote straight-alpha RGB into a premultiplied image.RGBA. It divided by the output alpha, so every partially transparent composite was invalid: rgba(255,0,0,0.8) came out (101,51,51) on white instead of (255,51,51) — a 60 % loss in the red channel. Channels now stay premultiplied. This also affected gradient stops and any translucent border.

3. Inset shadows were a linear ring ramp. Not the Gaussian CSS specifies, and it degraded with blur. They are now the blurred complement of a hole, reusing the calibrated blur: edge alpha at half, fading inward.

Plus a latent double release: boxBlurAlpha returns its input unchanged when the radius is 0, and the caller released it anyway — one buffer entered the alpha pool twice and the next two acquisitions aliased.

Measured

Nine dedicated shadow cases (inset/outset, blur 0/8/24/48, offset, spread, semi-transparent colour), pixel diff > 32 per channel against headless Chromium:

case before after
inset plain 0.62 % 0.00 %
inset blur 8 4.19 % 0.00 %
inset blur 24 10.98 % 0.00 %
inset blur 48 19.06 % 0.00 %
inset offset 6.35 % 0.00 %
inset spread 8.34 % 0.00 %
inset rgba 0.8 7.49 % 0.00 %
outset rgba 0.8 6.56 % 0.00 %
outset blur 0 0.69 % 0.00 %

The 21-case deck harness: 11-shadow 2.35 % → 0.00 %, every other case byte-identical (no collateral change).

Tests

render/shadow_png_test.go — all fail on main:

  • TestBlendOverReturnsPremultiplied — the premultiplied contract, asserted on both a transparent and an opaque destination.
  • TestBlendOverOpaqueSourceIsPassthrough
  • TestShadowBlurRadiusCalibration — blur 0/2/8/24/48.
  • TestOutsetShadowSemiTransparentColorIsNotDarkened — rgba(255,0,0,0.8) must render (255,51,51).
  • TestInsetShadowFalloffMatchesBrowser — half-darkness distance at blur 8/24/48 within 2 px of Chromium's measured 3/9/17 px, centre stays white.
  • TestShadowBlurZeroRepeatedRendersAreStable — catches the double release through pooled-buffer aliasing.

go test ./... and go vet ./... pass.

Fixes #29

Box shadows were off by up to 19 % of pixels against Chromium, for two
independent reasons.

The blur radius passed to boxBlurAlpha was the CSS blur value itself. A CSS
blur radius b is a Gaussian with sigma = b/2, while four box passes of radius
r have variance 4(r^2+r)/3, so the radius has to be derived from that equation
and then calibrated: the derived value alone still left 2 % of pixels wrong at
blur 48. shadowBlurRadius() now applies the calibration (k = 1.2) and matches
Chromium at 0.000 % for blur 0..48. The scratch canvas padding follows the
actual spread (2*radius+2) instead of a guessed blur*3.

blendOver() returned straight-alpha RGB (dividing by the output alpha) but
wrote into image.RGBA, which stores premultiplied samples. Every partially
transparent composite was therefore invalid: rgba(255,0,0,0.8) rendered as
(101,51,51) on white instead of (255,51,51), a 60 % loss in the red channel.
Channels now stay premultiplied.

Inset shadows were drawn by a ring loop that ramps alpha linearly from the
edge, which is not the Gaussian falloff CSS specifies (1.9-11 % of pixels off,
worse as blur grows). They are now the blurred complement of a hole, reusing
the same calibrated blur: edge alpha lands at half, fading inward, matching
Chromium within 2 px at blur 8/24/48.

Also fixes a latent double release: boxBlurAlpha returns its input unchanged
when the radius is 0, and the caller released it anyway, putting one buffer in
the alpha pool twice so the next two acquisitions aliased.

Measured against Chromium over nine shadow cases (inset and outset, blur
0..48, offset, spread, semi-transparent colour): 0.62-19.06 % of pixels
differing before, 0.00 % after. The 21-case deck harness keeps 11-shadow at
0.00 % (was 2.35 %) with no other case moving.

Fixes Macawls#29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

box-shadow blur is ~1.5x too wide: CSS blur radius used as box-blur radius

1 participant