Render targets: usage, cube faces, resolve and readback

CNA snapshot 009d40f5  ·  Deep Dives › The graphics machine  ·  source links pinned to 009d40f5

✓

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 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read at 009d40f5; no test was run. Resolve, mip and readback statements per family are code claims with named tests, not hardware measurements.

A render target is a texture with attachment policy: what it is created with, what binding it does to the device, what survives an unbind, and when multisampled content is resolved and mip levels regenerated. This page states those rules for RenderTarget2D and RenderTargetCube as the shared layer implements them at this snapshot, then where the renderer families still differ. It complements the user guide (Render targets), which shows how to use targets, and the step-by-step binding trace on Textures and render targets internals.

RenderTarget2D: what construction decides

RenderTarget2D (RenderTarget2D.cpp) derives from Texture2D, IRenderTarget and CNA's internal IContentLosable. Because it is a Texture2D, the same object can be bound as a target and then passed to SpriteBatch::Draw or an effect with no copy. Unlike Texture2D it is move-only: a render target has a unique wrapper identity, because the device's binding list refers to it by address.

The constructor computes its format and renderer before the Texture2D base exists, so it validates everything itself:

  1. Width and height must be positive (ArgumentOutOfRangeException).
  2. Each edge must be within the profile's ceiling (2048 under Reach, 4096 under HiDef) and the aspect ratio within 2048:1 (NotSupportedException). Inside the CNAEXT engine layer an internal EngineLayerTextureSizeScope raises the edge limit to the renderer's real GetMaxTextureDimension(), because its cascaded shadow atlas is wider than any XNA ceiling; a game's own targets never get that exemption.
  3. The preferred format is substituted, not refused: see Surface formats: render-target selection.
  4. A non-power-of-two mipmapped target is allowed even under Reach. FNA's RenderTarget2D passes such targets through to its renderer, so the Texture2D Reach restriction on mipmapped non-power-of-two textures does not apply here.
  5. The multisample count is rounded down to a power of two, and 1 becomes 0 (no multisampling), mirroring FNA's MathHelper.ClosestMSAAPower.
  6. CreateRenderTarget2DEXT is called with the substituted format, the depth format, the sample count, the mip flag and a Boolean "preserve contents" derived from the usage (see RenderTargetUsage).

A family without render-target storage returns a null renderer object, and construction still succeeds. Every operation that needs storage checks at its own point of use: binding throws NotSupportedException ("this renderer does not support RenderTarget2D"), and so do SetData/GetData. This is the same null-object convention as cubes and volumes, except that Texture3D refuses construction outright on a renderer that reports no volume capability.

After creation the object reports what the renderer applied, not what was requested: getMultiSampleCountProperty() is the renderer's device-clamped count (as FNA reads FNA3D_GetMaxMultiSampleCount), and getDepthStencilFormatProperty() is the renderer's GetAppliedDepthStencilFormatEXT answer. Families may normalise the depth request (the user guide lists the known cases under DepthFormat); the GPU families map Depth16, Depth24 and Depth24Stencil8 to distinct native attachments (EasyGL chooses the GL internal format and attachment point per request, and Vulkan stores a per-instance depth VkFormat). RenderTargetCube reports its applied depth format the same way since a Vulkan fix (VULKAN-215) noted that it was the third time the cube class had lagged its 2D twin.

What binding does

Every public binding call funnels into GraphicsDevice::SetRenderTargets (GraphicsDevice.cpp). SetRenderTarget(RenderTarget2D*) and SetRenderTarget(RenderTargetCube*, CubeMapFace) are thin wrappers; nullptr means "back to the back buffer". The single-target overload used to call the renderer directly, and on a family without targets that collapsed into an ordinary back-buffer request, so every "render-to-texture" draw silently landed on the screen; routing it through the one path (REMED-GFX-PGL-AUDIT) closed that. The rules a caller can observe:

  • An unchanged set is a no-op. If the new bindings name the same resources and cube faces in the same order, the call returns before validation or renderer work, so a repeated bind neither resolves, discards nor resets the viewport. Microsoft XNA returns early in the same case (SOFTWARE-222). The one exception is after a bound target was destroyed, when even an empty set is a real transition.
  • Limits. At most four bindings (MAX_RENDERTARGET_BINDINGS, XNA's general limit), and at most the profile's count: 1 under Reach, 4 under HiDef, through the renderer's GetMaxRenderTargetsForProfileEXT. Both throw NotSupportedException.
  • Per binding. A null target throws ArgumentException; a disposed one ObjectDisposedException; one created by another device InvalidOperationException (SOFTWARE-221); a RenderTarget2D array slice other than 0 NotSupportedException; a cube face outside the six values ArgumentOutOfRangeException; a target whose renderer object is null NotSupportedException.
  • Across bindings. The same resource may not occupy two slots (compared by resource, not by face); all targets must have the same dimensions, the same applied sample count and the same pixel size. Equal byte size is enough: formats need not match exactly, as in XNA (SOFTWARE-220).
  • Ordering. The renderer is called before any public state changes. Only after it returns does the device clear each target's content-lost flag, publish the bindings, mark itself bound, reset Viewport and ScissorRectangle to the first target's size (to the back buffer's size on unbind), and perform the discard clear. A game's own viewport never survives a target switch, as in FNA.

While any target is bound, GraphicsDevice::Present() refuses to present. The rationale and the rest of the transaction are traced on Textures and render targets internals.

RenderTargetUsage: three values, one predicate

RenderTargetUsage has XNA's three values, and CNA maps them to the renderer's "preserve" flag in exactly one place, RenderTargetUsagePreservesContentsEXT in RenderTargetUsage.hpp: only DiscardContents maps to false; PreserveContents and PlatformContents map to true. That is FNA's rule. Before the helper existed the two target classes passed usage == PreserveContents while the device cleared only DiscardContents targets, so PlatformContents was preserved by the shared layer and discarded by the renderer at the same time (REMED-GFX-136).

UsageOn every bindWhat survives
DiscardContents (default)The device clears the first target to a deterministic triple: colour (0, 0, 0, 255), depth 1.0, stencil 0, each aspect only where the attachment really existsNothing; the next frame starts from that triple, not from undefined memory
PreserveContentsNo clearColour, depth and stencil, across a full unbind and rebind, through other targets or the back buffer, with or without multisampling (the multisampled depth attachment itself survives; depth is never resolved)
PlatformContentsNo clearThe same as PreserveContents

Three further rules complete the contract. An explicit Clear() inside a bind cycle is a separate, ordered command that wins over the usage policy aspect by aspect: clearing depth never touches colour or stencil (REMED-GFX-129). A target that has never been rendered into has no specified contents, and nothing survives device recreation. For RenderTargetCube, colour is per face but depth and stencil are one buffer per target shared by all six faces, as FNA allocates it, so switching faces neither switches depth buffers nor provides a fresh one.

With several targets bound, the first target's usage decides whether the bind clears, which matches FNA's first-target policy. A renderer can still narrow this: Vulkan's multi-target render pass (GetOrCreateMRTRenderPass) begins each colour attachment with a clear from an undefined layout, except when it continues a split pass, so a preserving target does not keep earlier contents when it is bound as part of a multi-target set on that renderer. The back buffer is the other gap: PresentationParameters.RenderTargetUsage is stored, cloned and exposed, but returning to the back buffer never consults it, so the back buffer is neither cleared nor explicitly preserved according to that setting. The shared fixture rendertarget_depthstencil_usage_test.cpp enforces the clauses above, including PlatformContents as preservation; it is registered for the EasyGL, OpenGL 4 (through its EasyGL parity corpus), Vulkan, WebGPU, SDL_GPU, Direct3D 9, Direct3D 11/12 (through DirectXParityTests.cmake), Software, Headless, SDL_RENDERER, Canvas and FreeDirect families.

RenderTargetCube

RenderTargetCube (RenderTargetCube.cpp) derives from TextureCube and IRenderTarget, and passes one renderer object to its TextureCube base, so sampling and rendering use the same GPU image. Its size is checked against the profile's cube ceiling (512 under Reach, 4096 under HiDef) and, under Reach, must be a power of two; the format goes through the cube-target verdict. RenderTargetBinding stores a Texture* plus a face, so a cube face is an ordinary entry in the device's binding list: the singular SetRenderTarget(cube, face) overload builds RenderTargetBinding(cube, face), and the vector form of SetRenderTargets accepts the same entries, which the device normalises into a RenderTargetBindingDescriptor that carries the face, the dimensions and the applied sample count. Whether a cube face may be one member of a multi-target set is renderer-specific (the user guide lists the families under RenderTargetCube).

// A reflection probe: render one face, then sample the cube in a later pass.
// Under Reach the edge must be a power of two and at most 512.
RenderTargetCube probe(graphicsDevice, 256, /*mipMap=*/false,
                       SurfaceFormat::Color, DepthFormat::Depth24Stencil8);

graphicsDevice.SetRenderTarget(&probe, CubeMapFace::PositiveZ);
graphicsDevice.Clear(Color::CornflowerBlue);
// ... draw the scene as seen looking down +Z from the probe ...
graphicsDevice.SetRenderTarget(nullptr);   // back to the back buffer

environmentMapEffect.setEnvironmentMapProperty(&probe);

Sampling and CPU readback are separate capabilities. The cube above can feed an effect without probe.GetData() ever being called. Public readback of a rendered face reaches the renderer, and a family that cannot read back throws NotSupportedException rather than returning fabricated transparent black. Mip and multisample scope differs per family: the Direct3D 9 cube-target renderer allocates exactly one level and reports failure for any other, while WebGPU generates a mipmapped cube target's chain per face after that face's pass.

Uploads are narrower still. RenderTargetCube inherits the whole TextureCube SetData family, exactly as in FNA, so the call compiles; it validates first and then asks the cube-target renderer to store the region. The EasyGL, OpenGL 4, Vulkan, SDL_GPU, Direct3D 11, Direct3D 12 and Software cube targets define that upload; where a family's renderer reports that it stored nothing, the public call throws NotSupportedException instead of returning normally after changing nothing.

Leaving a face

A face's multisampled content must be resolved, and its mip chain generated, before the face is sampled. When that happens is a per-family implementation detail with visible consequences:

  • Direct3D 11 and 12 track the active cube face and call FlushPendingCubeResolveEXT before every cube, 2D, multi-target or back-buffer transition.
  • EasyGL finalises the outgoing face even when switching directly to another face of the same cube: SetRenderTargetCubeFace unbinds the current cube before binding the new face, because comparing only the owning cube pointer lost every intermediate resolve and mip generation in a face-to-face sequence.
  • Vulkan resolves into the matching layer of a cube-compatible image and generates mips after each pass (see Vulkan: RenderTargetUsage and cube targets).

Resolve and mip generation

A render target's upper levels come from rendering, not from SetData, so every family that accepts mipMap = true must generate them, and every family that accepts a sample count must resolve. The observable rule is FNA3D's: resolve first, then generate mips from the resolved level 0, when the target stops being the destination.

  • EasyGL keeps a multisampled renderbuffer and resolves it with glBlitFramebuffer on unbind, then regenerates the chain with glGenerateMipmap, the order FNA3D's OPENGL_ResolveTarget uses. Multisampled targets need the ES 3.0 generation; the ES 2.0 and WebGL 1 profiles have neither the multisample storage nor the blit.
  • Vulkan resolves multisampled 2D and cube targets and runs a per-level vkCmdBlitImage cascade after the pass (details and tests on Vulkan: render-target mips, MSAA and the MRT limit).
  • WebGPU has no blit and generates each level with a render pass that samples the previous one (WebGPU: mip generation without a blit).

Other families remain capability- and format-qualified; the multisample column of the capability matrix and the render-target table in the user guide are the starting points, and a resolve or mip claim for a family needs that family's own pixel test.

Reading a target back

GetData on a RenderTarget2D prefers the renderer, because the target's pixels come from rendering and a staging copy from an earlier SetData is not authoritative (see Texture data transfer). A partial SetData on a target seeds itself from the renderer's current pixels so the untouched texels are kept, and throws NotSupportedException on a family that cannot read its colour attachment back. Reading or writing a target while it is bound throws InvalidOperationException ("The render target must be resolved before its data can be transferred."), and so does binding a bound target to a sampler slot.

Disposing and moving a bound target

Both RenderTarget2D::Dispose(bool) and RenderTargetCube::Dispose(bool) scan the device's current bindings and throw InvalidOperationException ("Disposing target that is still bound") when the target is among them, matching FNA. For a cube this works because a cube face is stored in the same binding list as a 2D target; there is no separate "a cube is bound" flag that could hide it. A C++ destructor cannot throw, so destroying a still-bound target takes a second path: Texture2D::Dispose and TextureCube::Dispose call GraphicsDevice::DetachDestroyedRenderTarget, which returns the renderer to the back buffer while the target's backend still exists, drops the dead binding, and leaves the device marked bound so that Present() keeps refusing until the game binds again. Move-assigning over a bound RenderTarget2D throws "Moving over a render target that is still bound". The lifetime rules shared with other resources are on Resource lifetime.

IsContentLost

getIsContentLostProperty() on RenderTarget2D and RenderTargetCube is a real flag, not a constant. It becomes true, and ContentLost is raised, when a renderer reports a real device reset (the device calls NotifyContentLostEXT on every tracked resource that can lose content), and it is cleared when the target is next bound, because binding is when the caller takes ownership of the contents again and a bound DiscardContents target has nothing left to describe as lost. Families whose API cannot lose a device never set it. Which families report loss at all is on Graphics architecture: device loss and reset.

How the per-renderer picture was established

Render targets are where status documents went stale fastest. CNA's own render-target support ledger once listed five per-renderer divergences as open gaps (depth-format fidelity, mip generation, multisample resolve, cube sampling, and viewport and scissor reset); reading each renderer's current source showed most of them already fixed by later work whose comments the ledger never picked up. The durable lesson is methodological: a status column is a claim about the source, and the source is the authority. Two items from that list are worth knowing in their current form. Switching targets resets the public Viewport and ScissorRectangle on every renderer, because the shared layer does it (whether a renderer then applies a custom viewport natively is a separate per-family question, answered for Vulkan on Vulkan: draw-time snapshots). And the historical Vulkan "black cube" results, a clear-only target never entering the recorded pass list and a render-then-sample sequence returning black, are described with the ordered-clear repair on Vulkan: the black-frame defects; vulkan_rendertargetcube_sample_test.cpp (Vulkan_RenderTargetCube_SampleAfterUnbind) is the regression that renders every face and samples the cube.

Evidence and its limits

Checked by reading the CNA source at 009d40f5; not executed. The binding, usage, disposal and content-lost rules are shared code, pinned by RenderTargetSemanticsTests.cpp, RenderTargetFormatAgreementTests.cpp, bound_target_lifetime_test.cpp and the usage fixture above. Resolve, mip and readback statements per family come from each family's source and name its tests where they were found; they were not re-measured here, and a family not named is not claimed.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Maintainer workflow
Fix a renderer bug
Tests and validation
Test architecture