OpenGLES 3.x offscreen rendering without EGL pBuffer

I try to render a quad with texture into a framebuffer object with a texture GL_COLOR_ATTACHMENT0 attachment. Then I call glReadPixels to copy the content of the framebuffer back to main memory.

I am using EGL to provide an OpenGL context.

What I noticed is that unless I create an EGL pixel buffer (by calling eglCreatePbufferSurface) and make that part of the current context, glReadPixels will return a buffer that is completely black.

I have attached my code here, and the code produces the desired result as it is currently written. However, if I changes the line eglMakeCurrent(display, surface, surface, context); to eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context);, then the output I get is completely black.

Is it normal that I need both an EGL pixel buffer and OpenGL FBO to enable offscreen rendering?

A textbook I’ve been reading, OpenGL ES 3.0 Programming Guide by Dan Ginsburg, seems to suggest that OpenGL FBO is an alternative to EGL pixel buffer and we should not need both. If that’s the case, what am I doing wrong in my test code?

#include <iostream>
#include <sstream>
#include <stdexcept>
#include <vector>
#include <algorithm>
#include "dlib/image_io.h"
#include "dlib/image_transforms.h"
#include "glm/glm.hpp"
#include "glm/gtc/matrix_transform.hpp"
#include <EGL/egl.h>
#include "GLES3/gl31.h"

using namespace std;

namespace {
void assertOpenGLError(const std::string& msg) {
	GLenum error = glGetError();

	if (error != GL_NO_ERROR) {
		stringstream s;
		s << "OpenGL error 0x" << std::hex << error << " at " << msg;
		throw runtime_error(s.str());
	}
}

void assertEGLError(const std::string& msg) {
	EGLint error = eglGetError();

	if (error != EGL_SUCCESS) {
		stringstream s;
		s << "EGL error 0x" << std::hex << error << " at " << msg;
		throw runtime_error(s.str());
	}
}

template<typename T>
unsigned char SafeUCharCast(T input);

template<>
unsigned char SafeUCharCast<float>(float input)
{  
    return static_cast<unsigned char>(std::clamp<float>(input*255, 0.f, 255.f));
}

template<>
unsigned char SafeUCharCast<unsigned char>(unsigned char input)
{
    return input;
}

template<typename T>
void SaveRGBImageToDisk(std::string fileName, size_t width, size_t height, const T* dataRaw)
{
	constexpr size_t inputDepth = 3;
    dlib::array2d<dlib::rgb_pixel> img(height, width);

    int idx = 0;
    for(auto& pix: img)
    {
        pix.red = SafeUCharCast(dataRaw[idx]);
        pix.green = SafeUCharCast(dataRaw[idx+1]);
        pix.blue = SafeUCharCast(dataRaw[idx+2]);
        idx += inputDepth;
    }

    dlib::save_png(img, fileName+".png");
}

const static std::string vertexShader = 
	"#version 300 es\n"
	"layout(location = 0) in vec4 position;\n"
	"layout(location = 1) in vec2 texCoord;\n"
	"out vec2 v_TexCoord;\n"
	"uniform mat4 u_MVP;\n"
	"void main() {\n"
	"    gl_Position = u_MVP*position;\n"
	"    v_TexCoord = texCoord;\n"
	"}\n";

const static std::string fragmentShader = 
	"#version 300 es\n"
    "precision highp float;\n"
	"precision highp sampler2D;\n"
	"layout(location = 0) out vec4 color;\n"
	"in vec2 v_TexCoord;\n"
	"uniform sampler2D u_Texture;\n"
	"void main() {\n"
	"    vec4 texColor = texture(u_Texture, v_TexCoord);\n"
	"    color = texColor;\n"
	"}\n";

unsigned int CompileShader(unsigned int type, const std::string& source) {

    unsigned int id = glCreateShader(type);

    const char* src = source.c_str();
    glShaderSource(id, 1, &src, nullptr);
    glCompileShader(id);

    int result;
    glGetShaderiv(id, GL_COMPILE_STATUS, &result);
    if(result == GL_FALSE) {
        int length;
        glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
        char* message = (char*) alloca(length*sizeof(char));
        glGetShaderInfoLog(id, length, &length, message);
        std::cout << "Failed to compile "<< (type == GL_VERTEX_SHADER ? "vertex" : "fragment")<<" shader"<<std::endl;
        std::cout << message << std::endl;
        glDeleteShader(id);
        return 0;
    }

    return id;
}

unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader){
    unsigned int program = glCreateProgram();
    unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexShader);
    unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentShader);

    glAttachShader(program, vs);
	assertOpenGLError("glAttachShader");
    glAttachShader(program, fs);
	assertOpenGLError("glAttachShader");
    glLinkProgram(program);
	assertOpenGLError("glLinkProgram");
    glValidateProgram(program);
	assertOpenGLError("glValidateProgram");

	glDeleteShader(vs);
    glDeleteShader(fs);

    return program;
}

std::vector<unsigned char> LoadImageInterleaved(std::string fileName, size_t& width, size_t& height)
{
    constexpr auto numChannels = 3;

    dlib::array2d<dlib::rgb_pixel> img;
    dlib::load_png(img, fileName);

	width = img.nc();
	height = img.nr();

    std::vector<unsigned char> inputBuffer(img.nr()*img.nc()*numChannels);
    unsigned char* inputBufferRaw = &inputBuffer[0];
    
    for(auto pix: img)
    {
		inputBufferRaw[0] = pix.red;
		inputBufferRaw[1] = pix.green;
		inputBufferRaw[2] = pix.blue;

        inputBufferRaw += numChannels;
    }
    return inputBuffer;
}
}

int main() {
	unsetenv( "DISPLAY" );
	size_t imgWidth, imgHeight;
	auto imgData = LoadImageInterleaved("../res/textures/penguin.png", imgWidth, imgHeight);
	/*
	 * EGL initialization and OpenGL context creation.
	 */
	EGLDisplay display;
	EGLConfig config;
	EGLContext context;
	EGLSurface surface;
	EGLint num_config;

    const EGLint DISPLAY_ATTRIBS[] = {
            EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
            EGL_BLUE_SIZE, 5, EGL_GREEN_SIZE, 6, EGL_RED_SIZE, 5,
            EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
            EGL_NONE
    };

    const EGLint CONTEXT_ATTRIBS[] = {
            EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE
    };

	display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
	assertEGLError("eglGetDisplay");
	
	eglInitialize(display, nullptr, nullptr);
	assertEGLError("eglInitialize");

	eglChooseConfig(display, DISPLAY_ATTRIBS, &config, 1, &num_config);
	assertEGLError("eglChooseConfig");
	
	eglBindAPI(EGL_OPENGL_API);
	assertEGLError("eglBindAPI");
	
	context = eglCreateContext(display, config, EGL_NO_CONTEXT, CONTEXT_ATTRIBS);
	assertEGLError("eglCreateContext");

    constexpr int winWidth = 256;
    constexpr int winHeight = 256;

	EGLint SURFACE_ATTRIBS[] = {
		EGL_WIDTH, winWidth,
		EGL_HEIGHT, winHeight,
		EGL_NONE
	};

	surface = eglCreatePbufferSurface(display, config, SURFACE_ATTRIBS);
	assertEGLError("eglCreatePbufferSurface");
	
	eglMakeCurrent(display, surface, surface, context);
	//eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context);
	assertEGLError("eglMakeCurrent");
	
    std::string versionString = std::string((const char*)glGetString(GL_VERSION));
    std::cout<<versionString<<std::endl;

	//Enable blending
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glEnable(GL_BLEND);

	auto CreateTexture = [](GLuint& tex, int width, int height, void* data) {
		glGenTextures(1, &tex);
		assertOpenGLError("glGenTextures");
		glBindTexture(GL_TEXTURE_2D, tex);

		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

		glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
		assertOpenGLError("glTexImage2D");
		glBindTexture(GL_TEXTURE_2D, 0);
	};

	/*
	 * Create an OpenGL framebuffer as render target.
	 */
	GLuint frameBuffer;
	glGenFramebuffers(1, &frameBuffer);
	glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer);
	assertOpenGLError("glBindFramebuffer");
	
	/*
	 * Create a texture as color attachment.
	 */
	GLuint outputTex;
	CreateTexture(outputTex, winWidth, winHeight, nullptr);
	glBindTexture(GL_TEXTURE_2D, outputTex);
	/*
	 * Attach the texture to the framebuffer.
	 */
	glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, outputTex, 0);
	assertOpenGLError("glFramebufferTexture2D");

    if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
        throw std::runtime_error("frame buffer went kaput");

	/*
	 * Create an OpenGL texture and load it with image data.
	 */

	GLuint inputTex;
	CreateTexture(inputTex, imgWidth, imgHeight, imgData.data());
	glBindTexture(GL_TEXTURE_2D, inputTex);
	constexpr int textureSlot = 0;
	glActiveTexture(GL_TEXTURE0+textureSlot);
	assertOpenGLError("glActiveTexture");

	//Build and use the shaders
	auto program = CreateShader(vertexShader, fragmentShader);
	glUseProgram(program);
	assertOpenGLError("glUseProgram");

	glUniform1i(glGetUniformLocation(program, "u_Texture"), textureSlot);
	assertOpenGLError("glUniform1i, glGetUniformLocation");

	glm::mat4 proj = glm::ortho(0.f, (float)winWidth, 0.f, (float)winHeight, -1.f, 1.f);
	glUniformMatrix4fv(glGetUniformLocation(program, "u_MVP"), 1, GL_FALSE, &proj[0][0]);
	assertOpenGLError("glUniformMatrix4fv, glGetUniformLocation");

	//Define geometry to render and texture mapping
    float positions[] = {0.f, 0.f, 0.f, 0.f,
                        winWidth, 0.f, 1.f, 0.f, 
                        winWidth, winHeight, 1.f, 1.f,
                        0.f, winHeight, 0.f, 1.f
                        };

	unsigned int indices[] = {
        0, 1, 2,
        2, 3, 0
    };

	GLuint ibo;
    glGenBuffers(1, &ibo);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

	GLuint vbo;
	glGenBuffers(1, &vbo);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(GLfloat), (const void*) 0);

	glEnableVertexAttribArray(1);
	glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(GLfloat), (const void*) (2*sizeof(GLfloat)));

	/*
	 * Render something.
	 */
	//glClearColor(0.3, 0.8, 0.5, 1.0);
	
	glDrawElements(GL_TRIANGLES, sizeof(indices)/sizeof(unsigned int), GL_UNSIGNED_INT, nullptr);
	//glClear(GL_COLOR_BUFFER_BIT);
	glFlush();


	/*
	 * Read the framebuffer and save to disk as an image
	 */
    std::vector<unsigned char> buf(winWidth*winHeight*3);

	glReadBuffer(GL_COLOR_ATTACHMENT0);
	glReadPixels(0, 0, winWidth, winHeight, GL_RGB, GL_UNSIGNED_BYTE, buf.data());
	assertOpenGLError("glReadPixels");

    SaveRGBImageToDisk("output", winWidth, winHeight, buf.data());

	/*
	 * Destroy context.
	 */
	glDeleteBuffers(1, &ibo);
	glDeleteBuffers(1, &vbo);
	glDeleteTextures(1, &inputTex);
	glDeleteFramebuffers(1, &frameBuffer);
	glDeleteTextures(1, &outputTex);
	glDeleteProgram(program);

	eglDestroySurface(display, surface);
	assertEGLError("eglDestroySurface");
	
	eglDestroyContext(display, context);
	assertEGLError("eglDestroyContext");
	
	eglTerminate(display);
	assertEGLError("eglTerminate");

	return 0;
}
  1. Which OS, GPU, GPU driver, and driver version is this on?
  2. Does your EGL advertise EGL_KHR_surfaceless_context support?
  3. This doesn’t explain the black, but your glReadPixels() assumes 1-byte packing. Yet I see don’t see GL_PACK_ALIGNMENT set to 1.
  1. I am on Ubuntu 20.04, with an NVidia GeForce RTX 2080 Mobile. Driver version is 510.47.03. glGetString(GL_VERSION) returns “4.6.0 NVIDIA 510.47.03”.

  2. If I call eglQueryString(display, EGL_EXTENSIONS), I do see EGL_KHR_surfaceless_context in the list it returns. However, when I try to query OpenGL extensions using:

	GLint n=0; 
	glGetIntegerv(GL_NUM_EXTENSIONS, &n); 

	for (GLint i=0; i<n; i++) 
	{ 
	    const char* extension = 
		(const char*)glGetStringi(GL_EXTENSIONS, i);
	    printf("Ext %d: %s\n", i, extension); 
	} 

, I don’t see GL_OES_surfaceless_context. Could that be the reason why I cannot render offscreen to a FBO without creating a separate EGL pixel buffer?

Ok, thanks.

Hmm. That seems odd. Here on Linux desktop with an old GeForce 1xxx GPU and old 430.14 drivers, I show both as supported. I guess I don’t know without more details why what you’re trying isn’t working. However…

Why don’t you want to use a PBuffer for the surface?

What it sounds like you really want is surfaceless offscreen rendering support via EGL+GLES on an NVIDIA GPU+drivers. So use this:

For tips on how to make use of it, see:

With this, you don’t need a connection to the X server. But you can still render to an offscreen PBuffer if you want. Or just ignore it and use your FBO.

Anyway, this EGL context init method may get you want you want by taking a different route. You can then diff the two EGL context init methods and see what the crucial difference is that “breaks” your EGL_NO_SURFACE approach.

I noticed another interesting thing. In my code, as long as I have eglMakeCurrent(display, surface, surface, context);, I can delete all the code I have to create FBO, attach texture to FBO, the glReadBuffer(GL_COLOR_ATTACHMENT0); line. The subsequent glReadPixels will still return the correct result. Is there a call I need to make to tell OpenGL to read from the FBO and not the EGL PBuffer?

Blockquote Why don’t you want to use a PBuffer for the surface?

It’s not that I don’t want to use PBuffer. I am in the process of learning OpenGL and I’m just trying to figure out why FBOs don’t seem to work as advertised.
I also tried building my toy program for Android and run it on an Android device that supports both EGL_KHR_surfaceless_context and GL_OES_surfaceless_context. I get the exact same behaviour as on NVidia.

Thank you for the link to the NVidia blog. I tried (on Ubuntu + NVidia GPU) using eglGetPlatformDisplayEXT instead of the default display and skipped the eglCreatePbufferSurface call. I still get a completely black output.

glReadPixels() reads from the active “read buffer” within the active “read framebuffer”.

glBindFramebuffer() will set the latter, if you pass GL_READ_FRAMEBUFFER or GL_FRAMEBUFFER to it as the target (…framebuffer).

When the read framebuffer is bound, glReadBuffer() specifies the former. That is, which color buffer within that framebuffer pixels are read back from.

glBindFramebuffer() with a 0 handle binds the default (system-created) framebuffer. In your case, that’s the EGL pbuffer you created the context with. Of course, in your render-to-FBO case, you want this to be the FBO handle instead.

You might post your latest code in a code block. I can test it tonight or this weekend sometime on a Linux box running NVIDIA drivers + GPU and give you some feedback. This might provide some clues.

Thank you so much for your help. I included here my latest code and CMake script.

Code:

#include <iostream>
#include <sstream>
#include <stdexcept>
#include <vector>
#include <algorithm>
#include "dlib/image_io.h"
#include "dlib/image_transforms.h"
#include "glm/glm.hpp"
#include "glm/gtc/matrix_transform.hpp"
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include "GLES3/gl31.h"
#include "signal.h"

using namespace std;

#define ASSERT(x) if(!(x)) { raise(SIGTRAP); }

#define GLCall(x) ::GLClearError();\
    x;\
    ASSERT(::GLLogCall(#x, __FILE__, __LINE__))

#define EGLCall(x) ::EGLClearError();\
    x;\
    ASSERT(::EGLLogCall(#x, __FILE__, __LINE__))

namespace {

void GLClearError() {
    while(glGetError());
}

bool GLLogCall(const char* fn, const char* file, int line) {
    if(GLenum error = glGetError()) {
        std::cout<< "OpenGL error: 0x"<<std::hex << error<<"\n\tin function: "<<std::dec << fn<<"\n\tin file: "<<file<<"\n\tat line: "<<line<<std::endl;
        return false;
    }
    return true;
}

void EGLClearError() {
    while(eglGetError() != EGL_SUCCESS);
}

bool EGLLogCall(const char* fn, const char* file, int line) {
    if(EGLint error = eglGetError(); error != EGL_SUCCESS) {
        std::cout<< "EGL error: 0x"<<std::hex << error<<"\n\tin function: "<<std::dec << fn<<"\n\tin file: "<<file<<"\n\tat line: "<<line<<std::endl;
        return false;
    }
    return true;
}

template<typename T>
unsigned char SafeUCharCast(T input);

template<>
unsigned char SafeUCharCast<float>(float input)
{  
    return static_cast<unsigned char>(std::clamp<float>(input*255, 0.f, 255.f));
}

template<>
unsigned char SafeUCharCast<unsigned char>(unsigned char input)
{
    return input;
}

template<typename T>
void SaveRGBImageToDisk(std::string fileName, size_t width, size_t height, const T* dataRaw)
{
	constexpr size_t inputDepth = 3;
    dlib::array2d<dlib::rgb_pixel> img(height, width);

    int idx = 0;
    for(auto& pix: img)
    {
        pix.red = SafeUCharCast(dataRaw[idx]);
        pix.green = SafeUCharCast(dataRaw[idx+1]);
        pix.blue = SafeUCharCast(dataRaw[idx+2]);
        idx += inputDepth;
    }

    dlib::save_png(img, fileName+".png");
}

const static std::string vertexShader = 
	"#version 300 es\n"
	"layout(location = 0) in vec4 position;\n"
	"layout(location = 1) in vec2 texCoord;\n"
	"out vec2 v_TexCoord;\n"
	"uniform mat4 u_MVP;\n"
	"void main() {\n"
	"    gl_Position = u_MVP*position;\n"
	"    v_TexCoord = texCoord;\n"
	"}\n";

const static std::string fragmentShader = 
	"#version 300 es\n"
	"precision highp float;\n"
	"layout(location = 0) out vec4 color;\n"
	"in vec2 v_TexCoord;\n"
	"uniform sampler2D u_Texture;\n"
	"void main() {\n"
	"    vec4 texColor = texture(u_Texture, v_TexCoord);\n"
	"    color = texColor;\n"
	"}\n";

unsigned int CompileShader(unsigned int type, const std::string& source) {

    GLCall(unsigned int id = glCreateShader(type));

    const char* src = source.c_str();
    GLCall(glShaderSource(id, 1, &src, nullptr));
    GLCall(glCompileShader(id));

    int result;
    GLCall(glGetShaderiv(id, GL_COMPILE_STATUS, &result));
    if(result == GL_FALSE) {
        int length;
        GLCall(glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length));
        char* message = (char*) alloca(length*sizeof(char));
        GLCall(glGetShaderInfoLog(id, length, &length, message));
        std::cout << "Failed to compile "<< (type == GL_VERTEX_SHADER ? "vertex" : "fragment")<<" shader"<<std::endl;
        std::cout << message << std::endl;
        GLCall(glDeleteShader(id));
        return 0;
    }

    return id;
}

unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader){
    GLCall(unsigned int program = glCreateProgram());
    unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexShader);
    unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentShader);

    GLCall(glAttachShader(program, vs));
    GLCall(glAttachShader(program, fs));
    GLCall(glLinkProgram(program));
    GLCall(glValidateProgram(program));

	GLCall(glDeleteShader(vs));
    GLCall(glDeleteShader(fs));

    return program;
}

std::vector<unsigned char> LoadImageInterleaved(std::string fileName, size_t& width, size_t& height)
{
    constexpr auto numChannels = 3;

    dlib::array2d<dlib::rgb_pixel> img;
    dlib::load_png(img, fileName);

	width = img.nc();
	height = img.nr();

    std::vector<unsigned char> inputBuffer(img.nr()*img.nc()*numChannels);
    unsigned char* inputBufferRaw = &inputBuffer[0];
    
    for(auto pix: img)
    {
		inputBufferRaw[0] = pix.red;
		inputBufferRaw[1] = pix.green;
		inputBufferRaw[2] = pix.blue;

        inputBufferRaw += numChannels;
    }
    return inputBuffer;
}
}

int main() {
	unsetenv( "DISPLAY" );

	size_t imgWidth, imgHeight;
	auto imgData = LoadImageInterleaved("../res/textures/penguin.png", imgWidth, imgHeight);
	/*
	 * EGL initialization and OpenGL context creation.
	 */
	EGLDisplay display;
	EGLConfig config;
	EGLContext context;
	EGLSurface surface;
	EGLint num_config;


    const EGLint DISPLAY_ATTRIBS[] = {
            EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
            EGL_BLUE_SIZE, 5, EGL_GREEN_SIZE, 6, EGL_RED_SIZE, 5,
            EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
            EGL_NONE
    };

    const EGLint CONTEXT_ATTRIBS[] = {
            EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE
    };

	constexpr int MAX_DEVICES = 4;
	EGLDeviceEXT eglDevs[MAX_DEVICES];
	EGLint numDevices;

	/*
	PFNEGLQUERYDEVICESEXTPROC eglQueryDevicesEXT =
    	(PFNEGLQUERYDEVICESEXTPROC) eglGetProcAddress("eglQueryDevicesEXT");

	EGLCall(eglQueryDevicesEXT(MAX_DEVICES, eglDevs, &numDevices));

	PFNEGLGETPLATFORMDISPLAYEXTPROC eglGetPlatformDisplayEXT =
		(PFNEGLGETPLATFORMDISPLAYEXTPROC)
		eglGetProcAddress("eglGetPlatformDisplayEXT");

	EGLCall(display = eglGetPlatformDisplayEXT(EGL_PLATFORM_DEVICE_EXT, 
                                    eglDevs[0], 0));
	*/

	EGLCall(display = eglGetDisplay(EGL_DEFAULT_DISPLAY));
	EGLCall(eglInitialize(display, nullptr, nullptr));
	EGLCall(eglChooseConfig(display, DISPLAY_ATTRIBS, &config, 1, &num_config));

	EGLCall(eglBindAPI(EGL_OPENGL_ES_API));
	
	EGLCall(context = eglCreateContext(display, config, EGL_NO_CONTEXT, CONTEXT_ATTRIBS));

    constexpr int winWidth = 256;
    constexpr int winHeight = 256;

	EGLint SURFACE_ATTRIBS[] = {
		EGL_WIDTH, winWidth,
		EGL_HEIGHT, winHeight,
		EGL_NONE
	};

	EGLCall(surface = eglCreatePbufferSurface(display, config, SURFACE_ATTRIBS));

	EGLCall(eglMakeCurrent(display, surface, surface, context));
	//EGLCall(eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context));
	
    std::string versionString = std::string((const char*)glGetString(GL_VERSION));
    std::cout<<versionString<<std::endl;

	std::cout<<"Supported EGL extenstions: \n"<<eglQueryString(display,  EGL_EXTENSIONS)<<std::endl;

	GLint numGLExt = 0; 
	GLCall(glGetIntegerv(GL_NUM_EXTENSIONS, &numGLExt)); 
	stringstream extensions;

	for (GLint i=0; i<numGLExt; i++) 
	{ 
		extensions<<(const char*)glGetStringi(GL_EXTENSIONS, i)<<" ";
	} 
	std::cout<<"Supported GL extenstions: \n"<<extensions.str()<<std::endl;

	//Enable blending
    GLCall(glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA));
    GLCall(glEnable(GL_BLEND));

	auto CreateTexture = [](GLuint& tex, int width, int height, void* data) {
		GLCall(glGenTextures(1, &tex));
		GLCall(glBindTexture(GL_TEXTURE_2D, tex));

		GLCall(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR));
		GLCall(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR));
		GLCall(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE));
		GLCall(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE));

		GLCall(glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data));
		GLCall(glBindTexture(GL_TEXTURE_2D, 0));
	};

	/*
	 * Create an OpenGL framebuffer as render target.
	 */
	/*
	GLuint frameBuffer;
	GLCall(glGenFramebuffers(1, &frameBuffer));
	GLCall(glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer));
	*/

	/*
	 * Create a texture as color attachment.
	 */
	/*
	GLuint outputTex;
	CreateTexture(outputTex, winWidth, winHeight, nullptr);
	GLCall(glBindTexture(GL_TEXTURE_2D, outputTex));
	*/

	/*
	 * Attach the texture to the framebuffer.
	 */

	/*
	GLCall(glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, outputTex, 0));
	GLCall(GLenum fboStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER));
	
    if(fboStatus!= GL_FRAMEBUFFER_COMPLETE)
        throw std::runtime_error("frame buffer went kaput");
	*/

	/*
	 * Create an OpenGL texture and load it with image data.
	 */

	GLuint inputTex;
	CreateTexture(inputTex, imgWidth, imgHeight, imgData.data());
	GLCall(glBindTexture(GL_TEXTURE_2D, inputTex));
	constexpr int textureSlot = 0;
	GLCall(glActiveTexture(GL_TEXTURE0+textureSlot));

	//Build and use the shaders
	auto program = CreateShader(vertexShader, fragmentShader);
	GLCall(glUseProgram(program));
	GLCall(glUniform1i(glGetUniformLocation(program, "u_Texture"), textureSlot));

	glm::mat4 proj = glm::ortho(0.f, (float)winWidth, 0.f, (float)winHeight, -1.f, 1.f);
	GLCall(glUniformMatrix4fv(glGetUniformLocation(program, "u_MVP"), 1, GL_FALSE, &proj[0][0]));

	//Define geometry to render and texture mapping
    float positions[] = {0.f, 0.f, 0.f, 0.f,
                        winWidth, 0.f, 1.f, 0.f, 
                        winWidth, winHeight, 1.f, 1.f,
                        0.f, winHeight, 0.f, 1.f
                        };

	unsigned int indices[] = {
        0, 1, 2,
        2, 3, 0
    };

	GLuint ibo;
    GLCall(glGenBuffers(1, &ibo));
    GLCall(glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo));
    GLCall(glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW));

	GLuint vbo;
	GLCall(glGenBuffers(1, &vbo));
    GLCall(glBindBuffer(GL_ARRAY_BUFFER, vbo));
    GLCall(glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW));

	GLCall(glEnableVertexAttribArray(0));
	GLCall(glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(GLfloat), (const void*) 0));

	GLCall(glEnableVertexAttribArray(1));
	GLCall(glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(GLfloat), (const void*) (2*sizeof(GLfloat))));

	/*
	 * Render something.
	 */
	//GLCall(glClearColor(0.3, 0.8, 0.5, 1.0));
	//GLCall(glClear(GL_COLOR_BUFFER_BIT));

	GLCall(glDrawElements(GL_TRIANGLES, sizeof(indices)/sizeof(unsigned int), GL_UNSIGNED_INT, nullptr));
	GLCall(glFlush());


	/*
	 * Read the framebuffer and save to disk as an image
	 */
    std::vector<unsigned char> buf(winWidth*winHeight*3);

	//GLCall(glReadBuffer(GL_COLOR_ATTACHMENT0));
	GLCall(glReadPixels(0, 0, winWidth, winHeight, GL_RGB, GL_UNSIGNED_BYTE, buf.data()));

    SaveRGBImageToDisk("output", winWidth, winHeight, buf.data());

	/*
	 * Destroy context.
	 */
	GLCall(glDeleteBuffers(1, &ibo));
	GLCall(glDeleteBuffers(1, &vbo));
	GLCall(glDeleteTextures(1, &inputTex));
	//GLCall(glDeleteFramebuffers(1, &frameBuffer));
	//GLCall(glDeleteTextures(1, &outputTex));
	GLCall(glDeleteProgram(program));

	EGLCall(eglDestroySurface(display, surface));
	EGLCall(eglDestroyContext(display, context));
	EGLCall(eglTerminate(display));

	return 0;
}

CMakeLists.txt :

cmake_minimum_required(VERSION 3.18)
project(opengl_test)

set(CMAKE_CXX_STANDARD 17)

include(FetchContent)

FetchContent_Declare(
        dlib
        GIT_REPOSITORY https://github.com/davisking/dlib.git
        GIT_TAG v19.22)

if(NOT dlib_POPULATED)

    set(DLIB_NO_GUI_SUPPORT ON)
    set(DLIB_USE_CUDA OFF)
    set(DLIB_JPEG_SUPPORT ON)
    set(DLIB_PNG_SUPPORT ON)

    FetchContent_Populate(dlib)
    add_subdirectory(
	${dlib_SOURCE_DIR}
	${dlib_BINARY_DIR}
	)
endif()

FetchContent_Declare(
    glm 
    GIT_REPOSITORY https://github.com/g-truc/glm.git
    GIT_TAG master
    )

if(NOT glm)
    FetchContent_Populate(glm)
endif()

if(ANDROID)
    set(GL_LIBS GLESv3 EGL)
else()
    find_package(OpenGL REQUIRED)
    set(GL_LIBS OpenGL::GL OpenGL::EGL)
endif()

add_executable(opengl_test main.cpp)
                
target_link_libraries(opengl_test PRIVATE ${GL_LIBS} dlib)
target_include_directories(opengl_test PRIVATE 
                            ${OPENGL_INCLUDE_DIR}
                            ${glm_SOURCE_DIR})

I took your code and (for testing) hacked it to not require dlib. Built and ran it and got reasonable results.

Then I added these:

    // FIXME: Added
    GLCall( glPixelStorei( GL_PACK_ALIGNMENT,   1 ) );
    GLCall( glPixelStorei( GL_UNPACK_ALIGNMENT, 1 ) );

as we need these.

Then I switched to the surfaceless context:

eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)

with the FBO created and bound. This read back all zeros as you said.

So I added this before the FBO draw:

    // Clear the framebuffer (default FB or FBO) to RED
    glClearColor( 1,0,0,1 );
    glClear( GL_COLOR_BUFFER_BIT );

and the readback got RED.

So it appears there is something about your drawing that isn’t making it to the FBO or at least not filling the entire FBO’s color texture attachment. Not too sure what that is though.

I flipped the whole thing from GL_RGB8 to GL_RGBA8 including the readback (since GL_RGB8 isn’t a real format on the GPU) and that exhibited the same result. So it’s not likely the internal format for the render+readback that’s causing issues for the draw to the FBO.

I think I figured out the problem. I forgot to set a view port. If I insert a call to GLCall(glViewport(0, 0, winWidth, winHeight));, then I can see the correct result. You’ve been most helpful and I thank you for that.

Wow! Good find! Even after I read your reply, I still didn’t understand why.
This is something I don’t think I’ve ever read or heard before:

OpenGL 4.6 Spec (on glViewport() and glViewportIndexed()):

That’s definitely a default that “bites” when you’re using a surfaceless context. There’s identical language for this in the OpenGL ES spec as well.

Sure thing! Sorry I couldn’t get you all the way there.