diff --git a/app/headless/headless.go b/app/headless/headless.go new file mode 100644 index 00000000..58f4c9f3 --- /dev/null +++ b/app/headless/headless.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package headless + +import ( + "fmt" + "image" + "runtime" + + "gioui.org/app/internal/gl" + "gioui.org/app/internal/gpu" + "gioui.org/op" +) + +// Window is a headless window. +type Window struct { + size image.Point + ctx context + fbo *gl.SRGBFBO + gpu *gpu.GPU +} + +type context interface { + Functions() *gl.Functions + MakeCurrent() error + ReleaseCurrent() + Release() +} + +// NewWindow creates a new headless window. +func NewWindow(width, height int) (*Window, error) { + ctx, err := newContext() + if err != nil { + return nil, err + } + w := &Window{ + size: image.Point{X: width, Y: height}, + ctx: ctx, + } + err = contextDo(ctx, func() error { + f := ctx.Functions() + fbo, err := gl.NewSRGBFBO(f) + if err != nil { + ctx.Release() + return err + } + if err := fbo.Refresh(width, height); err != nil { + fbo.Release() + ctx.Release() + return err + } + gpu, err := gpu.New(f) + if err != nil { + fbo.Release() + ctx.Release() + return err + } + w.fbo = fbo + w.gpu = gpu + return err + }) + if err != nil { + return nil, err + } + return w, nil +} + +// Release resources associated with the window. +func (w *Window) Release() { + contextDo(w.ctx, func() error { + if w.gpu != nil { + w.gpu.Release() + w.gpu = nil + } + if w.fbo != nil { + w.fbo.Release() + w.fbo = nil + } + if w.ctx != nil { + w.ctx.Release() + w.ctx = nil + } + return nil + }) +} + +// Frame replace the window content and state with the +// operation list. +func (w *Window) Frame(frame *op.Ops) { + contextDo(w.ctx, func() error { + w.gpu.Collect(false, w.size, frame) + w.gpu.Frame(false, w.size) + w.gpu.EndFrame(false) + return nil + }) +} + +// Screenshot returns an image with the content of the window. +func (w *Window) Screenshot() (*image.RGBA, error) { + img := image.NewRGBA(image.Rectangle{Max: w.size}) + if len(img.Pix) != w.size.X*w.size.Y*4 { + panic("unexpected RGBA size") + } + contextDo(w.ctx, func() error { + f := w.ctx.Functions() + f.ReadPixels(0, 0, w.size.X, w.size.Y, gl.RGBA, gl.UNSIGNED_BYTE, img.Pix) + if glErr := f.GetError(); glErr != gl.NO_ERROR { + return fmt.Errorf("glReadPixels failed: %d", glErr) + } + return nil + }) + return img, nil +} + +func contextDo(ctx context, f func() error) error { + errCh := make(chan error) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := ctx.MakeCurrent(); err != nil { + errCh <- err + return + } + defer ctx.ReleaseCurrent() + errCh <- f() + }() + return <-errCh +} diff --git a/app/headless/headless_darwin.go b/app/headless/headless_darwin.go new file mode 100644 index 00000000..ff09c99d --- /dev/null +++ b/app/headless/headless_darwin.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package headless + +import "gioui.org/app/internal/gl" + +/* +#cgo CFLAGS: -DGL_SILENCE_DEPRECATION -Werror -Wno-deprecated-declarations -fmodules -fobjc-arc -x objective-c + +#include +#include "headless_darwin.h" +*/ +import "C" + +type nsContext struct { + c *gl.Functions + ctx C.CFTypeRef +} + +func newContext() (context, error) { + ctx := C.gio_headless_newContext() + return &nsContext{ctx: ctx, c: new(gl.Functions)}, nil +} + +func (c *nsContext) MakeCurrent() error { + C.gio_headless_makeCurrentContext(c.ctx) + return nil +} + +func (c *nsContext) ReleaseCurrent() { + C.gio_headless_clearCurrentContext(c.ctx) +} + +func (c *nsContext) Functions() *gl.Functions { + return c.c +} + +func (d *nsContext) Release() { + if d.ctx != 0 { + C.gio_headless_releaseContext(d.ctx) + d.ctx = 0 + } +} diff --git a/app/headless/headless_darwin.h b/app/headless/headless_darwin.h new file mode 100644 index 00000000..248e5d76 --- /dev/null +++ b/app/headless/headless_darwin.h @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +__attribute__ ((visibility ("hidden"))) CFTypeRef gio_headless_newContext(void); +__attribute__ ((visibility ("hidden"))) void gio_headless_releaseContext(CFTypeRef ctxRef); +__attribute__ ((visibility ("hidden"))) void gio_headless_clearCurrentContext(CFTypeRef ctxRef); +__attribute__ ((visibility ("hidden"))) void gio_headless_makeCurrentContext(CFTypeRef ctxRef); diff --git a/app/headless/headless_egl.go b/app/headless/headless_egl.go new file mode 100644 index 00000000..03b36d10 --- /dev/null +++ b/app/headless/headless_egl.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// +build linux freebsd windows + +package headless + +import ( + "gioui.org/app/internal/egl" +) + +func newContext() (context, error) { + return egl.NewContext(egl.EGL_DEFAULT_DISPLAY) +} diff --git a/app/headless/headless_ios.m b/app/headless/headless_ios.m new file mode 100644 index 00000000..90aa838c --- /dev/null +++ b/app/headless/headless_ios.m @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// +build darwin,ios + +@import OpenGLES; + +#include +#include "headless_darwin.h" + +void gio_headless_releaseContext(CFTypeRef ctxRef) { + CFBridgingRelease(ctxRef); +} + +CFTypeRef gio_headless_newContext(void) { + EAGLContext *ctx = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3]; + if (ctx == nil) { + return nil; + } + return CFBridgingRetain(ctx); +} + +void gio_headless_clearCurrentContext(CFTypeRef ctxRef) { + [EAGLContext setCurrentContext:nil]; +} + +void gio_headless_makeCurrentContext(CFTypeRef ctxRef) { + EAGLContext *ctx = (__bridge EAGLContext *)ctxRef; + [EAGLContext setCurrentContext:ctx]; +} diff --git a/app/headless/headless_js.go b/app/headless/headless_js.go new file mode 100644 index 00000000..5c3624a1 --- /dev/null +++ b/app/headless/headless_js.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package headless + +import ( + "errors" + "syscall/js" + + "gioui.org/app/internal/gl" +) + +type jsContext struct { + ctx js.Value + f *gl.Functions +} + +func newContext() (*jsContext, error) { + version := 2 + doc := js.Global().Get("document") + cnv := doc.Call("createElement", "canvas") + ctx := cnv.Call("getContext", "webgl2") + if ctx.IsNull() { + version = 1 + ctx = cnv.Call("getContext", "webgl") + } + if ctx.IsNull() { + return nil, errors.New("headless: webgl is not supported") + } + f := &gl.Functions{Ctx: ctx} + if err := f.Init(version); err != nil { + return nil, err + } + c := &jsContext{ + ctx: ctx, + f: f, + } + return c, nil +} + +func (c *jsContext) Functions() *gl.Functions { + return c.f +} + +func (c *jsContext) Release() { +} + +func (c *jsContext) ReleaseCurrent() { +} + +func (c *jsContext) MakeCurrent() error { + return nil +} diff --git a/app/headless/headless_macos.m b/app/headless/headless_macos.m new file mode 100644 index 00000000..d23f3ccb --- /dev/null +++ b/app/headless/headless_macos.m @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// +build darwin,!ios + +@import AppKit; +@import OpenGL; +@import OpenGL.GL; + +#include +#include "headless_darwin.h" + +void gio_headless_releaseContext(CFTypeRef ctxRef) { + CFBridgingRelease(ctxRef); +} + +CFTypeRef gio_headless_newContext(void) { + NSOpenGLPixelFormatAttribute attr[] = { + NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion3_2Core, + NSOpenGLPFAColorSize, 24, + NSOpenGLPFAAccelerated, + // Opt-in to automatic GPU switching. CGL-only property. + kCGLPFASupportsAutomaticGraphicsSwitching, + NSOpenGLPFAAllowOfflineRenderers, + 0 + }; + NSOpenGLPixelFormat *pixFormat = [[NSOpenGLPixelFormat alloc] initWithAttributes:attr]; + if (pixFormat == nil) { + return NULL; + } + NSOpenGLContext *ctx = [[NSOpenGLContext alloc] initWithFormat:pixFormat shareContext:nil]; + return CFBridgingRetain(ctx); +} + +void gio_headless_clearCurrentContext(CFTypeRef ctxRef) { + NSOpenGLContext *ctx = (__bridge NSOpenGLContext *)ctxRef; + CGLUnlockContext([ctx CGLContextObj]); + [NSOpenGLContext clearCurrentContext]; +} + +void gio_headless_makeCurrentContext(CFTypeRef ctxRef) { + NSOpenGLContext *ctx = (__bridge NSOpenGLContext *)ctxRef; + [ctx makeCurrentContext]; + glEnable(GL_FRAMEBUFFER_SRGB); + CGLLockContext([ctx CGLContextObj]); +} diff --git a/app/headless/headless_test.go b/app/headless/headless_test.go new file mode 100644 index 00000000..9fbd91af --- /dev/null +++ b/app/headless/headless_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package headless + +import ( + "image" + "image/color" + "testing" + + "gioui.org/f32" + "gioui.org/op" + "gioui.org/op/paint" +) + +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) + } + + col := color.RGBA{A: 0xff, R: 0xcc, G: 0xcc} + var ops op.Ops + paint.ColorOp{Color: col}.Add(&ops) + paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{ + X: float32(sz.X), + Y: float32(sz.Y), + }}}.Add(&ops) + w.Frame(&ops) + + img, err := w.Screenshot() + if err != nil { + t.Fatal(err) + } + if isz := img.Bounds().Size(); isz != sz { + t.Errorf("got %v screenshot, expected %v", isz, sz) + } + if got := img.RGBAAt(0, 0); got != col { + t.Errorf("got color %v, expected %v", got, col) + } +} diff --git a/app/internal/egl/egl.go b/app/internal/egl/egl.go index 580c9beb..02ce40a2 100644 --- a/app/internal/egl/egl.go +++ b/app/internal/egl/egl.go @@ -38,6 +38,7 @@ var ( nilEGLContext _EGLContext nilEGLConfig _EGLConfig nilEGLNativeWindowType NativeWindowType + EGL_DEFAULT_DISPLAY NativeDisplayType ) const ( @@ -71,6 +72,7 @@ func (c *Context) Release() { eglReleaseThread() c.eglCtx = nil } + c.disp = nilEGLDisplay } func (c *Context) Present() error { @@ -116,7 +118,7 @@ func (c *Context) ReleaseSurface() { } // Make sure any in-flight GL commands are complete. c.c.Finish() - eglMakeCurrent(c.disp, nilEGLSurface, nilEGLSurface, nilEGLContext) + c.ReleaseCurrent() eglDestroySurface(c.disp, c.eglSurf) c.eglSurf = nilEGLSurface } @@ -134,6 +136,12 @@ func (c *Context) CreateSurface(win NativeWindowType, width, height int) error { return err } +func (c *Context) ReleaseCurrent() { + if c.disp != nilEGLDisplay { + eglMakeCurrent(c.disp, nilEGLSurface, nilEGLSurface, nilEGLContext) + } +} + func (c *Context) MakeCurrent() error { if c.eglSurf == nilEGLSurface && !c.eglCtx.surfaceless { return errors.New("no surface created yet EGL_KHR_surfaceless_context is not supported") diff --git a/app/internal/gl/gl.go b/app/internal/gl/gl.go index 0c5239ba..109c4a33 100644 --- a/app/internal/gl/gl.go +++ b/app/internal/gl/gl.go @@ -36,6 +36,7 @@ const ( LUMINANCE = 0x1909 MAX_TEXTURE_SIZE = 0xd33 NEAREST = 0x2600 + NO_ERROR = 0x0 ONE = 0x1 ONE_MINUS_SRC_ALPHA = 0x303 QUERY_RESULT = 0x8866