Renderer selection. This document describes the renderer as a compile-time choice (
-DCNA_GRAPHICS_RENDERER=...), which remains the default and recommended mode. Sinceplan_runtimerenderer.md, CNA can also be built with several renderers and choose between them at runtime — seedocs/runtime-renderer-selection.md. Nothing below changes in single-renderer mode.
Status: v1 baseline, remediated.
CNA_GRAPHICS_RENDERER=BLEND2Dis a genuine 2D-only CPU-raster renderer backed by Blend2D v0.21.2. The renderer was first added in a single commit (branchclaude/renderer-blend2d-jallld) with only two smoke-level tests; this plan tracks the subsequent full-implementation audit and remediation pass that brought it to a tested, sanitizer-clean state. Seedocs/blend2d-renderer.mdfor the current capability boundary and evidence table — that document is the authoritative "what works today" reference; this plan is the task-level history and remaining-boundary tracker.
Mirrors the project-wide rules already established for plan_skia.md/plan_webgpu.md, applied
to a CPU-raster 2D-only renderer:
- Direct first, safe-resolve second, refusal last. Implement any XNA
SpriteBatch/Texture2D/RenderTarget2Dbehaviour Blend2D genuinely represents. Where Blend2D's own API cannot express something in-bounds (e.g. an out-of-range source rectangle), resolve it through a bounded, memory-safe CPU path rather than reading past a buffer. Where neither is possible (3D, custom shaders, mip/MSAA), refuse explicitly and truthfully. - No silent state loss. Every
ISpriteBatchRenderer/IGraphicsRendereroptional virtual this renderer inherits without overriding must be an explicit, documented decision — either the shared default is genuinely correct for a 2D-only CPU rasterizer, or it must be overridden, or the corresponding public operation must reject the request. A newly discovered silently- inherited default that changes observable behaviour is a defect, not a scope boundary. - No unchecked native memory access. Every raw pointer walk into a
BLImage's pixel buffer must be bounds-derived from validated dimensions, never directly from an untrusted publicRectangle. EveryBLResult-returning Blend2D call that can genuinely fail must be checked. - Capabilities are measured, not aspirational.
SupportsCapability()and the documentation table indocs/blend2d-renderer.mdreport only behaviour that has a passing, pixel-level test behind it. - Deterministic, headless-capable tests. Prefer
SDL_VIDEODRIVER=dummywindow-independent tests (no real display required) overSDL_VIDEODRIVER=x11ones so the renderer's correctness suite runs in ordinary CI. Reserve thex11display requirement for the one test that specifically needs the fullGame/GraphicsDeviceManagerintegration.
- BLEND2D-1. Add
cmake/ThirdPartyBlend2D.cmake(pinnedFetchContentfor Blend2Ddef0d1238c3e5d0983bb848e5676049d829e435b/ AsmJitb56f4176cb9b0c0501da659ac54d4c5877862c7b), wireBLEND2Dintocmake/RendererSelection.cmake,modules/core/include/CNA/ GraphicsRendererType.hpp,scripts/check_renderer_identities.py,docs/renderer-registry.md,docs/physical-modules.md,FUTURE.md. - BLEND2D-2.
modules/renderers/blend2d/module:Blend2DSurface(ownedBLImage+BLContext),Blend2DRenderer(backbuffer, presentation, render-target binding, blend/scissor state),Blend2DSpriteBatchRenderer(Drawoverloads viablit_image),Blend2DTextureRenderer,Blend2DRenderTargetRenderer,Blend2DPixelConvert.hpp(straight RGBA8 <-> premultiplied BGRA). Two smoke-level example/tests (blend2d_smoke_test.cpp,blend2d_surface_raster_test.cpp).
That baseline shipped as a single commit with no independent audit; every remaining task in this plan is the result of the audit that followed.
- BLEND2D-3.
BuildTintedSubImageEXT(renamedResolveSourceImageEXT) performed raw pointer arithmetic into aBLImage's pixel buffer using the caller'ssourceRectangledirectly, with no bounds check against the actual texture dimensions — an out-of-bounds CPU read for any negative or oversized source rectangle combined with a non-white tint. Fixed: every source pixel read now goes through an address-mode-aware coordinate mapper (AddressedCoordinateEXT) that always returns an in-bounds index; the in-bounds fast path is additionally protected by Blend2D's ownblit_imagebounds check (check_image_area,BL_ERROR_INVALID_VALUE, now checked instead of ignored). Verified hostile (negative, oversized, entirely-outside) source rectangles, tinted and untinted, clean under ASan/UBSan/LeakSanitizer (Blend2D_Correctness::TestSourceRectangles). - BLEND2D-4. Self-sampling (drawing a
RenderTarget2Dwhile it is the active render target) aliased the liveBLImageas both source and destination with no snapshot, an unverified assumption against Blend2D's pinned-version synchronous-context guarantees. Fixed: detected via pointer identity against the active surface's image and routed through the same safe resolved- copy path. Verified no corruption for a 1:1 self-sampled draw (Blend2D_Correctness::TestRenderTargets). - BLEND2D-5. Render-target lifetime (
Blend2DRenderTargetRenderer's rawBlend2DRenderer&) audited against CNA's establishedGraphicsResource/GraphicsDevice::Dispose()disposal-order contract (every tracked resource is disposed before the owningIGraphicsRendereris torn down) — confirmed safe under that contract, matching the codebase-wide raw-backpointer pattern; not a Blend2D-specific gap. Documented inBlend2DRenderer.hpp's class comment. - BLEND2D-6.
ScopedContextStateEXT(RAIIBLContext::save()/restore()) added so an exception partway through a draw (a failedBLResultcheck, a resolve allocation failure) cannot leave a stale transform/clip attached to a surface'sBLContext, corrupting subsequent draws.
- BLEND2D-7.
Blend2DTextureRenderer::UpdatePixelsignored itsstrideparameter and always advanced bywidth * 4, silently reading the wrong bytes for any caller-supplied stride wider than the tight row pitch. Fixed and verified with a deliberately padded stride and per-row distinct colours (Blend2D_Correctness::TestTextureStride). - BLEND2D-8.
SpriteEffectsflips were geometrically wrong for a non-zero origin:scale(-1,1)mirrored about the local coordinate origin rather than the destination rectangle's own center, moving a flipped sprite off its requested destination fororigin=(0,0)and producing systematically wrong results otherwise. Re-derived againstSoftwareSpriteBatchRenderer's per-vertex reference math (FNA/XNA's own flip semantics: mirror the SOURCE sampling, which is visually equivalent to mirroring the drawn content about the destination rect's own center) and generalized to negative destination width/height (an independent mirror source that XOR-composes withSpriteEffects). Verified across no-rotation, rotation, non-zero origin, non-uniform scale, both flip axes, and negative destination width (Blend2D_Correctness::TestFlipOriginRotation). - BLEND2D-9.
ApplyBlendStaterecognized only two raw tuples and silently routed every otherBlendState(including materially different independent-alpha combinations) throughSRC_OVER. Replaced with an explicit 6-factor match against the four stock XNA presets (Opaque/AlphaBlend/NonPremultiplied/Additive); any other tuple, including a colour/alpha mismatch, throws. Verified with a semi-transparent source over a semi-transparent destination for all four presets, not just opaque colours (Blend2D_Correctness::TestBlendPresets,TestTintChain). - BLEND2D-10.
BlendWriteState(ColorWriteChannels/MultiSampleMask) was accepted and silently discarded. Implemented a bounded whole-surface snapshot/merge for a non-defaultColorWriteChannels[0];ColorWriteChannels1-3(unimplemented MRT slots) and a non-defaultMultiSampleMaskare rejected outright. Verified RGB-only and alpha-only masks against their correct (premultiplied-storage-consistent) results (Blend2D_Correctness::TestColorWriteChannels). - BLEND2D-11.
SpriteBatch.Begin'sSetTransformMatrix/SetSamplerFilter/SetSamplerAddressMode/SetCustomEffect/SetImmediateModewere unimplemented (inherited the sharedISpriteBatchRendererno-op defaults), so a caller-supplied transform matrix, sampler state, or custom effect was silently ignored. All five implemented or given an explicit, audited no-op decision:SetTransformMatrix: composed viaBLContext::apply_transformas the outermost transform.SetSamplerFilter: mapped toBLPatternQuality(Point-family -> Nearest, Linear-family -> Bilinear), matchingSdlRenderer's establishedTextureFilterdecomposition.SetSamplerAddressMode: Wrap/Clamp/Mirror implemented exactly via the resolved-copy path.SetCustomEffect: rejects any non-null, non-exact-stockSpriteEffect.SetImmediateMode: explicit no-op — every draw already executes synchronously per call.
- BLEND2D-12.
RasterizerState.ScissorTestEnablehad no renderer-side effect at all (ApplyRasterizerStatewas never overridden): once any scissor rectangle was set viaSetScissorRect, it stayed applied forever, even after the caller disabled the scissor test, and stopped applying entirely after a render-target switch (eachBlend2DSurfaceowns its ownBLContext, and the clip had been applied eagerly to whichever one was active atSetScissorRect-call time). Redesigned: scissor rectangle and enabled flag are recorded only, and re-applied fresh on every draw, in target space, on whichever surface is currently active (Blend2D_Correctness::TestScissor). - BLEND2D-13 (found during the second, independent audit pass).
IGraphicsRenderer:: SetViewport's shared no-op default was inherited, so a non-default XNAViewport(e.g. split-screen) had no effect at all — every other CNA 2D renderer (Skia, Software) applies the viewport as SpriteBatch's coordinate origin and clip rectangle. Implemented to match; verified offset and clip behaviour (Blend2D_Correctness::TestViewport). - BLEND2D-14 (found during the second audit pass).
ApplyDepthStencilState/SetReferenceStencilsilently accepted anyDepthStencilState/reference value even thoughSupportsDepthStencil()/SupportsCapability(StencilBuffer)both correctly reportfalse.DepthStencilState.None(the state everySpriteBatch.Beginapplies) is accepted; anything depth/stencil-enabling now routes through the sameHandleUnsupported3DCallpolicy every other unsupported-3D entry point in this renderer uses.
- BLEND2D-15.
FixedHeightDynamicWidthmapped toSDL_LOGICAL_PRESENTATION_LETTERBOXwith the requested virtual width used unchanged — not the established CNA behaviour (aspect-derived logical width, re-computed as the window/output resizes). Reimplemented againstSkiaRenderer::RecreateBackbuffer/RefreshDynamicBackbufferIfNeeded's exact formula (logicalW = round(outputW * requestedH / outputH)) and refresh timing (onClear/Present/GetViewportSize, skipped while a render target is bound). A test-onlyDebugSetPresentationOutputSizeEXT/DebugClearPresentationOutputSizeEXTpair (mirroring Skia's own) makes this deterministically testable without a real resizable window. Verified the derived width for two different simulated output aspect ratios, and that the bound-target suppression + eventual-consistency-after-unbind both hold (Blend2D_Correctness::TestPresentationModes).
- BLEND2D-16.
dynamic_cast<const Blend2DNativeImageSourceEXT&>(texture)(an unchecked reference cast) produced an undiagnosablestd::bad_castif a foreign renderer's texture/ render-target crossed theSpriteBatch::Drawboundary. Replaced with a checkeddynamic_cast<const T*>+ explicitstd::invalid_argument, thrown before any native memory is touched (Blend2D_Correctness::TestCrossRendererSafety). - BLEND2D-17.
Blend2DSpriteBatchRenderer::Begin()/End()had no re-entrancy guard (a doubleBegin()silently overwrote the "begun" flag). Guarded to match every other CNA renderer's own Begin/End contract, and verified that a rejected double-Begin()/double-End()/ invalid-sampler-state call does not poison the next valid cycle (Blend2D_Correctness::TestBeginEndStateMachine).
- BLEND2D-18. Verified the pinned Blend2D/AsmJit commits against the exact upstream source
tree fetched by
cmake/ThirdPartyBlend2D.cmake(BL_FORMAT_PRGB32semantics,check_image_areabounds behaviour,BLContexttransform composition order,BLPatternQuality/BLExtendModeavailability) rather than current upstreammaster. A fresh configure + build (native, and-DCNA_SANITIZE=address,undefined) both succeed withCNA_BLEND2D_ROOT/CNA_ASMJIT_ROOTunset (networkFetchContentpath). - BLEND2D-19. Added
plan_blend2d.md(this file) — the branch referenced it from multiple source-file comments without it existing.
An independent post-remediation review of the state above (starting SHA 3da643712) found the
READY verdict was still incorrect: the four-preset BlendState match landed by BLEND2D-9 was
complete (every stock tuple recognized, everything else rejected) but two of the four presets
computed the wrong pixel; a RenderTarget2D public-property boundary and a filter-family claim
were asserted but not actually regression-tested against the real public API/a genuinely
distinguishing case.
- BLEND2D-20 (P0, superseded by BLEND2D-24 below).
ApplyBlendStatemappedNonPremultipliedtoBL_COMP_OP_SRC_OVERandAdditivetoBL_COMP_OP_PLUSas if they were exact native operators. First fix attempt: left the native draw's colour bytes as-is (believed correct — see BLEND2D-24, this belief was itself wrong) and corrected only the ALPHA byte via a bounded CPU-assisted pass (ApplyIndependentAlphaCorrectionEXT), reasoning thatSc*Sa(theSourceAlphacolour factor applied to the straight source colour) is algebraically the same byte Blend2D's native premultiplied storage already holds. This surfaced a real, previously-latent bug inBlend2DPixelConvert.hpp'sConvertPremultipliedBgraRowToStraightRgba: a stored premultiplied colour byte can legitimately exceed a newly-reduced alpha byte, and the unclampedstatic_cast<uint8_t>unpremultiply division would silently wrap instead of clamping to 255; fixed with an explicitstd::min(255, …)clamp on all three channels (this clamp itself remains correct defensive hardening after BLEND2D-24 — see there for why it is no longer load-bearing). - BLEND2D-21 (P0 test oracle, superseded by BLEND2D-24 below).
TestBlendPresetsassertedNonPremultiplied == AlphaBlend(true only for BLEND2D-9's uncorrected native mapping) and derivedAdditive's expected pixel from nativePLUSbehaviour instead ofBlendState's own factor tuples. First fix attempt: derived expected alpha independently fromBlendState.cpp's real factor tuples with alpha checked in isolation (CheckAlpha), but the expected RGB values (NonPremultiplied→(255,85,0,96),Additive→(128,0,255,128)) were still wrong — see BLEND2D-24. - BLEND2D-22 (P1).
Blend2DRenderTargetRendereroverrodeHasRealDepthBuffer()tofalsebut notGetAppliedDepthStencilFormatEXT(), soRenderTarget2D.cpp(which populates the publicDepthStencilFormatproperty from that method, not fromHasRealDepthBuffer) could reportDepth24/Depth24Stencil8on a target with no depth/stencil storage at all. Added the missing override (alwaysDepthFormat::None's raw ordinal);GetMultiSampleCount()/HasDefinedMipLevelwere already correctly un-overridden (truthfully inheriting the shared0/falsedefaults), soRenderTarget2D.MultiSampleCountand mip-level usability were already correct and needed no code change — only the regression test below, which the previous "READY" pass never wrote. - BLEND2D-23 (P1/P2 regression coverage). None of
RenderTarget2D's public property truthfulness (DepthStencilFormat/MultiSampleCount/LevelCountvs. actual mip usability), a real (non-SetCustomEffect(nullptr)-only) custom-Effectrejection, or an actually-distinguishing Point-vs-Linear sampler test existed. Addedblend2d_rendertarget_property_test.cpp(Blend2D_RenderTargetProperty), driving the real publicGame/GraphicsDeviceManager/RenderTarget2D/SpriteBatchAPI (not the internal renderer directly): all fourDepthFormatvalues reportDepthStencilFormat=None/HasRealDepthBuffer=false/HasRealStencilBuffer=false; a non-defaultDepthStencilStatestill throws;MultiSampleCountclamps to 0; amipMap=truetarget'sGetData(level=1)throwsNotSupportedExceptionwhileGetData(level=0)still succeeds; and a real minimal non-stockEffectsubclass is rejected bySpriteBatch.Begin()while null/the exact stockSpriteEffectare accepted and a rejected Begin() does not poison the next Begin()/Draw()/End() cycle (which is also proven to genuinely rasterize, not just "not throw"). Separately,TestSamplerFilter(Blend2D_Correctness) was extended: its only existing check drew a uniform 2x2 texture at 1:1 scale, where Point and Linear are mathematically identical by construction. Added a minification case (a two-texel red/green source collapsed into one destination pixel, the same oracle asskia_texture_filter_minification_test.cppSKIA-43) across all 9TextureFiltervalues, proving the NEAREST family selects one pure texel and the BILINEAR family actually blends. - Second-pass oracle audit. Reviewed the remaining ~10 test functions in
blend2d_renderer_correctness_test.cppfor the same class of bug (an expected value derived from the same assumption as the implementation rather than an independent oracle): flip/origin/ rotation geometry (direct rotation-matrix derivation), sampler addressing (wrap/clamp/mirror definitions on a 2-texel texture), tint chain (straight-alpha component-multiply XNA semantics, with a second, fully independent floating-point Porter-Duff cross-check already in place for the three-alpha-values case), colour-write-channel masking (premultiplied-representation math cross- referenced againstSkiaRenderer's own masked-write precedent), viewport/scissor (direct geometric containment), and render-target bind/switch/self-sample (behavioural round-trips, not derived pixel maths). No further instances of the bug class were found beyond BLEND2D-20/21/23 above.
A second independent review of BLEND2D-20/21 (starting SHA ad779eb4d) found the READY verdict
was STILL incorrect: the alpha equation was now right, but the reasoning that the native
BL_COMP_OP_SRC_OVER/PLUS colour bytes were "already correct" for NonPremultiplied/Additive
was itself wrong, and TestBlendPresets' corrected-looking RGB expectations
(NonPremultiplied → (255,85,0,96), Additive → (128,0,255,128)) were artifacts of that
mistake, not the true XNA result.
-
BLEND2D-24 (P0, final).
Sc*Sa(the source's own premultiplied contribution,Sca) being algebraically identical between XNA's colour equation and nativeSRC_OVER/PLUS's colour equation does NOT make the two operators' full colour output identical: native compositing premultiplies the blended straight colour against the OLD destination alphaDa(Dca'=Sca+Dca*(1-Sa), which issourceStraight*Sa + destinationStraight*Da*(1-Sa)once expanded), but XNA's trueCoutmust be premultiplied against the NEWAout— andAout != DawheneverSais neither 0 nor 1, which is exactly when the independent alpha equation matters at all. Confirmed againstSkiaRenderer.cpp'sMaskedBlendEffect()runtime blender — the actual semantic oracle for this renderer's wholeNonPremultiplied/Additiveroute (perSkiaBlendMapping.hpp'sRuntimeNonPremultiplied/RuntimeAdditiveroutes, both tagged source- alpha-conventionStraight) — which recovers straight destination colour (dstColor=Dca/Da), computesCout/Aoutin straight space, and storesCout*Aout/Aout, never retaining any native-op colour byte.Reworked
ApplyIndependentAlphaCorrectionEXTintoApplyIndependentBlendCorrectionEXT(Blend2DSpriteBatchRenderer.cpp): the nativeBLCompOpdraw is now only a STAGING pass whose colour AND alpha bytes are both unconditionally overwritten. The existing scratch-canvas pass (SRC_OVER onto a transparent destination) already recovers, per pixel, both the effective source alphaSa(its alpha channel) AND the source's own premultiplied contributionSca(its colour channel, sinceDca'=Sca+0*(1-Sa)=ScawhenDca=0) — reusing Blend2D's rasterizer for interpolation/coverage/transform/filter rather than a separate geometry pass. Combined with the existing whole-surface pre-draw snapshot (Da,Dca), the correction now computes, per touched pixel:dstColor=Da>0 ? Dca/Da : 0,Cout=saturate(Sca+dstColor*colorDstFactor),Aout=saturate(Sa*Sa+Da*colorDstFactor)(colorDstFactor=(1-Sa)forNonPremultiplied,1forAdditive), then stores(Cout*Aout, Aout).Coutis saturated to[0,1]BEFORE theCout*Aoutrepremultiply (matchingMaskedBlendEffect's ownsaturate()placement) — this is not cosmetic:Additive's colour weights (Sa + 1) do not sum to 1, so an unsaturatedCoutcan genuinely exceed 1.0 (real, expected additive colour "blow out"), and premultiplying an unsaturatedCoutwould itself produce an invalid PRGB32 pixel (R,G,B > A). BecauseCoutis provably<=1after saturation,stored.rgb = Cout*Aout <= Aoutholds by construction for every touched pixel, including partial-coverage (antialiased) ones — the correction reads the SAME per-pixel scratch-canvasSca/Sathe alpha side uses, so an antialiased edge column's colour is corrected with the SAME effective coverage as its alpha, not the center-pixel-only case.TestBlendPresetsrewritten (again) with the true independent oracle:NonPremultiplied(halfRedon semi-transparent green) → old wrong(255,85,0,96)→ corrected(128,128,0,96);Additive(halfBlueon semi-transparent red) → old wrong(128,0,255,128)→ corrected(255,0,128,128). Both hand-derivations were independently cross-checked against the actual built output (not derived from it) and matched exactly, with zero tolerance needed. AddedTestPrgb32Invariant(Blend2D_Correctness): reads Blend2D's raw native premultiplied bytes directly viaBlend2DRenderer::ActiveSurface().Image().get_data()(not the straight-RGBA readback path, which would hide a violation behindBlend2DPixelConvert.hpp's defensive clamp) and assertsR,G,B <= Afor every pixel across a 48-combination source-alpha/destination-alpha sweep (Sa/Dabytes spanning0/1/mid- range/255) for both presets — 0 violations after the fix (28 checks failed with a genuineR,G,B > Aviolation againstad779eb4d's alpha-only correction, confirming the invariant is now upheld by construction, not by luck). AddedTestBlendCorrectionEdgeCoverage: an axis- aligned fractional-scale draw (SetTransformMatrix(Matrix::CreateScale(2.5f,1,1)), Point sampling to isolate coverage from bilinear blur) produces an exactly-derivable 50% analytic AA coverage column; the half-covered column's colour AND alpha were independently hand-derived by substituting the half-effectiveSainto the same Skia oracle and matched the built output exactly (again zero tolerance needed), proving the corrected colour route uses the identical per-pixel coverage the alpha side already recovers — not a center-pixel-only fix that leaves antialiased edges wrong.Blend2DPixelConvert.hpp's unpremultiply clamp remains (defensive hardening against a malformed/out-of-contract PRGB32 pixel from any future producer) but is no longer load-bearing for this renderer's own output —TestPrgb32Invariantis the regression proof of that claim, not just a comment asserting it.All 28 of the newly-added/rewritten checks above fail against
ad779eb4d(confirmed by building and running this exact test file againstad779eb4d's unmodified implementation in an isolated worktree) and pass against the corrected implementation; every one of the 305 checks that already passed underad779eb4dstill passes unchanged.
These are documented, tested-as-absent boundaries, not oversights:
- Mip chains and MSAA are not implemented for
RenderTarget2D/Texture2D;CreateRenderTarget2D'smipMap/multiSampleCountparameters are accepted, andRenderTarget2D.LevelCountechoes the requested mip-chain length as metadata (the shared cross-renderer convention), but the underlying storage is single-level/zero-sample: no level beyond 0 is ever real (GetDataonlevel != 0throwsNotSupportedException, verified byBlend2D_RenderTargetProperty) andRenderTarget2D.MultiSampleCounttruthfully clamps to 0.GraphicsCapability::MultiSampleAntiAliasingcorrectly reportsfalse. RenderTarget2D.DepthStencilFormatalways truthfully reportsDepthFormat::Noneregardless of the format requested at construction — Blend2D never allocates a real depth/stencil plane (verified byBlend2D_RenderTargetProperty).- Cube/volume textures (
TextureCube,Texture3D,RenderTargetCube) have no Blend2D representation; the sharedIGraphicsRendererdefaults (return nullptr) are the correct, established "unsupported" signal this codebase already uses consistently for a renderer with no cube/volume concept, and are relied on unchanged. - Custom
Effect/shader pipeline: rejected outright (see BLEND2D-11). Blend2D has no runtime shading language comparable to Skia's bounded SkSL route; building one was judged out of scope for a CPU vector rasterizer whose value proposition is 2D raster fidelity, not a general shader ABI. Blend2D_Smoke's presentation checks require a real X11/Wayland display (SDL_VIDEODRIVER=x11) and were not exercised by this remediation pass in a headless sandbox;Blend2D_Correctnesscovers the same construction/Clear/Present/mode-switch paths underSDL_VIDEODRIVER=dummyinstead, which does not require (and cannot fully substitute for) a real compositor's presentation path.
- Skia (
modules/renderers/skia/src/Skia*.cpp): primary architectural reference for presentation (RecreateBackbuffer/RefreshDynamicBackbufferIfNeeded), the transform/scissor/ viewport composition order inSkiaSpriteBatchRenderer::Draw, and the masked-write (ColorWriteChannels) precedent. Skia-specific features (SkSL runtime effects, mip-chain blending, mesh draws) were deliberately NOT ported — Blend2D has no equivalent primitive for any of them, and the task scope is genuine 2D raster correctness, not feature-count parity. - Software (
modules/renderers/software/src/SoftwareSpriteBatch.cpp): the authoritative in-repo reference for XNA/FNA's actual per-vertexSpriteEffectsflip semantics (UV-coordinate swap, not destination-geometry mirroring), used to re-derive Blend2D's context-transform-based flip fix (BLEND2D-8) and to confirm the viewport-local coordinate contract (BLEND2D-13). - SdlRenderer (
modules/renderers/sdl-renderer/src/SdlRenderer.cpp): reference for theTextureFilter-> single-filter-mode decomposition (BLEND2D-11) and the general "no group-wrap for non-GPU renderers" register pattern for render targets.