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!

Texture Arrays in Unity

Recently I messed around with Texture Arrays as alternative for Texture Atlases.

I’ve heard of this feature before but never really touched it, and still find a lot of people doing texture atlassing. So here’s my two cents at making the internet have more mentions of texture arrays!

Why combine any textures?
Because when we have one model with one material it is cheaper to draw than many models with many different materials (we must split up models per-material at least).

So for every material we have a model and for every model we have to do a draw call. Then again for each shadow map / cascade, and so on. This amplifies the draw calls per mesh greatly, large numbers of draw calls make us slow, the CPU is communicating with the GPU a lot, we don’t want this.

We ideally just want 1 mesh to draw, although at some point we have to cut it up into chunks for level of detail, frustum and occlusion culling to reduce GPU load, but then we are doing draw calls to improve performance, not lose it!

The problem with Atlases
When you create a texture atlas, you may put several textures into one, so that one material and one mesh can be created.

Without proper tooling an artist may manually have to combine meshes, combine textures and move texture coordinates to be in the right part of the atlas. It also limits texture coordinates to be in 0 to 1 range. Big texture coordinates to introduce tiling would now look at different textures in the atlas.

Then there is a problem with mip mapping. If we naively mip map non-square textures, we can get a lot of bleeding between the individual textures. If all our textures are the same resolution, and a tool mip maps before atlassing, we can mitigate this issue somewhat.

Then we just have the problem of (tri)linear interpolation bleeding across borders. If texture coordinates touch the edge of a texture in the atlas, the pixel starts being interpolated with the adjacent pixel.

We can again mitigate this by moving our texture coordinates 1 pixel away from the texture borders, but as mip levels increase the resolution decreases, so to do this without issues we must consider the highest mip level and leave a border as big as the texture. That space waste is too much.

So, mip-mapping and texture atlassing are not exactly good friends.

Introducing texture Arrays
Texture arrays are just a list of textures. This allows us to combine e.g. the color maps of a bunch of materials into one array, so that one material can just use the array instead of having multiple materials. The limitation being that all textures must be of the same size and internal format.

It brings back the ability to use mip mapping, and has all the other benefits of atlassing (less materials leading to less draw calls).

The good news is that all an artist needs to do is assign a per-vertex attribute to identify what texture to use (if you have a vertex color multiplier, consider sacrificing it’s alpha channel; add a w component to your normal, whatever works).

The bad news is that we need to do some tooling to make this work at all (there is no real manual way for an artist to create a texture array and existing shaders will not support them).

There is a risk of pushing too many textures into one array, it’ll become hard to debug memory if there are many textures of unused assets mixed with data that we need to load. Matching used vertex attributes with texture array size could help analyze unused entries.

I did some of this in Unity while experimenting how viable a solution this was. The code is not really polished and I didn’t use editor scripts (because I could avoid doing UI with an ExecuteInEditMode component) but I’ll share it anyways!

This script can take a set of materials and write a given set of attributes to a folder as texture arrays (and material using texture arrays).

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

/* Match material input names with their respective texture (array) settings. */
[Serializable]
struct PropertyCombineSettings
{
    public string name; // material texture2D property to put in array
    public int width; // assume all materials have textures of this resolution
    public int height;
    public Color fallback; // if the property isn't used use this color ((0,0.5,0,0.5) for normals)
    public TextureFormat format; // assume all materials have textures of this format
    public bool linear; // are inputs linear? (true for normal maps)
}

[ExecuteInEditMode]
public class MaterialArray : MonoBehaviour
{
    [SerializeField] bool run = false; // Tick this to let an Update() call process all data.
    [SerializeField] Material[] inputs; // List of materials to push into texture array.
    [SerializeField] string outputPath; // Save created texture arrays in this folder.
    [SerializeField] PropertyCombineSettings[] properties; // Set of material inputs to process (and how).

    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 we have a folder to write to
        string absPath = Path.GetFullPath(outputPath);
        if (!Directory.Exists(absPath))
        {
            Debug.Log(String.Format("Path not found {0}", absPath));
            return;
        }

        // Combine one property at a time
        Texture2DArray[] results = new Texture2DArray[properties.Length];
        for(int i = 0; i < properties.Length; ++i)
        {
            // Delete existing texture arrays from disk as we can not alter them
            PropertyCombineSettings property = properties[i];
            string dst = outputPath + "/" + property.name + ".asset";
            if (File.Exists(dst))
            {
                AssetDatabase.DeleteAsset(dst);
            }

            // Create new texture array (of right resolution and format) to write to
            Texture2DArray output = new Texture2DArray(property.width, property.height, inputs.Length, property.format, true, property.linear);
            results[i] = output;

            Texture2D fallback = null;
            int layerIndex = 0;
            
            // For each material process the property for this array
            foreach (Material input in inputs)
            {
                Texture2D layer = input.GetTexture(property.name) as Texture2D;

                // If the material does not have a texture for this slot, fill the array with a flat color
                if (layer == null)
                {
                    Debug.Log(String.Format("Skipping empty parameter {0} for material {1}", property.name, input));
                    if(fallback == null)
                    {
                        // Generate a fallback texture with a flat color of the right format and size
                        TextureFormat fmt = property.format;
                        if (fmt == TextureFormat.DXT1) // We can't write to compressed formats, use uncompressed version and then compress
                            fmt = TextureFormat.RGB24;
                        else if (fmt == TextureFormat.DXT5)
                            fmt = TextureFormat.RGBA32;
                        fallback = new Texture2D(property.width, property.height, fmt, true, property.linear);
                        fallback.SetPixels(Enumerable.Repeat(property.fallback, property.width * property.height).ToArray());
                        fallback.Apply();
                        if (fmt != property.format) // Compress to final format if necessary
                            EditorUtility.CompressTexture(fallback, property.format, TextureCompressionQuality.Fast);
                    }
                    layer = fallback;
                }

                // Validate input data
                if (layer.format != property.format)
                {
                    Debug.LogError(String.Format("Format mismatch on {0} / {1}. Is {2}, must be {3}.", input, property.name, layer.format, property.format));
                    layerIndex += 1;
                    continue;
                }

                if (layer.width != property.width || layer.height != property.height)
                {
                    Debug.LogError(String.Format("Resolution mismatch on {0} / {1}", input, property.name));
                    layerIndex += 1;
                    continue;
                }

                // Copy input texture into array
                Graphics.CopyTexture(layer, 0, output, layerIndex);
                layerIndex += 1;
            }
            AssetDatabase.CreateAsset(output, dst);
        }

        // Create or get a material and assign the texture arrays
        // Unity keeps losing connections when re-saving the texture arrays so this is my workaround to avoid manually allocating
        string mtlDst = outputPath + ".mat";
        Material mtl = AssetDatabase.LoadAssetAtPath(mtlDst);
        bool create = false;
        if(mtl == null)
        {
            create = true;
            mtl = new Material(Shader.Find("Custom/NewShader"));
        }

        for (int i = 0; i < properties.Length; ++i)
        {
            PropertyCombineSettings property = properties[i];
            mtl.SetTexture(property.name, results[i]);
        }

        if (create)
        {
            AssetDatabase.CreateAsset(mtl, mtlDst);
        }

        AssetDatabase.SaveAssets();
    }
}

This is a surface shader that mimics unity's standard shader for a large part, but using texture arrays! It handles indexing by accessing uv2.y, it assumes uv2.x contains the actual uv2 as two float16 packed together.

Shader "Custom/NewShader" {
	Properties {
		_Color("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2DArray) = "" {}

		// _Glossiness("Smoothness", Range(0.0, 1.0)) = 0.5
		// _GlossMapScale("Smoothness Scale", Range(0.0, 1.0)) = 1.0
		// [Enum(Metallic Alpha,0,Albedo Alpha,1)] _SmoothnessTextureChannel("Smoothness texture channel", Float) = 0
		_MetallicGlossMap("Metallic", 2DArray) = "" {}

		_BumpScale("Scale", Float) = 1.0
		[Normal] _BumpMap("Normal Map", 2DArray) = "" {}

		_Parallax("Height Scale", Range(0.005, 0.08)) = 0.02
		_ParallaxMap("Height Map", 2DArray) = "" {}

		_OcclusionStrength("Strength", Range(0.0, 1.0)) = 1.0
		_OcclusionMap("Occlusion", 2DArray) = "" {}
		
		// _EmissionColor("Color", Color) = (0,0,0)
		// _EmissionMap("Emission", 2D) = "white" {}
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200

		CGPROGRAM
		// Physically based Standard lighting model, and enable shadows on all light types
		#pragma surface surf Standard fullforwardshadows

		// Use shader model 3.0 target, to get nicer looking lighting
		#pragma target 3.0

		fixed4 _Color;
		UNITY_DECLARE_TEX2DARRAY(_MainTex);
		UNITY_DECLARE_TEX2DARRAY(_MetallicGlossMap);
		half _Metallic;
		half _BumpScale;
		UNITY_DECLARE_TEX2DARRAY(_BumpMap);
		half _Parallax;
		UNITY_DECLARE_TEX2DARRAY(_ParallaxMap);
		half _OcclusionStrength;
		UNITY_DECLARE_TEX2DARRAY(_OcclusionMap);

		UNITY_INSTANCING_BUFFER_START(Props)
		// put more per-instance properties here
		UNITY_INSTANCING_BUFFER_END(Props)

		struct Input
		{
			float2 uv_MainTex;
			float2 uv2_BumpMap;
			float3 viewDir;
		};

		// Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
		// See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
		// #pragma instancing_options assumeuniformscaling
		UNITY_INSTANCING_BUFFER_START(Props)
		// put more per-instance properties here
		UNITY_INSTANCING_BUFFER_END(Props)

		void surf (Input IN, inout SurfaceOutputStandard o) 
		{
			uint xy = asuint(IN.uv2_BumpMap.x);
			uint mask = ((1 << 16) - 1);
			float2 uv2 = float2(asfloat(uint(xy & mask)),
								asfloat(uint((xy >> 16) & mask)));
			float textureIndex = IN.uv2_BumpMap.y;

			float2 offsetMainTex = ParallaxOffset(UNITY_SAMPLE_TEX2DARRAY(_ParallaxMap, float3(IN.uv_MainTex, 0)).r, _Parallax, IN.viewDir);
			float3 uv = float3(IN.uv_MainTex + offsetMainTex, textureIndex);
			
			fixed4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uv) * _Color;
			o.Albedo = c.rgb;

			fixed4 metal_smooth = UNITY_SAMPLE_TEX2DARRAY(_MetallicGlossMap, uv);
			o.Metallic = metal_smooth.g;
			o.Smoothness = metal_smooth.a;

			o.Normal = UnpackScaleNormal(UNITY_SAMPLE_TEX2DARRAY(_BumpMap, uv), _BumpScale);
			
			o.Occlusion = lerp(1.0, UNITY_SAMPLE_TEX2DARRAY(_OcclusionMap, uv).a, _OcclusionStrength);

			o.Alpha = 1.0;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

The last script i wrote takes a mesh filter from an imported model and writes it to a new separate mesh asset with an index set into uv2.y. I also pack uv2 into uv2.x.

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

[Serializable]
struct MeshArray
{
    public Mesh[] data;
}

[ExecuteInEditMode]
public class ArrayIndexSetter : MonoBehaviour
{
    [SerializeField] bool run = false; // Tick this to let an Update() call process all data.
    [SerializeField] MeshArray[] meshesPerIndex; // Primary index specifies material, the list of meshes then all get this material.

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

        // For each set of meshes assume the index is what we want to specify as material index.
        for (int index = 0; index < meshesPerIndex.Length; ++index)
        {
            // Alter each mesh to contain the index
            foreach (Mesh sharedMesh in meshesPerIndex[index])
            {
                // TODO: try to update previously generated version instead of instantiating.

                // Duplicate the mesh (without doing this we can't use 
                // CreateAsset as it will try to update the existing asset which, 
                // for example, may be a part of an FBX file).
                string assetPath = AssetDatabase.GetAssetPath(sharedMesh);
                Mesh mesh = AssetDatabase.LoadAssetAtPath(assetPath);
                mesh = Instantiate(mesh) as Mesh;

                // Query or allocate a UV2 attribute to store the index in
                Vector2[] uv2 = mesh.uv2;
                if (uv2 == null || uv2.Length != mesh.vertexCount)
                    uv2 = new Vector2[mesh.vertexCount];
                for (int i = 0; i < uv2.Length; ++i)
                {
                    // truncate existing data and pack into X component
                    byte[] x = BitConverter.GetBytes(uv2[i].x);
                    byte[] y = BitConverter.GetBytes(uv2[i].y);
                    byte[] data = { x[0], x[1], y[0], y[1] };
                    uv2[i].x = BitConverter.ToSingle(data, 0);
                    // add our index to the end
                    uv2[i].y = index;
                }

                // update and serialize
                mesh.uv2 = uv2;
                string dst = assetPath + "_indexed.asset";
                if (File.Exists(dst))
                    File.Delete(dst);
                AssetDatabase.CreateAsset(mesh, dst);
            }
        }

        AssetDatabase.SaveAssets();
    }
}

The result can render these 4 meshes with 4 different looks as a single draw call.
The meshes are generated & set to static so unity can combine them.
In this screenshot you see 2 draw calls as there is the draw-and-shade and the blit-to-screen call.
Enabling shadows would add a shadow cast and a collect call on top, but subsequent meshes would not increase this count.

PS: The textures I used come from https://freepbr.com/.

Monster Black Hole – Pythius

Pythius makes Drum & Bass and is a friend of mine. So when he told me he was doing a new track and I could make the visuals I didn’t think twice about it!

The short version

The video was made using custom software and lots of programming! Generally 3D videos are made with programs that calculate the result, which can take minutes or even hours. This means that every time you change something you have to wait to see whether the result is more to your liking. With the custom software that we made, everything is instantly updated and we are looking at the end result at all time. This makes tweaking anything, from colors and shapes to animation, a breeze. It allows for much iteration as we want and turns the video creation process into an interactive playground.

The technique we use generates all the visuals with code, there are very few images and no 3D model files. Everything you see on the screen is visualized through maths. As a side effect of not using big 3D model files, the code that can generate the entire video is incredibly small. About 300 kilobytes, 10 thousand times smaller than the video file it produced!

The details

Technologies used are Python (software) Qt (interface) OpenGL (visual effects). The rendering uses Enhanced Sphere Tracing & physically based shading.

I talked about thetool development here and the rendering pipeline here in the past.
More information about advanced sphere tracing here. Which is an enhancement of this!

Screenshot dump

This is without the awesome after effects work from Lex Hoogendam, but a straight rip from the tool. Apologies that it’s not the same, but I hope it’ll make for some cool stills!

Monster-Black-Hole-4k_00704.jpg
Monster-Black-Hole-4k_01303.jpg
Monster-Black-Hole-4k_01633.jpg
Monster-Black-Hole-4k_02140.jpg
Monster-Black-Hole-4k_02200.jpg
Monster-Black-Hole-4k_02592.jpg
Monster-Black-Hole-4k_02776.jpg
Monster-Black-Hole-4k_02879.jpg
Monster-Black-Hole-4k_03078.jpg
Monster-Black-Hole-4k_03474.jpg
Monster-Black-Hole-4k_03911.jpg
Monster-Black-Hole-4k_04865.jpg
Monster-Black-Hole-4k_5225.jpg
Monster-Black-Hole-4k_05680.jpg
Monster-Black-Hole-4k_05933.jpg
Monster-Black-Hole-4k_06365.jpg
Monster-Black-Hole-4k_06388.jpg
Monster-Black-Hole-4k_07089.jpg
Monster-Black-Hole-4k_07285.jpg
Monster-Black-Hole-4k_07854.jpg

A* path finding using a game’s wiki

Navigate the result here!

I recently replayed Metroid Prime 2 (for GameCube) and got lost. A lot. This game features many interconnecting rooms, most of which exist in a dark and a light dimension with one-way or two-way portals. It took ages to figure out what item to collect when and where, so I did what anyone would do and coded a system to find the optimal path to finish the game!

The game works with rooms, basically every room has a bunch of doors, usually 2 complex rooms are connected by tunnels, this allows the game to close doors and stream the world very late (as we don’t have any view of unloaded areas to worry about). To start out I scraped this site (while saving every page loaded to disk as to not get banned for doing too many requests). Every link in that list goes to a page that describes what other places we can go from this place, as well as what upgrades can be found in this room. The connecting rooms are also formulated like “Room X (through red door)”, from which I can derive that a red door requires the missile launcher upgrade. So I can scrape limitations as well!

This allowed me to build a massive structure. Instead of making serialization work I just did code generation into a set of global dictionaries. That looks a bit like this:

# structure.py
class Or(object):
    def __init__(self, *args):
        self.options = args

    def __str__(self):
        return 'Or(\'%s\')' % '\', \''.join(self.options)

    def __eq__(self, other):
        for opt in self.options:
            if opt == other:
                return True
        return False


class Item(object):
    def __init__(self, name):
        self.name = name
        self.requirements = []


class Transition(object):
    def __init__(self, sourceRoom, targetRoom):
        self.sourceRoom, self.targetRoom, self.requirements = sourceRoom, targetRoom, []

    def deregister(self):
        self.sourceRoom.transitions.remove(self)


areas = ['Temple Grounds', 'Sky Temple Grounds', 'Great Temple', 'Sky Temple', 'Agon Wastes', 'Dark Agon Wastes', 'Torvus Bog', 'Dark Torvus Bog', 'Sanctuary Fortress', 'Ing Hive']


class Room(object):
    def __init__(self, name):
        self.name = name
        self.transitions = []
        self.items = []
        self.area = None

    def displayData(self):
        print '%s (%s)' % (self.name, self.area)
        print '\tConnects to:'
        for transition in self.transitions:
            print transition.targetRoom.name, transition.requirements
        print '\tItems:'
        for item in self.items:
            print item.name
from structure import *

# list items in the game
items = {
    'beam ammo expansion1': Item('Beam Ammo Expansion'),
    'beam ammo expansion2': Item('Beam Ammo Expansion'),
    'beam ammo expansion3': Item('Beam Ammo Expansion'),
    'beam ammo expansion4': Item('Beam Ammo Expansion'),
    'boost ball': Item('Boost Ball'),
# ... etcetera
}
# list rooms in the game
rooms = {
    ('transit station', 'Agon Wastes'): Room('Transit Station'),
    ('mining plaza', 'Agon Wastes'): Room('Mining Plaza'),
# ... etcetera
}

# denote room worlds and contents
rooms[('transit station', 'Sanctuary Fortress')].area = 'Sanctuary Fortress'
rooms[('transit station', 'Sanctuary Fortress')].items.append(items['power bomb expansion6'])
# ... etcetera

# connect rooms in the game
transitions = {
    (('transit station', 'Agon Wastes'), ('mining plaza', 'Agon Wastes')): Transition(rooms[('transit station', 'Agon Wastes')], rooms[('mining plaza', 'Agon Wastes')]),
# ... etcetera
}
# register the connections
rooms[('transit station', 'Agon Wastes')].transitions.append(transitions[(('transit station', 'Agon Wastes'), ('mining plaza', 'Agon Wastes'))])
# ... etcetera
# add inventory requirements to the connections
transitions[(('sacred path', 'Temple Grounds'), ('profane path', 'Sky Temple Grounds'))].requirements.append('Dark Beam')
# ... etcetera

After executing that code I would have every room in the game in memory, where every room has links to the objects they’re connected to, including constraints such as upgrade requirements to open the door between those rooms!

Now with all this information, I can start writing a navigation unit in the game world. My unit has a certain inventory of upgrades and a current location and it can figure out where it can and can’t go at this point. So I wrote an A* path finder but for every connection I only consider evaluating it if the requirements of going through that door are met by the inventory.

Putting the unit at the start of the game and telling it to grab the first upgrade works instantly like this! A bit of a harder extension was adding the possibility to realize the requirements for a transition and then finding the path to that requirement first. This also slowed the algorithm down by a lot, but it does allow me to say “start here, go to final boss” and the navigator will figure out what to do! I realized there’s 100! (factorial) options to gather the upgrades so stuck with the manual order input for now…

I wanted to add the desire to obtain all items in the game, to complete it with 100% of the upgrades, but didn’t get around to it.

Next up was visualization! I soon realized that to highlight rooms on the map the most time efficient way was to just bite the bullet for an evening and trace them…
I wrote this little tool where I can navigate the different maps and draw the polygons for each room. That then gets saved as JSON and got used by the visualizer.

And here’s the code!

import json
import os
from qt import *
from scrapeoutput import *


class PolygonView(QWidget):
    def __init__(self, owner):
        super(PolygonView, self).__init__()
        self._owner = owner
        self.roomPolygons = {}
        self.setFocusPolicy(Qt.StrongFocus)
        self._panStart = None
        self.background = None
        self._panX = 0
        self._panY = 0

    def mousePressEvent(self, event):
        if event.button() == Qt.MiddleButton:
            self._panStart = self._panX, self._panY, event.x(), event.y()
            return

    def mouseReleaseEvent(self, event):
        if self._panStart is not None:
            self._panStart = None
            return

    def mouseMoveEvent(self, event):
        if self._panStart is not None:
            self._panX = self._panStart[0] + (event.x() - self._panStart[2])
            self._panY = self._panStart[1] + (event.y() - self._panStart[3])
            self.repaint()
            return

    def paintBackground(self, painter):
        if self.background:
            painter.setOpacity(0.2)
            painter.drawImage(0, 0, self.background)
            painter.setOpacity(1.0)

    def findActivePolygon(self):
        key = self._owner.activeKey()
        if key is None:
            return
        points = self.roomPolygons.get(key, [])
        if not points:
            return None
        return points

    def drawPolygon(self, painter, points):
        solidWhite = QPen(Qt.white, 1, Qt.SolidLine)
        dottedWhite = QPen(Qt.white, 1, Qt.DotLine)
        for i in xrange(len(points)):
            painter.setPen(solidWhite)
            painter.setBrush(Qt.NoBrush)
            x, y = points[i].x(), points[i].y()
            if i == 0:
                painter.setBrush(Qt.white)
            painter.drawRect(QRectF(x - 2.5, y - 2.5, 5.0, 5.0))
            if i == len(points) - 1:
                painter.setPen(dottedWhite)
            painter.drawLine(points[i], points[(i + 1) % len(points)])

    def paint(self, painter):
        self.paintBackground(painter)
        painter.setRenderHint(QPainter.Antialiasing)
        points = self.findActivePolygon()
        if not points:
            return
        self.drawPolygon(painter, points)

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.fillRect(QRect(0, 0, self.width(), self.height()), Qt.black)

        painter.translate(self._panX, self._panY)
        self.paint(painter)


def quadZoom(zoom, steps):
    # apply steps as multiplicative zoom to get a quadratic curve
    if steps < 0:
        return zoom * pow(1.000794567, steps)
    else:
        return zoom * pow(0.99912239, -steps)


class PolygonZoom(PolygonView):
    def __init__(self, owner):
        super(PolygonZoom, self).__init__(owner)
        self.zoom = 1.0
        self.state = 0

    def wheelEvent(self, event):
        oldUnits = (event.x() - self._panX) / self.zoom, (event.y() - self._panY) / self.zoom
        self.zoom = quadZoom(self.zoom, event.delta())
        newUnits = (event.x() - self._panX) / self.zoom, (event.y() - self._panY) / self.zoom
        deltaUnits = newUnits[0] - oldUnits[0], newUnits[1] - oldUnits[1]
        self._panX += deltaUnits[0] * self.zoom
        self._panY += deltaUnits[1] * self.zoom
        self.repaint()

    def drawPolygon(self, painter, points):
        if self.state == 0:
            painter.setPen(Qt.white)
        else:
            painter.setPen(QPen(QBrush(QColor(255, 180, 40)), 3.0))
        for i in xrange(len(points)):
            painter.drawLine(points[i], points[(i + 1) % len(points)])

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.fillRect(QRect(0, 0, self.width(), self.height()), Qt.black)

        painter.translate(self._panX, self._panY)
        painter.scale(self.zoom, self.zoom)
        self.paint(painter)


class PolygonEdit(PolygonView):
    def __init__(self, owner):
        super(PolygonEdit, self).__init__(owner)
        self.__dragStart = None

    def __indexUnderMouse(self, polygon, pos):
        tolerance = 8
        for index, point in enumerate(polygon):
            delta = point - pos
            if abs(delta.x()) + abs(delta.y()) < tolerance:
                return index
        return None

    def mousePressEvent(self, event):
        if event.button() == Qt.MiddleButton:
            self._panStart = self._panX, self._panY, event.x(), event.y()
            return

        if event.button() != Qt.LeftButton:
            return

        key = self._owner.activeKey()
        if key is None:
            return

        polygon = self.roomPolygons.get(key, None)
        if polygon is None:
            return None

        localPos = event.pos() - QPoint(self._panX, self._panY)
        index = self.__indexUnderMouse(polygon, localPos)
        if index is None:
            return

        self.__dragStart = localPos, polygon, index, QPoint(polygon[index])

    def mouseReleaseEvent(self, event):
        if self._panStart is not None:
            self._panStart = None
            return

        if self.__dragStart is None:
            if event.modifiers() == Qt.ControlModifier:
                key = self._owner.activeKey()
                if key is None:
                    return
                polygon = self.roomPolygons.get(key, [])
                localPos = event.pos() - QPoint(self._panX, self._panY)
                polygon.append(localPos)
                self.roomPolygons[key] = polygon
                self.repaint()
                return

            if event.button() == Qt.RightButton:
                key = self._owner.activeKey()
                if key is None:
                    return
                polygon = self.roomPolygons.get(key, None)
                if polygon is None:
                    return
                localPos = event.pos() - QPoint(self._panX, self._panY)
                index = self.__indexUnderMouse(polygon, localPos)
                if index is None:
                    return
                polygon.pop(index)
                self.repaint()
                return

        self.__dragStart = None

    def mouseMoveEvent(self, event):
        if self._panStart is not None:
            self._panX = self._panStart[0] + (event.x() - self._panStart[2])
            self._panY = self._panStart[1] + (event.y() - self._panStart[3])
            self.repaint()
            return
        if self.__dragStart is None:
            return
        localPos = event.pos() - QPoint(self._panX, self._panY)
        delta = localPos - self.__dragStart[0]
        self.__dragStart[1][self.__dragStart[2]] = self.__dragStart[3] + delta
        self.repaint()

    def paint(self, painter):
        self.paintBackground(painter)
        painter.setRenderHint(QPainter.Antialiasing)
        activeKey = self._owner.activeKey()
        if not activeKey:
            return

        painter.setOpacity(0.2)
        for key in self.roomPolygons:
            if key[1] == activeKey[1] and key[0] != activeKey[0]:
                self.drawPolygon(painter, self.roomPolygons[key])
        painter.setOpacity(1.0)

        points = self.findActivePolygon()
        if not points:
            return
        self.drawPolygon(painter, points)


class App(QMainWindowState):
    def __init__(self):
        super(App, self).__init__(QSettings('ImageEditor'))

        self.__worldList = QListView()
        self.__roomList = QListView()
        self.__view = PolygonEdit(self)
        self.__worldList.setModel(QStandardItemModel())
        self.__roomList.setModel(QStandardItemModel())
        self._addDockWidget(self.__worldList, 'Worlds')
        self._addDockWidget(self.__roomList, 'Rooms')
        self._addDockWidget(self.__view, 'View')

        self.__activeRoom = None
        self.__activeWorld = None
        self.__worldRooms = {}
        worldList = self.__worldList.model()
        for key in rooms:
            world = key[1]
            if world not in self.__worldRooms:
                worldList.appendRow(QStandardItem(world))
                self.__worldRooms[world] = []
            self.__worldRooms[world].append(key[0])

        self.__worldList.selectionModel().selectionChanged.connect(self.onWorldSelected)
        self.__roomList.selectionModel().selectionChanged.connect(self.onRoomSelected)

        self.__menuBar = QMenuBar()
        self.setMenuBar(self.__menuBar)
        self.__fileMenu = self.__menuBar.addMenu('File')
        self.__fileMenu.addAction('Save').triggered.connect(self.__onSave)
        self.__fileMenu.addAction('Load').triggered.connect(self.__onLoad)
        self.__fileMenu.addAction('Export').triggered.connect(self.__onExport)

        if os.path.exists('autosave.json'):
            self.load('autosave.json')

    def closeEvent(self, event):
        self.save('autosave.json')

    def __onSave(self):
        filePath = QFileDialog.getSaveFileName(self, 'Save', os.path.dirname(os.path.normpath(__file__)), '*.json')
        if filePath is not None:
            self.save(filePath)

    def __onLoad(self):
        filePath = QFileDialog.getOpenFileName(self, 'Load', os.path.dirname(os.path.normpath(__file__)), '*.json')
        if filePath is not None:
            self.load(filePath)

    def __onExport(self):
        filePath = QFileDialog.getExistingDirectory(self, 'Export', os.path.dirname(os.path.normpath(__file__)))
        if filePath is not None:
            self.export(filePath)

    def export(self, filePath):
        for key, value in self.__view.roomPolygons.iteritems():
            minX = min(value, key=lambda p: p.x()).x()
            minY = min(value, key=lambda p: p.y()).y()
            maxX = max(value, key=lambda p: p.x()).x()
            maxY = max(value, key=lambda p: p.y()).y()
            dest = os.path.join(filePath, '__'.join((key[0], key[1], str(minX), str(minY))) + '.bmp')
            image = QImage(maxX - minX + 1, maxY - minY + 1, QImage.Format_ARGB32)
            image.fill(Qt.black)
            for i, point in enumerate(value):
                image.setPixel(point.x() - minX, point.y() - minY, qRgb(i, 0, 0))
            image.save(dest)

    def save(self, filePath):
        with open(filePath, 'w') as fh:
            result = []
            for key, value in self.__view.roomPolygons.iteritems():
                format = {}
                format['key'] = key
                format['value'] = [(point.x(), point.y()) for point in value]
                result.append(format)
            json.dump(result, fh)

    def load(self, filePath):
        with open(filePath) as fh:
            self.__view.roomPolygons.clear()
            format = json.load(fh)
            for item in format:
                key, value = item['key'], item['value']
                self.__view.roomPolygons[(str(key[0]), str(key[1]))] = [QPoint(*point) for point in value]

    def activeKey(self):
        key = self.__activeRoom, self.__activeWorld
        if None in key:
            return None
        return key

    def onWorldSelected(self, *args):
        idx = self.__worldList.selectionModel().currentIndex()
        roomList = self.__roomList.model()
        roomList.clear()
        if not idx.isValid():
            self.__activeWorld = None
            self.__view.background = None
            return
        item = self.__worldList.model().itemFromIndex(idx)
        self.__activeWorld = str(item.text())
        self.__view.background = QImage(self.__activeWorld + '.jpg')
        for room in self.__worldRooms[self.__activeWorld]:
            inst = QStandardItem(room)
            if (room.lower(), self.__activeWorld) in self.__view.roomPolygons:
                inst.setBackground(Qt.lightGray)
            roomList.appendRow(inst)

        self.onRoomSelected()

    def onRoomSelected(self, *args):
        idx = self.__roomList.selectionModel().currentIndex()
        if not idx.isValid():
            self.__activeRoom = None
            self.__view.repaint()
            return
        item = self.__roomList.model().itemFromIndex(idx)
        self.__activeRoom = str(item.text())
        self.__view.repaint()


if __name__ == '__main__':
    a = QApplication([])
    w = App()
    w.show()
    a.exec_()

Then for navigation I wrote a basic A* pathfinder. This is the most basic introduction I could find, I think I learned from this same site years ago!

import json
import itertools
from structure import *
from scrapeoutput import *


_cache = {}
def findItem(target):
    """ given an item name find what room it lives in (and cache it for speedier queries in the future). """
    room = _cache.get(target, None)
    if room:
        return room
    for key, room in rooms.iteritems():
        if target in room.items:
            _cache[target] = room
            return room


def canTransition(transition, inventory):
    """
    check if a path is blocked
    :type transition: Transition
    :type inventory: [str]
    :rtype: bool
    """
    for requirement in transition.requirements:
        if requirement not in inventory:
            return False
    return True


class AStarNode(object):
    """ simply points to a room and knows how to get there through the hierarchy system """
    def __init__(self, data, parent=None):
        self.data = data
        self.parent = parent

    def cost(self):
        """ assume navigating through a room always costs "1", so going through as little doors as possible is assumed to be the shortest path """
        if self.parent is not None:
            return 1 + self.parent.cost()
        return 1

    def __repr__(self):
        if self.parent is not None:
            return '%s (from %s)' % (self.data.name, self.parent.data.name)
        return self.data.name


def growAdjacentNodes(node, inventory, open, closed):
    """
    Update the open list:
    Get adjacent tiles from node.
    If in closed do not add.
    If in open update best score.
    Else add to open.

    :type node: AStarNode
    :type inventory: [str]
    :type open: [AStarNode]
    :type closed: [AStarNode]
    :rtype: [AStarNode]
    """
    for transition in node.data.transitions:
        if not canTransition(transition, inventory):
            continue

        isInClosedList = False
        for entry in closed:
            if entry.data == transition.targetRoom:
                isInClosedList = True
                break
        if isInClosedList:
            continue

        newNode = AStarNode(transition.targetRoom, node)
        cost = newNode.cost()
        for i, entry in enumerate(open):
            if entry.data == transition.targetRoom:
                if cost < entry.cost():
                    open[i] = newNode
                break
        else:
            open.append(newNode)


class AstarException(Exception): pass


def aStar(startRoom, destinationRoom, inventory):
    """ basic path finder """
    closed = [AStarNode(startRoom)]
    open = []
    growAdjacentNodes(closed[0], inventory, open, closed)
    destinationNode = None
    for i in xrange(10000):  # failsafe
        for entry in open:
            if entry.data == destinationRoom:
                destinationNode = entry
                break
        if destinationNode:
            break
        if not open:
            raise AstarException('Out of options searching %s. Closed list: \n%s' % (destinationRoom.name, '\n'.join(str(i) for i in closed)))
        best = min(open, key=lambda x: x.cost())
        open.remove(best)
        closed.append(best)
        growAdjacentNodes(best, inventory, open, closed)
    iter = destinationNode
    path = []
    while iter:
        path.insert(0, (iter.data.name, iter.data.area))
        iter = iter.parent
    return path


def shortestPath(startRoom, itemKey, ioInventory):
    """ utility to kick off the path finder and compile the results """
    target = items[itemKey]
    for requirement in target.requirements:
        if requirement not in ioInventory:
            raise AstarException()
    destinationRoom = findItem(target)
    if startRoom == destinationRoom:
        ioInventory.append(target.name)
        return ['Aquired: %s' % target.name], destinationRoom
    path = aStar(startRoom, destinationRoom, ioInventory)
    if not path:
        raise RuntimeError()
    ioInventory.append(target.name)
    return path[1:] + ['Aquired: %s' % target.name], destinationRoom


def shortestMultiPath(startRoom, orderlessItems, finalItem, ioInventory):
    """ utility to try out all ways to get the orderlessItems and returns the most optimal version
    finalItem is the next item to get, it is included because the most optimal route should also be close to the next destination to be truly optimal """
    results = []
    for option in itertools.permutations(orderlessItems):
        inv = ioInventory[:]
        totalPath = []
        cursor = startRoom
        for itemKey in option + (finalItem,):
            path, cursor = shortestPath(cursor, itemKey, inv)
            totalPath += path
        results.append((totalPath, cursor, inv))
    # get result with shortest path
    result = min(results, key=lambda r: len(r[0]))
    ioInventory += result[2][len(ioInventory):]
    return result[0], result[1]

With this we can navigate from item to item and record the whole play through. After doing this some amendmends were necessary to fix things missed by the wiki scrape, such as activating a switch in room A to move aside something in room B. I solved that by adding a "switch" item to the right room and inserting it into the ordered item list that the navigator will walk along. After I had the pathfinder working and showing the resulting path on a map with highlighting, I went on and exported that all to a webpage! I just added that inline in the startup code. This whole thing does the navigation, generates a webpage and then shows a Qt app in a surprisingly small amount of time.

Here is that visualizer code:

from image_editor import PolygonZoom
from qt import *


class App(QMainWindowState):
    def __init__(self):
        super(App, self).__init__(QSettings('Navigator'))
        self.view = PolygonZoom(self)
        self.timeline = QListView()
        model = QStandardItemModel()
        self.timeline.setModel(model)
        self.setCentralWidget(self.view)
        self._addDockWidget(self.timeline, 'Timeline', Qt.LeftDockWidgetArea)
        self.timeline.selectionModel().selectionChanged.connect(self.__onUpdate)
        self.__key = None

        with open('autosave.json') as fh:
            self.view.roomPolygons.clear()
            format = json.load(fh)
            for item in format:
                key, value = item['key'], item['value']
                self.view.roomPolygons[(str(key[0]), str(key[1]))] = [QPoint(*point) for point in value]

        # record playthrough
        inventory = []
        itemOrder = ('missile launcher', 'violet translator', 'morph ball bomb', 'amber translator', 'space jump boots', 'dark beam', 'light beam',
                     ('dark agon temple key 1', 'dark agon temple key 2', 'dark agon temple key 3', 'dark suit'), 'agon energy', 'agon energy delivery',
                     'super missile', 'emerald translator', 'boost ball', 'dark torvus temple key 1', 'seeker launcher',
                     'catacombs lock', 'gathering lock',
                     'gravity boost', 'grapple beam',
                     ('dark torvus temple key 2', 'dark torvus temple key 3', 'dark visor'), 'torvus energy', 'torvus energy delivery',
                     'spider ball', 'power bomb', 'echo visor', 'screw attack',
                     ('ing hive temple key 1', 'ing hive temple key 2', 'ing hive temple key 3', 'annihilator beam'), 'sanctuary energy', 'sanctuary energy delivery',
                     'light suit',
                     'sky temple key 1', 'sky temple key 2', 'sky temple key 3', 'sky temple key 4', 'sky temple key 5', 'sky temple key 6', 'sky temple key 7', 'sky temple key 8', 'sky temple key 9',
                     'temple energy',
                     'temple energy delivery',

                     'missile expansion17', 'beam ammo expansion3', 'power bomb expansion5', 'darkburst', 'missile expansion12'
                     )
        startRoom = rooms[('landing site', 'Temple Grounds')]

        inst = QStandardItem('Landing Site')
        inst.setData('Temple Grounds')
        model.appendRow(inst)

        for itemKey in itemOrder:
            try:
                if isinstance(itemKey, tuple):
                    path, startRoom = shortestMultiPath(startRoom, itemKey[:-1], itemKey[-1], inventory)
                else:
                    path, startRoom = shortestPath(startRoom, itemKey, inventory)
            except ValueError:
                print 'Error finding %s' % str(itemKey)
                raise
            if not path:
                raise RuntimeError()
            for room_area in path:
                if isinstance(room_area, tuple):
                    room, area = room_area
                else:
                    room, area = room_area, None
                inst = QStandardItem(room)
                if area:
                    inst.setData(area)
                model.appendRow(inst)

        worldPolygons = {}
        with open('index.html', 'w') as fh:
            fh.write('')
            fh.write('')
            fh.write('')
            fh.write('')
            fh.write('
') for i in xrange(model.rowCount()): data = str(model.item(i).data()) room = str(model.item(i).text()) if not room.lower().startswith('aquired'): worldPolygons[data] = worldPolygons.get(data, {}) try: worldPolygons[data][room] = self.view.roomPolygons[(room.lower(), data)] except: worldPolygons[data][room] = [] data = '' if not data else 'data="%s"' % data fh.write('
%s
' % (data, room)) fh.write('
') with open('polygons.js', 'w') as fh: fh.write('polygons = {') for world in worldPolygons: fh.write('"%s": {' % world) for room in worldPolygons[world]: if worldPolygons[world][room]: poly = '%s' % (', '.join(['[%s, %s]' % (pt.x(), pt.y()) for pt in worldPolygons[world][room]])) fh.write('"%s": [%s],' % (room, poly)) fh.write('},') fh.write('};') def activeKey(self): return self.__key def __onUpdate(self, *args): self.view.state = 0 idx = self.timeline.selectionModel().currentIndex() if idx.isValid(): item = self.timeline.model().itemFromIndex(idx) label = str(item.text().lower()) if label.startswith('aquired: '): item = self.timeline.model().item(idx.row() - 1) label = str(item.text().lower()) self.view.state = 1 world = str(item.data()) self.__key = label, world self.view.background = QImage(world + '.jpg') else: self.__key = None self.view.background = None self.view.repaint() if __name__ == '__main__': a = QApplication([]) w = App() w.show() a.exec_()

This is definitely one of the sillier things to do in your spare time 🙂
Navigate the result here!

Accelerating Maya -> PyOpenGL mesh IO

A few weeks back I posted about exporting a mesh from Maya and drawing it with Python.

Faster export

I recently got back to improving this a little. First I improved maya exporter performance by porting it to a C++ plugin.
I don’t want to go over all the details, because it is similar to the python version posted before and if I’m going to explain this code properly I’d have to do a tutorial series on the Maya API in the first place! So here’s a little dump of the visual studio project instead: plugin.vcxproj!

It is currently very basic and just exports all data it can find. I’m aware certain maya models can crash certain other functions in Maya’s MFnMesh (and related) class. E.g. empty UV sets, UV sets with UVs for only certain vertices/faces, geometry with holes crashing getTriangles, etc. It may be good to write a python layer that does some validation on the mesh as well as add flags to explicitly export (or ignore) certain attributes and UV/color sets.

Faster import

Next I used the python mmap (memory map) module to upload the mesh directly from disk to openGL without getting (and therefore boxing) the raw data in Python objects first. Previously I was loading binary to python, which requires python to cast the binary to a python object, which I then wrapped into a ctypes object, allocating and copying huge chunks of memory and constructing tons of python objects. With mmap I can just cast the file handle to a void* and hand it to glBufferData.

import os
import mmap
import ctypes
import contextlib


@contextlib.contextmanager
def memoryMap(fileDescriptor, sizeInBytes=0, offsetInBytes=0):
    if isinstance(fileDescriptor, basestring):
        fd = os.open(fileDescriptor, os.O_RDWR | os.O_BINARY)
        ownFd = True
    else:
        fd = fileDescriptor
        ownFd = False
    mfd = None
    try:
        mfd = mmap.mmap(fd, sizeInBytes, offset=offsetInBytes)
        yield MappedReader(mfd)
    finally:
        if mfd is not None:
            mfd.close()
        if ownFd:
            os.close(fd)


class MappedReader(object):
    def __init__(self, memoryMap):
        """Wrap a memory map into a stream that can stream through the file and map sections to ctypes."""
        self.__memoryMap = memoryMap
        self.__offset = 0

    def close(self):
        self.__memoryMap.close()

    def size(self):
        return self.__memoryMap.size()

    def seek(self, offset):
        assert offset >= 0 and offset < self.size(), 'Seek %s beyond file bounds [0, %s)' % (offset, self.size())
        self.__offset = offset

    def tell(self):
        return self.__offset

    def read(self, ctype):
        """
        Map a part of the file memory to a ctypes object (from_buffer, so ctype points directly to file memory).
        Object type is inferred from the given type.
        File cursor is moved to the next unread byte (seek = tell + sizeof(ctype)).
        """
        result = ctype.from_buffer(self.__memoryMap, self.__offset)
        self.__offset += ctypes.sizeof(result)
        return result

    def readValue(self, ctype):
        """
        Utility to read and directly return the data cast as a python value.
        """
        return self.read(ctype).value

The memoryMap context can take a file descriptor (acquired through os.open, different from the regular open) or file path.
It will then open the entire file as read-only binary and map it instead of reading it.
Last it returns a MappedReader object which is a little wrapper around the mmap object that assists in reading chunks as a certain ctype.
This way I can easily read some header data (previously I'd do this by reading n bytes and using struct.unpack) and then read the remainder (or a large chunk) of the file as a ctypes pointer.

This code is a refactor from what I did in the tutorial mentioned at the top, but using mmap instead! It is mostly identical.

def _loadMesh_v0(stream, vao, bufs):
    vertexCount = stream.readValue(ctypes.c_uint32)
    vertexSize = stream.readValue(ctypes.c_ubyte)

    indexCount = stream.readValue(ctypes.c_uint32)
    indexSize = stream.readValue(ctypes.c_ubyte)

    assert indexSize in indexTypeFromSize, 'Unknown element data type, element size must be one of %s' % indexTypeFromSize.keys()
    indexType = indexTypeFromSize[indexSize]

    drawMode = stream.readValue(ctypes.c_uint32)
    assert drawMode in (GL_LINES, GL_TRIANGLES), 'Unknown draw mode.'  # TODO: list all render types

    # gather layout
    numAttributes = stream.readValue(ctypes.c_ubyte)

    offset = 0
    layouts = [None] * numAttributes
    for i in xrange(numAttributes):
        location = stream.readValue(ctypes.c_ubyte)
        dimensions = stream.readValue(ctypes.c_ubyte)
        assert dimensions in (1, 2, 3, 4)
        dataType = stream.readValue(ctypes.c_uint32)
        assert dataType in attributeElementTypes, 'Invalid GLenum value for attribute element type.'
        layouts[i] = AttributeLayout(location, dimensions, dataType, offset)
        offset += dimensions * sizeOfType[dataType]

    assert offset == vertexSize, 'File says each chunk of vertex data is %s bytes, but attribute layout used up %s bytes' % (vertexSize, offset)

    # apply layout
    for layout in layouts:
        glVertexAttribPointer(layout.location, layout.dimensions, layout.dataType, GL_FALSE, vertexSize, ctypes.c_void_p(layout.offset))  # total offset is now stride
        glEnableVertexAttribArray(layout.location)

    raw = stream.read(ctypes.c_ubyte * (vertexSize * vertexCount))
    glBufferData(GL_ARRAY_BUFFER, vertexSize * vertexCount, raw, GL_STATIC_DRAW)

    raw = stream.read(ctypes.c_ubyte * (indexSize * indexCount))
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexSize * indexCount, raw, GL_STATIC_DRAW)

    if stream.size() - stream.tell() > 0:
        raise RuntimeError('Error reading mesh file, more data in file after we were done reading.')
    
    return Mesh(vao, bufs, drawMode, indexCount, indexType)


def model(filePath):
    vao = glGenVertexArrays(1)
    glBindVertexArray(vao)
    bufs = glGenBuffers(2)
    glBindBuffer(GL_ARRAY_BUFFER, bufs[0])
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, bufs[1])
    with memoryMap(filePath) as stream:
        fileVersion = stream.readValue(ctypes.c_ubyte)
        if fileVersion == 0:
            return _loadMesh_v0(stream, vao, bufs)
        raise RuntimeError('Unknown mesh file version %s in %s' % (fileVersion, filePath))