Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions gpu/fallback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//go:build !nogpu

package gpu_test

import (
"errors"
"image"
"testing"

"github.com/gogpu/gg"
_ "github.com/gogpu/gg/gpu"
)

// Registering the accelerator must not cost the caller its geometry.
//
// The queueing operations used to accept a draw without checking that a device
// could be had, which made Context.doStroke skip the software rasterizer; the
// failure surfaced at Flush, by which time the draw had nowhere left to go. On
// a machine with no adapter — a container, a CI runner, a VM — that turned
// every filled and stroked path into nothing at all, silently, while text
// (which checks before it queues) still came out.
//
// The property is the one the package documents: with the accelerator
// registered, a chart still renders. It holds on hardware too, where the draw
// goes to the GPU and comes back.
// CI runs this package without the nogpu tag, including the fallback on runners
// without an adapter; the selected backend is allowed to vary by machine.
func TestAPathIsDrawnWithOrWithoutADevice(t *testing.T) {
t.Logf("accelerator registered: %v", gg.Accelerator() != nil)

draw := func(t *testing.T) image.Image {
t.Helper()
c := gg.NewContext(60, 40)
t.Cleanup(func() { _ = c.Close() })
c.ClearPath()
c.MoveTo(5, 35)
c.LineTo(30, 5)
c.LineTo(55, 35)
c.SetStroke(gg.Stroke{Width: 3})
c.SetColor(gg.RGBA{R: 1, A: 1})
if err := c.Stroke(); err != nil {
t.Fatalf("Stroke: %v", err)
}
if err := c.FlushGPU(); err != nil && !errors.Is(err, gg.ErrFallbackToCPU) {
t.Fatalf("FlushGPU: %v", err)
}
return c.Image()
}

img := draw(t)
b := img.Bounds()
first := img.At(b.Min.X, b.Min.Y)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
if img.At(x, y) != first {
return
}
}
}
t.Error("the stroked path is not in the buffer: every pixel is the same color")
}
74 changes: 74 additions & 0 deletions internal/gpu/device_readiness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2026 The gogpu Authors
// SPDX-License-Identifier: MIT

//go:build !nogpu

package gpu

import (
"errors"
"testing"

"github.com/gogpu/gg"
)

// Having a device object is not sufficient to accept a draw: device setup
// must be complete. Conversely, rasterAtlas needs no shape pipelines.
// Use the noop backend so both readiness states run on every machine.
func TestDrawOperationsRequireReadyDevice(t *testing.T) {
for _, ready := range []bool{false, true} {
state := "not_ready"
if ready {
state = "raster_atlas_ready"
}
for _, operation := range []string{"fill_path", "stroke_path", "fill_shape", "stroke_shape"} {
t.Run(state+"/"+operation, func(t *testing.T) {
device, queue, cleanup := createNoopDevice(t)
t.Cleanup(cleanup)
s := NewGPUShared()
s.device = device
s.queue = queue
s.deviceReady = ready
s.strategy = strategyRasterAtlas
rc := s.NewRenderContext()
t.Cleanup(rc.Close)
rc.pipelineMode = gg.PipelineModeRenderPass
paint := gg.NewPaint()
paint.SetStroke(gg.Stroke{Width: 3})
path := gg.NewPath()
path.MoveTo(5, 35)
path.LineTo(30, 5)
path.LineTo(55, 35)
path.Close()
shape := gg.DetectedShape{Kind: gg.ShapeRect, CenterX: 30, CenterY: 20, Width: 40, Height: 20}
target := makeTestTarget(60, 40)
var err error
switch operation {
case "fill_path":
err = rc.FillPath(target, path, paint)
case "stroke_path":
err = rc.StrokePath(target, path, paint)
case "fill_shape":
err = rc.FillShape(target, shape, paint)
case "stroke_shape":
err = rc.StrokeShape(target, shape, paint)
}
if !ready {
if !errors.Is(err, gg.ErrFallbackToCPU) {
t.Errorf("draw returned %v, want ErrFallbackToCPU", err)
}
if len(rc.pendingDraws) != 0 || rc.hasPendingTarget {
t.Error("draw queued before device setup completed")
}
return
}
if err != nil {
t.Errorf("draw returned %v with a ready rasterAtlas device", err)
}
if len(rc.pendingDraws) != 1 || !rc.hasPendingTarget {
t.Error("draw was not queued with a ready rasterAtlas device")
}
})
}
}
}
49 changes: 49 additions & 0 deletions internal/gpu/empty_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2026 The gogpu Authors
// SPDX-License-Identifier: MIT

//go:build !nogpu

package gpu

import (
"testing"

"github.com/gogpu/gg"
)

// Empty paths must be no-ops even before a device has been initialized.
// In particular, they must not request a CPU fallback or queue an empty draw.
func TestEmptyPathsDoNotInitializeDevice(t *testing.T) {
for _, mode := range []gg.PipelineMode{gg.PipelineModeAuto, gg.PipelineModeRenderPass, gg.PipelineModeCompute} {
for _, operation := range []string{"fill", "stroke", "dashed_stroke"} {
t.Run(mode.String()+"/"+operation, func(t *testing.T) {
s := NewGPUShared()
t.Cleanup(s.Close)
rc := s.NewRenderContext()
t.Cleanup(rc.Close)
rc.pipelineMode = mode
paint := gg.NewPaint()
if operation == "dashed_stroke" {
paint.SetStroke(gg.DashedStroke(2, 2))
}
path := gg.NewPath()
target := makeTestTarget(60, 40)
var err error
if operation == "fill" {
err = rc.FillPath(target, path, paint)
} else {
err = rc.StrokePath(target, path, paint)
}
if err != nil {
t.Errorf("empty path returned %v, want nil", err)
}
if s.instance != nil || s.device != nil || s.deviceReady {
t.Error("empty path initialized GPU resources")
}
if len(rc.pendingDraws) != 0 || rc.hasPendingTarget {
t.Error("empty path queued work")
}
})
}
}
}
68 changes: 66 additions & 2 deletions internal/gpu/gpu_render_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,24 @@ func (rc *GPURenderContext) QueueGlyphMask(target gg.GPURenderTarget, batch Glyp
rc.hasPendingTarget = true
}

// deviceUsable reports whether a device can actually be had, initializing one
// if that has not been tried yet.
//
// It asks for deviceReady rather than gpuReady, which is what the queueing
// operations need: under strategyRasterAtlas the shape pipelines are
// deliberately absent and Flush dispatches the queue on the CPU, so a queued
// draw still reaches the buffer. What must not happen is queueing with no
// device at all, where nothing will dispatch it.
func (rc *GPURenderContext) deviceUsable() bool {
rc.shared.mu.Lock()
defer rc.shared.mu.Unlock()
if rc.shared.deviceReady {
return true
}
err := rc.shared.ensureGPU()
return err == nil && rc.shared.deviceReady
}

// DrawText shapes and queues text for MSDF rendering (Tier 4).
func (rc *GPURenderContext) DrawText(target gg.GPURenderTarget, face any, s string, x, y float64, color gg.RGBA, matrix gg.Matrix, deviceScale float64) error {
textFace, ok := face.(text.Face)
Expand Down Expand Up @@ -696,6 +714,11 @@ func (rc *GPURenderContext) drawShapedGlyphMaskText(target gg.GPURenderTarget, f
// The path is cloned at queue time so the caller can reuse or mutate it.
// At Flush time, the path is dispatched to either GPU (convex/stencil) or CPU.
func (rc *GPURenderContext) FillPath(target gg.GPURenderTarget, path *gg.Path, paint *gg.Paint) error {
// An empty draw needs neither a device nor a CPU fallback.
if path.NumVerbs() == 0 {
return nil
}

rc.sceneStats.PathCount++
rc.sceneStats.ShapeCount++

Expand All @@ -710,6 +733,16 @@ func (rc *GPURenderContext) FillPath(target gg.GPURenderTarget, path *gg.Path, p
}
}

// Refuse the work while no device can be had, rather than queueing it.
// A draw accepted here is drawn by nothing else: doFill and doStroke treat
// a nil return as "the GPU has it" and skip the software rasterizer, so a
// failure discovered later — at Flush, where ensureGPU is called today —
// loses the geometry silently. DrawText already checks before it queues;
// these four did not.
if !rc.deviceUsable() {
return gg.ErrFallbackToCPU
}

if rc.hasPendingTarget && !sameTarget(&rc.pendingTarget, &target) {
if err := rc.Flush(rc.pendingTarget); err != nil {
return err
Expand Down Expand Up @@ -741,6 +774,11 @@ func (rc *GPURenderContext) FillPath(target gg.GPURenderTarget, path *gg.Path, p
// StrokePath queues a stroked path as a backend-agnostic draw command (ADR-051).
// Dashed strokes fall back to CPU. The path is cloned at queue time.
func (rc *GPURenderContext) StrokePath(target gg.GPURenderTarget, path *gg.Path, paint *gg.Paint) error {
// Check before dash fallback and compute routing as well as device setup.
if path.NumVerbs() == 0 {
return nil
}

if paint.IsDashed() {
return gg.ErrFallbackToCPU
}
Expand All @@ -759,8 +797,14 @@ func (rc *GPURenderContext) StrokePath(target gg.GPURenderTarget, path *gg.Path,
}
}

if path.NumVerbs() == 0 {
return nil
// Refuse the work while no device can be had, rather than queueing it.
// A draw accepted here is drawn by nothing else: doFill and doStroke treat
// a nil return as "the GPU has it" and skip the software rasterizer, so a
// failure discovered later — at Flush, where ensureGPU is called today —
// loses the geometry silently. DrawText already checks before it queues;
// these four did not.
if !rc.deviceUsable() {
return gg.ErrFallbackToCPU
}

if rc.hasPendingTarget && !sameTarget(&rc.pendingTarget, &target) {
Expand Down Expand Up @@ -808,6 +852,16 @@ func (rc *GPURenderContext) FillShape(target gg.GPURenderTarget, shape gg.Detect
}
}

// Refuse the work while no device can be had, rather than queueing it.
// A draw accepted here is drawn by nothing else: doFill and doStroke treat
// a nil return as "the GPU has it" and skip the software rasterizer, so a
// failure discovered later — at Flush, where ensureGPU is called today —
// loses the geometry silently. DrawText already checks before it queues;
// these four did not.
if !rc.deviceUsable() {
return gg.ErrFallbackToCPU
}

if rc.hasPendingTarget && !sameTarget(&rc.pendingTarget, &target) {
if err := rc.Flush(rc.pendingTarget); err != nil {
return err
Expand Down Expand Up @@ -852,6 +906,16 @@ func (rc *GPURenderContext) StrokeShape(target gg.GPURenderTarget, shape gg.Dete
}
}

// Refuse the work while no device can be had, rather than queueing it.
// A draw accepted here is drawn by nothing else: doFill and doStroke treat
// a nil return as "the GPU has it" and skip the software rasterizer, so a
// failure discovered later — at Flush, where ensureGPU is called today —
// loses the geometry silently. DrawText already checks before it queues;
// these four did not.
if !rc.deviceUsable() {
return gg.ErrFallbackToCPU
}

if rc.hasPendingTarget && !sameTarget(&rc.pendingTarget, &target) {
if err := rc.Flush(rc.pendingTarget); err != nil {
return err
Expand Down
Loading