The text pipeline stages a glyph's rect *origin* per instance and carries the rect's *size* once per draw, so a backend whose glyphs have differing rect sizes -- a TrueType packing trimmed to ink, which is Task 8's shape -- would have drawn every glyph at whichever size it happened to be told, with nothing downstream able to tell. Make that constraint structural rather than documented: - TextBackend::cell_uv() declares the one cell size, and the contract (an atlas is a fixed-cell grid; a proportional face draws its glyphs *into* a uniform cell instead of trimming each rect to its ink, and why the pipeline needs it) is stated on the atlas accessors. - glyph_is_cell() is the check; add_text()/set_cell() declare the cell from the backend, and the staging path asserts it for every glyph in a debug build. A release build draws on with the cell it was told, as before. - Renderer::flush_text() takes u_glyph_uv from the backend, not from the batch, so the draw's one rect size can never come from whichever glyph was staged first.
412 lines
16 KiB
C++
412 lines
16 KiB
C++
#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/bitmap_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 <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <dlfcn.h>
|
|
#include <utility>
|
|
|
|
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; }
|
|
)";
|
|
|
|
} // 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');
|
|
// 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 one of the cell's 64 pixels is checked against the glyph bitmap, so a
|
|
// wrong atlas cell, a wrong UV or a transposed axis cannot pass.
|
|
PLOT_TEST(text_pipeline_draws_a_glyph) {
|
|
using plot::render::BitmapFont;
|
|
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.
|
|
BitmapFont 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>(BitmapFont::kAtlasWidth), 1e-6);
|
|
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(BitmapFont::kAtlasHeight), 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)});
|
|
program.set_px_range({static_cast<f32>(BitmapFont::kGlyphPx), static_cast<f32>(BitmapFont::kGlyphPx)});
|
|
program.set_glyph_uv(font.cell_uv());
|
|
program.set_texture_size(atlas.texel_size());
|
|
program.set_color(plot::Color::hex(0xFF00FF));
|
|
atlas.bind();
|
|
glyphs.bind();
|
|
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
|
|
|
|
// One cell is 8x8 texels and u_px_range is 8, so texel (gx, gy) lands on one
|
|
// target pixel: Rect x = 64 + gx, and Rect y = 100 + gy is GL row 155 - gy.
|
|
const plot::u8* rows = font.glyph_bitmap('A');
|
|
plot::u8 px[4] = {};
|
|
for (plot::u32 gy = 0; gy < BitmapFont::kGlyphPx; ++gy) {
|
|
for (plot::u32 gx = 0; gx < BitmapFont::kGlyphPx; ++gx) {
|
|
const bool ink = ((rows[gy] >> gx) & 1u) != 0u;
|
|
PLOT_CHECK(ctx.read_pixel(64u + gx, 155u - gy, px));
|
|
if (ink) {
|
|
PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200);
|
|
} else {
|
|
PLOT_CHECK(px[0] < 40 && px[2] < 40);
|
|
}
|
|
}
|
|
}
|
|
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
|
|
ctx.destroy();
|
|
}
|