Skip to content

Instantly share code, notes, and snippets.

@jphsd
Last active October 30, 2025 19:12
Show Gist options
  • Select an option

  • Save jphsd/99fd3ff7fb2632c35fa79eaf9faf1b92 to your computer and use it in GitHub Desktop.

Select an option

Save jphsd/99fd3ff7fb2632c35fa79eaf9faf1b92 to your computer and use it in GitHub Desktop.
A Go Implementation of Tyler Hobbs' Watercolor Algorithm
package main
type Edge [2]int
type Edges []Edge
type Path []int
type Polygon []int
// Convert polygon descriptions on a point set to a list of polygons with discrete points
func PointsToPolyPoints(points [][]float64, polys ...Polygon) [][][]float64 {
res := [][][]float64{}
for _, poly := range polys {
rpoly := [][]float64{}
for _, p := range poly {
rpoly = append(rpoly, points[p])
}
res = append(res, rpoly)
}
return res
}
// Edges returns the path as a slice of edges.
func (p Path) Edges() Edges {
np := len(p)
res := make(Edges, np-1)
for i, v := range p {
if i == np-1 {
break
}
res[i] = Edge{v, p[i+1]}
}
return res
}
package main
import (
"fmt"
g2d "github.com/jphsd/graphics2d"
"github.com/jphsd/graphics2d/color"
"github.com/jphsd/graphics2d/image"
"image/draw"
"math"
"math/rand"
// Example font
"golang.org/x/image/font/gofont/gobolditalic"
"golang.org/x/image/font/sfnt"
)
// Simulate Tyler Hobbs' watercolor process
// https://www.tylerxhobbs.com/words/how-to-hack-a-painting
// with iterations as per TH's process (3, 3, 16, 3), result is 48 blended together.
func main() {
w, h := 1000, 1000
// Paths from font glyphs
fonts, err := sfnt.ParseCollection(gobolditalic.TTF)
font, _ := fonts.Font(0)
shape, _, err := g2d.StringToShape(font, "PCG!")
if err != nil {
fmt.Printf("Error reading glyphs %s\n", err)
return
}
bb := shape.BoundingBox()
xfm := g2d.ScaleAndInset(float64(w), float64(h), 100, 100, true, bb)
shape1 := shape.Transform(xfm)
paths := shape1.Paths()
polys := []Polygon{}
edges := Edges{}
points := [][]float64{}
cproc := g2d.NewCompoundProc(g2d.LimitProc{10})
cproc.Concatenate = true
// Paths are assumed to be closed
for _, path := range paths {
// Need to limit since distortion is proportional to edge length
path = path.Flatten(g2d.DefaultRenderFlatten).Process(cproc)[0]
// Convert to points and edges
npoints, gpath := G2dPathToPointsAndPath(path)
// gpath needs fixing since it references npoints and not points
np := len(points)
for i, _ := range gpath {
gpath[i] += np
}
points = append(points, npoints...)
polys = append(polys, Polygon(gpath))
edges = append(edges, gpath.Edges()...)
}
alpha := image.NewAlpha(w, h, color.Transparent)
acol := color.Alpha{4}
Compose(alpha, points, polys, edges, 3, 3, 16, 3, 1, acol) // 6: 3 x 16
img := image.NewRGBA(w, h, color.White)
draw.DrawMask(img, img.Bounds(), &image.Uniform{color.GopherBlue}, image.Point{}, alpha, image.Point{}, draw.Over)
image.SaveImage(img, "watercolor")
}
// ni+mi is the depth of subdivision
// n*m is the number of layers combined
func Compose(img draw.Image, points [][]float64, polys []Polygon, edges Edges, n, ni, m, mi int, ls float64, a color.Color) {
// Other params - see Tyler's three variables description
mpv := 0.25 // Midpoint variance
thv := 0.5 // Theta variance
lv := 0.5 // Magnitude variance
afill := &image.Uniform{a}
for range n {
npoints := make([][]float64, len(points))
copy(npoints, points)
nedges := make(Edges, len(edges))
copy(nedges, edges)
npolys := make([]Polygon, len(polys))
copy(npolys, polys)
for range ni {
npoints, nedges, npolys = Round(npoints, nedges, npolys, mpv, thv, lv, ls)
}
for range m {
mpoints := make([][]float64, len(npoints))
copy(mpoints, npoints)
medges := make(Edges, len(nedges))
copy(medges, nedges)
mpolys := make([]Polygon, len(npolys))
copy(mpolys, npolys)
for range mi {
mpoints, medges, mpolys = Round(mpoints, medges, mpolys, mpv, thv, lv, ls)
}
ppts := PointsToPolyPoints(mpoints, mpolys...)
shape := &g2d.Shape{}
for _, pts := range ppts {
shape.AddPaths(g2d.Polygon(pts...))
}
g2d.RenderShape(img, shape, afill)
}
}
}
// ma is edge midpoint, tha is 90 (edge direction determines normal side)
// Spits out new points and edges (for next round), and triangles
// Differs from original Round by using edge length in place of la and hurst
func Round(points [][]float64, edges Edges, polys []Polygon, mpv, thv, lv, ls float64) ([][]float64, Edges, []Polygon) {
nedges := Edges{}
for _, e := range edges {
// Figure edge break point
t := rand.NormFloat64()*mpv + 0.5
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
omt := 1 - t
p1, p2 := points[e[0]], points[e[1]]
mp := []float64{p1[0]*omt + p2[0]*t, p1[1]*omt + p2[1]*t}
la := math.Hypot(p2[0]-p1[0], p2[1]-p1[1]) / 2 * ls
// Figure variance angle
dth := rand.NormFloat64() * thv
if dth < -0.95 {
dth = -0.95
} else if dth > 0.95 {
dth = 0.95
}
dth *= math.Pi / 2
// Figure magnitude
l := rand.NormFloat64()*lv + la
if l < 0 {
// No polygon, just a new point and edge pair
np := len(points)
points = append(points, mp)
nedges = append(nedges, Edge{e[0], np}, Edge{np, e[1]})
continue
}
// Figure new point
dx, dy := p2[0]-p1[0], p2[1]-p1[1]
na := math.Atan2(-dx, dy) // normal to edge
na += dth
p3 := []float64{l*math.Cos(na) + mp[0], l*math.Sin(na) + mp[1]}
np := len(points)
points = append(points, p3)
nedges = append(nedges, Edge{e[0], np}, Edge{np, e[1]})
polys = append(polys, Polygon{e[0], np, e[1]})
}
return points, nedges, polys
}
// Convert a graphics2d path to points and Path
func G2dPathToPointsAndPath(path *g2d.Path) ([][]float64, Path) {
parts := path.Flatten(g2d.DefaultRenderFlatten).Parts()
lp := len(parts)
lp1 := lp + 1
points := make([][]float64, lp1)
p := make(Path, lp1)
var last [][]float64
for i, part := range parts {
points[i] = part[0]
p[i] = i
last = part
}
points[lp] = last[len(last)-1]
p[lp] = lp
return points, p
}

How To Run

  • Download Go for your platform from here
  • Create a new directory and switch to it
  • Download the .go files from this Gist into it
  • Run $ go mod init main this will create a file called go.mod that's needed for the next step
  • Run $ go mod tidy this will pull down the packages needed to run the program
  • Run $ go run *.go
  • The result will be in watercolor.png
@jphsd
Copy link
Author

jphsd commented Oct 30, 2025

watercolor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment