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
31 changes: 27 additions & 4 deletions internal/raster/edge_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,10 @@ func (eb *EdgeBuilder) VelloLines() []VelloLine {

// addQuad adds quadratic curve edges, chopping at Y extrema if needed.
func (eb *EdgeBuilder) addQuad(x0, y0, cx, cy, x1, y1 float32) {
eb.addQuadRecursive(x0, y0, cx, cy, x1, y1, 0)
}

func (eb *EdgeBuilder) addQuadRecursive(x0, y0, cx, cy, x1, y1 float32, depth int) {
// If flattenCurves is enabled, convert curve to line segments.
// Line clipping (clipAndAddLine) handles overflow prevention.
if eb.flattenCurves {
Expand All @@ -749,6 +753,14 @@ func (eb *EdgeBuilder) addQuad(x0, y0, cx, cy, x1, y1 float32) {
// CoverageFiller (SparseStrips/TileCompute) compatibility.
eb.flattenQuadToVelloLines(x0, y0, cx, cy, x1, y1)

// At large coordinates, float32 midpoints can round back to the input
// points, so subdivision cannot reduce the deviation. Match the depth
// limit of the flattening routines and emit the chord through line clipping.
if depth > 10 {
eb.addLine(x0, y0, x1, y1)
return
}

// Deviation-based subdivision for CPU rendering without MSAA.
//
// Forward-diff with diffToShift(shiftAA=2) produces chord segments
Expand Down Expand Up @@ -777,8 +789,8 @@ func (eb *EdgeBuilder) addQuad(x0, y0, cx, cy, x1, y1 float32) {
my12 := (cy + y1) * 0.5
mx := (mx01 + mx12) * 0.5
my := (my01 + my12) * 0.5
eb.addQuad(x0, y0, mx01, my01, mx, my)
eb.addQuad(mx, my, mx12, my12, x1, y1)
eb.addQuadRecursive(x0, y0, mx01, my01, mx, my, depth+1)
eb.addQuadRecursive(mx, my, mx12, my12, x1, y1, depth+1)
return
}

Expand Down Expand Up @@ -929,6 +941,10 @@ func (eb *EdgeBuilder) flattenQuadRecursive(x0, y0, cx, cy, x1, y1, tolerance fl

// addCubic adds cubic curve edges, chopping at Y extrema if needed.
func (eb *EdgeBuilder) addCubic(x0, y0, c1x, c1y, c2x, c2y, x1, y1 float32) {
eb.addCubicRecursive(x0, y0, c1x, c1y, c2x, c2y, x1, y1, 0)
}

func (eb *EdgeBuilder) addCubicRecursive(x0, y0, c1x, c1y, c2x, c2y, x1, y1 float32, depth int) {
// If flattenCurves is enabled, convert curve to line segments.
// Line clipping (clipAndAddLine) handles overflow prevention.
if eb.flattenCurves {
Expand All @@ -939,6 +955,13 @@ func (eb *EdgeBuilder) addCubic(x0, y0, c1x, c1y, c2x, c2y, x1, y1 float32) {
// Populate VelloLines for CoverageFiller compatibility (VelloLines only, no LineEdges).
eb.flattenCubicToVelloLines(x0, y0, c1x, c1y, c2x, c2y, x1, y1)

// As with quadratics, rounding can make a child identical to its parent.
// Bound subdivision even when the absolute deviation stays above tolerance.
if depth > 10 {
eb.addLine(x0, y0, x1, y1)
return
}

// Deviation-based subdivision for cubics (same as quad — see comment above).
// For cubic, max deviation is at t=1/3 and t=2/3 from the chord.
// Approximate: max deviation ≈ max(|P1 - lerp(P0,P3,1/3)|, |P2 - lerp(P0,P3,2/3)|)
Expand Down Expand Up @@ -966,8 +989,8 @@ func (eb *EdgeBuilder) addCubic(x0, y0, c1x, c1y, c2x, c2y, x1, y1 float32) {
m123y := (m12y + m23y) * 0.5
mx := (m012x + m123x) * 0.5
my := (m012y + m123y) * 0.5
eb.addCubic(x0, y0, m01x, m01y, m012x, m012y, mx, my)
eb.addCubic(mx, my, m123x, m123y, m23x, m23y, x1, y1)
eb.addCubicRecursive(x0, y0, m01x, m01y, m012x, m012y, mx, my, depth+1)
eb.addCubicRecursive(mx, my, m123x, m123y, m23x, m23y, x1, y1, depth+1)
return
}

Expand Down
132 changes: 132 additions & 0 deletions internal/raster/edge_builder_recursion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2026 The gogpu Authors
// SPDX-License-Identifier: MIT

package raster

import (
"context"
"math"
"os"
"os/exec"
"runtime/debug"
"testing"
"time"
)

// Run each regression in a subprocess: an unbounded subdivision causes a
// fatal stack overflow, which recover cannot catch. Limit the child stack so
// a regression fails quickly without exhausting the test runner's memory.
func TestEdgeBuilderCurveSubdivisionTerminates(t *testing.T) {
a := math.Float32frombits(0x4a0bba33)
b := math.Float32frombits(0x4a0bba34)
y := math.Float32frombits(0x43817f0a)
cases := []struct {
name string
verb PathVerb
points []float32
}{
{"cubic_horizontal", CubicTo, []float32{a, y, b, y, b, y, b, y}},
{"cubic_vertical", CubicTo, []float32{y, a, y, b, y, b, y, b}},
{"quad_horizontal", QuadTo, []float32{b, y, a, y, a, y}},
{"quad_vertical", QuadTo, []float32{y, b, y, a, y, a}},
}
for _, tc := range cases {
for _, sign := range []string{"positive", "negative"} {
for _, clip := range []string{"unclipped", "clipped"} {
t.Run(tc.name+"/"+sign+"/"+clip, func(t *testing.T) {
if os.Getenv("GG_CURVE_SUBDIVISION_CHILD") != t.Name() {
runCurveSubdivisionChild(t)
return
}
debug.SetMaxStack(1 << 20)
points := append([]float32(nil), tc.points...)
if sign == "negative" {
for i := range points {
points[i] = -points[i]
}
}
eb := NewEdgeBuilder(2)
eb.SetFlattenCurves(false)
if clip == "clipped" {
eb.SetClipRect(&Rect{MinX: 0, MinY: 0, MaxX: 800, MaxY: 600})
}
eb.BuildFromPath(&testPath{
verbs: []PathVerb{MoveTo, tc.verb},
points: points,
}, IdentityTransform{})
if tc.name == "cubic_horizontal" || tc.name == "quad_horizontal" {
if !eb.IsEmpty() {
t.Errorf("horizontal curve produced %d edges", eb.EdgeCount())
}
}
})
}
}
}
}

func runCurveSubdivisionChild(t *testing.T) {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exe, "-test.run=^"+t.Name()+"$")
cmd.Env = append(os.Environ(), "GG_CURVE_SUBDIVISION_CHILD="+t.Name())
if output, err := cmd.CombinedOutput(); err != nil {
// The full stack dump repeats the same frames many times.
if len(output) > 4096 {
output = output[:4096]
}
t.Fatalf("curve subdivision did not complete: %v\n%s", err, output)
}
}

// A curve far outside the right clip edge still contributes winding to the
// visible fill. Terminating subdivision must not discard that contour edge.
func TestEdgeBuilderLargeCurvesPreserveClippedFill(t *testing.T) {
const a, b = 2289292.75, 2289293.0
cases := []struct {
name string
verb PathVerb
points []float32
}{
{"cubic", CubicTo, []float32{10, 10, a, 10, b, 35, b, 65, b, 90, 10, 90}},
{"quad", QuadTo, []float32{10, 10, b, 10, a, 50, a, 90, 10, 90}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if os.Getenv("GG_CURVE_SUBDIVISION_CHILD") != t.Name() {
runCurveSubdivisionChild(t)
return
}
debug.SetMaxStack(1 << 20)
eb := NewEdgeBuilder(2)
eb.SetFlattenCurves(false)
eb.SetClipRect(&Rect{MinX: -2, MinY: -2, MaxX: 102, MaxY: 102})
eb.BuildFromPath(&testPath{
verbs: []PathVerb{MoveTo, LineTo, tc.verb, LineTo, Close},
points: tc.points,
}, IdentityTransform{})
var pixels [100][100]uint8
NewAnalyticFiller(100, 100).Fill(eb, FillRuleNonZero, func(y int, runs *AlphaRuns) {
for x, alpha := range runs.Iter() {
pixels[y][x] = alpha
}
})
for y := range pixels {
for x, got := range pixels[y] {
var want uint8
if x >= 10 && y >= 10 && y < 90 {
want = 255
}
if got != want {
t.Fatalf("coverage at (%d, %d) = %d, want %d", x, y, got, want)
}
}
}
})
}
}