Texture data transfer: SetData, GetData, mip levels and streams

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. A GetData round trip on a plain texture proves only the shared layer, as the page explains.

This page explains exactly what happens when CNA code moves pixels into or out of a texture: which SetData and GetData overloads exist, how their argument windows are validated, why the whole-level and region overloads update the renderer differently, what the CPU shadow is and why a successful GetData can prove nothing about the GPU, how mip levels, compressed blocks, cube faces and volumes are transferred, and how FromStream decodes images. It is for readers writing texture code that must behave the same on several renderers, and for anyone writing a test that is supposed to prove a renderer stored what it was given.

Texture2D: a shareable value wrapper

Texture2D (Texture2D.hpp) derives from Texture, which derives from GraphicsResource. Its renderer object is held as a std::shared_ptr<ITextureRenderer>, and its copy constructor and copy assignment are defaulted (CNAEXT), so a value copy shares the same renderer object and CPU shadow. That is what lets FromStream return by value, SpriteFont hold its glyph texture by value, and ContentManager hand every caller a copy of one cached texture. The shared state has two consequences worth knowing: a copy is not registered with the device (see Resource lifetime), and a whole-level upload through one copy must not rewrite the texture every other copy sees, which is why that upload builds a new renderer object (see two SetData paths). Moves transfer the renderer, the shadow and any device bindings.

The constructors differ in what they promise:

ConstructorWhat it creates
Texture2D(device, width, height)A zero-filled SurfaceFormat::Color texture, one level; profile size and renderer dimension checks only
Texture2D(device, width, height, mipMap, format)The requested format and a complete mip chain when mipMap is true, after the profile and renderer gates on Surface formats
Texture2D(assetName, device) (CNAEXT)Decodes an image file through the internal image loader and creates the renderer texture immediately
Texture2D(assetName) (CNAEXT)Decodes into CPU pixels only; the renderer object stays null until the texture is attached, so this is not a GPU texture
FromStream, DDSFromStreamEXTSee Decoding streams

Real XNA has no file-path constructor, because XNA loads textures through the compiled content pipeline; the two path overloads are CNA extensions, as are the escape hatches GetRenderer(), GetRendererWeak(), GetCpuPixelsWeak(), HasRenderer(), CreateFromPixels(), SetDataRGBA(), ReconstructFromCache() and the two ...ForTests factories. They exist for CNA's content, cache and test code, not for game code.

The transfer window

Every overload validates its arguments before anything is converted or sent to a renderer, so a rejected call cannot have changed a texel. The rules come from measured Microsoft XNA behaviour and are stricter than FNA's in one respect that the source spells out (REMED-GFX-149 in Texture2D.cpp):

  • startIndex indexes the caller's array, never the texture; it is not a byte offset.
  • elementCount must equal the requested region's element count exactly. A surplus window is as invalid as a short one, even when the backing array is larger: XNA compares elementCount × sizeof(T) with the exact region size, and CNA reproduces that rather than FNA's looser capacity rule. A mismatch throws System::ArgumentException ("total data size does not match the requested region").
  • startIndex + elementCount is computed in 64 bits and rejected when it exceeds INT_MAX. Computed as int it wrapped, passed every later bound and produced out-of-bounds copies, which the source records as a measured crash, not a rejected call.
  • A level outside [0, LevelCount) throws InvalidOperationException; a rectangle that leaves the level throws ArgumentException; a null pointer throws ArgumentNullException; a disposed texture throws ObjectDisposedException.
  • Resource-in-use rules, shared by all texture kinds through Texture::ThrowIfDataTransferResourceInUseEXT: a texture currently bound as a render target can be neither read nor written ("The render target must be resolved before its data can be transferred."), and a texture bound to a pixel sampler slot, or under HiDef to a vertex sampler slot, cannot be written ("The texture resource is in use."), although it can still be read.

Setting the environment variable CNA_TEXTURE_TRANSFER_TRACE prints, for every Texture2D::GetData, the overload, level, region, destination window and whether the bytes came from the renderer or from the CPU shadow, so the shared layer's interpretation can be compared with a renderer's without a debugger.

Typed overloads and the generic template

XNA's SetData<T>(T[]) is a single generic method. CNA offers both shapes: concrete overloads for Color, the twelve packed types (Bgr565, Bgra4444, Bgra5551, Rgba1010102, Alpha8, Rg32, Rgba64, NormalizedByte2, NormalizedByte4, HalfSingle, HalfVector2, HalfVector4), float, Vector2, Vector4, and the CNAEXT std::uint8_t and std::uint16_t element types; plus a constrained template SetData<T>/GetData<T> for any trivially copyable T, which transfers the object representation. Each comes in a whole-level form, a source-window form (data, startIndex, elementCount) and a region form (level, rect, data, startIndex, elementCount). nullptr_t overloads keep older call sites that passed a literal null resolving to the Color overload.

The element type is checked against the storage, not against a name: the element's byte width must be at most the texel size and divide it evenly, so a Single texture accepts float or bytes but not Vector2. The Color overloads transfer four raw bytes per texel in R, G, B, A order; on a renderer that promotes them, ColorBgraEXT and ColorSrgbEXT use the same overload, so the same four bytes are then interpreted as B, G, R, A or decoded from sRGB when sampled. For a format whose texel is not four bytes wide, the Color overload packs each colour's 32-bit packed value instead. Which of these routes is meaningful depends on whether the selected renderer stores the requested layout at all; the constructor refuses a format the renderer cannot store (see Surface formats: the renderer's verdict).

Two SetData paths

The simple whole-texture call and the region call do not update the renderer the same way.

SetData(const Color*, count) on a four-byte format converts the complete level-0 image to RGBA bytes and then, on an ordinary texture, calls IGraphicsRenderer::CreateTexture for a new renderer object and replaces this wrapper's shared reference with it. Updating in place would publish the upload to every other holder of the same cached texture, which is the aliasing that CNA's content-cache isolation tests pin (REMED-GFX-223). On a RenderTarget2D, whose renderer object also carries the target attachment, the same call updates level 0 in place with ITextureRenderer::UpdatePixels and keeps no CPU copy. A texture without a device returns without doing anything.

The region overload patches CNA's CPU shadow first and then sends the whole reconstructed level to the renderer: UpdatePixels for level 0 and UpdatePixelsLevel(level, …) for higher levels, even when the caller supplied a small rectangle. The renderer contract has no sub-rectangle upload, so the cost of a small update is proportional to the level, not to the rectangle. The std::uint8_t and typed overloads share this path (SetDataBytes).

The CPU shadow and context recovery

An ordinary Texture2D keeps level 0 in cpuPixels_ and every authored upper level in extraMipLevels_, and shares level 0 with its renderer through ShareCpuPixels. The shadow exists so that GL-context-backed families can re-upload after a context loss. GraphicsDevice::SetContextRecoveryEnabled(false) makes MaybeFreeCpuPixels() drop level 0's shadow after each full upload, which saves roughly one copy of texture memory per texture and has two costs:

  • A later partial update of level 0 throws std::runtime_error instead of re-uploading a zero-filled buffer over the texels outside the rectangle.
  • A later GetData of level 0 throws "no CPU-side pixel data available" instead of reading whatever the renderer currently holds.

A render target never trusts a shadow: every mip of a target can be regenerated by a later render pass, so a partial update seeds a temporary full-level buffer from the renderer's own GetData and throws NotSupportedException if that family cannot read the target back.

Why GetData can hide a failed upload

For an ordinary texture, Texture2D::GetData reads cpuPixels_ or extraMipLevels_. It does not read the sampled GPU resource. A SetData/GetData round trip on a plain texture therefore proves that the shared layer stored the bytes, and nothing about whether the renderer did: it would still pass if the renderer's upload were an empty function. CNA's own test file easygl_texture2d_mip_test.cpp says so in its header ("No framebuffer readback — pure CPU shadow buffer"). The WebGPU texture readback test deliberately calls IGraphicsRenderer directly for the same reason (see WebGPU: Texture2D GetData on the GPU path).

A GPU-side claim needs one of three proofs: a draw that samples the texture followed by a pixel readback (GetBackBufferData, or GetData on a render target that was drawn into), a renderer-level readback, or a comparison with a reference image. The two exceptions to "GetData reads the shadow" are a RenderTarget2D, whose readback prefers the renderer (falling back to a staging shadow only when the family cannot read its colour attachment, and throwing NotSupportedException when there is neither), and an upper mip level that the renderer itself defines (next section).

Mip levels

A mipmapped Texture2D always owns the complete chain, halving each dimension until 1×1. Upper levels are authored by the game with SetData(level, …), as in XNA; CNA does not promise that a renderer generates them after a level-0 upload. Two renderer answers qualify that statement:

  • ITextureRenderer::HasDefinedMipLevel(level) reports that the renderer owns deterministic, readable bytes for a level the game has not authored. Only then does GetData of an unauthored upper level read the renderer; otherwise it throws "no CPU-side pixel data for requested mip level". A partial update of such a level is likewise seeded from the renderer's bytes, so texels outside the rectangle are not replaced with zeros. The interface default is false, because allocated GPU mip storage is not necessarily initialised or readable; at this snapshot the EasyGL, OpenGL 4, SDL_GPU, FNA3D, Metal, Software and Direct2D families override it.
  • The WebGPU renderer regenerates every upper level after each write to level 0 of a plain mipmapped texture, including a partial one, which overwrites levels the game authored earlier; this is a documented, deliberate divergence described on WebGPU: mip generation without a blit.

Families differ in whether an upper level exists natively. The GPU families allocate and update native mip subresources through UpdatePixelsLevel; bounded 2D families refuse: the Canvas and FreeDirect texture renderers throw for any level above 0. Because the public shadow still accepts the bytes, only a sampling or readback test proves that rendering can consume a level; Vulkan_Texture2D_Mip_RoundTrip and sdlgpu_texture2d_mip_storage_test.cpp are examples of renderer-engaged mip tests.

Cubes and volumes. TextureCube and Texture3D take the same mipMap flag (a complete chain for authored per-level data, never generated by an upload), and their region and box overloads take the level. Authored levels are uploaded and read back for real on the families that store cubes and volumes natively. The evidence is the EasyGL fixtures easygl_texturecube_mip_test.cpp and easygl_texture3d_mip_test.cpp (EasyGL_TextureCube_Mip_RoundTrip, EasyGL_Texture3D_Mip_RoundTrip), which the Vulkan (Vulkan_TextureCube_Mip_RoundTrip, Vulkan_Texture3D_Mip_RoundTrip), SDL_GPU (SdlGpu_EasyGLOracle_texturecube_mip, SdlGpu_EasyGLOracle_texture3d_mip) and Direct3D 11/12 parity registrations reuse as oracles, plus WebGPU_TextureCube_GetData, which includes a cube mip-level-1 round trip. As for plain 2D textures this concerns explicitly supplied levels; whether a renderer also generates a chain on its own (WebGPU does, with a render pass) is a separate per-family behaviour.

Compressed blocks

When the renderer transfers a DXT or BC format as blocks (IsCompressedTransferFormatEXT), the byte overload switches to SetCompressedDataBytes, and the rules change:

  • Rectangle coordinates stay in texel space, but a partial rectangle must start on a 4×4 block boundary and be block-aligned or reach the level's real edge (which, for a non-power-of-two level, can fall inside a partial tail block).
  • elementCount is the exact padded block byte count, ceil(w/4) × ceil(h/4) × blockBytes. The source notes that this is deliberately more exact than FNA's w × h × size / blockSize² formula, which under-counts a rectangle whose edge ends inside a tail block.
  • No compressed CPU shadow exists. A partial update reads the whole level back from the renderer, patches the block rectangle and re-uploads the level; a family that cannot read blocks back throws NotSupportedException.

On renderers that do not transfer blocks, DXT content is decoded to Color by the loaders before it reaches a texture (see Surface formats: decoded or native).

TextureCube and Texture3D transfers

Both types derive from Texture at this snapshot, so both can be stored in GraphicsDevice.Textures and both are unbound from the device's pixel and vertex texture collections when disposed. Texture3D is move-only (its move constructor was added when the XNB Texture3D reader needed one); TextureCube is copyable like Texture2D. Their transfer contracts are simpler than Texture2D's in one important way: every GetData reaches the renderer. Texture3D keeps no CPU copy at all, and TextureCube keeps only a level-0 Color copy per face, shared with its renderer for context recovery and never used to answer GetData.

  • TextureCube::SetData(face, …) and GetData(face, …) take the face first; a region form adds level and rectangle. The window must be exact, as for Texture2D. A face outside PositiveX…NegativeZ throws InvalidOperationException, after the size and window checks.
  • Texture3D transfers take a box, (level, left, top, right, bottom, front, back); an empty or inverted box throws ArgumentException ("The box position or size is invalid."), and so does one that leaves the level.
  • Both accept typed and packed element types through templates, not only Color, for the formats the renderer stores.
  • A family that stores nothing, or cannot read back, makes the public call throw NotSupportedException. Before REMED-GFX-130/135 a missing readback produced a zero-filled region that looked like transparent-black content, and a dropped upload returned normally; the shared layer now converts only a region the renderer reports it filled.

At this snapshot the EasyGL, OpenGL 4, Vulkan, WebGPU, SDL_GPU, Direct3D 9/11/12, FNA3D, Metal and Software families implement real cube and volume readback (framebuffer reads, staging copies or map/lock calls), and Software's texture renderers are CPU arrays. HEADLESS validates and traces uploads but stores no pixels, so its cube SetData and GetData report failure and the public calls throw; Texture3D cannot be constructed on it at all, because it reports no Texture3D capability. Families without a factory (for example PORTABLEGL) leave the renderer object null, and every transfer throws.

Example: six faces, six SetData calls

Every face of a cube is square and the same size, so filling a solid-colour test cube is a loop over the six CubeMapFace values. Under the default Reach profile the edge must be a power of two and at most 512.

const int faceSize = 128;
TextureCube skybox(graphicsDevice, faceSize, /*mipMap=*/false, SurfaceFormat::Color);

const CubeMapFace faces[6] = {
    CubeMapFace::PositiveX, CubeMapFace::NegativeX,
    CubeMapFace::PositiveY, CubeMapFace::NegativeY,
    CubeMapFace::PositiveZ, CubeMapFace::NegativeZ,
};
const Color faceColors[6] = {
    Color::Red,   Color(128, 0, 0, 255),   // +X, -X
    Color::Green, Color(0, 128, 0, 255),   // +Y, -Y
    Color::Blue,  Color(0, 0, 128, 255),   // +Z, -Z
};

std::vector<Color> pixels(faceSize * faceSize);
for (int i = 0; i < 6; ++i) {
    std::fill(pixels.begin(), pixels.end(), faceColors[i]);
    skybox.SetData(faces[i], pixels.data(), static_cast<int>(pixels.size()));
}

// Reaches the renderer's own readback; throws NotSupportedException where there is none.
std::vector<Color> check(faceSize * faceSize);
skybox.GetData(CubeMapFace::NegativeY, check.data(), static_cast<int>(check.size()));

A useful readback test distinguishes faces, off-origin regions and mip levels (the example uses a different colour per face for that reason); asserting only that the call returned proves nothing.

Binding a cube or volume to a custom ShaderEffect

ShaderEffect (ShaderEffect.cpp) has CNAEXT SetTexture(int unit, TextureCube&) and SetTexture(int unit, Texture3D&) overloads beside the Texture2D& one. They forward to IEffectRenderer::BindTextureCube and BindTexture3D, whose defaults do nothing; at this snapshot the EasyGL, OpenGL 4, Vulkan, WebGPU, SDL_GPU, Direct3D 11 and Direct3D 12 effect renderers implement both. Whether a bound volume is actually sampled is reported separately by RendererFeature::Texture3DSampling in the capability profile, which the EasyGL, OpenGL 4, Vulkan, WebGPU and SDL_GPU families claim. EnvironmentMapEffect's EnvironmentMap is the stock-effect route to a cube. The device's Textures collection is a third route, but only for compiled XNA Effect passes, which bind their samplers through it; a ShaderEffect, a pair of source strings, is bound through SetTexture.

The EasyGL test easygl_shadereffect_texture3d_test.cpp (EasyGL_ShaderEffect_Texture3D) shows how to make such a proof discriminating. It uploads a 1×1×2 volume with a red slice 0 and a blue slice 1, and samples it at z = 0.25 and z = 0.75, the two texel centres given by (slice + 0.5) / depth; linear filtering exactly at a texel centre puts the full weight on that texel, so no blending tolerance is needed. It then creates and uploads an all-black decoy volume after the real one, so that a SetTexture call that failed to rebind would leave the decoy bound and the test would read black. The test passes only for the right reason. The shape of the calls, read-checked against the headers (the effect's shader sources are omitted):

// Texture3D needs GraphicsProfile::HiDef and a renderer reporting GraphicsCapability::Texture3D.
Texture3D volume(graphicsDevice, 1, 1, 2, /*mipMap=*/false, SurfaceFormat::Color);
const Color slices[2] = { Color(255, 0, 0, 255),    // z slice 0
                          Color(0, 0, 255, 255) };  // z slice 1
volume.SetData(slices, 2);

effect->Apply();
effect->SetTexture(0, volume);                   // binds the volume to unit 0
effect->SetUniformInt("VolumeSampler", 0);
effect->SetUniformVec3("coord", 0.5f, 0.5f, 0.25f); // centre of slice 0

Decoding streams: FromStream and DDSFromStreamEXT

Texture2D::FromStream(device, stream) follows the classic XNA contract, which CNA measured on the Microsoft runtime: it accepts only PNG, JPEG and GIF containers (checked by signature), decodes them with the vendored stb_image decoder into RGBA8 Color, and rejects everything else, including valid BMP, TGA, QOI, PSD, HDR and PPM files and valid DDS data, with InvalidOperationException ("Decoding image failed!"). Neither SDL3 nor SDL3_image takes part. The internal decoder understands more formats for CNA's own consumers; AVIF, TIFF and WebP are not supported at all. DDS goes through the explicit CNAEXT DDSFromStreamEXT, which recognises DXT1, DXT3 and DXT5, validates the header, dimensions and every level with DDS-specific exceptions, accepts either level zero only or the complete chain (a partial prefix is refused rather than padded with invented levels), and keeps native blocks where the renderer asks for them. TextureCube::DDSFromStreamEXT decodes cube DDS files to Color.

Stream handling follows XNA: the stream must be seekable (ArgumentException naming stream otherwise, before anything is read), decoding starts at the current position, and a stream whose position already equals its length is rewound first. The CNA document docs/texture-stream-formats.md records the conformance matrix and its tests (Texture2DFromStreamFormatTest, Texture2DFromStreamResizeTest).

The five-argument overload FromStream(device, stream, width, height, zoom) validates width and then height before reading any data and never keeps compressed blocks. Its zoom flag matters more than its name suggests:

  • zoom = false scales the image to fit, preserving aspect ratio, but chooses the scale from the source's larger dimension: the width for a landscape image, the height for a portrait or square one (ImageLoader::ResizeRgba tests width > height). It is not a general min(width/w, height/h) fit: it assumes a roughly square target box, and the result is neither padded nor cropped, so one dimension matches the request (up to truncation) and the other may be smaller or larger than requested.
  • zoom = true chooses the scale from the source's smaller dimension (the width for a portrait image, the height for a landscape or square one), scales by it and centre-crops the other axis to the requested aspect. The result is width × height when the requested box is close enough to the source's shape; when it is not (for example a wide box for a square source) the crop rectangle falls outside the source and the call throws std::invalid_argument (“crop rectangle lies outside the source image”) instead of returning a texture.

SaveAsPng and SaveAsJpeg read level 0 through the public GetData path (so a render target is read back from its renderer), convert the stored format to RGBA, and set fully transparent pixels to transparent black, which is what CNA measured Microsoft XNA 4.0 doing rather than a general premultiplication. The stream forms resample to the requested size with nearest-texel sampling. JPEG quality comes from the environment variable FNA_GRAPHICS_JPEG_SAVE_QUALITY, defaulting to 100, as in FNA.

Example: a procedural checkerboard

The two-argument constructor creates a Color texture, which every renderer can store:

const int size = 64;
Texture2D checker(graphicsDevice, size, size);   // SurfaceFormat::Color, one level

std::vector<Color> pixels(size * size);
for (int y = 0; y < size; ++y)
    for (int x = 0; x < size; ++x)
        pixels[y * size + x] = ((x / 8) + (y / 8)) % 2 == 0 ? Color::White : Color::Black;
checker.SetData(pixels.data(), static_cast<int>(pixels.size()));

// Read one texel back through a 1x1 region: the window must match the region exactly.
// This reads the CPU shadow, so it proves the shared layer stored the texels, not that
// the renderer did (see "Why GetData can hide a failed upload").
Color topLeft[1];
const Rectangle first(0, 0, 1, 1);
checker.GetData(0, &first, topLeft, 0, 1);

The shorter checker.GetData(&topLeft, 0, 1) does not read one texel: the whole-level overloads always cover level 0 completely, so an element count of 1 against a 4,096-texel level is a size mismatch and throws ArgumentException. Reading a single texel needs the region overload with a one-texel rectangle.

Evidence and its limits

Checked by reading the CNA source at 009d40f5; not executed. The transfer rules are shared code and are pinned by Texture2DTests.cpp, Texture2DCacheReconstructionTests.cpp and the stream conformance tests named above. Per-family statements (which families implement mip storage, cube and volume readback, effect binding) were read from each family's source and name the registered tests where they exist; they are code claims, not measurements on hardware. The FNA comparisons are CNA's own, as written in its source comments.

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