Unity 6 Android: wrong color space when loading textures from AssetBundles made with Unity 2022.3.X

Hello,

I am porting my project from Unity 2022.3.20f1 to Unity 6000.0.34f1.

Some of my assets are coming from scene AssetBundles built with Unity 2022.3.20f1.

  • I don’t have issues on my WebGL build and WebGL AssetBundles.
  • But on my Android build the textures from my Android AssetBundles are too white
    this is also visible in Editor (Android platform)
  • Other textures (not coming from AssetBundles) seem OK

Example in Unity 6 WebGL Editor (Unity 2022.3 WebGL SceneBundle):

Example in Unity 6 Android Editor (Unity 2022.3 Android SceneBundle):


I observe the same result on my Android Unity 6 player build.

It looks like a color space issue, if I draft a shader it looks like Unity 6 is applying a Linear to RGB conversion to my Unity 2022.3 Android textures:

  • Is it a known issue?
  • Is there a way to disable/reconfigure this conversion (without preventing me from using Unity 6 AssetBundles)?
  • Maybe I can write a C# function that can do the reverse color conversion to the textures when I load the Asset Bundle…
    • But I am not sure if a C# Script can have write access to all textures of the AssetBundle
    • Also if the conversion is done on the C# side this can use a lot of CPU time (slowing down a lot the loading time of my scenes)
    • I might need to filter UI textures (and maybe other kinds of textures, not sure if the issue is happening for every type)
    • The C# function needs to know if the AssetBundle is from Unity 2022.3 or Unity 6, not sure if it is possible
  • We have a lot of Unity 2022.3 AssetBundles, I don’t want to have to rebuild them with Unity 6 (some of them are from other colleagues projects, I don’t have direct access to their sources)

I hope someone can help me to find an easy fix for this issue…

Thanks in advance!

I came up with a custom solution.

One interesting thing I have noticed is that it looks like only RGB_ETC_UNorm are affected by the issue. Also it seems that new AssetBundles created with Unity6 are using ETC2 format.
So, at least in my application, it is safe to only and always convert RGB_ETC_UNorm textures.

To do so, I am using the following shader to do the RGB to Linear conversion:

The shader is called with Graphics.Blit() and the result is stored in dynamically created RenderTextures. The original textures are replaced by the new RenderTextures in their respective materials.

Here is the code I execute after loading my Scene Bundle:

    #if UNITY_ANDROID
    // Compatibility steps for pre Unity 6 Android Asset Bundles
    // For some reason RGB_ETC_UNorm textures require RGB to linear conversion
    // This is easy todo with 2D textures but not cubemaps
    // So for cubemaps we reduce the exposure/intensity by 0.5 of the Skyboxes and ReflectionProbes
    // It seems that Unity 6 Android Asset Bundles are using ETC2 format by default so this code should only executes for older AssetBundles

    // Iterate all materials currently loaded by the application (player and Asset Bundles materials)
    var loadedMaterials = Resources.FindObjectsOfTypeAll<Material>();
    foreach(var loadedMaterial in loadedMaterials) {
        if(_playerMaterials.Contains(loadedMaterial)) { // discards materials that are not in the loaded AssetBundles
            continue;
        }

        // Iterate the newly material textures
        var texturePropertyIds = loadedMaterial.GetTexturePropertyNameIDs();
        foreach(var texturePropertyId in texturePropertyIds) {

            // Skip normal maps, it looks like they are not affected (_normalTextureId = Shader.PropertyToID("_BumpMap"), texturePropertyId = Shader.PropertyToID("_DetailNormalMap"))
            if(texturePropertyId == _normalTextureId || texturePropertyId == _detailNormalTextureId) {
                continue;
            }

            var texture = loadedMaterial.GetTexture(texturePropertyId);

            // Only treat RGB_ETC_UNorm textures since it looks like these are the only one with such issue
            if(texture && texture.graphicsFormat == GraphicsFormat.RGB_ETC_UNorm) {

                    // Cubemaps are more complicated to connvert, it's easier to reduce Skyboxe _Exposure coeefiient (but not perfect)
                if(texture.dimension == TextureDimension.Cube) {
                    if(loadedMaterial.HasFloat("_Exposure")) {
                        loadedMaterial.SetFloat("_Exposure", 0.5f * loadedMaterial.GetFloat("_Exposure"));
                    }
                }
                else { // Copy the texture to a RenderTexture using RGB to Linear conversion shader
                    // create a RenderTexture with the same properties than the original one
                    RenderTexture convertedTexture = new(texture.width, texture.height, 0);
                    convertedTexture.useMipMap = texture.mipmapCount > 1;
                    convertedTexture.filterMode = texture.filterMode;
                    convertedTexture.wrapMode = texture.wrapMode;
                    convertedTexture.anisoLevel = texture.anisoLevel;
                    convertedTexture.mipMapBias = texture.mipMapBias;
                    convertedTexture.dimension = texture.dimension;

                    // Copy the texture into the RenderTexture using RGB to Linear conversion shader (must specify 0 since our shader is ShaderGraph)
                    Graphics.Blit(texture, convertedTexture, _rgbToLinearMaterial, 0);

                        // Replace the material texture by the RenderTexture
                    loadedMaterial.SetTexture(texturePropertyId, convertedTexture);

                    // Remember the created RenderTextures so we can destroy them later when we don't use the AssetBundle anymore (avoid memory leaks)
                    _convertedTextures.Add(convertedTexture);
                }
            }
        }
    }

    // ReflectionProbe textures are not accessible via materials so we also iterate ReflectionProbes and set their intensity if necessary
    var probes = Resources.FindObjectsOfTypeAll<ReflectionProbe>();
    foreach(var probe in probes) {
        if(probe.texture && probe.texture.graphicsFormat == GraphicsFormat.RGB_ETC_UNorm) {
            probe.intensity *= 0.5f;
        }
    }
    #endif

Note: this code is not fully optimized.

For instance, several materials could use the same texture, in that case my code create a new RenderTexture for each time the same original texture is referenced by materials.
Also, I am not unloading the original textures and the new RenderTextures are using at least as much additional memory.

Finally, there is the Cubemaps issue where I haven’t found an easy way to do the RGB to Linear conversion (doesn’t work with Graphics.Blit()). So I prefer to simply reduce the exposure/intensity of the Skyboxes/ReflectionProbes but it doesn’t produce the exact same result (ambient lighting and reflections are a bit less satured).


Edit: Normal maps don’t look good when converted. Not sure if they are affected by the issue but it’s preferable to skip them during the conversion step.