Skip to content

fix(gpu): keep draw order across render tiers - #538

Open
timzifer wants to merge 2 commits into
gogpu:mainfrom
timzifer:fix/gpu-draw-order-across-tiers
Open

timzifer wants to merge 2 commits into
gogpu:mainfrom
timzifer:fix/gpu-draw-order-across-tiers

Conversation

@timzifer

@timzifer timzifer commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Under a single clip, the GPU tier does not paint draws in the order they were submitted. buildScissorGroupsFromDraws puts consecutive draws with the same clip into one ScissorGroup. recordGroupDraws then renders a group one tier at a time: SDF shapes first, then convex paths, stencil paths, images and text. As a result, every convex fill of a clip lands before every stencil fill of that clip, whatever order the calls came in.

This breaks any painter's-algorithm scene. When a 3D surface is drawn back to front, its non-convex far faces and its grid lines end up painted over the near faces.

Fix

Start a new group where the render tier changes, as well as where the clip changes. RenderFrameGrouped already renders groups in sequence, so this keeps submission order across tiers:

  • A run of one tier stays in one group, so flat scenes (all convex, all text, …) get exactly the groups they had before.
  • Draws that add nothing to a group (the base layer, empty paths, transparent or unsupported SDF shapes) never split one.
  • The extra cost is one group boundary per tier change, and it applies only where tiers actually alternate.

drawTier mirrors the switch in drawsToScissorGroup, which is what decides the list a draw lands in. A table test checks the exact destination list for every draw command kind, including both path tiers and discarded shapes. A command-kind count requires new kinds to have a corresponding test fixture.

Minimal example

repro-order/main.go depends only on gg and wgpu. It paints three shapes back to front: a blue square, then an orange concave arrow over it, then a green square over the arrow's tip. The squares take the convex fast path and the arrow takes stencil-then-cover. The program renders the scene once on the GPU and once on the CPU and compares the two:

square(20, 20, blue)   // convex
c.MoveTo(30, 60)       // concave arrow → stencil-then-cover
c.LineTo(220, 120)
c.LineTo(30, 180)
c.LineTo(90, 120)
c.ClosePath()
c.SetColor(orange)
_ = c.Fill()
square(120, 90, green) // convex, must cover the arrow's tip
_ = c.FlushGPU()
pixels that differ from the CPU
v0.52.5 5.6 % — the arrow lands on top of the green square
this PR 0.0 % (14 anti-aliasing pixels)
GPU, v0.52.5 GPU, this PR CPU
order gpu before order gpu after order cpu

In the wild: figure

We found this in figure, a grammar-of-graphics plotting library for Go that renders through gg. Its 3D surfaces are sorted back to front and drawn as one quad per cell. Most cells are convex, but some fold and go through the stencil path. On the GPU tier, the folded far cells, the box walls behind the surface and the grid lines were all painted over the near surface.

Here is the same four-camera saddle, rendered with figure's GPU tier (github.com/timzifer/figure/backend/gg/gpu). Both renders already include the NonZero stroke fix:

GPU, v0.52.5 order GPU, this PR
figure surface gpu before order figure surface gpu after order

figure's GPU parity test for this scene drops from 1.2 % to 0.5 % of pixels off the CPU; what remains is anti-aliasing along cell edges. figure builds against our fork (timzifer/gg, tag v0.52.6-figure.2) until this lands upstream.

Tests

  • New internal/gpu/draw_order_test.go:
    • TestScissorGroups_KeepSubmissionOrderAcrossTiers: convex, stencil and convex fills under one clip must become three groups, in order. Without the fix they are one group.
    • TestScissorGroups_SameTierStaysOneGroup: five convex fills under one clip stay one group.
  • TestFlushGPU_DrawsConvertToScissorGroups now expects its SDF shape and convex path in two ordered groups instead of one.
  • TestDrawTierMatchesScissorGroup: 17 cases cover all nine command kinds and compare the reported tier against the exact destination list. Shape cases distinguish fill and stroke alpha and cover unsupported shapes.
  • TestScissorGroups_IgnoredDrawsDoNotSplitRun: base layers, empty paths and discarded shapes at the beginning, middle and end preserve a single convex run. Before the shape-tier correction, all four transparent/unsupported shape variants produced five groups instead of one.
  • go test -race ./... passes (Windows amd64, Go 1.25.3).
  • golangci-lint run --timeout=5m passes.

buildScissorGroupsFromDraws grouped consecutive draws by clip only, and a
ScissorGroup renders its tiers one after another: SDF shapes, convex
paths, stencil paths, images, text. Under one clip every convex fill
therefore landed before every stencil fill, whatever order they came in.
Painter's-algorithm scenes break: a 3D surface drawn back to front had
its non-convex far faces and its grid strokes painted over near faces.

Start a new group where the tier changes as well as where the clip does.
Runs of one tier stay one group, and draws that add nothing to a group
(base layer, empty paths) never split one.

@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.

The change looks right: groups split only where the tier changes, and runs of one tier keep their grouping. One question: drawTier mirrors the switch in drawsToScissorGroup. Would deriving one from the other, or adding a test that fails when a new tier is added to only one of them, keep the two from drifting apart?

@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.

The bug is real and the fix is at the right level. A ScissorGroup renders its lists one after another, so grouping by clip alone does reorder draws inside a group — and painter's-algorithm content (back-to-front surfaces, grid strokes) is exactly the case that shows it.

I checked the grouping logic against the awkward inputs:

  • len(rc.pendingDraws) == 0 returns early, so drawTier(&rc.pendingDraws[0]) cannot index past the slice.
  • A tierNone draw inside a run does not split it (convex, none, convex stays one group), and a group that starts with tierNone picks up the first real tier it meets. Both are right: those draws contribute nothing to the group, so they cannot reorder anything.
  • The clipChanged branch is now end := i in both arms, which is what the old code did with two branches; simplifying it is fine.

TestScissorGroups_SameTierStaysOneGroup is the test I care most about, because it pins the cost: a flat scene must not grow renders. Good that it is there.

One thing is still open from my earlier note, and this PR is the right place to close it. drawTier mirrors the switch in drawsToScissorGroup by hand. If someone adds a draw kind, or moves a kind between lists, in one of the two and not the other, the grouping silently reorders draws again — the exact bug this PR fixes, with no test failing. Two ways out, either is fine:

  • a table test that walks every drawCommandKind and asserts that drawTier says "none" exactly when drawsToScissorGroup([]drawCommand{cmd}) puts nothing in the group; or
  • one function that reports both, so the tier and the placement cannot disagree.

I would take the test: it is a few lines, it survives refactoring of both sides, and it turns this class of regression into a red build.

Approving on the code once that guard is in. CI needs a fresh push for the September-era runs regardless.

@codecov

codecov Bot commented Nov 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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