I created a model in Blender and applied a normal map to it. Everything looks smooth and correct in Blender. After exporting it as a glTF file, I also verified that it displays properly in Windows’ built-in 3D model viewer — the normal map appears smooth and without issues.
However, when I load the same model in Three.js, there is a strange banding effect on the surface that doesn’t appear in the other viewers. I’ve already tried adjusting the colorSpace of the normal map (including setting it explicitly to NoColorSpace / LinearEncoding), but the issue persists.
What could be causing this? Any insight or guidance would be appreciated.
The map file contained in your downloadable .zip file reveals some interesting detail:
It’s in rgb format having 16 bit Integer values per RGBA channel. So some “steps” are inevitable already.
While a 16 bit integer value in theory allows for some 65k distinct values per channel, you are artificially limiting yourself to a very narrow band of light blue shades only.
What I’m trying to say is, that a more than inevitable amount of banding is innate to this particular map file right from the start. The slight color gradients across the map which appear to be smooth are not continuous at all.
I then proceeded to apply a posterization effect to the map file in GIMP, with from left to right increasing even numbers of posterization steps [2 .. 10]:
Note, how the boundaries between different colors remain fixed in place. Even though I would allow for more distinct colors during less aggressive posterization, there aren’t any more distinct colors around!
Final test in Three.js Editor:
I applied the map as a Normal Map onto a sphere geometry. When the map file is mapped (pun intended) to the range [0.0 .. 1.0], there appears to be no visible banding:
By cranking up the upper bound of the mapping range, you effectively amplify the contrast between adjacent areas of different color, making the contrast more visible. By extension, without such amplification a contrast must have been present already, albeit hardly perceivable.
I would have liked to attach the project.json for loading into the Three.js editor, but due to size limit that wasn’t allowed - the map file has 8.4 MB already.
P.S.: my suggestion would be to switch to an RGB map file having floating point values per channel, which also utilizes the full range of intensities as much as possible.
I don’t think browsers can import PNG textures at 16-bit precision in WebGL (see: Looking to access 16-bit image data in Javascript/WebGL - Stack Overflow) so very likely it’s only being loaded at 8-bit precision. It would be an interesting test to export the normal map instead as a 16-bit or 32-bit EXR image (this would need to be separate from the glTF file) and see if the same issue occurs. I’m not sure that’s the best “fix”, but it would at least be helpful to know the result!
Testing some other viewers, not based on three.js…
… I see the same banding clearly in the PlayCanvas viewer. In Babylon and the Khronos Sample Viewer there is less, but it’s possible that is just because the lighting is less strong, at some angles I do still see the banding. Perhaps the Windows viewer is able to load the normal map at 16-bit rather than 8-bit precision?
I agree that dithering will sort of “smudge over” the effect of having low color resolution. Yet I wouldn’t call this a true “fix”, as it doesn’t introduce any new intermediary colors into a color ramp/transition.
The above linked Wikipedia article on posterization confirms btw. both views:
… The result may be compounded further by an optical illusion, called the Mach band illusion, in which each band appears to have an intensity gradient in the direction opposing the overall gradient. This problem may be resolved, in part, with dithering.
Typically dithering=true works well when you’re seeing banding at the minimum increments possible on an 8-bit display, common (for example) with a dark gradient covering a large area of the screen. In this case… the normal map is creating bands with steps >>1/255, in my tests it didn’t seem like dithering was strong enough. You can see the dithering RGB offsets are small:
But maybe a custom, stronger dither pattern would work too… If so I would consider it a good fix, and probably preferable to using a large 16 or 32-bit normal map.
Thank you so much for all your reply — it really helped me a lot!
At first, I tried switching to an 8-bit image, but the issue still persisted. Then I noticed what @vielzutun.ch mentioned about the lack of detail in the normal map itself, and that gave me a big hint. in Blender, I used a procedurally generated surface with a very limited height range, and then baked that directly into a normal map. I didn’t consider how little variation that would actually produce in the final texture.
Your comment made me think: why not bake a normal map with a much greater height range, and then use a small normalScale value in Three.js to get the subtle effect I want?
I gave it a try — and it worked perfectly! The result looks great and the banding issue is completely gone. I now realize that by restricting the height range too much during baking, I lost a lot of detail, which likely caused the visible artifacts. The solution was to preserve enough height variation during baking to retain detail, and then control the height using normalScale.