01The Canonical Model

uint8 and uint16 are the canonical integer representations. They are mutually coherent: bit replication and truncation between them is lossless, and their float conversions agree:

uint8 uint16 bit replication truncation /255 /65535 float float = identical values

All other bit depths are defined by their relationship to uint8.

Float is defined as uint8/255 (or equivalently uint16/65535). This is the canonical exit to floating point.

02Bitwise Conversions

Bit replication and truncation are the convention in hardware and in software rendering engines: they are how a uint5 or uint3 asset yields its canonical uint8 values. Formats that specify a conversion of their own are listed in Appendix A.

Bit extension

lower → higher bit depth. Replicate the source bits to fill the destination:

ABCABCAB
uint3 ABC → uint8 ABCABCAB
ABCDABCD
uint4 ABCD → uint8 ABCDABCD
ABCDEFGHABCDEFGH
uint8 ABCDEFGH → uint16 ABCDEFGH ABCDEFGH

Bit truncation

higher → lower bit depth. Keep the most significant bits:

uint8 → uint3: value >> 5
uint8 → uint4: value >> 4
uint16 → uint8: value >> 8

Integer to float

Extend to canonical depth first, then divide:

uint3 → float: extend to uint8, then /255
uint8 → float: /255
uint16 → float: /65535

Float to integer

Multiply, then truncate to desired depth:

float → uint8: round(clamp(f * 255, 0, 255)) — or dither; truncate bits if needed
Truncation

Truncation is the inverse of replication. It reads only the top bits, never overflows, and its error is symmetric within one code and unbiased — on the replication lattice it is neither nearest nor floor: 0xFF00 truncates to 0xFF, whose extension 0xFFFF sits almost a full code above the original. Rounding during truncation (adding half before truncating), a naive form of rounding, is incorrect; it rounds on the power-of-two stride, but replicated codes are spaced slightly wider.

Conversion labDetails
Extend a code, read it as a float, take it back down. Try uint3 and uint5, the depths that do not divide 8.
Source
Replicated
Extended value
Float · canonical
Float · exact v/(2ⁿ−1)
Truncate back
Rounding during truncation

03Exact Rounding

When the content is genuinely higher-precision — not a replicated code — reduction is quantization: read the source by its own divisor and round to the destination's, the exact v / (2^n - 1) model in both directions. Between coherent depths that is the nearest code on the replication lattice, and it has a closed form:

uint16 → uint8
(v * 255 + 32767) / 65535= round(v / 257)
general
(v * max_dst + max_src/2) / max_src
shift-only
(v + 128 - ((v + 128) >> 8)) >> 8uint16 → uint8, no multiply
Reduction lab: uint16 → uint8Details
Three ways to reduce the same value. The dots sit on the replication lattice, where every uint8 code lives at a multiple of 257.
Truncate · v >> 8
Rounding during truncation · (v + 128) >> 8
Exact rounding · closed form
No search, no ties

No candidate search and no tie-break rule is needed: the max values are odd, so a value is never exactly halfway between two codes.

Composes exactly

The same oddness makes exact rounding compose across coherent depths — the refinement factor (2^m−1)/(2^n−1) is odd, so every coarse decision boundary falls halfway between fine lattice points, out of reach of a nearest step: uint16 → uint8 → uint4 equals uint16 → uint4, always.

Nothing drifts

And like truncation, exact rounding inverts replication, so after one reduction any further sequence of extension and reduction — truncating or exact rounding, even mixed — is the identity.

Truncation is the fast option

Truncation is the fastest option with the same round-trip guarantees; exact rounding only tightens the first, lossy step — within half a code of the source instead of within one.

Extension is still replication. Quantized or not, uint8 → uint16 is v * 0x101, never the mid-cell padding (v << 8) | 0x80. Mid padding cannot reach either endpoint (0 becomes 0x0080 and 255 becomes 0xFF80 — full scale is lost), pulls every value toward mid gray by up to half a low code (+128/65535 at 0, −127/65535 at 255, nothing at 128), and puts every value but 128 off-lattice, so it is not a fixed point of the conversions: reducing and re-extending yields v * 0x101, not the value it came from.

Replication
v * 0x101
  0 → 0x0000
  1 → 0x0101
128 → 0x8080
255 → 0xFFFF
Mid-cell padding
(v << 8) | 0x80
  0 → 0x0080
  1 → 0x0180
128 → 0x8080
255 → 0xFF80

04Coherent Bit Depths

Bit depths that divide evenly into a larger depth are fully coherent: bit replication, truncation, and float conversion all align perfectly.

DepthDivides evenly into
2481632
uint1✓✓✓✓✓
uint2·✓✓✓✓
uint4··✓✓✓
uint8···✓✓
uint16····✓

For these, bit replication to the larger depth followed by division yields identical float values to direct division by (2^n - 1).

05Non-Divisor Bit Depths

Bit depths that don't divide evenly into 8:

RGB565
R · uint5
G · uint6
B · uint5
RGB332
R · uint3
G · uint3
B · uint2
DepthUsed in
uint3RGB332
uint5RGB565
uint6RGB565

For these depths:

  • Bit replication is the definition. This is what the format means, unless the format specifies otherwise (Appendix A).
  • Bit truncation is lossless. It is the inverse of replication.
  • Float conversion via /255 is the definition. The value extend_to_uint8(v) / 255.0 is the correct float representation.

Replication is not exact rounding up. Extending by the exact model, round(v * 255 / max), gives the same uint8 codes as replication for uint2 and uint3, so for RGB332 the two agree on every code; for uint5 they differ on 4 codes of 32 and for uint6 on 10 codes of 64, always by one:

DepthCodesReplicatedExact rounding up
uint53 · 7 · 24 · 2824 · 57 · 198 · 23125 · 58 · 197 · 230
uint611 to 15 · 48 to 5244 48 52 56 60 · 195 199 203 207 21145 49 53 57 61 · 194 198 202 206 210

The differing codes are those whose exact value lies within a quarter code of a halfway point, with the replicated low bits landing on the other side of it. RGB565 content extended by replication and by exact rounding are therefore different uint8 images; the format defines the first.

06Incorrect Conversions

Dividing by (2^n - 1) directly

Wrong
// WRONG: uint3 → float as v/7
float f = uint3_value / 7.0;
Correct
// CORRECT: extend first
float f = replicate_to_uint8(uint3_value) / 255.0;

The exact interpretation, each bit depth with its own divisor, skips the canonical form: the divisor belongs to the uint8 value the format defines, not to the low-depth code. Formats that define the float at their own depth are the exception: Direct3D's UNORM textures read B5G6R5_UNORM's red as v/31 (Appendix A).

Rounding during truncation

Wrong
// WRONG: rounding during truncation
uint3 = (uint8 + 16) >> 5;
Correct
// CORRECT: truncation
uint3 = uint8 >> 5;

Rounding during truncation targets a lattice that doesn't exist: 0xFE80 is nearest 0xFE (extension 0xFEFE), yet (0xFE80 + 128) >> 8 gives 0xFF, and 0xFFFF overflows. Exact rounding, nearest on the true lattice, is round(v * 255 / 65535) — for quantizing genuine 16-bit content, never for reinterpreting codes. Plain truncation is always more correct than rounding during truncation; where precision matters, exact rounding is the correct way; both keep the round-trip guarantee.

Try it

In the conversion lab above, keep uint3 and step through the codes: from code 4 upward, the “Rounding during truncation” result no longer matches, and code 7 overflows the 3-bit range. The reduction lab shows 0xFE80 and 0xFFFF the same way.

Mixing divisors across bit depths

Wrong
// WRONG: comparing values by their exact divisors
float a = uint3_value / 7.0;
float b = uint5_value / 31.0;
Correct
// CORRECT: extend both to canonical depth
float a = replicate_3_to_8(uint3_value) / 255.0;
float b = replicate_5_to_8(uint5_value) / 255.0;

07Why This Works: The Infinite Bit Replication Model

A uint_n value represents an infinitely repeating binary fraction:

0.001001001001…
uint3 = 1 (001) → 0.001001001001… (binary)

This infinite series sums to exactly v / (2^n - 1), which is why the endpoints work:

0.111111... (binary) = 1.0 (exactly, like 0.999... = 1 in decimal)

Bit extension reveals more digits of this infinite pattern. Bit truncation recovers the original pattern. These operations are inverses by construction.

When extending to a non-multiple bit depth (e.g., 3→8), the pattern truncates at a non-aligned boundary. The subsequent /255 then interprets an 8-bit repeating pattern rather than the original 3-bit repeating pattern. This is not an error; it is the definition. The uint8 form is canonical.

08Summary

OperationMethodLossless?
uint_n → uint8bit replication✓ lossless (by definition)
uint8 → uint_nbit truncation (>>)✓ lossless (inverse of above)
uint8 ↔ uint16bit replication / truncation✓ lossless (8 divides 16)
uint8 → float/255✓ lossless (by definition)
uint16 → float/65535✓ lossless (coherent with uint8)
uint3 → floatextend to uint8, then /255✓ lossless (by definition)

The system is fully coherent when you respect uint8 or uint16 as canonical. The only "incoherence" comes from applying the exact v / (2^n - 1) model directly between two arbitrary bit depths instead of converting to the canonical uint8 form first, and that holds whether a format defines its uint8 form bitwise or by a rounding of its own.

AAppendix A: Canonical Forms by Format

Some formats, DXT and BC7 among them, specify their own conversion for obtaining the uint8 value. Where a format does, that specification is authoritative: the uint8 value it produces is the canonical value, and everything in this article applies from that value onward. Bit replication is what hardware does for low bit depth formats, so outside those specified behaviors it is the correct option. The table lists what each format defines as its canonical value and the operation its specification names for getting there; where a row quotes a specification, the wording is the specification's own, and the sources follow the table.

Read down the third column, every integer color format that specifies its own expansion specifies bit replication, and the two that do not are the two whose codes do not mean fractions of full scale: limited-range video, where the scale is defined by the shift, and ASTC's trit and quint endpoints, which are not binary fractions at all. PNG is the one specification that ranks the methods, and it ranks them as this article does: linear scaling for precision, replication within one code of it, zero fill wrong at white. UNORM stands apart by defining a float, not an integer.

Sources: PNG Specification 1.2, sections 9.1 "Sample depth scaling" and 10.4 "Sample depth rescaling" (encoders, decoders); Direct3D 11.3 Functional Specification, 3.2.3 "Integer Conversion" and 19.5.2 to 19.5.6 (block compression error tolerance, promotion, BC1); BC7 Format, "Decoding the BC7 Format"; OES_compressed_ETC1_RGB8_texture and the Khronos Data Format Specification, ETC2 and ASTC chapters ("Endpoint Unquantization"); ITU-R BT.709 and BT.2100, digital representation tables.