Surface formats: profile gates, renderer verdicts and format usage

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. Per-renderer format answers are code claims and several come from live device queries, so they can differ per GPU.

A SurfaceFormat value is a request, not a promise. This page explains how CNA decides whether a texture, a cube, a volume texture or a render target may use a format: the per-resource GraphicsProfile tables, the renderer's three-way verdict, the format-size helpers, the draw-time rules that XNA applies late, and the per-format usage masks in the renderer capability profile. It is for readers who need more than "Color is portable" and want to know exactly which call refuses what, and why.

Four separate claims about one format

The 27-value SurfaceFormat enum (SurfaceFormat.hpp) describes a wider contract than any single configured product implements: the 20 XNA 4.0 formats plus seven CNAEXT values (ColorBgraEXT, ColorSrgbEXT, Dxt5SrgbEXT, Bc7EXT, Bc7SrgbEXT, ByteEXT, UShortEXT). For one format CNA asks up to four independent questions, and a yes to one is never a yes to another:

  1. Does the enumerator exist? Always; that is representation only.
  2. Is there a transferable CPU representation? The Color-shaped or typed SetData/GetData route (the ColorTransfer usage below).
  3. Can the selected renderer store and sample it? Texture storage, per resource kind (2D, cube, volume).
  4. Can it be rendered into? Render-target capability is a strictly narrower question than storage, and blending into it or filtering it are narrower again.

A format enumerator, a transferable CPU representation, a sampleable native resource and a render target are four separate claims, and each is answered by a different function. The rest of this page follows the order in which a constructor asks them.

Format-size helpers

Texture (Texture.cpp) exposes as public static the size rules that FNA keeps internal, because callers outside the class hierarchy (content readers, the CNB compiler, renderers, tests) need the same byte and block arithmetic:

HelperAnswer
GetBlockSizeSquaredEXT(format)16 for the six block-compressed formats (Dxt1, Dxt3, Dxt5, Dxt5SrgbEXT, Bc7EXT, Bc7SrgbEXT: 4×4 texel blocks); 1 for the 21 uncompressed formats; std::out_of_range for a value outside the enum
GetFormatSizeEXT(format)Bytes per block or per texel: 8 for Dxt1; 16 for the other block formats and Vector4; 1 for Alpha8/ByteEXT; 2 for the packed 16-bit formats, HalfSingle, NormalizedByte2 and UShortEXT; 4 for Color and the other 32-bit formats; 8 for HalfVector4, Rgba64, Vector2 and HdrBlendable
GetPixelStoreAlignment(format)min(8, GetFormatSizeEXT(format)), the OpenGL 2.1 rule that GL_PACK_ALIGNMENT/GL_UNPACK_ALIGNMENT never exceed 8
ValidateGetDataFormat(format, elementBytes)Throws std::invalid_argument when the destination element size does not evenly divide the format size

A block-compressed level is allocated as ceil(w/4) × ceil(h/4) × GetFormatSizeEXT(format) bytes, never as w × h texels; the Texture2D format constructor applies exactly that rule when it zero-fills the initial image.

Texture2D admission, in order

The explicit-format constructor Texture2D(device, width, height, mipMap, format) in Texture2D.cpp runs four checks before any native allocation; the two-argument-size constructor runs only the first two and always creates Color.

  1. Profile size ceiling (ValidateTextureSizeForProfileEXT): each edge at most the renderer's GetMaxTextureSizeForProfileEXT, whose shared default is 2048 under Reach and 4096 under HiDef, and an aspect ratio of at most 2048:1. Both throw System::NotSupportedException. These are profile ceilings, not hardware queries: a Reach game is held to 2048 on a GPU that could allocate more.
  2. Renderer dimension (ValidateTextureDimensionEXT): GraphicsDevice::GetMaxTextureDimension(), the renderer's real maximum, checked before creation because native validation layers are advisory or lazy (the source names Vulkan's validation layer and wgpu-native's submit-time validation).
  3. Format (ValidateTexture2DFormatEXT): first the profile, then the renderer; see the next two sections.
  4. Creation shape (ValidateTexture2DCreationShapeEXT): under Reach a non-power-of-two texture may be neither mipmapped nor DXT-compressed (NotSupportedException), and DXT dimensions must be multiples of four on both profiles (System::ArgumentException).

A mipmapped texture gets the complete chain, halving each dimension until 1×1 (CalculateMipLevels); there is no partial chain.

Where the renderer maximum comes from

GraphicsDevice::GetMaxTextureDimension() forwards to IGraphicsRenderer::GetMaxTextureDimension() (IGraphicsRenderer.hpp). The interface default returns 16384. Its header comment gives the reason: 16384 is the single-axis ceiling guaranteed on every native API CNA targets, namely Direct3D 11/12 feature level 11_0's REQ_TEXTURE2D_U_OR_V_DIMENSION and the value that real Vulkan, Metal and GL implementations report. At this snapshot four families override it.

FamilyWhat GetMaxTextureDimension() returns
VULKANThe physical device's maxImageDimension2D, clamped to int (0 before a physical device has been selected) (VulkanRenderer.cpp)
DIRECT2DThe device context's GetMaximumBitmapSize() (Direct2DRenderer.cpp)
GDISoftwareFramebufferMaxDimension, the 16384 ceiling it shares with the Software framebuffer allocator (SoftwareFramebufferAllocation.hpp)
FNA3DThe interface default, returned explicitly. FNA3D has no maximum-texture-size query, and the source comment argues that inventing a narrower number would reject textures the renderer can create (Fna3dRenderer.cpp)

Every other family inherits the constant, including the five EasyGL identities, OPENGL4, DIRECTX9, DIRECTX11, DIRECTX12, SDL_GPU, WEBGPU, METAL, SOFTWARE and PORTABLEGL. None of them overrides the method, so on those families the value is a documented assumption rather than a device query: a GL or GLES device with a smaller GL_MAX_TEXTURE_SIZE still reports 16384. The same header comment also says that "no renderer currently needs a tighter or looser override"; the four overrides above show that this sentence is out of date.

The profile ceiling (2048 under Reach, 4096 under HiDef) is checked first. For that reason the renderer maximum limits an ordinary Texture2D only when a family reports less than its profile allows. The value matters most in three other places:

  • Content and stream loading. The XNB Texture2D and SpriteFont readers check declared sizes against it (Texture2DContentTypeReader.cpp). The DDS decoder behind DDSFromStreamEXT refuses a larger header before it decodes anything (Texture2D.cpp).
  • Engine-layer targets. Inside an EngineLayerTextureSizeScope, the render-target ceiling is the larger of the profile ceiling and this value (EngineLayerTextureSize.cpp).
  • Capability profile. The profile publishes the value as RendererLimit::MaxTextureDimension and marks it known whenever it is non-negative (GraphicsDevice.cpp). On an inheriting family, a "known" 16384 is therefore the interface default and was not reported by the device.

The practical rule: on the four overriding families, treat the answer as a device fact. On every other family, treat it as an upper bound that the driver may still refuse. Checked by reading the sources above at 009d40f5; not executed.

The profile tables, per resource kind

XNA decides format legality by GraphicsProfile. CNA's tables in Texture.cpp say they were measured on the real XNA 4.0 runtime rather than transcribed from documentation, and they differ per resource kind. The profile is always asked first and refuses with XNA's own exception type, so a profile refusal is distinguishable from a renderer refusal.

ResourceHelperReachHiDef
Texture2DIsFormatAllowedByProfileEXTNine formats: Color, Bgr565, Bgra5551, Bgra4444, Dxt1/Dxt3/Dxt5, NormalizedByte2/NormalizedByte4. The other eleven XNA formats throw NotSupportedException.All 20 XNA formats
TextureCubeIsCubeFormatAllowedByProfileEXTEverything except NormalizedByte2/NormalizedByte4, at both profiles. Deliberately not the full Reach list: enforcing it would refuse a float cube on the default profile, and CNA's image-based-lighting products render irradiance and prefiltered cubes into float storage face by face (the source records this as an owner decision that the gate must not reopen).
Texture3DIsVolumeFormatAllowedByProfileEXTNothing: volume textures are HiDef-onlyFifteen uncompressed formats (the 20 minus the three DXT formats and the two signed-normalized ones)
Render targetsIsRenderTargetFormatAllowedByProfileEXTThe Texture2D list minus Dxt1/Dxt3/Dxt5, which nothing renders into. A profile-excluded format is substituted, not refused (see render-target selection).

The CNAEXT formats are "not the profile's business": IsFormatAllowedByProfileEXT returns true for them at both profiles, and the renderer alone accepts or refuses them.

The renderer's three-way verdict

After the profile, the constructor asks the active renderer. RendererFormatVerdict in IGraphicsRenderer.hpp has three values, and the third is the important one:

  • Supported: the renderer genuinely stores this format.
  • Unsupported: the renderer genuinely does not. For Texture2D this throws std::runtime_error naming the ordinal and saying the format "has not passed the renderer's promotion gate"; for cubes, volumes and render targets it throws NotSupportedException.
  • Defer: the renderer has no renderer-specific answer, so the framework rule applies. That rule, Texture::ValidateFormat, admits SurfaceFormat::Color and throws std::runtime_error ("... is not implemented by the selected graphics renderer") for anything else.

The tri-state exists because a plain Boolean would force every renderer without format work to restate the framework rule, and a wrong restatement would silently narrow a public API. The questions are separate virtuals because the answers really differ per resource kind:

VirtualAsked byDefault
ClassifySurfaceFormatEXTTexture2DDefer
ClassifyTextureCubeFormatEXTTextureCubeDelegates to the 2D answer (so adding it changed no renderer); overridden where cube allocation differs
ClassifyTexture3DFormatEXTTexture3DDefer, deliberately not the 2D answer: most volume paths store RGBA8 only
ClassifyRenderTargetFormatEXTRenderTarget2D, SupportsSurfaceFormatAsRenderTargetEXTDefer
ClassifyRenderTargetCubeFormatEXTRenderTargetCubeDelegates to the render-target answer
ClassifyColorTransferFormatEXTThe capability profile's ColorTransfer bitDefer; framework rule: any format whose texel size is a multiple of four bytes

The cube question exists because of a concrete failure recorded in the WebGPU source (WEBGPU-163): one classifier served both kinds, so a block-compressed cube was promised at construction and then refused by every SetData. The WebGPU cube path was later made to allocate the requested format, and the cube verdict now repeats the 2D one including its device-feature gate. The colour-transfer question exists for the reverse reason: Vulkan refuses a Color-shaped transfer over NormalizedByte4 (four bytes wide, but signed and sampled in [-1, 1]) and over the DXT formats (whose bytes are blocks, not texels), although the width rule would admit both.

Families that classify formats themselves, and what they accept, are summarised on Renderers: texture and surface formats and per renderer on 3D rendering: surface format boundaries; every other family defers, which means Color only.

Cube and volume creation rules

TextureCube (TextureCube.cpp) checks the profile's cube edge (GetMaxCubeSizeForProfileEXT: 512 under Reach, 4096 under HiDef), then the cube format table and verdict, then the shape: under Reach the edge must be a power of two, and a DXT cube edge must be a multiple of four. Texture3D (Texture3D.cpp) first requires SupportsCapability(GraphicsCapability::Texture3D) and throws NotSupportedException before any renderer call otherwise; then GetMaxVolumeExtentForProfileEXT, whose 0 under Reach means "no volume textures at all" and whose HiDef value is 256 per axis; then the volume table and verdict. A mipmapped volume's level count halves all three dimensions, following XNA's complete-chain request rather than FNA's width/height-only helper, which would truncate a depth-dominant texture.

Render-target format selection

RenderTarget2D and RenderTargetCube take a preferred format, and SelectRenderTargetFormatEXT implements XNA's substitution: a format the profile forbids for targets, or one the renderer classifies Unsupported or Defer, becomes Color without an exception. The substituted format is then classified again inside CreateValidatedRenderTargetRenderer before the renderer factory runs, so the object can never report a format its native resource does not have (an earlier defect in which a target reported a non-Color format over Color storage is the reason, recorded as Task 774 in the constructor comment).

Because the substitution is silent, ask first. GraphicsDevice::SupportsSurfaceFormatAsRenderTargetEXT(format) (GraphicsDevice.cpp) asks exactly the constructor's questions in the same order (profile table, then ClassifyRenderTargetFormatEXT, with Defer meaning Color only), so the two cannot disagree; RenderTargetFormatAgreementTests.cpp pins that agreement for every SurfaceFormat. Reading getFormatProperty() after construction gives the same answer after the fact.

Rules checked at draw time

Some XNA format restrictions are not construction rules at all. Microsoft XNA checks them in VerifyCanDraw, when a draw is submitted, and CNA reproduces that timing in GraphicsDevice::validateDrawState for ordinary draws and SpriteBatch alike, before the renderer is called:

  • Point-filter-only formats. Single, Vector2, Vector4, HalfSingle, HalfVector2, HalfVector4 and HdrBlendable may be sampled only with TextureFilter::Point; any other filter on the slot throws NotSupportedException ("The active GraphicsProfile does not support filtering SurfaceFormat …"). HiDef can allocate these formats, but XNA's Direct3D 9 storage for them does not permit filtered sampling, so CNA refuses the draw even on renderers that could filter. The one exception is a draw issued by the CNAEXT engine layer inside its float-filtering scope, and only for a format the live renderer reports it can filter (VMG-0006).
  • Non-blendable targets. With blending enabled or any colour-write mask other than All, a bound target in Single, Vector2, Vector4, HalfSingle, HalfVector2 or HalfVector4 throws. HdrBlendable is exempt, which is what its name promises.
  • Non-power-of-two under Reach. A non-power-of-two Texture2D sampled with any address mode other than Clamp on U or V throws "Reach requires Clamp addressing for non-power-of-two Texture2D resources."
  • Vertex textures. The vertex-stage TextureCollection has four slots under HiDef and none under Reach, and accepts only the seven float formats above (NotSupportedException otherwise), matching XNA's vertex-texture-fetch rule.

The first three rules are pinned by GraphicsProfileDrawStateFormatTests.cpp. They explain an otherwise surprising result: a float texture that constructs successfully can still make the next draw throw.

Per-format usage in the capability profile

GraphicsDevice::GetRendererSurfaceFormatSupportEXT(format) returns a RendererFormatSupport with two masks over RendererFormatUsage bits (RendererCapabilityProfile.hpp): TextureStorage, Sampled, Filterable, RenderTarget, Blendable, StorageRead, StorageWrite, StorageAtomic, TransferSource, TransferDestination, Mipmapped, Multisample and ColorTransfer. knownUsages says which bits have an answer at all; a missing known bit means "not classified", never "unsupported".

How BuildRendererCapabilityProfileEXT fills them matters when reading the answer:

  • The device always classifies three bits for all 27 formats. TextureStorage is the renderer's ClassifySurfaceFormatEXT verdict (Defer counting as supported only for Color); it does not include the profile gate, so under Reach it can report storage for a HiDef-only format that the Texture2D constructor would refuse. RenderTarget is SupportsSurfaceFormatAsRenderTargetEXT and does include the profile. ColorTransfer is ClassifyColorTransferFormatEXT with the four-byte-multiple framework rule.
  • A renderer may add its own audited answers through GetSurfaceFormatUsageSupportEXT; where it marks a bit known, its answer replaces the device's. At this snapshot the Vulkan, SDL_GPU, OpenGL 4 and WebGPU families implement that hook; every other family reports only the three device-classified bits.

So a format answer is only as detailed as the selected renderer's audit. Treat an unknown Filterable or Blendable bit as a question to test, not as a no. The accessors and the report are introduced on Renderers: RendererCapabilityProfile.

Block-compressed content: decoded or native

Whether DXT and BC data stays compressed is decided by two renderer answers, not by the format alone. IsCompressedTransferFormatEXT(format) says the renderer transfers that format as raw blocks through the byte-array SetData/GetData overloads (the default is false for every format); LoadsCompressedContentNativelyEXT() says the renderer prefers loaded content to arrive compressed. Only when both are true do Texture2D::DDSFromStreamEXT and the .xnb Texture2D reader keep the blocks; otherwise they decode each level to RGBA8 Color on the CPU (DxtUtil), which is the historical behaviour every renderer that has not opted in keeps. Cubes have their own transfer question, IsCompressedCubeTransferFormatEXT, because a backend may implement compressed 2D textures without the cube allocation and upload path. The byte-level transfer rules for compressed textures are on Texture data transfer.

Color is linear: a worked historical case

SurfaceFormat::Color is linear RGBA8; the gamma-encoded variant is the separate CNAEXT value ColorSrgbEXT. A fixed Vulkan defect shows why the distinction is easy to break without noticing. Vulkan's Texture2D path once created its images as VK_FORMAT_R8G8B8A8_SRGB while the swapchain separately preferred an sRGB format. The two wrong steps, an sRGB decode on sampling and an sRGB encode on presentation, approximately cancel for textured content, and the existing exact-colour tests used only 0 and 255, which are fixed points of the sRGB curve. The error appeared only on untextured output such as vertex colours and lighting, where a mid-grey 128 read back as 188. Both were corrected to _UNORM formats.

At this snapshot VulkanRenderer::MapSurfaceFormatToStorageEXT (VulkanRenderer.cpp) maps Color to VK_FORMAT_R8G8B8A8_UNORM for textures and off-screen targets, and the regression test vulkan_texture_srgb_test.cpp, registered as Vulkan_Texture2D_ColorFormat_Linear, draws a mid-grey quad once from a vertex colour and once from a sampled texture and requires both readbacks to agree. The lesson generalises: a colour-space test needs a mid-range value and an untextured path.

Evidence and its limits

Checked by reading the CNA source at 009d40f5; not executed. The profile tables, verdict order, substitution rule and draw-time checks are shared code, identical for every renderer, and are pinned by the unit tests named above (RenderTargetFormatAgreementTests.cpp, GraphicsProfileDrawStateFormatTests.cpp, the Texture2DTests.cpp format-acceptance cases). What a given renderer accepts after the profile is that renderer's claim: several families answer from live device queries (Vulkan asks vkGetPhysicalDeviceFormatProperties for the exact usage bits its texture path needs), so the same build can answer differently on another GPU. CNA's comments describe the profile tables as measured on the real XNA 4.0 runtime; this page did not repeat that measurement.

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