Hi, everyone. This is John Jackson, one of the engine devs at Nerd Kingdom who is currently responsible for working on the latest iteration of our material system. For those of you who are new to the concept, materials are a collection of textures, properties, and shaders that when in used in combination describe how a particular object is to be rendered on screen. I’m going to discuss very briefly how our previous material system has worked, what it was lacking, what we ultimately wanted out of our materials, and how that’s being implemented.
Previous Material System
As stated before, materials are a collection of various items working in tandem to describe how any given game object should be rendered to the screen. In the simplest terms possible, for each object or collection of objects (in the case of instancing), we grab all of the textures and material specific properties to be used, “bind” the shader to be used with this particular material and submit to the graphics card to render our game object. As our artists follow a PBR workflow (Physically Based Rendering), we had a fairly clear understanding early on what our materials would need regarding all of these items.
Originally, all of our materials consisted of the base following properties:
With just these colors, textures, and shared shaders and not much else (a few other properties are not listed here), it was fairly easy to be able to satisfy most of the requirements given by the artists for the game objects they needed to represent – anything from rocks to glowing swords to glass windows. Of course, there were still many limitations to this system that eventually started to become more and more apparent as time went on.
Issues / What was lacking
Firstly, regardless of the needs of a particular material, every single one was required to have these given properties. You have no need for metallic, roughness, or emissive maps and a constant value defined in a shader will suffice? Tough. Your material will still have these arrays for textures and colors and we will still have to allocate memory for it.
This might not seem like too big a deal at first, but as an example to demonstrate the concern this causes an engine programmer, let’s assume we have a simple material and all it needs are Albedo and Normal texture maps to achieve the desired effect. Using this current material system, we’ve just wasted space due to 4 other pre-allocated texture slots as well as 6 pre-allocated colors.
Secondly, as versatile as this set-up potentially is for most materials, it’s still limited due to being a basic surface material. What do I mean by that? If you remember, all of these materials are restricted to a few shared shaders that the engine team has written for the artists and these shaders are ultimately responsible for telling your graphics card how to take all of the input textures and properties given to it and ultimately draw the object to the screen. What’s the problem with this? Well, what if none of the shaders have instructions for implementing a very custom, specific effect one of the artists requests? For example, what if I want to have a material that emits a blinking light based on a sin wave or animate its verticies using a noise texture or tile a material’s albedo texture depending on how close the camera is from a given pixel or…?
Okay, hopefully it’s obvious to you that we’re missing out on some cool stuff now. So what do we do about it?
Well, if this previous material setup is to continue to be used and these desired effects are to be implemented, we have two basic options to choose from:
Both of these, while feasible and will certainly work in the short term, have fairly significant problems in the end.
If the first option is chosen, our materials have now become even more wasteful than before. For instance, simply wanting to scroll a texture in a material requires the material holding a variable for panning speed, which is a 2 float vector. Even this small variable means that all of our materials are now inflated by another 8 bytes, which obviously doesn’t scale well at all when you consider just how many more variables you’ll start to add on for other effects.
The second option is actually what we originally implemented once certain effects were being requested. We have specific implementations written to handle animated materials, flipbook materials, dissolve materials and even added parameters for wind controls. Each of these materials derives from our base graphics material class, they each hold specific properties required for the effect, and each corresponds to its own specific, handwritten shader that handles how it is to be rendered.
For a small number of materials for specific effects, this is a perfectly acceptable solution and has worked for us for a while. But as the number of requested effects continues to grow and experimentation becomes more and more desirable for different materials, this solution becomes very restrictive and time consuming. Again, just as the first option, this simply doesn’t scale to meet the desires of our team.
Inspiration / Design for new system
Sometimes finding guidance for how to implement a new system can be a challenging task, especially if you’ve never worked on anything similar to it before. Luckily, there are plenty of great engines out there that serve as a source of inspiration regarding design and features, so it didn’t take long to do some research regarding how other engines handle their own material implementations and compile a list of features that we wanted ours to have.
After discussions amongst the team and a preliminary planning/research stage, we had a fairly decent idea what we wanted our material system to be:
New System Overview
Essentially, the new system works like this:
All shader graphs are defined by a collection of nodes that together describe how a particular material and its shaders are to be created. Each of these nodes has its own functionality and ultimately are responsible for outputting some snippet of predefined shader code. As there was a strong desire to make this system as data-driven as possible, all of the nodes are defined in a node template file which is used by the engine to fill out specific, unique data for each evaluated node in the graph. This makes it very easy to tweak the behavior of already established nodes or create new templates very quickly.
Shown above is an example of what one of these node templates looks like. Most of the properties should be fairly self-explanatory, such as “NumberInputs”, but fields like “VariableDeclaration” and “VariableDefinition” require some explanation.
All nodes used in the shader graph once evaluated boil down to variables of the type defined by their output. For instance, a ConstantVec2Node will evaluate to some variable of type vec2, as is illustrated above under the “Output” section of the template.
For each node to be evaluated, we must declare its variable equivalent in the header of our generated shader as well as define this variable in the main body of our shader. This is what these two sections are responsible for doing. Obviously simply declaring and defining a vec2 is trivial, but using this system it is possible to define whole blocks of complicated shader code under the “VariableDeclaration” and “VariableDefinition” sections of the template.
Node Template Mark-up
What’s also notable are sections of the node template that contain custom-defined markup language that is replaced upon evaluation of the shader graph. For instance, all nodes have a unique name that is used for its shader variable name as well, so anytime the system sees the markup #NODE_NAME it knows to replace this text with the given name for that particular node. The #INPUT() markup looks at the “Inputs” field for the template and uses the specified input fields in the parentheses as replacements (in this instance, the “X” and “Y” fields respectively). There are many others, such as #G_WORLD_TIME for global world time of the engine as well as markup for vertex, camera, and pixel information.
All materials following this new system have now been reduced to two main properties:
The shader graph editor, even though in a very early stage, can be used to edit and create new shader graphs used for materials. The final output for the material is the “MainNode”, which can be seen in the picture below.
Here we have an example of a shader graph that creates the simple material that I was describing at the beginning of this post and requires only texture inputs for the albedo and normal channels. All other channels will use default values that will be constants within the shader and therefore not require any extra storage.
Examples of Graphs and Materials
My hope is that moving forward with this new material system will allow our artists and designers to explore and iterate on more and more creative options.
Below are some examples of some material effects that I threw together to show off what can be done with the new material system. What’s most important to me is that iteration on these was very easy and took only a small amount of time to create.
Have a great weekend!