Skip to content

Commit

Permalink
Fixed curve splitting for EdgeMarker.
Browse files Browse the repository at this point in the history
I realized my assumptions were incorrect and decided to switch to a 
simpler segmentation method. I also added images to the documentation on 
the algorithms.
  • Loading branch information
tinne26 committed May 13, 2022
1 parent e0e61d1 commit e4fc693
Show file tree
Hide file tree
Showing 10 changed files with 72 additions and 159 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func checkMissingRunes(name string, font *etxt.Font) error {

This example focuses on the mundane usage of the main **etxt** `FontLibrary` and `Renderer` types, with abundant checks to fail fast if anything seems out of place.

If you want flashier examples there are [many more](https://github.com/tinne26/etxt/tree/main/examples) in the project, so check them out!
If you want flashier examples there are still [many more](https://github.com/tinne26/etxt/tree/main/examples) in the project, make sure to check them out!

## Can I use this package without Ebiten?
Yeah, you can compile it with `-tags gtxt`. Notice that `gtxt` will make text drawing happen on the CPU, so don't try to use it for real-time stuff. In particular, be careful to not accidentally use `gtxt` with Ebiten (they are compatible in many cases, but performance will die).
Expand All @@ -99,8 +99,8 @@ If you are only dealing with text rendering incidentally and **ebiten/text** doe
The main consideration when using **etxt** is that you need to be minimally acquainted with how fonts work. [FreeType glyph conventions](https://freetype.org/freetype2/docs/glyphs/index.html) is the go to reference that you *really should be reading* (up to section IV or V).

## Any future plans?
This package is already quite solid, but there are still a few rough edges:
- EdgeMarker is still experimental.
This package is already quite solid, there are only a few points left to improve:
- Adding a few more effects (hollow text, shaders, etc).
- Missing a couple important examples (crisp UI and shaders).

If I get really bored, I'd also like to look into:
Expand Down
Binary file added docs/img/glyph_edges.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/glyph_filled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/glyph_sign.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/outline_vs_raster.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 11 additions & 6 deletions docs/rasterize-outlines.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ This document aims to give a general overview of the rasterization algorithms us
While this package focuses on glyph rendering, these algorithms are suitable for general 2D vector graphics. That said, they are CPU-based processes best suited for small shapes; in the case of big shapes, GPU algorithms based on triangulation and [spline curve rendering](https://developer.nvidia.com/gpugems/gpugems3/part-iv-image-effects/chapter-25-rendering-vector-art-gpu) may be a better choice.

## The problem
Given a vectorial outline, we want to rasterize it (convert the outline to a raster image, which is a grid of square pixels). Here's a visual example:
Given a vectorial outline, we want to rasterize it (convert the outline to a raster image, which is a grid of square pixels). Here's an example of outlines and raster:

TODO: add image (zhe, ElMessiri, fontForge).
![](https://github.com/tinne26/etxt/blob/main/docs/img/outline_vs_raster.png?raw=true)

We will call the resulting raster image a *mask*, as it only contains information about the opacity of each pixel, not about their colors. Colors can be chosen and applied later, at a separate step.

Expand Down Expand Up @@ -48,11 +48,11 @@ There are multiple answers to each of these questions:
## Marking outline boundaries
Let's say we have a glyph like this:

TODO: image (black/white char, e.g 楽).
![](https://github.com/tinne26/etxt/blob/main/docs/img/glyph_filled.png?raw=true)

Now, starting from the left side, we start going to the right, and each time we cross an outline boundary, we mark it. The result would be something like this:

TODO: image (black borders).
![](https://github.com/tinne26/etxt/blob/main/docs/img/glyph_edges.png?raw=true)

Well, that's the core idea that will help us solve our problem. Each time we issue a `LineTo` command to define a boundary segment for a contour, we will follow the line, see which pixels it crosses, and somehow store that information.

Expand All @@ -64,7 +64,7 @@ First, to account for clockwise and counter-clockwise directions and make "holes

If we use cyan for positive changes and magenta for negative ones, the result would now look like this:

TODO: image (cyan/magenta borders)
![](https://github.com/tinne26/etxt/blob/main/docs/img/glyph_sign.png?raw=true)

The important part is that different directions result in values of opposite sign (e.g.: you could make "up" be negative and "down" positive instead).

Expand All @@ -81,6 +81,11 @@ To illustrate the concept more practically, let's imagine we have a mask with a
I'd explain more, but at this point you have enough context and jumping directly [into the code](https://github.com/tinne26/etxt/blob/main/emask/edge_marker.go) may be the best next step.

## Limitations
This algorithm works decently in general, but notice that what happens inside a pixel can only be balanced, not distinguished. For example, if we define a 1x1 square in the middle of four pixels, we will get 25% opacity from each pixel. That's the best we can do, ok... but if you repeat the process 4 times, the 4 pixels will all get to 100% opacity. Mathematically speaking, this shouldn't happen, but since pixels can't tell where lines start or end within them, they can't tell it's always the same area being covered and the result "overflows".
This algorithm works decently in general, but notice that what happens inside a pixel can only be balanced, not distinguished. For example, if we define a 1x1 square in the middle of four pixels, we will get 25% opacity from each pixel. That's the best we can do, ok... but if you repeat the process 4 times, the 4 pixels will all get to 100% opacity. This wouldn't happen in a continuous space, but since pixels can't tell where lines start or end within them (discrete space), they can't tell it's always the same area being covered and the result "overflows".

Floating point precision can also be an issue when dealing with big shapes, unaligned and angled boundaries, etc.

## Further references
Curve segmentation and optimizations haven't been discussed, but are mentioned here and there through the code.

You will also be interested in Raph Levien's [blogpost](https://medium.com/@raphlinus/inside-the-fastest-font-renderer-in-the-world-75ae5270c445) explaining the [font-rs](https://github.com/raphlinus/font-rs) font renderer, which in turn serves as a base for Golang's `vector.Rasterizer` [implementation](https://cs.opensource.google/go/x/image/+/70e8d0d3:vector/raster_floating.go;l=31).
131 changes: 34 additions & 97 deletions emask/edge_marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,94 +146,47 @@ func (self *edgeMarker) LineTo(x, y float64) {
// Creates a boundary from the current position to the given target
// as a quadratic Bézier curve through the given control point and
// moves the current position to the new one.
func (self *edgeMarker) QuadTo(ctrlX, ctrlY, x, y float64) {
// Once we managed to draw straight lines, it's time to draw curves.
// The idea is simple: just split the curves into straight lines.
// If you aren't familiar with Bézier curves, have a look at
// https://javascript.info/bezier-curve.
//
// The method used here to do curve segmentation is simply to keep
// partitioning a curve in smaller segments until we hit a hard cutoff
// (maxCurveSplits) or we find that the current segment approximates
// the curve well enough (the distance between the next potential split
// point and the current straight line is equal or below the threshold
// (curveThreshold)).

// o and f are the start and end points of the curve
ox, oy, fx, fy := self.x, self.y, x, y

// create a slice to store curve targets and make the
// algorithm iterative instead of recursive
type curveTarget struct{ x, y, t float64; depth uint8 }
nextTargets := make([]curveTarget, 0, self.maxCurveSplits)
target := curveTarget { fx, fy, 1.0, 0 }

// during the process we need line equations in ABC form
// in order to compute distances between a line and a point,
// and we can reuse some of them, so we keep helper vars
a, b, c := toLinearFormABC(ox, oy, fx, fy)
func (self *edgeMarker) QuadTo(ctrlX, ctrlY, fx, fy float64) {
self.recursiveQuadTo(ctrlX, ctrlY, fx, fy, 0)
}

// keep splitting the curve until segments are within the
// curve threshold and we can draw them as straight lines
tReached := 0.0 // our progress in segmenting the curve
for {
// interpolate curve at the next split point
t := tReached + (target.t - tReached)/2
ix, iy := interpQuadBezier(ox, oy, fx, fy, ctrlX, ctrlY, t)
depth := target.depth

// see if the interpolated point is within the threshold
if depth == self.maxCurveSplits || self.withinCurveThreshold(a, b, c, ix, iy) {
// use a linear segment to interpolate
self.LineTo(target.x, target.y) // self x/y is advanced with this too
if len(nextTargets) == 0 { return } // last target reached, stop

// update our variables for the next iteration
tReached = target.t // increase reached t
target = nextTargets[len(nextTargets) - 1]
nextTargets = nextTargets[:len(nextTargets) - 1]
a, b, c = toLinearFormABC(self.x, self.y, target.x, target.y)
} else { // sub-split required
target.depth += 1
nextTargets = append(nextTargets, target)
target = curveTarget{ ix, iy, t, depth + 1 }
}
func (self *edgeMarker) recursiveQuadTo(ctrlX, ctrlY, fx, fy float64, depth uint8) {
if depth >= self.maxCurveSplits || self.withinThreshold(self.x, self.y, fx, fy, ctrlX, ctrlY) {
self.LineTo(fx, fy)
return
}

ocx, ocy := lerp(self.x, self.y, ctrlX, ctrlY, 0.5) // origin to control
cfx, cfy := lerp(ctrlX, ctrlY, fx, fy, 0.5) // control to end
ix , iy := lerp(ocx, ocy, cfx, cfy, 0.5) // interpolated point
self.recursiveQuadTo(ocx, ocy, ix, iy, depth + 1)
self.recursiveQuadTo(cfx, cfy, fx, fy, depth + 1)
}

// Creates a boundary from the current position to the given target
// as a cubic Bézier curve through the given control points and
// moves the current position to the new one.
func (self *edgeMarker) CubeTo(cx1, cy1, cx2, cy2, x, y float64) {
// go and read QuadTo's implementation. this is the same, but
// without documentation and using interpCubeBezier. that's it.
//
func (self *edgeMarker) CubeTo(cx1, cy1, cx2, cy2, fx, fy float64) {
// performance notes: reducing to 1 split can cut rasterization
// times in 15%. vector.Rasterizer's approach is also more
// direct and could cut rasterization time a bit.
ox, oy, fx, fy := self.x, self.y, x, y
type curveTarget struct{ x, y, t float64; depth uint8 }
nextTargets := make([]curveTarget, 0, self.maxCurveSplits)
target := curveTarget { fx, fy, 1.0, 0 }
a, b, c := toLinearFormABC(ox, oy, fx, fy)
tReached := 0.0
for {
t := tReached + (target.t - tReached)/2
ix, iy := interpCubeBezier(ox, oy, fx, fy, cx1, cy1, cx2, cy2, t)
depth := target.depth
if depth == self.maxCurveSplits || self.withinCurveThreshold(a, b, c, ix, iy) {
self.LineTo(target.x, target.y)
if len(nextTargets) == 0 { return }
tReached = target.t
target = nextTargets[len(nextTargets) - 1]
nextTargets = nextTargets[:len(nextTargets) - 1]
a, b, c = toLinearFormABC(self.x, self.y, target.x, target.y)
} else {
target.depth += 1
nextTargets = append(nextTargets, target)
target = curveTarget{ ix, iy, t, depth + 1 }
}
// direct and could slightly cut rasterization time.
self.recursiveCubeTo(cx1, cy1, cx2, cy2, fx, fy, 0)
}

func (self *edgeMarker) recursiveCubeTo(cx1, cy1, cx2, cy2, fx, fy float64, depth uint8) {
if depth >= self.maxCurveSplits || (self.withinThreshold(self.x, self.y, cx2, cy2, cx1, cy1) && self.withinThreshold(cx1, cy1, fx, fy, cx2, cy2)) {
self.LineTo(fx, fy)
return
}

oc1x , oc1y := lerp(self.x, self.y, cx1, cy1, 0.5) // origin to control 1
c1c2x, c1c2y := lerp(cx1, cy1, cx2, cy2, 0.5) // control 1 to control 2
c2fx , c2fy := lerp(cx2, cy2, fx, fy, 0.5) // control 2 to end
iox , ioy := lerp(oc1x, oc1y, c1c2x, c1c2y, 0.5) // first interpolation from origin
ifx , ify := lerp(c1c2x, c1c2y, c2fx, c2fy, 0.5) // second interpolation to end
ix , iy := lerp(iox, ioy, ifx, ify, 0.5) // cubic interpolation
self.recursiveCubeTo(oc1x, oc1y, iox, ioy, ix, iy, depth + 1)
self.recursiveCubeTo(ifx, ify, c2fx, c2fy, fx, fy, depth + 1)
}

// Sets the threshold distance to use when splitting Bézier curves into
Expand Down Expand Up @@ -312,12 +265,10 @@ func (self *edgeMarker) markBoundary(x, y, horzAdvance, vertAdvance float64) {
}
}

// given the A, B and C coefficients of a line equation in the form
// Ax + By + C = 0 and a point, and returns true if the given line and
// the point (px, py) are close enough (configurable threshold)
func (self *edgeMarker) withinCurveThreshold(a, b, c, px, py float64) bool {
func (self *edgeMarker) withinThreshold(ox, oy, fx, fy, px, py float64) bool {
// https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_an_equation
// dist = |a*x + b*y + c| / sqrt(a^2 + b^2)
a, b, c := toLinearFormABC(ox, oy, fx, fy)
n := a*px + b*py + c
return n*n <= float64(self.curveThreshold)*float64(self.curveThreshold)*(a*a + b*b)
}
Expand Down Expand Up @@ -356,22 +307,8 @@ func lerp(ax, ay, bx, by float64, t float64) (float64, float64) {
// interpolate a and b at the given t, which must be in [0, 1]
func interpolateAt(a, b float64, t float64) float64 { return a + t*(b - a) }

// interpolate the quadratic bézier curve at the given t [0, 1].
// see https://youtu.be/YATikPP2q70?t=205 for a visual explanation
func interpQuadBezier(ox, oy, fx, fy, ctrlX, ctrlY, t float64) (float64, float64) {
ocx, ocy := lerp(ox, oy, ctrlX, ctrlY, t)
cfx, cfy := lerp(ctrlX, ctrlY, fx, fy, t)
return lerp(ocx, ocy, cfx, cfy, t)
}

func interpCubeBezier(ox, oy, fx, fy, cx1, cy1, cx2, cy2, t float64) (float64, float64) {
oc2x, oc2y := interpQuadBezier(ox, oy, cx2, cy2, cx1, cy1, t)
c1fx, c1fy := interpQuadBezier(cx1, cy1, fx, fy, cx2, cy2, t)
return lerp(oc2x, oc2y, c1fx, c1fy, t)
}

// Given two points of a line, it returns its A, B and C
// coefficients from the form "Ax + Bx + C = 0".
// coefficients from the form "Ax + By + C = 0".
func toLinearFormABC(ox, oy, fx, fy float64) (float64, float64, float64) {
a, b, c := fy - oy, -(fx - ox), (fx - ox)*oy - (fy - oy)*ox
return a, b, c
Expand Down
2 changes: 0 additions & 2 deletions emask/edge_marker_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ func makeRng() *rand.Rand {
func BenchmarkStdRast(b *testing.B) {
rng := makeRng()
rast := &DefaultRasterizer{}
// b.ReportAllocs() // around 24 allocs/op
for n := 0; n < b.N; n++ {
for size := 16; size <= 512; size *= 2 {
shape := randomShape(rng, 16, size, size)
Expand All @@ -33,7 +32,6 @@ func BenchmarkStdRast(b *testing.B) {
func BenchmarkEdgeRast(b *testing.B) {
rng := makeRng()
rast := NewStdEdgeMarkerRasterizer()
// b.ReportAllocs() // around 88 allocs/op
for n := 0; n < b.N; n++ {
for size := 16; size <= 512; size *= 2 {
shape := randomShape(rng, 16, size, size)
Expand Down
29 changes: 6 additions & 23 deletions emask/edge_marker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ func TestCompareEdgeAndStdRasts(t *testing.T) {
stdRasterizer := &DefaultRasterizer{}
edgeRasterizer := NewStdEdgeMarkerRasterizer()

for n := 0; n < 10; n++ {
for n := 0; n < 30; n++ {
// create random shape
shape := randomShape(rng, 16, canvasWidth, canvasHeight)
segments := shape.Segments()
Expand Down Expand Up @@ -281,30 +281,16 @@ func TestCompareEdgeAndStdRasts(t *testing.T) {
}

totalDiff += int(diff)
// Note: individual pixel comparisons are reasonable when there are
// only straight segments, and not bad when there are quadratic
// curves, but when adding cubic curves it gets a bit too
// crazy. multiple curves can be drawn on top of each other
// and cause some weird situations
// if diff > pixCmpTolerance {
// t.Fatalf("iter %d, stdMask.Pix[%d] = %d, edgeMask.Pix[%d] = %d", n, i, stdValue, i, edgeValue)
// }
// Note: we could compare pixel values individually here, but
// different thresholds and curve segmentation methods
// can cause severe value differences in some cases.
}

avgDiff := float64(totalDiff)/(canvasWidth*canvasHeight)
if avgDiff > avgCmpTolerance {
exportTest("cmp_rasts_" + strconv.Itoa(n) + "_edge.png", edgeMask)
exportTest("cmp_rasts_" + strconv.Itoa(n) + "_rast.png", stdMask)
t.Fatalf("iter %d, totalDiff = %d average tolerance is too big (%f) (written files for visual debug)", n, totalDiff, avgDiff)
// TODO: this test fails often. There's most definitely something
// going on, but I haven't explored it in depth yet. Maybe
// I can use only cubic curves to test, and make bigger
// images and print the full data for shapes so I can reproduce
// manually... but it's hard. I know it only happens with
// cubic curves. And it may also be vector.Rasterizer's fault,
// which uses far more tricks for optimization. Or use
// vector.Rasterizer's code for cubic curves temporarily and
// see what's up.
}
}
}
Expand Down Expand Up @@ -347,11 +333,8 @@ func randomShape(rng *rand.Rand, lines, w, h int) Shape {
shape.QuadToFract(cx, cy, x, y)
case 2: // CubeTo
cx1, cy1 := makeXY()
shape.QuadToFract(cx1, cy1, x, y)
// TODO: cubic curves disabled in testing until I figure
// out exactly why they differ from vector.Rasterizer
// cx2, cy2 := makeXY()
// shape.CubeToFract(cx1, cy1, cx2, cy2, x, y)
cx2, cy2 := makeXY()
shape.CubeToFract(cx1, cy1, cx2, cy2, x, y)
}
}
shape.LineToFract(startX, startY)
Expand Down
Loading

0 comments on commit e4fc693

Please sign in to comment.