easy-gl and meta-gl: the two-library GL stack beneath the EasyGL family

CNA snapshot 009d40f5  ·  Deep Dives › Sibling libraries  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. CNA facts were read at 009d40f5; easy-gl facts at develop @ deda7a42 (2026-08-22) and meta-gl facts at develop @ 20c8b2dc (2026-09-03), which CNA does not pin. The code examples were syntax-checked with g++ -std=c++23 -fsyntax-only against those two header trees (with a stub for meta-gl's generated export header); no library, test or GPU run was executed.

CNA's five GL identities (OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, WEBGL2) share one renderer implementation that sits on two separate sibling libraries: meta-gl, which loads OpenGL entry points and wraps them in typed calls, and easy-gl, which adds object ownership on top. This page describes those two libraries as CNA consumes them: which revisions CNA snapshot 009d40f5 actually needs, how responsibility and failure handling are split between the layers, how features are gated, what happens per thread and on context loss, how the libraries test themselves, and what CNA inherits when it adds them to its build. It is for maintainers who debug a GL-family problem below CNA's own renderer code or who update the sibling checkouts.

Which revisions this page describes

CNA does not pin either library. RendererSelection.cmake adds the fixed relative path ../easy-gl once when any GL identity is selected, and easy-gl's own CMake adds ../meta-gl; there is no submodule, lock file, FetchContent fallback or SHA comparison. The only easy-gl and meta-gl commits named anywhere in the snapshot are in its integration records (INTEGRATION_BRANCH_INVENTORY.md, FINAL_RECONCILIATION.md): easy-gl 0b46d35c and meta-gl 571d3a62, recorded on 2026-08-09 as the then-current public heads. Those are dated evidence, and they are no longer sufficient:

  • EasyGLRenderer.cpp maps SurfaceFormat::Rg32 to ::easygl::InternalFormat::Rgba16 (a CNA change of 2026-09-10). easy-gl's InternalFormat is an alias of metagl::InternalFormat, and the Rgba16 enumerator first exists in meta-gl 20c8b2dc (2026-09-03, "expose desktop RGBA16 format"). Against the recorded 571d3a62 that line has no enumerator to name (read in both repositories; nothing was compiled).
  • Under Emscripten, CNA sets the cache variable EASYGL_EMSCRIPTEN_EXCEPTION_MODEL to JS before adding easy-gl. The variable exists only from easy-gl deda7a42 (2026-08-22, "make the exception ABI selectable"); an older easy-gl ignores it (see two failure models).

The practical minimum for this snapshot is therefore the current develop tip of each library, and that is what this page was read at:

LibraryRevision readTreeWhat CNA 009d40f5 needs from it
easy-gldevelop @ deda7a42 (2026-08-22)d9b556c9The selectable Emscripten exception ABI; the RAII resource classes and ResourceRegistry
meta-gldevelop @ 20c8b2dc (2026-09-03)0250915fInternalFormat::Rgba16; the loader, typed wrappers and context-lifecycle notifications CNA calls directly

Both are evidence for those revisions, not versions CNA promises to work with. They are also the revisions that recent consumer projects record: the dependencies.lock files of cna-street and living-room-simulator pin exactly deda7a42 and 20c8b2dc, next to CNA commits of 2026-09-06 and 2026-09-11, while the older lock of cna-template (CNA of 2026-08-11) still names 0b46d35c and 571d3a62 (see applications as bounded consumers). Continuous integration does not settle the question either: most CNA workflows obtain siblings through clone_siblings.sh, which shallow-clones, for each sibling, the first branch that exists among the pushed branch, the pull request's target and next, falling back to develop; a few workflows (for example d3d-windows-ci.yml) use a plain checkout of the default branch. Either way a CI run builds against whatever the sibling's tip is on that day. A result about a GL-family build is complete only when it names the CNA commit and both sibling commits.

The 2026-08-09 history rewrite

On 2026-08-09 both libraries' public histories were replayed to remove co-author trailers. CNA's integration record states, and the local clones confirm, that the rewritten heads have exactly the trees CNA had accepted before (meta-gl 571d3a62 has tree a7771c55, easy-gl 0b46d35c has tree e89ff546), so the content did not change; the pre-rewrite commit objects are no longer reachable from any branch, and a citation of one of them cannot be resolved by a reader. The same record says the archive tags were re-pointed and now carry annotations that name their old targets; the clones read for this page carry no tags, so that part was not re-checked. Signatures changed too: in these clones the rewritten commits up to 0b46d35c and 571d3a62 report no signature, and the later commits carry signatures whose key is not in this host's keyring. Cite these libraries by branch, commit, tree and date, and do not repeat a statement that their history is signed without saying which objects it is about.

Two libraries, one ownership split

  host (CNA platform: SDL3, SDL2, X11, Wayland or Win32; browser under Emscripten)
    |  creates window + GL context, makes it current, swaps, supplies GetProcAddress
    v
  CNA EasyGL renderer  (cna_renderer_easygl, one target for five identities)
    |                         \
    |  easygl::Device,         \  43 distinct metagl::gl* wrappers called directly,
    |  Texture, Buffer, ...      \ plus metagl::Initialize / IsContextLost / NotifyContextLost
    v                             v
  easy-gl  (namespace easygl) ----------> meta-gl  (namespace metagl)
  RAII, move-only resource classes        loader, typed enums and handles,
  throws Exception /                      context facts, per-call validation;
  UnsupportedFeatureException             std::terminate() on contract violation
                                              |
                                              v
                                   driver entry points (GL, GLES, WebGL)
Figure. The GL stack under CNA's EasyGL family, top to bottom. The host platform owns the window, the context and presentation and hands a GetProcAddress callback down. CNA's single EasyGL renderer target uses easy-gl for owned resources and device state, but also calls meta-gl directly for 43 wrappers (44 with the opt-in compiled-effects source) and for loader and context-lifecycle functions. easy-gl builds on meta-gl, which alone talks to the driver's entry points. The two libraries report failure differently: easy-gl throws C++ exceptions, meta-gl terminates the process.

meta-gl is the procedural layer: runtime loading of entry points through the host's GetProcAddress, enum class wrappers for GL constants, lightweight typed handles and one free function per GL call in namespace metagl, with std::span<const T> overloads for array and data-upload parameters (buffer data, texture images, compressed textures; T must satisfy the SpanCompatible concept, trivially copyable with standard layout) next to the raw const void* forms that allocate-only calls need. It deliberately owns nothing: its own notes say ownership belongs in easy-gl. easy-gl is the object layer: move-only classes whose constructors create and whose destructors delete GL objects, a Device for state and draws, capability detection and a few RAII helpers, in namespace easygl. The dependency is hard in both build and source: easy-gl's CMake adds ../meta-gl and links meta-gl::meta-gl PUBLIC, and at deda7a42 18 of its production files (3 public headers, 15 sources) include meta-gl headers directly.

"Toolkit-independent", easy-gl's own description, means independent of the windowing toolkit, not of every layer below it. Neither library creates a window or context, pumps events or presents; CNA's platform layer does that, which is how one easy-gl serves SDL3, SDL2, X11, Wayland, Win32 and the browser. CNA is also not purely an easy-gl client: EasyGLRenderer.cpp calls 43 distinct metagl::gl* wrappers itself (queries, pixel store, attachment enums, timer queries; the opt-in CNA_EASYGL_COMPILED_EFFECTS source EasyGLCompiledEffect.cpp adds a 44th, glBindTexture), so meta-gl's failure contract applies to CNA's own calls as well.

Propertymeta-gl @ 20c8b2dceasy-gl @ deda7a42
RoleFunction loading, typed enums and handles, context and capability facts, per-call validationResource ownership (RAII, move-only), device state and draws, feature gating
Failure modelstd::terminate(), also in ReleaseC++ exceptions (easygl::Exception, UnsupportedFeatureException)
Public headers12 under include/metagl/ (plus vendored Khronos headers)30 under include/easygl/
Physical lines in include/ + src/12,581 (3 sources plus one .inc)4,449 (21 sources)
Language levelC++23C++23 (cxx_std_23 is a PUBLIC compile feature); the README still says C++20
Options when nestedAll default OFF; legacy aliases honoured only when top-levelEASYGL_BUILD_TESTS and EASYGL_BUILD_EXAMPLES default ON
PackagingInstallable; find_package(meta-gl 0.3), same-minor compatibility only while 0.xNo package config; consumers use add_subdirectory

The size relation surprises people who think of easy-gl as "the library CNA uses": the lower, procedural layer is almost three times larger by physical lines, because it is a hand-audited typed mapping of hundreds of entry points. Older figures measured with a code-line counter on the 2026-08-07 trees (3,606 and about 8,856 code lines) show the same inversion; the numbers above were counted with git show <rev>:<file> | wc -l over the two directories at the revisions read.

Device initialization: context facts come from meta-gl

easygl::Device::initialize(loader) is one-shot (a second call returns at once) and runs in this order: a null loader throws easygl::Exception("GL loader callback is null."); metagl::Initialize(loader) must succeed or "Failed to bootstrap GL functions." is thrown; easy-gl then copies meta-gl's GetContextInfo() and GetCapabilities() (API kind, version, vendor, renderer, version strings, extensions, WebGL 1/2 flags) into its own Capabilities and runs detect_common_features(). Five baseline features must then be present (VertexArrayObject, Shader, Program, Buffer, BasicRendering); any missing one throws. Finally it records GL_MAX_TEXTURE_SIZE as the limit max_texture_size.

Device is easy-gl's largest class. Besides initialisation it covers clear (including the per-buffer clear_buffer* forms), viewport and scissor, blend state with per-draw-buffer overloads of the blend function and colour mask for multiple render targets, depth and stencil, culling, polygon offset and line width, sample coverage and sample mask, pixel store, plain and bounds-checked readback (read_pixels, read_pixels_robust), the draw family (arrays, elements, instanced, indirect, ranged and base-vertex), compute dispatch, the tessellation patch size (set_patch_vertices) and debug facilities (debug groups, object labels, message control and set_debug_callback). Polygon mode (wireframe) is not on Device; CNA detects the native wireframe API itself.

Earlier easy-gl versions parsed GL_VERSION themselves. Delegating to meta-gl matters on the web: meta-gl classifies every Emscripten context as ApiKind::WebGL (from __EMSCRIPTEN__ and the version string) instead of letting an ES-shaped version string pass for native OpenGL ES, and it reports WebGL 1 as version 2.0 and WebGL 2 as 3.0 so that one ES-style comparison serves both. metagl::Initialize itself fails, without publishing a half-filled table, when the version is unsupported or a required entry point is missing: desktop needs OpenGL 3.3 and glGetStringi; WebGL 1 needs the GLES 2.0 entry points; WebGL 2 additionally needs glGetStringi. OpenGL ES older than 2.0 and desktop OpenGL older than 3.3 are out of scope.

Because Device::initialize is one-shot, CNA does not call it again after a context loss: on restore the renderer reloads the platform loader and meta-gl's function table directly (metagl::LoadCurrentContext and metagl::Initialize in EasyGLRenderer.cpp), as described in EasyGL internals: context loss.

Capability gating by feature, not by version

easygl::Capabilities::detect_common_features() is a per-feature table, not one "desktop or ES" branch, because the two API families diverge feature by feature. WebGL deliberately goes through the ES rules, which is how features WebGL never has (geometry and tessellation shaders, debug output) fall out of the same version checks.

FeatureDesktop OpenGLOpenGL ES and WebGL
VertexArrayObject3.0, or GL_ARB_vertex_array_object3.0, or GL_OES_vertex_array_object (the WebGL 1 route)
FramebufferObject3.0, or the ARB/EXT framebuffer-object extensionsalways (2.0 and up: FBOs are core in ES 2.0)
UniformBufferObject3.1 or GL_ARB_uniform_buffer_object3.0
Texture3D, TextureFloat, TextureRG1.2, 3.0, 3.03.0, or GL_OES_texture_3D, GL_OES_texture_float, GL_EXT_texture_rg
Instancing, SamplerObject3.1, 3.33.0 (instancing also via GL_EXT_draw_instanced or GL_NV_draw_instanced), 3.0
GeometryShader3.23.2
TessellationShader4.03.2: the ES requirement is numerically lower
ComputeShader, DebugOutput4.3 (compute also via GL_ARB_compute_shader), 4.33.1, 3.2
DirectStateAccess4.5never set; an unset feature answers false
OpenGlOnlyImmediateMode, OpenGlOnlyPolygonMode, OpenGlOnlyLineWidthWidealways true, even on a 3.3 core contextfalse
AnisotropicFiltering, TextureCompressionextension only, both families: GL_EXT_texture_filter_anisotropic; S3TC or ASTC LDR

Two quirks follow from the table. The desktop rows are version checks, so a driver that exposes a feature through an extension not listed there reports it as absent. And OpenGlOnlyImmediateMode answers true on a core-profile context that has no immediate mode, so that flag says "desktop family", not "usable". Calling code asks the table instead of guessing from is_opengles():

// Query the per-feature table rather than branching on the API family.
if (device.supports(easygl::Feature::TessellationShader))
{
    ConfigurePatchPipeline();
}
else
{
    UseFallbackSubdivision();   // desktop GL below 4.0, or GLES below 3.2
}

// With the default Config::throw_on_missing_feature == true this throws
// UnsupportedFeatureException; with false it returns silently.
device.require(easygl::Feature::FramebufferObject);

CNA's renderer does not use this table at the snapshot: it contains no easygl::Feature query and no require() call. It reads capabilities().is_webgl(), is_opengl() and the version string, asks metagl::IsFunctionAvailable("glColorMaski") for indexed colour masks, and probes limits and formats on the live context itself, because a GLES 3.0 request is routinely answered with a 3.2 context (see EasyGL internals: what the five profiles change). Treat the table as easy-gl's contract for its own classes and for other clients, not as the source of CNA's capability answers.

The desktop ES tier is a diagnostic, not the sharing mechanism

meta-gl computes a DesktopEsTier (None, Baseline, Es30, Es31, Es32) for a desktop 3.3+ context by checking the same mandatory entry-point lists it uses for native ES, with GL_ARB_ES3_1_compatibility and GL_ARB_ES3_2_compatibility as a fast extra signal. At 20c8b2dc it is explicitly internal: the header is excluded from the metagl.hpp umbrella, the function is metagl::detail::GetDesktopEsTier() and is outside the API/ABI guarantees, and the public Capabilities::gles30/gles31/gles32 flags stay false on every desktop context by a recorded design choice, so that Capabilities never blurs which kind of context it is. CNA references none of it. What lets OPENGL33 share one CNA implementation with the ES and WebGL identities is CNA's own profile layer (the predicates in GlProfile.hpp) on top of meta-gl's common programmable subset, not the tier value.

Two failure models one layer apart

easy-gl reports a missing capability as a recoverable C++ exception. For six optional entry points it checks metagl::IsFunctionAvailable() before calling: Query, Sampler, TransformFeedback, Sync, ProgramPipeline::create() and Texture::get_level_parameter*() throw UnsupportedFeatureException instead of calling a null pointer. ProgramPipeline documents that separable program pipelines are permanently absent from WebGL 1 and WebGL 2, not a tier a later WebGL will close.

meta-gl treats misuse as a contract violation and ends the process with std::terminate(), in Release as well: a size_t that does not fit GLsizei, incomplete matrix data, an unsupported bitfield. The same applies to any wrapper called before initialisation or whose entry point did not load: every wrapper begins with a guard of the form if (!(g_gl.initialized && g_gl.X != nullptr)) std::terminate(); (372 std::terminate() sites in src/Functions.cpp). A wrapper that easy-gl does not pre-check therefore terminates rather than throws when its entry point is missing. easy-gl's own notes list draw_elements_base_vertex and similar ES 3.2 calls as unguarded on ES 3.0 contexts, which is why CNA never reaches glDrawElementsBaseVertex on the ES and WebGL profiles and rebases attribute pointers instead (base-vertex draws on every profile).

Handles cross between the layers without ownership. A meta-gl handle is a plain value; wrapping a raw name neither creates nor deletes anything:

metagl::TextureId texture{rawName};          // just a typed GLuint
metagl::glBindTexture(metagl::TextureTarget::Texture2D, texture);
// TextureId never deletes rawName; an easygl::Texture owns that lifetime.

The failure model reaches the WebAssembly build

Because easy-gl throws, Emscripten builds need C++ exception support, and every object in the final link must use the same exception ABI, meta-gl's included. easy-gl sets the model in its own CMake before it adds meta-gl. Up to 0b46d35c that was an unconditional -fwasm-exceptions. From deda7a42 it is the cache variable EASYGL_EMSCRIPTEN_EXCEPTION_MODEL: WASM (the standalone default, -fwasm-exceptions) or JS (-fexceptions, and -sDISABLE_EXCEPTION_CATCHING=0 at link); any other value is a configure error. CNA forces JS because its own web contract is the JS-lowered exception ABI plus Asyncify for a blocking Game::Run(), and Emscripten rejects Asyncify combined with -fwasm-exceptions (BuildPerformance.cmake; web build: one exception ABI). The easy-gl commit and the CNA commit that sets the variable were made 46 seconds apart on 2026-08-22, which is the clearest sign that they belong together: an easy-gl older than deda7a42 silently ignores the variable and compiles itself and meta-gl with native Wasm exceptions, a mix CNA's web contract does not allow.

meta-gl's typed surface and its release discipline

At 20c8b2dc meta-gl exports 358 numbered metagl::gl* wrappers. Its verifier, tools/verify_api.py, fails unless the markers form the contiguous range 1 to 358 with 358 unique names, and it cross-checks the exact mandatory sets of 142, 104, 68 and 44 functions that GLES 2.0, 3.0, 3.1 and 3.2 add against the vendored Khronos gl32.h. The type vocabulary is 105 enum class declarations in Enums.hpp and 16 structs in Types.hpp: eleven object handles (ShaderId, ProgramId, TextureId, BufferId, FramebufferId, RenderbufferId, SamplerId, VertexArrayId, QueryId, TransformFeedbackId, ProgramPipelineId), UniformLocation, AttribLocation, ActiveAttribIndex, ImageUnit and the helper GlBitfieldTraits. Counts of 99 enums and 15 structs in meta-gl's older notes are stale. Three commits after the 2026-08-09 record added the four S3TC compressed formats, guarded the native version parser out of Emscripten builds, and added Rgba16; none changed the wrapper count.

For a 12-header library the binary discipline is strict: hidden default visibility with an explicit METAGL_API export macro, a GNU/Clang linker version script (cmake/metagl.version), SONAME and exported-symbol policy tests, and an installed-package consumer test. The project version is 0.3.0, and for 0.x releases the package accepts only the same minor version because a minor bump may break the API. Options do not leak into a parent: METAGL_BUILD_TESTS, METAGL_BUILD_GPU_TESTS, METAGL_BUILD_EXAMPLES, METAGL_BUILD_DOCS, METAGL_SANITIZE, METAGL_ENABLE_DEBUG_LOGGING and METAGL_DEBUG_IMMEDIATE all default OFF, and the old BUILD_TESTING/BUILD_EXAMPLES/SANITIZE names are honoured only when meta-gl is the top-level project. Nested under CNA it therefore builds nothing but the library.

Per-thread dispatch: what "the current context" means

All of meta-gl's mutable state is thread_local at 20c8b2dc: the function table (g_gl) and the availability map in src/Functions.cpp, and the context info, generation counter, capabilities and listener list in src/Context.cpp. The loader header still describes the model as "call once on the GL thread … then hand off rendering to any thread"; the code does not allow that. A thread that has not called Initialize has an empty table, so its first wrapper call terminates, and it has its own generation counter and its own (empty) listener list. Three consequences for CNA:

  • Every thread that issues GL calls must initialise meta-gl itself. CNA's EnsureCallingThreadContext makes the context current and calls metagl::Initialize when metagl::IsInitialized() is false for that thread; the background-upload lease relies on it (context lease).
  • A generation comparison is only meaningful on the thread that created the resource; another thread compares against its own counter.
  • Context-loss listeners registered on the render thread are notified only by NotifyContextLost/NotifyContextRestored calls made on that thread.

This was established by reading the declarations and the guards; no multi-threaded run was made for this page.

API corrections to know when porting older easy-gl code

easy-gl's planning records several reasoned corrections that change call sites. Program::uniform_block_index() returns std::optional<unsigned int> instead of the raw GL_INVALID_INDEX sentinel, so a caller needs no GL header to test for failure. Framebuffer's attach functions take const Texture& or const Renderbuffer& instead of a bare unsigned int, so passing the wrong kind of handle is a compile error rather than a later GL_INVALID_OPERATION; ProgramPipeline and TransformFeedback were changed the same way.

if (std::optional<unsigned int> blockIndex = shaderProgram.uniform_block_index("Lighting"))
{
    shaderProgram.set_uniform_block_binding(*blockIndex, /* bindingPoint */ 0);
}   // no "Lighting" block: handled without ever comparing against GL_INVALID_INDEX

easygl::Framebuffer gbuffer;
gbuffer.attach_texture_2d(easygl::FramebufferTarget::Framebuffer,
                          easygl::FramebufferAttachment::Depth,
                          easygl::TextureTarget::Texture2D, depthTexture, /* level */ 0);
// depthTexture is a const easygl::Texture&; a Renderbuffer here does not compile.

Two behavioural changes are easy to miss. Device::clear() once disabled the scissor test before every clear; at deda7a42 it only builds the clear mask and calls glClear, so code that relied on the implicit disable must call set_scissor_test_enabled(false) itself. CNA does not depend on either behaviour: its own Clear disables the scissor test and forces all colour write masks for the duration of the clear and restores both, because XNA's clear ignores them (EasyGL internals: draw path). And Texture::bind(target) activates texture unit 0 before binding, while active_bind(unit, target) activates the given unit; both leave that unit active. The unit-0 activation was lost in a refactoring and restored in 0b46d35c with a regression test, test_texture_bind_and_active_bind_semantics.

Four small RAII helpers

Four utilities close ergonomic gaps rather than adding GL capability. ScopedDebugGroup, ScopedBind and ResourceRegistration delete their copy and move operations; UniformCache declares none, so it is an ordinary copyable and movable value (a copy keeps the same Program pointer and starts from a copy of the cached locations). CNA uses none of them at the snapshot (it registers its recoverable objects with ResourceRegistry::add/remove directly).

  • ScopedDebugGroup(device, label, id = 0) calls push_debug_group(DebugSource::Application, id, label) and its destructor calls pop_debug_group() unconditionally, so a labelled region in a RenderDoc or Nsight capture stays balanced across early returns and exceptions. It performs no availability check; use it only where debug groups exist.
  • UniformCache(program) memoises uniform_location by name. A failed lookup (a mistyped or optimised-out uniform, -1) is memoised exactly like a successful one, so repeating the name never re-queries; only invalidate(), required after every relink, clears the cache. It stores a raw const Program*, so the program must outlive the cache.
  • ScopedBind(bind, unbind) runs the first callable in its constructor and stores the second in a std::function that the destructor always runs; it works for any bind/unbind pair, not one resource type.
  • ResourceRegistration(registry, resource) calls registry.add(&resource) and, in its destructor, registry.remove(&resource), so a recoverable resource cannot leave a dangling pointer in its registry.
{
    easygl::ScopedDebugGroup group(device, "Shadow Pass");
    RenderShadowCasters();                      // pop_debug_group() runs even if this throws
}

easygl::UniformCache uniforms(shaderProgram);
shaderProgram.set_uniform(uniforms["uTime"], elapsedSeconds);
shaderProgram.set_uniform(uniforms["uLightDir"], lightDir.x, lightDir.y, lightDir.z);
// ... only after shaderProgram is relinked:
uniforms.invalidate();

{
    easygl::ScopedBind bound([&] { vao.bind(); }, [&] { vao.unbind(); });
    ConfigureVertexAttributes(vao);             // unbind() runs on every exit path
}

Two tiers of context-loss recovery

easy-gl has two separate mechanisms for surviving a lost GL context, at two levels of commitment.

Tier one: detection, for free. Eleven resource classes derive from easygl::detail::GenerationTracked: Buffer, Framebuffer, Program, ProgramPipeline, Query, Renderbuffer, Sampler, Shader, Texture, TransformFeedback and VertexArray. The base stores the handle and the meta-gl generation at creation (creation_generation()); is_valid_for_current_generation() is true only when the handle is non-zero and that generation equals metagl::GetContextGeneration() (on the calling thread; see per-thread dispatch); reset_handle_no_gl() zeroes handle and generation without issuing a GL call, because a lost context cannot be trusted with one. Sync is the exception: its handle is a pointer-shaped GLsync, it does not derive from the base, and it offers only its own reset_handle_no_gl(), so it can drop a stale handle but cannot answer the generation question.

Tier two: rebuilding, opt-in. A class that derives from easygl::RecoverableResource implements release_gl_handle_only() (forget GPU handles, keep CPU-side data) and recreate_gl_resource() (rebuild from that data). easygl::ResourceRegistry is a metagl::ContextListener: after register_with_meta_gl(), meta-gl's loss and restore notifications call OnContextLost() and OnContextRestored(), which invoke the two methods on every registered resource in registration order, both times front to back. The destructor unregisters from meta-gl. The registry iterates its live vector, not a copy, so a callback that adds or removes a registration while it runs invalidates the iteration; keep registration changes out of those callbacks (read, not exercised).

Inside easy-gl no production class implements tier two: every resource class inherits GenerationTracked, none RecoverableResource. easy-gl's lifecycle suite therefore tests the mechanism with a stub, in test_resource_registry_context_lost and test_resource_registry_wired_to_meta_gl:

// From easy-gl's ContextLifecycleTests.cpp: a test-only stub, not a production class.
struct FakeResource : easygl::RecoverableResource
{
    int& released;
    int& recreated;
    FakeResource(int& rel, int& rec) : released(rel), recreated(rec) {}
    void release_gl_handle_only() override { ++released; }
    void recreate_gl_resource()   override { ++recreated; }
};

The real adopters are in CNA. EasyGLRenderer.hpp declares ten classes that derive from ::easygl::RecoverableResource: EasyGLTextureRenderer, EasyGLRenderTargetRenderer, EasyGLRenderTargetCubeRenderer, EasyGLTextureCubeRenderer, EasyGLOcclusionQueryRenderer, EasyGLGpuTimerRenderer, EasyGLSpriteBatchRenderer, EasyGLVertexBufferRenderer, EasyGLIndexBufferRenderer and EasyGLRenderer itself, which registers first so that renderer-owned objects are released before and recreated before their children. A plain easy-gl client still has to detect staleness through tier one and rebuild by hand, or write its own RecoverableResource around its CPU data, as CNA does. What each CNA object keeps and rebuilds, and the policy switch that turns registration off, are on EasyGL internals: context loss and context-recovery policy.

How the libraries test themselves without a GPU

easy-gl has four hand-rolled test executables, all driven by fake loaders: easy-gl-smoke-tests (SmokeInitTests.cpp), easy-gl-resource-smoke-tests (SmokeResourceTests.cpp), easy-gl-context-lifecycle-tests and easy-gl-webgl-tests. A fake GetProcAddress returns lambdas with hard-wired answers, literal version strings such as "3.3.0 easy-gl stub", "OpenGL ES 3.1" or "OpenGL ES 2.0 (WebGL 1.0 (OpenGL ES 2.0 Chromium))", and literal enum values, so detection and resource bookkeeping run with no GPU or display. The lifecycle suite drives generation counting, loss and restore, tiered GLES 2.0/3.0 loaders and a missing required GLES 2.0 function; the WebGL suite checks ApiKind::WebGL classification, WebGL 1/2 flags, the GL_OES_vertex_array_object gate (initialisation must throw on a sub-3.0 WebGL context without it) and exception-not-crash behaviour of the guarded entry points. The two desktop-shaped suites are not built under Emscripten, because their invented desktop version strings can never come from a browser; the Emscripten-relevant coverage is the other two. That is a test-model boundary, not evidence that the browser behaviour was run.

The checks are 128 plain assert() calls. In a build with NDEBUG (CMake's Release and RelWithDebInfo) they compile to nothing and the executables pass without checking anything; easy-gl's own Emscripten preset uses a Debug build for exactly that reason. easy-gl has no CI workflow at deda7a42.

meta-gl's suite is broader: metagl-compile-tests, metagl-mock-loader-test, metagl-release-contract-test, metagl-thread-test, metagl-desktop-tier-test, the Python metagl-soname-test and metagl-export-symbols-test, metagl-api-consistency-test and metagl-installed-package-test. A real headless context is exercised only by the opt-in metagl-egl-smoke-test (EGL plus Mesa or another EGL implementation), and METAGL_BUILD_GPU_TESTS without METAGL_BUILD_TESTS is a configure-time FATAL_ERROR. Its GitHub workflow builds Linux GCC static, Linux Clang shared and Windows MSVC shared configurations, runs the EGL smoke test on Linux, and has a separate ASan/UBSan job. None of these suites was run for this page, and CNA's own EasyGL tests (see how to validate this family) are a separate population.

What CNA inherits when it adds easy-gl

The two libraries behave differently when nested. meta-gl builds only its library. easy-gl defaults EASYGL_BUILD_TESTS and EASYGL_BUILD_EXAMPLES to ON (and EASYGL_USE_SYSTEM_OPENGL and EASYGL_ENABLE_WARNINGS as well), calls enable_testing() in its own subtree when tests are on, and is added by CNA without EXCLUDE_FROM_ALL. CNA sets the two build options to OFF only in its web preset (CMakePresets.json); neither the root CMake nor the renderer-selection code forces them. A native GL-identity configuration from any other preset or from a plain command line therefore compiles the four easy-gl test executables as part of the default build, and whether their add_test registrations appear in a ctest run from CNA's build root was not established (they are added before CNA's own enable_testing(); nothing was configured for this page). Pass -DEASYGL_BUILD_TESTS=OFF -DEASYGL_BUILD_EXAMPLES=OFF when the extra targets are unwanted.

The example is self-limiting but not inert: hello-triangle-sdl is added only when find_package(SDL3 QUIET) finds an SDL3 package. CNA's SdlAvailability.cmake records (NPV-0114) that this lookup once picked up an SDL3 installed under /usr/local and added an SDL-linked executable to a CNA_ENABLE_SDL=OFF build; with SDL disabled CNA now sets CMAKE_DISABLE_FIND_PACKAGE_SDL3 and its siblings, so an optional lookup anywhere in the tree finds nothing. When the easy-gl target exists, UnitTests.cmake also links it into cna_test_build_config, so CNA's aggregate test build sees easy-gl's headers.

Known limitations at the revisions read

  • Config::enable_debug_logging does nothing: Config has no log_callback to receive output, and easy-gl's notes track the callback as unfinished. Config::validate_calls is likewise declared and never read.
  • Query::result_u64() reads the result with the 32-bit glGetQueryObjectuiv and widens it, because meta-gl exposes no 64-bit query-object read. Values above UINT32_MAX cannot be returned.
  • ES 3.2 entry points such as draw_elements_base_vertex are not version-guarded; on an ES 3.0 context they reach meta-gl's terminate guard.
  • easy-gl installs targets but ships no package configuration, so find_package(easy-gl) is not supported.

The 32-bit query limit is visible in CNA. EasyGLGpuTimerRenderer issues GL_TIME_ELAPSED queries through meta-gl directly, casting 0x88BF into metagl::QueryTarget because that ES 3.0-shaped enum does not contain the extension target, and reads nanoseconds with glGetQueryObjectuiv: a measurement saturates a little above 4.29 seconds. meta-gl also offers no way to ask GL_GPU_DISJOINT_EXT, so the renderer cannot detect a disjoint event; it rejects the all-ones value 0xFFFFFFFF, which is both the saturation value and what drivers return for an undefined result, as "not available" (the source records that before this rule every timer's first sample read as 4294.967295 ms). Frame-scale GPU timings are meaningful; a result that crosses a disjoint event without producing that value is not detected.

Source reading order

  1. RendererSelection.cmake: the sibling check, the single add_subdirectory(../easy-gl) and the Emscripten exception-model override.
  2. meta-gl include/metagl/Loader.hpp, Context.hpp and ContextEvents.hpp, then the thread_local state at the top of src/Functions.cpp and src/Context.cpp.
  3. easy-gl src/Device.cpp (initialize, require, clear) and src/Capabilities.cpp (detect_common_features).
  4. easy-gl include/easygl/detail/GenerationTracked.hpp, RecoverableResource.hpp and src/ResourceRegistry.cpp, then tests/smoke/ContextLifecycleTests.cpp.
  5. EasyGLRenderer.hpp: CNA's RecoverableResource adopters, then EnsureCallingThreadContext and EasyGLGpuTimerRenderer in EasyGLRenderer.cpp.

Setting the libraries up for a build is covered by Building CNA: sibling checkouts and Tutorial 102; the wider map of sibling repositories is The CNA ecosystem, and the libraries' role from an application's point of view is Tutorial 90.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture