Computer Graphics · Graphics Tooling

OpenEXR Scanline and Tiled Layouts with Half-Float Pixels

How EXR stores channels in separate planes, what the half type buys over float and where it runs out, and why the tile descriptor changes random-access cost.

A renderer’s output is not a picture; it is a set of physical measurements, some of which exceed the displayable range by several orders of magnitude. A pixel in a specular highlight can be a thousand times brighter than a mid-grey surface, and a value behind a light source can be ten thousand times brighter again. Storing that in eight-bit integers clips it. Storing it in 32-bit floats costs 12 bytes per RGB pixel and, for a 4K frame with a dozen channels, more memory than the render itself used. OpenEXR exists to occupy the space between those two positions, and its file layout reflects a set of choices about channels, precision, and access pattern that are worth separating.

Channels as separate planes, not as interleaved pixels

The first structural decision is that EXR does not store RGBA tuples. It stores a list of named channels, each with its own pixel type and sampling rate, and each stored as its own plane across the image. A file might hold R, G, B at half precision, A at half precision, Z at 32-bit float, and a set of arbitrary named channels for motion vectors or object identifiers.

The consequence is that reading one channel does not read the others. A compositing operation that needs only depth reads a plane of 4 bytes per pixel rather than a 16-byte interleaved record. Against that, reading all channels of a small region touches as many separate memory ranges as there are channels, and for a deep channel list the access pattern becomes scattered.

Channels can also have different sampling rates in each axis. A channel with xSampling = 2, ySampling = 2 stores one value per 2×2 block of pixels, which is how chroma subsampling is expressed without a special format. A reader that assumes every channel has the same dimensions overruns its buffer on the first subsampled channel it meets; the sampling rates are per channel in the header and must be honoured when computing plane sizes.

The channel list is ordered, and the order determines the byte layout within each scanline: for a scanline block, the channels’ data for that row are stored consecutively in the order the header declares. That makes channel order a layout decision with real consequences, since a reader streaming rows wants the channels it uses most to be adjacent.

The half type: what 16 bits actually buy

Half precision is a 16-bit float with 1 sign bit, 5 exponent bits, and 10 mantissa bits. Compared with a 32-bit float’s 8 exponent bits, the difference is not precision but range.

PropertyHalfFloat
Exponent bits58
Mantissa bits1023
Approximate decimal precision3 digits7 digits
Largest finite value65 504$\approx 3.4 \times 10^{38}$
Smallest positive normal$\approx 6.1 \times 10^{-5}$$\approx 1.2 \times 10^{-38}$
Bytes per component24

Three digits of precision is enough for display-referred imagery and, with care, for scene-referred values used in compositing. A highlight stored as half has a relative error of about $2^{-11}$, roughly 0.05%, which is far below any perceptual threshold for a single pixel and below the noise floor of most renderers’ sampling.

The range is the real constraint. A value above 65 504 becomes infinity, and values that large do occur: a small light source seen directly, a caustic focus, or an emissive surface with an extreme multiplier. The failure is not graceful — an infinity propagates through every subsequent multiply and add, and a compositing tree that averages a region containing one becomes infinite everywhere. Pipelines therefore clamp emissive values before writing, which is a lossy operation applied to exactly the pixels that carry the most energy.

The lower end is worse in a specific way. The smallest normal half is about $6.1 \times 10^{-5}$, and below that the format switches to subnormals with progressively fewer mantissa bits, reaching zero at about $6 \times 10^{-8}$. For a renderer working in scene units where a dark surface sits at $10^{-6}$ of the reference white, half cannot represent it with any precision, and the value quantizes to a handful of subnormal codes or to zero. The result is banding in the darkest regions — and because the encoding is not perceptually uniform, the bands are wide and visible after tone mapping. This is why deep-shadow content is often stored in float even when the highlights are not.

Scanline versus tiled, and the cost that separates them

EXR offers two arrangements, declared by a single header field, and they are not interchangeable.

A scanline file stores rows in blocks — typically 1, 16, or 32 rows per block — and the offset table at the start of the file has one entry per block, giving its byte position. Reading a horizontal band is one seek plus a sequential read. Reading a vertical band is one seek per block, which for a 4K image with 16-row blocks is 256 seeks. Random access to a single pixel in the middle of the image requires reading a whole block.

A tiled file stores square tiles, with a level structure for mip and rip levels and a tile offset table per level. The tile dimensions are declared in the header and are constrained to be multiples of 16, with 32×32 or 64×64 being common. Reading a rectangle costs one seek per intersecting tile; reading a single pixel costs one tile. For a 64×64 tile of half RGBA that is 32 KB read to obtain 8 bytes, which is the price of the access pattern.

The trade is between sequential throughput and random access. Scanline files are better when the consumer streams: a video encoder, a mip generator, or a file copy. Tiled files are better when the consumer samples: a texture mapper reading a region, a compositor working on a sub-rectangle, or a renderer resuming from a checkpoint. A tiled file also supports mip levels natively, which a scanline file does not.

The offset table is the mechanism that makes either work, and it is also the mechanism that makes a file unreadable when damaged. The table is a fixed-size array of 64-bit file offsets, one per block (per tile per level for tiled files), written before any pixel data. Truncating a file or patching it in place can leave the table describing positions that no longer hold the right data, and a reader following the table lands in the middle of a different block. Writers therefore emit the table after they know the layout, which means either buffering the whole image or making two passes over the output.

Compression, and the fact that it is per-block

EXR’s compression is applied per block, not to the file as a whole, because per-block compression is what allows a reader to decompress one tile without the rest. The schemes differ in what they assume about the data.

A general-purpose lossless scheme (a deflate variant) suits synthetic imagery with flat regions and repeated values. A predictive scheme that reorders bytes before compressing suits photographic and rendered content, where neighbouring pixels correlate strongly; it costs more computation per block and yields better ratios on smooth gradients. A wavelet-based lossy scheme suits the case where a small quality loss is acceptable in exchange for a large size reduction, and its parameters are in the header so that a reader can reconstruct exactly what the writer intended.

Two properties are common to all of them and matter for pipeline design. First, the compression is independent per block, so a corrupted block damages a tile and not the file — but a reader has no way to know a block is corrupt beyond a checksum, and EXR’s per-block structure does not require one, so silent corruption is possible.

Second, lossy schemes interact with the channel structure. A scheme that operates on the interleaved bytes of a scanline sees channels of different types adjacent to each other, and a predictor that assumes continuity across those boundaries is modelling something that is not there. The reordering step that precedes compression exists to fix exactly this, and its exact permutation is part of the format so that a reader can invert it.

Why the format keeps its position

The combination of per-channel planes, half precision, tiled access, and per-block compression is not the simplest design available, and every element has a cheaper alternative that was rejected for a specific reason.

Interleaved pixels would be simpler but make single-channel access expensive. Float-only storage would remove the range problem but double the file size for content that does not need it. Scanline-only would simplify the reader but make random access quadratic in the wrong dimension. Whole-file compression would compress better but forbid partial reads.

The result is a format that is good at being an intermediate in a rendering pipeline and mediocre at being an archive or a delivery format — which is why delivery pipelines convert to a block-compressed GPU format, the accounting for which is in block-compressed texture bit rates, and why a texture that must serve several GPU generations usually ships as a container holding an intermediate representation rather than as an EXR, as described in KTX2 containers and Basis transcoding.

The precision question also propagates. A pipeline that renders to half, composites in float, and writes half again loses the range it recovered in the middle stage, and the loss is invisible until a grade pushes the shadows. Deciding which stages hold float is a decision about which operations are allowed to be non-invertible, and it is the same decision that determines whether a quantized vertex format can represent the geometry it was built from — the constraint set out in vertex attribute quantization.

References

  1. OpenEXR File Layout — OpenEXR project
  2. IEEE 754-2019: Standard for Floating-Point Arithmetic — IEEE
  3. Technical Introduction to OpenEXR — Industrial Light & Magic
  4. High Dynamic Range Imaging: Acquisition, Display, and Image-Based Lighting — Reinhard, Ward, Pattanaik, Debevec, Heidrich, Myszkowski, Morgan Kaufmann