Search Unity

Bug Unity logs 'Assertion failed on expression: 'mipLevel < m_MipCount' but code works fine?

Discussion in 'General Graphics' started by krubbles, May 16, 2023.

  1. krubbles

    krubbles

    Joined:
    Sep 20, 2020
    Posts:
    14
    I have already filed it as a bug report, but I wanted to know if anybody has an idea of how I can workaround it. I wrote this utility to execute Texture2D.Compress() asynchronously and it works, but I get the error 'Assertion failed on expression: 'mipLevel < m_MipCount' whenever I call compress. (to recreate it attach this mono-behaviour to an object and press play.)
    Code (CSharp):
    1. using System;
    2. using System.Collections;
    3. using Unity.Collections;
    4. using Unity.Profiling;
    5. using UnityEngine;
    6. public class BugExample : MonoBehaviour
    7. {
    8.     public Texture2D testUncompressed;
    9.     public Texture2D testCompressed;
    10.     public int width = 256, height = 256;
    11.     // Start is called before the first frame update
    12.     void Start()
    13.     {
    14.         testUncompressed = new Texture2D(width, height, TextureFormat.RGBA32, true);
    15.         for (int y = 0; y < height; y++)
    16.             for (int x = 0; x < width; x++)
    17.                 testUncompressed.SetPixel(x, y, new Color(x / (float)width, y / (float)height, 0.3f));
    18.         testUncompressed.Apply();
    19.         (IEnumerator job, Texture2D result) = AsyncCompression.CompressTextureJob(testUncompressed);
    20.         while (job.MoveNext()) { }
    21.         testCompressed = result;
    22.     }
    23.     // Update is called once per frame
    24.     void Update()
    25.     {
    26.      
    27.     }
    28. }
    29. public static class AsyncCompression
    30. {
    31.     public static TextureFormat GetCompressedFormat(TextureFormat format)
    32.     {
    33.         return format switch
    34.         {
    35.             TextureFormat.RGBA32 => TextureFormat.DXT5,
    36.             TextureFormat.ARGB32 => TextureFormat.DXT5,
    37.             TextureFormat.RGB24 => TextureFormat.DXT1,
    38.             TextureFormat.R8 => TextureFormat.BC4,
    39.             _ => throw new NotSupportedException()
    40.         };
    41.     }
    42.     // log2 of value. returns 0 for 1, 1 for 2, 2 for 4
    43.     static int PowerOfTwo(int value)
    44.     {
    45.         for (int i = 0; i < 32; ++i)
    46.             if (value >> i == 0)
    47.                 return i - 1;
    48.         throw new ArgumentOutOfRangeException($"cannot get power of two: {value}");
    49.     }
    50.     public static (IEnumerator job, Texture2D result) CompressTextureJob(Texture2D source, int blockCount = 8)
    51.     {
    52.         TextureFormat compressed = GetCompressedFormat(source.format);
    53.         Texture2D result = new(source.width, source.height, compressed, source.mipmapCount - 2, !source.isDataSRGB, true);
    54.         return (CompressTexureJob(source, result, blockCount), result);
    55.     }
    56.     static readonly ProfilerMarker PCompressTextureBlock = new("Compress Texture Block");
    57.     static IEnumerator CompressTexureJob(Texture2D source, Texture2D result, int blockCount)
    58.     {
    59.         bool linear = !source.isDataSRGB;
    60.         NativeArray<byte>[] resultData = new NativeArray<byte>[result.mipmapCount];
    61.         for (int i = 0; i < resultData.Length; ++i)
    62.             resultData[i] = result.GetPixelData<byte>(i); // pointer, not copy
    63.         NativeArray<byte>[] sourceData = new NativeArray<byte>[source.mipmapCount];
    64.         for (int i = 0; i < sourceData.Length; ++i)
    65.             sourceData[i] = source.GetPixelData<byte>(i); // pointer, not copy
    66.         int sourceBlockByteSize = sourceData[0].Length / blockCount;
    67.         int blockByteSize = resultData[0].Length / blockCount;
    68.         int blockHeight = source.height / blockCount;
    69.         int blockMipCount = PowerOfTwo(blockHeight) - 2;
    70.         Texture2D block = new(source.width, blockHeight, source.format, blockMipCount, linear, true);
    71.         for (int i = 0; i < blockCount; ++i)
    72.         {
    73.             PCompressTextureBlock.Begin();
    74.             for (int mip = 0; mip < blockMipCount; ++mip)
    75.                 block.SetPixelData(sourceData[mip], mip, (sourceBlockByteSize >> mip * 2) * i);
    76.             block.Compress(true);
    77.             for (int mip = 0; mip < blockMipCount; ++mip)
    78.             {
    79.                 NativeArray<byte> blockData = block.GetPixelData<byte>(mip);
    80.                 int mipBlockByteSize = blockByteSize >> mip * 2;
    81.                 resultData[mip].Slice(mipBlockByteSize * i, mipBlockByteSize).CopyFrom(blockData);
    82.             }
    83.             block.Reinitialize(block.width, block.height, source.format, true);
    84.             PCompressTextureBlock.End();
    85.             yield return null;
    86.         }
    87.         GameObject.Destroy(block);
    88.         int remainingMips = source.mipmapCount - 2 - blockMipCount;
    89.         if (remainingMips > 0)
    90.         {
    91.             Texture2D lowMips = new(source.width >> blockMipCount, source.height >> blockMipCount, source.format, remainingMips, linear, true);
    92.             for (int mip = 0; mip < remainingMips; ++mip)
    93.                 lowMips.SetPixelData(sourceData[mip + blockMipCount], mip);
    94.             lowMips.Compress(true);
    95.             for (int mip = 0; mip < remainingMips; ++mip)
    96.                 result.SetPixelData(lowMips.GetPixelData<byte>(mip), mip + blockMipCount);
    97.                 GameObject.Destroy(lowMips);
    98.         }
    99.         result.Apply(false);
    100.     }
    101. }
     
  2. c0d3_m0nk3y

    c0d3_m0nk3y

    Joined:
    Oct 21, 2021
    Posts:
    666
    This doesn't look right:
    Code (CSharp):
    1. int blockHeight = source.height / blockCount;
    2. int blockMipCount = PowerOfTwo(blockHeight)
    I think you need to call PowerOfTwo(Math.Max(source.width, source.height)).

    Also, why do you exclude the last 2 mip levels? Even block compressed textures can go down to 1 x 1. The smallest mips will still be at least one block large but the GPU will ignore most texels of the block.

    The expression
    sourceBlockByteSize >> mip * 2
    looks weird to me too. Are you sure the operator precedence is what you think it is? I would put some parentheses around it. Also, why times two?

    You also should deal with non-square textures. Your code won't work if width is greater than height. You need to clamp the mip size.
    int mipWidth = Math.Max(source.width >> mipLevel, 1);
    int mipHeight = Math.Max(source.height >> mipLevel, 1);

    PS: Actually, the longer I look at it, the less I understand the code. Why do you go through all of the trouble with blocks? Can't you just call Texture2D.Compress on the entire texture?
     
    Last edited: May 16, 2023
  3. krubbles

    krubbles

    Joined:
    Sep 20, 2020
    Posts:
    14
    I should have said this is only running on square textures at the moment. It would be nice to support non square ones so I'll do it at some point.

    The operator precedence is multiply first, but I should probably have parenthesis for code readability. The reason for it is that each mip is 4 times less data then the previous, because its twice as small on both the x and y axis.

    The reason to go for all the trouble with blocks is to make the compression asynchronous. If you call Texture2D.Compress() on a 1024 square texture (which my project does regularly at runtime) that takes around 40ms on my fast CPU, and probably would take around 80-100ms on our min-spec. That would involve dropping a lot of frames. By doing the work like this in an enumerator, you can keep the work per-frame to something more reasonable like 5ms.

    To be clear though, this code does work, it just gives this error message. It's not a C# exception, its unity C++ logging an error, and I don't know what I did that causes the unity code to run in a bad state.
     
  4. c0d3_m0nk3y

    c0d3_m0nk3y

    Joined:
    Oct 21, 2021
    Posts:
    666
    Ok, thanks for the clarification. I would probably do it on the GPU in a shader to make it fast enough.

    I'm still wondering why you need mips for a single block, though.
     
  5. krubbles

    krubbles

    Joined:
    Sep 20, 2020
    Posts:
    14
    I'm not sure I know what you mean by needing mips for a block. The blocks need to have mips because you need your final texture needs to have mips. If you have no mips in your blocks, then you to do all the mips in the final pass of this function, which is 25% of the total work, which is not a small enough subdivision.

    I'm not doing it on the GPU because as bug-prone and annoying as the way I'm doing it now is, doing it on the GPU is like 10x worse. I'd have to write my own BC compressor in HLSL, use the GraphicsFence which in my experience only works if you use the enum value that explicitly says its unsupported, do some weird Graphics.CopyTexture call between non-compressed and compressed textures of different sizes (does that even work in unity? what they do in the paper is create two textures that share a GPU memory buffer but I don't think unity exposes an API that lets you do that). And all that would have to work at different mip levels and be called a bunch of times. It would be faster, but I don't think it's worth it.
     
  6. c0d3_m0nk3y

    c0d3_m0nk3y

    Joined:
    Oct 21, 2021
    Posts:
    666
    That's just an optimization for XBox. But I understand if you don't want to go this route because it would require some additional time and effort.

    I mean that you are passing blockMipCount instead of 1 when creating the block texture.
    Code (CSharp):
    1. Texture2D block = new(source.width, blockHeight, source.format, blockMipCount, linear, true);
    I haven't looked at your code in detail but conceptionally this doesn't make sense to me. When you use block compression, each mip level is subdivided into 4x4 blocks of texels. Then instead of storing 16 colors, you just store a palette of 2 colors and 16 two-bit indices that give you 4 ways to mix those two colors. So a block is a two-dimensional by nature. Each mip level is seen as a separate image. Blocks don't reference texels of other mip levels.

    So in pseudo code you would write something like this
    Code (CSharp):
    1. Texture2D block = new(4, 4, source.format, 1, source.linear);
    2.  
    3. for (int mip = 0; mip < source.mipCount; ++mip)
    4. {
    5.     int mipWidth = Math.Max(source.width >> mip, 1);
    6.     int mipHeight = Math.Max(source.height >> mip, 1);
    7.     int numBlocksX = Math.Max(mipWidth / 4, 1);
    8.     int numBlocksY = Math.Max(mipHeight / 4, 1);
    9.  
    10.     for (int y = 0; y < numBlocksY; ++y)
    11.     {
    12.         for (int x = 0; x < numBlocksX; ++x)
    13.         {
    14.             Rect sourceRect = new Rect(x * 4, y * 4, 4, 4);
    15.             Point destPos = new Point(0, 0);
    16.             int srcMip = mip;
    17.             int dstMip = 0;
    18.             CopyTexture(source, block, sourceRect, destPos, srcMip, dstMip);
    19.             block.Compress();
    20.             // TODO copy compressed texels to destination texture
    21.             yield return;
    22.         }
    23.     }
    24. }
    Am I missing something?

    PS: Here is a compute shader implementation for Unity:
    https://forum.unity.com/threads/runtime-texture-compression-using-compute-shader.1031014/
     
    Last edited: May 17, 2023
  7. krubbles

    krubbles

    Joined:
    Sep 20, 2020
    Posts:
    14
    The difference between what you are doing and what I am doing is just that I iterate over the mips within the x and y iterations, not over them. its foreach (x) { foreach (y) { foreach (mip) } } instead of foreach (mip) { foreach (x) { foreach (y) } } I call compress on the block outside of the loop for the mip and because the compressed block has mips, I am effectively compressing all the mips in the 1 call to .Compress() and then copying them into the destination texture.