Skip to content

fix(gpu): leave gradient fills and strokes to the CPU - #539

Open
timzifer wants to merge 1 commit into
gogpu:mainfrom
timzifer:fix/gpu-gradient-cpu-fallback
Open

timzifer wants to merge 1 commit into
gogpu:mainfrom
timzifer:fix/gpu-gradient-cpu-fallback

Conversation

@timzifer

Copy link
Copy Markdown

Problem

With a GPU accelerator registered, a gradient fill comes out as one flat colour: the gradient's first stop.

The GPU tiers paint every draw in a single colour. getFillColorFromPaint / getStrokeColorFromPaint (in internal/gpu/sdf_gpu.go) take the inline solid colour, and for any other brush they fall back to ColorAt(0, 0). So a LinearGradientBrush handed to FillShape, FillPath, StrokeShape or StrokePath is painted as one solid block. Nothing in tryGPUFill or tryGPUStroke checks the brush before the accelerator gets it.

Fix

tryGPUFill and tryGPUStroke now return ErrFallbackToCPU when the paint side they draw is not a single colour. The CPU rasterizer samples the brush per pixel. doFill and doStroke already flush pending GPU work before a CPU draw, so draw order is kept.

Solid draws are unaffected. That covers an inline solid colour, a SolidBrush, and the default brush.

Minimal example

dc := gg.NewContext(100, 40) // with github.com/gogpu/gg/gpu imported
grad := gg.NewLinearGradientBrush(0, 0, 100, 0).
	AddColorStop(0, gg.RGBA{R: 1, A: 1}).
	AddColorStop(1, gg.RGBA{B: 1, A: 1})
dc.SetFillBrush(grad)
dc.DrawRectangle(0, 0, 100, 40)
_ = dc.Fill()
_ = dc.FlushGPU()
// v0.52.5: solid red. With this PR: red to blue.

In the wild: figure

We hit this in figure, a grammar-of-graphics plotting library for Go that renders through gg. On the GPU tier, its colour bars are drawn as gradient fills and came out as a solid block of the ramp's first colour.

The chart below is a heatmap of a device's gain with isolines, rendered with figure's GPU tier (github.com/timzifer/figure/backend/gg/gpu):

GPU, v0.52.5 GPU, this PR CPU reference
gradient gpu before gradient gpu after gradient cpu reference

figure builds against our fork (timzifer/gg, tag v0.52.6-figure.3) until this lands upstream.

Tests

New gradient_gpu_fallback_test.go registers an accelerator that claims every operation, counts what reaches it and paints nothing:

  • TestGradientFillsAreLeftToTheCPU: a gradient fill must not reach the accelerator, and the painted pixels must actually go from red to blue. Without the fix, the fill reaches the accelerator and nothing is painted.
  • TestGradientStrokesAreLeftToTheCPU: the same rule for strokes.
  • TestSolidFillsStillReachTheGPU: solid fills still go to the accelerator.

go test -race ./... and golangci-lint run --timeout=5m pass (Windows amd64, Go 1.25.3).

Possible follow-up

Real gradient support in the GPU tiers (per-vertex or per-fragment colour) would keep these draws on the GPU. This PR only makes them correct.

The GPU tiers paint every draw in one colour, read from the brush at
(0, 0), so a linear gradient handed to them rendered as a solid block
of its first stop: a colour bar came out as one colour.

tryGPUFill and tryGPUStroke now return ErrFallbackToCPU when the paint
side they draw is not a single colour. The CPU samples the brush per
pixel, and doFill/doStroke flush pending GPU work before it draws, so
draw order is kept. Solid draws still go to the GPU.
@timzifer
timzifer requested a review from kolkov as a code owner September 11, 2026 08:17
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.

1 participant