Files
plotter/tests/test_gl_context.cpp
T
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00

960 lines
39 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include "egl_headless.hpp"
#include <EGL/egl.h> // eglGetProcAddress: the context-aware entry point resolver
#include <plot/gl/caps.hpp>
#include <plot/gl/handle.hpp>
#include <plot/gl/loader.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/shaders.hpp>
#include <plot/render/text_atlas.hpp>
#include <plot/render/vertex_array.hpp>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <dlfcn.h>
#include <utility>
#include <vector>
#include <fcntl.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>
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<plot::gl::ProcLoader>(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; }
)";
/// Valid GLSL on both sides but an interface that cannot link: the fragment
/// reads a `vec3` where the vertex writes a `vec2`, so the link has to fail and
/// carry the driver's log through `Status::message`.
constexpr const char* kMismatchedVertexSrc = R"(#version 330 core
layout(location = 0) in vec2 a_pos;
out vec2 v_value;
void main() { v_value = a_pos; gl_Position = vec4(a_pos, 0.0, 1.0); }
)";
constexpr const char* kMismatchedFragmentSrc = R"(#version 330 core
in vec3 v_value;
out vec4 o_color;
void main() { o_color = vec4(v_value, 1.0); }
)";
/// Holds one GL entry point at a stub for as long as it lives, the render-smoke
/// stubs' save/restore pattern made generic over the slot's function-pointer
/// type so this file needs one class rather than one per entry point. The
/// pointer is restored by the destructor, which also runs while a failed
/// `PLOT_CHECK` unwinds, so a stub can never leak into the next case.
template <class Fn>
class EntryGuard {
public:
EntryGuard(Fn& slot, Fn value) noexcept : slot_(slot), saved_(slot) { slot_ = value; }
~EntryGuard() { slot_ = saved_; }
EntryGuard(const EntryGuard&) = delete;
EntryGuard& operator=(const EntryGuard&) = delete;
private:
Fn& slot_;
Fn saved_;
};
// --- the caps probe, driven by fake strings ----------------------------------
/// Reported by the stubbed `glGetString`; each case sets them before probing.
const char* g_fake_version = "2.1";
const char* g_fake_extensions = "";
bool g_fake_version_missing = false;
const plot::gl::GLubyte* fake_get_string(plot::gl::GLenum name) noexcept {
if (name == plot::gl::GL_VERSION) {
return g_fake_version_missing ? nullptr
: reinterpret_cast<const plot::gl::GLubyte*>(g_fake_version);
}
if (name == plot::gl::GL_EXTENSIONS) {
return g_fake_extensions == nullptr
? nullptr
: reinterpret_cast<const plot::gl::GLubyte*>(g_fake_extensions);
}
if (name == plot::gl::GL_RENDERER) {
return reinterpret_cast<const plot::gl::GLubyte*>("fake renderer");
}
if (name == plot::gl::GL_VENDOR) {
return reinterpret_cast<const plot::gl::GLubyte*>("fake vendor");
}
return nullptr;
}
/// The indexed list's length and the profile mask the fake `glGetIntegerv`
/// reports; only the 3.x indexed path reads them.
void fake_get_integer_v(plot::gl::GLenum name, plot::gl::GLint* out) noexcept {
*out = name == plot::gl::GL_NUM_EXTENSIONS ? 2 : 0; // 0 = no core-profile bit
}
/// What the stubbed `glGetStringi` reports: 0 = a null pointer, 1 = the
/// extension the probe looks for, 2 = some other one.
int g_fake_stringi_mode = 0;
const plot::gl::GLubyte* fake_get_string_i(plot::gl::GLenum, plot::gl::GLuint) noexcept {
if (g_fake_stringi_mode == 0) {
return nullptr;
}
return reinterpret_cast<const plot::gl::GLubyte*>(
g_fake_stringi_mode == 1 ? "GL_ARB_buffer_storage" : "GL_ARB_something_else");
}
// --- entry points that hand out no name, for the constructors' failure paths --
void zero_gen_buffers(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; }
void zero_gen_vertex_arrays(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; }
void zero_gen_textures(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; }
void zero_gen_framebuffers(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; }
plot::gl::GLuint zero_create_shader(plot::gl::GLenum) noexcept { return 0; }
plot::gl::GLuint zero_create_program() noexcept { return 0; }
/// Probes caps once with the fake string table installed and `GL_EXTENSIONS`
/// reporting `extensions` (null meaning the query returns no string). The
/// `load()` first clears the cached probe *and* restores the real entry point
/// -- the previous probe's guard has already run -- so each call reads the stub.
/// Returning a copy keeps the probe valid after the guard restores the table.
plot::gl::Caps probe_legacy_caps(const char* extensions) noexcept {
plot::gl::load(nullptr);
EntryGuard<decltype(plot::gl::glGetString)> guard(plot::gl::glGetString, &fake_get_string);
g_fake_version = "2.1";
g_fake_version_missing = false;
g_fake_extensions = extensions;
return plot::gl::caps();
}
/// Probes caps once on a 3.2+ context, where the indexed list supplies the
/// names: `GL_NUM_EXTENSIONS` and the profile mask come from the fake
/// `glGetIntegerv`, each name from the fake `glGetStringi` (`stringi_mode`:
/// 0 = null pointer, 1 = the extension sought, 2 = another).
plot::gl::Caps probe_indexed_caps(int stringi_mode) noexcept {
plot::gl::load(nullptr);
EntryGuard<decltype(plot::gl::glGetString)> string_guard(plot::gl::glGetString,
&fake_get_string);
EntryGuard<decltype(plot::gl::glGetIntegerv)> int_guard(plot::gl::glGetIntegerv,
&fake_get_integer_v);
EntryGuard<decltype(plot::gl::glGetStringi)> stringi_guard(plot::gl::glGetStringi,
&fake_get_string_i);
g_fake_version = "3.3";
g_fake_version_missing = false;
g_fake_stringi_mode = stringi_mode;
return plot::gl::caps();
}
} // 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');
}
// The `kNoLoader` family and the `load()` failure branch need `available()` to
// be false, and `load()` is one-way and process-wide: once this suite has
// resolved the table -- the very next case does -- a failure cannot be produced
// in-process. So the whole case runs in a forked child, where poisoning the
// table poisons only its own copy. On a host that ships libGL.so.1 the only way
// left to make a required entry point unresolvable is to block `dlopen` by
// exhausting the file descriptors, which is what the child does first.
//
// It must run before `headless_context_provides_gl33`: after that case the
// process has opened libGL.so.1, and the excluded descriptors would no longer
// stop `dlopen` from handing the loader the table it wants.
PLOT_TEST(a_gl_failure_without_a_loader_is_safe_in_a_forked_child) {
std::fflush(nullptr); // so the child's exit() cannot re-flush the parent's stdout
const pid_t child = ::fork();
PLOT_CHECK(child >= 0);
if (child == 0) {
int rc = 0;
try {
struct rlimit original {};
PLOT_CHECK(::getrlimit(RLIMIT_NOFILE, &original) == 0);
struct rlimit lowered = original;
lowered.rlim_cur = original.rlim_max < 64 ? original.rlim_max : 64;
PLOT_CHECK(::setrlimit(RLIMIT_NOFILE, &lowered) == 0);
std::vector<int> held;
int fd = -1;
while ((fd = ::open("/dev/null", O_RDONLY)) >= 0) {
held.push_back(fd);
}
// Every descriptor is spent, so dlopen("libGL.so.1") fails and no required
// entry point can be resolved: load() reports the first one it could not.
PLOT_CHECK(!plot::gl::load(nullptr));
PLOT_CHECK(!plot::gl::available());
plot::Status st;
plot::gl::Buffer buffer;
PLOT_CHECK(!buffer.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(st.message != nullptr);
PLOT_CHECK(buffer.id() == 0);
plot::gl::VertexArray vao;
PLOT_CHECK(!vao.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(vao.id() == 0);
plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &st);
PLOT_CHECK(!st);
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(program.id() == 0);
plot::gl::Texture rgba;
PLOT_CHECK(!rgba.create_rgba(4, 4, &st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(rgba.id() == 0);
plot::gl::Texture r8;
PLOT_CHECK(!r8.create_r8(4, 4, &st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(r8.id() == 0);
plot::gl::Framebuffer fbo;
PLOT_CHECK(!fbo.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(fbo.id() == 0);
plot::gl::Fence fence;
PLOT_CHECK(!fence.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(fence.id() == nullptr);
// An empty object's guarded operations must not reach the null table.
plot::gl::Buffer empty_buffer;
empty_buffer.storage(16, plot::gl::GL_MAP_WRITE_BIT); // entry point null: no-op
plot::gl::Program empty_program;
empty_program.set_uniform_int("u_value", 1); // id 0: no-op
PLOT_CHECK(fence.wait(0)); // nothing in flight
// Hand the descriptors back so the coverage dump at exit can open its file.
for (const int held_fd : held) {
::close(held_fd);
}
PLOT_CHECK(::setrlimit(RLIMIT_NOFILE, &original) == 0);
} catch (...) {
rc = 1;
}
std::exit(rc);
}
int status = 0;
PLOT_CHECK(::waitpid(child, &status, 0) == child);
PLOT_CHECK(WIFEXITED(status));
PLOT_CHECK(WEXITSTATUS(status) == 0);
}
// `resolve()`'s second source, `dlsym(RTLD_DEFAULT, name)`, only answers when a
// GL library sits in the global symbol scope -- the usual arrangement on a host
// without glvnd's local dispatch. This host is the other kind, so the case
// promotes libGL into that scope itself. The promotion is one-way and
// process-wide, which is exactly why it happens in a forked child: the parent's
// namespace is untouched, and the child's exit() flushes the counters for the
// branch it walked.
PLOT_TEST(a_globally_visible_gl_library_satisfies_the_fallback_resolver) {
std::fflush(nullptr);
const pid_t child = ::fork();
PLOT_CHECK(child >= 0);
if (child == 0) {
int rc = 0;
try {
void* handle = ::dlopen("libGL.so.1", RTLD_NOW | RTLD_GLOBAL);
// Not every host ships a GL library to promote; where there is none the
// case simply cannot reach the branch rather than pretending it did.
if (handle != nullptr && ::dlsym(RTLD_DEFAULT, "glGetString") != nullptr) {
PLOT_CHECK(plot::gl::load(nullptr));
PLOT_CHECK(plot::gl::available());
}
} catch (...) {
rc = 1;
}
std::exit(rc);
}
int status = 0;
PLOT_CHECK(::waitpid(child, &status, 0) == child);
PLOT_CHECK(WIFEXITED(status));
PLOT_CHECK(WEXITSTATUS(status) == 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');
// EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR is requested, so this checks our profile-mask parsing.
PLOT_CHECK(caps.core_profile);
const char* version = reinterpret_cast<const char*>(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. Only
// a host that ships desktop libGL can exercise it; on a GLES-only host there
// is nothing to assert.
if (dlopen("libGL.so.1", RTLD_LAZY) != nullptr) {
PLOT_CHECK(plot::gl::load(nullptr));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
const char* fallback_version =
reinterpret_cast<const char*>(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();
}
// The GLSL of both pipelines has to be accepted by the real driver: a typo that
// no CPU test can catch (a missing varying, a reserved word) fails here.
PLOT_TEST(shaders_compile_and_link) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Status s0;
plot::Status s1;
plot::gl::Program line =
plot::gl::Program::from_source(plot::render::kLineVS, plot::render::kLineFS, &s0);
plot::gl::Program text =
plot::gl::Program::from_source(plot::render::kTextVS, plot::render::kTextFS, &s1);
PLOT_CHECK(bool(s0));
PLOT_CHECK(bool(s1));
PLOT_CHECK(line.id() != 0);
PLOT_CHECK(text.id() != 0);
ctx.destroy();
}
// A renderer wrapper that was never successfully created must be a harmless
// no-op: Task 7 checks `create()`'s bool and then keeps drawing with whatever
// else it has, so the setters must not reach an empty program.
PLOT_TEST(render_programs_are_inert_until_created) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::LineProgram line;
plot::render::TextProgram text;
PLOT_CHECK(!line.valid());
PLOT_CHECK(!text.valid());
line.bind();
line.set_scale({1.0f, 1.0f});
line.set_offset({0.0f, 0.0f});
line.set_viewport({256.0f, 256.0f});
line.set_width(2.0f);
line.set_color(plot::Color::hex(0xFFFFFF));
text.bind();
text.set_viewport({256.0f, 256.0f});
text.set_px_range({8.0f, 8.0f});
text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f});
text.set_texture_size({128.0f, 56.0f});
text.set_color(plot::Color::hex(0xFFFFFF));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// End to end on the real driver: the instanced attribute layout, the
// triangle-strip expansion, the single y flip between Rect pixels and GL, and
// the AA ramp that makes a thin trace legible.
PLOT_TEST(line_pipeline_draws_an_aa_strip) {
plottest::HeadlessContext ctx(256, 256);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::LineProgram program;
PLOT_CHECK(program.create(&st));
plot::render::LineVertexArray lines;
PLOT_CHECK(lines.create(&st));
// Three instances: two segments at different y, so a wrong attribute divisor
// cannot pass (with a divisor of zero the second quad would be assembled from
// the first instance's leftover attribute values), plus a degenerate one at
// Rect (64, 140) — the shape a dense trace's min == max bucket stages, which
// must still leave a mark instead of collapsing to zero area.
const f32 segments[12] = {0.0f, 0.0f, 40.0f, 0.0f, 0.0f, 20.0f,
40.0f, 20.0f, 0.0f, 40.0f, 0.0f, 40.0f};
lines.upload(segments, 3);
PLOT_CHECK(lines.instance_count() == 3);
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
program.bind();
program.set_scale({1.0f, 1.0f});
program.set_offset({64.0f, 100.0f});
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_width(3.0f);
program.set_color(plot::Color::hex(0xFF00FF));
lines.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3);
plot::u8 px[4] = {};
// Rect y = 100 is GL row 155.5, so rows 155 and 156 straddle the axis
// (d = 0.5), rows 154 and 157 are the half-covered AA fringe (d = 1.5), and
// row 152 is outside the quad. Getting the flip wrong, or applying it twice,
// moves the whole strip to the other half of the target.
PLOT_CHECK(ctx.read_pixel(84, 155, px));
PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 156, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 157, px));
PLOT_CHECK(px[0] > 60 && px[0] < 200);
PLOT_CHECK(ctx.read_pixel(84, 152, px));
PLOT_CHECK(px[0] + px[2] < 40);
// The second instance is where its own segment says: Rect y = 120 is rows
// 135/136, and the gap between the two strips stays background.
PLOT_CHECK(ctx.read_pixel(84, 135, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 136, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 145, px));
PLOT_CHECK(px[0] + px[2] < 40);
// Past the end of the segment (flat cap) and far from both strips.
PLOT_CHECK(ctx.read_pixel(110, 155, px));
PLOT_CHECK(px[0] + px[2] < 40);
// The degenerate instance: Rect y = 140 is rows 115/116 at x = 64, and the
// mark must be visible rather than a zero-area quad, while staying inside its
// own stroke width + fringe (columns 62..66).
PLOT_CHECK(ctx.read_pixel(64, 115, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(64, 116, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(64, 121, px));
PLOT_CHECK(px[0] + px[2] < 40);
PLOT_CHECK(ctx.read_pixel(70, 115, px));
PLOT_CHECK(px[0] + px[2] < 40);
PLOT_CHECK(ctx.read_pixel(128, 0, px));
PLOT_CHECK(px[0] + px[2] < 40);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// The text pipeline end to end: the R8 atlas upload, the per-instance glyph UV
// rect, the NEAREST texel-centre sampling, the tint and the same single y flip.
// Every texel of the cell is checked against the backend's own coverage byte, so
// a wrong atlas cell, a wrong UV or a transposed axis cannot pass.
PLOT_TEST(text_pipeline_draws_a_glyph) {
using plot::render::EmbeddedMonoFont;
plottest::HeadlessContext ctx(256, 256);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
// The backend's atlas, through the owner the renderer uses: single channel,
// GL_NEAREST, straight alpha, uploaded from the backend's own bytes.
EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
PLOT_CHECK(atlas.sync(font, &st));
PLOT_CHECK(!atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast<plot::f32>(font.atlas_width()), 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(font.atlas_height()), 1e-6);
plot::render::TextProgram program;
PLOT_CHECK(program.create(&st));
plot::render::TextVertexArray glyphs;
PLOT_CHECK(glyphs.create(&st));
plot::render::TextBatch batch;
batch.begin();
// One cell at Rect (64, 100), at the backend's own size: `px` of 1 means one
// font pixel per pixel, which is what makes the texel-to-pixel mapping below
// an identity.
batch.add_text("A", {64.0f, 100.0f}, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 1);
PLOT_CHECK(batch.data().size() == 6);
glyphs.upload(batch.data().data(), batch.glyph_count());
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
program.bind();
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
// The backend's own cell, on screen at 1:1: the draw's one rect size.
const plot::f32 cell_w = static_cast<plot::f32>(EmbeddedMonoFont::cell_width());
const plot::f32 cell_h = static_cast<plot::f32>(EmbeddedMonoFont::cell_height());
program.set_px_range({cell_w, cell_h});
program.set_glyph_uv(font.cell_uv());
program.set_texture_size(atlas.texel_size());
// White, so the blended pixel value *is* the coverage byte: the shader emits
// `vec4(rgb, a * coverage)`, and a white tint over the black clear lands at
// `1.0 * coverage` in every channel.
program.set_color(plot::Color::hex(0xFFFFFF));
atlas.bind();
glyphs.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
// The cell is cell_w x cell_h texels and u_px_range is the same, so texel
// (gx, gy) lands on exactly one target pixel: Rect x = 64 + gx, and Rect y =
// 100 + gy is GL row 155 - gy. A transposed axis, a wrong cell or a wrong UV
// all move ink away from where the coverage byte says it is.
const plot::u32 slot = EmbeddedMonoFont::glyph_index(static_cast<plot::u32>('A'));
const plot::Vec2 origin = EmbeddedMonoFont::atlas_origin(slot);
const plot::u8* atlas_bytes = font.atlas_pixels();
plot::u32 antialiased = 0;
plot::u8 px[4] = {};
for (plot::u32 gy = 0; gy < EmbeddedMonoFont::cell_height(); ++gy) {
for (plot::u32 gx = 0; gx < EmbeddedMonoFont::cell_width(); ++gx) {
const plot::u32 ax = static_cast<plot::u32>(origin.x) + gx;
const plot::u32 ay = static_cast<plot::u32>(origin.y) + gy;
const plot::u8 want = atlas_bytes[static_cast<std::size_t>(ay) * font.atlas_width() + ax];
PLOT_CHECK(ctx.read_pixel(64u + gx, 155u - gy, px));
PLOT_CHECK_NEAR(px[0], static_cast<plot::f32>(want), 2.0);
PLOT_CHECK_NEAR(px[1], static_cast<plot::f32>(want), 2.0);
PLOT_CHECK_NEAR(px[2], static_cast<plot::f32>(want), 2.0);
if (want > 0u && want < 255u) ++antialiased;
}
}
// 'A' has grey edge texels and they reached the framebuffer: a 1-bit atlas
// could only ever have produced 0 or 255 here.
PLOT_CHECK(antialiased > 0u);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// The loader's legacy scanner: a 2.1 version string routes `caps()` down the
// pre-3.0 path, the only one that reads `GL_EXTENSIONS` as one space-separated
// string, so these probes go through `has_token` exactly as a real 2.1 driver
// would. The near-misses are the assertions that matter -- a `strstr`-only
// scanner would set the cap for both of them.
PLOT_TEST(legacy_extension_string_is_scanned_a_whole_token_at_a_time) {
PLOT_CHECK(probe_legacy_caps("GL_ARB_other GL_ARB_buffer_storage GL_ARB_more").buffer_storage);
// At the very start and the very end: the boundaries the scanner exists for.
PLOT_CHECK(probe_legacy_caps("GL_ARB_buffer_storage GL_ARB_more").buffer_storage);
PLOT_CHECK(probe_legacy_caps("GL_ARB_more GL_ARB_buffer_storage").buffer_storage);
// Prefix-only near-misses must not count.
PLOT_CHECK(!probe_legacy_caps("GL_ARB_buffer_storageX").buffer_storage);
PLOT_CHECK(!probe_legacy_caps("XGL_ARB_buffer_storage").buffer_storage);
// No string at all: the null guard.
PLOT_CHECK(!probe_legacy_caps(nullptr).buffer_storage);
const plot::gl::Caps caps = probe_legacy_caps("GL_ARB_buffer_storage");
PLOT_CHECK(caps.major == 2 && caps.minor == 1); // the stub really was read
PLOT_CHECK(caps.buffer_storage && caps.persistent_map);
PLOT_CHECK(std::strcmp(caps.renderer, "fake renderer") == 0);
}
// The 3.0+ indexed path: `GL_NUM_EXTENSIONS` and the profile mask come from
// `glGetIntegerv`, each name from `glGetStringi`. A null `glGetStringi` means
// the list is unavailable, and the probe must then refuse -- the legacy string
// is illegal on 3.0+ core -- rather than quietly report no support.
PLOT_TEST(indexed_extension_list_is_probed_on_a_3x_context) {
PLOT_CHECK(probe_indexed_caps(1).buffer_storage);
PLOT_CHECK(!probe_indexed_caps(2).buffer_storage); // other names only
PLOT_CHECK(!probe_indexed_caps(0).buffer_storage); // the entries are null
{
plot::gl::load(nullptr);
EntryGuard<decltype(plot::gl::glGetString)> string_guard(plot::gl::glGetString,
&fake_get_string);
EntryGuard<decltype(plot::gl::glGetIntegerv)> int_guard(plot::gl::glGetIntegerv,
&fake_get_integer_v);
EntryGuard<decltype(plot::gl::glGetStringi)> stringi_guard(plot::gl::glGetStringi, nullptr);
g_fake_version = "3.3";
g_fake_version_missing = false;
g_fake_extensions = "GL_ARB_buffer_storage"; // present, but must not be consulted
PLOT_CHECK(!plot::gl::caps().buffer_storage);
}
const plot::gl::Caps caps = probe_indexed_caps(1);
PLOT_CHECK(caps.major == 3 && caps.minor == 3);
}
// Neither a context that answers no version string nor one whose version does
// not parse may leave stale caps behind: both must report the zeroed probe.
PLOT_TEST(caps_are_zeroed_when_the_version_is_missing_or_unparsable) {
{
plot::gl::load(nullptr);
EntryGuard<decltype(plot::gl::glGetString)> guard(plot::gl::glGetString, &fake_get_string);
g_fake_version_missing = true;
const plot::gl::Caps caps = plot::gl::caps();
PLOT_CHECK(caps.major == 0 && caps.minor == 0);
PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] == '\0');
}
{
plot::gl::load(nullptr);
EntryGuard<decltype(plot::gl::glGetString)> guard(plot::gl::glGetString, &fake_get_string);
g_fake_version_missing = false;
g_fake_version = "not a version";
const plot::gl::Caps caps = plot::gl::caps();
PLOT_CHECK(caps.major == 0 && caps.minor == 0);
}
g_fake_version = "2.1";
g_fake_version_missing = false;
g_fake_extensions = "";
}
// Every fallible constructor's refusal branches: a driver that hands out name 0
// (`GLFailure`), a table with the entry point absent (`Unsupported`), and the
// argument checks. Each must report the documented code and leave an object
// that is empty yet safe to destroy. The stubs save and restore the slot, so a
// failure cannot leak into the next block.
PLOT_TEST(fallible_handles_report_a_refused_driver_name_and_stay_empty) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(egl_loader()));
{
EntryGuard<decltype(plot::gl::glGenBuffers)> guard(plot::gl::glGenBuffers, &zero_gen_buffers);
plot::gl::Buffer buffer;
PLOT_CHECK(!buffer.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(std::strcmp(st.message, "glGenBuffers returned 0") == 0);
PLOT_CHECK(buffer.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glGenBuffers)> guard(plot::gl::glGenBuffers, nullptr);
plot::gl::Buffer buffer;
PLOT_CHECK(!buffer.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(buffer.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glGenVertexArrays)> guard(plot::gl::glGenVertexArrays,
&zero_gen_vertex_arrays);
plot::gl::VertexArray vao;
PLOT_CHECK(!vao.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(std::strcmp(st.message, "glGenVertexArrays returned 0") == 0);
PLOT_CHECK(vao.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glGenVertexArrays)> guard(plot::gl::glGenVertexArrays, nullptr);
plot::gl::VertexArray vao;
PLOT_CHECK(!vao.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(vao.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glGenTextures)> guard(plot::gl::glGenTextures,
&zero_gen_textures);
plot::gl::Texture tex;
PLOT_CHECK(!tex.create_rgba(4, 4, &st));
PLOT_CHECK(st.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(std::strcmp(st.message, "glGenTextures returned 0") == 0);
PLOT_CHECK(tex.id() == 0);
PLOT_CHECK(!tex.create_r8(4, 4, &st));
PLOT_CHECK(st.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(tex.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glGenTextures)> guard(plot::gl::glGenTextures, nullptr);
plot::gl::Texture tex;
PLOT_CHECK(!tex.create_rgba(4, 4, &st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(!tex.create_r8(4, 4, &st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
}
{
plot::gl::Texture tex;
PLOT_CHECK(!tex.create_rgba(0, 4, &st));
PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument);
PLOT_CHECK(!tex.create_r8(4, 0, &st));
PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument);
PLOT_CHECK(tex.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glGenFramebuffers)> guard(plot::gl::glGenFramebuffers,
&zero_gen_framebuffers);
plot::gl::Framebuffer fbo;
PLOT_CHECK(!fbo.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(std::strcmp(st.message, "glGenFramebuffers returned 0") == 0);
PLOT_CHECK(fbo.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glGenFramebuffers)> guard(plot::gl::glGenFramebuffers, nullptr);
plot::gl::Framebuffer fbo;
PLOT_CHECK(!fbo.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(fbo.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glFenceSync)> guard(plot::gl::glFenceSync, nullptr);
plot::gl::Fence fence;
PLOT_CHECK(!fence.create(&st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(fence.id() == nullptr);
}
// Program: a table without glCreateShader, a null source, a shader the driver
// refuses to create and a program it refuses to create.
{
EntryGuard<decltype(plot::gl::glCreateShader)> guard(plot::gl::glCreateShader, nullptr);
plot::Status bad;
plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad);
PLOT_CHECK(!bad);
PLOT_CHECK(bad.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(program.id() == 0);
}
{
plot::Status bad;
plot::gl::Program program = plot::gl::Program::from_source(nullptr, kFragmentSrc, &bad);
PLOT_CHECK(!bad);
PLOT_CHECK(bad.code == plot::ErrorCode::InvalidArgument);
PLOT_CHECK(program.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glCreateShader)> guard(plot::gl::glCreateShader,
&zero_create_shader);
plot::Status bad;
plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad);
PLOT_CHECK(!bad);
PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(std::strcmp(bad.message, "glCreateShader returned 0") == 0);
PLOT_CHECK(program.id() == 0);
}
{
EntryGuard<decltype(plot::gl::glCreateProgram)> guard(plot::gl::glCreateProgram,
&zero_create_program);
plot::Status bad;
plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad);
PLOT_CHECK(!bad);
PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(std::strcmp(bad.message, "glCreateProgram returned 0") == 0);
PLOT_CHECK(program.id() == 0);
}
plot::gl::Program empty;
empty.set_uniform_int("u_value", 1); // an empty program's setter is a no-op
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// A program whose stages each compile but cannot be linked together: the
// constructor has to delete the program and report the driver's own log, not a
// bare failure. This is the path the link-failure branch implements.
PLOT_TEST(a_program_that_cannot_link_reports_the_driver_log) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(egl_loader()));
plot::Status bad;
plot::gl::Program program =
plot::gl::Program::from_source(kMismatchedVertexSrc, kMismatchedFragmentSrc, &bad);
PLOT_CHECK(!bad);
PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(bad.message != nullptr);
PLOT_CHECK(std::strncmp(bad.message, "program link failed", std::strlen("program link failed")) ==
0);
// The driver's log follows the prefix: a real reason, not an empty tail. Mesa's
// linker names the mismatched varying.
PLOT_CHECK(std::strlen(bad.message) > std::strlen("program link failed: "));
PLOT_CHECK(std::strstr(bad.message, "v_value") != nullptr);
PLOT_CHECK(program.id() == 0);
ctx.destroy();
}
// A vertex array that could not be fully created must not be left behind: the
// pair fails when the vertex array itself is refused, and again when the
// instance buffer is refused after the vertex array exists, and in both cases
// the object reports nothing to draw.
PLOT_TEST(vertex_arrays_leave_nothing_behind_when_create_fails) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(egl_loader()));
{
EntryGuard<decltype(plot::gl::glGenVertexArrays)> guard(plot::gl::glGenVertexArrays,
&zero_gen_vertex_arrays);
plot::Status bad;
plot::render::LineVertexArray lines;
PLOT_CHECK(!lines.create(&bad));
PLOT_CHECK(!lines.valid());
plot::render::TextVertexArray glyphs;
PLOT_CHECK(!glyphs.create(&bad));
PLOT_CHECK(!glyphs.valid());
}
{
EntryGuard<decltype(plot::gl::glGenBuffers)> guard(plot::gl::glGenBuffers, &zero_gen_buffers);
plot::Status bad;
plot::render::LineVertexArray lines;
PLOT_CHECK(!lines.create(&bad));
PLOT_CHECK(!lines.valid());
plot::render::TextVertexArray glyphs;
PLOT_CHECK(!glyphs.create(&bad));
PLOT_CHECK(!glyphs.valid());
}
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// An empty batch -- a null pointer or a count of zero, with no vertex array
// created -- stages nothing and draws nothing, without touching the GL table.
PLOT_TEST(vertex_arrays_stage_nothing_for_an_empty_batch) {
plot::render::LineVertexArray lines;
lines.upload(nullptr, 3);
PLOT_CHECK(lines.instance_count() == 0);
const f32 segments[4] = {0.0f, 0.0f, 1.0f, 1.0f};
lines.upload(segments, 0);
PLOT_CHECK(lines.instance_count() == 0);
plot::render::TextVertexArray glyphs;
glyphs.upload(nullptr, 4);
PLOT_CHECK(glyphs.instance_count() == 0);
const f32 glyph_data[6] = {0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f};
glyphs.upload(glyph_data, 0);
PLOT_CHECK(glyphs.instance_count() == 0);
}