GBuffer helper – Packing integer and float together

Version : 1.0 – Living blog

With GBuffer approach, it is often required to pack values together. One useful case is to be able to pack an integer value (often matching an enum like material ID) and a float value (inside 0..1 range).

For example in Unity High Definition Render Pipeline we have pack inside the GBuffer:

  • DiffusionProfile (16 values) and Subsurface Mask (Float 0..1)
  • Material Features (8 values) and Coat Mask (Float 0..1)
  • Quadrant for tangent frame (8 values) and metallic (Float 0..1)

During development we have change several times the number of bit required for our packing and it quickly come to us that we were needed to have general packing functions to encode arbitrary values. This is the topic of this short blog post.

Let’s say that we want to encode a Mask on 1 bit with a Scalar in range 0..1 with 8 bit of precision inside a shader. Mean in practice we pack both values in a component of a RGBA 32bit render target. Remember that the float value in the shader is convert at the end of the pipeline to corresponding render target format, in our case the float value will be multiply by 255 to fit into the 8bit precision of the component. We will have 7 bits to encode the float, this could be perform with a simple remapping:

(127.0 * Scalar)  / 255.0

multiply by 127 (or (1 << 7) – 1) which is 01111111 in binaries leave 1 bit available for the Mask.
Then we divide by 255.0
Then we need to add the bit for the mask itself at the 8th position mean value of 128 (or (1 << 8) – 1)

(128.0 * Mask) / 255.0

So encoding is

Val = (127.0 / 255.0) * Scalar + (128.0 / 255.0) * Mask

Decoding should be the reverse of the operation above. First we need to retrieve the Mask value

Mask = int((255.0 / 128.0) * Val)

Note that here we use the int cast to remove all the Scalar value part.
For example if we have Scalar of 0 and Mask with 1, Val is suppose to be 128.0 / 255.0.
Mean the above code give us 1
if Scalar is 1, Val is suppose to be 1.0, mean Mask = int(1.9921875) = 1. All good.
We then retrieve the value of Scalar

Scalar = (Val - (128.0 / 255.0) * float(Mask)) / (127.0 / 255.0)

Now let’s consider a RGBA1010102 render target with a Mask on 4 bit. The process is exactly the same.
First remap value to cover 6 bit for the float value and 4 bit for the Mask value

Val = (63.0 / 1023.0) * Scalar + (64.0 / 1023.0) * Mask

For example if Mask is 2 (i.e 128.0 / 1023.0) and Scalar is 1.0 we get 0010 1111 11 as binaries representation.
4 bit for Mask then 6 bit for Scalar.
For decoding we first retrieve the Mask then the Scalar

Mask = int((1023.0 / 64.0) * Val)
Scalar = (Val - (64.0 / 1023.0) * float(Mask)) / (63.0 / 1023.0)

Important addition.
Due to rounding and floating point calculation on GPU it may appear that Mask reconstruction is shifted by one value.
This can be fixed by adding the smallest epsilon allowed by the render target format. i.e

Mask = int((1023.0 / 64.0) * Val + 1.0 / 1023.0)

We can easily generalize this process for any unsigned render target format and any Mask size. Here are the functions to do the work.

float PackFloatInt(float f, uint i, uint numBitI, uint numBitTarget)
{
    // Constant optimize by compiler
    float precision = float(1 << numBitTarget);
    float maxi = float(1 << numBitI);
    float precisionMinusOne = precision - 1.0;
    float t1 = ((precision / maxi) - 1.0) / precisionMinusOne;
    float t2 = (precision / maxi) / precisionMinusOne;

    // Code
    return t1 * f + t2 * float(i);
}

void UnpackFloatInt(float val, uint numBitI, uint numBitTarget, out float f, out uint i)
{
    // Constant optimize by compiler
    float precision = float(1 << numBitTarget);
    float maxi = float(1 << numBitI);
    float precisionMinusOne = precision - 1.0;
    float t1 = ((precision / maxi) - 1.0) / precisionMinusOne;
    float t2 = (precision / maxi) / precisionMinusOne;

    // Code
    // extract integer part
    // + rcp(precisionMinusOne) to deal with precision issue
    i = int((val / t2) + rcp(precisionMinusOne));
    // Now that we have i, solve formula in PackFloatInt for f
    //f = (val - t2 * float(i)) / t1 => convert in mads form
    f = saturate((-t2 * float(i) + val) / t1); // Saturate in case of precision issue
}

// Define various variants for ease of use and code read
float PackFloatInt8bit(float f, uint i, uint numBitI)
{
    return PackFloatInt(f, i, numBitI, 8);
}

void UnpackFloatInt8bit(float val, uint numBitI, out float f, out uint i)
{
    UnpackFloatInt(val, numBitI, 8, f, i);
}

float PackFloatInt10bit(float f, uint i, uint numBitI)
{
    return PackFloatInt(f, i, numBitI, 10);
}

void UnpackFloatInt10bit(float val, uint numBitI, out float f, out uint i)
{
    UnpackFloatInt(val, numBitI, 10, f, i);
}

float PackFloatInt16bit(float f, uint i, uint numBitI)
{
    return PackFloatInt(f, i, numBitI, 16);
}

void UnpackFloatInt16bit(float val, uint numBitI, out float f, out uint i)
{
    UnpackFloatInt(val, numBitI, 16, f, i);
}

And example usage:

// Encode
outSSSBuffer0.a = PackFloatInt8bit(sssData.subsurfaceMask, sssData.diffusionProfile, 4);
// Decode
UnpackFloatInt8bit(inSSSBuffer0.a, 4, sssData.subsurfaceMask, sssData.diffusionProfile);

// Encode
outGBuffer2.a  = PackFloatInt8bit(coatMask, materialFeatureId, 3);
// Decode
float coatMask;
uint materialFeatureId;
UnpackFloatInt8bit(inGBuffer2.a, 3, coatMask, materialFeatureId);

One Response to GBuffer helper – Packing integer and float together

  1. Pingback: GBuffer helper – Packing integer and float together - TECHBIRD | TECHBIRD - プログラミングを楽しく学ぼう

Leave a comment