Skip to content

fix(gpu): premultiply stencil colors only once - #540

Merged
kolkov merged 1 commit into
gogpu:mainfrom
timzifer:fix/gpu-stencil-alpha
Nov 8, 2026
Merged

kolkov merged 1 commit into
gogpu:mainfrom
timzifer:fix/gpu-stencil-alpha

Conversation

@timzifer

Copy link
Copy Markdown
Contributor

Problem

Translucent fills and strokes rendered through the GPU stencil path are darker and grayer than the CPU output. Opaque colors are correct, as are translucent SDF shapes and convex fills, which makes the difference depend on the geometry being drawn.

GPURenderContext.preTessellateFill stores premultiplied RGBA in StencilPathCommand.Color. buildStencilResourcesBatch then puts those values into a straight-alpha gg.RGBA, and makeCoverUniform multiplies RGB by alpha again. Over a background B, the result is alpha² * color + (1 - alpha) * B instead of alpha * color + (1 - alpha) * B.

This affects concave paths, multiple subpaths in one fill, and strokes whose expanded outline takes the stencil path.

Fix

Keep the command's premultiplied [4]float32 color through createRenderBuffers and makeCoverUniform, writing it unchanged to the cover shader's uniform. The standalone StencilRenderer.RenderPath entry point still accepts straight alpha and now premultiplies once before calling the shared buffer builder.

No public API change. The shader, blend state, and SDF/convex paths are unchanged.

Minimal example

With github.com/gogpu/gg/gpu and a hardware backend imported:

c := gg.NewContext(100, 100)
c.ClearWithColor(gg.White)
c.SetRGBA(0, 114.0/255, 178.0/255, 89.0/255)
c.MoveTo(10, 10)
c.LineTo(90, 10)
c.LineTo(60, 50) // inward corner: stencil-then-cover
c.LineTo(90, 90)
c.LineTo(10, 90)
c.ClosePath()
_ = c.Fill()
_ = c.FlushGPU()
// Interior RGB: before (166,180,188), after (166,206,228).
// CPU reference: (166,205,228); the remaining byte is rounding.

repro-alpha/main.go renders SDF rectangles, convex fills, concave fills and disjoint subpaths at five alpha values. Each run also renders a CPU reference. Opaque shapes and the first two rows are controls.

GPU, v0.52.5 GPU, this PR CPU reference
Alpha GPU before Alpha GPU after Alpha CPU reference

In the wild: figure

We found this in figure, a grammar-of-graphics plotting library for Go that renders through gg. Its violin plots use 35% fill opacity. Switching to the GPU made the interiors gray while the opaque outlines and legend colors stayed correct.

repro-alpha-violin/main.go renders the same deterministic gallery example on both tiers:

GPU, v0.52.5 GPU, this PR CPU reference
Violin GPU before Violin GPU after Violin CPU reference

The CPU reference PNGs are byte-identical before and after the change. Reproduction instructions and measured pixels are on the evidence branch; image files and examples are outside this PR's code diff.

Tests

  • TestStencilCoverPreservesPremultipliedColor follows fill and stroke paint through tessellation to the cover uniform at alpha 0, 0.125, 0.35, 0.5, 0.75 and 1. It uses different fill and stroke colors and needs no GPU. Reintroducing the second multiplication makes all eight translucent cases fail.
  • TestMakeCoverUniform now checks the explicit premultiplied input contract.
  • go test -race ./..., go build ./..., and golangci-lint run --timeout=5m pass on the isolated PR branch (Windows amd64, Go 1.25.3).
  • The two examples were also rendered on a working GPU. At alpha 89/255, the previously darkened interior channels now differ from the CPU by at most one byte.

@timzifer
timzifer requested a review from kolkov as a code owner September 14, 2026 04:32
timzifer added a commit to timzifer/figure that referenced this pull request Sep 14, 2026
main has since decided where the gg fork belongs: only backend/gg/gpu
registers the accelerator every fork fix changes, so only that module
carries the replace, and the raster and window backends resolve upstream gg
again. This branch still had the replace in all three, from before that
decision, which is what every conflict was.

Resolved main's way, with the pin moved to v0.52.6-figure.5 — the fork
build this branch needs, which also premultiplies a stencil fill's alpha
once (gogpu/gg#540) — and that PR added to the note above the replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdSCXbhBhWBgUiCns9Abhf

@kolkov kolkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the color path: the stencil command's color is already premultiplied, and makeCoverUniform multiplied it again. Keeping the [4]float32 through to the uniform fixes it without touching the shader or blend state. This also merges cleanly with the fixes queued for the next release, which touch the same two files.

@kolkov

kolkov commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

A follow-up from testing the v0.52.6 release: examples/clip_demo shows exactly the bug you fixed here. Its 15% white orbit ring is invisible on the dark background and draws as a dark line across every circle it crosses, which is the double premultiply on the stencil path. It looks the same on v0.52.5, so it's been there a while.

v0.52.6 is out without this one, only because CI never ran on the branch. A push to start CI is all it needs, and I'd like it in v0.52.7.

@kolkov
kolkov force-pushed the fix/gpu-stencil-alpha branch from 0f8d5df to 26abe0d Compare November 8, 2026 13:43
@codecov

codecov Bot commented Nov 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kolkov kolkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is the right fix and the analysis is right. preTessellateFill/preTessellateStroke store premultiplied RGBA in StencilPathCommand.Color, and makeCoverUniform multiplied by alpha a second time, so a translucent stencil fill or stroke came out as alpha²·color + (1−alpha)·B. Opaque colours were unaffected, which is why it depended on the geometry.

I checked both callers of createRenderBuffers: the batch path already carries a premultiplied [4]float32, and RenderPath takes straight alpha and now premultiplies once before handing over. Both correct, and no other caller exists.

The test walks the colour from the paint through tessellation to the cover uniform, for fill and stroke at six alphas, and it fails on main. That is the coverage this needed.

Merging for v0.52.7.

@kolkov
kolkov merged commit 8021af7 into gogpu:main Nov 8, 2026
14 checks passed
@kolkov kolkov mentioned this pull request Nov 8, 2026
kolkov added a commit that referenced this pull request Nov 8, 2026
…ultiFace architecture note

#540 and #546 shipped without a CHANGELOG entry. The architecture table
still said MultiFace falls back to bitmaps; since #513 each run keeps its
source face and single-font runs stay on the GPU text paths.
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.

2 participants