Lived 3D

Technical Art Ramblings

  • BLOG
  • Privacy Policy

SeaBomb - A Matter Of Substance

A Matter Of Substance

I love nothing more than a good dungeon loot crawler. Despite my best intention to keep things simple on this project, I just had to add a loot system. Coding the feature wasn’t that big a deal, however, that isn’t where the cost lies; the sheer number of assets needed is. After all, I wouldn't play a loot crawler if every item looked the same. I needed to cut down on time per an asset fast.

There are a lot better artists out there than me, so I'll keep the actual modelling and Substance work-flow description as short as I can. The real meat of the post will be about scripting Substance Designer, and finally, adding it into a game engine pipeline.

This is a fairly chunky blog post, so feel free to skip ahead to any areas of interest. Otherwise, TL;DR: magically textured models in game thanks to Substance Designer.

  • Model To Unwrap
  • Substance to the Rescue
  • Into the Game (AKA, the real bottle neck)
  • All Wrapped Up - Wrapping Substance Designer
  • Enter the Pipeline

Model To Unwrap

To cut down on modelling time, I decided I wouldn't have time to build high poly models for each individual item. Instead, I created a single texture that has a bake of all the details I want on my item's surfaces (well, almost all, currently the sheet is mostly empty, but I'll fill it out over time).

Picture of modeling to unwrap

This texture set contains:

  • A height map.
  • A Material ID map that contains several colours representing the surface type of the material.
    • Metal
    • Paint
    • Energy Glow

The texture contains no details that give reference to a specific scale, and as such, I can unwrap with disregard to Texel ratio. The idea is that as I'm modelling something, I can unwrap over the top of the height map to give additional panel edges and details that I would otherwise have to model into a high poly (which would significantly increase time needed per an asset).

Substance to the Rescue

In order for my unwrapped models to play nice with Substance Designer I gave them an additional UV channel. The channel contains a one to one UV layout with consistent Texel ratio. The basic idea is that I transfer both the Material ID map, and the height map over to the new layout using Substance Designers built in baker.

Picture of transfer UVWs.

After this, Substance Designer takes over:

  • It converts the height to a normal map.
  • Adds in the appropriate materials to the texture based on the Material ID map.
  • Adds in leaks, rust, and any other weathering I need.
Picture of final work.

All of this is pretty standard Substance fair. However, I was really happy with the final result in my test render. Further more, the assets were fast to make.

Into the Game (AKA, the real bottle neck)

I sat down to add all the assets into the game and grab some screenshots!

All I needed to do was:

  • Go through and set up unique Substances for each model.
  • Set up bakes for each model.
  • Bake each models textures.
  • Add each baked texture into the Substances I made.
  • Export each set of textures for every Substance.
  • Set up a unique material for every single bake I did.
  • Reference the material from the correct model, and export it.
  • Add each model to the build pipeline.
  • Build.
  • Run the game, and check it worked.

That's not too bad right? Well, currently I only have 12 or so assets, and only 1 Substance I need to apply to them. So I sat down and got started! I made it about 3 assets in before my ADD kicked in. If I can't even get through 3, how am I going to go when I have over 100 models with multiple substances I want to apply to them. It just isn't going to happen, even with the help of Designer, this feature is already too expensive for me.

Picture of built data.

Further more, what of my poor Git Repository? All of these textures are data that I would have to version control (they take ages to set up, so I really need to back them up somehow right?!) What if I tweak my Substance, now that's another iteration in Git. My repository size is going to explode with this approach. Additionally, I'm pretty sure I'd go borderline insane managing it all.

All Wrapped Up - Wrapping Substance Designer

The first problem I needed to deal with was all the Substance Designer set up I require for every model. The kind guys over at Algorithmic provide some batch tools for doing everything you need with Substance Designer through the command line. So first steps are to wrap the command line utilities in functions that can be accessed from Lua (spoiler, I chose Lua for this so that it's accessible from my build pipeline). You could wrap up the tools directly in Lua, I just felt like doing it in C# at the time.

The first functions I had to wrap up were all of the baking utilities. I’ve shown a specific use case for one of them, but including all of them would make this already rather long post unbearable. To figure the rest out run “sbsbaker.exe –help”.

//Helper function for running executable files. static void RunApp(string path, string[] args) { ProcessStartInfo startInfo = new ProcessStartInfo() { Arguments = string.Join(" ", args), CreateNoWindow = true, FileName = path, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; using (Process process = new Process() { StartInfo = startInfo }) { process.Start(); string output = process.StandardOutput.ReadToEnd(); string error = process.StandardError.ReadToEnd(); if (!string.IsNullOrEmpty(error)) { Console.WriteLine(path + " " + startInfo.Arguments); Console.WriteLine(error); throw new Exception(error); } } } //Bakes a heightmap from a high poly mesh to a low poly mesh. public void BakeHeightFromMesh(string highPoly, string lowPoly, string output, int size) { //AssetFactory.GetPath returns the full path on disk. string fullOutputPath = AssetFactory.GetPath(output); string name = Path.GetFileNameWithoutExtension(fullOutputPath); //BakerPath is the full path to sbsbaker.exe RunApp(BakerPath, new string[] { "height-from-mesh", "--inputs", AssetFactory.GetPath(lowPoly), "--output-format png", //GetOutputPath returns the folder, and creates it on disk if it's missing. "--output-path", GetOutputPath(fullOutputPath), "--output-name", name, //GetBakeSize takes a size in pixels, and returns the corresponding bake size number. "--output-size", GetBakeSize(size), "--highdef-mesh", AssetFactory.GetPath(highPoly) }); }

Most of the helper functions are for managing file paths on disk, and aren't really worth talking about. The only confusing one is the GetBakeSize function. This is because the batch tools use a numerical code to represent texture size. Even more confusing, the codes are different from the sbsbaker tools, to the sbsrender tool. I'd rather not have to remember anything to do with this, so I've built a function that takes a size in pixels, finds the nearest power of 2, and then returns the appropriate code for baking. I have another that does the same thing for the rendering side of things. Here's the mapping for pixel size to code for both baking and rendering:

m_bakeSizeValues = new Dictionary<int, string>() { { 32, "0" }, { 64, "1" }, { 128, "2" }, { 256, "3" }, { 512, "4" }, { 1024, "5" }, { 2048, "6" }, { 4096, "7" } }; m_renderSizeValues = new Dictionary<int, string>() { {2, "1"}, {4, "2"}, {8, "3" }, {16, "4"}, {32, "5"}, {64, "6" }, {128, "7"}, {256, "8"}, {512, "9" }, {1024, "10"}, {2048, "11"}, {4096, "12" } };

Once I wrapped all of the bake functionality, the next step was to hook the baked textures into an existing substance. The batch tool appropriate for this task is the sbsmutator.exe. I've written a C# friendly version of the command, and then created an additional Lua friendly one that accepts a list of connections as a table. This command will basically create a new substance file, with all of the textures you've listed connected into the appropriate slots. I generally try to keep the substance file in 16bits to keep precision when creating normal maps from height maps. Unfortunately, the sbsmutate tends to set everything up as 8bit. I've not yet found a way of setting this through the mutate command, so I've resorted to hacking the XML myself (if anybody at Allegorithmic or otherwise is reading this and knows a sbsmutator based solution, please hit me up on twitter with it, and I'll update my blog).

//Mutate a substance to add textures to it. public void MutateSubstance(string graphFile, List<Tuple<string, string>> connections, string output) { string fullOutputPath = AssetFactory.GetPath(output); string name = Path.GetFileNameWithoutExtension(fullOutputPath); //Prepare the commandline arguments for sbsmutator.exe. List<string> command = new List<string>() { "specialization", "--input \"" + AssetFactory.GetPath(graphFile) + "\"", "--presets-path \"" + ResourcesPath + "\"", }; //Add the texture's to the commandline, connecting them to an input of the substance. foreach (Tuple<string, string> connection in connections) { command.Add("--connect-image \"" + connection.Item1 + "\"@path@\"" + AssetFactory.GetPath(connection.Item2) + "\"@tiling@1@format@RAW@level@1.0"); } //Add the output information to the commandline information. command.Add("--output-path " + GetOutputPath(fullOutputPath)); command.Add("--output-name " + name); command.Add("--output-graph-name " + name); //Run sbsmutator.exe. RunApp(MutatorPath, command.ToArray()); //Hack the properties of the substance file to force 16bit rendering. string[] sbsFile = File.ReadAllLines(fullOutputPath); sbsFile = sbsFile.Select(x => x.Replace( "<parameters>" , "<parameters><parameter><name v = \"format\"/><relativeTo v = \"0\"/><paramValue> <constantValueInt32><value v = \"1\"/></constantValueInt32></paramValue></parameter>" ).Replace( "<parameters/>" , "<parameters><parameter><name v = \"format\"/><relativeTo v = \"0\"/> <paramValue><constantValueInt32><value v = \"1\"/></constantValueInt32></paramValue> </parameter></parameters>" )).ToArray(); File.WriteAllLines(fullOutputPath, sbsFile); } //A lua friendly mutate function. public void LuaMutateSubstance(string graphFile, LuaTable connections, string output) { List<Tuple<string, string>> connectionList = new List<Tuple<string, string>>(); foreach (LuaTable connectionTable in connections.Values) { string[] connection = connectionTable.Values.OfType<string>().ToArray(); connectionList.Add(new Tuple<string, string>(connection[0], connection[1])); } MutateSubstance(graphFile, connectionList, output); }

After mutating the substance you need to “Cook” it (which converts it into a packaged binary format ready for the renderer using sbscooker.exe), and then to render it (using sbsrender.exe). This step outputs your final texture maps from the substance file. When cooking the substance you have to include the path of Substance Designers own library, and any other libraries you may be working from. I found the order of the command line argument was pretty finicky with the cooker, and this was the particular magic I settled on that worked:

//Cook the substance preparing it for rendering. public void CookSubstance(string input, string output) { string fullOutputPath = AssetFactory.GetPath(output); string name = Path.GetFileNameWithoutExtension(fullOutputPath); RunApp(CookerPath, new string[] { "--inputs", AssetFactory.GetPath(input), "--includes \"" + ResourcesPath + "\"", "--output-path", GetOutputPath(fullOutputPath), "--expose-output-size 1", "--size-limit 12", "--output-name", name, }); } //Render the substance. public void RenderSubstance(string input, int sizeX, int sizeY) { string fullOutputPath = AssetFactory.GetPath(input); string name = Path.GetFileNameWithoutExtension(fullOutputPath); RunApp(RenderPath, new string[] { "render", AssetFactory.GetPath(input), "--output-format png", "--output-path", GetOutputPath(fullOutputPath), "--output-name", name + "_{outputNodeName}", "--set-value $outputsize@" + GetRenderSize(sizeX).ToString() + "," + GetRenderSize(sizeY).ToString() }); }

The last step is to wrap up the function for Lua access. I'm using nLua for this, which makes the whole process incredibly painless.

//The class that holds all the Substance Designer functions SubstanceDesigner substanceDesigner = new SubstanceDesigner(); //Substance commands //Baker lua.RegisterFunction("BakeHeightFromMesh", substanceDesigner, substanceDesigner.GetType().GetMethod("BakeHeightFromMesh")); lua.RegisterFunction("BakePosition", substanceDesigner, substanceDesigner.GetType().GetMethod("BakePosition")); lua.RegisterFunction("BakeNormalWorldSpace", substanceDesigner, substanceDesigner.GetType().GetMethod("BakeNormalWorldSpace")); lua.RegisterFunction("BakeCurvature", substanceDesigner, substanceDesigner.GetType().GetMethod("BakeCurvature")); lua.RegisterFunction("BakeAmbientOcclusionFromMesh", substanceDesigner, substanceDesigner.GetType().GetMethod("BakeAmbientOcclusionFromMesh")); lua.RegisterFunction("BakeTextureFromMesh", substanceDesigner, substanceDesigner.GetType().GetMethod("BakeTextureFromMesh")); lua.RegisterFunction("BakeColorFromMesh", substanceDesigner, substanceDesigner.GetType().GetMethod("BakeColorFromMesh")); //Mutator lua.RegisterFunction("MutateSubstance", substanceDesigner, substanceDesigner.GetType().GetMethod("LuaMutateSubstance")); //Cooker lua.RegisterFunction("CookSubstance", substanceDesigner, substanceDesigner.GetType().GetMethod("CookSubstance")); //Render lua.RegisterFunction("RenderSubstance", substanceDesigner, substanceDesigner.GetType().GetMethod("RenderSubstance"));

With the batch tools completely wrapped up for Lua, it isn’t terribly hard to write a script that spits out everything the game needs. However I’m still stuck with the problem of version controlling and importing it all.

Enter the Pipeline

I've previously talked about the build pipeline before in my “Getting Started” blog post, and I made it pretty clear that one of my core pillars is “Do not version control built data”. Typically you wouldn't add your EXE files to your repository. You'd add your code files, and build the damn binaries. So why do any different with art. It's kind of a pet peeve of mine. I've seen people version control a DDS file, and forget to add the photoshop file. Later they quit, their PC gets formatted, usually on that day it's suddenly mission critical, “We can't possibly ship with that blood in that texture in JAPAN, HOLY HELL WE ONLY HAVE HOURS UNTIL FIRST SUB!”

Picture of a typical asset bulid.

So, what I already have is a Lua scripted build pipeline. My assets are added to the build via script, this creates a dependency tree that then resolves any dynamic dependencies, and builds everything in the correct order. This system is used to build all my game data, and is the logical place to add in my Substance Designer Pipeline. The example above shows a build for a typical model. It has dependencies on the source model, and materials. These in turn have dependencies on shaders and textures. The build pipeline also has the ability to save out information for the game to access. It can, for example, tell the game where to find all the models to use for generating weapons and items.

There are however a few missing pieces:

  • I was building from source directly into data, and had nowhere to stash temporary files. Easy fix, I quickly added the concept of an interim folder for temporary data (the key to this, is that it’s temporary, and shouldn’t be version controlled. You can cache this somewhere to help speed up subsequent builds, but you don’t need to keep it).
  • Secondly, all of my build steps are set up on the C# side of things. This is really problematic when you are trying to add in highly customisable bake steps that do wildly different things per asset. The solution for this was to add a scripted build node, where the Build() and GetDynamicDependencies() functions are defined on the Lua side.
--Cook the substance local CookSubstanceNode = AddAsset("Cook_"..substanceName, "ScriptFactory") CookSubstanceNode.Script = { Input = "\\Interim\\ItemTextures\\"..substanceName..".sbs", Output = "\\Interim\\ItemTextures\\"..substanceName..".sbsar", Build = function(self, node) CookSubstance(self.Input, self.Output) end, GetDynamicDependencies = function(self) return { AddAsset(self.Input, ""), } end, }
  • Thirdly, I would still have had to manually make a material for the output of every substance file. I fixed this by allowing materials to be defined on the script side of things in my build pipeline in addition to being read from a file.
  • Finally, I would still have to manually export a model that pointed to the correct material for every substance created. This was fixed by adding some basic support for reading in models and editing them on the Lua side using Assimp. I was then able to use a scripted build node to create new models pointing to the correct materials.
Picture of dependency graph for full pipeline.

All of this has allowed me to define a graph that takes a model, and a Substance file, and directly build the data that the game loads. It’s an easy step from there to define all my weapons and armour as a list of models, and a list of substances. The pipeline then builds every model against every substance, and saves out a list of what is available for the game to use. It was beautiful… Except it took 30 minutes to run my build pipeline now (it used to be 15 seconds).

This brings me to one of the main reasons for setting this all up in a dependency tree; you can save the state of the tree for later use. If the inputs to a node don’t change, then the output will be the same. So I saved all of the inputs, and a list of all the hashes of the output files for each node. This allowed me to check which nodes I needed to build, and only build the data that has actually changed. With this set up, the second run of my build pipeline was down to 10 seconds flat. Awesome, now I’m back into a reasonable iteration time for editing and testing data. If I was a multi-person studio, I would save the interim and game data on a shared network drive allowing other people to benefit from previous builds on my machine.

All of this has left me exactly where I need to be. No textures need to be version controlled (they get built by the pipeline, so no need). Additionally, all I need to do to add a model to the game is add it to a list. After that the pipeline takes over, and magical substance things begin to happen.