Saturday, April 9, 2016

A Material System, Part 1: An Introduction

Tentative series plan:
  1. An Introduction (you are here)
  2. Deciphering the HLSL Packing Rules
  3. Shader Reflection (clever title pending)
  4. Runtime Parameters (clever title pending)
I've been working on some sort of material system, for rendering objects and such. One feature I'm looking for is that it should be easy to setup up new materials and shaders with minimal (or preferably no) code changes. At first glance, this may seem like a simple thing: "well, shaders are written in some shader language, generally as separate data files... so just write a new shader and attach it to your mesh!" But things are seldom so simple...

This post is going to look at some high-level concepts for my materials to set the stage. For the time being, I'm focusing on the pixel shader for the material design, as that's what I'm currently working on. Vertex/Input Assembly has not reached the level of flexibility I want yet, so maybe I'll write about that later when I get there (as well as maybe other crazy things like tessellation support).

Disclaimer 1 : The material system I have arrived at suits my needs at this time. There may be better ways to do it, but this is what I've gone with. To build up to my design, I'll talk about some other possibilities that don't work for me. I'm not saying they're terrible, just that they don't fit what I want. And even if I do say it's terrible, maybe it's perfect for some other purpose. If you're using one of them, and don't need to go any more advanced, then that's fine!

Disclaimer 2 : I'm talking about D3D11/HLSL here. The material design can probably be carried over to other APIs and shader languages, but I'm not generally considering that.

Start with something simple

The simplest thing, with limited flexibility, is probably to have your pixel shader like this:

cbuffer Material : register(b0)
{
  float4 Color;
};
Texture2D DiffuseTex;

float4 psmain( VSOUT In )
{
  return Color * DiffuseTex.Sample( sampler, In.uv );
}

Give or take some missing code, this gives you a configurable color parameter and a texture to sample from. In your C++ code, you might have something like:

struct MaterialParameterData
{
  float Color[4];
};
struct PerObjectMaterialParameters
{
  ID3D11Buffer* Constants;
  ID3D11Texture2D* DiffuseTexture;
};

Give each object a PerObjectMaterialParameters, with Constants filled with a MaterialParameterData. Bind everything and draw your thing. Maybe you read the colors from some data file when creating the object, along with a filename to grab a texture from. Totally flexible! Just change the data and get different colors and textures! Ship it!

Don't get too excited... What happens when color modulation isn't good enough? Maybe someone decided that some objects should use the texture as a mask over a solid color. Well that's easy, just use a new shader:

cbuffer Material : register(b0)
{
  float4 Color;
};
Texture2D DiffuseTex;

float4 psmain( VSOUT In )
{
  float4 tex = DiffuseTex.Sample( sampler, In.uv );
  return lerp( Color, tex, tex.a );
}

Like magic! And look, the cbuffer and texture are the same, so no code changes required! Just point the object at this new shader, and it'll be perfect! ... but wait, someone now wants to have a layered material:

cbuffer Material : register(b0)
{
  float4 Color0;
  float4 Color1;
};
Texture2D DiffuseTex0;
Texture2D DiffuseTex1;

float4 psmain( VSOUT In )
{
  float4 tex0 = DiffuseTex0.Sample( sampler, In.uv );
  float4 tex1 = DiffuseTex1.Sample( sampler, In.uv );
  return lerp( Color0*tex0, Color1*tex1, tex1.a );
}

Phooey. We've got more constants and more textures. Now there are two obvious choices:
  1. Update the other shaders to have the same cbuffer and textures, just don't use the extra stuff. This is will require some small C++ changes to use the new data, but it's pretty simple. But as the materials get more complex the buffer size and number of possible texture bindings may rapidly increase.
  2. Add a new struct in C++. Objects can specify what type of material they use, and get the appropriate constant and texture bindings. Each material's buffer will only contain the data it needs, but any new material will require several code changes.

Which one is best? Neither, they're both terrible.

A Little More Flexible

It's likely that you don't want to spend all your time supporting new materials, with new parameters, new textures, new computations. Maybe eventually it would stabilize, but at what cost? There are more important things to do!

So throw out everything. From the C++ side, we'll treat the constant buffer as a black box. It's just a chunk of memory that gets filled with something. For textures, we'll just have a list of bindings (essentially a texture and the slot to bind it to). Considering the first shader above, with a single Color parameter and a single Texture, we might define the parameters in some data file like:

constants:
  1, 1, 0, 1
textures:
  0=texture.dds

... or whatever. I don't care how it's stored, but somehow we parse that, come up with a bunch of floats to stick in a buffer, and a texture to load and bind. And we get a lovely yellow thing. Now how about the clever layered material? Well, how about doing something like:

constants:
  1, 1, 0, 1,
  0, 1, 1, 1,
textures:
  0=bottom_layer.dds
  1=top_layer.dds

Now there are 8 floats for the buffer and two textures, but because we aren't making assumptions about it, there's no need to make any code changes. Amazing! Okay, this is the best thing since bacon-wrapped hot dogs! We can do anything now, what more could we want?

Well, as it happens, the moment you've finished off this masterpiece, someone comes along and gives you this:

cbuffer Material : register(b0)
{
  float4 Color0;
  float Blend;
  float4 Color1;
};

float4 psmain()
{
  return DoSomethingCleverWithTheParameters();
}

"Easy," you think, "I'll just give it data like this:"

constants:
  1, 0, 0, 1,
  0.3,
  0, 1, 0, 1

... and then it doesn't work as expected... This is where things can get a little complicated. The HLSL compiler has certain rules for how variables are packed into a cbuffer. When using float4, it's nice and easy. Using just float, or just float2 is also nice. When you start mixing things, it gets much worse. I'm not going to go into detail here, I'll just say that in this case, there's 12 bytes of padding inserted after the Blend variable. You can check the link for some more detail, although it's maybe not as complete as it should be.

Let's assume we've got the packing all worked out. We can explicitly pad stuff like so:

constants:
  1, 0, 0, 1,
  0.3, -1,-1,-1
  0, 1, 0, 1

Or we can use shader reflection to figure out programmatically where every value needs to go. This is what I'm doing, and a future article in this series will cover all the annoying fiddly bits of that.

Another potential problem here, is that we're assuming the material parameters are packed into a single cbuffer. But what if we have some effect we want to apply on top of a regular material:

cbuffer Material : register(b0)
{
  float4 BaseColor;
};
cbuffer Effect : register(b1)
{
  float Amount;
  float2 Displacement;
};

These have been split up because Material is some basic properties that are likely shared between many objects (maybe instances of the same object, maybe entirely different, doesn't matter). Sure, we could merge the two, and just not share buffers when the effect is active. But if the base material is much bigger than a single color, and if the effect parameters are changing per frame, maybe it would be a good idea to have a small buffer to update.

An easy solution here is to do the same for constant buffers as we did for textures: Just have a list of them. Then the data might look like:

cb0:
  1, 1, 1, 1
cb1:
  0.75,
  -3, 2.7

This works fine for static data, but if it's static we're probably better off with everything in one buffer. For this effect, we want to update parameters at runtime, which requires runtime knowledge of where one value ends and the next begins. This will be another topic for the future.

That's All For Now

So far, we have a material system that allows a pixel shader to be written with any parameters packed into any constant buffers, and any texture bindings we want. The parameter values for the constant buffers, and names for the textures, can be specified in a separate data file. There are a lot of details that I've glossed over, which I hope to explore deeper in the future.

No comments:

Post a Comment