diff --git a/app/headless/backend_test.go b/app/headless/backend_test.go new file mode 100644 index 00000000..04874bde --- /dev/null +++ b/app/headless/backend_test.go @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package headless + +import ( + "bytes" + "flag" + "image" + "image/color" + "image/png" + "io/ioutil" + "math" + "runtime" + "testing" + + "gioui.org/gpu/backend" + "gioui.org/internal/unsafe" +) + +var dumpImages = flag.Bool("saveimages", false, "save test images") + +var clearCol = color.RGBA{A: 0xff, R: 0xde, G: 0xad, B: 0xbe} + +func TestFramebufferClear(t *testing.T) { + b := newBackend(t) + sz := image.Point{X: 800, Y: 600} + fbo := setupFBO(t, b, sz) + img := screenshot(t, fbo, sz) + if got := img.RGBAAt(0, 0); got != clearCol { + t.Errorf("got color %v, expected %v", got, clearCol) + } +} + +func TestSimpleShader(t *testing.T) { + b := newBackend(t) + sz := image.Point{X: 800, Y: 600} + fbo := setupFBO(t, b, sz) + p, err := b.NewProgram(shader_simple_vert, shader_simple_frag) + if err != nil { + t.Fatal(err) + } + defer p.Release() + b.BindProgram(p) + b.DrawArrays(backend.DrawModeTriangles, 0, 3) + img := screenshot(t, fbo, sz) + if got := img.RGBAAt(0, 0); got != clearCol { + t.Errorf("got color %v, expected %v", got, clearCol) + } + // Just off the center to catch inverted triangles. + cx, cy := 300, 400 + shaderCol := [4]float32{.25, .55, .75, 1.0} + if got, exp := img.RGBAAt(cx, cy), tosRGB(shaderCol); got != exp { + t.Errorf("got color %v, expected %v", got, exp) + } +} + +func TestInputShader(t *testing.T) { + b := newBackend(t) + sz := image.Point{X: 800, Y: 600} + fbo := setupFBO(t, b, sz) + p, err := b.NewProgram(shader_input_vert, shader_simple_frag) + if err != nil { + t.Fatal(err) + } + defer p.Release() + b.BindProgram(p) + buf, err := b.NewImmutableBuffer(backend.BufferBindingVertices, + unsafe.BytesView([]float32{ + 0, .5, .5, 1, + -.5, -.5, .5, 1, + .5, -.5, .5, 1, + }), + ) + if err != nil { + t.Fatal(err) + } + defer buf.Release() + b.BindVertexBuffer(buf, 4*4, 0) + layout, err := b.NewInputLayout(shader_input_vert, []backend.InputDesc{ + { + Type: backend.DataTypeFloat, + Size: 4, + Offset: 0, + }, + }) + if err != nil { + t.Fatal(err) + } + defer layout.Release() + b.BindInputLayout(layout) + b.DrawArrays(backend.DrawModeTriangles, 0, 3) + img := screenshot(t, fbo, sz) + if got := img.RGBAAt(0, 0); got != clearCol { + t.Errorf("got color %v, expected %v", got, clearCol) + } + cx, cy := 300, 400 + shaderCol := [4]float32{.25, .55, .75, 1.0} + if got, exp := img.RGBAAt(cx, cy), tosRGB(shaderCol); got != exp { + t.Errorf("got color %v, expected %v", got, exp) + } +} + +func TestFramebuffers(t *testing.T) { + b := newBackend(t) + sz := image.Point{X: 800, Y: 600} + fbo1 := newFBO(t, b, sz) + fbo2 := newFBO(t, b, sz) + var ( + col1 = color.RGBA{R: 0xad, G: 0xbe, B: 0xef, A: 0xde} + col2 = color.RGBA{R: 0xfe, G: 0xba, B: 0xbe, A: 0xca} + ) + fcol1, fcol2 := fromsRGB(col1), fromsRGB(col2) + b.ClearColor(fcol1[0], fcol1[1], fcol1[2], fcol1[3]) + b.BindFramebuffer(fbo1) + b.Clear(backend.BufferAttachmentColor) + b.ClearColor(fcol2[0], fcol2[1], fcol2[2], fcol2[3]) + b.BindFramebuffer(fbo2) + b.Clear(backend.BufferAttachmentColor) + img := screenshot(t, fbo1, sz) + if got := img.RGBAAt(0, 0); got != col1 { + t.Errorf("got color %v, expected %v", got, col1) + } + img = screenshot(t, fbo2, sz) + if got := img.RGBAAt(0, 0); got != col2 { + t.Errorf("got color %v, expected %v", got, col2) + } +} + +func setupFBO(t *testing.T, b backend.Device, size image.Point) backend.Framebuffer { + fbo := newFBO(t, b, size) + b.BindFramebuffer(fbo) + b.Clear(backend.BufferAttachmentColor | backend.BufferAttachmentDepth) + b.Viewport(0, 0, size.X, size.Y) + return fbo +} + +func newFBO(t *testing.T, b backend.Device, size image.Point) backend.Framebuffer { + fboTex, err := b.NewTexture( + backend.TextureFormatSRGB, + size.X, size.Y, + backend.FilterNearest, backend.FilterNearest, + backend.BufferBindingFramebuffer, + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + fboTex.Release() + }) + const depthBits = 16 + fbo, err := b.NewFramebuffer(fboTex, depthBits) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + fbo.Release() + }) + return fbo +} + +func newBackend(t *testing.T) backend.Device { + ctx, err := newContext() + if err != nil { + t.Skipf("no context available: %v", err) + } + runtime.LockOSThread() + if err := ctx.MakeCurrent(); err != nil { + t.Fatal(err) + } + b, err := ctx.Backend() + if err != nil { + t.Fatal(err) + } + b.BeginFrame() + // ClearColor accepts linear RGBA colors, while 8-bit colors + // are in the sRGB color space. + col := fromsRGB(clearCol) + b.ClearColor(col[0], col[1], col[2], col[3]) + t.Cleanup(func() { + b.EndFrame() + ctx.ReleaseCurrent() + runtime.UnlockOSThread() + ctx.Release() + }) + return b +} + +func screenshot(t *testing.T, fbo backend.Framebuffer, size image.Point) *image.RGBA { + img := image.NewRGBA(image.Rectangle{Max: size}) + err := fbo.ReadPixels( + image.Rectangle{ + Max: image.Point{X: size.X, Y: size.Y}, + }, img.Pix) + if err != nil { + t.Fatal(err) + } + flipImageY(img) + if *dumpImages { + if err := saveImage(t.Name()+".png", img); err != nil { + t.Error(err) + } + } + return img +} + +func saveImage(file string, img image.Image) error { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return err + } + return ioutil.WriteFile(file, buf.Bytes(), 0666) +} + +func tosRGB(col [4]float32) color.RGBA { + for i := 0; i <= 2; i++ { + c := col[i] + // Use the formula from EXT_sRGB. + switch { + case c <= 0: + c = 0 + case 0 < c && c < 0.0031308: + c = 12.92 * c + case 0.0031308 <= c && c < 1: + c = 1.055*float32(math.Pow(float64(c), 0.41666)) - 0.055 + case c >= 1: + c = 1 + } + col[i] = c + } + return color.RGBA{R: uint8(col[0]*255 + .5), G: uint8(col[1]*255 + .5), B: uint8(col[2]*255 + .5), A: uint8(col[3]*255 + .5)} +} + +func fromsRGB(col color.Color) [4]float32 { + r, g, b, a := col.RGBA() + color := [4]float32{float32(r) / 0xffff, float32(g) / 0xffff, float32(b) / 0xffff, float32(a) / 0xffff} + for i := 0; i <= 2; i++ { + c := color[i] + // Use the formula from EXT_sRGB. + if c <= 0.04045 { + c = c / 12.92 + } else { + c = float32(math.Pow(float64((c+0.055)/1.055), 2.4)) + } + color[i] = c + } + return color +} diff --git a/app/headless/gen.go b/app/headless/gen.go new file mode 100644 index 00000000..dc89e1f7 --- /dev/null +++ b/app/headless/gen.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package headless + +//go:generate go run ../../internal/cmd/convertshaders -package headless diff --git a/app/headless/headless.go b/app/headless/headless.go index f7885659..bae92738 100644 --- a/app/headless/headless.go +++ b/app/headless/headless.go @@ -127,18 +127,23 @@ func (w *Window) Screenshot() (*image.RGBA, error) { if err != nil { return nil, err } + flipImageY(img) + return img, nil +} + +func flipImageY(img *image.RGBA) { // Flip image in y-direction. OpenGL's origin is in the lower // left corner. row := make([]uint8, img.Stride) - for y := 0; y < w.size.Y/2; y++ { - y1 := w.size.Y - y - 1 + sy := img.Bounds().Dy() + for y := 0; y < sy/2; y++ { + y1 := sy - y - 1 dest := img.PixOffset(0, y1) src := img.PixOffset(0, y) copy(row, img.Pix[dest:]) copy(img.Pix[dest:], img.Pix[src:src+len(row)]) copy(img.Pix[src:], row) } - return img, nil } func contextDo(ctx context, f func() error) error { diff --git a/app/headless/headless_test.go b/app/headless/headless_test.go index ab938a53..e709311d 100644 --- a/app/headless/headless_test.go +++ b/app/headless/headless_test.go @@ -13,14 +13,11 @@ import ( ) func TestHeadless(t *testing.T) { - sz := image.Point{X: 800, Y: 600} - w, err := NewWindow(sz.X, sz.Y) - if err != nil { - t.Skipf("headless windows not supported: %v", err) - } - defer w.Release() + w, release := newTestWindow(t) + defer release() - col := color.RGBA{A: 0xff, R: 0xcc, G: 0xcc} + sz := w.size + col := color.RGBA{A: 0xff, R: 0xca, G: 0xfe} var ops op.Ops paint.ColorOp{Color: col}.Add(&ops) // Paint only part of the screen to avoid the glClear optimization. @@ -41,3 +38,15 @@ func TestHeadless(t *testing.T) { t.Errorf("got color %v, expected %v", got, col) } } + +func newTestWindow(t *testing.T) (*Window, func()) { + t.Helper() + sz := image.Point{X: 800, Y: 600} + w, err := NewWindow(sz.X, sz.Y) + if err != nil { + t.Skipf("headless windows not supported: %v", err) + } + return w, func() { + w.Release() + } +} diff --git a/app/headless/shaders.go b/app/headless/shaders.go new file mode 100644 index 00000000..bda6e588 --- /dev/null +++ b/app/headless/shaders.go @@ -0,0 +1,123 @@ +// Code generated by build.go. DO NOT EDIT. + +package headless + +import "gioui.org/gpu/backend" + +var ( + shader_input_vert = backend.ShaderSources{ + Inputs: []backend.InputLocation{backend.InputLocation{Name: "position", Location: 0, Semantic: "POSITION", SemanticIndex: 0, Type: 0x0, Size: 4}}, + GLSL100ES: "#version 100\n\nattribute vec4 position;\n\nvoid main()\n{\n gl_Position = position;\n}\n\n", + GLSL300ES: "#version 300 es\n\nlayout(location = 0) in vec4 position;\n\nvoid main()\n{\n gl_Position = position;\n}\n\n", + /* + static float4 gl_Position; + static float4 position; + + struct SPIRV_Cross_Input + { + float4 position : POSITION; + }; + + struct SPIRV_Cross_Output + { + float4 gl_Position : SV_Position; + }; + + void vert_main() + { + gl_Position = position; + } + + SPIRV_Cross_Output main(SPIRV_Cross_Input stage_input) + { + position = stage_input.position; + vert_main(); + SPIRV_Cross_Output stage_output; + stage_output.gl_Position = gl_Position; + return stage_output; + } + + */ + HLSL: []byte(nil), + } + shader_simple_frag = backend.ShaderSources{ + GLSL100ES: "#version 100\nprecision mediump float;\nprecision highp int;\n\nvoid main()\n{\n gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n\n", + GLSL300ES: "#version 300 es\nprecision mediump float;\nprecision highp int;\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main()\n{\n fragColor = vec4(0.25, 0.5, 0.75, 1.0);\n}\n\n", + /* + static float4 fragColor; + + struct SPIRV_Cross_Output + { + float4 fragColor : SV_Target0; + }; + + void frag_main() + { + fragColor = float4(0.25f, 0.5f, 0.75f, 1.0f); + } + + SPIRV_Cross_Output main() + { + frag_main(); + SPIRV_Cross_Output stage_output; + stage_output.fragColor = fragColor; + return stage_output; + } + + */ + HLSL: []byte(nil), + } + shader_simple_vert = backend.ShaderSources{ + GLSL100ES: "#version 100\n\nvoid main()\n{\n float x;\n float y;\n if (gl_VertexID == 0)\n {\n x = 0.0;\n y = 0.5;\n }\n else\n {\n if (gl_VertexID == 1)\n {\n x = 0.5;\n y = -0.5;\n }\n else\n {\n x = -0.5;\n y = -0.5;\n }\n }\n gl_Position = vec4(x, y, 0.5, 1.0);\n}\n\n", + GLSL300ES: "#version 300 es\n\nvoid main()\n{\n float x;\n float y;\n if (gl_VertexID == 0)\n {\n x = 0.0;\n y = 0.5;\n }\n else\n {\n if (gl_VertexID == 1)\n {\n x = 0.5;\n y = -0.5;\n }\n else\n {\n x = -0.5;\n y = -0.5;\n }\n }\n gl_Position = vec4(x, y, 0.5, 1.0);\n}\n\n", + /* + static float4 gl_Position; + static int gl_VertexIndex; + struct SPIRV_Cross_Input + { + uint gl_VertexIndex : SV_VertexID; + }; + + struct SPIRV_Cross_Output + { + float4 gl_Position : SV_Position; + }; + + void vert_main() + { + float x; + float y; + if (gl_VertexIndex == 0) + { + x = 0.0f; + y = 0.5f; + } + else + { + if (gl_VertexIndex == 1) + { + x = 0.5f; + y = -0.5f; + } + else + { + x = -0.5f; + y = -0.5f; + } + } + gl_Position = float4(x, y, 0.5f, 1.0f); + } + + SPIRV_Cross_Output main(SPIRV_Cross_Input stage_input) + { + gl_VertexIndex = int(stage_input.gl_VertexIndex); + vert_main(); + SPIRV_Cross_Output stage_output; + stage_output.gl_Position = gl_Position; + return stage_output; + } + + */ + HLSL: []byte(nil), + } +) diff --git a/app/headless/shaders/input.vert b/app/headless/shaders/input.vert new file mode 100644 index 00000000..ed9a4bd7 --- /dev/null +++ b/app/headless/shaders/input.vert @@ -0,0 +1,11 @@ +#version 310 es + +// SPDX-License-Identifier: Unlicense OR MIT + +precision highp float; + +layout(location=0) in vec4 position; + +void main() { + gl_Position = position; +} diff --git a/app/headless/shaders/simple.frag b/app/headless/shaders/simple.frag new file mode 100644 index 00000000..4614f337 --- /dev/null +++ b/app/headless/shaders/simple.frag @@ -0,0 +1,11 @@ +#version 310 es + +// SPDX-License-Identifier: Unlicense OR MIT + +precision mediump float; + +layout(location = 0) out vec4 fragColor; + +void main() { + fragColor = vec4(.25, .55, .75, 1.0); +} diff --git a/app/headless/shaders/simple.vert b/app/headless/shaders/simple.vert new file mode 100644 index 00000000..a226816d --- /dev/null +++ b/app/headless/shaders/simple.vert @@ -0,0 +1,20 @@ +#version 310 es + +// SPDX-License-Identifier: Unlicense OR MIT + +precision highp float; + +void main() { + float x, y; + if (gl_VertexIndex == 0) { + x = 0.0; + y = .5; + } else if (gl_VertexIndex == 1) { + x = .5; + y = -.5; + } else { + x = -.5; + y = -.5; + } + gl_Position = vec4(x, y, 0.5, 1.0); +}