Search Unity

  1. Megacity Metro Demo now available. Download now.
    Dismiss Notice
  2. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

Way To Bake _bumpscale Into A Normal Map? I'm Atlasing Many Textures.

Discussion in 'General Graphics' started by hungrybelome, Apr 14, 2019.

  1. hungrybelome

    hungrybelome

    Joined:
    Dec 31, 2014
    Posts:
    336
    Hi, I'm making an atlas of models that have different bump scales greater than 1 for their normal maps. Some of the models are pieces of armor with intricate designs that really need the bump scale.

    I can bake different material _Color values into their albedo map on the atlas by simply multiplying the source texture by the _Color when transfering pixels into the atlas. Same with _OcclusionStrength and the oclcusion texture.

    But is there a way to also bake the _BumpScale into the normal map? I was thinking that if I could somehow use non-normalized normal maps, I could multiply the normal map .rg channels by the bump scale when copying pixles in the atlas, since I believe UnpackNormals() multiplies the .rg channels by the bump scale. But this does not seem to be working, as using a texture not marked as a normal map does not seem to be usable for normals. I've read that maybe this is because of how textures marked as normal maps get swizzled or something? And if I do mark the modified normal map as a normal map, the values are still incorrect, and I'm wondering if marking as a normal map forces the vectors to normalize as 1 magnitude.

    If I cannot have normals with magnitudes greater than 1, I thought that maybe I could divide the normal map by 3, and then use _BumpScale 3 for the atlas material, and then normal maps that *should* be using _BumpScale 1 would equal roughly 1. I would be okay with the loss of precision if it actually worked. Though this would also require non-normalized normal maps.

    Any ideas?

    Thanks!
     
    Last edited: Apr 14, 2019
  2. bgolus

    bgolus

    Joined:
    Dec 7, 2012
    Posts:
    12,329
    While it's true two components of the normal map are multiplied by the bump scare, the final used normal that UnpackScaledNormal spits out is still normalized with a magnitude of 1, and that normal can be represented in a normalized normal map just fine. The hard part is producing that scaled normal map. Something like substance might be able to do it, but your average paint program won't. The best option I can think of would be to use Unity to do it with a Blit to a render texture with a shader that just takes a normal map and applies the scale (then * 0.5 + 0.5) and save out the results to a PNG.
     
  3. hungrybelome

    hungrybelome

    Joined:
    Dec 31, 2014
    Posts:
    336
    Hi @bgolus, thanks for your reply.

    Oh, I missed that it does infact normalize after the scaling.

    For my test case, I was modifying the texture and creating a new one with code like this:

    Code (CSharp):
    1.  
    2. var bumpScale = material.GetFloat("_BumpScale");
    3.  
    4. var copyOfNormalMap = new Texture2D(originalNormalMap.width, originalNormalMap.height, TextureFormat.RGBA32, false, false);
    5.  
    6. for (var x = 0; x < copyOfNormalMap.width; x++)
    7. {
    8.     for (var y = 0; y < copyOfNormalMap.height; y++)
    9.     {
    10.         var pixel = originalNormalMap.GetPixel(x, y);
    11.      
    12.         pixel.r *= bumpScale;
    13.         pixel.g *= bumpScale;
    14.      
    15.         var normalized = new Vector3(pixel.r, pixel.g, pixel.b).normalized;
    16.  
    17.         pixel.r = normalized.x;
    18.         pixel.g = normalized.y;
    19.         pixel.b = normalized.z;
    20.  
    21.         copyOfNormalMap.SetPixel(x, y, pixel);
    22.     }
    23. }
    24.  
    However, assigning the output texture as the normal map for my material with bump scale 1 did not match the original normal map with the bump scale value assigned. Since .rg channels were multiplied by bump scale 3 in my case, the output texture did look much more yellow in the inspector, which I believe to correct. But it seems I got something wrong since the output texture is not very accurate as a normal map (everything appears way too flat).

    Can you please elaborate on the "(then * 0.5 + 0.5)" you mentioned?

    Thanks for your help!

    *EDIT*

    Ah, I see, you meant to repack the normals!

    I got it working now with this code:

    Code (CSharp):
    1. for (var x = 0; x < copyOfNormalMap.width; x++)
    2. {
    3.     for (var y = 0; y < copyOfNormalMap.height; y++)
    4.     {
    5.         var pixel = originalNormalMap.GetPixel(x, y);
    6.      
    7.         var normal = new Vector3(pixel.r, pixel.g, pixel.b);
    8.      
    9.         // Unpack the normals.
    10.         normal.x = normal.x * 2 - 1;
    11.         normal.y = normal.y * 2 - 1;
    12.         normal.z = normal.z * 2 - 1;
    13.        
    14.         // Appply bump scale.
    15.         normal.x *= bumpScale;
    16.         normal.y *= bumpScale;
    17.      
    18.         // Re-pack the normals.
    19.         normal.x = normal.x * 0.5f + 0.5f;
    20.         normal.y = normal.y * 0.5f + 0.5f;
    21.         normal.z = normal.z * 0.5f + 0.5f;
    22.  
    23.         pixel.r = normal.x;
    24.         pixel.g = normal.y;
    25.         pixel.b = normal.z;
    26.      
    27.         copyOfNormalMap.SetPixel(x, y, pixel);
    28.     }
    29. }
    Thanks Bgolus!
     
    Last edited: Apr 15, 2019
  4. bgolus

    bgolus

    Joined:
    Dec 7, 2012
    Posts:
    12,329
    Textures are generally stored using formats that can only represent a 0.0 to 1.0 value, but normals are a vector, with each component having a -1.0 to +1.0 range. This means to store a normal in a texture you need to remap the -1.0 to +1.0 range to a 0.0 to 1.0 range, as well as remap the 0.0 to 1.0 range back to -1.0 to +1.0 when reading the texture. Hence when storing the normal you need to use normal * 0.5 + 0.5, and when reading the normal you need to use normalTex * 2.0 - 1.0.

    Tangent space normal maps work by storing a normal relative to the surface's normal and UVs. This means tangent space normal maps can make the assumption that the z component of the normal is always positive (always facing away from the surface), and because normals need to be a unit length, you can get away with only storing the x and y components and deriving the z component using Pythagorean theorem.

    So the default UnpackNormal function looks like this:
    Code (CSharp):
    1. // Unpack normal as DXT5nm (1, y, 1, x) or BC5 (x, y, 0, 1)
    2. // Note neutral texture like "bump" is (0, 0, 1, 1) to work with both plain RGB normal and DXT5nm/BC5
    3. fixed3 UnpackNormalmapRGorAG(fixed4 packednormal)
    4. {
    5.     // This do the trick
    6.    packednormal.x *= packednormal.w;
    7.  
    8.     fixed3 normal;
    9.     normal.xy = packednormal.xy * 2 - 1;
    10.     normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
    11.     return normal;
    12. }
    Unity stores normal maps in one of three different layouts, mobile RGB, "DXTnm" AG, and BC5 RG.

    For mobile platforms without DXTC/BC support (the common GPU texture formats used on the PC and console), normal maps are stored in that platform's color texture format of choice as it appears in the original normal map texture file as you would see it if you open the file in Photoshop or Gimp. The default UnpackNormal just does return normalMap.xyz * 2 - 1; and assumes at some point it'll get normalized elsewhere (which isn't always true for performance reasons, and leads to weird lighting artifacts on some mobile devices). That's in a separate function than what I pasted above, but the above line is literally the entire meat of that function so I skipped it.

    The default format Unity uses is the "DXTnm" format, which is just a DXT5 RGBA texture with the normal's x stored in the alpha channel, y stored in g (like you would expect), and the R and B channels cleared to 1.0. The reason for this is because the RGB and A of a DXT5 are compressed separately, resulting in less compression artifacts.

    An optional format, and the default one used by the SRPs, is BC5, which has been the console and PC game industry standard for the last decade. It's a texture format with the equivalent of two DXT5 alpha channels as the R and G channels. The alpha channel of the DXT5 is much higher quality than the RGB channels, so this results in a much higher quality normals than DXT5 can produce.

    Because Unity supports both of the last two formats on console and PC, it uses a "trick" so that the shader doesn't have to know which format is being used. That trick being to multiply the x and w components together. Since the DXTnm texture has the R = 1, and the A = x, and BC5 has R = x and A = 1, you end up with the same RG = xy in both cases. The x and y are then expanded from the 0.0 to 1.0 range to the -1.0 to +1.0 range, and a positive z value is derived on the line after. That sqrt(1 - saturate(dot(normal.xy, normal.xy)) is pythagorean theorem with some extra protection against floating point errors potentially causing a negative value inside the sqrt:
    z = sqrt(1 - clamp01(x * x + y * y));

    Okay, so that's your basic normal map unpacking. What about scaled normals?
    Code (CSharp):
    1. half3 UnpackScaleNormalRGorAG(half4 packednormal, half bumpScale)
    2. {
    3.     #if defined(UNITY_NO_DXT5nm)
    4.         half3 normal = packednormal.xyz * 2 - 1;
    5.         #if (SHADER_TARGET >= 30)
    6.             // SM2.0: instruction count limitation
    7.             // SM2.0: normal scaler is not supported
    8.             normal.xy *= bumpScale;
    9.         #endif
    10.         return normal;
    11.     #else
    12.         // This do the trick
    13.         packednormal.x *= packednormal.w;
    14.  
    15.         half3 normal;
    16.         normal.xy = (packednormal.xy * 2 - 1);
    17.         #if (SHADER_TARGET >= 30)
    18.             // SM2.0: instruction count limitation
    19.             // SM2.0: normal scaler is not supported
    20.             normal.xy *= bumpScale;
    21.         #endif
    22.         normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy)));
    23.         return normal;
    24.     #endif
    25. }
    The first part of this function is the section used by mobile platforms, like I mentioned before. The second section "does a trick" to support both the DXTnm and BC5 compressed textures (just like the previous function). The scaling is only done on GLES 3.0 or better platforms (technically some GLES 2.0 platforms with certain extensions too). Unity no longer supports any PCs or consoles that aren't "SHADER_TARGET >= 3.0", so scaling always happens on non-mobile platforms. Notice however that it does the scaling before deriving z for the non-mobile section. Also note, it doesn't actually renormalize, but it's fairly safe to assume that it will later for non-mobile platforms.

    One curiosity of all of this is, usually, the result of UnpackNormal is guaranteed to be a normalized vector (within float precision limits). But because the UnpackScaleNormal function is outputting the scaled and unclamped xy values and deriving the z from clamped values, it actually potentially produces an unnormalized vector. Again, the normal will eventually be normalized again elsewhere most of the time, but it can cause issues in custom shaders if you're not careful!


    So, your above C# code has two issues. First is you're multiplying the raw pixel color values, and you're not rederiving the z component. Technically if you'd just been remapping the ranges prior to, you'd match roughly what the mobile version of scaled normals does, but to match what you're seeing in the editor requires a bit more math.
    Code (csharp):
    1. var pixel = originalNormalMap.GetPixel(x, y);
    2.  
    3. Vector3 normal = new Vector3((pixel.r * pixel.a) * 2f - 1f, pixel.g * 2f - 1f, 0f);
    4. normal *= bumpScale;
    5. normal.z = Mathf.Sqrt(1f - Mathf.Clamp01(normal.x * normal.x + normal.y * normal.y));
    6. normal = normal.normalized;
    7.  
    8. // this assumes you're spitting this back out to the disk, otherwise you may need to reswizzle the values
    9. pixel.r = normal.x * 0.5f + 0.5f;
    10. pixel.g = normal.y * 0.5f + 0.5f;
    11. pixel.b = normal.z * 0.5f + 0.5f;
    12. pixel.a = 1f;
     
    hungrybelome and neoshaman like this.
  5. hungrybelome

    hungrybelome

    Joined:
    Dec 31, 2014
    Posts:
    336
    Thanks @bgolus for the excellent write up! Actually, I was going through your detailed answers in a number of related threads, which led to my understanding of your unpack/repack hints a little before your new post. This long post is a nice instructive summary and elobration on your previous answers in the other threads. Also a lot of interesting history and info on the PC normal map compression types, which I was unfamiliar with since I mainly work with mobile. Also a great tip about bewaring potentially unnormalized vectors on some low-end mobile!

    Once again, thank you for your very generous help!