How do you paint RGB or RGBA splatmaps outside of Unity?

For use with something like this shader, for example. How can you ensure your channels all add up to white? Is there a Photoshop plugin or something?

As far as I know, there is no tool available; we had to roll out our own (a simple C#, very slow but fast enough import script). The tool simply read in four black and white images (easily exported from PhotoShop), and edited the splatmap object directly.

Making the channels add up is tricky, though, because you have to decide what to do when they don’t add up. Here is the approach we followed, which worked out OK (but not perfect):

I don’t have the code with me; will check back Monday if it may help you.

Edit: Turns out the actual implementation is a bit more complicated than I remember. Here is the updated explanation:

The importer support two modes, and tries to guess the right mode:

If the values roughly add up to one, it is in mix mode and all channels are normalised.

Otherwise, we are in foreground / background mode. In this mode, one channel is left unpainted (the background), and we paint in the other channels (the foreground). When we process the map, we take one of these actions:

  • if the sum of channels is less than 1, we add to the background layer enough so that the new sum equals 1.
  • if the sum is more than 1,
    • and the other channels add up to less than 1, we add to the background layer enough so that the new sum equals 1. (Otherwise, we have weird brightening artefacts)
    • otherwise, set the background to 0, and normalise the other channels.

Mix mode is useful where mixtures are simple, and can be done with 25%, 20%, 50% etc. greys.

Foreground / background mode works well when, for the most part, each part (pixel) of the splatmap is occupied with only one texture, with blending only occurring at borders between regions.

The artist must commit to one of these modes when painting the map.

There are some problems with this method—in some cases it is hard to figure out how to get a specific effect.

(We also tried just normalising the channels, but this means the artist must delete (paint black) all other channels when painting in one channel, and needs special care to ensure there are no totally black areas, and overall, it causes brain damage.)

Here is the core function:

private static float[, ,] ReadSplatMaps(int width, int height, UnityEngine.Object[] textures)
{

    //1. Check some stuff.

    int layerCount = textures.Length;

    if (layerCount  4)
    {
        Debug.LogWarning("More than 4 textures have been selected.");
    }        

    float[, ,] splatMaps = new float[width, height, layerCount];
    float[, ,] newSplatMaps = new float[width, height, layerCount];

    
    //2. Associate names and indices. Allows finding index based on name without parsing.        
    Dictionary<string, int> layerIndices = new Dictionary<string, int>();

    int layer = 0;

    for (layer = 0; layer < layerCount; layer++)
    {
        layerIndices["splat" + layer] = layer;
    }

    //3. Read in back and white images from the red channel.

    layer = 0;        

    foreach (UnityEngine.Object obj in textures)
    {
        Texture2D texture = obj as Texture2D;

        if (texture != null)
        {
            if (texture.width != width)
            {
                EditorUtility.DisplayDialog("Error!", "The texture size does not match the terrain data splatmap size", "Ok");
                throw new Exception("The texture size does not match the terrain data splatmap size");
            }

            if (texture.height != height)
            {
                EditorUtility.DisplayDialog("Error!", "Not all textures are the same size", "Ok");
                throw new Exception("Not all textures are the same size"); ;
            }

            for (int x = 0; x < width; x++)
            {
                for (int y = 0; y < height; y++)
                {
                    Color color = texture.GetPixel(x, y);

                    splatMaps[x, y, layer] = color.r; // All channels should be the same
                }
            }

            layer++;
        }
    }        

    //4. Fix splat maps to add to 1.

    for (int x = 0; x < width; x++)
    {
        for (int y = 0; y < height; y++)
        {
            // Add all the pixels.
            float sum = 0;
            
            for (layer = 0; layer < layerCount; layer++)
            {
                sum += splatMaps[x, y, layer];
            }

            //if we can pretend (sum == 1)
            //we are in mix mode
            if((sum > 0.9f) && (sum < 1.1f))
            {
                for (layer = 0; layer < layerCount; layer++)
                {
                    int newLayerIndex = layerIndices[textures[layer].name];

                    newSplatMaps[y, x, newLayerIndex]                    
                        = splatMaps[x, y, layer] / sum;
                }
            }//else we are in foreground/background mode
            else if (sum < 1.0f) //actually 0.9f, but will be easier to narrow range above
            {
                for (layer = 0; layer < layerCount; layer++)
                {
                    int newLayerIndex = layerIndices[textures[layer].name];

                    newSplatMaps[y, x, newLayerIndex]
                        = splatMaps[x, y, layer];
                }

                newSplatMaps[y, x, 0] += 1 - sum;
            }
            else // sum > 1 (or 1.1, actually)
            {
                for (layer = 0; layer < layerCount; layer++)
                {
                    int newLayerIndex = layerIndices[textures[layer].name];

                    newSplatMaps[y, x, newLayerIndex]
                        = splatMaps[x, y, layer];
                }

                if(newSplatMaps[y, x, 0] > sum - 1) //i.e. Other channels add up to less than 1
                {
                    newSplatMaps[y, x, 0] -= (sum - 1); // So leave enough in this channel to fill up to 1.
                }
                else
                {
                    // Erase the background layer
                    sum = sum - newSplatMaps[y, x, 0];
                    newSplatMaps[y, x, 0] = 0;

                    // Normalise other layers.
                    for (layer = 0; layer < layerCount; layer++)
                    {
                        int newLayerIndex = layerIndices[textures[layer].name];

                        if (newLayerIndex != 0)
                        {
                            newSplatMaps[y, x, newLayerIndex]
                                = splatMaps[x, y, layer] / sum;
                        }
                    }
                }                    
            }                
        }
    }

    return newSplatMaps;
}