A "hard" material system, which is what I was trying to avoid, works something like this: A material is either left to defaults, in which case the engine uses a hard-coded mechanism to determine the best way to render it, or it's defined as a "custom" material. Custom materials generally need to be described for every non-default material. These used to require things very close to the metal, now they're often just done by declaring values to throw at hardware shaders, which is a bit higher-level but still pretty close to the metal.
There are some flaws with this: It forces artists to deal with rendering concepts that are normally programmer territory, and the only way to deal with some shader customization or other being used repeatedly is either hard-code a new "default" into the engine, or copy/paste a lot of material specifications.
TDP's material system was designed with a few design goals, largely to address these issues:
- Artists should only have to describe the type of surface (or layer) and the assets needed for it, not how to render it.
- Material customizations should be reusable.
- Rendering paths should be data-driven, not hard-coded.
- Asset importation should be automatic, not require an extra step.
The third point was the most difficult one, but all contributed to the creation of a script-based "mediator" which I called "profiles."
Using profiles to describe rendering paths
Radically-different rendering paths became less of an issue as programmable shaders became standard fare, but it's still necessary: Some features (i.e. texture access in vertex shaders) have major performance differences between hardware, some are not available at all, some (i.e. blends into sRGB framebuffers) behave differently, etc.
Profiles needed to be "smart" and consistently formulate the best solution. At the same time, script VMs tend to be slow and executing them every frame is a great way to kill performance. My solution was to use Lua scripts to create rendering definitions, but also to use a caching scheme to avoid needing to re-execute them unless the rendering environment had changed.
Profiles are consequently aware of two types of parameters: Static and dynamic. Static parameters include things that will never change over a material's lifetime: What textures it refers to, graphics settings, etc. Dynamic parameters include things that might change over its lifetime: How many lights are affecting it, whether it's being viewed from inside a fog volume, etc.
I call each rendering path generated by an execution of the profile script a "run." Caching runs was a design challenge of its own. They needed to be cacheable using only relevant information, but they also needed to be deterministic:
- If the static parameters are the same for two materials, they should be able to use the same runs.
- Runs should be grouped by their static parameters, but only relevant ones. This means the same set of static parameters should always be relevant for every run on a material.
The dynamic parameter checks were then used as a lookup tree: Each combination of static parameters had one run tree associated with it. Rendering attempts on a material would traverse the tree, and if they hit an empty node, the profile script would be executed to create a new run. During that execution, every DynamicParameter function call would attempt to descend the tree, creating a new node if the result hadn't been seen yet, or going down an existing one if it did. The nodes contained what parameter was checked, and branched based on the results.
The product of a run, to the engine, was a set of techniques containing several draw layers for a surface for the various reasons that surface could be drawn, as well as the capabilities and limitations of it. For example, lightmapped surfaces in TDP have a "lightmapBase" technique which is drawn once for every lightmapped surface. Runs can specify a "maxLights" value which determines how many lights can be merged into that first pass, and if there are too many, they can be rendered using the "pointLight" technique instead and blended over it.
The techniques contain several layers, each layer corresponding to one pass, and each pass containing a reference to a shader, as well as declarations (which are determined out by the run) to decide which permutation of that shader to use. Of course, permutations can vary by asset availability, which means profiles will choose shader permutations that assign appropriate default values and avoid unnecessary computation if some component (i.e. a glow overlay) isn't available.
Using templates to describe multiple materials
The highest-level material definitions in TDP are templates. Instead of materials needing to be explicitly defined each time, TDP looks for a material definition for a material name, and if that fails, it looks for a "default.mpt" file in every directory in the tree, up to the root directory. Templates are also Lua scripts, and they're aware of the material name. This lets them do all kinds of things with it: They can attempt to derive information from the material name itself, and they can form a list of other assets to try loading, like a normal map.
This makes things pretty easy for material creation: You don't need to create a material, import a normal map, and assign that normalmap to the material inside an asset browser. Just drop an image with the material name and "_nm" attached to it in the same directory, and it will be automatically detected, imported, and associated with the material. TDP's terrain uses this quite liberally: To the renderer, terrain is nothing more than a vertex-lit model, but the material system associates numerous other assets with everything in the "terrain" directory like the detailmaps and lightmaps and renders it like actual terrain.
Importation and processing jobs are another thing recycled heavily using templates. Most textures are converted to YCoCg DXT5 textures and color-corrected into linear space, which is simply specified as a processing job in one template and then repeated for every material that template applies to.