Chapter 14 Textures and Render Targets
Texture and render-target APIs are shared, but their native formats, readback paths, mipmaps, multisampling, and attachment behavior differ substantially by renderer family. Texture2D has the broadest coverage. Texture3D and TextureCube now share the expected base type but retain narrower shader and renderer evidence. Render targets still have named gaps. This chapter keeps those scopes separate.
14.1 Texture: a shared Color gate with one renderer-specific branch
Texture (inheriting GraphicsResource) exposes Format (SurfaceFormat) and LevelCount, plus a set of static helpers ported from FNA’s own internal size-calculation methods — made public static in CNA rather than FNA’s internal, because three of the four real call sites in CNA are not even Texture subclasses. The base ValidateFormat(format) method accepts only SurfaceFormat::Color; every non-Skia build routes ordinary Texture2D and RenderTarget2D construction through that gate, and Texture3D plus TextureCube use it on every renderer.
The compile-time Skia product is the deliberate exception. Its Texture2D validator admits 26 of the 27 enum values and retains each promoted native layout, including compressed DXT/BC7 blocks; Dxt5SrgbEXT is the one refusal. Its render-target validator admits the 14 formats that FNA reports renderable. The exception is therefore specific to two 2D resource classes, not permission to infer broad format support from the enum alone. Separately, the portable Texture2D::FromStream DDS route recognizes DXT1/3/5 and CPU-decompresses them to RGBA8 before ordinary upload.
14.2 Texture3D and TextureCube: current structure and history
The pinned headers declare both Texture3D and TextureCube as Texture subclasses, as XNA does. Regression coverage assigns a Texture3D to GraphicsDevice.Textures[slot] and checks that disposal releases the renderer handle and unbinds the resource from the device’s pixel and vertex texture collections.
Historical note. Earlier revisions did not derive these classes from Texture; assignment to a TextureCollection could not compile, and Texture3D skipped the base unbind-on-dispose path. An earlier manuscript pass described that state. The current contract above replaces it, while the regression preserves the reason for the change.
The practical consequence this earlier pass described — “a custom ShaderEffect has no way to bind a Texture3D or TextureCube” — is also closed, and by a more direct route than the generic texture-slot mechanism the class-hierarchy fix alone would suggest: ShaderEffect gained dedicated SetTexture(int, TextureCube&) and SetTexture(int, Texture3D&) overloads, each binding straight to the matching GL texture target (glBindTexture(GL_TEXTURE_CUBE_MAP, ...) / GL_TEXTURE_3D) rather than routing through the ordinary 2D texture-slot path. This is EasyGL-only as of this writing — the other nine renderers were explicitly deferred, per the same task’s own scope note — so “a custom shader can sample a volume or cube texture” is currently a real, tested EasyGL capability, not yet a cross-renderer one. EnvironmentMapEffect’s own EnvironmentMap field, mentioned in an earlier pass as the only way any effect could reach a cube texture, is now one of two paths rather than the sole exception.
14.3 Texture2D
Texture2D is copyable and movable (its renderer is a shared_ptr-held ITextureRenderer), constructible from raw dimensions, from a file path (two CNAEXT overloads — real XNA has no such constructor, since real XNA loads textures exclusively through the compiled content pipeline), or via FromStream (device plus stream, or a five-argument form that resizes/crops to an explicit width, height, and zoom fit-vs-cover flag). SetData / GetData cover full-array and partial (level, rectangle, start index, element count) forms, all Color-only. SaveAsPng / SaveAsJpeg round out the surface, alongside several CNAEXT escape hatches (GetRenderer(), GetCpuPixelsWeak(), CreateFromPixels) that exist for interop with CNA’s own content-pipeline and testing code rather than for game code.
14.3.1 Building a texture from raw pixel data
The following procedural checkerboard uses the constructor and SetData / GetData signatures from Texture2D.hpp:
Two constraints from this chapter’s opening section apply directly to this example. The two-argument Texture2D(device, width, height) constructor defaults to SurfaceFormat::Color, so Color is the portable path across renderer families. On a non-Skia build, passing any of the other 26 SurfaceFormat values to the explicit-format constructor throws before upload. The public class does now provide typed SetData/GetData overloads for packed vectors, floats, vectors, half formats, bytes, and unsigned shorts; those routes become materially useful with Skia’s promoted native layouts. They are concrete overloads rather than XNA’s arbitrary T[] template.
14.3.2 Two SetData paths, and why GetData can hide a failed upload
The simple whole-texture call in the example and the detailed level/rectangle overload do not update the resource the same way. SetData(Color*, count) converts the complete level 0 image, replaces this object’s shared renderer reference, and calls CreateTexture() for a replacement resource. The detailed overload instead patches CNA’s CPU shadow first. At level 0 it sends the whole reconstructed level through ITextureRenderer::UpdatePixels(), even when the caller supplied a small rectangle; above level 0 it sends that level’s complete shadow through UpdatePixelsLevel(). This differs from FNA, whose detailed overload forwards the requested rectangle directly to FNA3D_SetTextureData2D().
That CPU shadow changes what a test proves. An ordinary Texture2D::GetData() reads cpuPixels_ or extraMipLevels_; it does not read the sampled GPU resource. The source file for easygl_texture2d_mip_test.cpp says this explicitly: its three-level “round trip” is pure CPU-shadow verification with no framebuffer readback. It would still return every color supplied by SetData() if the corresponding renderer update were an empty body. GPU sampling, a native renderer readback, or a rendered pixel is required to establish the other half.
That distinction exposes a live split. EasyGL, Vulkan, BGFX, WebGPU, and D3D9/11/12 allocate and update real Texture2D mip subresources. SDL_RENDERER, ASCII, Canvas, and FREEDIRECT reject a level-above-zero upload with std::runtime_error. Software explicitly discards it, Headless records the call without storing a renderer mip, and SDL GPU inherits the interface’s empty UpdatePixelsLevel() body while allocating only one physical level even when public LevelCount reports a full chain. In all three discard cases, the public CPU shadow still remembers the supplied level, so a following GetData(level,...) can look successful while rendering can never consume it.
WebGPU has the opposite extra behavior: writing level 0 automatically regenerates every higher level with a real linear-filtered render-pass cascade. That prevents undefined mip content, but differs from FNA and means a later level 0 update overwrites any authored higher levels written earlier. Chapter 19, §19.3.3, gives the complete renderer and evidence matrix.
14.3.3 FromStream format support
Format detection in FromStream starts with a minimal internal DDS-header sniffer (recognizing only the DXT1/3/5 FourCC codes, decoding via an internal DxtUtil), and falls through to SDL3_image’s IMG_Load_IO — which auto-detects the container format from the byte stream itself — for everything else. Verified round-trip formats: PNG, JPEG (lossy, within tolerance), 24-bit uncompressed BMP, and DDS (DXT1/3/5). AVIF, TIFF, and WebP are present in the linked SDL3_image build’s own dependencies but are not independently tested by CNA’s own suite. The zoom parameter on the five-argument overload matters more than its name suggests: zoom=false uses a simplified largest-dimension-fit heuristic (not a general min(w/w0, h/h0) bounding-box fit — it assumes a roughly square target box), while zoom=true scales up and center-crops to exactly fill the requested box.
14.4 Texture3D and TextureCube
Beyond the structural deviation described above, Texture3D and TextureCube are both non-copyable (each owns a unique GPU handle) but movable — Texture3D specifically gained move construction/assignment to satisfy a glTF content-pipeline reader’s needs. Their requested SurfaceFormat is not silently ignored: the shared Texture::ValidateFormat() gate rejects every value except SurfaceFormat::Color before a renderer is created, so every surviving resource is RGBA8 by construction.
Neither type keeps a Texture2D-style CPU pixel shadow. Every valid GetData() on a constructed renderer therefore reaches that renderer’s own readback path. The old support document’s claim that only EasyGL really implements this is now stale: EasyGL, Vulkan, BGFX, WebGPU, SDL GPU, and all three Direct3D renderers have real GPU readback for both resource types. Their mechanisms differ — framebuffer reads, staging copies, native lock/map calls, or a temporary BGFX transfer texture — but all eight return actual sub-region bytes. Software adds a real level-0 CPU implementation for TextureCube, while its Texture3D factory is absent. Headless deliberately returns transparent-black placeholders. Chapter 19’s §19.3.3 gives the full factory/readback matrix, including the separate and much narrower RenderTargetCube result.
Authored mip-level upload and readback are likewise real on both ordinary resource types across those eight GPU renderers. Exact public mip round trips are registered for EasyGL, Vulkan, BGFX, WebGPU, and SDL GPU; the Direct3D implementations allocate and address mip subresources too, although their cited smoke-test readbacks concentrate on discriminating level-0 subregions. This statement is about explicitly supplied mip data, not a promise that every renderer automatically generates a mip chain after a level-0 upload. CubeMapFace validation, by contrast, is a place CNA is stricter than FNA: an out-of-range face throws std::out_of_range at the public API layer, a safety check real XNA never performs at all.
14.4.1 Six faces, six SetData calls
TextureCube’s constructor takes a single face size (every face is square and identical in size, per the real XNA contract) plus mipMap and format — and, per this chapter’s own opening finding, format must be SurfaceFormat::Color or construction throws immediately. Populating a solid-color test cube (a stand-in for a real skybox’s six photographic faces) is a direct, mechanical loop over CubeMapFace’s six enumerators, since SetData takes the face as its first argument:
This loop can now be verified with a real GetData() round trip on any of the eight GPU renderers above, rather than only on EasyGL as the stale support document still says. The strongest reusable checks deliberately distinguish slices, faces, off-origin regions, and mip levels; they do not merely assert that the call returned. Software can make the same check at level 0 for a cube. skybox can also be bound to a custom ShaderEffect on EasyGL, per §14.2 above — either through SetTexture(int, TextureCube&) directly, or, since TextureCube now inherits Texture, through the ordinary GraphicsDevice.Textures[slot] assignment every 2D texture in this chapter already uses.
14.4.2 Sampling a volume texture from a custom shader
modules/renderers/easygl/examples/easygl_shadereffect_texture3d_test.cpp is the real, EasyGL-only test that proves ShaderEffect::SetTexture(int, Texture3D&) genuinely reaches a sampler3D uniform, not just that the call compiles. The volume is deliberately tiny — a texture, one texel per Z-slice — so the whole capability proof reduces to two texel reads instead of a real ray-marching setup:
Sampling at and — each slice’s own texel centre, per — reads back (255,0,0,255) and (0,0,255,255) respectively, even with GL_LINEAR filtering on both axes: linear interpolation sampled exactly at a texel centre puts its full weight on that one texel, so there is no blending artifact to account for. The real test’s own false-positive guard is worth naming as a technique in its own right, since it recurs throughout this book’s worked examples: a second, all-black “decoy” Texture3D is created and uploaded after the real one, so an unmutated SetTexture() call would leave GL’s texture-unit-0/GL_TEXTURE_3D binding pointing at the decoy rather than the intended volume — the two-slice colour check only passes for the right reason (a genuine, per-call rebind) if disabling the SetTexture() call would make it fail.
14.4.3 Rendering into one RenderTargetCube face
GraphicsDevice has a dedicated overload for exactly this purpose — SetRenderTarget(RenderTargetCube*, CubeMapFace) — distinct from the plain SetRenderTarget(RenderTarget2D*) form used for ordinary 2D targets, and from the SetRenderTargets(const std::vector<RenderTargetBinding>&) form used for simultaneous multi-target binding (RenderTargetBinding’s own (Texture*, CubeMapFace) constructor exists specifically to build entries for that vector form, not for single-target binding). Rendering a small scene into a single cube face and then sampling that cube in a later draw is the pattern a real-time reflection probe or a baked-environment-map tool would use:
Sampling and CPU readback are separate capabilities here. The code above can feed the cube to an effect without ever calling probe.GetData(); nevertheless public rendered-face readback is now exact on EasyGL, Vulkan, BGFX, WebGPU, SDL GPU, and D3D9/11/12. Skia provides a separate exact six-surface CPU emulation. Unsupported identities now throw instead of converting a renderer no-op into fabricated transparent black. Mip/MSAA scope still differs — notably D3D9 has only level 0 and WebGPU refuses a mipmapped cube target — so the central matrix in §19.3.3 remains the authoritative per-level comparison.
The inherited SetData family has narrower renderer support still. It is legal at compile time because RenderTargetCube inherits the whole TextureCube upload family, exactly as it does in FNA. EasyGL implements the GPU upload with its documented row convention, and Skia implements exact CPU-surface upload. Every other constructed target returns failure from the renderer contract; the public call validates first and then throws NotSupportedException instead of returning normally after changing nothing. Thus the feature remains narrow, but the former silent compatibility gap is closed.
The unbind line now routes correctly on Vulkan, WebGPU, SDL GPU, BGFX, D3D9, D3D11 and D3D12. The Direct3D 11/12 renderers explicitly track the active cube and finalize it before every cube/2D/MRT/backbuffer transition; shared public readback, usage and per-face-MSAA fixtures make that edge observable. EasyGL retains a narrower exception: switching directly between faces of the same cube skips outgoing-face finalization. Per-face MSAA storage now preserves each face’s samples, but only a finalized face is resolved/generated for later sampling; insert a backbuffer unbind between freshly rendered faces when all must immediately be sampled. The full transition matrix and BGFX’s renderer-local (not normalized public) cube-null no-op are recorded in §19.3.3.
Whether this actually produces a correct image is, per §14.5 below, renderer-dependent in ways the call site cannot infer. EasyGL renders it correctly. Vulkan’s two formerly-tracked black-render defects (Tasks 875/876) are now both closed: clear-only targets enter the used-target list, and the original cube-sampling regression test passes repeatedly end to end, although the exact incidental change that retired the second defect was not bisected. BGFX’s former unconditional static_cast type-confusion bug is likewise fixed by shared cube-sampling interfaces and virtual handle dispatch. The remaining transition, same-frame, and upload qualifications above still apply; “Vulkan cube sampling is broken” no longer does.
14.5 Render targets: a once-sharp per-renderer divergence, mostly closed since
RenderTarget2D inherits both Texture2D and IRenderTarget, which is what lets it be both drawn into and sampled as an ordinary texture afterward; its Dispose(bool) correctly throws InvalidOperationException if it is still bound to the device, matching FNA. An earlier explanation for RenderTargetCube’s missing equivalent guard is now stale: RenderTargetCube does inherit TextureCube and IRenderTarget, while TextureCube itself inherits Texture, so RenderTargetBinding can store it through a Texture*. The guard is still absent for a different reason. The singular cube-target setter clears currentRenderTargets_ and retains only a boolean “some target is bound,” not the cube pointer; RenderTargetCube supplies no Dispose(bool) override of its own to compensate. The compatibility gap remains, but the former class-hierarchy explanation does not.
14.5.1 RenderTargetUsage: three values, more than three behaviors
RenderTargetUsage has the expected three ordinals: DiscardContents, PreserveContents, and PlatformContents. DiscardContents says old attachment data need not survive a bind; it does not promise a readable replacement color. PreserveContents requests survival even at a performance or memory cost. PlatformContents permits survival only when cheap. In current FNA, both non-discard values are passed to the native layer as a preserve request, while the exact enum still controls whether the managed layer issues its discard clear.
CNA now centralizes those decisions in RenderTargetUsagePreservesContentsEXT(): only Discard maps to false, while Preserve and Platform both map to true, matching FNA. RenderTarget2D and RenderTargetCube pass that Boolean through their renderer factories, and the shared normalized bind route emits the deterministic black/max-depth/zero-stencil clear only for Discard. An explicit Clear() remains a distinct ordered operation and wins over the usage policy aspect by aspect.
Cube targets now use the same binding path rather than storing only an enum beside an untracked face. A RenderTargetBindingDescriptor preserves the selected face, dimensions, applied sample count, and renderer pointer; usage reaches the cube factory, and the shared discard clear can inspect the cube’s real depth/stencil attachments. The remaining differences are native mechanisms and renderer capability — for example WebGPU rejects MRT — not a missing cube usage parameter.
The backbuffer’s corresponding PresentationParameters.RenderTargetUsage is currently stored, cloned, and exposed only. Returning to the backbuffer never consults it. FNA, by contrast, supplies it to native target binding and uses it to choose the unbind-to-backbuffer discard clear. The first 2D target’s usage controls CNA’s MRT implicit clear, which matches FNA’s first-target policy in shape, but deferred Vulkan MRT passes discard regardless and the public plural cube form cannot reach a cube renderer at all.
Current evidence is substantially broader than the early non-MSAA 2D tests. Shared cube-usage, per-face MSAA preservation, depth/stencil usage, pass-boundary, and plural-cube fixtures are registered across capable renderers; Platform is asserted as preservation rather than left implicit. Backbuffer usage and redundant-binding elision remain separate gaps: returning to the backbuffer still ignores its presentation usage, and CNA still performs native/state work for an identical repeated binding instead of FNA’s early return. The complete source/evidence matrix is §19.4.1; the binding-identity consequences are §12.4.1.
The render-target support ledger once listed five further per-renderer divergences here (Tasks 877–881) as open gaps, and an earlier pass of this chapter reproduced that list faithfully. Reading every renderer’s own current .cpp directly instead of trusting that document’s status column finds four of the five already closed — each superseded by a later task (903, 907, or 911) whose own fix comments the tracking document was never updated to reflect, the identical “a status that looked settled turned out to be stale” pattern Chapters 22 and 23 both ran into independently while auditing this exact same document for their own renderers. What is actually still open, and what is now fixed:
- Cube sampling.
-
Sampling a RenderTargetCube face after rendering into it, then unbinding, works correctly on EasyGL. Vulkan now works in both discriminating regressions: the clear-only case explicitly records the target, while the original render-all-faces/then-sample test no longer reproduces the black result (the exact fixing commit was not retroactively isolated). BGFX: fixed — the unconditional static_cast that once read a framebuffer-pool handle where a texture-pool handle was expected (Chapter 24 names the exact mechanism) is gone, replaced by a real dynamic_cast to a shared cube-samplable interface for the EnvironmentMapEffect path and virtual width/height dispatch for the ordinary SpriteBatch path.
- Depth/stencil format fidelity.
-
Now real on all three renderers: each maps the actual requested DepthFormat ordinal to the correct native format (GL internal format plus attachment point on EasyGL; a per-instance Vulkan format; bgfx’s own format enum), rather than the single, always-the-same format every renderer once allocated regardless of what was asked for.
- Mipmap generation.
-
EasyGL already worked — real per-level GPU storage, auto-regenerated via glGenerateMipmap on unbind, matching FNA3D’s own real mechanism. Vulkan and BGFX now do too: a real, per-level mip-chain regeneration cascade (vkCmdBlitImage on Vulkan; bgfx’s own equivalent) runs against a render target’s just-rendered content once its render pass ends, rather than silently accepting and discarding the request the way both once did.
- Multisample antialiasing.
-
EasyGL already worked: a real multisampled renderbuffer with a glBlitFramebuffer resolve on unbind. Vulkan and BGFX now genuinely implement it too, rather than honestly reporting MultiSampleCount = 0 the way both once correctly did while the feature was still unimplemented.
Two further, project-wide findings from the same original list are also now closed, not open. Switching the active render target correctly resets the shared public Viewport and ScissorRectangle values to the new target’s (or the backbuffer’s) own dimensions on every renderer; whether those setters then change native rasterization is the separate current renderer contract in §19.4.3. GraphicsDevice.Viewport itself now has real GPU wiring on all three hardware renderers investigated — EasyGL applies a custom sub-region Viewport on its ordinary 3D paths whether or not a render target is currently bound (SpriteBatch is the documented exception), while Vulkan’s own version of the identical fix is honestly narrower, backbuffer-pass-only, for an architectural reason Chapters 23 and 22 both cover in full. And SetRenderTargets() now genuinely enforces FNA’s real four-target cap at the shared, cross-renderer level — throwing if a caller passes more than four bindings, regardless of what any individual renderer’s own internal buffer happens to be sized for — closing what this section once described as no renderer matching that real XNA limit at all.
14.6 SurfaceFormat, without flattening the Skia exception
The 27-value SurfaceFormat enum describes a wider contract than most configured products implement. For 41 renderer families, ordinary texture construction still accepts only SurfaceFormat::Color; compressed content loading commonly becomes RGBA8 before the GPU. Skia is the bounded exception: 26 formats have native Texture2D storage and typed transfer routes (all except Dxt5SrgbEXT), while only 14 are admitted for RenderTarget2D. Volume and cube textures remain Color-only even there. A format enumerator, a transferable CPU representation, and render-target capability are therefore three separate claims.
One real, historical bug is worth naming because of what it reveals about how subtle a graphics-correctness bug can be: Vulkan’s texture path once created images as VK_FORMAT_R8G8B8A8_SRGB (wrong — SurfaceFormat::Color is linear, not sRGB) while the swapchain separately preferred a matching sRGB format. The two wrong encode/decode steps approximately canceled out for ordinary textured content, which is exactly why the bug went unnoticed for a long time — it only became visible on non-textured content (flat vertex colors, raw lighting output), where a mid-gray value of 128 was read back as 188. Both were corrected to their _UNORM equivalents.
14.7 Buffers: VertexBuffer and IndexBuffer
VertexBuffer and IndexBuffer both inherit GraphicsResource and both maintain a private cpuShadow_ byte buffer purely to support GetData() without requiring a real per-renderer GPU readback path — since nothing in CNA’s own pipeline ever writes GPU-side data back into one of these buffers, a CPU-side shadow copy is a faithful, sufficient implementation of read-your-own-writes semantics. VertexBuffer::SetData / GetData are typed per concrete vertex struct — VertexPositionColor, VertexPositionColorTexture, VertexPositionNormalTexture, and VertexPositionTexture — plus a CNAEXT skinned variant and a raw SetDataRaw(data, count, stride) escape hatch for layouts with no dedicated XNA struct. DynamicVertexBuffer and DynamicIndexBuffer add IsContentLost (always false) and SetData(..., SetDataOptions) overloads. One half of the chapter’s old simplification is still true: the protected VertexBuffer and IndexBuffer constructors receive a dynamic boolean from the two derived classes but deliberately leave the parameter unnamed and never pass it to a renderer factory. Choosing Dynamic* therefore does not itself select different storage. D3D9 and D3D11 allocate their ordinary and dynamic buffers through the same always-dynamic native path; BGFX likewise uses dynamic handles for both. The per-upload hint is no longer universally ignored, however:
-
•
EasyGL maps Discard to buffer orphaning plus a new sub-data upload and NoOverwrite to an in-place sub-data update. Its vertex path makes this distinction whenever storage already exists. Its index path has one surprising coupling: when context recovery is disabled, no renderer CPU shadow is retained, so its NoOverwrite-eligibility check fails and falls back to a fresh data allocation.
-
•
SDL GPU’s native cycle flag is true for None and Discard, but false for NoOverwrite. D3D9 uses the corresponding DISCARD/NOOVERWRITE lock flags once storage exists (a fresh allocation forces discard). D3D11 uses the corresponding WRITE_DISCARD/WRITE_NO_OVERWRITE map modes.
-
•
Vulkan and BGFX inherit the interface default, which discards the option and invokes plain SetData. WebGPU and D3D12 explicitly receive but ignore it before their queue-write or synchronous staging-copy path. Software and Headless likewise replace their CPU/trace data without interpreting the hint. The four 2D-only renderers reject buffer construction before any of these methods can be reached.
The real easygl_dynamic_buffer_stress_test.cpp exercises the distinction through the public classes rather than calling a renderer directly. Across twelve frames it cycles None, Discard, and NoOverwrite, uploads a differently colored six-vertex quad and six indices, draws, then reads the center pixel and checks the two public capacities. That is a genuine pixel proof that all three routes preserve current data through repeated use. It is not, by itself, proof of which native allocation primitive ran; the orphan/sub-data distinction is established by the implementation read above.
14.7.1 A shared readback hole in the options-taking path
That audit exposes a separate bug above every renderer. The ordinary typed VertexBuffer::SetData and IndexBuffer::SetData overloads update the shared cpuShadow_ after uploading. None of the protected SetDataWithOptions overloads does. A DynamicVertexBuffer or DynamicIndexBuffer created with BufferUsage::None can therefore inherit GetData, but after its normal options-taking SetData call the shared shadow is still empty (or stale after an earlier ordinary upload): a fresh buffer throws ArgumentOutOfRangeException instead of returning the bytes just written. BufferUsage::WriteOnly still throws NotSupportedException by design; that documented restriction does not excuse the broken non-write-only case. This is shared public code, so changing graphics renderer cannot avoid it.
14.8 Vertex layout: fixed by stride, except for EasyGL raw declarations
The ordinary typed paths still choose fixed GPU layouts from the uploaded byte stride. The current project-wide family has grown from five to eight shapes: 16, 20, 24, and 32 bytes for the four stock XNA vertex types; 48 for CNA’s PBR tangent layout; 52 for GPU skinning; 56 for the same skinned data plus vertex color; and 68 for tangent-space PBR plus skinning. Exact shader/effect combinations remain renderer-specific, so recognizing a stride is not a promise that every effect can consume it.
There is now one deliberate exception to the stride-only rule. VertexBuffer::SetDataRaw() pushes the buffer’s own VertexDeclaration element list through IVertexBufferRenderer::SetVertexDeclaration() immediately before upload. That interface method defaults to a no-op, and EasyGL is currently its only override. EasyGL maps all twelve XNA element-format values to the correct GL attribute shape and uses the declaration’s element order as GLSL locations ; the semantic VertexElementUsage does not choose the location.
The pixel proof is stronger today than its original “non-standard stride” description. easygl_shadereffect_custom_vertex_layout_test.cpp supplies five elements in a 48-byte record: three Vector3 fields, one Vector2, and a trailing normalized Color. Forty-eight bytes is now also the size of CNA’s fixed PBR layout, but that layout uses a four-float tangent and has no trailing color. The test encodes normal, tangent, UV, and color inputs into the output pixel; its two distinct, tolerance-bounded readbacks therefore prove that EasyGL followed the declaration’s offsets rather than merely selecting its different fixed 48-byte case.
The practical rule is consequently renderer-specific. An arbitrary VertexDeclaration is a real EasyGL+ShaderEffect capability when uploaded through the CNAEXT SetDataRaw path. Every other renderer still discards that declaration at the interface default and relies on its known stride/effect combinations; SDL_Renderer and the other 2D-only renderers reject vertex-buffer construction outright.