EasyGL renderer internals
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 c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Per-profile live validation remains open: native tests register only for an EasyGL default profile on non-Windows hosts, and the WebGL identities need a separate browser run.
modules/renderers/easygl/ implements three public identities: OPENGLES3, OPENGL33 and WEBGL2. The three names do not mean three renderers: one cna_renderer_easygl target and one EasyGLRenderer sit on the sibling easy-gl → meta-gl stack, and a runtime GlProfile decides context attributes, GLSL dialect and capability limits. "Does this bug affect one profile or all of them?" is therefore the first question for any GL bug. This page follows the family from CMake identity to context, draw, resources, context loss and teardown, and names what changed at this snapshot: the game thread holds the GL context for whole frames, resource work on any other thread takes the same context lease, and the back buffer is an offscreen framebuffer that reaches the window at Present.
From CMake identity to a GL context
CNA_GRAPHICS_RENDERER / CNA_GRAPHICS_RENDERERS
-> cmake/RendererSelection.cmake: platform partition, ../easy-gl check, CNA_RENDERER_EASYGL,
CNA_GL_PROFILE_<IDENTITY>, optional CNA_EASYGL_COMPILED_EFFECTS
-> modules/renderers/easygl/CMakeLists.txt: cna_renderer_easygl -> easy-gl (-> meta-gl)
-> cmake/RendererRegistry.cmake: three rows, EasyGL|GetDescriptorOpenGLES3 ... GetDescriptorWebGL2
-> EasyGLRendererDescriptor.cpp: DescriptorFor<Identity>()
-> CreateGraphicsRendererForProfile(args, ToGlProfile(identity))
-> EasyGLRenderer(surface, IPlatformGlContext&, ..., profile, depthFormat)
-> EasyGLPlatformContext: platform creates the context -> meta-gl loads dispatch -> easygl::Device
RendererSelection.cmake refuses OPENGLES3 and OPENGL33 under Emscripten and WEBGL2 anywhere else with a FATAL_ERROR, and warns that OPENGLES3 is "primarily tested on Linux" on other systems. easy-gl is a required sibling checkout at ../easy-gl, not a submodule; a missing checkout is a configure-time FATAL_ERROR whose message also names easy-gl's own ../meta-gl requirement, never a renderer runtime error. When several GL identities are selected the easy-gl subdirectory is added once; under Emscripten its exception model is forced to JS. Every GL identity defines CNA_RENDERER_EASYGL (the implementation identity older #ifdefs test) plus CNA_GL_PROFILE_<IDENTITY>. Sibling setup and branches are summarised in the GL family section of the user guide.
CNA_EASYGL_COMPILED_EFFECTS (default OFF, one switch for all three identities) configures MojoShader; easygl/CMakeLists.txt then adds a Python build step that embeds FNA3D's SpriteEffect.fxb through embed_effects.py into a generated Fna3dStockEffectBlobs.hpp, and links cna_renderer_mojoshader_effect. It does not define the ordinary stock-shader path. In a single-renderer build the CNA_GL_PROFILE_* definition still supplies kCompileTimeGlProfile in GlProfile.hpp, the default used by the profile-less CreateGraphicsRenderer; every registry row instead passes its own identity's profile at run time, and the compile-time default is no substitute for the selected descriptor. The header comment of EasyGLRendererDescriptor.cpp still describes a runtime profile as future work; the code beneath it is the current behaviour.
Every descriptor declares RendererWindowKind::OpenGL, needsWindow, needsVideoSubsystem, needsGlContext and AlwaysAvailable. Pre-construction adapter queries are deliberately conservative: before any context exists the GLES and WebGL profiles promise only SurfaceFormat::Color as a render-target format (IsGuaranteedGlesRenderTargetFormat; RGBA16 UNORM, for example, needs EXT_texture_norm16), while OPENGL33 registers no hook and uses the shared table. Real support is probed later on the live context. CreateGraphicsRendererForProfile throws PlatformNotSupportedException(OpenGlContext) when args.glContext is null, before any native GL state exists.
Construction order in EasyGLRenderer.cpp: the member initialisers create EasyGLPlatformContext (from the platform's IPlatformGlContext, the window id and RequestedGlContext(profile)), the thread-lease control and the surface state; the body publishes profile_ and ActiveGlProfile() before anything else, takes the proc-address loader (null is a PlatformException), initialises easygl::Device, detects the native wireframe API, narrows the XNA pixel-centre offset for contexts with few subpixel bits, enables program point size on desktop core, probes and logs limits, sets the swap interval, adds itself first to its easygl::ResourceRegistry and registers that with meta-gl, creates MSAA storage if needed, installs Emscripten context-loss callbacks, and only then calls RegisterForWindow. Registering earlier would leave a dangling window-registry entry if a later step threw, because a throwing constructor never runs its destructor.
What the three profiles really change
| Profile | Context requested | GLSL | Hard limits pinned by CNA |
|---|---|---|---|
OpenGLES3 (EasyGL default) | native GLES 3.0 | ES 3.00 | base-vertex by pointer rebase (glDrawElementsBaseVertex is ES 3.2) |
OpenGL33 | desktop 3.3 core | 3.30 | native base-vertex draws; the only profile that does not rebase |
WebGL2 | WebGL 2 | ES 3.00 | no indexed colour masks, no texture swizzle, no compute, no indirect draw |
The predicates in GlProfile.hpp (IsDesktopCoreProfile, HasTextureSwizzle, RequiresBaseVertexPointerRebase) are the single place these distinctions are made. The MRT limit is min(4, GL_MAX_DRAW_BUFFERS, GL_MAX_COLOR_ATTACHMENTS), and indexed colour masks need a non-WebGL context of ES 3.2 or desktop 3.0 and a loaded glColorMaski; WebGL 2 is an ES 3.0-shaped API but is not a general GLES 3 device. ActiveGlProfile() is thread_local because free shader and GL helpers share the current context's dialect; EnsureCallingThreadContext republishes it whenever this renderer makes its context current. A profile-specific fix belongs in a capability or profile test, not in a #ifdef that ignores runtime multi-renderer selection.
Capabilities come from the live context, not from the identity name. SupportsComputeShadersEXT is false on WebGL, needs ES 3.1 or desktop 4.3 otherwise; SupportsIndirectDrawEXT is false on WebGL and needs ES 3.1 or desktop 4.0 (a context between 4.0 and 4.3 can draw indirectly but not dispatch). Both are asked of the runtime context because an ES 3.0 request is routinely answered with ES 3.2 by Mesa. The identity's contract does not grow with the driver: OPENGL33 stays a 3.3 core profile, and only these two runtime queries report what a newer desktop context can do. The startup log line lists the probed MSAA ceiling, MRT count, indexed-mask support, anisotropy and the texture and render-target formats (norm16, RGB10_A2, half-float and float targets are probed, not asserted). SupportsShadowSamplingEXT and SupportsImageBasedLightingEXT return true on all three profiles; the capability matrix in the user guide records that only the built profile is tested. ClassifyRenderTargetFormatEXT and the creation route refuse an unsupported format instead of substituting Color. Treat a descriptor's availability and a successful build as weaker evidence than the capability query on a live context; the practical consequences are in Tutorial 102.
Presentation, resize and coordinate ownership
The renderer's surfaceState_ is EasyGLSurfaceState, an alias of GlPresentationSurfaceState in GlPresentationSurfaceState.hpp, a platform-neutral value object, so resize and display-scale behaviour can be tested without a native window or GL. It holds the platform's RendererSurfaceInfo snapshot, the virtual resolution and the presentation mode (native back buffer, fixed-height dynamic width, stretch, letterbox, overscan), and translates logical game coordinates, window coordinates and physical drawable pixels. Letterbox and overscan can make the default viewport smaller than the drawable, which is why GetDefaultViewportRect is overridden rather than taken from the logical size.
The back buffer is an offscreen framebuffer of the drawable size CNA reports, not the window's surface (cna-killer KF-6, OffscreenBackBuffer in EasyGLRenderer.cpp): drawing, Clear and GetBackBufferData use it, and Present blits it to the window, a resolve when it is multisampled and a copy when it is not. The window surface is not a dependable image of its own: on Wayland (Mesa's EGL) it lags CNA's size for a frame after a resize, a minimize or a fullscreen change, or when a loading thread binds it between frames. This is the rule on every native profile; WEBGL2 keeps drawing into framebuffer 0 unless multisampling is on, because CNA sizes the web canvas itself. GetBackBufferData reads the offscreen buffer at the back buffer's own size, so letterbox bars, stretch or a display scale of 2 do not change what it returns.
OnSurfaceChanged updates the snapshot but throws PlatformException for a different window identity. BindDefaultFramebuffer lazily rebuilds the offscreen back-buffer FBO when the drawable extent or the back-buffer depth format changed; a minimized window's 0×0 drawable leaves it absent until a real size returns (KF-2). CreateMsaaBuffers clamps to GL_MAX_SAMPLES, rebuilds colour and depth(-stencil) renderbuffers together for the selected DepthFormat and throws when the FBO is incomplete; ApplyMultiSampleCount implements a reset to a new count. Present returns at once during context loss; otherwise it blits the offscreen back buffer to the default framebuffer, calls EasyGLPlatformContext::SwapBuffers and rebinds the offscreen FBO for the next frame (recreating it when the drawable changed size or a lost context took it). SetSwapInterval records the request and forwards it; it does not prove the driver honoured it.
This is an immediate-context backend: draws issue GL calls rather than filling an explicit command buffer, and there is no queue/fence model like Vulkan's. For a black frame, check in order whether the logical viewport maps into the drawable, whether the correct framebuffer was bound, whether the offscreen back buffer was blitted (and resolved, with MSAA) and whether the platform actually swapped. A window resize does not recreate the renderer: the snapshot changes and dependent default-framebuffer storage is renewed on use.
Indexed draw path inside the backend
The neutral indexed draw trace reaches EasyGLRenderer::DrawIndexedPrimitivesEx with backend buffers, matrices, primitive type and count and GpuDrawParams. In order:
- Return during context loss; apply the stencil primitive topology.
- Compiled XNA effect (only when built): collect and validate the effect's streams, rebase base vertex where the profile needs it, bind a dedicated compiled-effect VAO and draw. This branch runs before the stock declaration guard because a compiled effect's layout is arbitrary.
- Stock path only:
RequireDeclarationFitsStockProgramEXTchecks the declaration against the inputs of the stock program the effect state selects before the VAO is touched (REMED-GFX-218): it refuses only a consumed element whose format cannot be converted, an input the declaration omits stays unbound, and the combined stride merely picks the dual-UV PBR variants. A customShaderEffectowns its element-index attribute convention and skips it. - Custom effect with several vertex streams:
ConfigureMultiStreamAttributes, which throwsInvalidOperationExceptionunless every bound vertex buffer carries a declaration; thenBindCustomEffectMatricesand the draw. - Stock effect:
SelectProgram(layoutStride, params),BindDrawParams(matrices and effect state),ConfigureDeclarationForStockProgramEXT, then the draw. - The index format picks the 16- or 32-bit GL type and
startIndexbecomes a byte offset.DrawIndexedWithBaseVertexFallbackfolds a negative base vertex into a rewritten index slice, shifts enabled per-vertex attribute pointers by the base and back on rebasing profiles, and uses native base-vertex draws onOpenGL33. - Restore the single-stream layout and any declaration layout the draw changed.
Visible geometry corruption can therefore come from CNA's public range checks, from declaration-to-program selection, VAO configuration, the index byte offset or the pointer-rebase fallback; changing only a shader may be the wrong layer. State mapping is explicit too: ApplyBlendState, ApplyDepthStencilState, ApplyRasterizerState, ApplySamplerState and the viewport/scissor functions translate neutral ordinals into GL state (graphics state). Clear clears colour only (REMED-GFX-142: a bare ClearOptions::Target no longer wipes depth; combined clears have their own entry points), temporarily disables the scissor test and forces all colour write masks when a restricted mask is active, and restores both. A state change that leaks into the next draw is a backend contract regression even when the immediate draw looks right.
One stock shader corpus for three profiles
EasyGL never carried one shader corpus per identity: each stock program is authored once in GLSL ES 3.00 and adapted at run time to #version 330 core for OPENGL33, or left as ES 3.00. That one corpus lives beside the renderer in GlStockShaderSources.hpp (namespace CNA::Internal::Renderers::GlStockShaders), which calls itself the most-corrected statement of XNA's stock-effect semantics in CNA. The header provides the program builders (SpriteSource, Colored3DSource, Textured3DSource, ColoredTextured3DSource, Lit3DSource, Lit3DVertexLitSource, DualTextured3DSource, DualTexturedColored3DSource, EnvMapped3DSource, SkinnedSource, SkinnedVertexLitSource, PbrSource, PbrSkinnedSource), AdaptStockVertexShaderForOpenGL, and the shared GLSL declarations for render-target row order (uRtFlipV/uRtFlipVHi), the sRGB transfer, the skinned normal, instancing, shadow reception, punctual lights and image-based lighting.
The uniform and attribute names in that header are the contract EasyGL's binders (BindDrawParams and the declaration helpers) rely on; the header says they must not change without them. A rename belongs in the same change as the binder that reads it, and a stock-effect fix reaches all three profiles at once: build and test at least one ES profile and OPENGL33 before trusting it.
Custom ShaderEffect source is compiled in the context's own dialect: GLSL ES on the ES and WebGL profiles, desktop GLSL on OPENGL33. Compiled XNA effects go through MojoShader's OpenGL adapter in EasyGLCompiledEffect.cpp. User-side dialect rules are in shader effects: renderer availability.
Texture and render-target lifetime
EasyGLTextureRenderer owns an easygl::Texture, format, extent and mip metadata, optionally shared CPU pixels, and the exact DXT block stream of every declared level. Its constructor creates GL storage, uploads level 0, allocates the declared higher levels without inventing content (REMED-GFX-175, otherwise a mipMap=true texture sampled black), clamps GL_TEXTURE_MAX_LEVEL to the real count (Task 924), and adds itself to the recovery registry. GetData returns exact DXT blocks for compressed textures; uncompressed texture readback is not implemented in this class. The destructor removes the object from the recovery registry before its GL wrapper dies. That registry is for context-loss recovery; it is not GraphicsDevice's public ownership registry (see textures and render targets).
RenderTarget2D wrapper -> EasyGLRenderTargetRenderer -> FBO + colour texture + optional depth/MSAA storage SetRenderTarget2D / SetRenderTargets -> shared EasyGLBoundTargetEXT record (renderer-owned) switch away -> UnbindAsRenderTarget -> MSAA colour resolve -> mip generation sample/readback -> resolved colour texture target destructor -> DetachFromBindingEXT() first, no finalisation
EasyGLBoundTargetEXT is the renderer's heap-owned, non-owning record of the active single target, cube face or MRT set (mrt, mrtCount, mrtFramebuffer, depth format and extent); each target holds a weak_ptr to it. That shape prevents a use-after-free when a bound target is destroyed without a prior Dispose and lets a target outlive the renderer without calling into it (REMED-GFX-168). A dying target clears only its own slot and deliberately does not resolve into storage that is about to be freed; other live MRT slots stay finalisable. On a normal switch, SetRenderTarget2D finalises the MRT set (FinalizeCurrentMRT) and the outgoing target before binding the next; switching cube faces finalises the old face. SetRenderTargets refuses more targets than the profile allows, a binding that is not an EasyGL target, mismatched dimensions or applied sample counts, the same subresource in two slots, distinct per-slot colour write masks without indexed masks, and pending native GL errors before MRT setup. A render-target fix must preserve both normal switch semantics and safe destruction in either order.
Context loss, background loading and shutdown
easygl::ResourceRegistry tracks the renderer (added first, so renderer-owned programs, samplers and MojoShader objects are forgotten before child resources and recreated before child callbacks bind against the new context) and every recoverable child. On loss, release_gl_handle_only zeroes native handles without calling into the lost context: stock programs, default textures, samplers, sampler and viewport shadows, MSAA and MRT FBOs, scratch index buffers and probe caches. On restore, recreate_gl_resource recreates compiled effects; everything else is recreated lazily on first use, from retained CPU state where a resource needs content. DebugSimulateContextLoss on desktop runs metagl::NotifyContextLost(), platformContext_->Recreate(), reloads the loader and meta-gl's function table directly (easygl::Device::initialize is one-shot), restores point size, wireframe and depth bias, then NotifyContextRestored(). On WebGL the browser's loss and restore callbacks are asynchronous. Do not conflate this with a full GraphicsDevice replacement; objects must stay meaningful across a GL context generation.
AcquireThreadContextLeaseEXT serialises work from other threads against the game's frames and establishes the calling thread's context and dispatch. The game thread takes it for the whole of Initialize, BeginRun, every Tick (Update and Draw) and EndRun (Game::AcquireGameThreadContextLease in Game.cpp, cna-killer KF-16, so GL work issued from Update reaches the device) and lets go between frames. Constructing a texture, buffer or render target, SetData, GetData and Dispose take the same lease on whatever thread they run on (GraphicsResource::LeaseRendererContext, KF-1; ContentReader holds it for a whole asset), so a loading thread works in the gaps between frames while the game keeps drawing, XNA's loading-screen pattern. The mutual exclusion is needed on Emscripten too: with an offscreen framebuffer every GL call is proxied to one browser-thread context, so an upload's bind/upload pair could otherwise be split by the frame's binds (the source records background-loaded textures rendering black in a sample before this change). Only the binding hand-over is platform-specific. EnsureCallingThreadContext republishes the profile, makes the platform context current and initialises per-thread meta-gl dispatch. The one pattern that cannot work is the game thread waiting, inside LoadContent, Update or Draw, for another thread that is creating graphics resources: the game thread still holds the context, so the two wait for each other (CNA's docs/opengl33-renderer.md notes that FNA and MonoGame deadlock the same way). The lease is EasyGL's; no other renderer family overrides it, and drawing stays on the game thread. "Background loads work" is not permission to draw concurrently; see the thread and callback map.
Destruction: ~EasyGLRenderer unregisters the window, removes itself from the recovery registry and, with compiled effects, releases every registered compiled effect through the same path context loss uses while the context is still current, because a SpriteBatch or another effect owner can outlive the renderer. platformContext_ is the first-declared member and therefore dies last, after every GL member. Reordering those members, or moving effect release after context destruction, risks native GL calls through dead dispatch.
How to change and validate this family
For a shader or layout bug, decide first whether it affects stock programs (and therefore all three profiles), a custom ShaderEffect or a compiled effect; each takes a different branch of DrawIndexedPrimitivesEx. Compare OPENGLES3 and OPENGL33 (and, in a browser, WEBGL2) where the feature exists, because shader headers, base-vertex handling and API limits differ. For a format feature, update classification and the real upload, attachment and readback paths together; a capability claim without storage support is unsafe. For a resize or render-target bug, exercise a normal bind, draw, unbind cycle, disposal while bound, a target outliving the renderer, and context-loss restoration. For a concurrency bug, include a background content load and inspect the context lease rather than relying on single-threaded screenshots, and never make the frame wait for that load.
examples/CMakeLists.txt returns early, with a status message, when the configuration has no SDL3::SDL3 target (the suite links SDL3 directly). Otherwise it registers tests only when CNA_BUILD_EXAMPLES, CNA_BUILD_TESTS, a non-Emscripten, non-Windows host, and an EasyGL profile (OPENGLES3 or OPENGL33) as the build's default renderer (_cna_default_renderer_identity) are all true. VKPAR-0016 made it the default rather than set membership after a multi-renderer build produced hundreds of EasyGL test executables that ran Vulkan while asserting EasyGL. Compiling EasyGL as a secondary renderer therefore registers none of them. Useful named cases include EasyGL_RenderTargetPreserveAcrossResize, EasyGL_RenderTarget2D_MsaaResolve, EasyGL_BoundResourceDispose, EasyGL_Gltf_ContextLoss, the threading and back-buffer cases EasyGL_WorkerThreadResources, EasyGL_UpdateTimeGlWork and EasyGL_WorkerResizeBackBuffer, the EasyGL_Parity_* fixtures, and EasyGL_XnaLineCoverage, the family's gate on two scenes of the XNA oracle corpus (how renderers are verified). GoogleTest sources under tests/ join the unit aggregate only when a GL identity is in the renderer set. Check registration with ctest --test-dir <build> -N -R EasyGL before ctest --test-dir <build> -R EasyGL --output-on-failure. A WebGL identity needs separate browser/Emscripten validation that native EasyGL tests cannot provide. The test wiring and source paths were read for this page; no GL or display test was executed.
Source reading order
RendererSelection.cmake,RendererRegistry.cmakeandeasygl/CMakeLists.txt: separate public identity from one physical target and from the sibling GL dependencies.EasyGLRendererDescriptor.cppandGlProfile.hpp: the per-identity factory and the current-thread profile rules.EasyGLRenderer.hpp: object ownership, the bound-target record, resource classes and member order before the large implementation.EasyGLRenderer.cpp: constructor, destructor andPresentfirst, then target switching, texture recovery,DrawIndexedPrimitivesExand the context lease; follow one operation at a time.GlStockShaderSources.hppandGlPresentationSurfaceState.hpp: the stock programs and the presentation transform.examples/CMakeLists.txt: when the integration tests are really registered, then a specific scenario.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- easy-gl and meta-gl: the two-library GL stack beneath the EasyGL family — How meta-gl and easy-gl split loading, typed calls, ownership and failure beneath CNA's three GL identities: revisions CNA needs, feature gating, per-thread state, context loss, tests and build inheritance.
- EasyGL state, clears, targets, queries and buffers: current semantics — What the EasyGL GL-profile renderer does at this snapshot for wireframe, occlusion counts, colour masks, clears, two-sided stencil, fog, base vertex, render targets, context-loss policy and viewports.
- EasyGL: three GL profiles, one implementation, and how far evidence carries — What OPENGLES3, OPENGL33 and WEBGL2 share in EasyGL, where they differ, how far evidence carries between them, and how to choose among them.
- Four shader routes: stock semantics, D3D9 stock sources, compiled effects and ShaderEffect — How CNA answers XNA's .fx: renderer-owned stock effects, DIRECTX9's recompiled Microsoft sources, compiled Effect Framework bytecode on qualified renderers, and the renderer-specific ShaderEffect contract.
- The CNA ecosystem: siblings, references and consumers — Which repositories CNA builds against, audits against and is consumed by, when CMake needs each sibling, where each sibling draws its boundary, and which revision each statement refers to.
- The XNA stock effects: exact semantics, worked uses and verification history — BasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect and SkinnedEffect in CNA: defaults, formulas, ordering traps, per-vertex versus per-pixel lighting, worked uses and the defect patterns behind the current code.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-225: Two EasyGL comments still describe runtime GL profiles (phase P11) as future work — EasyGLRendererDescriptor.cpp's file header and the CNA_RENDERER_SHARED_EASYGL comment in RendererCombinations.cmake say a runtime GL profile is still to come, although both files implement or record it.
- CNA-BUG-229: docs/graphics-renderer-feature-matrix.md calls itself up to date but still says EasyGL's ReferenceStencil gap (Task 872) is open and lists its test as a known failure, contradicting its own table row — The matrix's ReferenceStencil row says EasyGL passes (remeasured 2026-09-09), but its failure baseline and Vulkan bottom line still call the gap open, although REMED-GFX-236 fixed EasyGL on 2026-08-29.
- CNA-VGAP-057: EasyGL: a full-backbuffer SpriteBatch draw before a frame's first 3D draw is listed as open (Task 933) but has never been reproduced, and the reported scene is untested — docs/migration-guide.md still lists Task 933 as a currently open EasyGL caveat. Four isolated repros and a dedicated fixture pass, so the original scene (bound target, file texture, real frame loop) is untested and the r
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Internals
- Renderer family map · Indexed draw trace · Textures and render targets · Renderer selection internals
- Maintainer workflow
- Fix a renderer bug · Thread and callback map · Architectural invariants
- Tests and validation
- Test architecture · What to test after changing X
- Reference
- Test target index · CMake option index