Converting Unreal4 textures to Unity

I ported some assets from UE4 to Unity and wrote a script to split up textures to match Unity’s material system. Additionally I wrote a script in Unity to auto-create materials for the output.

It does assume some very strict naming and file structure layout and I have not looked at any color conversions.

I don’t know if Unity and UE4 have the same roughness response for example, or whether I need to pow() the roughness channel by 0.5 or 2.0, nor do I know if I need to do gamma correction on some of the data.

Example input files:
“Textures/Bricks_BaseColor.png”
“Textures/Bricks_Normal.png”
“Textures/Bricks_OcclusionRoughnessMetallic.png”
“Textures/Grass_BaseColor.png”
“Textures/Grass_Normal.png”
“Textures/Grass_OcclusionRoughnessMetallic.png”

For every texture set name we must have exactly those 3 _BaseColor.png, _Normal.png, _OcclusionRoughnessMetallic.png textures. This data will be interpreted as a “Bricks” and a “Grass” material.

The output will then be as follows:
“Textures/Unity/Bricks/Bricks_MainTex.png”
“Textures/Unity/Bricks/Bricks_BumpMap.png”
“Textures/Unity/Bricks/Bricks_OcclusionMap.png”
“Textures/Unity/Bricks/Bricks_MetallicGlossMap.png”

A new folder is introduced and textures a separated into Unity’s material format. File names match Unity material parameters so they can be picked up by a script.

import shutil
import os
import ctypes
import traceback

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from contextlib import contextmanager


@contextmanager
def edit(path):
    img = QImage(path).convertToFormat(QImage.Format_ARGB32)
    bits = ctypes.c_void_p(img.bits().__int__())
    bits = ctypes.cast(bits, ctypes.POINTER(ctypes.c_int * (img.width() * img.height())))[0]
    yield bits, img.width(), img.height()
    img.save(path)


def gather():
    textureSets = {}
    for name in os.listdir('.'):
        if not name.lower().endswith('.png'):
            continue
        key, sub = name.rsplit('_', 1)
        data = textureSets.get(key, [])
        data.append(sub)
        textureSets[key] = data

    # validate sets
    for name, members in textureSets.iteritems():
        if set(members) != set(('BaseColor.png', 'Normal.png', 'OcclusionRoughnessMetallic.png')):
            raise RuntimeError('Unexpected texture set %s' % name)

    return textureSets


def convert(name):
    # copy maps
    shutil.copy(name + '_BaseColor.png', 'Unity/%s/%s _MainTex.png' % (name, name))

    dst = 'Unity/%s/%s _BumpMap.png' % (name, name)
    shutil.copy(name + '_Normal.png', dst)

    # flip normal map green channel
    with edit(dst) as data:
        pixels, width, height = data
        for px in xrange(width * height):
            # invert green
            g = pixels[px] & 0x0000ff00
            g = (255 - (g >> 8)) << 8 # overwrite green arb = pixels[px] & ~(0x0000ff00) pixels[px] = arb | g # split occlusion from roughness & metallic dst = 'Unity/%s/%s _OcclusionMap.png' % (name, name) shutil.copy(name + '_OcclusionRoughnessMetallic.png', dst) # occlusion can be in A8 format, make monochrome with edit(dst) as data: pixels, width, height = data for px in xrange(width * height): r = (pixels[px] & 0x00ff0000) pixels[px] = 0xff000000 | r | r >> 8 | r >> 16

    dst = 'Unity/%s/%s _MetallicGlossMap.png' % (name, name)
    shutil.copy(name + '_OcclusionRoughnessMetallic.png', dst)

    # unity metallic & smoothness live in R and A respectively
    with edit(dst) as data:
        pixels, width, height = data
        for px in xrange(width * height):
            g = pixels[px] & 0x0000ff00
            b = pixels[px] & 0x000000ff
            g = (255 - (g >> 8)) << 24
            pixels[px] = g | (b << 16)


def run():
    textureSets = gather()
    diag = QProgressDialog()
    diag.setMaximum(len(textureSets))
    diag.show()
    for i, name in enumerate(textureSets):
        diag.setLabelText('Converting: ' + name)
        diag.setValue(i)
        QApplication.processEvents()
        if diag.wasCanceled():
            break
        convert(name)


if __name__ == '__main__':
    app = QApplication([])
    try:
        run()
    except Exception as e:
        QMessageBox.critical(None, 'Error!', e.message + '\n\n' + traceback.format_exc(e))
    app.exec_()

This script must be attached to a game object, then we must enter the folder where we generated the unity format textures (must be in Assets/, I usually generate first and then copy over to Unity). Last we click “run” and it will create a material per folder with all textures assigned. A pop-up will ask us to convert normal maps to the right format and we’re done!

Last but not least, it is possible to compress _OcclusionMap assets to a texture of type “Single Channel”, with Alpha from Grayscale enabled. I have no reason to believe Unity does this automatically so it will save some memory.

using System.IO;
using System;
using UnityEngine;
using UnityEditor;

[Serializable]
struct StringPair
{
    public string input;
    public string output;
}

/*
Given a folder, processes all subfolders recursively. 
If a folder has no subfolders, consider it contains textures for a specific material.
Texture names are assumed to be "<folderName> <materialPropertyName>
There is a renamePairs attribute to rename from one property name to another, e.g.
when you already have a lot of "<folderName> normal" you can rename "normal" to "_BumpMap".

Materials are generated next to the textures, matching the folder name. 
Example file structure:
Textures/
    RustyIron/
        RustyIron _MainTex.png
        RustyIron _BumpMap.png
*/
[ExecuteInEditMode]
public class MaterialGenerator : MonoBehaviour
{
    [SerializeField] bool run = false; // Tick this to let an Update() call process all data.
    [SerializeField] string textureSetsRoot = "Assets/Materials/"; // Point to a folder that contains all material sets.
    [SerializeField] StringPair[] renamePairs; // Rename material properties found from input to output before trying to set it on materials.
    [SerializeField] string shaderName = "Standard";

    void ProcessMaterial(string textureSetDir)
    {
        string materialName = Path.GetFileName(textureSetDir);

        string dst = textureSetDir + "/" + materialName + ".mat";

        Material mtl = AssetDatabase.LoadAssetAtPath<Material>(dst);

        bool create = false;

        if (mtl == null)
        {
            create = true;
            mtl = new Material(Shader.Find(shaderName));
        }
        else
        {
            mtl.shader = Shader.Find(shaderName);
        }

        foreach (string texturePath in Directory.GetFiles(textureSetDir))
        {
            Texture texture = AssetDatabase.LoadAssetAtPath<Texture2D>(texturePath);

            if (texture == null)
            {
                continue;
            }
            
            string attributeName = Path.GetFileName(texturePath).Substring(materialName.Length + 1);
            attributeName = attributeName.Substring(0, attributeName.LastIndexOf("."));
            foreach (StringPair rename in renamePairs)
            {
                if (rename.input.ToLower() == attributeName.ToLower())
                {
                    attributeName = rename.output;
                }
            }
            mtl.SetTexture(attributeName, texture);
        }

        if (create)
        {
            Debug.Log(String.Format("Saving material at {0}", dst));
            AssetDatabase.CreateAsset(mtl, dst);
        }
        else
        {
            Debug.Log(String.Format("Updating material at {0}", dst));
        }
    }

    void ParseRecursively(string parentDirectory)
    {
        string[] subDirectories = Directory.GetDirectories(parentDirectory);
        if (subDirectories.Length == 0)
        {
            ProcessMaterial(parentDirectory);
        }
        else
        {
            foreach (string subDir in subDirectories)
            {
                ParseRecursively(subDir);
            }
        }
    }

    void Update()
    {
        // Run once in Update() and then disable again so we can process errors, or we are done.
        if (!run)
            return;
        run = false;

        // Ensure our source data exists.
        string absPath = Path.GetFullPath(textureSetsRoot);
        if(!Directory.Exists(absPath))
        {
            Debug.Log(String.Format("Path not found {0}", absPath));
            return;
        }

        ParseRecursively(textureSetsRoot);

        AssetDatabase.SaveAssets();
    }
}

Important: I found that I had to view the generated materials in the inspector for them to pick up the normal maps!

Leave a Reply

Your email address will not be published. Required fields are marked *