MagicaVoxel Unity Mesh Splitter

This is an overview of a Unity 3D Engine prototype for processing MagicaVoxel OBJ meshes into submeshes for each voxel color so that they can be assigned different materials, physics materials, collider layers, etc.

In August I was using MagicaVoxel to prototype a videogame’s 3d overworld. It was looking pretty good.

But then I ran into an annoying problem. My Unity character controller had code for the player to make ripples on water physics materials. I also had plans for making water reflect the sky. This worked fine in my first prototype scene of cubes with manually assigned materials, but the .obj terrain file imported from MagicaVoxel was all one mesh, so I couldn’t assign different render materials and physics materials to each surface type.

Obviously, the solution was to add a whole new import feature to my asset pipeline so that my placeholder art could support the cosmetic water ripples feature. (Note: See the list of problems with my approach at the bottom of this blog post.)

I decided the simplest way to implement this was using a component to split the mesh out into separate submeshes. It looks like this:

using System.Collections.Generic;
using UnityEngine;

namespace KoboldsKeep
{
    namespace MagicaVoxelSplitter
    {
        public class MagicaVoxelRuntimeMeshSplitter : MonoBehaviour
        {
            void Start()
            {
                ProcessMesh();
            }

            private struct STriangle
            {
                public int a, b, c;
                public Vector2 uv; // We can assume that all three points on a triangle from Magicavoxel point at the same texture coordinate.
            }

            private void ProcessMesh()
            {
                MeshFilter originalMeshFilter = GetComponentInChildren();
                Mesh rawMesh = originalMeshFilter.mesh;
                int[] rawTris = rawMesh.triangles; // Triplets of indices into the vertex array.
                Vector3[] rawVerts = rawMesh.vertices;
                Vector2[] rawUvs = rawMesh.uv; // Color coordinates on the material's palette.

                // Convert the mesh into a triangles list.
                List allTriangles = new List();
                for (int i = 0; i < rawTris.Length; i += 3)
                {
                    STriangle currentTriangle = new STriangle();
                    currentTriangle.a = rawTris[i];
                    currentTriangle.b = rawTris[i + 1];
                    currentTriangle.c = rawTris[i + 2];
                    currentTriangle.uv = rawUvs[currentTriangle.a];
                    allTriangles.Add(currentTriangle);
                }
                // Dictionary key is the texture UVs of the triangle on the MagicaVoxel color palette.
                Dictionary<Vector2, List> meshDataDictionary = new Dictionary<Vector2, List>();
                // Split the triangles out into meshes based on their texture UVs.
                foreach (STriangle triangle in allTriangles)
                {
                    if (!meshDataDictionary.ContainsKey(triangle.uv))
                    {
                        meshDataDictionary[triangle.uv] = new List();
                    }
                    meshDataDictionary[triangle.uv].Add(triangle);
                }
                foreach (Vector2 key in meshDataDictionary.Keys)
                {
                    List values = meshDataDictionary[key];
                    MeshFilter newMeshObject = GameObject.Instantiate(originalMeshFilter);
                    newMeshObject.mesh = new Mesh();
                    newMeshObject.mesh.vertices = rawVerts;
                    List tris = new List();
                    foreach (STriangle triangle in values)
                    {
                        tris.Add(triangle.a);
                        tris.Add(triangle.b);
                        tris.Add(triangle.c);
                    }
                    newMeshObject.mesh.SetTriangles(tris, submesh: 0);
                    newMeshObject.mesh.normals = rawMesh.normals;
                    newMeshObject.mesh.uv = rawMesh.uv;
                    newMeshObject.mesh.RecalculateBounds();
                    // Set up colliders
                    MeshCollider newCollider = newMeshObject.GetComponent();
                    newCollider.sharedMesh = newMeshObject.mesh;
                }
                // Disable the original so that it doesn't overlap with the split meshes.
                originalMeshFilter.gameObject.SetActive(false);
            }
        }
    }
}

When we run the scene, the meshes get split out by color like this:

Great! Now we just need to split these out to assign separate materials and physics materials to them. This seems like a good thing to automate, so we’ll have a data prefab that maps color values to materials. This data prefab can be assigned as a field to other meshes we import from MagicaVoxel, so that we don’t have to set up the data separately for each mesh’s prefab.

using System.Collections.Generic;
using UnityEngine;

namespace KoboldsKeep
{
    namespace MagicaVoxelSplitter
    {
        public class MagicaVoxelRuntimeMeshSplitterData : MonoBehaviour
        {
            [System.Serializable]
            public struct STerrainData
            {
                public Color colorToLookFor;
                public Material renderMaterial;
                public PhysicMaterial physicsMaterial;
            };
            public STerrainData[] terrainImportData;

            // Cache created RenderTextures so we don't have to blit the texture every time we check for a color.
            private static Dictionary<Material, Texture2D> cachedRenderTextures = new Dictionary<Material, Texture2D>();

            public Color GetColorFromMaterial(Material material, Vector2 uv)
            {
                Color ret;
                // Copy the material's Texture2D to a new Texture2D because it imports into Unity as non-readalbe by default.
                Texture2D textureToSample = null;
                if (!cachedRenderTextures.TryGetValue(material, out textureToSample))
                {
                    Texture2D materialTexture = material.mainTexture as Texture2D;
                    if (materialTexture != null)
                    {
                        // Have to use a RenderTexture 
                        RenderTexture blitTexture = new RenderTexture(materialTexture.width, materialTexture.height, depth: 0, format: RenderTextureFormat.Default, readWrite: RenderTextureReadWrite.Linear);
                        Graphics.Blit(materialTexture, blitTexture);
                        textureToSample = new Texture2D(blitTexture.width, blitTexture.height, materialTexture.format, mipmap: false, linear: true);
                        textureToSample.ReadPixels(new Rect(0, 0, blitTexture.width, blitTexture.height), destX: 0, destY: 0);
                        textureToSample.Apply();
                        cachedRenderTextures.Add(material, textureToSample);
                    }
                }
                if (textureToSample != null)
                {
                    ret = textureToSample.GetPixelBilinear(uv.x, uv.y);
                }
                else
                {
                    Debug.LogError("Could not get RenderTexture for " + material.name);
                    ret = Color.black;
                }

                return ret;
            }

            public STerrainData GetTerrainDataFromVoxelColor(Color color)
            {
                STerrainData ret = terrainImportData[0];
                float bestDifference = float.PositiveInfinity;
                foreach (STerrainData data in terrainImportData)
                {
                    float newDifference = Mathf.Abs(color.r - data.colorToLookFor.r)
                        + Mathf.Abs(color.g - data.colorToLookFor.g)
                        + Mathf.Abs(color.b - data.colorToLookFor.b);
                    if (newDifference < bestDifference)
                    {
                        bestDifference = newDifference;
                        ret = data;
                    }
                }
                return ret;
            }
        }
    }
}

And we get the data from the splitter like so:

                    // Get data based on the color of this voxel
                    MeshRenderer newMeshRenderer = newMeshObject.GetComponent();
                    Color voxelColor = meshData.GetColorFromMaterial(newMeshRenderer.material, key);
                    if (logColors)
                    {
                        // Log the color and if you double-click on the message in the Console, you'll select it in the Hierarchy.
                        Debug.Log(voxelColor, newMeshObject.gameObject);
                    }
                    MagicaVoxelRuntimeMeshSplitterData.STerrainData colorData = meshData.GetTerrainDataFromVoxelColor(voxelColor);
                    newCollider.material = colorData.physicsMaterial;
                    newMeshRenderer.material = colorData.renderMaterial;

Once we fill in some basic color data and materials, we get this result:

It’s not perfect, and could use some more color entries to be more accurate. But this should be enough for anyone to get started using MagicaVoxel files for terrain in Unity. You can download the project here: https://github.com/tkeene/MagicaVoxelUnitySplitter/tree/master/MagicaVoxelSplitter

This project serves well as a proof of concept, but is still a bit lacking. It’s easy to think of a few other features and refactors that would improve it:

  • Parse .vox format files directly to skip the process of importing to .obj from the MagicaVoxel tool.
  • Currently not equipped to handle submeshes, which Unity automatically generates for meshes over Unity’s max vertex/triangle count.
  • Meshes that are generated could be optimized, right now they naively keep all of the vertices of the source mesh. This also makes focusing on split meshes in the editor a bit annoying.
  • Meshes could be given vertex color data, which could be fed to a custom shader (something that looks like this).
  • Split the meshes out in the editor instead of having to do it at runtime.