Automatically Combining Multiple Texture Sets for the Same Model

If you have multiple normal maps, for instance, for the same mesh UVs, combining them in Photoshop or GIMP can be tedious.
When using Substance Painter it can be useful to keep parts of your model separated into different texture sets. This can help with masking, or simply keeping your layer stack cleaner. Unfortunately, Painter cannot export all the maps from multiple texture sets as a single atlas, even if the UVs of the separate objects were created with that in mind. Obviously, in the world of game design it's a good idea to keep the number of large textures you load into memory to a minimum, so we certainly shouldn't use five texture sets for a single object in-game if we can help it. To be fair to Substance, there's probably a million ways to prevent this problem in the first place. I suspect that with some clever ID masking you could fairly easily texture an entire model with only one texture set. But fixing this problem afterward shouldn't be as annoying as it is.
Combining these textures is a relatively simple process: you only need to separate the UV islands from whatever background color substance puts out, and layer them on top of each other. Separating what parts of the image are covered by UV islands may sound difficult, but don't worry, we don't actually need to get the UVs from the model. We only need to get all the pixels with a color different to the background fill color that Substance uses for that map, then copy all those pixels from all the maps in a given texture group (i.e. all the normal maps) and paste them into one, final texture. This is a simple process, but still quite tedious to do by hand, especially if you need to frequently iterate on textures.
So I wrote a python script to do it for me. But that was (very) slow, so I also wrote another C# script which was way faster. This was not only because C# tends to be a faster language in general, but also because I multi-threaded it so it can combine all of the texture groups I need at once.
I've provided my code, which you're welcome to use, but note that I'm using the Unity HDRP workflow in Substance Painter; you'll need to make some changes if you want it to work properly for any other use case.
using System.IO;
using System.Drawing;
using System.Drawing.Imaging;
using System.Diagnostics;
using System.Threading;
public class Atlaser
{
public static void Main(string[] args)
{
/*Here I'm creating a thread to combine the maps for each texture set component.
You'll want to add or remove threads depending on what kinds of maps you're using in your workflow.
You'll also need to change the filename endings to match those of your textures.
Finally, change the colors to match the default background color of the maps. Remember that C# formats colors as ARGB (alpha, red, green, blue) for some reason.
*/
Console.WriteLine("Opening Threads...");
Thread normal = new Thread(Atlaser.Atlas);
Thread baseColor = new Thread(Atlaser.Atlas);
Thread Mask = new Thread(Atlaser.Atlas);
Thread Emissive = new Thread(Atlaser.Atlas);
normal.Start(new FileAndCol("_Normal.png", Color.FromArgb(255, 127, 127, 255)));
baseColor.Start(new FileAndCol("_BaseMap.png", Color.FromArgb(255, 0, 0, 0)));
Mask.Start(new FileAndCol("_MaskMap.png", Color.FromArgb(178, 0, 0, 0)));
Emissive.Start(new FileAndCol("_Emissive.png", Color.FromArgb(255, 0, 0, 0)));
}
/*
Atlas() grabs all the files in the current directory with the provided file extension, creates a new texture of the same size filled with the baseline/background color, then moves the deltas from the opened maps over to the new texture, and finally saves the new texture to the disk.
*/
private static void Atlas(object? fcobj)
{
if(fcobj == null)
return;
FileAndCol fc = fcobj as FileAndCol;
var watch = new Stopwatch();
watch.Start();
Console.WriteLine("Beginning thread for " + fc.fileExtension + " images.");
List<Bitmap> maps = new List<Bitmap>();
string[] files = Directory.GetFiles(Directory.GetCurrentDirectory(), "*" + fc.fileExtension);
string o = "Found " + files.Length + " maps : \n ";
foreach (string file in files)
{
o += file + "\n ";
maps.Add(new Bitmap(file));
}
Console.WriteLine(o);
Bitmap finalMap = new Bitmap(maps[0].Width, maps[0].Height);
using (Graphics g = Graphics.FromImage(finalMap))
using (SolidBrush brush = new SolidBrush(Color.FromArgb(fc.col.ToArgb())))
{
Rectangle rect = new Rectangle(0, 0, finalMap.Width, finalMap.Height);
g.FillRectangle(brush, rect);
}
//Console.WriteLine("Iterating through maps...");
for (int i = 0; i < maps.Count; i++)
{
Console.WriteLine(" Beginning map: " + files[i] + " (" + fc.fileExtension + ") " + (i+1) + " of " + maps.Count);
for (int x = 0; x < finalMap.Width; x++)
{
for (int y = 0; y < finalMap.Height; y++)
{
Color px = maps[i].GetPixel(x, y);
if (!px.Equals(fc.col))
{
finalMap.SetPixel(x, y, px);
}
}
}
}
finalMap.Save(fc.fileExtension.Remove(fc.fileExtension.Length - 4) + "_Combined.png", ImageFormat.Png);
watch.Stop();
Console.WriteLine(" **" + fc.fileExtension + " thread completed in " + watch.ElapsedMilliseconds + " ms");
}
}
public class FileAndCol
{
public string fileExtension;
public Color col;
public FileAndCol(string s, Color c)
{
fileExtension = s;
col = c;
}
}
It's not a terribly complicated script, all said. If you're new to .NET (or if you only ever use C# inside of Unity) then running this is pretty simple:
- Create a new C# console app in Visual Studio or VS Code (or Rider or any other .NET IDE)
- Create a new, empty C# script
- Copy and paste the above code in there
- Make any edits you need to work with your particular textures, save the file
- Move your separate texture maps in the same directory as the script
- Run the script by pressing the big green play button at the top (Visual Studio), or by opening the directory in a terminal and entering the following command:
dotnet run
Once you're sure its working how you want, you could even build the project to an executable which you could just drag-and-drop to your Substance Painter export folder.
It would also be handy to build a small GUI or CLI to customize exactly which maps to atlas and how, but it works well enough as-is for me so this is where I'm leaving it. Hopefully you learned something, or at least got a useful tool!
By the way, if you were curious about the Python version I wrote, here it is:
import glob, os
from PIL import Image, ImageColor
textures = []
for img in glob.glob("*BaseMap.png"):
textures.append(Image.open(img))
print('Found ' + img)
neutralColor = (0,0,0,1)
finalImg = Image.new('RGBA', textures[0].size, neutralColor)
print('Iterating through maps...')
i = 0
for img in textures:
print(' Beginning map: ' + img.filename)
for x in range(img.size[0]):
#print(" Current col: ", x)
for y in range(img.size[1]):
px = img.getpixel((x,y))
if px != neutralColor:
finalImg.putpixel((x,y), px)
i += 1
finalImg.show()
finalImg.save('combinedBlackMaskTexture.png')
It's an even simpler script. In uses pillow to create images, and follows the same basic algorithm: looping through every pixel of every image, and copying over the parts that don't match a given baseline color.
I only ever got it to atlas a single texture set, and decided that Python was a little too slow for my needs. In fact it took 2.5 times longer than the C# script to atlas a single texture group. Since there can be four or more texture groups in a single set, and parallel processing in Python can be a bit of a tall order, I decided the switch to another language was a good idea. Although, writing this script was a lot easier.