Headless: Run OpenGL offscreen on macOS, without a window

A CGL context with no drawable, rendering into a framebuffer object of its
own that stands in for the backbuffer through g_defaultFBO. Core profile,
as the SDL app uses on macOS.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-09-25 09:47:58 -06:00
1 parent c9cddb99e9
commit ea101b714f
3 files changed
+151

No files matched your search

+5
View File
@@ -966,6 +966,11 @@ int main(int argc, const char* argv[]) {
vulkanContext->SetOffscreen(480, 272);
graphicsContext = vulkanContext;
deviceSetting = &g_Config.sVulkanDevice;
#if PPSSPP_PLATFORM(MAC) && defined(SDL)
} else if (gpuCore == GPUCORE_GLES) {
// So does OpenGL, into a framebuffer object of its own.
graphicsContext = new CGLHeadlessGraphicsContext(480, 272);
#endif
} else {
// TODO: Will we need a larger window for higher resolutions? Well, not if we use buffered rendering.
window = CreateHiddenWindow(480, 272, cmdLineOptions.gpuBackend.value_or(GPUBackend::OPENGL), &windowDesc);
+91
View File
@@ -25,7 +25,12 @@
#include "headless/SDLHeadlessGLGraphicsContext.h"
#include "Common/GPU/OpenGL/GLCommon.h"
#include "Common/GPU/OpenGL/GLFeatures.h"
#if PPSSPP_PLATFORM(MAC)
// After glew, which has its own definitions of what this would include.
#include <OpenGL/OpenGL.h>
#endif
#include "Common/GPU/thin3d_create.h"
#include "Common/StringUtils.h"
#include "Common/File/VFS/VFS.h"
#include "Common/File/VFS/DirectoryReader.h"
#include "Common/GPU/GraphicsContext.h"
@@ -92,6 +97,92 @@ bool SDLHeadlessGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1,
return true;
}
#if PPSSPP_PLATFORM(MAC)
// Bound by the GL backend wherever it would bind the default framebuffer.
extern GLuint g_defaultFBO;
bool CGLHeadlessGraphicsContext::InitAPI(void *wnd, std::string *deviceName, std::string *errorMessage) {
const CGLPixelFormatAttribute attributes[] = {
kCGLPFAAccelerated,
kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_GL4_Core,
kCGLPFAColorSize, (CGLPixelFormatAttribute)24,
kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8,
(CGLPixelFormatAttribute)0,
};
CGLPixelFormatObj pixelFormat = nullptr;
GLint formatCount = 0;
if (CGLChoosePixelFormat(attributes, &pixelFormat, &formatCount) != kCGLNoError || !pixelFormat) {
*errorMessage = "CGLChoosePixelFormat failed";
return false;
}
CGLContextObj context = nullptr;
CGLError err = CGLCreateContext(pixelFormat, nullptr, &context);
CGLDestroyPixelFormat(pixelFormat);
if (err != kCGLNoError) {
*errorMessage = StringFromFormat("CGLCreateContext failed: %s", CGLErrorString(err));
return false;
}
context_ = context;
CGLSetCurrentContext(context);
// Core profile drivers leave some extensions out of the list, so glew has to look for them anyway.
SetGLCoreContext(true);
glewExperimental = true;
if (glewInit() != GLEW_OK) {
*errorMessage = "Failed to initialize glew";
return false;
}
// glew causes an invalid enum error with core profiles, ignore it.
glGetError();
// There's no drawable, so the backbuffer is a framebuffer object of our own.
glGenRenderbuffers(1, &colorBuffer_);
glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer_);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, width_, height_);
glGenRenderbuffers(1, &depthStencilBuffer_);
glBindRenderbuffer(GL_RENDERBUFFER, depthStencilBuffer_);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width_, height_);
glGenFramebuffers(1, &fbo_);
glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer_);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, depthStencilBuffer_);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
*errorMessage = "The offscreen framebuffer is incomplete";
return false;
}
g_defaultFBO = fbo_;
CheckGLExtensions();
SetGPUBackend(GPUBackend::OPENGL);
draw_ = Draw::T3DCreateGLContext(false);
renderManager_ = (GLRenderManager *)draw_->GetNativeObject(Draw::NativeObject::RENDER_MANAGER);
renderManager_->SetInflightFrames(g_Config.iInflightFrames);
bool success = draw_->CreatePresets();
_assert_(success);
// Nothing to swap. Flushing keeps the frames moving like a swap would.
renderManager_->SetSwapFunction([]() {
glFlush();
});
return success;
}
void CGLHeadlessGraphicsContext::ShutdownSurface() {
delete draw_;
draw_ = nullptr;
g_defaultFBO = 0;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo_);
glDeleteRenderbuffers(1, &colorBuffer_);
glDeleteRenderbuffers(1, &depthStencilBuffer_);
CGLSetCurrentContext(nullptr);
CGLDestroyContext((CGLContextObj)context_);
context_ = nullptr;
}
#endif
bool SDLHeadlessGLGraphicsContext::InitAPI(void *wnd, std::string *deviceName, std::string *errorMessage) {
SDL_Init(SDL_INIT_VIDEO);
+55
View File
@@ -19,6 +19,7 @@
#ifdef SDL
#include "ppsspp_config.h"
#include <SDL3/SDL.h>
#include "Common/GPU/GraphicsContext.h"
@@ -70,4 +71,58 @@ private:
void *CreateHiddenWindow(int w, int h, GPUBackend backend, WindowDesc *desc);
void DestroyHiddenWindow(void *window, WindowDesc desc);
#if PPSSPP_PLATFORM(MAC)
// An OpenGL context with no window: a CGL context without a drawable, rendering into a framebuffer
// object of its own that stands in for the backbuffer (through g_defaultFBO). Core profile, like
// the SDL app uses on macOS.
class CGLHeadlessGraphicsContext : public GraphicsContext {
public:
CGLHeadlessGraphicsContext(int width, int height) : width_(width), height_(height) {}
~CGLHeadlessGraphicsContext() { delete draw_; }
bool InitAPI(void *wnd, std::string *deviceNameSetting, std::string *errorMessage) override;
bool InitSurface(WindowSystem winsys, void *data1, void *data2, std::string *errorMessage) override {
return true;
}
void ShutdownSurface() override;
bool NeedsSeparateEmuThread() const override { return true; }
Draw::DrawContext *GetDrawContext() override {
return draw_;
}
void ThreadStart() override {
renderManager_->ThreadStart(draw_);
}
bool ThreadFrame() override {
return renderManager_->ThreadFrame();
}
void ThreadEnd() override {
renderManager_->ThreadEnd();
}
void Resize() override {}
// Call from emu thread
void NotifyEmuThreadExit() override {
renderManager_->NotifyEmuThreadExit();
}
private:
Draw::DrawContext *draw_ = nullptr;
GLRenderManager *renderManager_ = nullptr;
void *context_ = nullptr; // CGLContextObj
unsigned int fbo_ = 0;
unsigned int colorBuffer_ = 0;
unsigned int depthStencilBuffer_ = 0;
int width_;
int height_;
};
#endif
#endif