Texture Edge Bleed From UV Mask

TextureEdgeBleedFromUVMask


Unity3D –  One of the most common art bugs I run into is the texture seam that appears in game but that are not visible in the source asset.   This is most common when the in game texture is at a lower resolution than the source or that they primary resolution you see an object is at a lower MIP level.
ResolutionSeam
This comes down to how much padding the pixels on the edge have and the background color of the unmapped pixels. With the way a lot of artist work, using 3d painting tools to remove mismatched texture seams and baking from high resolution source, it is common to not have enough padding on the edges to generate good MIPs or even a good base texture if the source is higher resolution.

edgePad
So to fix that we need to bleed out the edge color into the unmapped pixels. This can be done in source but can be a pain for something that can just be automated. I noticed that Unity already does edge bleeding as it processes png textures with alpha for use in UI and found this post about the process
http://docs.unity3d.com/Manual/HOWTO-alphamaps.html to use as a base for my script.

In my this example I will be using png files and building on classes I used in Image Extension Methods and Using WWW to Avoid Compression. The reason I use pngs is to avoid needed to change import setting such as read/write enable, gama, resolution, compression, etc. to work with the raw image. To test the functions there is a test window script “TextureEdgeBleedingWindow.cs” under Texture=>TextureEdgeBleeding.

TextureEdgeBleedingWindow.cs

/// <summary>
/// TextureEdgeBleedingWindow.cs
/// www.noobpaint.com
/// Demo window to test texture edge bleeding.
/// uses EditorCoroutines by benblo
/// </summary>

using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;

public class TextureEdgeBleedingWindow : EditorWindow
{
  public Texture2D sourceTexture;
  public Mesh sourceMesh;
  private Texture2D uvIslands;
  public Texture2D destTexture;
  public Color[] destColors;

  private TextureEdgeBleeding textureEdgeBleed = new TextureEdgeBleeding();

  private Vector2 _scrollPos = Vector2.zero;

  int imageRez = 128;

  enum eWindowState
  {
    normal,
    generateUV,
    bleedTexture,
    reset,
  }
  private eWindowState currentState = eWindowState.normal;

  [MenuItem("Texture/TextureEdgeBleeding")]
  private static void CreateWizard()
  {
    EditorWindow.GetWindow<TextureEdgeBleedingWindow>("TextureEdgeBleeding");
  }

  void OnGUI()
  {
    Color previousColor = GUI.backgroundColor;
    GUI.backgroundColor = Color.grey;
    EditorGUILayout.BeginVertical();
    EditorGUILayout.LabelField("Current State: " + currentState.ToString());
    GUI.backgroundColor = Color.green;

    sourceTexture = EditorGUILayout.ObjectField("Texture", sourceTexture, 
                    typeof(Texture2D), false) as Texture2D;
    imageRez =EditorGUILayout.IntField("Load at Image Resolution", imageRez);
    sourceMesh = EditorGUILayout.ObjectField("Mesh", sourceMesh, 
                 typeof(Mesh), false) as Mesh;

    EditorGUILayout.BeginHorizontal();
    TextureEdgeBleeding.debugIsland = EditorGUILayout.Toggle(
    "Show Pending Edge", TextureEdgeBleeding.debugIsland);
    TextureEdgeBleeding.debugFill = EditorGUILayout.Toggle(
    "Show Fill Steps", TextureEdgeBleeding.debugFill);
    EditorGUILayout.EndHorizontal();

    _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);

    if (sourceMesh != null && sourceTexture != null)
    {
      if (GUILayout.Button("Generate UV Islands") && 
          eWindowState.normal == currentState)
      {
        // do generate UV islands in update loop
        currentState = eWindowState.generateUV;
      }
    } else {
      if (uvIslands != null)
      {
        DestroyImmediate(uvIslands);
      }
    }

    if (uvIslands != null)
    {
      GUILayout.Label(uvIslands);
    }

    if (destTexture != null)
    {
      if (GUILayout.Button("Reset Bleed Texture"))
      {
        // do reset in update loop
        currentState = eWindowState.reset;
      }
    }
    else
    {
      if (uvIslands != null && sourceTexture != null)
      {
        if (GUILayout.Button("Generate Bleed Image") && 
            eWindowState.normal == currentState)
        {
          // do webload and bleed on texture in update
          currentState = eWindowState.bleedTexture;
        }
      }
    }

    if (destTexture != null)
    {
      GUILayout.Label(destTexture);
    }

    EditorGUILayout.EndScrollView();
    EditorGUILayout.EndVertical();
    GUI.backgroundColor = previousColor;
  }

  void Update()
  {
    switch(currentState)
    {
    case eWindowState.normal:
      {
        if (null != textureEdgeBleed.loadTexture)
        {
          destTexture = textureEdgeBleed.loadTexture.Copy();
          textureEdgeBleed.reset();
        }
        break;
      }
    case eWindowState.bleedTexture:
      {
        if (null == sourceTexture)
        {
          Debug.LogError ("Source Texture is NULL!");
          currentState = eWindowState.normal;
          break;
        }
        if (null == sourceMesh)
        {
          Debug.LogError ("Source Mesh is NULL!");
          currentState = eWindowState.normal;
          break;
        }
        if (!textureEdgeBleed.locked )
        {
          textureEdgeBleed.EditorBleedTexture(sourceMesh, sourceTexture, 
          imageRez, imageRez);
        }
        if (null != textureEdgeBleed.loadTexture)
        {
          currentState = eWindowState.normal;
        }
        break;
      }
    case eWindowState.generateUV:
      {
        uvIslands = new Texture2D(imageRez, imageRez);
        uvIslands = sourceMesh.GetUVMask(0, imageRez);
        currentState = eWindowState.normal;
        break;
      }

    case eWindowState.reset:
      {
        destTexture = null;
        textureEdgeBleed.reset();
        currentState = eWindowState.normal;
        break;
      }
    }
  }
}

UVislands
We are going to need several components to properly process the image, the unwrapped mesh UVs, the texture and possible the material if we need to fix all texture channels on a mesh. Most of these are simple to gather from an object in Unity except the unwrapped UVs in a usable form.  To do this I created a mesh extension method that will generate a new mesh with its vertices replaced by a given UV channel then render that as a texture for use as our edge mask. A colleague of mine Peter Hanshaw came up with what I find to be the best way of generating this mask by placing the UV mesh in the scene and using an orthographic camera to generate a render texture. I had tried to write a 2d image rasterizer to achieve this but it turned out to be too slow as a unity script running in editor.

MeshExtensionMethods.cs

/// <summary>
/// MeshExtensionMethods.cs
/// www.noobpaint.com
/// Mesh extension methods
/// </summary>

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public static class MeshExtensionMethods
{
  /// <summary>
  /// Gets the UV shell for rendering a Texture.
  /// </summary>
  /// <returns>The UV shell.</returns>
  /// <param name="mesh">Mesh.</param>
  /// <param name="index">Index.</param>
  public static Mesh GetUVShell(this Mesh mesh, int index)
  {
    Mesh shellMesh = new Mesh();
    shellMesh = Object.Instantiate(mesh) as Mesh;
    List<Vector2> getUVs = new List<Vector2>();
    shellMesh.GetUVs(index, getUVs);
    Vector3[] meshUVs = new Vector3[shellMesh.vertexCount];
    Color[] meshColors = new Color[shellMesh.vertexCount];

    for (int i = 0; i < mesh.vertexCount; i++)
    {
      meshUVs[i] = new Vector3(getUVs[i].x, 0f, getUVs[i].y);
      meshColors[i] = Color.white;
    }

    shellMesh.vertices = meshUVs;
    shellMesh.colors = meshColors;
    shellMesh.UploadMeshData(false);

    return shellMesh;
  }

  /// <summary>
  /// Gets the UV mask as a texture.
  /// </summary>
  /// <returns>The UV mask.</returns>
  /// <param name="mesh">Mesh.</param>
  /// <param name="channel">Channel.</param>
  /// <param name="mapSize">Map size.</param>
  public static Texture2D GetUVMask(this Mesh mesh, int channel, 
                                    int mapSize = 512)
  {
    Texture2D uvMask = new Texture2D(mapSize, mapSize);

    // works at runtime just wanting to avoid using this method at runtime
    #if UNITY_EDITOR
    GameObject renderCam = new GameObject("UVCam");
    renderCam.transform.rotation = Quaternion.LookRotation(Vector3.down);
    renderCam.transform.position = new Vector3(0.5f, 1f, 0.5f);
    renderCam.hideFlags = HideFlags.HideAndDontSave;

    Camera camera = renderCam.AddComponent<Camera>();
    camera.orthographic = true;
    camera.aspect = 1.0f;
    camera.orthographicSize = 0.5f;

    camera.clearFlags = CameraClearFlags.SolidColor;
    camera.backgroundColor = Color.clear;
    //using last layer mask 
    //hopefully not used in current scene
    camera.cullingMask = 1<<31;

    RenderTexture rt = new RenderTexture(mapSize, mapSize, 0);
    rt.Create();
    camera.targetTexture = rt;
    RenderTexture.active = rt;

    Mesh uvIslands = mesh.GetUVShell(channel);
    GameObject meshRenderObj = new GameObject(string.Format(
        "UVMeshUV{0}", channel));
    meshRenderObj.hideFlags = HideFlags.HideAndDontSave;
    MeshFilter meshFilter = meshRenderObj.AddComponent<MeshFilter>();
    meshFilter.sharedMesh = uvIslands;

    meshRenderObj.layer = 31;

    MeshRenderer meshRenderer = meshRenderObj.AddComponent<MeshRenderer>();
    meshRenderer.sharedMaterial = new Material(Shader.Find(
        "GUI/Text Shader"));
    meshRenderer.sharedMaterial.SetColor("_Color", Color.white);

    camera.Render();

    uvMask.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
    uvMask.Apply();

    // Clean up render objects
    camera.targetTexture = null;
    RenderTexture.active = null;

    Object.DestroyImmediate(rt);
    Object.DestroyImmediate(renderCam);
    Object.DestroyImmediate(meshRenderObj);
    #endif

    uvMask.name = mesh.name + "_UVMask";
    return uvMask;
  }
}

The main portion of the texture edge bleed is the AlphaBleedingTexture() function. The function first finds what pixels are are covered by a UVed portion of the mesh and what is not. If it is not UVed I then do a multi tap sample of the surrounding pixels to see if it is adjacent to a UVed pixel. If not the pixel is considered “loose” otherwise we know it is an edge pixel and it is “pending” to be filled in the main loop. We can see this in action with the “Show Pending Edge” toggle on the test UI.

PendingEdge
Once, we know our “pending” edge pixels and our floating “loose” pixels we can loop over the edge pixels with the same multi tap sample simultaneously using it to accumulate the already filled pixels and marking the currently “loose” adjacent pixels as the new “pending” edge.

FillSteps
This algorithm is able to efficiently iterate over an inter image very rapidly. Due to the way the algorithm keeps track of the “pending” edge and the “loose” pixels, it only needs to iterate over a given pixel a minimum of times. You can see the steps the algorithm took with the “Show Fill Steps” in the demo tool window.

TextureEdgeBleeding.cs

/// <summary>
/// TextureEdgeBleeding.cs
/// www.noobpaint.com
/// methods for bleeding (extruding)
/// the edge color of a texture to fill 
/// empty pixels.
/// adapted from docs.unity3d.com/Manual/HOWTO-alphamaps.html
/// uses EditorCoroutines by benblo
/// </summary>

using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;

public class TextureEdgeBleeding : TextureWebLoad
{
  public static bool debugIsland = false;
  public static bool debugFill = false;
  private static Color[] debugColors = new Color[]{
        Color.white, Color.red, Color.blue, Color.green};

  /// <summary>
  /// Editor safe way to call WebLoadTextureAndBleed()
  /// </summary>
  /// <param name="renderer">Renderer.</param>
  /// <param name="sourceTexture">Source texture.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  public void EditorBleedTexture(Renderer renderer, Texture2D sourceTexture, 
        int width, int height)
  {
    if (!_locked)
    {
      _locked = true;
      edCoroutine = Swing.Editor.EditorCoroutine.start(
        WebLoadTextureAndBleed(renderer, sourceTexture, width, height, 
            callback => _loadTexture = callback)
      );
    }
  }

  /// <summary>
  /// Editor safe way to call WebLoadTextureAndBleed()
  /// </summary>
  /// <param name="mesh">Mesh.</param>
  /// <param name="sourceTexture">Source texture.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  public void EditorBleedTexture(Mesh mesh, Texture2D sourceTexture, 
        int width, int height)
  {
    if (!_locked)
    {
      _locked = true;
      edCoroutine = Swing.Editor.EditorCoroutine.start(
        WebLoadTextureAndBleed(mesh, sourceTexture, width, height, 
            callback => _loadTexture = callback)
      );
    }
  }

  /// <summary>
  /// Editor safe way to call WebLoadTextureAndBleed()
  /// </summary>
  /// <param name="uvIsland">Uv island.</param>
  /// <param name="sourceTexture">Source texture.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  public void EditorBleedTexture(Texture2D uvIsland, 
        Texture2D sourceTexture, int width, int height)
  {
    if (!_locked)
    {
      _locked = true;
      edCoroutine = Swing.Editor.EditorCoroutine.start(
        WebLoadTextureAndBleed(uvIsland, sourceTexture, width, height, 
            callback => _loadTexture = callback)
      );
    }
  }

  /// <summary>
  /// Sets you texture for edge bleed and runs AlphaBleedingTexture() 
  /// </summary>
  /// <returns>The texture.</returns>
  /// <param name="texture">Texture.</param>
  /// <param name="uvTexture">Uv texture.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  public static Texture2D BleedTexture (Texture2D texture, 
        Texture2D uvTexture, int width, int height)
  {
    Color[] bleedMask = new Color[]{};
    if (uvTexture.width != width || uvTexture.height != height)
    {
      bleedMask = uvTexture.GetPixelsBilinear(width, height);
    }
    else
    {
      bleedMask = uvTexture.GetPixels();
    }

    Color[] sourceColor = new Color[]{};
    if (texture.width != width || texture.height != height)
    {
      sourceColor = texture.GetPixelsBilinear(width, height);
    }
    else
    {
      sourceColor = texture.GetPixels();
    }

    sourceColor = AlphaBleedingTexture(sourceColor, bleedMask, 
        width, height);
    Texture2D bleedTexture = new Texture2D(width, height);
    bleedTexture.name = texture.name + "_Bleed";
    bleedTexture.SetPixels(sourceColor);
    bleedTexture.Apply();

    return bleedTexture;
  }

  /// <summary>
  /// Uses a mask texture(island) to fill non uv mapped (ocean) 
  /// sections of the texture, marching from the islands 
  /// outside edge (shore) till the ocean is filled in.
  /// </summary>
  /// <returns>The bleeding texture.</returns>
  /// <param name="sourceColors">Source colors.</param>
  /// <param name="bleedMask">Bleed mask.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  public static Color[] AlphaBleedingTexture(Color[] sourceColors, 
        Color[] bleedMask, int width, int height)
  {
    if (sourceColors.Length != bleedMask.Length || 
        (width*height) != sourceColors.Length)
    {
      Debug.LogError("Bad Data for Texture Bleed!");
      return null;
    }

    Texture2D outTexture = new Texture2D(width, height);
    Color[] outColors = outTexture.GetPixels();
    int N = sourceColors.Length;

    int[] opaque = new int[N];
    bool[] loose = new bool[N];
    List<int> pending = new List<int>();
    List<int> pendingNext = new List<int>();

    // offesets for multi tap sampling of surrounding pixels
    int[,] offsets = new int[,]{
      {-1, -1},
      { 0, -1},
      { 1, -1},
      {-1,  0},
      { 1,  0},
      {-1,  1},
      { 0,  1},
      { 1,  1}
    };

    //pre process bleedMask
    //find all pixels not adjacent to island
    for (int i = 0; i < N; i++)
    {
      if (bleedMask[i].a < 0.5f)
      {
        bool isLoose = true; //not adjacent to colored pixels

        int y = i % width;
        int x = (i - y) / width;

        //8 tap adjacent pixels
        for (int o = 0; o < 8; o++)
        {
          int xOffset = offsets [o,0] + x;
          int yOffset = offsets [o,1] + y;

          //check if pixel within bounds
          if(xOffset >= 0 && xOffset < width
            && yOffset >= 0 && yOffset <height)
          {
            int index = (xOffset * width) + yOffset;
            if (bleedMask[index].a > 0.5f)
            {
              isLoose = false;
              break;
            }
          }
        }

        if (!isLoose)
        {
          pending.Add(i); //has color pixels adjacent
          loose[i] = false;
          opaque[i] = 0;
          if (debugIsland)
            outColors[i] = debugColors[1];
        }
        else
        {
          loose[i] = true;
          opaque[i] = 0;
          if (debugIsland)
            outColors[i] = debugColors[2];
        }
      }
      else
      {
        loose[i] = false;
        opaque[i] = 1;
        if (debugIsland)
          outColors[i] = debugColors[0];
        else
          outColors[i] = sourceColors[i];
      }
    }

    int debugCount = 0;

    if (!debugIsland)
    {
      while (pending.Count > 0)
      {
        pendingNext.Clear();

        for (int p = 0; p < pending.Count; p++)
        {
          int i = pending[p];
          int y = i % width;
          int x = (i - y) / width;

          Vector4 fillColor = Vector4.zero;

          int count = 0;

          for (int o = 0; o < 8; o++)
          {
            int xOffset = offsets [o,0] + x;
            int yOffset = offsets [o,1] + y;

            if(xOffset >= 0 && xOffset < width
              && yOffset >= 0 && yOffset < height)
            {
              int index = (xOffset * width) + yOffset;
              if(opaque[index] == 1)
              {
                fillColor += (Vector4)outColors[index];
                count++;
              }
            }
          }

          if (count > 0)
          {
            if (debugFill)
              outColors[i] = (Vector4)debugColors[debugCount];
            else
              outColors[i] = fillColor / count;
            opaque[i] = 1;


            for (int o = 0; o < 8; o++)
            {
              int xOffset = offsets [o,0] + x;
              int yOffset = offsets [o,1] + y;

              if(xOffset >= 0 && xOffset < width
                && yOffset >= 0 && yOffset < height)
              {
                int index = (xOffset * width) + yOffset;

                if (loose[index])
                {
                  pendingNext.Add(index);
                  loose[index] = false;
                }
              }
            }
          }
          else
          {
            pendingNext.Add(i);
          }
        }

        if (pendingNext.Count > 0)
        {
          for (int p = 0; p < pending.Count; p++)
          {
            opaque[pending[p]] = 1;
          }
        }

        pending.Clear();
        pending.AddRange(pendingNext.ToArray());
        if (debugFill)
        {
          debugCount++;
          if (debugCount >= debugColors.Length)
            debugCount = 0;
        }

      }
    }
    return outColors;
  }

  /// <summary>
  /// Uses WebLoadTexture() to get a modifiable texture
  /// then runs steps to bleed the texture edges
  /// </summary>
  /// <returns>The load texture and bleed.</returns>
  /// <param name="renderer">Renderer.</param>
  /// <param name="texture">Texture.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  /// <param name="result">Result.</param>
  public static IEnumerator WebLoadTextureAndBleed (Renderer renderer, 
    Texture2D texture, int width, int height, 
    System.Action<Texture2D> result)
  {
    Mesh mesh = null;
    if (renderer is SkinnedMeshRenderer)
    {
      SkinnedMeshRenderer meshRenderer = renderer as SkinnedMeshRenderer;
      mesh = meshRenderer.sharedMesh;
    }
    else if (renderer is MeshRenderer)
    {
      MeshFilter meshFilter = renderer.GetComponent<MeshFilter>();
      if (meshFilter != null || meshFilter)
      {
        mesh = meshFilter.sharedMesh;
      }
    }
    Texture2D outTexture = null;

    Swing.Editor.EditorCoroutine.start(
      WebLoadTextureAndBleed(mesh, texture, width, height, 
        callback => outTexture = callback ));
    while(outTexture == null)
    {
      yield return null;
    }
    outTexture.name = texture.name;

    result(outTexture);
  }

  /// <summary>
  /// Uses WebLoadTexture() to get a modifiable texture
  /// then runs steps to bleed the texture edges
  /// </summary>
  /// <returns>The load texture and bleed.</returns>
  /// <param name="mesh">Mesh.</param>
  /// <param name="texture">Texture.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  /// <param name="result">Result.</param>
  public static IEnumerator WebLoadTextureAndBleed (Mesh mesh, 
        Texture2D texture, int width, int height, 
        System.Action<Texture2D> result)
  {
    Texture2D loadTexture = null;
    Swing.Editor.EditorCoroutine loader = null;
    loader = Swing.Editor.EditorCoroutine.start(
      TextureWebLoad.WebLoadTexture(texture, width, height, 
        callback => loadTexture = callback));

    while(loadTexture == null)
    {
      yield return null;
    }
    Debug.Log("Loaded for bleed");
    if (loader != null)
    {
      loader.stop();
    }

    if (mesh != null)
    {
      Texture2D uvIsland = mesh.GetUVMask(0, width);
      Debug.Log("got UV island");

      loadTexture = TextureEdgeBleeding.BleedTexture(loadTexture, 
        uvIsland, width, height);
    }
    else
    {
      Debug.LogError("Mesh is Null");
    }

    loadTexture.name = texture.name;

    result(loadTexture);
  }

  /// <summary>
  /// Uses WebLoadTexture() to get a modifiable texture
  /// then runs steps to bleed the texture edges
  /// </summary>
  /// <returns>The load texture and bleed.</returns>
  /// <param name="uvIsland">Uv island.</param>
  /// <param name="texture">Texture.</param>
  /// <param name="width">Width.</param>
  /// <param name="height">Height.</param>
  /// <param name="result">Result.</param>
  public static IEnumerator WebLoadTextureAndBleed (Texture2D 
        uvIsland, Texture2D texture, int width, int height, 
        System.Action<Texture2D> result)
  {
    Texture2D loadTexture = null;
    Swing.Editor.EditorCoroutine loader = null;
    loader = Swing.Editor.EditorCoroutine.start(
      TextureWebLoad.WebLoadTexture(texture, width, height, 
        callback => loadTexture = callback));

    while(loadTexture == null)
    {
      yield return null;
    }
    Debug.Log("Loaded for bleed");
    if (loader != null)
    {
      loader.stop();
    }

    if (uvIsland != null)
    {
      loadTexture = TextureEdgeBleeding.BleedTexture(loadTexture, 
        uvIsland, width, height);
    }
    else
    {
      Debug.LogError("Mesh is Null");
    }

    loadTexture.name = texture.name;

    result(loadTexture);
  }
}

NoBleedImageBleedImage