Files
ebiten/vector/stroke.go
T
Hajime Hoshi 97ebef8298 vector: add (*Path).AddPathStroke reimplement DrawFilledPath and StrokePath
The new implementation uses Evan's method [1] using texture representing
a stencil buffer instead of the graphics library's stencil buffer.
This simplifies the implementaiton of the graphics drivers, and enables
better anti-alias without changing the rendering cost.

Also, this fixes an issue of line rendering quality

This change deprecates some existing APIs like DrawImageOptions.AntiAlias
and FillRule. Users should always use DrawFilledPath or StrokePath.

[1] https://medium.com/@evanwallace/easy-scalable-text-rendering-on-the-gpu-c3f4d782c5ac

Closes #3124
Closes #3153
2025-07-06 23:35:48 +09:00

424 lines
12 KiB
Go

// Copyright 2025 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package vector
import (
"math"
"github.com/hajimehoshi/ebiten/v2"
)
// LineCap represents the way in which how the ends of the stroke are rendered.
type LineCap int
const (
LineCapButt LineCap = iota
LineCapRound
LineCapSquare
)
// LineJoin represents the way in which how two segments are joined.
type LineJoin int
const (
LineJoinMiter LineJoin = iota
LineJoinBevel
LineJoinRound
)
// StrokeOptions is options to render a stroke.
type StrokeOptions struct {
// Width is the stroke width in pixels.
//
// The default (zero) value is 0.
Width float32
// LineCap is the way in which how the ends of the stroke are rendered.
// Line caps are not rendered when the sub-path is marked as closed.
//
// The default (zero) value is [LineCapButt].
LineCap LineCap
// LineJoin is the way in which how two segments are joined.
//
// The default (zero) value is [LineJoinMiter].
LineJoin LineJoin
// MiterLimit is the miter limit for [LineJoinMiter].
// For details, see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-miterlimit.
//
// The default (zero) value is 0.
MiterLimit float32
}
// AddPathStrokeOptions is options for [Path.AddPathStroke].
type AddPathStrokeOptions struct {
// StrokeOptions is options for the stroke.
StrokeOptions
// GeoM is a geometry matrix to apply to the path.
//
// The default (zero) value is an identity matrix.
GeoM ebiten.GeoM
}
// AddPathStroke adds a stroke path to the path p.
//
// The added stroke path must be rendered with FileRuleNonZero.
func (p *Path) AddPathStroke(src *Path, options *AddPathStrokeOptions) {
if options == nil {
return
}
if options.Width <= 0 {
return
}
// Normalize the source path to simplify the logic to generate a stroke path.
src.normalize()
origN := len(p.subPaths)
// p might be the same as src. Use srcN to avoid modifying the overlapped region.
srcN := len(src.subPaths)
for _, subPath := range src.subPaths[:srcN] {
_, sp1, sp2, sp3, sp4 := strokeStartControlPositions(&subPath, options.Width/2)
p.MoveTo(sp4.x, sp4.y)
appendParalleledPathFromSubPath(p, &subPath, &options.StrokeOptions)
_, ep1, ep2, ep3, ep4 := strokeEndControlPositions(&subPath, options.Width/2)
if subPath.closed {
p.Close()
p.MoveTo(ep4.x, ep4.y)
} else {
switch options.LineCap {
case LineCapButt:
p.LineTo(ep4.x, ep4.y)
case LineCapRound:
p.ArcTo(ep1.x, ep1.y, ep2.x, ep2.y, options.Width/2)
p.ArcTo(ep3.x, ep3.y, ep4.x, ep4.y, options.Width/2)
case LineCapSquare:
p.LineTo(ep1.x, ep1.y)
p.LineTo(ep3.x, ep3.y)
p.LineTo(ep4.x, ep4.y)
}
}
appendParalleledPathFromSubPathReversed(p, &subPath, &options.StrokeOptions)
if !subPath.closed {
switch options.LineCap {
case LineCapButt:
p.LineTo(sp4.x, sp4.y)
case LineCapRound:
p.ArcTo(sp1.x, sp1.y, sp2.x, sp2.y, options.Width/2)
p.ArcTo(sp3.x, sp3.y, sp4.x, sp4.y, options.Width/2)
case LineCapSquare:
p.LineTo(sp1.x, sp1.y)
p.LineTo(sp3.x, sp3.y)
p.LineTo(sp4.x, sp4.y)
}
}
p.Close()
}
if options.GeoM != (ebiten.GeoM{}) {
for i, subPath := range p.subPaths[origN:] {
x, y := options.GeoM.Apply(float64(subPath.start.x), float64(subPath.start.y))
p.subPaths[origN+i].start = point{x: float32(x), y: float32(y)}
for j, op := range subPath.ops {
switch op.typ {
case opTypeLineTo:
x1, y1 := options.GeoM.Apply(float64(op.p1.x), float64(op.p1.y))
p.subPaths[origN+i].ops[j].p1 = point{x: float32(x1), y: float32(y1)}
case opTypeQuadTo:
x1, y1 := options.GeoM.Apply(float64(op.p1.x), float64(op.p1.y))
x2, y2 := options.GeoM.Apply(float64(op.p2.x), float64(op.p2.y))
p.subPaths[origN+i].ops[j].p1 = point{x: float32(x1), y: float32(y1)}
p.subPaths[origN+i].ops[j].p2 = point{x: float32(x2), y: float32(y2)}
}
}
}
}
}
func strokeStartControlPositions(subPath *subPath, dist float32) (point, point, point, point, point) {
p := subPath.startAtOp(0)
dir := subPath.startDir(0).inv().norm().mul(dist)
dirPerp := dir.perp()
// TODO: These values are a little tricky. Refactor this.
return p.add(dirPerp), p.add(dir).add(dirPerp), p.add(dir), p.add(dir).add(dirPerp.inv()), p.add(dirPerp.inv())
}
func strokeEndControlPositions(subPath *subPath, dist float32) (point, point, point, point, point) {
p := subPath.endAtOp(len(subPath.ops) - 1)
dir := subPath.endDir(len(subPath.ops) - 1).norm().mul(dist)
dirPerp := dir.perp()
// TODO: These values are a little tricky. Refactor this.
return p.add(dirPerp), p.add(dir).add(dirPerp), p.add(dir), p.add(dir).add(dirPerp.inv()), p.add(dirPerp.inv())
}
func appendParalleledPathFromSubPath(strokePath *Path, subPath *subPath, options *StrokeOptions) {
if len(subPath.ops) == 0 {
panic("not reached")
}
// As the source path is normalized, every operation is guaranteed to be valid.
// A line operation must have a different point from the start point.
// A quadratic curve operation must have create a curve, not a line.
cur := subPath.start
for i, op := range subPath.ops {
switch op.typ {
case opTypeLineTo:
appendParalleledLine(strokePath, cur, op.p1, options.Width/2)
cur = op.p1
case opTypeQuadTo:
appendParalleledQuad(strokePath, cur, op.p1, op.p2, options.Width/2)
cur = op.p2
}
addJoint(strokePath, subPath, i, false, options)
}
}
func appendParalleledPathFromSubPathReversed(strokePath *Path, subPath *subPath, options *StrokeOptions) {
if len(subPath.ops) == 0 {
panic("not reached")
}
// As the source path is normalized, every operation is guaranteed to be valid.
// A line operation must have a different point from the start point.
// A quadratic curve operation must have create a curve, not a line.
for i := len(subPath.ops) - 1; i >= 0; i-- {
op := subPath.ops[i]
nextP := subPath.startAtOp(i)
switch op.typ {
case opTypeLineTo:
appendParalleledLine(strokePath, op.p1, nextP, options.Width/2)
case opTypeQuadTo:
appendParalleledQuad(strokePath, op.p2, op.p1, nextP, options.Width/2)
}
addJoint(strokePath, subPath, i, true, options)
}
}
func appendParalleledLine(path *Path, p0, p1 point, dist float32) {
if p0 == p1 {
panic("not reached")
}
dir := vec2{x: p1.x - p0.x, y: p1.y - p0.y}
v := dir.perp().norm().mul(dist)
pp1 := p1.add(v)
path.LineTo(pp1.x, pp1.y)
}
// appendParalleledLineForQuadIfNeeded appends a paralleled line for a quadratic curve if the quadratic curve is just a line.
func appendParalleledLineForQuadIfNeeded(path *Path, p0, p1, p2 point, dist float32) bool {
if p0 == p1 && p0 == p2 {
panic("not reached")
}
// This curve is empty as the start and the end points are the same.
if p0 == p2 {
return true
}
// This curve is a line as the control point is the same as the start point.
if p0 == p1 || p1 == p2 {
appendParalleledLine(path, p0, p2, dist)
return true
}
// This curve is a line as p0, p1, and p2 are on the same line.
if (p1.x-p0.x)*(p2.y-p0.y)-(p2.x-p0.x)*(p1.y-p0.y) == 0 {
appendParalleledLine(path, p0, p2, dist)
return true
}
return false
}
func appendParalleledQuad(path *Path, p0, p1, p2 point, dist float32) {
if appendParalleledLineForQuadIfNeeded(path, p0, p1, p2, dist) {
return
}
doAppendParalleledQuad(path, p0, p1, p2, dist, 0)
}
func doAppendParalleledQuad(path *Path, p0, p1, p2 point, dist float32, level int) {
if p0 == p1 && p0 == p2 {
return
}
if appendParalleledLineForQuadIfNeeded(path, p0, p1, p2, dist) {
return
}
// B(t) = (1-t)*(1-t)*p0 + 2*(1-t)*t*p1 + t*t*p2
// B'(t) = 2*(1-t)*(p1-p0) + 2*t*(p2-p1)
// B'(0) = 2*(p1-p0)
// B'(0.5) = p2-p0
// B'(1) = 2*(p2-p1)
// B''(t) = 2*(p0 - 2*p1 + p2)
// t = 0
dir0 := vec2{x: p1.x - p0.x, y: p1.y - p0.y}
v0 := dir0.perp().norm().mul(dist)
pp0 := p0.add(v0)
// t = 1
dir2 := vec2{x: p2.x - p1.x, y: p2.y - p1.y}
v2 := dir2.perp().norm().mul(dist)
pp2 := p2.add(v2)
// t = 0.5
dir1 := vec2{x: p2.x - p0.x, y: p2.y - p0.y}
v1 := dir1.perp().norm().mul(dist)
mid := point{
x: 0.25*p0.x + 0.5*p1.x + 0.25*p2.x,
y: 0.25*p0.y + 0.5*p1.y + 0.25*p2.y,
}.add(v1)
// Calculate the control point P1 from B(0.5).
pp1 := point{
x: 2*mid.x - 0.5*(pp0.x+pp2.x),
y: 2*mid.y - 0.5*(pp0.y+pp2.y),
}
if level > 5 {
path.QuadTo(pp1.x, pp1.y, pp2.x, pp2.y)
return
}
// If any of the points is not a regular float32, do not call this function recursively.
if !isRegularF32(pp0.x) || !isRegularF32(pp0.y) || !isRegularF32(pp1.x) || !isRegularF32(pp1.y) || !isRegularF32(pp2.x) || !isRegularF32(pp2.y) {
path.QuadTo(pp1.x, pp1.y, pp2.x, pp2.y)
return
}
var needSplit bool
for _, t := range []float32{0.25, 0.75} {
gotP := point{
x: (1-t)*(1-t)*pp0.x + 2*(1-t)*t*pp1.x + t*t*pp2.x,
y: (1-t)*(1-t)*pp0.y + 2*(1-t)*t*pp1.y + t*t*pp2.y,
}
dir := vec2{
x: (1-t)*(p1.x-p0.x) + t*(p2.x-p1.x),
y: (1-t)*(p1.y-p0.y) + t*(p2.y-p1.y),
}
v := dir.perp().norm().mul(dist)
p := point{
x: (1-t)*(1-t)*p0.x + 2*(1-t)*t*p1.x + t*t*p2.x + v.x,
y: (1-t)*(1-t)*p0.y + 2*(1-t)*t*p1.y + t*t*p2.y + v.y,
}
expectedP := p.add(v)
if !arePointsInRange(gotP, expectedP, max(dist-1.0/16.0, 0), dist+1.0/16.0) {
needSplit = true
break
}
}
if !needSplit {
path.QuadTo(pp1.x, pp1.y, pp2.x, pp2.y)
return
}
// Split a quadratic curve into two quadratic curves by De Casteljau algorithm.
p01 := point{
x: (p0.x + p1.x) / 2,
y: (p0.y + p1.y) / 2,
}
p12 := point{
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2,
}
p012 := point{
x: (p01.x + p12.x) / 2,
y: (p01.y + p12.y) / 2,
}
doAppendParalleledQuad(path, p0, p01, p012, dist, level+1)
doAppendParalleledQuad(path, p012, p12, p2, dist, level+1)
}
func addJoint(strokePath *Path, subPath *subPath, opIndex int, reverse bool, options *StrokeOptions) {
var p point
var dir0, dir1 vec2
if !reverse {
nextOpIdx := opIndex + 1
if nextOpIdx == len(subPath.ops) {
if !subPath.closed {
return
}
nextOpIdx = 0
}
p = subPath.endAtOp(opIndex)
dir0 = subPath.endDir(opIndex).norm()
dir1 = subPath.startDir(nextOpIdx).norm()
} else {
nextOpIdx := opIndex - 1
if nextOpIdx == -1 {
if !subPath.closed {
return
}
nextOpIdx = len(subPath.ops) - 1
}
p = subPath.startAtOp(opIndex)
dir0 = subPath.startDir(opIndex).inv().norm()
dir1 = subPath.endDir(nextOpIdx).inv().norm()
}
if dir0 == dir1 {
return
}
v1 := dir1.perp().mul(options.Width / 2)
p1 := p.add(v1)
// If the joint is an internal angle (< 180 degrees), the joint is not rendered. Just connect the two segments.
// [vec2.cross] has a precision issue. Use a comparison instead.
if dir0.x*dir1.y > dir0.y*dir1.x {
strokePath.LineTo(p1.x, p1.y)
return
}
v0 := dir0.perp().mul(options.Width / 2)
p0 := p.add(v0)
// Add a joint.
switch options.LineJoin {
case LineJoinMiter:
theta := math.Acos(float64(dir0.x*(-dir1.x) + dir0.y*(-dir1.y)))
exceed := float32(math.Abs(1/math.Sin(float64(theta/2)))) > options.MiterLimit
if !exceed {
cp := crossingPointForTwoLines(p0, p0.add(dir0), p1, p1.add(dir1))
if isRegularF32(cp.x) && isRegularF32(cp.y) {
strokePath.LineTo(cp.x, cp.y)
}
}
strokePath.LineTo(p1.x, p1.y)
case LineJoinBevel:
strokePath.LineTo(p1.x, p1.y)
case LineJoinRound:
dir := vec2{
x: dir0.x - dir1.x,
y: dir0.y - dir1.y,
}.norm()
cp := p.add(dir.mul(options.Width / 2))
cp0 := crossingPointForTwoLines(p0, p0.add(dir0), cp, cp.add(dir.perp()))
cp1 := crossingPointForTwoLines(p1, p1.add(dir1), cp, cp.add(dir.perp()))
if isRegularF32(cp.x) && isRegularF32(cp.y) && isRegularF32(cp0.x) && isRegularF32(cp0.y) && isRegularF32(cp1.x) && isRegularF32(cp1.y) {
strokePath.ArcTo(cp0.x, cp0.y, cp.x, cp.y, options.Width/2)
strokePath.ArcTo(cp1.x, cp1.y, p1.x, p1.y, options.Width/2)
} else {
strokePath.LineTo(p1.x, p1.y)
}
}
}