Render-target binding, clears, viewport and scissor across renderers

CNA snapshot 3b43bd86  ·  Deep Dives › Renderers  ·  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. Shared rules read in GraphicsDevice.cpp at c6dec91f and re-checked at 3b43bd86; family rows from the named hooks or a source search. No test was executed and the Windows, macOS and browser paths were not observed.

Binding a render target, clearing it and restricting drawing to a viewport or scissor rectangle look like simple setters, but in CNA each one is a shared transaction in GraphicsDevice followed by a family-specific native operation. This page states the shared rules at snapshot 3b43bd86 — the order of validation, renderer call and public state, when a rebind is a no-op, what RenderTargetUsage really selects, which clear requests throw — and then what each renderer family does with the result. It is for anyone debugging a black target, a clear that wiped too much or too little, or a split-screen that works on one renderer only, and for test authors who need a check that a wrong implementation would fail.

Binding is one transaction

Every public binding call — SetRenderTarget(RenderTarget2D*), the cube-face overload and SetRenderTargets — funnels into GraphicsDevice::SetRenderTargets in GraphicsDevice.cpp. Each binding is normalised into a RenderTargetBindingDescriptor that names a 2D target or one cube face together with its dimensions and applied sample count; the first descriptor then decides the viewport, the scissor and the discard clear for the whole set. Before any renderer work the shared layer rejects more than four bindings, more than the profile allows (one under Reach), a null, disposed or foreign-device target, a non-zero 2D array slice, a cube face out of range, a target whose renderer object is null (NotSupportedException), the same resource in two slots — including two faces of one cube — and mismatched dimensions, applied sample counts or pixel sizes. Only then does it call the renderer, and only after the renderer returns does it publish the bindings, reset viewport and scissor and perform the discard clear, so a failed native bind never leaves the device claiming success. The step-by-step trace, including destruction of a bound target, is on Textures and render targets: SetRenderTargets.

One shape is refused by some families only, at the renderer: a cube face inside a multi-target set. DIRECTX9 and WEBGPU refuse it with NotSupportedException while their MultipleRenderTargets answer stays true, a documented capability boundary; SDL_GPU, which refused it too, binds a face in any slot since 24 October 2026, and HEADLESS accepts it (see RenderTargetCube).

Rebinding the same set is a no-op

An unchanged binding set returns immediately — before validation, renderer work, resolve, discard or the viewport reset. The comparison is by target resource and selected cube face, so an empty-to-empty call is also a no-op; the one exception is a set whose bound target was destroyed, where the next call is a real transition even though nothing remains to compare. This matches Microsoft XNA 4.0, and it is recent: descriptions that say CNA has no redundant-binding early return, so that rebinding a DiscardContents target clears it again and repeats resolve work, describe an older revision. Code ported from such advice, which deliberately re-binds to force a clear, now needs an explicit Clear.

RenderTargetUsage: two answers, not three

RenderTargetUsagePreservesContentsEXT(usage) is the single mapping every renderer receives: PreserveContents and PlatformContents both mean "preserve", only exact DiscardContents means "discard". Discard is implemented once, in the shared layer: after a successful bind whose first target is DiscardContents, the device clears colour to opaque black, depth to 1 and stencil to 0, including each depth or stencil aspect only when the first descriptor's renderer object reports that plane as really present (HasRealDepthBuffer, HasRealStencilBuffer). A renderer therefore needs no load action to carry a usage decision; EasyGL deliberately ignores the preserveContents factory argument because an FBO bind never touches its attachment. The back buffer's own usage is outside this policy: only the first bound target's usage is consulted. The public reference is RenderTargetUsage.

Multiple targets and the first-target rule

Because the first descriptor owns viewport, scissor, discard and the depth buffer, an MRT set behaves like its first target in every shared respect. The stock effects write attachment 0 only; only a custom ShaderEffect with outputs at locations 0 to 3 fans out. Which families bind real MRT storage — Software up to four CPU targets, WebGPU two to four, the ES 3 generation of EasyGL, SDL_GPU, DirectX 9 and FNA3D — is tabulated on Render targets: MRT. A true MultipleRenderTargets answer (false under Reach on every renderer, because the device ANDs it with the profile limit) must therefore be paired with a successful multi-target bind and an oracle that checks every intended output, not only attachment 0.

Target transitions are family work. A family that defers or multisamples must finalise the outgoing target — resolve, regenerate mips — before another face, 2D target, MRT set or the back buffer becomes active. EasyGL finalises the MRT set and the outgoing target on every switch, including a switch between faces of the same cube (older descriptions that it could skip the outgoing face are stale; see EasyGL internals); SDL_GPU keeps a destroyed target's native state alive until the frame that names it has been submitted (SDL_GPU target lifetime), and DirectX 9 resolves a multisampled target and regenerates its mip chain when it is unbound.

Clears

What the device sends

GraphicsDevice::Clear(ClearOptions, Color, depth, stencil) converts the colour once (the Vector4 overload quantises through Color first, as XNA does), normalises depth, and dispatches exactly one of seven renderer entry points — ClearColorDepthAndStencil, ClearColorAndDepth, ClearColorAndStencil, ClearDepthAndStencil, Clear (colour only), ClearDepth, ClearStencil — all pure virtual. Aspects are not silently masked: if the request names depth or stencil and the active target or back buffer has no real plane for it, the call throws InvalidOperationException, as Microsoft XNA does. Clear(Color) instead uses the device's default options, which include depth and stencil only where those planes really exist, with depth 1. The raw Clear(float r, g, b, a) overload calls the renderer's colour-only entry point directly.

What each family does

FamiliesClear behaviour at this snapshot
EasyGLImmediate glClear. The colour-only entry point clears colour only (it formerly added the depth bit, so a ClearOptions::Target request also wiped depth); every clear temporarily disables the scissor test and forces all colour write channels, because XNA clears the complete target regardless of scissor and ColorWriteChannels, then restores both. Details on EasyGL clears
WEBGPU, SDL_GPURecorded and replayed in submission order across deferred pass segments; see WebGPU ordered clears
DIRECTX9Native clear whose depth and stencil flags follow the active target's depth format, tracked on every target bind (it formerly consulted the back-buffer format even while a differently formatted target was bound); the viewport and an enabled scissor are lifted for the clear, so it covers the whole target as XNA's does (Direct3D 9 clears)
SOFTWAREColour, depth and stencil cleared in its CPU arrays
HEADLESSValidates and records the request; there are no attachments to clear

No capability member reports whether a clear is aspect-selective or how it orders against surrounding draws. A test must prime each aspect, clear only one, and then observe the others after intervening work: an unchanged colour pixel alone does not prove that a depth-only clear happened, and a colour check alone does not prove that a colour-only clear left depth alone. easygl_clear_overloads_test.cpp is an example of the overload matrix on one family.

Viewport and scissor

The shared rules

setViewportProperty and setScissorRectangleProperty validate against the active surface — a viewport outside the target, with a non-positive size or with an unordered or out-of-range depth range throws ArgumentException, as does a scissor rectangle outside the surface — then map the logical rectangle onto the presentation rectangle (MapLogicalRectToPresentation; the identity while a render target is bound, because a target's rectangles are in its own pixels), call the renderer hook, and only then commit the public value. The public property keeps the logical rectangle the game assigned. Scissor enable never travels with the rectangle: it arrives separately in ApplyRasterizerState from the current RasterizerState. A getter round trip therefore proves accepted bookkeeping, not rasterisation.

Binding a target resets both rectangles to the first target's size, and returning to the back buffer resets them to the back-buffer size; construction establishes both full rectangles. Present() and a reset that leaves the logical size and the physical presentation rectangle unchanged preserve a game-set viewport, because UpdateViewportFromWindow resets only when one of them changed (GraphicsDevice internals). No capability member reports viewport or scissor support.

What each family does

FamiliesViewport and scissor at this snapshot
EasyGLApplied immediately to GL state, Y-flipped against the active target's height, for the back buffer, 2D targets and cube faces; EasyGL's SpriteBatch keeps a custom sub-viewport and builds its projection from the viewport's logical size
WEBGPU, SDL_GPUCaptured per deferred draw and replayed with it; see the family pages
DIRECTX9, FNA3DOverride both hooks and apply them through their own native state
SOFTWARESnapshots the viewport transform and a clip rectangle (framebuffer ∩ viewport ∩ scissor when enabled) per draw, for 2D and 3D alike
HEADLESSValidates and records; nothing is rasterised

An enabled zero-area scissor is one place the families still differ: SDL_GPU expands it to the full target, while EasyGL, SOFTWARE and WEBGPU clip the draw entirely, as an empty glScissor box does; the shared deferred_scissor_capture_test.cpp records each family's answer. The user-level description of both properties is on Graphics state: Viewport and ScissorRectangle. easygl_scissor_test.cpp (disabled, enabled, disabled again) is the shape of a test that a wrong implementation fails.

Evidence and limits

The shared rules were read in GraphicsDevice.cpp at c6dec91f and re-checked at 3b43bd86; the family rows come from reading the named hooks or, where a row says only that a family overrides a hook, from a source search. No test was executed; the Direct3D and browser rows in particular were not observed on their hosts.

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: GPU tests