(Caution: Long post)
Background
In computer graphics the term “Material” tends to get thrown around as something that describes the texture or color of an object, and so tends to be very vague. The concept of materials has been something that I’ve wrestled with way back in the old days of Spark Engine, mostly because I hate how most materials end up to be a class that has a very specific purpose and not at all flexible. In most cases this implies the material has properties for color (diffuse, specular, ambient, emissive) and one or two textures.
As is the overall theme of Tesla, I like things to be modular and flexible. Nothing hard-coded, and thus the specifications of how I were to design the material system were:
- Assume nothing! Every developer has their own needs and will want to use their own shaders. Do not impose a restriction, just let them do what they want. The engine’s shader library is a starting point and is there to fit most developer needs (much like XNA’s BasicEffect). I do not want to box people in that they have to use my shaders or my lighting design. This means no properties such as “DiffuseColor” or “Texture”.
- Extensible! Be able to easily add new functionality and logic without having to subclass. This specification has two purposes:
1. Be able to easily bind shader uniforms to data automatically. Either have engine-defined values or let the developer define their own modules which will extract data from their geometry (or other classes) and then send that data to the shader without any effort on the developer’s part. An example of this is how lights are handled (read on for that).
2. This can be billed as an argument for “Preshaders”, where you’re able to add and execute logic in order to do computation on the CPU before sending it to the device for performance reasons (e.g. concatenate World-View-Projection matrix once on CPU, rather than doing it hundreds or thousands of times on the GPU).
My first attempt (with SparkEngine’s design) was a material class that was composed of definitions. You had a texture definition, a light definition, and a world definition. These “Material Definitions” would pluck data from your mesh (world matrix, lights and textures) and renderer (camera position, view/projection matrices). The developer could come up with their own definitions to re-implement the default logic in a different way, or expand on it. E.g. if you wanted to use a different lighting equation you’d replace the default light definition with your own and use a custom shader.
I was satisfied with this design scheme for the most part, but I ran into problems while trying to port it over to Tesla’s new design. The problem lied mostly with how render states were handled. In SparkEngine, lights and textures were also considered “render states” and added to the scene graph. In Tesla, lights are added to the scene graph, but textures are not. And they no longer were render states, instead Tesla followed the D3D10/11 example of the four basic render state objects (Sampler, Rasterizer, DepthStencil, and Blend). Additionally, I decided that these should not be managed by the scene graph, but be directly apart of the material. So I took another crack at the design.
Design Overview
At a high level, the Material class in Tesla is composed of the following:
- An Effect that it directly manages. You’re able to set the current technique, set parameters, etc.
- Render state management. For every technique and every pass in the effect, you’re able to set a render state. When the pass is applied, these are sent to the renderer (where if they are not found in the cache, are applied to the device). I prefer to set render states at the application level, and not the shader (fx / cgFx file) level.
- Parameter management. The material has SetParameter/GetParameter which are more or less identical to setting/getting on an IEffectParameter. However, it’s safer since setting/getting parameters that don’t exist or are invalid for the piece of data you’re setting will just fall through and it caches the parameter value. This allows us to clone a material without reading anything back from the device, which can be useful to quickly copying an effect as well as re-using data (or textures).
- Engine Values. This is a biggie and one of the more important details of the design. These are engine-defined values that are automatically sent to your shader, if you tell the engine to bind the enumerations to your material. This means parameters such as the World-View-Projection matrices do not have to be manually set by the user every frame, as well as other data such as the camera’s position (eye for lighting). More on this below.
- Material logic. This replaced the idea of “definitions” which had gotten a bit too cumbersome. You’re able to write and add pieces of logic (Preshaders) to a material that is then executed when the material is applied. If there’s something the system does not provide, this is how you remedy that. Logic is the first thin executed (before states are applied or engine values served). Every piece of logic is bound to its parent material, and is sent the renderer and renderable when ApplyMaterial() is called.
Material logic is actually how lighting is implemented in the engine. Every material is created with a default lighting logic module that binds effect parameters based on a semantic or name (configurable of course!) and uses IRenderable’s world lighting list to apply them to the shader. It can apply light data to either a single uniform or to an array of uniforms (by default, the logic looks for an effect parameter with a semantic called “LIGHTLIST”).
So this means you can ignore, reconfigure, or replace entirely how lighting data is sent to the device by the engine. This is very convenient if you want a different rendering path such as Deferred Rendering. The engine is by default, a Forward Renderer, but with a little tweaking you should be able to easily setup a deferred render path quite easily by bypassing the default light logic and build a global light list from all your renderables (or not even use them).
And there’s more!
Two words: Material scripts.
I”ve been quite happy with how Tesla’s Material system has turned out, which is ironic considering it’s a lot simpler than its previous incarnation (maybe not that ironic). However, from the very beginning I had the eventual goal to incorporate scripting (a la OGRE3D material script, but simpler). Scripting is a very powerful tool since you have a way to easily bind data to your shader in an artist-friendly way using text files. Also, in moy castes once an object’s material is defined, it tends to stay static. If you have a lizard-on-a-rock model that uses normal mapping, most likely its color and textures stay constant (if they do, we’ll let you handle that). Additionally, many models may use the same shader, but a different color or texture. So it would be great if you could define a template that describes a material, then re-use it later down the road and change some specifics. This also helps in managing content, since you’re removing it from the code almost completely (if you need to change something, you’re editing a text file, not a code file).
Enter “Tesla Engine Material” or TEM files (nifty name, eh?). Here’s a feature overview
- Every material script indicates an effect (by file path) and a technique to use.
- Shader parameters are able to be set, by name, directly in the script. This includes all supported parameter types (float, int, bool, Vectors, Matrix, Quaternion, and Textures). Setting a texture means setting its file path.
- Binding engine values to shader uniforms, by name. This tells the material that the effect parameters should be cached and be tied to one of the EngineValue enumerations. E.g. a float4x4 uniform named WVP bound to EngineValue.WorldViewProjection means your shader will be served that data everytime it prepares to render your geometry.
- Render states. You’re able to create render states and name them (currently the script only supports the pre-built states). You’re then able to bind these states to 1 or more of the passes in any of the effect’s techniques. By giving them a name, that allows you to re-use states. If two passes require a the same BlendState, you’re only creating one and re-using it.
- Inheritance – every material can inherit from another script (only one depth at the moment). This means all the parameters, engine values, and renderstates are carried over, or overwritten. This is very convenient when used in conjunction with the built-in shader library, since you’re able to inherit from the engine’s standard materials.
Bottom line:
You’re able to alter an object’s material, whether it be a texture/color/render state without having to write any C# code. That should make artists downright giddy with excitement. You don’t need to write any code, just edit a text file. Of course, you can completely bypass scripting and programmatically create the same shaders. Its your choice.
Note on engine values:
This is an area I recently enhanced. Originally they were managed by the Renderer, now they’re managed in a value map by the Engine static class. This value map requires you to set the current camera and/or the timer you use to get all the valid engine values. The map also provides a random number stream that can be used to feed random values to yoru shader. The only thing you need to do when rendering geometry (which the scene graph does for you) is set the world matrix, everything else is taken from the camera/timer.
Examples
Below are some examples, I won’t get into the script syntax but it should be easy enough to understand what’s going on.
From the engine’s shader library, a lit material that has no textures/vertex colors.
Material LitBasicColorMaterial {
Effect {
File : Shaders//LitBasicEffect.fx
Technique : LitBasicColor
}
MaterialParameters {
Vector3 MatDiffuse : 1.0 1.0 1.0
Vector3 MatAmbient : .2 .2 .2
Vector3 MatEmissive : 0 0 0
Vector3 MatSpecular : 0 0 0
float MatShininess : 16.0
float Alpha : 1.0
}
EngineParameters {
WorldViewProjection : WVP
WorldMatrix : World
WorldInverseTranspose : WorldIT
CameraPosition : EyePos
}
}
Here’s another that’s extremely simple but shows inheritance. The child material inherits from the built-in skybox shader that references a TEBO shader file and specifies the engine values and render states. This script uses all of that and adds in a cubemap.
Material MySkyBoxMaterial : SkyBox.tem {
MaterialParameters {
TextureCube DiffuseMap : Textures/purplenebula.dds
}
}
Lastly, here’s a complex example showing render state setting. It’s the material I created for my planet shader I posted about earlier in the month.
Material PlanetMaterial {
Effect {
File : Shaders/PlanetEffect.tebo
Technique : Planet
}
MaterialParameters {
Texture2D DiffuseMap : Textures/earth_diffuse.dds
Texture2D CloudMap : Textures/earth_clouds.dds
Texture2D NormalMap : Textures/earth_normal.dds
Texture2D SpecularMap : Textures/earth_specular.jpg
Texture2D LightsMap : Textures/earth_lights.dds
bool UseSpecularMap : true
float SkyScale : 2.5
float CloudScale : 1.0
float CloudRotateDirection : -1.0
Vector3 InvWaveLength : 5.602 9.478 19.646
float InnerRadius : 200
float OuterRadius : 205
float InnerRadiusSquared : 40000
float OuterRadiusSquared : 42025
float KrESun : .0375
float KmESun : .0225
float Kr4PI : .0314
float Km4PI : .0188
float Scale : .2
float ScaleOverScaleDepth : .8
float ScaleDepth : .25
float InvScaleDepth : 4
float G : -.95
float GSquared : .9025
}
EngineParameters {
WorldViewProjection : WorldViewProj
WorldMatrix : World
CameraPosition : CamPos
}
RenderStates {
RasterizerState rs : CullBackClockwiseFront
BlendState bs : AdditiveBlend
}
Technique Planet {
Pass SkyFromSpace {
RasterizerState : rs
BlendState : bs
}
Pass CloudsFromSpace {
BlendState : bs
}
}
}
Content Management
As mentioned, the nice thing about material scripts is that you can reference a TEBO file and not fx/cgfx/glsl files. All the material knows is that there’s some effect it needs to load and that’s it. This means you’re able to use a material script that can potentially reference several different versions of the same shader that are each specific to a certain platform. For example, the skybox example inherits from a material that is found in the default content managers of the D3D10 and XNA render systems. There’s two versions of the same material for each, but the child material doesn’t know nor needs to know, it inherits from whichever is available at runtime.
So another benefit of material scripts is you can split up your shader content more easily if you want to support different platforms, but in you code you only need to reference one piece of content – your TEM file.
TODO List
This isn’t yet a feature complete system, some stuff left to do:
- Better stability. Currently it’s “stable enough”, although it may blow up in your face if you use comments. The parser does support // style comments, but ensure you space them out from your script variables. If it does return an error in parsing, the parser tells you roughly your line and column where the error happened.
- Render state configuration. Currently you can only set the pre-configured states (by their name). At some point I’d like to have it where you’re able to set their properties.
- More than one material in a single text file. Currently you can only have one. I’d like to make it where you can hand over a single TEM file with several materials to a model loader, and have the loader use materials whose names correspond to geometry names. This should make loading geometry with custom materials a lot easier when you have a model with more than one mesh. Or when you compile a scene graph, all the materials used are dumped in a single TEM file (and by scene graph I mean a model with more than 1 mesh).
- Specifiy material logic. Be able to specify logic modules directly in the script and specify their properties.
The last bit is inspired (and material logic in general) by valve’s material script actually, notably for the material used when the spy cloaks in Team Fortress 2, where an alpha value is set and changed over time to give the illusion he’s cloaking.