Texture Packing for UE5 - Feedback Welcome

Nanite and Lumen slightly alter the focus of how textures are handled in UE5 (for example legacy AO is set to be superseded by Lumen), so I wanted to revisit the texture packing method I’ve been using on my projects up until now because the more I read, the more I realised how un-optimised and redundant it was. Even with the advances in poly count for meshes in Lumen and the help provided by virtual texturing, there’s still no getting around the limits of the good ol’ texture pool. I’m by no means an expert in compression standards or how unreal handles them so I wanted to improve my knowledge by throwing this over to the community. Any feedback on this is much appreciated.

This process is inspired by a few forum posts here –
https://polycount.com/discussion/184005/normal-map-compression-and-channel-packing
https://forums.unrealengine.com/t/using-red-and-green-channels-as-normal-map-and-using-blue-as-mask/104587/10
and this article -
https://www.reedbeta.com/blog/understanding-bcn-texture-compression-formats/

For this exercise I’ll be focusing on opaque materials without a height map only – I think for most games these types of textures form the majority of background props and can be used for things like grass, gravel, sand, bricks, rocks, wood, metal surfaces etc.

The first material graph is my current ‘catch all’ for most static materials (for translucent material I’d normally add the alpha to the alpha channel of the basecolour RGBA).

This second material is my first updated option, it derives the R and G normal channels from the two alpha channels of the textures. As Normal maps only need two dimensions (R and G) Blue is always a flat value. The channels need to have a scale of -1 to 1 (not just 0 to 1 like the other channels in the material) which is achieved using the ‘ConstantBiasScale’ (set to -0.5 and 2) node and then the Blue channel is added with the ‘DeriveNormalZ’ node. I’d make this into it’s own custom node for a project to save on a bit of performance across multiple materials.
Note: some users have suggested using the ‘normalise’ node after the DeriveNormalZ node, but I’ve found that to make no visual difference in my tests, so I saved on the instruction.

Another thing to note is that this option gives the best result in terms of compression artifacts because the alpha channel of the textures is compressed at 8bit without cross talk from the other colours (as I understand it, could be wrong). The RGB channels are compressed at 5bits for R and B and 6bits for G (don’t ask me why).

This third option cuts down on overhead by doing away with the alpha channel from the BaseColour and instead used the G (remember slightly better quality than R and B) from our composite texture. This does introduce higher compression and cross talk from the other channels but imo it’s hardly noticeable, especially if the textures tend to be in the background more often. We surrender the Specular slot here but for this texture I just added it back as a constant 0, adjust to your own liking.

Lastly I reduced my 4K textures down to 512x512 to try to exaggerate the differences after compression, and yes the third option began to show it’s compression more, but it really wasn’t that bad, you decide.
texturepacking01

Overall I’m pretty happy with the new system, I noted that the base pass instruction count was the same for material 1 and 3 and only 1 extra instruction for material 2 (even though 2 and 3 both have three additional nodes in them to handle the normal map generation). There two important metrics here; 1. Texture lookups (or how many texture calls the system has to make every time a mesh using these materials is loaded in) and 2. Texture pool size (the total amount of textures loaded on the graphics card memory at any one time). I know this technique reduces lookups (by -33% obviously), where I’m not so sure is where using fewer textures with alpha channels actual saves over more textures without them, because of how uncompressed alpha channels are in Unreal. Perhaps someone with more knowledge could shed some light on this.

I always find a height-map useful. For my part I use RHGB for the main, height in the G and a ‘regular’ normal map for my second-sampler; I too tend to use just two. I might derive my normal as you do. Having an extra slot would certainly be handy.

As far as your setup, given you don’t use a heightmap, and G is the ‘better’ channel, I’d put normals R/G into the G channels of each texture and reassemble as neeed. At least get that extra bit (ha!) of fidelity. As i see it now, you’ve got 1/2 and 1/2 on your normal with one channel at a slight loss.

Yeah good points. I think you’re right about using the G channels of RGB only textures for normals, it’s slightly better (even by 1 bit). The only thing is that I would then try to avoid using the alpha channel on any textures at all as adding it almost doubled the texture size for me. So maybe something like.

Texture Sampler 1
R - BaseRed
G - Normal R
B - BaseGreen

Texture Sampler 2
R - BaseBlue
G - Normal G
B - Metallic

Texture Sampler 3
R - Spec
G - Rough
B - Height

But then you are using three textures. I honestly don’t know what’s better for performance - two textures that use the alpha channel or three textures that don’t.

Seems to me that spanning over into three textures, as opposed to two, increases the lookups by +50%, which can’t be good for performance.

My understanding is having the alpha doubles memory consumption when sampling the texture, but having the one-less sampler is more performant since sampling can be shared in the material. Ultimately you use less samplers/samples but extra RAM.

I’d go for the 2-texture. I seem to be doing well enough with that. I use a liberal amount of noise/maths to vary stuff up and seem to be getting away with just the two. Since my materials came out a bit more expensive than I wanted, instruction-wise, I’m feeling the tight-use of packed textures saved me.

Question: I use BC5 for a ‘plain’ normal map. I know Unreal reconstructs the Z on it’s own. Is the fidelity of that, bit-wise, the same as what you are doing with yout 2-channel DeriveNormalZ? If so, I could gain 2 moar channels in my setup by hacking apart my normal-map…

My take is memory is cheap, easily-used, and ultimately grows faster than the core-performance of GPUs. It’s better long-term to gain costs in RAM vs performance (additional sampler overhead).

Thoughts?

=-=-=-=-=-=

EDIT: I tried this on my own. Here’s an EXTREME closeup, like 1 more tiny mouse tick forward puts you in the model-cube. Left is a texture of XYR w/deriveNormalZ and the right is a snip from a straight-up normal-map. SMALL differences, but it looks very workable:

Normal is BC5 and the derived is DXT1.

I’ll do my best to answer your question with what I know.
I think that deriving the normal from two separate alpha channels is actually higher quality (though you can’t perceive it) than the regular individual normal map texture compression that UE uses. But I think doing that adds a greater memory footprint than using 3 x RGB only textures (including an individual normal map).

However, the more important thing, I didn’t really dwell on before, is that at higher resolutions (4k+) the compression artifacts that you’re seeing in your demo there are waaaay less noticeable. As I said, I had to bring the resolution down to 512x512 before I could really begin to notice the compression artifacts messing with the overall appearance.

So my conclusion is that texture packing into 2 x RGB only textures (and deriving normals from the two G channels) where possible when dealing with 4k textures is a big memory and lookup saver, without compromising too much on overall appearance. At lower resolutions those savings aren’t worth it because of the visual fidelity loss.

What would be good in future would be an image standard that allowed unlimited channels and the ability to choose how you compress each one. Just a thought Epic ; )

Where does the assumption material AO is being deprecated with Lumen come from? Material AO is separate from SSAO/GTAO and still functions as expected, provided you are using a movable skylight and have static lighting disabled, just like how it was in UE4.

So my take was similar, that the larger the texture, the better the returns scale.

Not sure I don’t want to give up my g-channel, heightmap; that extra fidelity translates into decently small details on a virtual-heightfield. Overall, good musings though, thanks!

Hello. I just tested it, and I couldn’t get material AO to show up in a level with or without static lighting disabled if lumen was enabled. I had moveable skylights and still nothing. May I ask why you state that it does still function as it did in ue4?

Edit: Also, to anyone else reading who may have some insight, I came across this thread in search of information for my current situation. Dealing with massive project bloat, I need to go ahead and do a major optimization pass on my textures. Most of my assets include AO maps and I don’t know if I should keep them, or scrap them if I’m planning on using lumen in UE5. @PrizmIzm You seem to have a good handle on texture packing. If you were making a large open world game with lots and lots of assets, with most textures being 1k or lower for the sake of storage more than performance, what packing set up do you think would be optimal for ue5 and lumen?

Good post.
I’ve found the same as you regarding UE5 with Lumen enabled - AO textures are ignored. I’m sure I watched a UE5 lecture, when the EA was first released, that talked about the AO channel being ignored and that Lumen used the normal to generate surface shadows. From my tests I can’t see any difference from a material with or without AO plugged in, whereas the difference was obvious in UE4. Which tells me Lumen IS disabling the AO channel in materials.

In the same lecture they suggested that you could multiply the AO texture over the basecolour if you really wanted to use it - but at that point I’d just add it to the basecolour before I even brought it into UE5.

I think, like a lot of things with this EA, we should wait for the final release to judge. But I’ve never been a big user of AO anyway, though I realise it does grant more realistic surface shadowing.

So for your project it depends what additional textures you’re using. I’d advise setting up a number of ‘master’ textures, then make children of those and / or have master texture nodes. For example my approach is to have a master texture for ‘opaque background’ textures. Opaque meaning that the material doesn’t require an alpha channel, but it also wouldn’t use metallic, emissive, bump or height in any way, or WPOs. And ‘background’ meaning props that aren’t really looked at closely. It’s amazing how much that covers - cliffs, tree trunks, rocks, concrete, bricks, wooden surfaces etc. Then every relevant prop I bring into the game I’d make a new child material and use that for the prop.

For that master texture you could arrange your samplers like this.

Texture Sampler 1
R - BaseRed
G - Normal R
B - BaseGreen

Texture Sampler 2
R - BaseBlue
G - Normal G
B - Rough

Then for props that require more material features (hero props) I’d make a master with something like this.

Texture Sampler 1
R - BaseRed
G - BaseGreen
B - BaseBlue
A - Normal R

Texture Sampler 2
R - Metallic
G - Rough
B - Height
A - Normal G

With the normalZ derived from the nodes shown in my original post. You can swap out the Met Rough Height for whatever you want. Personally I’m going to be using Nanite over bump offset or POM maps (for example I had bump offset brick textures everywhere in my game, but I’m replacing them with actual nanite geometry now), so my second sampler node is R - Met G - Spec B - Rough A Normal G.

Bump Offset

Nanite Geo

Oh, wow. I really appreciate the detailed response. So with your hero props, you do value a two 4-channel texture approach over three 3-channel textures even when the resolutions are a bit smaller?