外观
着色器
着色器在 GPU 上根据像素位置、应用每帧传入的值以及它读取的图像,计算矩形中每个像素的颜色:流动噪点与渐变、逐像素点亮的纹理、掠过窗口的涟漪。ui/shader 包编译用 Go 语法编写的着色器,并在绘制该窗口的任何 GPU 渲染器上绘制(Metal、Direct3D 11、OpenGL 或 WebGL 2);若都不具备,则在 CPU 上绘制,在舍入误差范围内得到相同的像素。
go
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 填充绘制器的一个矩形,和它绘制的任何内容一样随裁剪、变换而变换、随不透明度而淡化;Options.Radius 使其四角变圆。
文本、路径与图标
着色器也可以代替颜色来填充形状,就像渐变一样,正如 SwiftUI 用着色器填充形状或文本:FillPath 和 StrokePath 绘制一个 ui.Path,Text 绘制一行文本,Fill 则在你自己指定的矩形内,绘制一个函数所画的全部文本、路径和图标:
go
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))
})
})每个像素显示着色器的颜色乘以形状在该像素上的覆盖比例,文本边缘的校正方式与平台上以该颜色绘制文本时相同。pos 覆盖形状的包围盒(路径的盒子,外扩半个笔触宽度;文本的盒子;或 Fill 传入的盒子),rectSize() 返回其大小;emoji 保持自身颜色,Fill 的函数绘制的矩形和图像按本来的样子绘制。
元素效果
着色器还可以基于元素渲染出的内容来绘制该元素,就像 SwiftUI 的 colorEffect、distortionEffect 和 layerEffect。用入口函数代替 Fragment 来指明是哪一种:
go
// 内容的颜色,逐像素改变。
func ColorEffect(pos vec2, color vec4) vec4
// 计算 pos 处像素应从内容何处读取。
func DistortionEffect(pos vec2) vec2
// 像素的颜色,可从任意位置读取内容。
func LayerEffect(pos vec2) vec4Element.Effect 接受着色器的 Effect:
go
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},
}))每当元素绘制一帧,元素及其子元素(连同其阴影和 Draw 回调绘制的内容)都会渲染到 GPU 上的一张位图中,效果代替元素在其之上绘制。pos 从元素包围盒的角点开始,其大小由 rectSize() 返回。LayerEffect 用 layerAt(p) 读取内容——p 处的像素——或用 layerSample(p) 读取——在像素之间混合,p 与 pos 一样以 DIP 为单位;DistortionEffect 用 layerSample 读取。超出渲染范围的内容读取为透明:Options.MaxSampleOffset 会在包围盒四周多渲染该距离,供读取或移动那么远像素的效果使用,效果会在全部内容之上绘制。元素的点击、焦点和无障碍行为保持原位不变:只有显示的内容发生变化。效果的着色器读取自身的图像 0 到 2;图像 3 是内容。
背景
着色器可以读取窗口在其背后绘制的内容,就像 Flutter 的 BackdropFilter 配合着色器那样:磨砂玻璃面板、透镜、跟随其下滚动内容的着色。backdropAt(p) 返回 p 处的背景像素,backdropSample(p) 混合 p 周围的像素,p 与 pos 一样以 DIP 为单位;Options.BackdropBlur 先对其模糊,其标准差以 DIP 为单位:
go
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,
})
})渲染器捕获背景的方式与其为玻璃插件的面板所做的一样,每帧一次,因此背景会跟随其后移动的一切。元素的效果也能读取背景。读取背景的着色器只能绘制矩形(Draw、Effect)而非形状(Fill),并读取自身的图像 0、1 和 3;图像 2 是背景。
语言
着色器是 package main 的一个 Go 文件。其函数 Fragment 返回像素的颜色,采用预乘 alpha(每个颜色分量乘以 alpha):
go
func Fragment(dst vec4, pos vec2) vec4dst 是像素中心,以所绘制窗口(或画布)的像素为单位;pos 是同一点,以距矩形左上角的 DIP 为单位。Fragment 可以只接受 dst,也可以不接受参数。
该语言是 Ebitengine 的 Kage,MyGo 的编译器即派生自其编译器:使用 Go 的语法,并带有这些类型:
| 类型 | |
|---|---|
bool, int, uint, float | 32 位标量 |
vec2, vec3, vec4 | 浮点向量,支持分量重排(v.xy、c.rgb) |
ivec2 到 ivec4、uvec2 到 uvec4 | int 和 uint 向量 |
mat2, mat3, mat4 | 浮点矩阵,按列存储 |
[N]T | 以上任意类型的数组 |
支持自定义函数、if、for 循环(边界不必是常量,也可对 int 使用 range)、常量,以及 GLSL 的内置函数:sin、cos、atan2、pow、exp、sqrt、abs、floor、fract、mod、min、max、clamp、mix、step、smoothstep、length、distance、dot、cross、normalize 等。int 和 uint 是两种整数,只有在移位时可以混用,与 Go 相同;uint 会回绕,哈希函数正需要这一点:
go
func pcg(v uint) uint {
s := v*747796405 + 2891336453
w := ((s >> ((s >> 28) + 4)) ^ s) * 277803737
return (w >> 22) ^ w
}discard() 让像素保持原样。dfdx、dfdy 和 fwidth 给出值到相邻像素的变化量:在 GPU 上为实际导数,在 CPU 上返回零。
统一变量
包级别的导出变量就是统一变量(uniform):应用在 Options.Uniforms 中按名称为每次绘制设置的值。一个数字设置标量;数字切片或数组设置向量、矩阵(按列)或数组(按元素顺序);ui.Color 设置一个 vec4,其分量取值 0 到 1。选项中未提供的统一变量为零值。
go
var Taps [6]vec4 // x、y、时间、力度
var Count intgo
taps := 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)}})对于每帧都要设置的值,Values 通过只查找一次的变量来设置它们,每次绘制无需 map、无需类型转换、也不分配内存,就像 Flutter 的 FragmentShader 和 Skia 的 SkRuntimeShaderBuilder 那样持有它们:
go
var (
timeVar = waves.Uniform("Time") // 若着色器没有该名称则 panic
tintVar = waves.Uniform("Tint")
values = waves.NewValues()
)
values.Float(timeVar, float32(t)).Color(tintVar, theme.Accent)
waves.Draw(p, r, shader.Options{Values: values})每个 setter 都会检查变量的类型(Float、Vec2 到 Vec4、Int、Uint、Bool,以及用于向量、矩阵和数组的 Floats、Ints 和 Uints),类型不符或数字过多都会 panic;绘制时会复制这些值,因此一个 Values 可用于所有绘制。Options.Uniforms 随后会用其中包含的名称覆盖对应的值。
颜色(Values.Color,或 Options.Uniforms 中的 ui.Color)把 vec3 或 vec4 设置为其 0 到 1 的分量,不预乘 alpha,处于 sRGB 空间;当窗口以广色域绘制时,超出 sRGB 的颜色(如 ui.Oklch 产生的)会以扩展 sRGB(分量低于 0 或高于 1)传入着色器,与窗口自身的颜色一样。
图像
着色器最多可读取四张图像,即 Options.Images:位图或画布(Canvas.Bitmap),坐标以其左上角为原点的像素:
| 函数 | |
|---|---|
image0At(p vec2) vec4 | p 处的像素,图像之外为透明 |
image0Sample(p vec2) vec4 | 在 p 周围四个像素之间混合,边缘处钳制 |
image0Size() vec2 | 图像的尺寸(像素) |
以此类推,还有 image1At 到 image3At 等。rectSize() 返回着色器填充的矩形大小(以 DIP 为单位),pixelScale() 返回一个 DIP 对应的像素数。
绘制到画布中的着色器可以成为另一个着色器的输入,就像效果的多重渲染通道那样:一个把场的少量采样计算到一张小画布上,下一个在窗口的每个像素处采样读取它。
go
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}})
})错误与开销
Compile 会连同行号报告着色器的问题;着色器应只编译一次,如作为包级变量配合 MustCompile。Compile 大约耗时一毫秒:它检查着色器并把它翻译成 GPU 的语言(Metal、HLSL 或 GLSL),之后由 GPU 驱动编译,正如 SwiftUI 的 Shader.compile(as:) 在使用前预先完成这一步。在 macOS 上,Compile 会在一个自己的 goroutine 中启动这一步:Metal 的 library 和 pipeline 在应用于该 Mac 上首次运行时总共约需 0.2 秒,所有窗口的渲染器都用它们绘制,只需等待剩余部分;作为包变量编译的着色器在其第一帧到来之前早已就绪,而且 Metal 会把两者都保存在磁盘上,后续启动可立即使用。Linux 的 Mesa 驱动也会把编译结果保存在磁盘上(位于 ~/.cache/mesa_shader_cache):CPU 的 llvmpipe 首次需要零点一至零点三秒,之后只需几毫秒。在 Windows 上,Direct3D 编译器对短着色器需要约零点一秒,对几百行的着色器需要约一秒,因此 Compile 会在一个自己的 goroutine 中启动编译:所有窗口的渲染器共享编译结果,首次绘制时只需等待剩余部分,编译产物会留在用户的缓存目录(MyGo\ShaderCache)中供后续启动使用。着色器在 GPU 上绘制的开销很小,无论它每个像素计算什么;在 CPU 上(最后手段的渲染器,以及 ui.Tester)它以解释方式运行,每个像素几微秒:一个窗口要好几秒。覆盖窗口大部分面积的着色器可以询问 p.Accelerated()——在 CPU 渲染以及 GPU 渲染器实际在 CPU 上绘制的情况下(WARP、llvmpipe、SwiftShader)它为 false——从而少画一些,或者不画:
go
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
if !p.Accelerated() {
return // 一个背景效果,窗口没有它也无关紧要
}
backdrop.Draw(p, r, shader.Options{})
})若把统一变量设置为非数字的值,Draw 会 panic。