From 26abe0d5bc3cb9a8b322d46c93a9425e631eac98 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:22:26 +0200 Subject: [PATCH] fix(gpu): premultiply stencil colors only once --- internal/gpu/render_session.go | 8 +---- internal/gpu/stencil_alpha_test.go | 55 +++++++++++++++++++++++++++++ internal/gpu/stencil_render_test.go | 2 +- internal/gpu/stencil_renderer.go | 28 ++++++++------- 4 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 internal/gpu/stencil_alpha_test.go diff --git a/internal/gpu/render_session.go b/internal/gpu/render_session.go index f66c6f17..ddf6af12 100644 --- a/internal/gpu/render_session.go +++ b/internal/gpu/render_session.go @@ -1673,12 +1673,6 @@ func (s *GPURenderSession) buildStencilResourcesBatch(paths []StencilPathCommand result := make([]*stencilCoverBuffers, len(paths)) for i := range paths { cmd := &paths[i] - color := gg.RGBA{ - R: float64(cmd.Color[0]), - G: float64(cmd.Color[1]), - B: float64(cmd.Color[2]), - A: float64(cmd.Color[3]), - } // Destroy old pooled entry and create fresh buffers. // Stencil paths vary wildly per frame (different vertex counts, colors), @@ -1687,7 +1681,7 @@ func (s *GPURenderSession) buildStencilResourcesBatch(paths []StencilPathCommand s.stencilBufPool[i].destroy() s.stencilBufPool[i] = nil } - bufs, err := s.stencilRenderer.createRenderBuffers(w, h, cmd.Vertices, cmd.CoverQuad, color) + bufs, err := s.stencilRenderer.createRenderBuffers(w, h, cmd.Vertices, cmd.CoverQuad, cmd.Color) if err != nil { // Clean up buffers created in this batch. for j := 0; j < i; j++ { diff --git a/internal/gpu/stencil_alpha_test.go b/internal/gpu/stencil_alpha_test.go new file mode 100644 index 00000000..75c690dd --- /dev/null +++ b/internal/gpu/stencil_alpha_test.go @@ -0,0 +1,55 @@ +//go:build !nogpu + +package gpu + +import ( + "fmt" + "testing" + + "github.com/gogpu/gg" +) + +// Exercise the color all the way from the paint, through tessellation, to +// the cover shader's uniform. This requires no device; opaque colors alone +// would not detect the second alpha multiplication that darkened violins. +func TestStencilCoverPreservesPremultipliedColor(t *testing.T) { + for _, kind := range []drawCommandKind{drawCmdFillPath, drawCmdStrokePath} { + for _, alpha := range []float64{0, 0.125, 0.35, 0.5, 0.75, 1} { + t.Run(fmt.Sprintf("kind=%d/alpha=%g", kind, alpha), func(t *testing.T) { + path := gg.NewPath() + path.MoveTo(8, 8) + path.LineTo(56, 8) + path.LineTo(40, 24) + path.LineTo(56, 56) + path.LineTo(8, 56) + path.Close() + fill := gg.RGBA{R: 0.2, G: 0.5, B: 0.8, A: alpha} + stroke := gg.RGBA{R: 0.9, G: 0.4, B: 0.1, A: alpha} + paint := gg.NewPaint() + paint.SetFillBrush(gg.Solid(fill)) + paint.SetStrokeBrush(gg.Solid(stroke)) + paint.SetStroke(gg.Stroke{Width: 4, Cap: gg.LineCapButt, Join: gg.LineJoinMiter, MiterLimit: 10}) + cmd := drawCommand{kind: kind, path: path, paint: *paint} + rc := &GPURenderContext{} + source := fill + if kind == drawCmdStrokePath { + source = stroke + rc.preTessellateStroke(&cmd) + } else { + rc.preTessellateFill(&cmd) + } + if cmd.stencilCmd == nil { + t.Fatal("the path did not reach stencil-then-cover") + } + uniform := makeCoverUniform(64, 64, cmd.stencilCmd.Color) + want := [4]float32{float32(source.R * alpha), float32(source.G * alpha), float32(source.B * alpha), float32(alpha)} + for i, expected := range want { + got := decodeFloat32(uniform[16+i*4 : 20+i*4]) + if testAbs32(got-expected) > 1e-6 { + t.Errorf("cover channel %d = %g, want %g (one alpha multiplication)", i, got, expected) + } + } + }) + } + } +} diff --git a/internal/gpu/stencil_render_test.go b/internal/gpu/stencil_render_test.go index 8b87b4e8..aac25915 100644 --- a/internal/gpu/stencil_render_test.go +++ b/internal/gpu/stencil_render_test.go @@ -274,7 +274,7 @@ func TestMakeStencilFillUniform(t *testing.T) { } func TestMakeCoverUniform(t *testing.T) { - color := gg.RGBA{R: 1.0, G: 0.5, B: 0.25, A: 0.8} + color := [4]float32{0.8, 0.4, 0.2, 0.8} // already premultiplied buf := makeCoverUniform(1920, 1080, color) if len(buf) != coverUniformSize { t.Fatalf("expected %d bytes, got %d", coverUniformSize, len(buf)) diff --git a/internal/gpu/stencil_renderer.go b/internal/gpu/stencil_renderer.go index 1953130f..8cf7a1f6 100644 --- a/internal/gpu/stencil_renderer.go +++ b/internal/gpu/stencil_renderer.go @@ -244,7 +244,13 @@ func (sr *StencilRenderer) RenderPath(target gg.GPURenderTarget, path *gg.Path, } // Create GPU buffers and bind groups for the render pass. - bufs, err := sr.createRenderBuffers(w, h, fanVerts, tess.CoverQuad(), color) + // RenderPath accepts straight alpha; the buffer builder shares the + // premultiplied color contract used by queued StencilPathCommands. + premul := [4]float32{ + float32(color.R * color.A), float32(color.G * color.A), + float32(color.B * color.A), float32(color.A), + } + bufs, err := sr.createRenderBuffers(w, h, fanVerts, tess.CoverQuad(), premul) if err != nil { return err } @@ -268,9 +274,9 @@ func (sr *StencilRenderer) ensureReady(w, h uint32) error { } // createRenderBuffers creates vertex buffers, uniform buffers, and bind groups -// for a stencil-then-cover render pass. +// for a stencil-then-cover render pass. color is already premultiplied. func (sr *StencilRenderer) createRenderBuffers( - w, h uint32, fanVerts []float32, coverQuad [12]float32, color gg.RGBA, + w, h uint32, fanVerts []float32, coverQuad [12]float32, color [4]float32, ) (*stencilCoverBuffers, error) { b := &stencilCoverBuffers{ fanVertexCount: uint32(len(fanVerts) / 2), //nolint:gosec // len/2 fits uint32 @@ -518,21 +524,17 @@ func makeStencilFillUniform(w, h uint32) []byte { // makeCoverUniform creates the 32-byte uniform buffer for the cover pass. // Layout: viewport (vec2) + padding (vec2) + color (vec4, premultiplied alpha). -func makeCoverUniform(w, h uint32, color gg.RGBA) []byte { +func makeCoverUniform(w, h uint32, color [4]float32) []byte { buf := make([]byte, coverUniformSize) binary.LittleEndian.PutUint32(buf[0:4], math.Float32bits(float32(w))) binary.LittleEndian.PutUint32(buf[4:8], math.Float32bits(float32(h))) // padding bytes 8..15 are zero - // Premultiply alpha for GPU blending. - premulR := float32(color.R * color.A) - premulG := float32(color.G * color.A) - premulB := float32(color.B * color.A) - premulA := float32(color.A) - binary.LittleEndian.PutUint32(buf[16:20], math.Float32bits(premulR)) - binary.LittleEndian.PutUint32(buf[20:24], math.Float32bits(premulG)) - binary.LittleEndian.PutUint32(buf[24:28], math.Float32bits(premulB)) - binary.LittleEndian.PutUint32(buf[28:32], math.Float32bits(premulA)) + // The command already carries premultiplied RGB. Applying alpha here + // again would darken every translucent stencil fill and stroke. + for i, channel := range color { + binary.LittleEndian.PutUint32(buf[16+i*4:20+i*4], math.Float32bits(channel)) + } return buf }