Skip to content
The CNA BibleCNA 0.1.0-alpha.1 Edition

Chapter 24 BGFX, Magnum, LLGL, and Diligent

BGFX integrates the third-party bkaradzic/bgfx rendering library (fetched directly via CMake’s FetchContent, Chapter 19) behind the same contract every other renderer implements, using bgfx’s own native API — window and platform initialization, texture creation, sprite draws, frame submission — rather than routing through SDL_Renderer.

The chapter compares four wrappers whose underlying API can be chosen below CNA. That second selection axis is part of every bug report: BGFX on Vulkan and BGFX on OpenGL are one CNA family but not the same native execution path.

Identity Backend resolution Shader strategy RT / MRT / query
BGFX runtime library selection, CNA override available bgfx shader assets yes / yes / device bit
MAGNUM compile-time desktop GL path raw GLSL compiled at runtime yes / runtime maximum / yes
LLGL runtime module selection, Linux currently OpenGL generated SPIR-V and verbatim GLSL yes / yes / yes
DILIGENT runtime ordered device fallback one HLSL source cross-compiled at runtime yes / yes / device features

The abstraction boundary also owns this renderer’s default attachments. CNA passes bgfx a window, dimensions, renderer selection, and reset flags, but neither the requested SurfaceFormat nor DepthFormat. The exact platform backbuffer and depth/stencil format are therefore bgfx choices which CNA neither selects nor queries, even though it submits depth/stencil clear flags. A shared SDL fullscreen call can still resize or restyle the window, after which EnsureViewState() notices the dimensions and calls bgfx::reset; the empty presentation-format hook does not prevent that window route.

24.1 Clear owns an ordered, aspect-selective view

BGFX exposes clearing through view state rather than an issue-ordered clear command, so CNA now makes each public clear its own view. RecordClear() allocates the next per-frame view, binds the current backbuffer/2D/cube/MRT destination, installs exactly the requested BGFX_CLEAR_COLOR, DEPTH, and/or STENCIL mask and values, then touches the view so a clear-only cycle is observable. A later draw or clear receives another view; bgfx::setViewOrder() remaps those views into public call order. Clear therefore ignores a custom viewport and covers the full target, while draw–clear, clear–draw, repeated clear, target-switch, cube-face, and MRT sequences retain their chronology. The shared clear-options and ordered-clear suites discriminate masks and order rather than merely proving that a final colour appeared; see §19.4.2.

BGFX’s explicit-view model implements target usage without a native load-policy object. The shared layer maps only DiscardContents to a deterministic black/max-depth/zero-stencil clear on every bind; that request becomes its own ordered clear view. Preserve and Platform issue no bind clear, so the attachment survives by construction under CLEAR_NONE. The same rule now reaches 2D targets and cube faces, including per-face multisample colour; depth/stencil follows it too. The cube-usage, per-face-MSAA, and depth/stencil-usage pixel suites distinguish Discard, Preserve, and Platform rather than relying on the older no-crash usage smoke test. The full comparison is §19.4.1.

24.2 Recorded BGFX campaign

The recorded BGFX validation campaign reported 4,375 passes in a 4,377-case configured population, with two hardware skips; its renderer-specific ctest population passed 103 of 105. These are campaign populations, not a universal current test count; see Chapter 69. The two remaining outcomes were classified as environment ceilings: one render-target MSAA-resolve test fails because this project’s own headless Xvfb display has no DRI3 support to resolve against, and one render-target-cube depth-format test (a Depth24Stencil8-attached cube face producing no color output) has been investigated three times without finding a root cause and remains deferred. The row-by-row triage recorded 38 gaps, closed 37 in its final batch, and left the depth-bias item described below; that item was subsequently fixed. These are historical campaign results, not present-day population counts.

24.3 Choosing bgfx’s underlying native renderer

Unlike every other renderer in this book, BGFX is itself an abstraction over several further, distinct native graphics APIs — OpenGL, OpenGL ES, Vulkan, Direct3D 11/12, and Metal — and CNA exposes a real, previously-undocumented mechanism to choose among them: the CNA_BGFX_RENDERER environment variable, resolved through three small, dedicated functions in BgfxRendererSelection.cpp. GetDefaultRendererType() picks bgfx::RendererType::OpenGL on Linux specifically and Count (bgfx’s own “let the library auto-detect” sentinel) everywhere else; ParseRendererTypeOverride() case-insensitively accepts AUTO, OPENGL, OPENGLES, VULKAN, METAL, DIRECT3D11 / D3D11, DIRECT3D12 / D3D12, and NOOP (bgfx’s own headless no-op renderer, useful for pure logic testing with no GPU work issued at all), throwing a std::runtime_error naming every valid value for anything else; ResolveRendererType() ties the two together, falling back to the platform default whenever the environment variable is unset or empty.

24.3.1 Routing a test around a BGFX/OpenGL limitation

This mechanism exists for a concrete, real reason, not as a speculative escape hatch: modules/renderers/bgfx/examples/bgfx_rendertarget2d_msaa_test.cpp’s own header comment documents a genuine investigation. In this project’s own Linux development sandbox, bgfx’s default OpenGL renderer negotiates only a legacy OpenGL 2.1 context — confirmed via glxinfo that the underlying Mesa/RadeonSI driver actually supports OpenGL 4.6 with full hardware acceleration, so this is bgfx itself requesting an old context by default, not a driver ceiling — and MSAA-flagged framebuffer-attached textures do not actually resolve with real sub-pixel blending under that legacy GL 2.1 path, despite bgfx::getCaps() itself reporting BGFX_CAPS_FORMAT_TEXTURE_FRAMEBUFFER_MSAA support for the exact format requested. The investigation ruled out a CNA-side defect specifically: forcing bgfx onto its Vulkan renderer instead (CNA_BGFX_RENDERER=VULKAN, routing through the identical GPU) made both of the test’s own checks pass cleanly, including a genuine intermediate (blended) pixel value along the test triangle’s diagonal edge, with zero code changes on either side — proof the C++ wiring itself is correct and renderer-agnostic, and the gap is specifically in bgfx’s own default legacy-GL-context resolve path. The real ctest registration (modules/renderers/bgfx/examples/CMakeLists.txt) reflects this finding directly rather than leaving it as a comment nobody acts on:

1 cna_register_renderer_test(NAME Bgfx_RenderTarget2D_MsaaResolve
2 COMMAND cna_test_bgfx_rendertarget2d_msaa
3 TIMEOUT 30
4 ENVIRONMENT "SDL_VIDEODRIVER=x11;DISPLAY=${CNA_TEST_DISPLAY};CNA_BGFX_RENDERER=VULKAN")

This is worth reading alongside, not instead of, this chapter’s own “Test baseline” section above and docs/graphics-renderer-feature-matrix.md’s repeatedly-reconfirmed count (spanning multiple independently-dated task closures) that Bgfx_RenderTarget2D_MsaaResolve remains one of exactly two still-failing tests in that project’s own tracked baseline, attributed there to a related but distinctly-framed cause: “this sandbox’s Xvfb has no DRI3 support.” The two explanations are not actually in tension — DRI3 is specifically what a windowed OpenGL context needs to negotiate real hardware acceleration under X11 indirect rendering, so its absence is a plausible reason bgfx’s OpenGL path falls back to a legacy context in the first place, while Vulkan’s own presentation integration does not depend on the same DRI3/GLX negotiation at all. What this book cannot independently confirm is whether the specific sandbox behind that repeatedly-cited count genuinely has real Vulkan/GPU access available to the CNA_BGFX_RENDERER=VULKAN override the way the test’s own comment describes, or whether that particular environment lacks real GPU access down either path — this book’s own screenshot-feasibility investigation (Chapter 12) independently confirmed no GPU passthrough at all in this book-writing session’s own container, a genuinely different environment from CNA’s own separate development sandbox. Both real, sourced explanations are given here rather than silently picking one, consistent with this book’s own methodology of not resolving an ambiguity the underlying sources themselves leave open.

24.4 DepthBias: an upstream BGFX limit

RasterizerState.DepthBias is the one feature in this chapter whose fix required working around a genuine absence in bgfx itself, confirmed by reading bgfx’s own vendored source directly: its high-level state API has no depth-bias mechanism of any kind — no corresponding flag in its own headers, and no glPolygonOffset call anywhere in its vendored OpenGL renderer. The fix emulates the feature entirely at the CNA level, via a per-draw vertex-shader Z-offset (a new u_depthBias uniform threaded through every 3D vertex shader). SlopeScaleDepthBias, by contrast, remains deliberately unimplemented — a project-owner decision, not an oversight — because a true per-fragment, screen-space-slope computation would force every 3D shader off the GPU’s early-Z optimization path, a cost judged not worth paying for this specific, rarely-used parameter.

24.5 Render-target readback crashes: one root cause, five fixes

Five of six render-target-related glReadPixels / Xvfb crashes were traced to a single, shared root cause: bgfx processes its internal views in ascending ID order every frame, which means any render-target view is always the last one processed — and therefore still GL-bound at the exact moment glReadPixels() fires for a screenshot. The fix adds a dedicated “flush” view, touched immediately before every screenshot specifically to force the correct view to be current first. Only the render-target-cube depth-format crash mentioned above has a different, still-unidentified root cause and was not resolved by this fix.

24.6 View IDs: a free-list-backed pool, not a hardcoded slot

The screenshot flush route follows from bgfx’s numbered views, an ordering mechanism distinct from render-target binding. Every bgfx::submit() call targets a specific numeric view id, and bgfx processes views in ascending id order each frame. The original renderer assigned view 1 to every render target. Detail::AllocateRtViewId() now allocates from a free-list-backed pool:

1 static bgfx::ViewId AllocateRtViewId()
2 {
3 static constexpr bgfx::ViewId kMaxBgfxViews = kBackbufferFlushViewId;
4 auto& pool = RtViewIdFreeList();
5 if (!pool.empty()) {
6 const bgfx::ViewId id = pool.back();
7 pool.pop_back();
8 return id;
9 }
10 static bgfx::ViewId nextId = 1; // 0 is permanently reserved for the backbuffer
11 if (nextId >= kMaxBgfxViews)
12 throw std::runtime_error("Bgfx: exhausted view ids (more concurrently-live "
13 "render targets than bgfx supports views)");
14 return nextId++;
15 }

View 0 is reserved for the backbuffer. kBackbufferFlushViewId is the highest reserved view, so bgfx processes it last; the allocator’s exclusive upper bound keeps it out of the render-target pool. Freed IDs return to RtViewIdFreeList(), preventing normal creation/destruction churn from exhausting bgfx’s BGFX_CONFIG_MAX_VIEWS ceiling (256 by default). Simultaneously live targets can still exhaust the bounded pool, in which case creation throws std::runtime_error.

24.7 Sampling a render target: a wrong-handle-type cast, since fixed

Both BgfxSpriteBatchRenderer::Draw and the environment-mapping branch once cast any ITextureRenderer to the ordinary-texture concrete class. Render targets use sibling classes whose first member is a framebuffer handle. Because bgfx represents framebuffer and texture handles with the same uint16_t idx layout, the cast compiled and usually did not crash; it used a framebuffer-pool index as a texture-pool index and could sample an unrelated texture.

The current SpriteBatch route obtains dimensions through virtual GetWidth() and GetHeight(). Environment mapping resolves cube sampling through IBgfxCubeSamplable with a checked cast. Focused 2D and cube render-target tests cover the non-crashing type-confusion that ordinary smoke tests missed.

24.8 Corrected silent-black-mesh defect

One bug is worth naming for how invisible it was until specifically tested: this renderer’s graphics-renderer class never overrode the effect-aware indexed-draw entry point at all. Any indexed draw bound to an Effect whose vertex format lacked a Color attribute — which describes essentially any Model loaded through ContentManager (Chapter 38) — silently fell back to the base class’s default implementation, discarding the entire GpuDrawParams struct and reading an unbound color attribute (which GL defaults to black). The practical, visible consequence: any loaded 3D model rendered as a solid black silhouette on this renderer, regardless of its real diffuse color, texture, or lighting — fixed by adding a real override.

24.8.1 Recognizing the trigger condition

The black-mesh bug’s real trigger condition — a vertex format with no Color attribute, which is essentially every Model loaded from content rather than hand-authored with vertex colors baked in — is checkable directly against a real VertexDeclaration, grounded in VertexElement’s real accessor:

1 bool HasColorAttribute(const VertexDeclaration& decl)
2 {
3 for (const VertexElement& element : decl.GetVertexElements()) {
4 if (element.getVertexElementUsageProperty() == VertexElementUsage::Color) {
5 return true;
6 }
7 }
8 return false;
9 }

Before this bug’s fix, any effect-aware indexed draw where HasColorAttribute would have returned false was exactly the condition that silently rendered solid black on this renderer — which, per the finding above, covers essentially every ordinary Model draw, since a typical imported mesh carries position/normal/texture-coordinate data but no per-vertex color. This is precisely why the bug was invisible for so long despite being this common: nothing about it depended on an unusual asset or an edge-case vertex layout, only on whether anyone had specifically pixel-tested a textured, unlit 3D model on this one renderer.

24.9 OcclusionQuery: a real implementation whose correctness cannot be fully proven here

OcclusionQuery was previously a pure no-op on this renderer; the fix wires the query handle into bgfx’s own dedicated submit-with-occlusion-query overload, across all twelve 3D-draw submission call sites — no separate correlation/tagging machinery was needed the way Vulkan’s fix required, since bgfx submits synchronously. Two honestly-documented caveats remain: first, pixel/query correctness genuinely cannot be established in this project’s own sandbox, because sabotaging the fix back to a pure no-op produces identical IsComplete() / PixelCount() output to the fixed version — the sandbox’s own software Mesa driver returns a non-null result even for a query that was never submitted anywhere at all, a real ceiling of the software renderer itself, not a CNA defect. Second, bgfx’s own official usage example attaches an occlusion measurement to a separate, dedicated view specifically so the query is not polluted by other geometry sharing the same depth buffer; CNA’s current fix instead shares whichever view the game’s own 3D draw already targets — a real, acknowledged architecture gap against true scene-depth query correctness, not yet attempted.

24.10 Instancing: exact transport, with a native-profile shader fault

GraphicsDevice::DrawInstancedPrimitives() reaches a real renderer implementation — BgfxRenderer::DrawInstancedPrimitivesEx() is a substantial implementation, not a stub. There is no standalone modules/renderers/bgfx/examples/*instanced*.cpp binary, but concluding “untested” from that filename search would now be wrong. The shared graphics suite contains BGFX-specific pixel, cache-cardinality, submission-count, exact native-range, transient-allocation, wireframe, and InstanceFrequency cases. These prove considerably more than a no-throw smoke test while also exposing a profile-dependent defect described below.

The shared transport is no longer the old pointer-only bridge. A game marks a bound VertexBuffer as per-instance data by giving its VertexBufferBinding an InstanceFrequency greater than zero when calling SetVertexBuffers(). GraphicsDevice composes every semantically contributing binding into the same immutable GpuDrawParams::vertexStreams array used by ordinary draws, preserving slot, renderer pointer, declaration stride, vertex offset, frequency, and element count by value. The classic BGFX-supported shape is exactly one stream of each input rate:

1 FillVertexStreamBindings(p, /*foldedOffset=*/0,
2 /*allowLegacyEmptyDeclarationFallback=*/false);
3 ValidateVertexStreamRanges(p, baseVertex + minVertexIndex, numVertices,
4 "numVertices", std::to_string(numVertices));
5 ValidateInstanceStreamRanges(p, instanceCount);
6 ValidateVertexStreamCapability(p);

BGFX reads the lowest-slot positive-frequency entry as the instance stream and explicitly rejects more than one per-vertex or more than one per-instance stream. This is why its MultiStreamVertexInput capability remains false: a declaration cannot be split across two geometry buffers and two instance buffers cannot contribute independently. The ordinary one-plus-one instancing shape does not require that capability and remains valid.

Both binding offsets are now real. The geometry stream’s VertexOffset is added to baseVertex through bgfx::setVertexBuffer’s start-vertex term, while the instance stream begins copying at its own VertexOffset. BGFX exposes no native instance divisor in setInstanceDataBuffer; CNA therefore expands frequency grouping when filling the transient instance buffer: destination instance i copies source record VertexOffset+i/InstanceFrequency. Frequency one stays a single bulk copy. Shared validation first proves that every geometry stream covers the declared vertex window and that each instance stream contains 1+(n1)/f source records, so an undersized binding throws before native submission instead of reading uninitialized transient memory. Chapter 19, §19.3.2, compares this transport and its proof boundary across every renderer.

Two silent exits remain, but their current mechanisms are narrower than the former pointer-only account. If no semantically contributing binding has a positive InstanceFrequency, FirstInstanceStream(params) returns null and the renderer draws nothing. A transient-capacity shortage reported by bgfx::getAvailInstanceDataBuffer() also skips the draw. Invalid vertex or instance ranges no longer share that outcome: they throw during the validation described above. A game whose large instanced batch disappears on BGFX should therefore check that one binding really is per-instance and then consider transient instance-buffer pressure; malformed ranges should already have produced a diagnostic.

The remaining correctness fault is in the checked-in instancing shader, not this transport. vs_instanced3d.sc constructs the world matrix as mat4(i_data0, i_data1, i_data2, i_data3). BGFX’s GLSL profile treats those arguments as columns, but its HLSL/SPIR-V/Metal/WGSL profiles interpret the generated matrix constructor differently; BGFX supplies mtxFromCols() precisely to normalize this cross-profile difference. The dedicated test proves per-instance transforms on OpenGL/OpenGL ES and pins their failure on Vulkan. Thus “working” is justified only for the verified GLSL native profile today. Exact ranges, frequency expansion, and one-submit behavior remain proven independently of that shader-orientation bug; changing the source to mtxFromCols() and regenerating bgfx_shaders.hpp is the distinct remaining repair.

One further real detail is worth stating precisely because it is easy to assume otherwise: the draw call’s own world matrix parameter is explicitly unused on this path (a commented-out parameter name in the real signature, const Matrix& /*world*/), because per-instance world transforms are expected to arrive already baked into each instance’s own per-instance vertex data, not supplied once as a shared uniform the way a single, non-instanced draw’s World property works — only view and projection are combined into the one vp uniform every instance in the batch shares.

24.11 Texture3D and TextureCube readback: copy first, then read

The shared renderer interfaces once left Texture3D and TextureCube GetData() as silent no-ops. The current copy-before-read route avoids placing a readback-only usage flag on ordinary sampled textures. BGFX documents BGFX_TEXTURE_READ_BACK as incompatible with a texture that is also a normal GPU resource, so marking the game texture itself readback-capable would undermine the very texture that an effect needs to sample.

Instead, each GetData() call creates a short-lived transfer texture exactly matching the requested region. For a cube source it is a 2D RGBA8 destination; bgfx::blit() selects the requested cube face through the source Z coordinate. For a volume source it is a matching 3D destination, so the requested x,y,z,w,h,depth sub-volume remains explicit on both sides of the copy. A temporary destination is marked for a blit destination and for readback; the ordinary source texture keeps its normal sampled form.

This is not synchronous memory access. bgfx::readTexture() returns the frame at which its bytes become available. The shared AdvanceFramesUntil() helper advances up to four BGFX frames before the transfer texture is destroyed, so the caller’s buffer is not handed back before the asynchronous copy completes. If a selected BGFX renderer cannot create the temporary transfer texture, CNA now logs the missing BGFX_CAPS_TEXTURE_BLIT/READ_BACK capability instead of reverting to the old indistinguishable no-op. The project’s Xvfb/llvmpipe OpenGL environment was checked to support both capabilities before this path was adopted.

The same change also finally made mip allocation testable: mipMap is now passed through to BGFX’s createTexture3D and createTextureCube calls instead of being silently hardcoded false. Four already-renderer-agnostic programs are registered for BGFX rather than being reimplemented as cosmetic duplicates: a distinct-slice volume round trip, a non-origin cube partial rectangle, a three-level volume mip round trip, and a per-face cube mip round trip. Together they distinguish a correct transfer from reading level zero, the wrong cube face, or a CPU buffer left untouched by the former interface default.

24.12 Viewport uses ordered views; scissor remains per draw

Both public setters first become stored BGFX-side values, but that similarity ends at submission. An enabled, nonzero scissor is reissued through bgfx::setScissor() before every 3D submit and every SpriteBatch submit, because bgfx scissor is per draw and otherwise resets to “none.” The Boolean from RasterizerState is retained independently from the rectangle. bgfx_scissor_test proves both halves, then disables the test again to exclude a one-way/stuck-state implementation.

Viewport is view-level state rather than a draw bit, so CNA preserves changes by selecting an ordered view segment before each 3D draw or SpriteBatch batch. Consecutive work with the same target, viewport, and — for sprites — Begin transform reuses the segment; a changed viewport or sprite transform allocates the next view, binds the same target with CLEAR_NONE, and keeps execution order through the frame’s explicit view-order table. This works for the backbuffer and render targets. SpriteBatch keeps the view rectangle full-sized so a public clear still covers the target, then folds the captured viewport origin into its orthographic matrix; 3D sets the sub-rectangle directly. The public min/max depth values remain discarded as an explicit bgfx limitation.

The original single-subregion proof now sits beside Bgfx_Deferred_Viewport, which queues several viewport changes without a flush, and the transform-specific regression that separates two Begin matrices even when the viewport is unchanged. The analogous deferred scissor oracle proves that each draw still receives its own captured rectangle and enable bit. Section 19.4.3 records that broader current scope.

24.13 Current qualification

The recorded closure predates the readback and mip work above. The still-deliberate SlopeScaleDepthBias omission, the non-GLSL instancing matrix-constructor defect, and the two environment-bound render-target checks remain important limitations, while later feature work should be evaluated from the current renderer plan and its registered tests rather than from the historical campaign summary.

24.14 MAGNUM: a wrapper with no offline shader generator

MAGNUM first searches for an installed Magnum and otherwise fetches pinned Corrade and Magnum revisions. Emscripten is rejected. Its default desktop path is GLX, with EGL an explicit CMake option rather than a runtime driver choice.

Its shader boundary is unique in this cluster: GLSL lives as C++ string literals and is compiled by Magnum::GL::Shader at runtime. There is no checked-in generated shader header and no staleness checker to run. That removes an offline tool dependency while moving syntax and driver compatibility failures into device startup.

Extended draws and 2D targets are native. MRT capability follows the discovered maximum target count rather than an unconditional constant, anisotropy follows the runtime sampler limit, and occlusion queries are real. This is the desirable capability shape: report the device actually created, not the feature ceiling of the wrapper library in the abstract.

24.15 LLGL: a broad library behind a deliberately narrower CNA path

LLGL is fetched at a pinned release unless CNA_LLGL_ROOT supplies it. The renderer has its own module-selection helper, but the available set is platform-bounded: on Linux CNA presently offers OpenGL, and an explicit request for Vulkan throws instead of quietly selecting something else.

Two shader forms are checked in. Vulkan-style sources are compiled to SPIR-V, while OpenGL-specific sources are preserved as GLSL; both are assembled into a generated header. The generator has a check mode, so CI can distinguish a source change from a forgotten regeneration.

The current renderer owns native extended draws, 2D targets, MRT, and occlusion queries. Its documentation nevertheless describes a narrower boundary than upstream LLGL. Library support is not automatically CNA support: each resource and state path still requires an adapter, a capability answer, and observable evidence through the selected module.

24.16 DILIGENT: one HLSL program, several device types

DILIGENT fetches DiligentCore and resolves a device at runtime. The preference order is D3D12, Vulkan, D3D11, then OpenGL, filtered by what the build contains; on Linux the real candidate list is Vulkan followed by OpenGL. Tests that exercise only the first successful candidate therefore do not prove the fallback path.

Rather than shipping one shader package per device type, the renderer stores a single HLSL source and asks DiligentCore to cross-compile it at runtime. The row-major pragma is part of that contract because the HLSL-to-GLSL converter recognizes the pragma form. A shader edit can thus affect several native backends without generating separate checked-in blobs, but each translation path still needs its own execution evidence.

Native extended draws, 2D targets, and MRT are implemented. Occlusion-query support follows device features. Of the wrappers in this chapter, Diligent is closest to broad CNA parity, yet its runtime fallback order creates the sharpest test-design warning: one test binary registered twice is useful only if the device override genuinely forces two different paths.

24.17 Wrapper-selection checklist

For every reproduction, capture the CNA identity, underlying native backend, shader delivery path, and whether fallback was automatic or forced. Then verify the specific feature through pixels or query values. Construction proves that two abstraction layers agreed on startup; it does not prove that state and resources survived both translations.

Type at least three characters. Results are ranked by how often and where the words occur.