The packing of data is good practice for many reasons, including disk space and efficient RAM or cache access. If we know the meaning of data we can often narrow down the range and precision, making informed decisions as to the amount of bytes we need. I was inspired once by this article and here’s my take on the topic. We’ll explore common ways of packing certain kinds of data common in videogames, their possible implementation and rationale; worthy of note is that this is not an article about compression. I’ll be using HLSL syntax but this will look very familiar to C++ and can be ported easily to any other language.
Normalized Data
This is the simplest type of data to pack so we’ll start here. Normalized data ranges from 0 to 1. You can easily normalize data by shifting and dividing by its maximum value. This mostly applies to colors or bounded values (think a shadow or transparency term) and sometimes normalized vectors, although there are better methods as we’ll see later. The D3D12 formats for this kind of data are the _UNORM class, such as R8G8B8A8_UNORM or R16G16_UNORM. The code examples below show how to encode a typical color with alpha into 8 and 16 bits, they are common cases but you can make as many variations as needed depending on the bitrate and the data you want to store.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | uint PackFloat4ToRGBA8Unorm(float4 value) { uint4 uvalue = uint4(value * 255.0 + 0.5); return (uvalue.a << 24) | (uvalue.b << 16) | (uvalue.g << 8) | uvalue.r; } float4 UnpackRGBA8UnormToFloat4(uint packed) { uint ri = packed & 0xff; uint gi = (packed >> 8) & 0xff; uint bi = (packed >> 16) & 0xff; uint ai = packed >> 24; return float4(ri, gi, bi, ai) / 255.0; } uint PackFloat2ToRG16Unorm(float2 value) { uint2 uvalue = uint2(value * 65535.0 + 0.5); return (uvalue.g << 16) | uvalue.r; } float2 UnpackRG16UnormToFloat2(uint packed) { uint ri = packed & 0xffff; uint gi = packed >> 16; return float2(ri, gi) / 65535.0; } |
Note how we add 0.5 to the result after multiplying by 255. This operation followed by casting is equivalent to rounding but avoids the round instruction since the add gets factored into the multiply-add. Some of these operations are so common that many closed platforms have intrinsics or special instructions to encode and decode bits. Recently, HLSL added some special packing instructions to Shader Model 6.6 so we can also write the RGBA8 packing as follows.
1 2 3 4 5 6 7 8 9 10 | uint PackFloat4ToRGBA8Unorm(float4 value) { uint4 ivalue = uint4(value * 255.0 + 0.5); return pack_u8(uvalue); } float4 UnpackRGBA8UnormToFloat4(uint packed) { return float4(unpack_u8u32(packed)) / 255.0; } |
We’ll stop here for a minute to analyze the RDNA bytecode generated from these instructions. I have grouped them to make logical sense as the compiler is free to reorder these. These tests were performed on the Radeon Graphics Analyzer using the 1103 RDNA3 ASIC in offline mode. We need to be careful as older RGA versions produce worse than the baseline, whereas the latest one I used here shows an improvement. As always, measure and make sure! The command line I used, should you wish to replicate the results, is .\rga.exe -s dx12 -c gfx1103 –offline –cs example.hlsl –cs-entry CSMain –cs-model cs_6_6 –dxc-opt –isa example_hlsl_v1.txt
|
| ||||
|
|
As you can see the compiler is able to improve our hand-written logic and squeeze a couple extra instructions for our packing using v_perm_b32, an instruction that swizzles values into a single one. We don’t have the high-level instructions to perform the same operations manually which is unfortunate. There are other normalized formats commonly used in videogames that don’t have the same bit width for all components, for example R5G6B5, R5G5B5A1 or R10G10B10A2 formats. We can see how to encode and decode one of them below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | uint PackFloat4ToRGB10A2Unorm(float4 value) { uint3 rgbi = uint3(value.rgb * 1023.0 + 0.5); uint ai = uint(value.a * 3.0 + 0.5); return (ai << 30) | (rgbi.b << 20) | (rgbi.g << 10) | rgbi.r; } float4 UnpackRGB10A2UnormToFloat4(uint packed) { uint ri = packed & 0x3ff; uint gi = (packed >> 10) & 0x3ff; uint bi = (packed >> 20) & 0x3ff; uint ai = packed >> 30; return float4(float3(ri, gi, bi) / 1023.0, ai / 3.0); } |















































