Files
aego/tests/golden/golden_test.go
2026-09-26 16:32:40 +03:00

337 lines
8.6 KiB
Go

//go:build aego_golden
package golden
import (
"flag"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"aego/core/log"
"aego/core/mathx"
"aego/engine"
"aego/gpu"
"aego/render"
rendermodule "aego/render/module"
"aego/render/sprite"
"aego/render/texture"
"aego/render/view"
)
const size = 256
var update = flag.Bool("update", false, "rewrite reference images")
type sceneFunc func(*render.Renderer, gpu.TextureHandle)
type testCase struct {
name string
tolerance int
scene sceneFunc
}
func TestGolden(t *testing.T) {
mod := rendermodule.New(render.Options{MaxSprites: 4096})
e, err := engine.NewBuilder(engine.Config{
Title: "golden",
Width: size,
Height: size,
Hidden: true,
ResRoot: "../../demos/res",
UserRoot: t.TempDir(),
AppID: "aego-golden",
}).WithLogger(log.Nop()).Use(mod).Build()
if err != nil {
t.Skipf("no GL context available: %v", err)
}
defer e.Close()
renderer := e.Caps().Renderer
t.Logf("golden renderer: %s", renderer)
if !softwareRenderer(renderer) {
if os.Getenv("AEGO_GOLDEN_ALLOW_HARDWARE") == "" {
t.Fatalf("golden references require a software rasterizer, got %q; "+
"run with SDL_VIDEODRIVER=offscreen LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe, "+
"or set AEGO_GOLDEN_ALLOW_HARDWARE=1 to compare anyway", renderer)
}
t.Logf("comparing against golden references on a hardware renderer %q, results are advisory", renderer)
}
if *update && !softwareRenderer(renderer) {
t.Fatalf("refusing to write golden references from a hardware renderer %q", renderer)
}
r := mod.Renderer()
target, err := e.GPU().CreateTexture(gpu.TextureDesc{
Width: size, Height: size, Format: gpu.TexSRGBA8, Label: "golden.target",
})
if err != nil {
t.Fatal(err)
}
for _, tc := range cases(t, r) {
t.Run(tc.name, func(t *testing.T) {
frame := e.GPU().BeginFrame(size, size)
if err := r.BeginFrame(frame, size, size, 1); err != nil {
t.Fatal(err)
}
tc.scene(r, target)
r.EndFrame()
e.GPU().EndFrame()
buf := make([]byte, size*size*4)
if err := e.GPU().ReadPixels(target, 0, 0, size, size, buf); err != nil {
t.Fatal(err)
}
img := &image.NRGBA{Pix: buf, Stride: size * 4, Rect: image.Rect(0, 0, size, size)}
path := filepath.Join("testdata", tc.name+".png")
if *update {
writePNG(t, path, img)
return
}
compare(t, path, img, tc.tolerance)
})
}
}
func softwareRenderer(name string) bool {
n := strings.ToLower(name)
for _, s := range []string{"llvmpipe", "softpipe", "swrast", "lavapipe", "null"} {
if strings.Contains(n, s) {
return true
}
}
return false
}
func writePNG(t *testing.T, path string, img image.Image) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
func compare(t *testing.T, path string, got *image.NRGBA, tolerance int) {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("reference missing, run with -update: %v", err)
}
defer f.Close()
want, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
differing := 0
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
if diff(want.At(x, y), got.At(x, y)) > tolerance {
differing++
}
}
}
if pct := float64(differing) * 100 / float64(size*size); pct > 0.5 {
writePNG(t, filepath.Join("testdata", "actual-"+filepath.Base(path)), got)
t.Fatalf("%.3f%% of pixels differ beyond tolerance %d", pct, tolerance)
}
}
func diff(a, b color.Color) int {
ar, ag, ab, aa := a.RGBA()
br, bg, bb, ba := b.RGBA()
d := 0
for _, p := range [4][2]uint32{{ar, br}, {ag, bg}, {ab, bb}, {aa, ba}} {
v := int(p[0]>>8) - int(p[1]>>8)
if v < 0 {
v = -v
}
if v > d {
d = v
}
}
return d
}
func targetView(target gpu.TextureHandle, clear mathx.Color) view.View {
return view.View{
Target: target,
TargetW: size,
TargetH: size,
Viewport: mathx.Rect{W: size, H: size},
PixelScale: 1,
Camera: view.Camera2D{Zoom: 32},
Clear: clear,
DoClear: true,
Label: "golden",
}
}
func cases(t *testing.T, r *render.Renderer) []testCase {
t.Helper()
white, err := r.Textures().Upload(texture.Solid(8, 8, mathx.Color{R: 1, G: 1, B: 1, A: 1}), texture.UploadOptions{})
if err != nil {
t.Fatal(err)
}
solid := texture.Whole(white, 8, 8)
atlas := buildAtlas(t, r)
black := mathx.Color{A: 1}
return []testCase{
{"alpha", 0, func(r *render.Renderer, tgt gpu.TextureHandle) {
f := r.View(targetView(tgt, black))
for i := 0; i < 3; i++ {
f.Sprite(sprite.Draw{
Region: solid,
Position: mathx.Vec2{X: float32(i)*0.6 - 0.6},
Size: mathx.Vec2{X: 2, Y: 2},
Origin: mathx.Vec2{X: 0.5, Y: 0.5},
Tint: mathx.Color{R: 1, G: 0.5, B: 0.2, A: 0.4},
Order: int16(i),
})
}
f.End()
}},
{"rotation", 4, func(r *render.Renderer, tgt gpu.TextureHandle) {
f := r.View(targetView(tgt, black))
for i := 0; i < 6; i++ {
f.Sprite(sprite.Draw{
Region: atlas,
Position: mathx.Vec2{X: float32(i%3)*2 - 2, Y: float32(i/3)*2 - 1},
Size: mathx.Vec2{X: 1.5, Y: 1.5},
Origin: mathx.Vec2{X: 0.5, Y: 0.5},
Rotation: float32(i) * 0.3,
Tint: mathx.Color{R: 1, G: 1, B: 1, A: 1},
})
}
f.End()
}},
{"camera", 4, func(r *render.Renderer, tgt gpu.TextureHandle) {
v := targetView(tgt, black)
v.Camera = view.Camera2D{Position: mathx.Vec2{X: 1, Y: -0.5}, Rotation: 0.4, Zoom: 48}
f := r.View(v)
for y := -2; y <= 2; y++ {
for x := -2; x <= 2; x++ {
f.Sprite(sprite.Draw{
Region: atlas,
Position: mathx.Vec2{X: float32(x), Y: float32(y)},
Size: mathx.Vec2{X: 0.9, Y: 0.9},
Origin: mathx.Vec2{X: 0.5, Y: 0.5},
Tint: mathx.Color{R: 1, G: 1, B: 1, A: 1},
})
}
}
f.End()
}},
{"atlas", 0, func(r *render.Renderer, tgt gpu.TextureHandle) {
f := r.View(targetView(tgt, black))
for i := 0; i < 4; i++ {
f.Sprite(sprite.Draw{
Region: atlas.Grid(2, 2, i),
Position: mathx.Vec2{X: float32(i%2)*2 - 1, Y: float32(i/2)*2 - 1},
Size: mathx.Vec2{X: 1.8, Y: 1.8},
Origin: mathx.Vec2{X: 0.5, Y: 0.5},
Tint: mathx.Color{R: 1, G: 1, B: 1, A: 1},
})
}
f.End()
}},
{"clipping", 0, func(r *render.Renderer, tgt gpu.TextureHandle) {
v := targetView(tgt, black)
f := r.View(v)
f.PushClip(mathx.Rect{X: 40, Y: 40, W: 120, H: 90})
f.Sprite(sprite.Draw{
Region: solid,
Size: mathx.Vec2{X: 8, Y: 8},
Origin: mathx.Vec2{X: 0.5, Y: 0.5},
Tint: mathx.Color{R: 0.2, G: 0.9, B: 0.4, A: 1},
})
f.PopClip()
f.End()
}},
{"srgb", 4, func(r *render.Renderer, tgt gpu.TextureHandle) {
f := r.View(targetView(tgt, black))
for i := 0; i < 8; i++ {
g := float32(i) / 7
f.Sprite(sprite.Draw{
Region: solid,
Position: mathx.Vec2{X: float32(i)*0.9 - 3.2},
Size: mathx.Vec2{X: 0.85, Y: 4},
Origin: mathx.Vec2{X: 0.5, Y: 0.5},
Tint: mathx.Color{R: g, G: g, B: g, A: 1},
})
}
f.End()
}},
{"text", 0, func(r *render.Renderer, tgt gpu.TextureHandle) {
v := view.ScreenView(size, size, 1)
v.Target = tgt
v.Clear = black
v.DoClear = true
f := r.View(v)
f.Text(r.DebugFont(), "aego\ngolden\n0123456789", mathx.Vec2{X: 16, Y: 24}, 2,
mathx.Color{R: 1, G: 1, B: 1, A: 1}, 0, 0)
f.End()
}},
{"offscreen_flip", 0, func(r *render.Renderer, tgt gpu.TextureHandle) {
v := view.ScreenView(size, size, 1)
v.Target = tgt
v.Clear = black
v.DoClear = true
f := r.View(v)
f.Sprite(sprite.Draw{
Region: solid,
Size: mathx.Vec2{X: size, Y: 32},
Tint: mathx.Color{R: 1, G: 0.2, B: 0.1, A: 1},
})
f.Sprite(sprite.Draw{
Region: solid,
Position: mathx.Vec2{Y: size - 32},
Size: mathx.Vec2{X: size, Y: 32},
Tint: mathx.Color{R: 0.1, G: 0.3, B: 1, A: 1},
})
f.End()
}},
}
}
func buildAtlas(t *testing.T, r *render.Renderer) texture.Region {
t.Helper()
d := texture.New(64, 64, gpu.TexSRGBA8)
colors := [4]mathx.Color{
mathx.ColorRGB8(0xE0, 0x7B, 0x39),
mathx.ColorRGB8(0x52, 0xA7, 0xC9),
mathx.ColorRGB8(0x8A, 0xC9, 0x52),
mathx.ColorRGB8(0xC9, 0x52, 0x8A),
}
for y := 0; y < 64; y++ {
for x := 0; x < 64; x++ {
cell := (x/32)%2 + ((y/32)%2)*2
c := colors[cell]
if (x/4+y/4)%2 == 0 {
c = c.Lerp(mathx.Color{A: 1}, 0.3)
}
d.SetPixel(x, y, c)
}
}
tex, err := r.Textures().Upload(d, texture.UploadOptions{Label: "golden.atlas"})
if err != nil {
t.Fatal(err)
}
return texture.Whole(tex, 64, 64)
}