- 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 mainthis will create a file called go.mod that's needed for the next step - Run
$ go mod tidythis will pull down the packages needed to run the program - Run
$ go run *.go - The result will be in watercolor.png
Last active
October 30, 2025 19:12
-
-
Save jphsd/99fd3ff7fb2632c35fa79eaf9faf1b92 to your computer and use it in GitHub Desktop.
A Go Implementation of Tyler Hobbs' Watercolor Algorithm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } |
Author
jphsd
commented
Oct 30, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment