Files
aego/tests/gl/readpixels_test.go
sanspie dae4bc19e1 Stage 1: engine core, platform, gpu, vfs
Foundation for the aego engine.

core/    errs, log, handle arena, mathx, clock, stats
platform SDL3 backend plus a headless one, input state, events
gpu      device abstraction with gl33 and null backends
vfs      scheme-based virtual filesystem with overlay mounts
engine   builder, module registry, phase-ordered system loop, jobs
demos    basic, resize, input, headless; sandbox driver

OpenGL bindings are generated into gpu/gl33/gl by tools/gen-gl.sh,
so the module has no external dependencies.
2026-09-18 20:51:54 +03:00

163 lines
4.4 KiB
Go

//go:build aego_gltest
// Package gl holds tests that need a real OpenGL context. They are excluded
// from the default build; run them with `make test-gl`, which points SDL at the
// offscreen video driver and Mesa at llvmpipe so results are deterministic and
// no display server is required.
package gl
import (
"math"
"testing"
"aego/core/log"
"aego/core/mathx"
"aego/engine"
"aego/gpu"
)
const vertSrc = `#version 330 core
layout(location = 0) in vec2 pos;
void main() { gl_Position = vec4(pos, 0.0, 1.0); }
`
const fragSrc = `#version 330 core
out vec4 frag;
void main() { frag = vec4(1.0, 0.0, 0.0, 1.0); }
`
func newGLEngine(t *testing.T) *engine.Engine {
t.Helper()
e, err := engine.NewBuilder(engine.Config{
Title: "aego-gltest", Width: 64, Height: 64, Hidden: true,
ResRoot: t.TempDir(), UserRoot: t.TempDir(), AppID: "aego-gltest",
}).WithLogger(log.Nop()).Build()
if err != nil {
t.Fatalf("build: %v", err)
}
if e.Caps().Renderer == "null" {
e.Close()
t.Skip("no OpenGL context available")
}
return e
}
// TestReadPixelsOrientation pins two contracts at once:
//
// - D17: gl33 flips viewport and scissor only for the window, never for an
// offscreen target. A target-space viewport at y=H/2..H is therefore GL's
// own upper half.
// - ReadPixels always returns rows top-down, whatever the backend's origin.
//
// If either regresses the image comes back mirrored, which is precisely the
// failure that would silently invalidate every Stage 2 golden.
func TestReadPixelsOrientation(t *testing.T) {
e := newGLEngine(t)
defer e.Close()
dev := e.GPU()
if !dev.Caps().OriginBottomLeft {
t.Skip("backend is already top-down; this test targets bottom-left origins")
}
const size = 64
target, err := dev.CreateTexture(gpu.TextureDesc{
Width: size, Height: size, Format: gpu.TexRGBA8, Label: "target",
})
if err != nil {
t.Fatalf("target: %v", err)
}
sh, err := dev.CreateShader(gpu.ShaderDesc{
Label: "flat",
Sources: []gpu.ShaderSource{
{Stage: gpu.StageVertex, Source: vertSrc},
{Stage: gpu.StageFragment, Source: fragSrc},
},
})
if err != nil {
t.Fatalf("shader: %v", err)
}
pipe, err := dev.CreatePipeline(gpu.PipelineDesc{
Shader: sh,
Layout: gpu.VertexLayout{
Attrs: []gpu.VertexAttr{{Location: 0, Slot: 0, Format: gpu.VFFloat2, Offset: 0}},
Strides: [gpu.MaxVertexSlots]uint16{8},
},
Primitive: gpu.PrimTriangles,
Label: "flat",
})
if err != nil {
t.Fatalf("pipeline: %v", err)
}
vb, err := dev.CreateBuffer(gpu.BufferDesc{
Usage: gpu.BufferVertex,
Data: f32bytes(-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1),
Label: "vb",
})
if err != nil {
t.Fatalf("vbuf: %v", err)
}
fr := dev.BeginFrame(size, size)
p := fr.BeginPass(gpu.PassDesc{
Color: gpu.LoadClear,
ClearColor: mathx.Color{R: 0, G: 0, B: 1, A: 1},
Target: target,
Label: "orientation",
})
p.SetViewport(mathx.Rect{X: 0, Y: size / 2, W: size, H: size / 2})
p.SetPipeline(pipe)
p.SetVertexBuffer(0, vb)
p.Draw(0, 6)
p.End()
dev.EndFrame()
out := make([]byte, size*size*4)
if err := dev.ReadPixels(target, 0, 0, size, size, out); err != nil {
t.Fatalf("read pixels: %v", err)
}
row := func(y int) (r, g, b, a byte) {
i := y * size * 4
return out[i], out[i+1], out[i+2], out[i+3]
}
rt, _, bt, at := row(0)
rb, _, bb, ab := row(size - 1)
if at != 255 || ab != 255 {
t.Fatalf("alpha not opaque: top=%d bottom=%d", at, ab)
}
if !(rt > 200 && bt < 50) || !(rb < 50 && bb > 200) {
t.Fatalf("image is mirrored or wrong: top=(%d,_,%d) bottom=(%d,_,%d)", rt, bt, rb, bb)
}
}
func TestReadPixelsRejectsBadArgs(t *testing.T) {
e := newGLEngine(t)
defer e.Close()
dev := e.GPU()
tex, err := dev.CreateTexture(gpu.TextureDesc{Width: 8, Height: 8, Format: gpu.TexRGBA8})
if err != nil {
t.Fatal(err)
}
big := make([]byte, 8*8*4)
if err := dev.ReadPixels(tex, 0, 0, 16, 16, big); err == nil {
t.Fatal("out-of-range region accepted")
}
if err := dev.ReadPixels(tex, 0, 0, 8, 8, big[:4]); err == nil {
t.Fatal("undersized destination accepted")
}
if err := dev.ReadPixels(gpu.TextureHandle{}, 0, 0, 8, 8, big); err == nil {
t.Fatal("invalid handle accepted")
}
}
func f32bytes(vals ...float32) []byte {
out := make([]byte, len(vals)*4)
for i, v := range vals {
b := math.Float32bits(v)
out[i*4+0], out[i*4+1] = byte(b), byte(b>>8)
out[i*4+2], out[i*4+3] = byte(b>>16), byte(b>>24)
}
return out
}