software.go
Functions
func Fill
func (r *SoftwareRenderer) Fill(pixmap *Pixmap, p *Path, paint *Paint) error {
// Non-AA path: completely separate code path (Skia/tiny-skia pattern).
// Integer scanline, binary coverage, no CoverageFiller/AnalyticFiller.
if !r.antiAlias {
return r.fillNoAA(pixmap, p, paint)
}
// Force mode: specific algorithm without auto-selection.
switch r.rasterizerMode {
case RasterizerAnalytic:
// Skip CoverageFiller entirely → always use AnalyticFiller below.
case RasterizerSparseStrips:
if filler := r.forcedFiller(RasterizerSparseStrips); filler != nil {
r.fillWithCoverageFiller(pixmap, p, paint, filler)
return nil
}
case RasterizerTileCompute:
if filler := r.forcedFiller(RasterizerTileCompute); filler != nil {
r.fillWithCoverageFiller(pixmap, p, paint, filler)
return nil
}
default: // RasterizerAuto
// Auto-selection: use CoverageFiller for complex paths.
if filler := GetCoverageFiller(); filler != nil && shouldUseTileRasterizer(p) {
r.fillWithCoverageFiller(pixmap, p, paint, filler)
return nil
}
}
// AnalyticFiller path (scanline) — simple paths, forced analytic, or no filler
r.edgeBuilder.Reset()
r.analyticFiller.Reset()
// Clip paths to canvas bounds to prevent FDot6→FDot16 integer overflow.
// At aaShift=4, coordinates > 2048px overflow int32 in FDot16, causing
// silent wrap-around that places edges at wrong positions (RAST-010).
// Small margin for AA bleed — coordinates at canvas+2px are still well
// within safe range.
clipMargin := float32(2)
clipRect := raster.Rect{
MinX: -clipMargin,
MinY: -clipMargin,
MaxX: float32(pixmap.Width()) + clipMargin,
MaxY: float32(pixmap.Height()) + clipMargin,
}
r.edgeBuilder.SetClipRect(&clipRect)
// Flatten curves to line segments for the AnalyticFiller.
// Forward differencing (QuadraticEdge/CubicEdge) can produce zero-height
// segments after FDot6 rounding, silently losing winding contribution.
// Pre-flattening with adaptive subdivision (0.1px tolerance) eliminates
// this class of errors. This is the standard approach in tiny-skia and
// Skia's analytic AA scanline rasterizer.
r.edgeBuilder.SetFlattenCurves(true)
defer r.edgeBuilder.SetFlattenCurves(false)
// Build edges from the path directly from float64 coords (zero-alloc).
// PathVerb values match between gg and raster packages (both 0-4 iota).
// gg.PathVerb is byte, so []PathVerb has identical memory layout to []byte.
verbs := p.Verbs()
if len(verbs) > 0 {
verbBytes := unsafe.Slice((*byte)(unsafe.Pointer(unsafe.SliceData(verbs))), len(verbs))
r.edgeBuilder.BuildFromPathF64(verbBytes, p.Coords())
}
// If no edges, nothing to fill
if r.edgeBuilder.IsEmpty() {
return nil
}
// Convert fill rule
coreFillRule := raster.FillRuleNonZero
if paint.FillRule == FillRuleEvenOdd {
coreFillRule = raster.FillRuleEvenOdd
}
if color, ok := solidColorFromPaint(paint); ok {
// Fast path: solid color
clipFn := paint.ClipCoverage
maskFn := paint.MaskCoverage
r.analyticFiller.Fill(r.edgeBuilder, coreFillRule, func(y int, runs *raster.AlphaRuns) {
r.blendAlphaRunsFromCoreRuns(pixmap, y, runs, color, clipFn, maskFn)
})
} else {
// Pattern/gradient path: per-pixel color sampling
clipFn := paint.ClipCoverage
maskFn := paint.MaskCoverage
r.analyticFiller.Fill(r.edgeBuilder, coreFillRule, func(y int, runs *raster.AlphaRuns) {
r.blendAlphaRunsFromCoreRunsPaint(pixmap, y, runs, paint, clipFn, maskFn)
})
}
return nil
}
func NewSoftwareRenderer
NewSoftwareRenderer creates a new software renderer with analytic anti-aliasing.
func NewSoftwareRenderer(width, height int) *SoftwareRenderer {
eb := raster.NewEdgeBuilder(2) // 4x AA (Skia default), max coord 8191px
return &SoftwareRenderer{
edgeBuilder: eb,
analyticFiller: raster.NewAnalyticFiller(width, height),
width: width,
height: height,
deviceScale: 1.0,
antiAlias: true,
}
}
func Resize
Resize updates the renderer dimensions (physical pixels).
This should be called when the context is resized.
func (r *SoftwareRenderer) Resize(width, height int) {
r.width = width
r.height = height
eb := raster.NewEdgeBuilder(2) // 4x AA (Skia default), max coord 8191px
if r.deviceScale > 1.0 {
eb.SetFlattenTolerance(0.1 / r.deviceScale)
}
r.edgeBuilder = eb
r.analyticFiller = raster.NewAnalyticFiller(width, height)
// Reset lazy no-AA resources so they pick up new dimensions.
r.noAAFiller = nil
r.noAAEdgeBuilder = nil
}
func SetAntiAlias
SetAntiAlias enables or disables anti-aliasing for subsequent Fill/Stroke calls.
When disabled, the NoAAFiller (integer scanline, binary coverage) is used
instead of the AnalyticFiller or CoverageFiller.
This method is intended for use by the scene renderer which needs to
propagate per-draw AA state decoded from TagSetAntiAlias commands.
func (r *SoftwareRenderer) SetAntiAlias(enabled bool) {
r.antiAlias = enabled
}
func SetDeviceScale
SetDeviceScale sets the HiDPI device scale factor for the renderer.
When scale > 1.0, curve flattening tolerance is reduced for finer
subdivision on HiDPI displays (femtovg pattern: tol = baseTol / scale).
This produces smoother curves at physical pixel resolution.
func (r *SoftwareRenderer) SetDeviceScale(scale float32) {
if scale <= 0 {
scale = 1.0
}
r.deviceScale = scale
if scale > 1.0 {
r.edgeBuilder.SetFlattenTolerance(0.1 / scale)
}
}
func Stroke
Stroke implements Renderer.Stroke with anti-aliasing support.
Strokes are expanded to fill paths and rendered with the Fill method,
which provides analytic anti-aliased results.
func (r *SoftwareRenderer) Stroke(pixmap *Pixmap, p *Path, paint *Paint) error {
// Get effective line width
width := paint.EffectiveLineWidth()
// Get transform scale for dash pattern scaling
transformScale := paint.TransformScale
if transformScale <= 0 {
transformScale = 1.0
}
// Apply dash pattern if set
// Scale dash pattern by transform scale (Cairo/Skia convention)
pathToDraw := p
if paint.IsDashed() {
dash := paint.EffectiveDash()
if transformScale > 1.0 {
dash = dash.Scale(transformScale)
}
pathToDraw = dashPath(p, dash)
}
// Convert gg.PathVerb to stroke.PathVerb (same layout, just cast)
strokeVerbs := convertVerbsToStroke(pathToDraw.Verbs())
// Create stroke style from paint
// Scale line width by transform scale (path coordinates are already transformed)
effectiveWidth := width * transformScale
if effectiveWidth < 1.0 {
effectiveWidth = 1.0 // Minimum 1px stroke for visibility
}
strokeStyle := stroke.Stroke{
Width: effectiveWidth,
Cap: convertLineCap(paint.EffectiveLineCap()),
Join: convertLineJoin(paint.EffectiveLineJoin()),
MiterLimit: paint.EffectiveMiterLimit(),
}
if strokeStyle.MiterLimit <= 0 {
strokeStyle.MiterLimit = 4.0 // Default
}
// Create stroke expander with tight tolerance for smooth curves.
// 0.1 px base tolerance; on HiDPI, divide by deviceScale for finer curves.
expander := stroke.NewStrokeExpander(strokeStyle)
strokeTol := float64(0.1)
if r.deviceScale > 1.0 {
strokeTol = 0.1 / float64(r.deviceScale)
}
expander.SetTolerance(strokeTol)
// Expand stroke to fill path (SOA: verb+coords in, verb+coords out)
outVerbs, outCoords := expander.Expand(strokeVerbs, pathToDraw.Coords())
// Convert back to gg.Path (reuse scratch to avoid per-stroke allocation).
if r.scratchStrokePath == nil {
r.scratchStrokePath = NewPath()
}
strokeResultToPath(r.scratchStrokePath, outVerbs, outCoords)
// Route stroke fills through AnalyticFiller (Skia AAA scanline).
// Stroke-expanded multi-contour outlines (e.g., closed path → 4 contours)
// require per-scanline winding tracking that the tile-based SparseStripsFiller
// does not support (Vello's strip pipeline uses per-strip fill_gap flags).
// This matches Skia Ganesh which routes strokes through scanline renderers,
// not tile rasterizers. Single-contour strokes work with either filler after
// the expander.go fix (#347), but multi-contour needs scanline.
prevMode := r.rasterizerMode
r.rasterizerMode = RasterizerAnalytic
err := r.Fill(pixmap, r.scratchStrokePath, paint)
r.rasterizerMode = prevMode
return err
}
Structs
type SoftwareRenderer struct
SoftwareRenderer is a CPU-based scanline rasterizer using analytic anti-aliasing.
Analytic AA computes the exact area of the shape within each pixel using
trapezoidal integration. This provides higher quality anti-aliasing than
supersampling approaches, with no extra memory overhead.
type SoftwareRenderer struct {
// Analytic AA components
edgeBuilder *raster.EdgeBuilder
analyticFiller *raster.AnalyticFiller
// Dimensions (physical pixels)
width, height int
// HiDPI device scale factor (1.0 = no scaling).
// Used to adjust curve flattening tolerance for sharper rendering on Retina.
deviceScale float32
// rasterizerMode is set by Context before calling Fill/Stroke
// to support forced algorithm selection (RasterizerSparseStrips, etc.).
// Reset to RasterizerAuto after each call.
rasterizerMode RasterizerMode
// antiAlias is set by Context before calling Fill/Stroke.
// When false, the NoAAFiller (integer scanline, binary coverage) is used
// instead of AnalyticFiller/CoverageFiller. Reset to true after each call.
antiAlias bool
// noAAFiller is the non-anti-aliased filler (lazy-initialized).
noAAFiller *raster.NoAAFiller
// noAAEdgeBuilder is a separate EdgeBuilder with aaShift=0 for non-AA.
// Non-AA does not need sub-pixel edge coordinate shifting.
noAAEdgeBuilder *raster.EdgeBuilder
// scratchStrokePath reuses path allocation across Stroke calls.
// Matches Skia fOuter.reset() pattern — zero per-stroke allocation.
scratchStrokePath *Path
}
Fill implements Renderer.Fill using analytic anti-aliasing.
For complex paths, it auto-selects the registered CoverageFiller (tile-based
rasterizer) when available, using an adaptive threshold based on both path
element count and bounding box area.
When rasterizerMode is set (via Context.SetRasterizerMode), the forced
algorithm is used instead of auto-selection.