diff --git a/CMakeLists.txt b/CMakeLists.txt index 0788673..3d4de1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,8 @@ include(plot_options) add_library(plot_core src/core/status.cpp src/math/transform.cpp + src/gl/loader.cpp + src/gl/handle.cpp ) add_library(plot::core ALIAS plot_core) diff --git a/include/plot/gl/api.hpp b/include/plot/gl/api.hpp new file mode 100644 index 0000000..8c8fe88 --- /dev/null +++ b/include/plot/gl/api.hpp @@ -0,0 +1,187 @@ +#pragma once +#include + +// The GL surface of this library is hand-written on purpose: the core target +// links and includes nothing third-party, so there is no glad/GLEW/GLM and no +// Khronos header. Everything below is either a typedef fixed by the OpenGL +// specification or an entry point resolved at runtime by `plot::gl::load()` +// (see loader.hpp). Only OpenGL 3.3 core is assumed; anything newer is guarded +// by the capability probe in caps.hpp. + +namespace plot::gl { + +using GLenum = unsigned int; +using GLuint = unsigned int; +using GLint = int; +using GLsizei = int; +using GLboolean = unsigned char; +using GLbitfield = unsigned int; +using GLubyte = unsigned char; +using GLchar = char; +using GLintptr = std::intptr_t; +using GLsizeiptr = std::ptrdiff_t; +using GLuint64 = u64; +using GLsync = void*; // opaque sync object (`struct __GLsync*`) + +inline constexpr GLboolean GL_FALSE = 0; +inline constexpr GLboolean GL_TRUE = 1; + +// Enumerant values are fixed by the OpenGL specification; comment each group +// with the version or extension that introduced it. +enum : GLenum { + // Errors and primitives. + GL_NO_ERROR = 0x0000, + GL_LINE_STRIP = 0x0003, + GL_TRIANGLES = 0x0004, + GL_TRIANGLE_STRIP = 0x0005, + + // Clear state. + GL_COLOR_BUFFER_BIT = 0x00004000, + + // Data types (unsigned short is the index/16-bit data type). + GL_UNSIGNED_BYTE = 0x1401, + GL_UNSIGNED_SHORT = 0x1403, + GL_FLOAT = 0x1406, + + // Strings and context queries. + GL_VENDOR = 0x1F00, + GL_RENDERER = 0x1F01, + GL_VERSION = 0x1F02, + GL_EXTENSIONS = 0x1F03, + GL_NUM_EXTENSIONS = 0x821D /* 3.0 */, + GL_CONTEXT_PROFILE_MASK = 0x9126 /* 3.2 */, + GL_CONTEXT_CORE_PROFILE_BIT = 0x00000001, + + // Blending. + GL_BLEND = 0x0BE2, + GL_SRC_ALPHA = 0x0302, + GL_ONE_MINUS_SRC_ALPHA = 0x0303, + + // Pixel store state. + GL_UNPACK_ALIGNMENT = 0x0CF5, + + // Shaders and programs. + GL_FRAGMENT_SHADER = 0x8B30, + GL_VERTEX_SHADER = 0x8B31, + GL_COMPILE_STATUS = 0x8B81, + GL_LINK_STATUS = 0x8B82, + + // Buffers (3.3 baseline plus the 4.4 storage flags used by the caps-driven + // persistent-mapping path). + GL_ARRAY_BUFFER = 0x8892, + GL_DYNAMIC_DRAW = 0x88E8, + GL_STATIC_DRAW = 0x88E4, + GL_STREAM_DRAW = 0x88E0, + GL_MAP_WRITE_BIT = 0x0002 /* 4.4 */, + GL_MAP_PERSISTENT_BIT = 0x0040 /* 4.4 */, + GL_MAP_COHERENT_BIT = 0x0080 /* 4.4 */, + + // Textures. + GL_TEXTURE_2D = 0x0DE1, + GL_TEXTURE0 = 0x84C0, + GL_TEXTURE_MAG_FILTER = 0x2800, + GL_TEXTURE_MIN_FILTER = 0x2801, + GL_TEXTURE_WRAP_S = 0x2802, + GL_TEXTURE_WRAP_T = 0x2803, + GL_NEAREST = 0x2600, + GL_LINEAR = 0x2601, + GL_CLAMP_TO_EDGE = 0x812F, + GL_RED = 0x1903, + GL_RGBA = 0x1908, + GL_R8 = 0x8229, + GL_RGBA8 = 0x8058, + + // Framebuffer objects (renderbuffers back depth attachments). + GL_FRAMEBUFFER = 0x8D40, + GL_COLOR_ATTACHMENT0 = 0x8CE0, + GL_FRAMEBUFFER_COMPLETE = 0x8CD5, + GL_RENDERBUFFER = 0x8D41, + + // Sync objects (3.2). + GL_SYNC_GPU_COMMANDS_COMPLETE = 0x9117, + GL_SYNC_FLUSH_COMMANDS_BIT = 0x00000001, + GL_ALREADY_SIGNALED = 0x911A, + GL_CONDITION_SATISFIED = 0x911C, +}; + +// Single source of truth for the runtime entry points: every entry is declared +// as a function pointer here and resolved by `plot::gl::load()`. Adding an +// entry makes it required, so only add what the library actually calls. +#define PLOT_GL_FUNCS(X) \ + /* context and state */ \ + X(const GLubyte*, glGetString, (GLenum)) \ + X(const GLubyte*, glGetStringi, (GLenum, GLuint)) \ + X(void, glGetIntegerv, (GLenum, GLint*)) \ + X(GLenum, glGetError, (void)) \ + X(void, glFinish, (void)) \ + X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \ + X(void, glClearColor, (float, float, float, float)) \ + X(void, glClear, (GLbitfield)) \ + X(void, glEnable, (GLenum)) \ + X(void, glBlendFunc, (GLenum, GLenum)) \ + X(void, glPixelStorei, (GLenum, GLint)) \ + /* shaders and programs */ \ + X(GLuint, glCreateShader, (GLenum)) \ + X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*)) \ + X(void, glCompileShader, (GLuint)) \ + X(void, glGetShaderiv, (GLuint, GLenum, GLint*)) \ + X(void, glGetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \ + X(void, glDeleteShader, (GLuint)) \ + X(GLuint, glCreateProgram, (void)) \ + X(void, glAttachShader, (GLuint, GLuint)) \ + X(void, glDetachShader, (GLuint, GLuint)) \ + X(void, glLinkProgram, (GLuint)) \ + X(void, glGetProgramiv, (GLuint, GLenum, GLint*)) \ + X(void, glGetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \ + X(void, glDeleteProgram, (GLuint)) \ + X(void, glUseProgram, (GLuint)) \ + X(GLint, glGetUniformLocation, (GLuint, const GLchar*)) \ + X(void, glUniform1i, (GLint, GLint)) \ + X(void, glUniform1f, (GLint, float)) \ + X(void, glUniform2f, (GLint, float, float)) \ + X(void, glUniform1fv, (GLint, GLsizei, const float*)) \ + X(void, glUniform2fv, (GLint, GLsizei, const float*)) \ + /* buffers */ \ + X(void, glGenBuffers, (GLsizei, GLuint*)) \ + X(void, glDeleteBuffers, (GLsizei, const GLuint*)) \ + X(void, glBindBuffer, (GLenum, GLuint)) \ + X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \ + X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \ + /* vertex arrays */ \ + X(void, glGenVertexArrays, (GLsizei, GLuint*)) \ + X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \ + X(void, glBindVertexArray, (GLuint)) \ + X(void, glEnableVertexAttribArray, (GLuint)) \ + X(void, glVertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*)) \ + /* drawing */ \ + X(void, glDrawArrays, (GLenum, GLint, GLsizei)) \ + X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \ + /* textures */ \ + X(void, glGenTextures, (GLsizei, GLuint*)) \ + X(void, glDeleteTextures, (GLsizei, const GLuint*)) \ + X(void, glActiveTexture, (GLenum)) \ + X(void, glBindTexture, (GLenum, GLuint)) \ + X(void, glTexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*)) \ + X(void, glTexParameteri, (GLenum, GLenum, GLint)) \ + /* framebuffers */ \ + X(void, glGenFramebuffers, (GLsizei, GLuint*)) \ + X(void, glDeleteFramebuffers, (GLsizei, const GLuint*)) \ + X(void, glBindFramebuffer, (GLenum, GLuint)) \ + X(void, glFramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \ + X(GLenum, glCheckFramebufferStatus, (GLenum)) \ + X(void, glReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*)) \ + X(void, glGenRenderbuffers, (GLsizei, GLuint*)) \ + X(void, glDeleteRenderbuffers, (GLsizei, const GLuint*)) \ + X(void, glBindRenderbuffer, (GLenum, GLuint)) \ + X(void, glRenderbufferStorage, (GLenum, GLenum, GLsizei, GLsizei)) \ + X(void, glFramebufferRenderbuffer, (GLenum, GLenum, GLenum, GLuint)) \ + /* sync objects */ \ + X(GLsync, glFenceSync, (GLenum, GLbitfield)) \ + X(GLenum, glClientWaitSync, (GLsync, GLbitfield, GLuint64)) \ + X(void, glDeleteSync, (GLsync)) + +#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args; +PLOT_GL_FUNCS(PLOT_GL_DECL) +#undef PLOT_GL_DECL + +} // namespace plot::gl diff --git a/include/plot/gl/caps.hpp b/include/plot/gl/caps.hpp new file mode 100644 index 0000000..864c512 --- /dev/null +++ b/include/plot/gl/caps.hpp @@ -0,0 +1,30 @@ +#pragma once +#include + +namespace plot::gl { + +/// What the current context can do. Only OpenGL 3.3 core is required; every +/// field above that baseline is false when the driver does not offer it. +struct Caps { + int major = 0; + int minor = 0; + /// GL_VERSION / GL_RENDERER / GL_VENDOR strings. They are owned by the + /// context: valid until it is destroyed, and empty ("") when nothing has + /// been probed yet. + const char* renderer = ""; + const char* vendor = ""; + bool compute = false; ///< GL 4.3+ (compute shaders) + bool buffer_storage = false; ///< GL 4.4+ or ARB_buffer_storage + bool persistent_map = false; ///< GL 4.4+ or ARB_buffer_storage + bool core_profile = false; ///< GL_CONTEXT_CORE_PROFILE_BIT +}; + +/// Capabilities of the context that is current on the calling thread, probed +/// once on the first successful query and cached afterwards. Safe to call +/// before load(): with no usable context (or no resolved entry points) every +/// field stays zero. `load()` invalidates the cache. The cache is +/// process-global and unsynchronised: probe from the thread that owns the +/// current context. +const Caps& caps() noexcept; + +} // namespace plot::gl diff --git a/include/plot/gl/handle.hpp b/include/plot/gl/handle.hpp new file mode 100644 index 0000000..8a9cde2 --- /dev/null +++ b/include/plot/gl/handle.hpp @@ -0,0 +1,192 @@ +#pragma once +#include +#include +#include +#include + +namespace plot::gl { + +/// Move-only ownership of one GL object name (`GLuint`). +/// +/// `Derived` supplies `static void destroy(GLuint) noexcept`. Copying is +/// deleted and moving transfers the name, leaving the source empty. +/// Destruction and `reset()` are no-ops when `id() == 0`, so an empty handle is +/// safe to destroy at any point — including after its context is gone. A +/// non-empty handle must not outlive the context that owns the name; call +/// `reset()` first when the context is about to be destroyed. +template +class Handle { + public: + Handle() noexcept = default; + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + ~Handle() { reset(); } + + /// GL object name, or 0 when the handle is empty. + GLuint id() const noexcept { return id_; } + explicit operator bool() const noexcept { return id_ != 0; } + + /// Deletes the GL object, if any, and empties the handle. + void reset() noexcept { + if (id_ != 0) { + Derived::destroy(id_); + id_ = 0; + } + } + + protected: + Handle(Handle&& other) noexcept : id_(other.id_) { other.id_ = 0; } + Handle& operator=(Handle&& other) noexcept { + if (this != &other) { + reset(); + id_ = other.id_; + other.id_ = 0; + } + return *this; + } + + GLuint id_ = 0; +}; + +/// Vertex buffer object. All fallible operations report through `Status`; +/// the void ones assume that `plot::gl::load()` ran and a context is current. +class Buffer final : public Handle { + public: + /// Allocates a buffer name. Fails with `Unsupported` when the entry points + /// are not resolved yet and `GLFailure` when the driver returns name 0. + bool create(Status* st = nullptr) noexcept; + + void bind(GLenum target = GL_ARRAY_BUFFER) const noexcept; + + /// Replaces the whole store with `bytes` bytes of `data` (`data` may be null + /// to allocate without initialising). + void upload(const void* data, usize bytes, GLenum usage) const noexcept; + + /// Overwrites `bytes` bytes starting at `offset`; the store must already be + /// at least `offset + bytes` large. + void upload_sub(const void* data, usize bytes, usize offset) const noexcept; + + /// Exposed for the CRTP base. Deleting an unknown name is a GL no-op. + static void destroy(GLuint id) noexcept; +}; + +/// Vertex array object holding the attribute layout of the bound buffer. +class VertexArray final : public Handle { + public: + bool create(Status* st = nullptr) noexcept; + + void bind() const noexcept; + + /// Enables attribute `index` as `components` floats read from the currently + /// bound `GL_ARRAY_BUFFER`, advancing `stride` bytes per vertex and starting + /// `offset` bytes into it. Requires this vertex array to be bound. + void attrib(u32 index, i32 components, usize stride, usize offset) const noexcept; + + /// Exposed for the CRTP base. + static void destroy(GLuint id) noexcept; +}; + +/// Linked shader program. Constructed only through `from_source()`. +class Program final : public Handle { + public: + /// Compiles and links a vertex/fragment source pair. On failure the returned + /// program is empty and `st` (when non-null) carries `GLFailure` plus the + /// driver's info log, or `Unsupported` when `load()` was never called. + static Program from_source(const char* vertex_src, const char* fragment_src, + Status* st = nullptr) noexcept; + + void use() const noexcept; + + /// Sets a float uniform; a name that is not an active uniform of this program + /// is a silent no-op. + void set_uniform(const char* name, f32 value) const noexcept; + + /// Sets a two-component float uniform. + void set_uniform(const char* name, Vec2 value) const noexcept; + + /// Exposed for the CRTP base. + static void destroy(GLuint id) noexcept; +}; + +/// 2D texture. Creation only allocates storage; `upload_*` replaces the whole +/// image (`glTexImage2D`), which is what the plot pipelines need for atlases +/// and one-shot uploads. +class Texture final : public Handle { + public: + /// Allocates `width` x `height` 8-bit RGBA storage (`GL_RGBA8`), linear + /// filtering, clamped edges. + bool create_rgba(u32 width, u32 height, Status* st = nullptr) noexcept; + + /// Allocates `width` x `height` single-channel 8-bit storage (`GL_R8`) for + /// coverage masks and narrow series data. + bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept; + + /// Replaces the whole image with `width * height * 4` bytes of RGBA data. + void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept; + + /// Replaces the whole image with `width * height` bytes of single-channel + /// data (row padding is disabled, so any width is valid). + void upload_r8(u32 width, u32 height, const void* pixels) const noexcept; + + /// Binds to `unit` (e.g. `GL_TEXTURE0`). + void bind(GLenum unit = GL_TEXTURE0) const noexcept; + + void set_filter(GLenum min, GLenum mag) const noexcept; + + /// Exposed for the CRTP base. + static void destroy(GLuint id) noexcept; +}; + +/// Framebuffer object, the render target of the off-screen paths. +class Framebuffer final : public Handle { + public: + bool create(Status* st = nullptr) noexcept; + + void bind(GLenum target = GL_FRAMEBUFFER) const noexcept; + + /// Attaches `texture`'s level 0 to `GL_COLOR_ATTACHMENT0`. + void attach_color(const Texture& texture) const noexcept; + + /// True when the attachment set is `GL_FRAMEBUFFER_COMPLETE`. + bool complete() const noexcept; + + /// Exposed for the CRTP base. + static void destroy(GLuint id) noexcept; +}; + +/// Move-only GL sync object (`GLsync`, GL 3.2) used to guard reuse of buffers +/// that the GPU may still be reading. +class Fence { + public: + Fence() noexcept = default; + Fence(const Fence&) = delete; + Fence& operator=(const Fence&) = delete; + Fence(Fence&& other) noexcept : id_(other.id_) { other.id_ = nullptr; } + Fence& operator=(Fence&& other) noexcept { + if (this != &other) { + reset(); + id_ = other.id_; + other.id_ = nullptr; + } + return *this; + } + /// Deleting no fence is a no-op, so an empty fence is always safe. + ~Fence() { reset(); } + + /// Inserts a fence in the GL command stream. + bool create(Status* st = nullptr) noexcept; + + /// Waits up to `timeout_ns` nanoseconds for the fence to be signalled, + /// flushing the command stream first. True when it signalled in time. + bool wait(u64 timeout_ns) const noexcept; + + GLsync id() const noexcept { return id_; } + explicit operator bool() const noexcept { return id_ != nullptr; } + + void reset() noexcept; + + private: + GLsync id_ = nullptr; +}; + +} // namespace plot::gl diff --git a/include/plot/gl/loader.hpp b/include/plot/gl/loader.hpp new file mode 100644 index 0000000..2660994 --- /dev/null +++ b/include/plot/gl/loader.hpp @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace plot::gl { + +/// Entry point resolver supplied by the caller, normally the *GetProcAddress +/// of the API that owns the current context: `eglGetProcAddress`, +/// `glXGetProcAddressARB` or `wglGetProcAddress`. +using ProcLoader = void* (*)(const char*); + +/// Resolves every entry point of `PLOT_GL_FUNCS` (see api.hpp) into the global +/// table declared there, so the rest of the library can call `gl*` directly. +/// +/// Returns false when any required name cannot be found; entries that did +/// resolve keep their pointers, so a failed load() never breaks a table that +/// was already usable. `loader` is consulted first; when it is null, or returns +/// null for a name, the fallback is `dlsym(RTLD_DEFAULT, name)` and then +/// `dlopen("libGL.so.1")` + `dlsym`. The fallback makes the library usable +/// without a caller-supplied resolver, but only a context-aware resolver is +/// guaranteed to return pointers valid for the *current* context — pass one +/// whenever a context is current. +/// +/// A load() attempt invalidates the caps() cache. The table is process-global +/// and is not synchronised, so call from the thread that owns the current +/// context. +bool load(ProcLoader loader = nullptr) noexcept; + +} // namespace plot::gl diff --git a/src/gl/handle.cpp b/src/gl/handle.cpp new file mode 100644 index 0000000..cb063f5 --- /dev/null +++ b/src/gl/handle.cpp @@ -0,0 +1,340 @@ +#include + +#include +#include + +namespace plot::gl { +namespace { + +constexpr const char* kNoLoader = "plot::gl::load() has not been called"; + +/// `Status::message` is a non-owning `const char*`, so the text of a failure +/// lives here: valid until the next failure reported on the same thread. +thread_local char g_message[1024]; + +bool fail(Status* st, ErrorCode code, const char* text) noexcept { + if (st != nullptr) { + std::snprintf(g_message, sizeof g_message, "%s", text); + st->code = code; + st->message = g_message; + } + return false; +} + +/// As `fail()`, but appends driver text (a shader or program info log). +bool fail_log(Status* st, const char* what, const char* log) noexcept { + if (st != nullptr) { + std::snprintf(g_message, sizeof g_message, "%s: %s", what, log != nullptr ? log : ""); + st->code = ErrorCode::GLFailure; + st->message = g_message; + } + return false; +} + +/// Compiles one stage and returns its shader name, or 0 on failure (the reason +/// is already in `st`). The shader is deleted if it fails to compile. +GLuint compile_shader(GLenum stage, const char* source, const char* what, Status* st) noexcept { + const GLuint shader = glCreateShader(stage); + if (shader == 0) { + fail(st, ErrorCode::GLFailure, "glCreateShader returned 0"); + return 0; + } + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == 0) { + char log[1024] = {}; + GLsizei written = 0; + glGetShaderInfoLog(shader, static_cast(sizeof log - 1), &written, log); + glDeleteShader(shader); + fail_log(st, what, log); + return 0; + } + return shader; +} + +/// Allocates 2D storage with the sampling state every texture here shares: +/// linear filtering, clamped edges. Returns the name, or 0 if the driver +/// handed out none. +GLuint alloc_texture(GLenum internal_format, GLenum format, u32 width, u32 height) noexcept { + GLuint name = 0; + glGenTextures(1, &name); + if (name == 0) { + return 0; + } + glBindTexture(GL_TEXTURE_2D, name); + 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, static_cast(internal_format), static_cast(width), + static_cast(height), 0, format, GL_UNSIGNED_BYTE, nullptr); + return name; +} + +} // namespace + +// ------------------------------------------------------------------- Buffer + +bool Buffer::create(Status* st) noexcept { + if (glGenBuffers == nullptr) { + return fail(st, ErrorCode::Unsupported, kNoLoader); + } + reset(); + GLuint name = 0; + glGenBuffers(1, &name); + if (name == 0) { + return fail(st, ErrorCode::GLFailure, "glGenBuffers returned 0"); + } + id_ = name; + return true; +} + +void Buffer::bind(GLenum target) const noexcept { glBindBuffer(target, id_); } + +void Buffer::upload(const void* data, usize bytes, GLenum usage) const noexcept { + bind(); + glBufferData(GL_ARRAY_BUFFER, static_cast(bytes), data, usage); +} + +void Buffer::upload_sub(const void* data, usize bytes, usize offset) const noexcept { + bind(); + glBufferSubData(GL_ARRAY_BUFFER, static_cast(offset), static_cast(bytes), + data); +} + +void Buffer::destroy(GLuint id) noexcept { glDeleteBuffers(1, &id); } + +// -------------------------------------------------------------- VertexArray + +bool VertexArray::create(Status* st) noexcept { + if (glGenVertexArrays == nullptr) { + return fail(st, ErrorCode::Unsupported, kNoLoader); + } + reset(); + GLuint name = 0; + glGenVertexArrays(1, &name); + if (name == 0) { + return fail(st, ErrorCode::GLFailure, "glGenVertexArrays returned 0"); + } + id_ = name; + return true; +} + +void VertexArray::bind() const noexcept { glBindVertexArray(id_); } + +void VertexArray::attrib(u32 index, i32 components, usize stride, usize offset) const noexcept { + const GLuint attrib = static_cast(index); + glEnableVertexAttribArray(attrib); + glVertexAttribPointer(attrib, static_cast(components), GL_FLOAT, GL_FALSE, + static_cast(stride), + reinterpret_cast(static_cast(offset))); +} + +void VertexArray::destroy(GLuint id) noexcept { glDeleteVertexArrays(1, &id); } + +// ------------------------------------------------------------------ Program + +Program Program::from_source(const char* vertex_src, const char* fragment_src, + Status* st) noexcept { + Program program; + if (glCreateShader == nullptr || glCreateProgram == nullptr) { + fail(st, ErrorCode::Unsupported, kNoLoader); + return program; + } + if (vertex_src == nullptr || fragment_src == nullptr) { + fail(st, ErrorCode::InvalidArgument, "shader source must not be null"); + return program; + } + + const GLuint vertex = compile_shader(GL_VERTEX_SHADER, vertex_src, "vertex shader failed", st); + if (vertex == 0) { + return program; + } + const GLuint fragment = + compile_shader(GL_FRAGMENT_SHADER, fragment_src, "fragment shader failed", st); + if (fragment == 0) { + glDeleteShader(vertex); + return program; + } + + const GLuint name = glCreateProgram(); + if (name == 0) { + glDeleteShader(vertex); + glDeleteShader(fragment); + fail(st, ErrorCode::GLFailure, "glCreateProgram returned 0"); + return program; + } + + glAttachShader(name, vertex); + glAttachShader(name, fragment); + glLinkProgram(name); + // The program owns the linked code from here on, so the shader objects can + // be detached and deleted. + glDetachShader(name, vertex); + glDetachShader(name, fragment); + glDeleteShader(vertex); + glDeleteShader(fragment); + + GLint linked = 0; + glGetProgramiv(name, GL_LINK_STATUS, &linked); + if (linked == 0) { + char log[1024] = {}; + GLsizei written = 0; + glGetProgramInfoLog(name, static_cast(sizeof log - 1), &written, log); + glDeleteProgram(name); + fail_log(st, "program link failed", log); + return program; + } + + program.id_ = name; + return program; +} + +void Program::use() const noexcept { glUseProgram(id_); } + +void Program::set_uniform(const char* name, f32 value) const noexcept { + if (id_ == 0 || name == nullptr) { + return; + } + const GLint location = glGetUniformLocation(id_, name); + if (location >= 0) { + glUniform1f(location, value); + } +} + +void Program::set_uniform(const char* name, Vec2 value) const noexcept { + if (id_ == 0 || name == nullptr) { + return; + } + const GLint location = glGetUniformLocation(id_, name); + if (location >= 0) { + glUniform2f(location, value.x, value.y); + } +} + +void Program::destroy(GLuint id) noexcept { glDeleteProgram(id); } + +// ------------------------------------------------------------------ Texture + +bool Texture::create_rgba(u32 width, u32 height, Status* st) noexcept { + if (glGenTextures == nullptr) { + return fail(st, ErrorCode::Unsupported, kNoLoader); + } + if (width == 0 || height == 0) { + return fail(st, ErrorCode::InvalidArgument, "texture size must be non-zero"); + } + reset(); + const GLuint name = alloc_texture(GL_RGBA8, GL_RGBA, width, height); + if (name == 0) { + return fail(st, ErrorCode::GLFailure, "glGenTextures returned 0"); + } + id_ = name; + return true; +} + +bool Texture::create_r8(u32 width, u32 height, Status* st) noexcept { + if (glGenTextures == nullptr) { + return fail(st, ErrorCode::Unsupported, kNoLoader); + } + if (width == 0 || height == 0) { + return fail(st, ErrorCode::InvalidArgument, "texture size must be non-zero"); + } + reset(); + const GLuint name = alloc_texture(GL_R8, GL_RED, width, height); + if (name == 0) { + return fail(st, ErrorCode::GLFailure, "glGenTextures returned 0"); + } + id_ = name; + return true; +} + +void Texture::upload_rgba(u32 width, u32 height, const void* pixels) const noexcept { + glBindTexture(GL_TEXTURE_2D, id_); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glTexImage2D(GL_TEXTURE_2D, 0, static_cast(GL_RGBA8), static_cast(width), + static_cast(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); +} + +void Texture::upload_r8(u32 width, u32 height, const void* pixels) const noexcept { + glBindTexture(GL_TEXTURE_2D, id_); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // single-channel rows have no padding + glTexImage2D(GL_TEXTURE_2D, 0, static_cast(GL_R8), static_cast(width), + static_cast(height), 0, GL_RED, GL_UNSIGNED_BYTE, pixels); +} + +void Texture::bind(GLenum unit) const noexcept { + glActiveTexture(unit); + glBindTexture(GL_TEXTURE_2D, id_); +} + +void Texture::set_filter(GLenum min, GLenum mag) const noexcept { + glBindTexture(GL_TEXTURE_2D, id_); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, static_cast(min)); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, static_cast(mag)); +} + +void Texture::destroy(GLuint id) noexcept { glDeleteTextures(1, &id); } + +// -------------------------------------------------------------- Framebuffer + +bool Framebuffer::create(Status* st) noexcept { + if (glGenFramebuffers == nullptr) { + return fail(st, ErrorCode::Unsupported, kNoLoader); + } + reset(); + GLuint name = 0; + glGenFramebuffers(1, &name); + if (name == 0) { + return fail(st, ErrorCode::GLFailure, "glGenFramebuffers returned 0"); + } + id_ = name; + return true; +} + +void Framebuffer::bind(GLenum target) const noexcept { glBindFramebuffer(target, id_); } + +void Framebuffer::attach_color(const Texture& texture) const noexcept { + bind(); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture.id(), 0); +} + +bool Framebuffer::complete() const noexcept { + bind(); + return glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; +} + +void Framebuffer::destroy(GLuint id) noexcept { glDeleteFramebuffers(1, &id); } + +// -------------------------------------------------------------------- Fence + +bool Fence::create(Status* st) noexcept { + if (glFenceSync == nullptr) { + return fail(st, ErrorCode::Unsupported, kNoLoader); + } + reset(); + const GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + if (sync == nullptr) { + return fail(st, ErrorCode::GLFailure, "glFenceSync returned no sync object"); + } + id_ = sync; + return true; +} + +bool Fence::wait(u64 timeout_ns) const noexcept { + if (id_ == nullptr) { + return true; // nothing in flight + } + const GLenum result = glClientWaitSync(id_, GL_SYNC_FLUSH_COMMANDS_BIT, timeout_ns); + return result == GL_ALREADY_SIGNALED || result == GL_CONDITION_SATISFIED; +} + +void Fence::reset() noexcept { + if (id_ != nullptr) { + glDeleteSync(id_); + id_ = nullptr; + } +} + +} // namespace plot::gl diff --git a/src/gl/loader.cpp b/src/gl/loader.cpp new file mode 100644 index 0000000..059ebb6 --- /dev/null +++ b/src/gl/loader.cpp @@ -0,0 +1,192 @@ +#include + +#include + +#include +#include +#include + +// Debug-only diagnostic for the first entry point the loader cannot resolve; +// the failure itself is reported through load()'s return value, so this never +// aborts a release build or a test run. +#ifndef NDEBUG +#define PLOT_ASSERT(cond, message) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "plot::gl::load: cannot resolve %s (%s:%d)\n", (message), \ + __FILE__, __LINE__); \ + } \ + } while (false) +#else +#define PLOT_ASSERT(cond, message) ((void)0) +#endif + +namespace plot::gl { + +// The one definition of the entry point table declared in api.hpp. +#define PLOT_GL_DEF(ret, name, args) ret (*name) args = nullptr; +PLOT_GL_FUNCS(PLOT_GL_DEF) +#undef PLOT_GL_DEF + +namespace { + +/// The system GL library, opened on the first name that neither the caller's +/// resolver nor the global namespace can supply. Never closed: the entry +/// points handed out must stay valid for the whole process. +void* system_gl() noexcept { + static void* lib = nullptr; + static bool opened = false; + if (!opened) { + opened = true; + lib = dlopen("libGL.so.1", RTLD_LAZY | RTLD_LOCAL); + } + return lib; +} + +void* resolve(ProcLoader loader, const char* name) noexcept { + if (loader != nullptr) { + if (void* symbol = loader(name)) { + return symbol; + } + } + if (void* symbol = dlsym(RTLD_DEFAULT, name)) { + return symbol; + } + if (void* lib = system_gl()) { + return dlsym(lib, name); + } + return nullptr; +} + +// ---------------------------------------------------------------- caps probe + +Caps g_caps; +bool g_caps_valid = false; + +const char* gl_string(GLenum name) noexcept { + const char* text = reinterpret_cast(glGetString(name)); + return text != nullptr ? text : ""; +} + +/// True when `all` (the legacy space-separated GL_EXTENSIONS string) contains +/// `name` as a whole token. +bool has_token(const char* all, const char* name) noexcept { + if (all == nullptr || name == nullptr) { + return false; + } + const usize len = std::strlen(name); + for (const char* hit = std::strstr(all, name); hit != nullptr; hit = std::strstr(hit + len, name)) { + const bool at_start = hit == all || hit[-1] == ' '; + const bool at_end = hit[len] == '\0' || hit[len] == ' '; + if (at_start && at_end) { + return true; + } + } + return false; +} + +/// Tests for an extension in the form the current context exposes it: the +/// indexed list on 3.0+, the legacy string before that (where +/// `glGetString(GL_EXTENSIONS)` is still legal). +bool has_extension(int major, const char* name) noexcept { + if (major >= 3) { + if (glGetStringi == nullptr) { + return false; // indexed list unavailable: the legacy string is illegal on 3.0+ core + } + GLint count = 0; + glGetIntegerv(GL_NUM_EXTENSIONS, &count); + for (GLint i = 0; i < count; ++i) { + const char* ext = + reinterpret_cast(glGetStringi(GL_EXTENSIONS, static_cast(i))); + if (ext != nullptr && std::strcmp(ext, name) == 0) { + return true; + } + } + return false; + } + // Before 3.0 the legacy space-separated string is the only enumeration. + return has_token(reinterpret_cast(glGetString(GL_EXTENSIONS)), name); +} + +/// Fills `out` from the context that is current on this thread. Returns false +/// when there is no usable context, leaving `out` untouched. +bool query_caps(Caps& out) noexcept { + if (glGetString == nullptr || glGetIntegerv == nullptr) { + return false; + } + const char* version = reinterpret_cast(glGetString(GL_VERSION)); + if (version == nullptr) { + return false; // no current context + } + int major = 0; + int minor = 0; + // Drivers report e.g. "4.6 (Compatibility Profile) Mesa 25.2.0"; only the + // leading "major.minor" is part of the specification. + if (std::sscanf(version, "%d.%d", &major, &minor) != 2) { + return false; + } + + out.major = major; + out.minor = minor; + out.renderer = gl_string(GL_RENDERER); + out.vendor = gl_string(GL_VENDOR); + + const bool has_44 = major > 4 || (major == 4 && minor >= 4); + out.compute = major > 4 || (major == 4 && minor >= 3); + const bool storage = has_44 || has_extension(major, "GL_ARB_buffer_storage"); + out.buffer_storage = storage; + out.persistent_map = storage; + + GLint profile = 0; + if (major >= 3) { + // GL_CONTEXT_PROFILE_MASK only exists from 3.2 on; older contexts would + // raise GL_INVALID_ENUM and leave the value at 0. + glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &profile); + } + out.core_profile = (static_cast(profile) & GL_CONTEXT_CORE_PROFILE_BIT) != 0u; + return true; +} + +} // namespace + +bool load(ProcLoader loader) noexcept { + const char* missing = nullptr; + +#define PLOT_GL_RESOLVE(ret, name, args) \ + do { \ + void* symbol = resolve(loader, #name); \ + if (symbol != nullptr) { \ + name = reinterpret_cast(symbol); \ + } else if (missing == nullptr) { \ + missing = #name; \ + } \ + } while (false); + + PLOT_GL_FUNCS(PLOT_GL_RESOLVE) + +#undef PLOT_GL_RESOLVE + + // The table may have changed, so whatever caps() cached for the old one is + // stale — including when this load() fails. + g_caps_valid = false; + if (missing != nullptr) { + PLOT_ASSERT(false, missing); + return false; + } + return true; +} + +const Caps& caps() noexcept { + if (!g_caps_valid) { + if (query_caps(g_caps)) { + g_caps_valid = true; + } else { + // No context (or no resolved entry points): report an empty probe rather + // than stale or uninitialised values. + g_caps = Caps{}; + } + } + return g_caps; +} + +} // namespace plot::gl diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3715056..1ed6230 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,8 +1,34 @@ +# EGL powers the surfaceless headless context the GL tests assert pixels +# against. It is a *test-only* dependency: plot_core never links or includes it, +# so a machine without EGL can still build the library (-DPLOT_BUILD_TESTS=OFF). +# Prefer CMake's own module, fall back to pkg-config, and fail loudly if neither +# can supply EGL rather than silently dropping the GL tests. +find_package(EGL QUIET) +if(NOT EGL_FOUND) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(EGL_PC QUIET IMPORTED_TARGET egl) + endif() +endif() + +if(TARGET EGL::EGL) + set(plot_egl_library EGL::EGL) +elseif(TARGET PkgConfig::EGL_PC) + set(plot_egl_library PkgConfig::EGL_PC) +else() + message(FATAL_ERROR + "plot: PLOT_BUILD_TESTS=ON requires EGL (EGL/egl.h plus libEGL) for the headless " + "OpenGL context used by the GL tests. Install it (e.g. libegl-dev, libegl1-mesa-dev " + "or mesa-libEGL-devel) or configure with -DPLOT_BUILD_TESTS=OFF.") +endif() + add_executable(plot_tests main.cpp test_transform.cpp + test_gl_context.cpp + egl_headless.cpp ) -target_link_libraries(plot_tests PRIVATE plot::core) +target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library}) plot_apply_warnings(plot_tests) add_test(NAME plot_tests COMMAND plot_tests) diff --git a/tests/egl_headless.cpp b/tests/egl_headless.cpp new file mode 100644 index 0000000..e166a08 --- /dev/null +++ b/tests/egl_headless.cpp @@ -0,0 +1,206 @@ +#include "egl_headless.hpp" + +#include +#include + +#include +#include + +#include +#include + +namespace plottest { +namespace { + +/// `eglGetPlatformDisplayEXT`, declared locally so the harness does not depend +/// on the exact spelling of the EGL_EXT_platform_base typedef. +using GetPlatformDisplayEXT = EGLDisplay (*)(EGLenum platform, void* native_display, + const EGLint* attrib_list); + +/// Keeps the text of the last EGL failure alive for the returned Status. +thread_local char g_message[512]; + +/// Records the pending EGL error in `g_message`, echoes it to stderr so a +/// failing CTest run shows the driver's own error code, and returns false. +bool fail(plot::Status* st, const char* what) noexcept { + const EGLint error = eglGetError(); + std::snprintf(g_message, sizeof g_message, "%s (EGL error 0x%04X)", what, + static_cast(error)); + std::fprintf(stderr, "plot_tests: headless EGL: %s\n", g_message); + if (st != nullptr) { + st->code = plot::ErrorCode::GLFailure; + st->message = g_message; + } + return false; +} + +} // namespace + +HeadlessContext::HeadlessContext(u32 w, u32 h) noexcept : width(w), height(h) {} + +HeadlessContext::~HeadlessContext() { destroy(); } + +bool HeadlessContext::create(plot::Status* st) noexcept { + if (framebuffer_.id() != 0) { + return true; // already created + } + if (width == 0 || height == 0) { + if (st != nullptr) { + st->code = plot::ErrorCode::InvalidArgument; + st->message = "headless context size must be non-zero"; + } + return false; + } + + auto abort = [&](const char* what) { + const bool reported = fail(st, what); + destroy(); + return reported; + }; + + // 1. Prefer the surfaceless platform: no window system at all. Fall back to + // the default display (still surfaceless when it can be), and only then + // accept a pbuffer surface. + const GetPlatformDisplayEXT get_platform_display = reinterpret_cast( + eglGetProcAddress("eglGetPlatformDisplayEXT")); + EGLDisplay display = EGL_NO_DISPLAY; + if (get_platform_display != nullptr) { + display = get_platform_display(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr); + } + if (display == EGL_NO_DISPLAY) { + display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + } + if (display == EGL_NO_DISPLAY) { + return abort("eglGetPlatformDisplayEXT(EGL_PLATFORM_SURFACELESS_MESA) and eglGetDisplay failed"); + } + display_ = display; + + // 2. Initialise the display for the desktop GL API. + EGLint egl_major = 0; + EGLint egl_minor = 0; + if (eglInitialize(display, &egl_major, &egl_minor) != EGL_TRUE) { + return abort("eglInitialize failed"); + } + if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) { + return abort("eglBindAPI(EGL_OPENGL_API) failed"); + } + + // 3. An RGBA8 config that can back an OpenGL context. + const EGLint config_attribs[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, + EGL_DEPTH_SIZE, 0, EGL_NONE, + }; + EGLConfig config = nullptr; + EGLint config_count = 0; + if (eglChooseConfig(display, config_attribs, &config, 1, &config_count) != EGL_TRUE || + config_count == 0) { + return abort("no EGL config offers 8-bit RGBA with EGL_OPENGL_BIT"); + } + + // 4. OpenGL 3.3 core, the baseline the library targets. + const EGLint context_attribs[] = { + EGL_CONTEXT_MAJOR_VERSION, + 3, + EGL_CONTEXT_MINOR_VERSION, + 3, + EGL_CONTEXT_OPENGL_PROFILE_MASK, + EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR, + EGL_NONE, + }; + const EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs); + if (context == EGL_NO_CONTEXT) { + return abort("eglCreateContext(OpenGL 3.3 core) failed"); + } + context_ = context; + + // 5. Surfaceless current context first; a 1x1 pbuffer only if the driver + // lacks EGL_KHR_surfaceless_context. PLOT_HEADLESS_FORCE_PBUFFER=1 skips + // straight to the fallback, so that path can be verified somewhere the + // surfaceless path would otherwise always win. + const bool force_pbuffer = std::getenv("PLOT_HEADLESS_FORCE_PBUFFER") != nullptr; + if (!force_pbuffer && + eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context) == EGL_TRUE) { + egl_path = "egl surfaceless"; + } else { + const EGLint pbuffer_attribs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; + const EGLSurface surface = eglCreatePbufferSurface(display, config, pbuffer_attribs); + if (surface == EGL_NO_SURFACE || + eglMakeCurrent(display, surface, surface, context) != EGL_TRUE) { + return abort("neither a surfaceless context nor a 1x1 pbuffer surface could be made current"); + } + surface_ = surface; + egl_path = "egl pbuffer"; + } + + // 6. Resolve the entry points against the context that is now current. + if (!plot::gl::load(reinterpret_cast(eglGetProcAddress))) { + if (st != nullptr) { + st->code = plot::ErrorCode::GLFailure; + st->message = "plot::gl::load(eglGetProcAddress) could not resolve every entry point"; + } + std::fprintf(stderr, "plot_tests: headless EGL: %s\n", st != nullptr ? st->message : ""); + destroy(); + return false; + } + + // 7. The off-screen target: an RGBA8 texture on colour attachment 0. + plot::Status gl_status; + if (!color_.create_rgba(width, height, &gl_status) || + !framebuffer_.create(&gl_status)) { + if (st != nullptr) { + *st = gl_status; + } + std::fprintf(stderr, "plot_tests: headless EGL: %s\n", + gl_status.message != nullptr ? gl_status.message : "framebuffer setup failed"); + destroy(); + return false; + } + framebuffer_.attach_color(color_); + if (!framebuffer_.complete()) { + std::fprintf(stderr, "plot_tests: headless EGL: framebuffer is not complete\n"); + if (st != nullptr) { + st->code = plot::ErrorCode::GLFailure; + st->message = "framebuffer is not complete"; + } + destroy(); + return false; + } + return true; +} + +void HeadlessContext::destroy() noexcept { + // GL names belong to the context that created them, so drop them while it is + // still current. + framebuffer_.reset(); + color_.reset(); + + if (display_ != nullptr) { + const EGLDisplay display = static_cast(display_); + eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (context_ != nullptr) { + eglDestroyContext(display, static_cast(context_)); + } + if (surface_ != nullptr) { + eglDestroySurface(display, static_cast(surface_)); + } + eglTerminate(display); + } + display_ = nullptr; + context_ = nullptr; + surface_ = nullptr; + egl_path = ""; +} + +bool HeadlessContext::read_pixel(u32 x, u32 y, std::uint8_t rgba[4]) const noexcept { + if (framebuffer_.id() == 0 || x >= width || y >= height) { + return false; + } + framebuffer_.bind(); + plot::gl::glReadPixels(static_cast(x), static_cast(y), 1, 1, + plot::gl::GL_RGBA, plot::gl::GL_UNSIGNED_BYTE, rgba); + return plot::gl::glGetError() == plot::gl::GL_NO_ERROR; +} + +} // namespace plottest diff --git a/tests/egl_headless.hpp b/tests/egl_headless.hpp new file mode 100644 index 0000000..d51da7f --- /dev/null +++ b/tests/egl_headless.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include + +#include + +namespace plottest { + +using plot::u32; + +/// A surfaceless EGL context plus an off-screen RGBA framebuffer, so renderer +/// output can be asserted pixel by pixel without a window or a display server. +/// +/// Test-only: `plot_core` neither links nor includes EGL. +class HeadlessContext { + public: + explicit HeadlessContext(u32 w = 256, u32 h = 256) noexcept; + ~HeadlessContext(); + + HeadlessContext(const HeadlessContext&) = delete; + HeadlessContext& operator=(const HeadlessContext&) = delete; + + /// Makes an OpenGL 3.3 core context current on this thread, resolves the GL + /// entry points against it (`plot::gl::load(eglGetProcAddress)`) and + /// allocates the colour attachment. Idempotent. On failure `st` (when + /// non-null) carries the reason, every resource already created is released, + /// and the error is also printed to stderr so a failing CTest run is + /// diagnosable. + bool create(plot::Status* st = nullptr) noexcept; + + /// Releases the framebuffer, the GL context and the EGL display. Safe to call + /// more than once, and to call after a failed create(). + void destroy() noexcept; + + /// Binds the off-screen target and reads one RGBA8 pixel, in GL's + /// origin-at-the-bottom-left convention. False when it cannot be read. + bool read_pixel(u32 x, u32 y, std::uint8_t rgba[4]) const noexcept; + + u32 width = 0; + u32 height = 0; + /// The headless path create() selected ("egl surfaceless" or "egl pbuffer"); + /// empty before a successful create() and after destroy(). Setting + /// PLOT_HEADLESS_FORCE_PBUFFER in the environment skips the surfaceless + /// attempt, so the pbuffer fallback can be exercised on a driver that does + /// support surfaceless contexts. + const char* egl_path = ""; + + private: + plot::gl::Texture color_; + plot::gl::Framebuffer framebuffer_; + void* display_ = nullptr; // EGLDisplay + void* context_ = nullptr; // EGLContext + void* surface_ = nullptr; // EGLSurface, null on the surfaceless path +}; + +} // namespace plottest diff --git a/tests/test_gl_context.cpp b/tests/test_gl_context.cpp new file mode 100644 index 0000000..f8560e1 --- /dev/null +++ b/tests/test_gl_context.cpp @@ -0,0 +1,197 @@ +#include "harness.hpp" +#include "egl_headless.hpp" + +#include // eglGetProcAddress: the context-aware entry point resolver + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +/// `eglGetProcAddress` is the context-aware resolver of the EGL context: in a +/// surfaceless context there is no GLX/WGL dispatch to fall back on, so this is +/// the path that must work. +plot::gl::ProcLoader egl_loader() noexcept { + return reinterpret_cast(eglGetProcAddress); +} + +constexpr const char* kVertexSrc = R"(#version 330 core +layout(location = 0) in vec2 a_pos; +uniform vec2 u_offset; +void main() { gl_Position = vec4(a_pos + u_offset, 0.0, 1.0); } +)"; + +constexpr const char* kFragmentSrc = R"(#version 330 core +uniform float u_alpha; +out vec4 o_color; +void main() { o_color = vec4(1.0, 0.0, 0.0, u_alpha); } +)"; + +/// Deliberately unparseable GLSL, to exercise the failure path of +/// `Program::from_source`. +constexpr const char* kBrokenFragmentSrc = R"(#version 330 core +out vec4 o_color; +void main() { o_color = this is not glsl; } +)"; + +} // namespace + +// Must stay the first GL case in this translation unit: `caps()` caches after +// the first successful query, so "before any load()" is observable only once. +PLOT_TEST(caps_before_load_is_zeroed) { + const plot::gl::Caps& caps = plot::gl::caps(); + PLOT_CHECK(caps.major == 0 && caps.minor == 0); + PLOT_CHECK(!caps.compute && !caps.buffer_storage && !caps.persistent_map); + PLOT_CHECK(!caps.core_profile); + PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] == '\0'); + PLOT_CHECK(caps.vendor != nullptr && caps.vendor[0] == '\0'); +} + +PLOT_TEST(headless_context_provides_gl33) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); // fails loudly if surfaceless EGL is unavailable + + // Primary path: resolve through the current context. + PLOT_CHECK(plot::gl::load(egl_loader())); + + const plot::gl::Caps& caps = plot::gl::caps(); + PLOT_CHECK(caps.major > 3 || (caps.major == 3 && caps.minor >= 3)); + PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] != '\0'); + PLOT_CHECK(caps.vendor != nullptr && caps.vendor[0] != '\0'); + PLOT_CHECK(caps.core_profile); // the context was created as 3.3 core + const char* version = reinterpret_cast(plot::gl::glGetString(plot::gl::GL_VERSION)); + PLOT_CHECK(version != nullptr); + const int major = caps.major; + const int minor = caps.minor; + std::printf("[info] GL %d.%d, renderer \"%s\", vendor \"%s\", %s profile, EGL path \"%s\"\n", + major, minor, caps.renderer, caps.vendor, caps.core_profile ? "core" : "compat", + ctx.egl_path); + std::printf("[info] caps: compute=%d buffer_storage=%d persistent_map=%d core_profile=%d\n", + caps.compute ? 1 : 0, caps.buffer_storage ? 1 : 0, caps.persistent_map ? 1 : 0, + caps.core_profile ? 1 : 0); + + // The dlsym(RTLD_DEFAULT) / libGL.so.1 fallback must resolve the whole table + // too, and its pointers must still dispatch to the current EGL context. + PLOT_CHECK(plot::gl::load(nullptr)); + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + const char* fallback_version = + reinterpret_cast(plot::gl::glGetString(plot::gl::GL_VERSION)); + PLOT_CHECK(fallback_version != nullptr); + PLOT_CHECK(std::strcmp(fallback_version, version) == 0); // same context, same driver + const plot::gl::Caps& reprobed = plot::gl::caps(); // load() invalidated the cache + PLOT_CHECK(reprobed.major == major && reprobed.minor == minor); + PLOT_CHECK(reprobed.renderer != nullptr && reprobed.renderer[0] != '\0'); + + // Hand the table back to the context-aware resolver so teardown (deleting the + // framebuffer) runs through the entry points of the context it belongs to. + PLOT_CHECK(plot::gl::load(egl_loader())); + ctx.destroy(); +} + +PLOT_TEST(headless_framebuffer_reads_back_clear_color) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(egl_loader())); + + plot::gl::glClearColor(0.25f, 0.5f, 0.75f, 1.0f); + plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); + + std::uint8_t px[4] = {0, 0, 0, 0}; + PLOT_CHECK(ctx.read_pixel(0, 0, px)); + PLOT_CHECK_NEAR(px[0], 64, 2); // round(0.25 * 255) + PLOT_CHECK_NEAR(px[1], 128, 2); + PLOT_CHECK_NEAR(px[2], 191, 2); + PLOT_CHECK_NEAR(px[3], 255, 2); + + // A second corner proves the whole attachment is readable. + PLOT_CHECK(ctx.read_pixel(ctx.width - 1, ctx.height - 1, px)); + PLOT_CHECK_NEAR(px[2], 191, 2); + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + ctx.destroy(); +} + +PLOT_TEST(raii_handles_round_trip) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(egl_loader())); + + const float verts[6] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f}; + plot::gl::Buffer vbo; + PLOT_CHECK(vbo.create(&st)); + PLOT_CHECK(vbo.id() != 0); + vbo.upload(verts, sizeof verts, plot::gl::GL_STATIC_DRAW); + vbo.upload_sub(verts, sizeof verts, 0); + + plot::gl::VertexArray vao; + PLOT_CHECK(vao.create(&st)); + vao.bind(); + vbo.bind(); + vao.attrib(0, 2, 2 * sizeof(float), 0); + + const std::uint8_t texels[8 * 8 * 4] = {0}; + plot::gl::Texture color; + PLOT_CHECK(color.create_rgba(8, 8, &st)); + color.upload_rgba(8, 8, texels); + + const std::uint8_t coverage[8 * 8] = {0}; + plot::gl::Texture mask; + PLOT_CHECK(mask.create_r8(8, 8, &st)); + mask.upload_r8(8, 8, coverage); + mask.set_filter(plot::gl::GL_NEAREST, plot::gl::GL_NEAREST); + mask.bind(); + + plot::gl::Framebuffer fbo; + PLOT_CHECK(fbo.create(&st)); + fbo.attach_color(color); + PLOT_CHECK(fbo.complete()); + + plot::gl::Fence fence; + PLOT_CHECK(fence.create(&st)); + plot::gl::glFinish(); + PLOT_CHECK(fence.wait(0)); + + // Move-only: the source is emptied and the GL name survives. + plot::gl::Buffer moved = std::move(vbo); + PLOT_CHECK(moved.id() != 0); + PLOT_CHECK(vbo.id() == 0); + moved.reset(); + PLOT_CHECK(moved.id() == 0); + + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + ctx.destroy(); +} + +PLOT_TEST(program_compiles_trivial_shaders) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(egl_loader())); + + plot::Status ok; + plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &ok); + PLOT_CHECK(bool(ok)); + PLOT_CHECK(program.id() != 0); + + program.use(); + program.set_uniform("u_offset", plot::Vec2{1.0f, 2.0f}); + program.set_uniform("u_alpha", 0.5f); + program.set_uniform("u_missing", 0.25f); // unknown name: silent no-op + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + + plot::Status bad; + plot::gl::Program broken = plot::gl::Program::from_source(kVertexSrc, kBrokenFragmentSrc, &bad); + PLOT_CHECK(!bad); + PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(bad.message != nullptr && bad.message[0] != '\0'); + PLOT_CHECK(broken.id() == 0); + ctx.destroy(); +}