Blog Content

Home – Blog Content

YCoCg Compression

Introduction

When seeking to save on memory, one approach is to reduce texture file sizes. This is because textures are typically the largest user of memory.

Usually, texture size can be reduced through:

  • Texture compression
    • This can introduce loss of color accuracy and introduce artefacts when used aggressively.
  • Texture resolution
    • This introduces loss of detail and crispness to textures.

Textures are typically stored in the format of RGB (red, green & blue) intensities, as this replicates the three color primaries of light that our eyes are sensitive to (and the typical primary colors on displays).

However, our eyes are more sensitive to luminance (or “brightness”) than they are of raw color. Take the following image as an example, here you can see a source image broken up into its luminance and color components:

Left: Source image, Center: Luminance, Right: Color (source without luminance)
Left: Source image, Center: Luminance, Right: Color (source without luminance)

As you can see, your eyes are much more sensitive to the center image than the right.

In fact, you can shrink the resolution of the color map down by an order of magnitude and you will struggle to see any difference. See the following:

Left: Source Image, Right: Reconstructed image using a color map at 1/10th resolution and the original Luminance

As you can see, even with 10x less color resolution, you will be hard-pressed to see any difference.

This presents us an opportunity to save on texture memory if we extract the color information and reduce its resolution – we can reduce memory usage without affecting visuals.

There are several color spaces that use luminance, which include CIE color spaces (such as Lab and Lch) and Y-spaces (such as YCbCr, YCoCg, YDbDr, etc).

In theory, we can use any of these, but we should seek to use one that has a fast conversion back to RGB. This is because our shaders output to RGB and the color conversion has to be done in the shader stage.

We therefore need a color space that has fast conversions.

CIE color spaces use exponents and branching logic, making it expensive.

YCoCg is the most ideal for us to consider, as it is lossless, uses fewer bits than others and is simple to code. YDbDr could also work, but its matrices are simply less human-readable.

Terminology:

Just to make things clearer, I’ll take a moment to explain the terminology:

Y → This is the “luma” component. This is slightly different from “L”, which is the “luminance” component. Luminance is the brightness of a color, whereas luma is the perceived brightness. Luma is a gamma-corrected luminance value. See more here.

CoCg → This refers to the two color primaries. Much like RGB is a record of the red, green and blue values of a pixel, Co and Cg refer to the distance a pixel color is from orange and from green. Consider Co and Cg as meaning how much a pixel color is the “color orange” and the “color green”.

An example of conversion from RGB to YCoCg (note: the green channel is actually almost empty, so I have increased the contrast here to show the information in the channel)

Process:

What we then will look to do is:

  1. Convert Albedo RGB to a YCoCg texture set
  2. Construct a shader to convert back to RGB at runtime
  3. Compare loss and compression/memory usage wins.

Converting RGB to YCoCg


Converting an RGB pixel to YCoCg is fairly straightforward with a matrix:

I shall put this in code-terms, which should hopefully make the above clearer if you don’t understand matrix multiplication:

var Y = (0.25 * red) + (0.5 * green) + (0.25 * blue);
var Co = (0.5 * red) + (0.0 * green) - (0.5 * blue);
var Cg = (-0.25 * red) + (0.5 * green) - (0.25 * blue);

However, it is worth noting that as we are dealing with “luma”, and in Unity we will be importing these as linear textures, you must run your RGB values through gamma correction first:

var gamma = 2.2; // typical gamma value
red = pow(red, gamma);
green = pow(green, gamma);
blue = pow(blue, gamma);

// Now we can convert it
var Y = (0.25 * red) + (0.5 * green) + (0.25 * blue);
var Co = (0.5 * red) + (0.0 * green) - (0.5 * blue);
var Cg = (-0.25 * red) + (0.5 * green) - (0.25 * blue);

And that’s all we need to do to convert our textures…

Well, sort of. The CoCg channels are -1 to +1 values, and we would lose a lot of precision if we remapped this to 0-1 (half our color information would be destroyed when we saved the image).

So our final step is to create 4 values that correlate to:
Co -1 to 0

Co 0 to +1

Cg -1 to 0

Cg 0 to +1

We can easily fit these into a 4-channel image file.

Our final pseudocode looks like this:

var gamma = 2.2; // typical gamma value
red = pow(red, gamma);
green = pow(green, gamma);
blue = pow(blue, gamma);

// Now we can convert it
var Y = (0.25 * red) + (0.5 * green) + (0.25 * blue);
var Co = (0.5 * red) + (0.0 * green) - (0.5 * blue);
var Cg = (-0.25 * red) + (0.5 * green) - (0.25 * blue);

var outputRed = Co < 0 ? 0 - Co : 0;
var outputGreen = Co >= 0 ? Co : 0;
var outputBlue = Cg < 0 ? 0 - Cg : 0;
var outputAlpha = Cg >= 0 ? Cg : 0;

What is important to note is that our Y value and CoCg values should be separate files. This is so we can maintain the Y-value’s resolution, and reduce the CoCg resolution to as low as we can get-away-with in Unity.

This is straightforward enough, but we’re not going to save much if we simply swap the Albedo RGB for Y + CoCg textures. This is because on Quest we are using ASTC compression, which means that the Y image and Albedo RGB will compress to a similar file size. We will only get the savings if we can put the Y image inside another texture.

Handily, Unity’s default URP texture setup puts Metallic and Smoothness textures into the R and A channel of an RGBA mask image, and leaves the G and B channels free. This is done because older compression modes, such as DXT, decrease green and blue bit depths as your eyes are less sensitive to those colors, and so you lose precision if you these channels as masks.

However, as we are using ASTC and linear textures, this isn’t a concern as they compress equally, and we can populate the G or B channel with our luma/Y image.

Whilst we’re at it, Unity keeps a separate AO texture, rather than packing it. The reasons for this are rather esoteric, and are valid, but in the pursuit of texture compression, we can put this into our new mask setup.

Proposed changes. Note the CoCg file can now be a much smaller resolution than the Albedo

I am currently working on a development tool to test this more easily:

Watch this space.

YCoCg Shader

Throwing together a shader in Unity in Shader Graph to test this is simple enough.
We just need to make sure that we import the textures as linear (not sRGB);

Step 1 is to unpack the texture, and set the CoCg components back to the -1 to +1 range:

float4 sampleMask = SAMPLE_TEXTURE(_Mask, uv);
float Y = sampleMask.g;
float4 sampleCoCg = SAMPLE_TEXTURE(_CoCg, uv);
float Co = (0 - sampleCoCg.r) + sampleCoCg.g;
float Cg = (0 - sampleCoCg.b) + sampleCoCg.a;

Step 2, we run the numbers through this even simpler matrix:

float3 rgb = float3(Y + Co - Cg, 
Y + Cg, 
Y - Co - Cg);

Our final pseudocode looks like so:

float4 sampleMask = SAMPLE_TEXTURE(_Mask, uv);
float Y = sampleMask.g;
float4 sampleCoCg = SAMPLE_TEXTURE(_CoCg, uv);
float Co = (0 - sampleCoCg.r) + sampleCoCg.g;
float Cg = (0 - sampleCoCg.b) + sampleCoCg.a;
float3 albedo = float3(Y + Co - Cg, Y + Cg, Y - Co - Cg);

Here it is in action:

I know which one is which, but do you?!

Compression Results

These results are based on the following original texture map settings:

Albedo – 4096*4096, ASTC 6×6 sRGB compression, 9.5 MB

Metallic/Smoothness – 4096*4096, ASTC 6×6 compression, 9.5 MB

AO – 4096*4096, ASTC 6×6 Compression, 9.5 MB

Total texture usage: 28.5 MB

Mask CompressionCoCg CompressionCoCg ResolutionMemory savingResult
ASTC 6×6ASTC 6×64096*40969.5 MB
ASTC 6×6ASTC 6×6512*51218.8 MB
ASTC 6×6ASTC 6×632*3219 MB
ASTC 6×6ASTC 10×1032*3219 MB

As you can see, we can save 9.5MB from packing textures more efficiently, but we can double that saving by using a tiny CoCg map without sacrificing quality.

It is worth noting that we lose color accuracy the more we compress the CoCg texture. As it is so small, a 32×32 uncompressed CoCg is still less than 1 MB, so we can reduce compression where necessary here.

It is also worth noting one side effect of putting luma/Y into one channel:

When using RGB, we essentially have “3 values” of luma, so we maintain good accuracy when we compress. However, when we only have one luma value, any compression affects the luma more. See the following image:

As you can see, we get artefacts when using the same compression settings. We should aim to use the most aggressive compression setting we can, but note that we will get artefacts faster as we increase compression when using YCoCg.

Leave a Reply

Most Recent Posts

  • All Post
  • Articles
  • Quick Tips
  • Tangents
  • Tutorials
  • Uncategorized

Services

FAQ's

Privacy Policy

Terms & Condition

Team

Contact Us

Company

About Us

Services

Latest News

© 2023 half4.xyz ltd. Created with Royal Elementor Addons