main.go
// Command canvas is a generative-art demo of the widget.Canvas escape hatch.
//
// It draws an interference field of pulsing dots — hundreds of fills plus live
// text, re-recorded every frame — entirely inside one Canvas.Draw callback, in
// local coordinates. It exercises the custom-paint surface end to end through
// whichever rasterizer is resolved at runtime — GPU by default, CPU as a
// fallback or when forced (the on-screen readout says which).
//
// go run ./examples/canvas # GPU (default), CPU if none
// GOPHICS_RENDERER=cpu go run ./examples/canvas # force CPU
package main
import (
"fmt"
"log"
"math"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/widget"
)
// Field is a stateful widget that advances a clock every frame and rebuilds a
// full-window Canvas from it.
type Field struct{}
func (Field) CreateState() widget.State { return &fieldState{} }
type fieldState struct {
widget.StateBase[Field]
t float64 // seconds since start
}
// Init registers this state as a per-frame ticker.
func (s *fieldState) Init(ctx widget.Ctx) { ctx.AddTicker(s) }
// Tick advances the clock and repaints; returning true keeps it animating.
func (s *fieldState) Tick(dt float64) bool {
s.SetState(func() { s.t += dt })
return true
}
var (
colTop = paint.RGB(0.05, 0.06, 0.11)
colBot = paint.RGB(0.01, 0.01, 0.03)
colA = paint.RGB(0.20, 0.85, 0.90) // cyan crest
colB = paint.RGB(0.90, 0.25, 0.72) // magenta trough
colTxt = paint.RGB(0.92, 0.94, 0.97)
colDim = paint.RGB(0.45, 0.50, 0.62)
)
func (s *fieldState) Build(widget.Ctx) widget.Widget {
t := s.t
return widget.Canvas{Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
w, h := size.W, size.H
// Background: a soft vertical gradient. Origin is (0,0) — local coords.
c.FillRRectGradient(geom.Rect{Max: size.Pt()}, 0, colTop, colBot, false)
// Two slow-orbiting wave sources drive an interference pattern.
s1 := geom.Pt{
X: w*0.5 + w*0.30*float32(math.Cos(t*0.70)),
Y: h*0.5 + h*0.30*float32(math.Sin(t*0.90)),
}
s2 := geom.Pt{
X: w*0.5 + w*0.34*float32(math.Cos(t*1.10+2)),
Y: h*0.5 + h*0.28*float32(math.Sin(t*0.60+1)),
}
// A grid of dots whose size, color and opacity follow the summed waves.
const step = 26
for gy := float32(step) / 2; gy < h; gy += step {
for gx := float32(step) / 2; gx < w; gx += step {
d1 := math.Hypot(float64(gx-s1.X), float64(gy-s1.Y))
d2 := math.Hypot(float64(gx-s2.X), float64(gy-s2.Y))
wave := (math.Sin(d1*0.050-t*3.0) + math.Sin(d2*0.045-t*2.3)) * 0.5
m := float32(wave*0.5 + 0.5) // 0 (trough) .. 1 (crest)
rad := 1.5 + m*6
col := paint.Lerp(colB, colA, m).WithAlpha(0.20 + 0.80*m)
c.FillRRect(geom.RectXYWH(gx-rad, gy-rad, rad*2, rad*2), rad, col)
}
}
// Title and a live readout, drawn into the same surface — this is the
// text path that renders correctly on the GPU build.
c.TextIn("", "gophics · canvas", geom.Pt{X: 28, Y: 50}, 30, colTxt)
c.TextIn("", fmt.Sprintf("generative interference field — %.1fs — %s rasterizer", t, rasterizer()),
geom.Pt{X: 28, Y: 76}, 14, colDim)
}}
}
func main() {
if err := app.Run(Field{}, app.Config{
Title: "gophics canvas",
Size: geom.Size{W: 960, H: 620},
Background: colBot,
Font: goregular.TTF,
}); err != nil {
log.Fatal(err)
}
}
backend.go
package main
import "github.com/doug/gophics/paint"
// rasterizer names the backend actually in effect at runtime (the renderer is
// resolved from Config/GOPHICS_RENDERER, no longer a build tag), for the demo
// readout.
func rasterizer() string {
if paint.GPUAvailable() {
return "GPU"
}
return "CPU"
}