EasyGL renderer internals

CNA snapshot c1c316b9  ·  Development › Graphics internals  ·  source links pinned to c1c316b9

✓

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

ProfileContext requestedGLSLHard limits pinned by CNA
OpenGLES3 (EasyGL default)native GLES 3.0ES 3.00base-vertex by pointer rebase (glDrawElementsBaseVertex is ES 3.2)
OpenGL33desktop 3.3 core3.30native base-vertex draws; the only profile that does not rebase
WebGL2WebGL 2ES 3.00no 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:

  1. Return during context loss; apply the stencil primitive topology.
  2. 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.
  3. Stock path only: RequireDeclarationFitsStockProgramEXT checks 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 custom ShaderEffect owns its element-index attribute convention and skips it.
  4. Custom effect with several vertex streams: ConfigureMultiStreamAttributes, which throws InvalidOperationException unless every bound vertex buffer carries a declaration; then BindCustomEffectMatrices and the draw.
  5. Stock effect: SelectProgram(layoutStride, params), BindDrawParams (matrices and effect state), ConfigureDeclarationForStockProgramEXT, then the draw.
  6. The index format picks the 16- or 32-bit GL type and startIndex becomes a byte offset. DrawIndexedWithBaseVertexFallback folds 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 on OpenGL33.
  7. 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

  1. RendererSelection.cmake, RendererRegistry.cmake and easygl/CMakeLists.txt: separate public identity from one physical target and from the sibling GL dependencies.
  2. EasyGLRendererDescriptor.cpp and GlProfile.hpp: the per-identity factory and the current-thread profile rules.
  3. EasyGLRenderer.hpp: object ownership, the bound-target record, resource classes and member order before the large implementation.
  4. EasyGLRenderer.cpp: constructor, destructor and Present first, then target switching, texture recovery, DrawIndexedPrimitivesEx and the context lease; follow one operation at a time.
  5. GlStockShaderSources.hpp and GlPresentationSurfaceState.hpp: the stock programs and the presentation transform.
  6. examples/CMakeLists.txt: when the integration tests are really registered, then a specific scenario.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.