Guides
Shaders
Pixels computed on the GPU by shaders written in Go's syntax, with values and images the app passes them.
A shader computes the color of every pixel of a rectangle, on the GPU,
from its position, values the app passes it each frame and images it
reads: noise and gradients that move, a texture lit pixel by pixel, a
ripple across the window. Package ui/shader compiles shaders written in
Go's syntax, and draws them on whichever GPU renderer draws the window
(Metal, Direct3D 11, OpenGL or WebGL 2), and on the CPU where none can,
the same pixels within rounding.
import "github.com/egoist/mygo/ui/shader"
var waves = shader.MustCompile(`package main
var Time float
func Fragment(dst vec4, pos vec2) vec4 {
v := 0.5 + 0.5*sin(pos.x/20+Time)
return vec4(v*0.2, v*0.4, v, 1)
}
`)
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
waves.Draw(p, r, shader.Options{
Uniforms: map[string]any{"Time": p.Now().Sub(start).Seconds()},
})
p.AnimationFrame()
})Draw fills a rectangle of the painter, clipped, transformed and faded
with it as anything it paints; Options.Radius rounds its corners.
Text, paths and icons
A shader also fills shapes in place of a color, as a gradient does, and
as SwiftUI fills a shape or text with a shader: FillPath and
StrokePath paint a ui.Path, Text a line of text, and Fill
whatever text, paths and icons a function paints, in a box of your own:
var circle ui.Path
circle.Circle(60, 60, 50)
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
opts := shader.Options{Uniforms: map[string]any{"Time": t}}
waves.FillPath(p, &circle, opts)
waves.Text(p, 140, 40, "Hello", 48, opts)
waves.Fill(p, ui.Rect{X: 140, Y: 100, W: 200, H: 40}, opts, func() {
p.RichText(140, 100, 200, ui.Span{Text: "and more", Size: 24, Weight: 700})
p.Icon(star, ui.Rect{X: 300, Y: 100, W: 24, H: 24}, ui.RGB(0, 0, 0))
})
})Each pixel shows the shader's color times how much of it the shape
covers, the edges of text corrected as the platform's text is for that
color. pos runs across the shape's box (the path's, grown by half the
stroke's width, the text's, or the one Fill takes), which rectSize()
returns; emoji keep their own colors, and rectangles and images Fill's
function paints paint as they would.
Effects on elements
A shader can also draw an element from what it renders, as SwiftUI's
colorEffect, distortionEffect and layerEffect do. Its entry point
says which, in place of Fragment:
// The content's colors, changed pixel by pixel.
func ColorEffect(pos vec2, color vec4) vec4
// Where to read the content for the pixel at pos.
func DistortionEffect(pos vec2) vec2
// The pixel's color, from the content read anywhere.
func LayerEffect(pos vec2) vec4Element.Effect takes the shader's Effect:
var wave = shader.MustCompile(`package main
var Time float
func DistortionEffect(pos vec2) vec2 {
return pos + vec2(0, 6*sin(pos.x/12+Time))
}
`)
ui.Text(c, "Wavy").FontSize(24).Effect(wave.Effect(shader.Options{
Uniforms: map[string]any{"Time": t},
MaxSampleOffset: ui.Point{Y: 6},
}))The element and its children, with their shadows and what their Draw
callbacks paint, render into a bitmap on the GPU every frame the element
paints, and the effect draws over it in place of the element. pos runs
from the corner of the element's box, whose size rectSize() returns. A
LayerEffect reads the content with layerAt(p), its pixel at p, or
layerSample(p), blended between pixels, p in DIPs as pos is; a
DistortionEffect reads it with layerSample. Content reads as
transparent beyond what renders: Options.MaxSampleOffset renders that
much more around the box, for an effect that reads or moves pixels that
far, and the effect draws over it all. The element stays where it is for
clicks, focus and accessibility: only what shows changes. An effect's
shader reads images 0 to 2 of its own; image 3 is the content.
The backdrop
A shader can read what the window drew behind it, as Flutter's
BackdropFilter with a shader does: frosted panes, lenses, a tint that
follows what scrolls under it. backdropAt(p) returns its pixel at p,
and backdropSample(p) blends the pixels around p, p in DIPs as
pos is; Options.BackdropBlur blurs it first, its standard deviation
in DIPs:
var frost = shader.MustCompile(`package main
var Tint vec4
func Fragment(dst vec4, pos vec2) vec4 {
b := backdropSample(pos)
return mix(b, Tint, Tint.a)
}
`)
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
frost.Draw(p, r, shader.Options{
BackdropBlur: 12,
Uniforms: map[string]any{"Tint": ui.RGBA(255, 255, 255, 0.3)},
Radius: 16,
})
})The renderers capture the backdrop as they do for the glass plugin's
panes, every frame, so it follows whatever moves behind. An element's
effect reads it too. A shader reading its backdrop draws rectangles
(Draw, Effect), not shapes (Fill), and reads images 0, 1 and 3 of
its own; image 2 is the backdrop.
The language
A shader is a Go file of package main. Its function Fragment returns the
pixel's color, premultiplied (each color component multiplied by the
alpha):
func Fragment(dst vec4, pos vec2) vec4dst is the pixel's center in pixels of the window, or of the canvas
drawn into, and pos the same point in DIPs from the rectangle's top
left corner. Fragment may take dst alone, or nothing.
The language is Ebitengine's Kage, whose compiler MyGo's is forked from: Go's syntax, with these types:
| Types | |
|---|---|
bool, int, uint, float |
scalars of 32 bits |
vec2, vec3, vec4 |
vectors of floats, with swizzles (v.xy, c.rgb) |
ivec2 to ivec4, uvec2 to uvec4 |
vectors of ints and uints |
mat2, mat3, mat4 |
matrices of floats, by columns |
[N]T |
arrays of any of them |
Functions of your own, if, for loops (to bounds that need not be
constants, and range over an int), constants, and GLSL's built-in
functions: sin, cos, atan2, pow, exp, sqrt, abs, floor,
fract, mod, min, max, clamp, mix, step, smoothstep,
length, distance, dot, cross, normalize, and the others. ints
and uints are two kinds of integers that mix only in shifts, as in Go;
uints wrap around, which hashes need:
func pcg(v uint) uint {
s := v*747796405 + 2891336453
w := ((s >> ((s >> 28) + 4)) ^ s) * 277803737
return (w >> 22) ^ w
}discard() leaves the pixel as it was. dfdx, dfdy and fwidth give
how a value changes to the next pixel on the GPU, and zero on the CPU.
Uniforms
Exported variables at the package level are uniforms: values the app sets
for each draw in Options.Uniforms, by name. A number sets a scalar; a
slice or an array of numbers sets a vector, a matrix (by columns) or an
array (elements in order); a ui.Color sets a vec4, its components from
0 to 1. A uniform the options leave out is zero.
var Taps [6]vec4 // x, y, when, how hard
var Count inttaps := make([]float32, 0, 24)
for _, t := range ripples {
taps = append(taps, t.x, t.y, t.at, t.force)
}
fx.Draw(p, r, shader.Options{Uniforms: map[string]any{"Taps": taps, "Count": len(ripples)}})For values set every frame, Values sets them by variables looked up
once, with no map, no conversions and no allocation each draw, as
Flutter's FragmentShader and Skia's SkRuntimeShaderBuilder hold them:
var (
timeVar = waves.Uniform("Time") // panics for a name the shader lacks
tintVar = waves.Uniform("Tint")
values = waves.NewValues()
)
values.Float(timeVar, float32(t)).Color(tintVar, theme.Accent)
waves.Draw(p, r, shader.Options{Values: values})Each setter checks the variable's type (Float, Vec2 to Vec4, Int,
Uint, Bool, and Floats, Ints and Uints for vectors, matrices and
arrays) and panics for another, as for too many numbers; drawing copies
the values, so one Values serves every draw. Options.Uniforms then
sets the names it holds over them.
A color (Values.Color, or a ui.Color in Options.Uniforms) sets a
vec3 or vec4 to its components from 0 to 1, not premultiplied, in
sRGB; where the window draws a wide gamut, a color outside sRGB, as
ui.Oklch makes, reaches the shader in extended sRGB (components below 0
or above 1), as the window's own colors do.
Images
A shader reads up to four images, Options.Images: bitmaps, or canvases'
(Canvas.Bitmap), in their pixels from their top left corners:
| Function | |
|---|---|
image0At(p vec2) vec4 |
the pixel at p, transparent outside the image |
image0Sample(p vec2) vec4 |
blended between the four pixels around p, clamped to the edges |
image0Size() vec2 |
the image's size in pixels |
and image1At to image3At, and so on. rectSize() returns the size of
the rectangle the shader fills, in DIPs, and pixelScale() the pixels a
DIP.
A shader drawing into a canvas makes the input of another, as the passes of an effect do: one computes a field at a few samples into a small canvas, the next reads it, sampled, at every pixel of the window.
field := ui.NewCanvas(cols, rows)
box.Draw(func(p *ui.Painter, r ui.Rect) {
field.Draw(p, func(cp *ui.Painter) {
windShader.Draw(cp, ui.Rect{W: float32(cols), H: float32(rows)}, shader.Options{Uniforms: wind})
})
glyphShader.Draw(p, r, shader.Options{Images: [4]*ui.Bitmap{field.Bitmap(), atlas}})
})Errors and cost
Compile reports what is wrong with a shader with its lines; compile
shaders once, as package variables with MustCompile. Compile takes
about a millisecond: it checks the shader and translates it into the
GPU's language (Metal, HLSL or GLSL), which the GPU's driver then
compiles, as SwiftUI's Shader.compile(as:) has it done ahead of use.
On macOS, Compile starts that on a goroutine of its own: Metal's
library and pipeline take about 0.2 s together the first time the app
runs on the Mac, and the renderers of every window draw with them,
waiting only for what is left; a shader compiled as a package variable
is ready long before its first frame, and Metal keeps both on disk, so
later launches have them at once. Linux's Mesa
drivers keep theirs on disk too (in ~/.cache/mesa_shader_cache): from
a tenth to a third of a second on the CPU's llvmpipe, the first time,
and a few milliseconds after. On Windows, Direct3D's compiler takes from
a tenth of a second for a short shader to a second for one of a few
hundred lines, so Compile starts it on a goroutine of its own: the
renderers of every window share the code, waiting on their first draw
only for what is left, and it stays in the user's cache directory
(MyGo\ShaderCache) for later launches. A shader costs
little to draw on a GPU, whatever it computes per pixel; on the CPU (the
renderer of last resort, and ui.Tester) it runs interpreted, a few
microseconds a pixel: seconds for a window. A shader covering much of the
window can ask p.Accelerated(), which is false there and where the GPU's
renderer draws on the CPU (WARP, llvmpipe, SwiftShader), and draw less,
or nothing:
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
if !p.Accelerated() {
return // a backdrop, which the window does without
}
backdrop.Draw(p, r, shader.Options{})
})Draw panics on a uniform set to a value that is
not a number.