This would be more efficient if done in a shader but if you need to do pixel manipulation on the CPU, this is the quickest way I have found so far. Let's say you have 3 cubes, each with their own texture and you want to be able to dynamically color specific areas of the cube and you'd also like all 3 textures to be combined into one large texture. Here is an example of what I'm going for.

I have the original 512 X 512 textures and what I am calling overlay textures (from my time using UMA in Unity.) Here is an example of one of the original textures and one of the overlay textures. You can think of the overlay texture as a mask.


This code is written in c# but hopefully, I explain it well enough that it could easily be converted to GDScript. It assumes that all of your original textures and overlays are all the same size. If they aren't, then may god have mercy on your soul.
First, we load the original textures and the overlays.
Texture2D someTexture = GD.Load<Texture2D>(pathToTexture);
We declare an integer variable called offsetMax with a value of 3. This keeps track of how many images wide the destination image is. We multiply the width of any one of our textures by the number of images we are combining to get our new width. In this case, 3. Height will the same for the source textures as the destination image since we are laying each texture next to each other horizontally.
int offsetMax = 3;
int newWidth = someTexture.GetWidth() * offsetMax;
int newHeight = someTexture.GetHeight();
Now, we can create the destination image.
Image destImage = Image.Create(newWidth,newHeight,true, Image.Format.Rgba8);
We have several textures we want to append to the destImage, so let's create a function for doing that.
void AddImageWithOverlay(Image mainImage, Texture2D albedoTexture, Texture2D overlayTexture, int offset, int offsetMax, Color color, float colorStrength)
mainImage is the large destImage we created above. albedoTexture is any one of the original textures we loaded above. overlayTexture is any one of the overlay textures we loaded above. offset is where on the larger image the original texture will land. offset and offsetMax are expressed in number of images. So offestMax is the maximum number of original textures we are adding and offset is how many image widths we are moving to the right in mainImage. color is the color we want to color the area specified by our overlay texture. And finally, colorStrength is how much color we want to add. A colorStrength of 1 will result in a solid color. A colorStrength of 0.5 was used in the example image at the top of this post. It sort of has the same effect as the alpha channel but that's not what it's actually doing. More on this later.
We can't read raw pixel data from a texture so we'll need access to the texture's image. From here, we can grab the pixel data for the mainImage, original texture and the overlay.
Image albedoImage = albedoTexture.GetImage();
Image overlayImage = overlayTexture.GetImage();
byte[] mainImageBytes = mainImage.GetData();
byte[] albedoImageBytes = albedoImage.GetData();
byte[] overlayImageBytes = overlayImage.GetData();
We'll get the width and height of all images involved and check them to make sure they are the right size.
int mainImageWidth = mainImage.GetWidth();
int mainImageHeight = mainImage.GetHeight();
int albedoImageWidth = albedoImage.GetWidth();
int albedoImageHeight = albedoImage.GetHeight();
int overlayImageWidth = overlayImage.GetWidth();
int overlayImageHeight = overlayImage.GetHeight();
if(mainImageHeight != albedoImageHeight || albedoImageHeight != overlayImageHeight)
{
GD.Print("Heights of all images must match");
return;
}
if(albedoImageWidth != overlayImageWidth)
{
GD.Print("Albedo and overlay widths must be the same");
return;
}
Raw image data isn't arranged in a way you'd like it to be. Instead of having columns and rows so we can access the pixel information at pixelData[x][y], it is one continuous array. And to make matters worse, it's not even an array of colors but an array of the values for each color. For example, pixelData[0] is the red channel for the upper left most pixel in the image. pixelData[1] is green, pixelData[2] is blue, and pixelData[3] is alpha. pixelData[4] is the red channel for the pixel to the right of the first pixel. It goes this way all the way to the end without any indication of when a new row begins.
Putting an image in the center of the mainImage is therefore not as straightforward as we'd hope. We've got to manually keep track of where we are in mainImageBytes so we know what to do when we reach the width of albedoImage. We can't just go to the next element in mainImageBytes.
int destPosition = albedoImageWidth * 4 * offset;
int widthCounter = 0;
destPosition will keep track of where we are in mainImageBytes. We set it to an initial position of albedoImageWidth * 4 * offset. If offset is set to zero, we start at element 0 which is the upper left corner of the image. We multiply by 4 because there are 4 array elements for every pixel. widthCounter will be incremented each time through the loop below and if it gets larger than albedoImageWidth, we'll know we are at the right hand side of albedoImage and need to start a new row.
The loop I mentioned loops through every color in albedoImageBytes. Since albedoImageBytes and overlayImageBytes are the same size, our loop counter variable can be used on either. Note that our loop counter is incremented by 4 each time through the loop. This way, we are looping through colors, not individual elements.
for(int a = 0; a < albedoImageBytes.Length; a += 4)
Let's first check if the pixel in overlayImageBytes at this location has anything in the red channel. If it does, this means we want to apply our color overlay. If it doesn't, we just apply the color of albedoImageBytes to mainImage.
if(overlayImageBytes[a] > 0)
{
Color imgColor = new Color(albedoImageBytes[a],albedoImageBytes[a + 1],albedoImageBytes[a + 2],255);
Color newColor = imgColor.Lerp(color, colorStrength);
mainImageBytes[destPosition] = (byte)newColor.R;
mainImageBytes[destPosition + 1] = (byte)newColor.G;
mainImageBytes[destPosition + 2] = (byte)newColor.B;
mainImageBytes[destPosition + 3] = (byte)newColor.A;
}
First, we create imgColor based on the color at this location in albedoImageBytes. Then, we create newColor by "lerping?" between imgColor and the color supplied in our function parameter color with a strength set by the function parameter colorStrength. Now, we can apply this new color to mainImageBytes using destPosition to tell us which array elements to change.
If our overlayImage didn't have anything in the red channel, we apply the color of albedoImageBytes to mainImageBytes.
else
{
mainImageBytes[destPosition] = albedoImageBytes[a];
mainImageBytes[destPosition + 1] = albedoImageBytes[a+1];
mainImageBytes[destPosition + 2] = albedoImageBytes[a+2];
mainImageBytes[destPosition + 3] = albedoImageBytes[a+3];
}
Now that that's all taken care of, we increment widthCounter by 4 to move to the next color.
widthCounter +=4;
Now, we need to check if we are at the right side edge of albedoImageWidth and if we are, we need to move to the next row in mainImageBytes.
if(widthCounter >= (albedoImageWidth * 4))
{
widthCounter = 0;
destPosition += (albedoImageWidth * 4 * (offsetMax-1)) + 4;
}
else
destPosition += 4;
If we are at the right side edge of albedoImageWidth, we set widthCounter to zero to start the count over again and then increment destPosition by the width of albedoImage multiplied by 4 for each color element which is then multiplied by offestMax minus 1. If offsetMax is set to one, that means we only have one image to append to mainImage so there won't really be an offset. If offsetMax is set to 2, we have 2 images to append to mainImage so we move over the width of one image. All of this gets us to the color just before the one we are looking for, so we add 4 on the end to get to the next color.
If we aren't at the right side edge of albedoImage, we just increment destPosition by 4 to move to the next color.
Finally, with all that done, after the loop, we can apply mainImageBytes back to mainImage and that's it for our function.
mainImage.SetData(mainImageWidth, mainImageHeight,true,Image.Format.Rgba8,mainImageBytes);
I hope this helps someone out there. If I did something stupid or you know a better way to do this, please do everyone a favor and let us know by replying to this thread with your insights.
EDIT: In hindsight, I realized that if you want to have 2 different overlays for the same texture image, the first one would be overwritten by the second one. It would therefore be better to have a function that applies the overlays to the texture image and then a separate function that appends that newly modified image to the mainImage. I'll do that and then create a new post that explains the new code.