Chapter 59 easy-gl and meta-gl: Profile, Loader, and Dispatch
This book introduced easy-gl as the C++23 OpenGL/OpenGL ES wrapper behind CNA’s five shared-family profiles: OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, and WEBGL2. That implementation family uses a two-library stack. meta-gl owns loading, typed calls, context facts, and validation; easy-gl owns resources, object-oriented state, and RAII.
59.1 The ownership split
easy-gl’s own README frames it as toolkit-independent, meaning independent of a windowing toolkit like SDL or Qt — but it is not independent of a lower C++ GL wrapper underneath it. easy-gl itself is built on a sibling project, meta-gl, which provides the actual procedural, typed, ownership-free OpenGL/GLES call surface (typed enum-class wrappers, std::span-based views, lightweight typed handles, no RAII of its own); easy-gl then layers real object-oriented, RAII, move-only ownership semantics on top. The dependency is real and direct in the build system (meta-gl is pulled in as a sibling add_subdirectory and linked publicly), and in the source: 18 production files include meta-gl headers directly, comprising three public headers and fifteen implementation files. The layering is worth remembering precisely because it clarifies what “toolkit-independent” actually means here: independent of the host windowing/event toolkit, not independent of every other C++ abstraction below it.
Neither library creates a window or GL context, pumps events, nor presents a frame. The host owns those operations and supplies a GetProcAddress callback after making its context current. CNA fills that host role through SDL, while the same libraries remain usable with a different toolkit.
The size inversion is easy to miss. Easy-gl exposes 30 headers and about 3,606 production code lines; meta-gl exposes only 12 headers but roughly 8,856 true C/C++ code lines. The lower procedural layer is about 2.5 times larger because its generated-looking surface is still a hand-audited typed mapping of hundreds of GL entry points.
59.2 Device initialization delegates context truth downward
easygl::Device is the largest single entry point, covering initialization, the bulk of GL state (clear, viewport/scissor, blend — including indexed, per-attachment blend functions for multiple render targets — depth/stencil, culling, polygon/line mode, sample coverage), pixel readback (a bounds-checked read_pixels_robust alongside the plain form), the full draw-call family (instanced, indirect, ranged, base-vertex), compute dispatch, tessellation patch configuration, and debug facilities (labeled debug groups, a settable debug callback).
Older easy-gl code duplicated version-string parsing. The audited implementation calls metagl::Initialize() and then copies GetContextInfo() and GetCapabilities() into its higher-level model. This matters on the web: meta-gl classifies Emscripten contexts as ApiKind::WebGL, so WebGL no longer masquerades as ordinary OpenGL ES merely because its shading language is ES-shaped.
Meta-gl also computes a DesktopEsTier: the OpenGL ES tier whose mandatory functions a desktop OpenGL context can supply. That mapping is what lets CNA’s OPENGL33 identity share one implementation with native ES and WebGL profiles without pretending that their version numbers have identical meanings.
Once detection completes, easy-gl requires vertex arrays, shaders, programs, buffers, and basic rendering. A missing baseline throws during initialization. Optional entry points — queries, samplers, transform feedback, syncs, program pipelines, and texture-level queries — are checked through metagl::IsFunctionAvailable() and fail with easygl::UnsupportedFeatureException instead of calling a null function pointer. WebGL 1 may obtain vertex arrays through GL_OES_vertex_array_object; separable program pipelines have no WebGL equivalent, including WebGL 2.
59.3 Capability gating by feature, not version
easygl::Capabilities::detect_common_features() implements a genuine, per-feature gating table rather than a single “desktop GL is capability X, GLES is capability Y” heuristic, and the two API families frequently diverge in specific, non-obvious ways. Vertex array objects need GL 3.0 (or the GL_ARB_vertex_array_object extension) on desktop, but GLES 3.0 or the differently-named GL_OES_vertex_array_object extension on GLES. Framebuffer objects need GL 3.0 or an extension on desktop, but are simply always present on GLES 2.0 and above, since FBOs are core to ES2 from the start. Tessellation shaders need GL 4.0 on desktop but only GLES 3.2 — a case where the GLES requirement is numerically lower. Direct State Access, by contrast, is not modeled for GLES at all, since it is a desktop-only OpenGL 4.5 capability with no GLES equivalent whatsoever. This per-feature table — not a single “is this GLES” branch — is exactly the mechanism Chapter 22 described CNA’s own EasyGL renderer implementation relying on via device.supports(...) / device.require(...).
59.4 Meta-gl’s typed call surface and invalid-input contract
Meta-gl exports 358 numbered metagl::gl* wrappers. A Python verifier requires the markers to form one contiguous 1–358 sequence with unique names and cross-checks the mandatory OpenGL ES 3.0, 3.1, and 3.2 sets (104, 68, and 44 additions) against the vendored Khronos header. The public type vocabulary contains 105 enum classes in Enums.hpp and 16 struct declarations in Types.hpp: typed object handles, locations, indices, an image unit, and a bitfield-traits helper. Older continuation counts of 99 enums and 15 structs are stale.
These handles are intentionally ownership-free:
The two layers also choose different failure semantics. Easy-gl reports unsupported features with C++ exceptions. Meta-gl’s invalid-input contract calls std::terminate() even in Release for conditions such as a size_t that cannot fit in GLsizei, incomplete matrix data, or an unsupported bitfield. Those are treated as caller contract violations, not recoverable driver capabilities.
This difference has a build consequence on Emscripten. Easy-gl enables -fwasm-exceptions globally before adding meta-gl so every object in the combined stack agrees on the Wasm exception model. A failure-policy choice at the OOP layer therefore affects how the lower procedural layer must be compiled even though meta-gl itself does not throw for its input contract.
Meta-gl’s small header count also hides unusually strict binary discipline: hidden visibility, a public export macro, a GNU/Clang version script, SONAME and exported-symbol policy tests, and an installed-package consumer. It can be found as meta-gl 0.3; an uncommitted 0.4.0-snapshot file in the local worktree is not a released or pinned version.
59.5 Design corrections and one breaking change
easy-gl’s own planning documents record several real, reasoned design corrections.
Program::uniform_block_index() originally returned GL’s own raw GL_INVALID_INDEX sentinel on a failed lookup, forcing every caller to know and compare against a raw GL constant; it now returns std::optional<unsigned int> instead, so a caller never needs to include a GL header at all just to check for failure. Several resource classes (Framebuffer, ProgramPipeline, TransformFeedback) were changed from accepting a raw unsigned int GL handle to accepting a real Texture / Program reference directly, removing a whole class of “passed the wrong handle type by mistake” bug at the API boundary.
One specific, genuine breaking behavioral change is worth knowing if you are porting code against an older version of this library: Device::clear() used to call glDisable(GL_SCISSOR_TEST) before every clear, silently overriding whatever scissor state the caller had already set. This is no longer true — clear() no longer touches scissor state at all, and a caller that was relying on the old implicit disable must now call the scissor-disable method explicitly. The project’s own migration notes frame this as low-impact, since it only matters when scissor testing happens to already be active at the moment clear() is called — but it is exactly the kind of silent, implicit side-effect this book has flagged elsewhere (Chapter 13’s ignored transform matrix, for instance) as the class of bug most likely to go unnoticed until specifically tested for.
59.5.1 Four focused RAII additions
Two small utility classes, added after this chapter’s own original pass, are worth a direct mention because each closes a real, easy-to-get-wrong ergonomic gap rather than adding a new GL capability. ScopedDebugGroup is a two-call RAII pair — its constructor calls Device::push_debug_group, its destructor unconditionally calls pop_debug_group — specifically so a labeled debug region in a RenderDoc or NVIDIA Nsight capture can never be left unbalanced by an early return or an exception unwinding through the scope:
UniformCache addresses the other classic OpenGL ergonomics problem: repeatedly calling glGetUniformLocation by name is a real, measurable per-draw cost real engines avoid by caching the resolved integer location once. operator[] looks up a cached location by name, falling through to a real Program::uniform_location call and memoizing the result only on a cache miss — and, worth knowing explicitly since it is not stated in the header’s own brief comment, a failed lookup (a mistyped or since-optimized-out uniform name, returning ) is memoized exactly the same way a successful one is. Calling operator[] again with the same wrong name will not re-attempt the GL query and magically start succeeding once the shader changes — only an explicit invalidate() call (needed after any program relink, since old locations are not guaranteed valid against a newly linked program) clears the cache and allows a fresh lookup:
Two further small RAII utilities, added in the same wave as the two above, round out this pattern rather than introducing a new one. ScopedBind generalizes the same constructor-runs-first/destructor-always-runs shape into a reusable, resource-agnostic wrapper — constructed from any pair of bind/unbind callables, not tied to one specific GL object type at all:
ResourceRegistration closes a different, narrower gap: any GL resource that participates in this library’s context-loss recovery mechanism must register itself with a ResourceRegistry to be told when to rebuild itself after a context is lost and recreated, and must remember to deregister on destruction to avoid the registry holding a dangling pointer. Before this addition, both calls were the caller’s own responsibility to remember; ResourceRegistration’s constructor calls registry.add(&resource) and its destructor unconditionally calls registry.remove(&resource), the identical RAII shape as every other addition in this section, applied specifically to the register/deregister pair a RecoverableResource would otherwise have to manage by hand at every one of its own constructor and destructor sites.
59.6 Two tiers of context-loss recovery
ResourceRegistration above closes the register/deregister half of the context-loss story, but reading GenerationTracked.hpp and the context-lifecycle test suite together reveals a fuller picture worth spelling out on its own: easy-gl actually has two separate mechanisms for surviving a lost GL context, at two different levels of commitment, and only one of them is currently used by anything real.
The first tier covers every integer-named GL resource class and is effectively free. Those eleven classes inherit a common base class, easygl::detail::GenerationTracked. It stamps each resource with the meta-gl-tracked context generation number at creation time (creation_generation()) and exposes is_valid_for_current_generation() so any caller can cheaply ask “was this handle created against the context that is actually current right now, or a since-lost one” without any registration step at all. The pointer- shaped Sync object is the exception: it has its own creation and no-generation reset surface, so it can discard a stale handle but cannot answer the generation-validity query. reset_handle_no_gl() zeroes either representation without issuing a single gl* call, since a lost context cannot be trusted to process one correctly anyway.
The second tier is the opt-in, full self-healing mechanism this chapter already covers: RecoverableResource’s release_gl_handle_only() / recreate_gl_resource() pair, driven by ResourceRegistry (itself a real metagl::ContextListener, receiving OnContextLost() / OnContextRestored() automatically once register_with_meta_gl() is called). This tier is where the real commitment lives: implementing it means a resource class must retain enough CPU-side state (raw pixel data, shader source text, buffer contents) to rebuild the GPU object from scratch, not merely notice that its handle went stale.
A genuinely worth-knowing gap: the infrastructure has production implementations — ResourceRegistry dispatches the two lifecycle calls and ResourceRegistration supplies RAII add/remove — but zero concrete resource classes adopt it. Texture, Buffer, VertexArray, Framebuffer, and every other real GL object class inherit GenerationTracked (tier one) but none of them implement RecoverableResource (tier two). The library’s own planning document confirms this is a known, tracked state rather than an oversight this chapter is the first to notice: Task U2 (“Test context-loss / ResourceRegistry recovery cycle”) is explicitly scoped against “a concrete RecoverableResource subclass” precisely because no real one currently exists to test against — and, consistent with that, ContextLifecycleTests.cpp tests this directly, with two dedicated ResourceRegistry tests:
-
•
test_resource_registry_context_lost
-
•
test_resource_registry_wired_to_meta_gl
Both construct a minimal, test-only FakeResource stub rather than exercising any real resource type, for exactly this reason:
The practical upshot for a caller today: a context loss is cheaply detectable per resource via is_valid_for_current_generation() (tier one, universal), but nothing in easy-gl itself will automatically rebuild a lost Texture or Buffer for you (tier two, fully built and independently tested as a mechanism, but with no adopters yet) — a caller that needs real survive-context-loss behavior today has to detect staleness via tier one and rebuild the resource manually, or write its own RecoverableResource subclass around its own CPU-side data. This is the same shape of gap this book has already named on the CNA side more than once (a mechanism exists and is correctly wired, but nothing yet plugs into it) — here, one level further down the stack, in the sibling library CNA’s own EasyGL implementation is built on.
59.7 Testing without a GPU
Easy-gl’s suite has four hand-rolled, mock-loader binaries: the initialization, resource, context-lifecycle, and WebGL smoke suites. Their CMake target names all begin easy-gl- and end in -tests. The mechanism making them useful without any real GPU or display is worth describing directly: fake GLGetProcAddressFn loaders return hard-wired callbacks, literal strings such as "OpenGL ES 3.0", and literal enum values in place of driver responses. The smallest loaders provide the context-query handful needed by Device::initialize() and use a non-callable sentinel for other loaded core functions; the resource suite supplies a broader set of callable stubs for the operations it actually exercises. This lets context/capability detection and resource lifecycle assertions execute entirely off-GPU without accidentally issuing a real GL call.
The lifecycle suite drives context loss and restoration; the WebGL suite checks explicit classification, ES2/ES3 tiers, OES vertex-array gating, and exception-not-crash behavior for unavailable entry points. The desktop-shaped initialization and resource smoke binaries are not built or registered under Emscripten because their invented version strings cannot occur in a real WebGL host. This is an honest test-model boundary, not proof that the corresponding browser behavior was executed.
Nested build defaults are asymmetric. Easy-gl defaults both tests and examples ON. CNA’s web preset explicitly sets both EASYGL_BUILD_* options to OFF, but CNA’s root CMake and renderer-selection helper do not force either value. A direct GL-profile configuration that does not use that preset or pass its own overrides therefore inherits the four test binaries, and its SDL hello-triangle example can configure because CNA already supplies SDL. Meta-gl defaults all tests, GPU tests, examples, documentation, sanitizers, and debug options OFF when nested. The effective extra work consequently depends on the CNA configure entry point; the read-only source audit did not build the siblings to measure its wall-clock cost.
Meta-gl’s standalone tests add compile, mock-loader, release-contract, thread, desktop-tier, SONAME, export-symbol, and installed-package checks. A real EGL/Mesa smoke test is separately opt-in, and requesting GPU tests without ordinary tests is a configure-time error.
59.8 Two known, honestly stated limitations
Two gaps are worth knowing about directly rather than assuming closed.
First, Config::enable_debug_logging currently does nothing at all — no log_callback field yet exists on Config to actually receive the output, despite the flag existing to request it. Second, Query::result_u64() is implemented using the 32-bit glGetQueryObjectuiv internally, because the underlying meta-gl layer does not expose the real 64-bit query variant — meaning it is suitable for frame counts, but not for a high-resolution GPU timer query, despite its 64-bit-suggesting name. Both are stated plainly in the project’s own tracking rather than silently left for a reader to discover.
59.9 The public history was rewritten without changing the trees
On 2026-08-09, both libraries’ public histories were replayed to remove co-author trailers. The current easy-gl and meta-gl tree hashes are byte-identical to the content CNA had already accepted, but the old commit objects are unreachable from any branch. Archive tags were repointed while retaining annotations that name old targets, and the rewritten commits are unsigned even though the pre-rewrite objects had valid signatures.
For this book, the durable evidence is branch, tree hash, and audit date. The current public heads are easy-gl 0b46d35c and meta-gl 571d3a62; older SHAs from the integration campaign may exist only as dangling local objects and should not be offered as reader-resolvable citations. The rewrite changed provenance objects, not the implementation described in this chapter.