Textures and render targets

CNA snapshot 3b43bd86  ·  Development › Graphics internals  ·  source links pinned to 3b43bd86

✓

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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. The listed tests exist at this snapshot; none was run for this page. Per-family native allocation, upload and destruction are covered only for SDL_GPU here.

The public texture is not the native texture. GraphicsResource registers a disposable C++ object with its device; Texture2D holds a shareable renderer-side ITextureRenderer; RenderTarget2D adds attachment semantics and a binding relationship with the device. These lifetimes overlap but are not interchangeable, and most resource bugs come from treating one as another. This page follows construction, upload, binding and disposal through the neutral layer and into SDL_GPU's deferred release.

Texture2D construction and data path

Texture2D(device, width, height, mipMap, format)
  → Texture / GraphicsResource(&device): register this, ResourceCreated, weak device-lifetime token
  → ValidateTextureSizeForProfileEXT, ValidateTextureDimensionEXT,
    ValidateTexture2DFormatEXT, ValidateTexture2DCreationShapeEXT
  → ImageData{ width, height, mipLevels, surfaceFormat, zero-filled level 0 }
       (block-compressed formats: ceil(w/4) × ceil(h/4) blocks, not w × h texels)
  → IGraphicsRenderer::CreateTexture(ImageData)  → unique_ptr<ITextureRenderer>
       held by Texture2D::renderer_ as shared_ptr (value copies share it)
  → cpuPixels_ (shared CPU shadow) → renderer_->ShareCpuPixels(cpuPixels_) → MaybeFreeCpuPixels()
  → SetData / GetData … bind through TextureCollection or an effect → sampled at draw time
  → Texture2D::Dispose(bool): DetachDestroyedRenderTarget(this) if the device is alive,
                              renderer_.reset(), Texture::Dispose → GraphicsResource::Dispose

The constructors in Texture2D.cpp differ in what they promise. Texture2D(assetName, device) decodes through ImageLoader::Load and immediately creates the backend texture. Texture2D(assetName) loads CPU pixels but has no device, so renderer_ stays null until the texture is attached; treating every Texture2D as GPU-backed is wrong. The dimension constructors validate the profile's size limit and the renderer's dimension, and the format constructor also validates the format and the shape (mip chain, block alignment) against the active renderer. Allocation is always IGraphicsRenderer::CreateTexture(ImageData), never GraphicsResource.

The CPU shadow is a context-loss copy. MaybeFreeCpuPixels() drops cpuPixels_ only when the device's context recovery is disabled (GraphicsDevice::SetContextRecoveryEnabled(false)); by default the shadow is kept and shared with the renderer through ShareCpuPixels(), which is what lets context-backed families re-upload after a loss. A later partial SetData on level 0 of an ordinary texture whose shadow was freed throws, rather than re-uploading a zero-filled buffer over the untouched texels. A new upload or readback path must therefore check both MaybeFreeCpuPixels() and the specific family's storage assumptions.

Renderer objects are shareable. Texture2D's copy constructor and copy assignment are defaulted (CNAEXT), so a value copy — such as the copies ContentManager hands out from its cache — shares the same renderer_ and CPU shadow and adds no registry entry. Two consequences follow in the source:

  • A full-level SetData(const Color*, int) on an ordinary texture builds a fresh renderer object instead of updating in place, so one wrapper's upload is never published to other holders of the cached renderer. Texture2DCacheReconstructionTests.cpp and the content-side cache isolation tests pin this.
  • A renderer object can outlive both the wrapper that created it and the family's renderer. On SDL_GPU every texture and target state registers itself in the renderer's liveTextureStates_ on construction and removes itself on destruction, and the renderer's teardown releases the native handles of every still-listed state and detaches it (SdlGpuRenderer.cpp), so a surviving wrapper never touches a destroyed SDL GPU device.

Why registration is not ownership

GraphicsResource(GraphicsDevice*) appends its address to GraphicsDevice::resources_ (under resourcesMutex_, because XNA lets resources be constructed and destroyed on any thread; cna-killer KF-1a), raises ResourceCreated, and copies a weak_ptr to the device's resourceDeviceLifetime_. The device uses the list to dispose resources while the renderer still exists. GraphicsResource::Dispose(bool) on an ordinary resource returns if already disposed, raises Disposing only on the explicit path, and — only if the weak token has not expired — raises the device's ResourceDestroyed and unregisters, then marks itself disposed. A C++ resource object may outlive its device; the token is what prevents dereferencing the stale raw graphicsDevice_.

Value-style states (BlendState, DepthStencilState, RasterizerState, SamplerState, VertexDeclaration) use a SharedIdentity: copy construction registers nothing, and copy assignment (plus VertexDeclaration's copy constructor) calls ShareResourceIdentityWith(), which makes the assigned object an alias of one canonical object whose device, name, tag and disposed flag are shared. The copy constructors of the four state classes instead produce an independent, mutable copy (CNA's spelling of XNA's new BlendState()), which is not an alias. Disposing any alias marks all of them disposed and, on the explicit path, raises Disposing on each with the canonical object as sender; destroying an alias only detaches it. This is why SpriteBatch::Begin assigning four states does not flood the create/destroy event stream. Moves transfer the registry entry with TransferResourceReference() (and, for shared identities, re-point the alias), and a moved texture also transfers its texture-slot and render-target bindings through TransferMovedTexture(). Any change to copy, move or dispose needs tests that cover both ordinary resources and shared identities.

When the device itself disposes, it marks itself disposed before callbacks, moves and clears the registry, and calls each tracked resource's public Dispose(); only then does it destroy the renderer and the window. Re-entrant unregistering during that loop is therefore harmless. Do not replace the registry with owning unique_ptrs without redesigning public resource allocation and value semantics: the pointer list is a disposal-order mechanism, not an allocator. The full disposal order is on GraphicsDevice internals.

RenderTarget2D is a texture with attachment policy

RenderTarget2D computes its format and renderer before the Texture2D base receives them. SelectRenderTargetFormatEXT implements XNA's “preferred” semantics: a format the profile forbids, or that the renderer classifies as Unsupported or Defer, becomes Color. CreateValidatedRenderTargetRenderer then checks positive dimensions, the profile's maximum size, a 2048:1 aspect-ratio limit and the renderer's format verdict, rounds the multisample request down to a power of two, and calls CreateRenderTarget2DEXT. A family without render-target storage returns null and construction still succeeds; binding such a target later throws NotSupportedException (see which families have real render targets).

The protected base constructor marks the content GPU-only (gpuOnlyContent_): the live renderer is the sole authority for the pixels, so a full-level SetData updates the target in place and keeps no CPU shadow, and a partial update seeds itself from the renderer's GetData (or throws if the family cannot read back). The target records the depth format the renderer actually applied and the renderer-clamped sample count, not the requested ones. rtRenderer_ is a borrowed IRenderTargetRenderer* into the object the base texture's renderer_ owns; RenderTarget2D::Dispose clears it after base disposal so GetRenderTargetRenderer() cannot return a dangling pointer. RenderTargetCube has its own face-binding path (RenderTargetBindingDescriptor::ForRenderTargetCubeFace); it is not a disguised 2D target.

SetRenderTargets: neutral transaction

GraphicsDevice::SetRenderTargets — the path every SetRenderTarget overload now funnels into, so there is one place to keep correct:

  1. Compare resource identity and cube face with the current set. An unchanged set returns immediately, without resolving, discarding or resetting viewport and scissor; an empty-to-empty call is therefore a no-op — except after a bound target was destroyed, when boundRenderTargetDestroyed_ forces a real transition even though no binding remains to compare.
  2. Enforce XNA's four-binding ceiling (MAX_RENDERTARGET_BINDINGS), then the renderer's GetMaxRenderTargetsForProfileEXT() (default: one under Reach, four under HiDef).
  3. Empty set: renderer_->SetRenderTargets(nullptr, 0) first; only after it returns clear the public bindings and both flags and reset viewport and scissor to the back-buffer size.
  4. For each binding reject null, disposed and foreign-device targets; convert a RenderTarget2D (array slice must be 0) or a cube face (face range checked) into a RenderTargetBindingDescriptor; a missing renderer object is NotSupportedException.
  5. Reject a resource bound to two slots, mismatched dimensions, mismatched applied sample counts and mismatched pixel sizes. Equal byte size is compatible even when the exact formats differ, as in XNA.
  6. Call renderer_->SetRenderTargets(descriptors, count) before touching public state. Only after it succeeds: clear each target's content-lost flag, publish currentRenderTargets_, set renderTargetBound_, clear boundRenderTargetDestroyed_, reset viewport and scissor to the first target, and for DiscardContents clear colour to opaque black plus whichever depth and stencil buffers really exist.

Because a target is a writable attachment, reordering these steps could report a successful bind, or restored content, while the native bind threw. GraphicsDevice::Present() refuses to present while renderTargetBound_ is true; code that implements “render target to back buffer” must unbind through SetRenderTargets and then present. Changing only the renderer's native target without the device's public flag breaks the invariant. Reset() unbinds for the same reason. The user-level contract is on Render targets: setting the render target and restoring the back buffer.

Why SDL_GPU cannot free on unbind

SdlGpuRenderer::SetRenderTargets routes a single cube face through its own route; every other bind, a multi-target set holding cube faces included, opens a new pass segment, one native render pass, with every attachment's own format in the pipeline description. An empty set opens a new back-buffer segment.

The native textures belong to SdlGpuRenderTarget2DState, SdlGpuRenderTargetCubeState and SdlGpuSampledTextureState objects, which segments and queued draw commands keep alive through shared_ptr; a destructor hands its textures to QueueTextureRelease instead of releasing them, and the renderer drains those releases only after the frame's command buffer was submitted. Freeing a target's texture at unbind time would invalidate passes still queued for the frame. This is a family-specific lifetime rule layered underneath the neutral transaction; SDL_gpu renderer internals covers the pass segments and the rest of the deferred-release machinery.

Bound target disposal has two paths

RenderTarget2D::Dispose(bool) throws InvalidOperationException (“Disposing target that is still bound”) when the target is still in the live device's bindings. A C++ destructor cannot throw, and by the time ~Texture2D runs the object is no longer a RenderTarget2D, so the destructor path reaches Texture2D::Dispose(bool), which calls GraphicsDevice::DetachDestroyedRenderTarget(this) before resetting the backend object. That helper, when the target is bound:

  • asks the renderer to return to the back buffer while the target's backend still exists (errors swallowed — it is noexcept), because not every family detaches a dying backend itself (EasyGL does; Software keeps a raw pointer it would only unbind on the next transition);
  • clears currentRenderTargets_, so GetRenderTargets(), draws and the identity comparison can never see the dead pointer (or treat a new target built at the same address as already bound);
  • sets boundRenderTargetDestroyed_ and deliberately leaves renderTargetBound_ set, so Present() keeps refusing until the caller explicitly calls SetRenderTargets again.

The split prevents use-after-free without pretending a valid back-buffer transition occurred. Move-assigning over a bound RenderTarget2D is also rejected (“Moving over a render target that is still bound”). The shared fixture bound_target_lifetime_test.cpp is registered as <Family>_BoundTargetLifetime for EasyGL, Headless, SDL_gpu (also in per-leg variants), Software and WebGPU.

Change and validation guide

For a new surface format, start with the public shape and format checks in Texture.cpp and Texture2D, then the renderer's ClassifySurfaceFormatEXT and ClassifyRenderTargetFormatEXT (a family with no classifier accepts Color only), then upload, readback and attachment for each family that claims it. Test ordinary sampling and render-target use separately; a format can support one and not the other.

For a render-target transition bug, first establish which of three things happened: validation failed before the renderer was called, the renderer threw during the transition, or public state diverged after a successful native call. Cover same-binding, empty-to-empty, cube face, MRT, cross-device, move, destruction while bound and Present-after-unbind. For a texture upload bug, cover an ordinary texture, a value copy from the content cache, a render target, and the context-recovery-disabled case. Renderer conformance is required beyond neutral unit tests; SDL_GPU additionally needs validation output checked and multi-frame coverage of its deferred releases.

Test sourceEstablishes
Texture2DTests.cppPublic texture construction, format acceptance per renderer, transfer contracts.
Texture2DCacheReconstructionTests.cppCPU-shadow round trips, detaching on upload, no aliasing between reconstructed wrappers, render targets keeping in-place semantics.
RenderTargetSemanticsTests.cpp, RenderTargetFormatAgreementTests.cppBinding semantics, and agreement between SupportsSurfaceFormatAsRenderTargetEXT and the constructor's preferred-format selection for every SurfaceFormat.
bound_target_lifetime_test.cpp and the SDL_GPU SdlGpu_RenderTargetLifetime, SdlGpu_DeferredSourceLifetime, SdlGpu_MRT and SdlGpu_RenderTarget2DMSAA registrationsDestruction while bound, and deferred release on the family that defers.

These tests establish public semantics; they do not necessarily cover every native format translation or GPU completion order, and none was run for this page.

Read in lifecycle order

  1. GraphicsResource.cpp: registration, the weak lifetime token and shared identities, before any concrete resource.
  2. Texture2D.cpp: constructors, MaybeFreeCpuPixels, the full-level and partial SetData paths, move and Dispose.
  3. RenderTarget2D.cpp: format selection, validated native creation, applied parameters, bound-disposal and bound-move refusal.
  4. GraphicsDevice.cpp: SetRenderTargets, DetachDestroyedRenderTarget, TransferMovedTexture, Present and Dispose.
  5. SdlGpuRenderer.cpp: CreateTexture, SetRenderTargets, the render-target state classes, QueueTextureRelease and the release drain after submit.

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

Tests and validation
Test architecture