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 009d40f5; 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 five public identities: OPENGLES2, OPENGLES3, OPENGL33, WEBGL1 and WEBGL2. The five names do not mean five 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 stock shader corpus and the presentation transform now live in shared GL headers that the separate OPENGL4 renderer also uses.
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: five rows, EasyGL|GetDescriptorOpenGLES2 ... 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 OPENGLES2, OPENGLES3 and OPENGL33 under Emscripten and WEBGL1/WEBGL2 anywhere else with a FATAL_ERROR, and warns that OPENGLES2/OPENGLES3 are "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 five 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, forces single sampling on the ES 2.0 generation, 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 five profiles really change
| Profile | Context requested | GLSL | Hard limits pinned by CNA |
|---|---|---|---|
OpenGLES2 | native GLES 2.0 | ES 1.00 | 1 sample, 1 render target, no indexed colour masks, base-vertex by pointer rebase |
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 |
WebGL1 | WebGL 1 | ES 1.00 | as OpenGLES2 |
WebGL2 | WebGL 2 | ES 3.00 | no indexed colour masks, no compute, no indirect draw |
The predicates in GlProfile.hpp (UsesGlslEs100, IsDesktopCoreProfile, UsesEs2ApiGeneration, RequiresBaseVertexPointerRebase) are the single place these distinctions are made. The ES 2.0 generation pins its limits even if the driver hands back a newer context: the constructor does not query GL_MAX_SAMPLES or the draw-buffer limits there, because those enums are ES 3.0 and a generous driver's numbers must not be acted on. Elsewhere 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 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 five 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, which is now an alias of GlPresentationSurfaceState in GlPresentationSurfaceState.hpp (moved verbatim out of EasyGL by GL4-0010 so OPENGL4 applies the same transform). 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.
OnSurfaceChanged updates the snapshot but throws PlatformException for a different window identity. BindDefaultFramebuffer lazily rebuilds the multisample back-buffer FBO when the drawable extent or the back-buffer depth format changed. 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 MSAA FBO to the default framebuffer, calls EasyGLPlatformContext::SwapBuffers and rebinds the MSAA draw FBO for the next frame. 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 MSAA was resolved 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.
Stock shader corpus shared with OpenGL4
EasyGL never carried five shader corpora: each stock program is authored once in GLSL ES 3.00 and adapted at run time to #version 330 core, to GLSL ES 1.00, or left as ES 3.00. At this snapshot that one corpus lives in GlStockShaderSources.hpp, moved verbatim out of EasyGLRenderer.cpp by GL4-0008 so that OPENGL4 compiles the same text. 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 a contract with two binders, EasyGL's and OpenGL4's. A stock-effect fix now changes both renderers; build and test both, and read OpenGL4 internals before renaming anything there.
Custom ShaderEffect source is compiled in the context's own dialect: GLSL ES on the ES and WebGL profiles, desktop GLSL on OPENGL33 (the shader-effect example was corrected for this by GL4-0018). 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 outside the ES 2.0 generation (Task 924), and adds itself to the recovery registry. Under ES 2.0, which has no max-level parameter, a side registry (Es2RegisterTextureLevels) records declared levels so sampling can demote mip terms instead. GetData returns exact DXT blocks for compressed textures; uncompressed texture readback is not implemented in this class. The destructor drops the ES 2.0 registration before GL can reuse the name and 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, probe caches and the ES 2.0 level registry. 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 a background content upload against a frame and establishes the calling thread's context and dispatch. 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. "Background loads work" is not permission to draw or dispose 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 an engine-layer pass can keep a SpriteBatch's compiled effect alive past 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 OPENGL4 too), a custom ShaderEffect or a compiled effect; each takes a different branch of DrawIndexedPrimitivesEx. Compare OPENGLES2, OPENGLES3 and OPENGL33 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.
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 (OPENGLES2, 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 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 shared with OPENGL4.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 five 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: five GL profiles, one implementation, and the other GL-named renderers — What OPENGLES2, OPENGLES3, OPENGL33, WEBGL1 and WEBGL2 share in EasyGL, where they differ, how far evidence carries between them, and how OPENGL4 and PORTABLEGL differ.
- 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-BUG-255: EasyGL family: ClearColorAndStencil leaves the stencil write mask forced to all ones, so a later draw ignores StencilWriteMask — GraphicsDevice::Clear with Target and Stencil reaches EasyGLRenderer::ClearColorAndStencil, which forces the GL stencil mask open and never restores it, unlike the other EasyGL clears (REMED-GFX-237). A later StencilWrit
- CNA-GAP-029: EasyGL GPU timers read 32-bit nanosecond results and cannot detect GL disjoint events — EasyGLGpuTimerRenderer reads GL_TIME_ELAPSED as a 32-bit value because meta-gl exposes only glGetQueryObjectuiv, not a 64-bit query-object read, and it never queries GL_GPU_DISJOINT_EXT, so results saturate near 4.29 s a
- CNA-VGAP-055: Process exit with live EasyGL-family resources whose creating thread has already ended is untested; a binding recorded SIGABRT on an earlier CNA revision — cna-java recorded that a process exiting with a live vertex buffer whose creating thread had ended aborts on the EasyGL renderer identities; CNA's own process-exit teardown test runs on one thread, so this case has no ev
- 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
- OpenGL4 (shares the stock shader corpus) · 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