Rendering 10

More Complexity

Bake self-shadowing into a material.

Add details to part of a surface.

Support more efficient shader variants.

Edit multiple materials at once.

This is the tenth part of a tutorial series about rendering. Last time, we used multiple textures to create complex materials. We'll add some more complexity this time, and also support multi-material editing.

This tutorial was made with Unity 5.4.3f1.

Complex materials are often a mess.

Occluded Areas

Even through we can create materials that appear complex, it is just an illusion. The triangles are still flat. Normal maps can give the impression of depth, but that only works for direct light. There is no self-shadowing. Parts that are supposedly higher, should casts shadows on areas that are lower. But this doesn't happen. This is most obvious when the normal map would suggest that there are small holes, dents, or cracks.

For example, let's say that someone has been shooting at our circuit board. The shots didn't go through the board, but left significant dents. Here's an adjusted normal map for that.

Dented circuitry normal map.

When using this normal map, the circuitry material indeed appears dented. But the deepest parts of the dents are lit just as well as the undented surface. There isn't any self-shadowing going on in the dents. As as result, they do not appear to be very deep.

Dented circuitry.

Occlusion Map

To add self-shadowing, we can use what's known as an occlusion map. You can think of this as a fixed shadow map that's part of the material. Here is such a map for the dented circuitry, as a grayscale image.

Occlusion map.

To use this map, add a texture property for this map to our shader. Also add an occlusion strength slider property, so we can fine-tune it.

This new method is nearly identical do DoMetallic, which is also about a map, a slider, and a keyword. So duplicate that method and make the required changes. While DoMetallic shows the slider when there is no map, we have to do the opposite here. Also, Unity's standard shader uses the G color channel of the occlusion map, so we'll do this as well. Indicate this in the tooltip.

When the occlusion strength is zero, the map should't affect the light at all. Thus, the function should return 1. When at full strength, the result is exactly what's in the map. We can do this by interpolating between 1 and the map, based on the slider.

return lerp(1, tex2D(_OcclusionMap, i.uv.xy).g, _OcclusionStrength);

To apply the shadows to the light, we have to factor the occlusion into the light attention inside CreateLight.

Shadowing Indirect Light

The dents have become darker, but overall not by much. That's because a lot of the light is actually indirect light in this scene. As our occlusion map is not specific to any light, we can apply it to indirect light as well. This is done by modulating both the diffuse and specular indirect light.

This produces much stronger shadows. In fact, they might be too strong. As the occlusion map is based on the surface shape and not on a specific light, it makes sense that it is only applied to indirect light. Light coming from all directions is reduced the deeper you go into a dent. But when a light shines directly in it, the dent should be fully lit. So let's remove occlusion from direct lights.

As far as occlusion maps go, this is as realistic as it can get. Having said that, you'll often find games where occlusion maps are applied to direct lights as well. Unity's older shaders did this too. While that is not realistic, it does give artist more control over lighting.

What about screen-space ambient occlusion?

SSAO is a post-processing image effect that uses the depth buffer to create an occlusion map for an entire frame on the fly. It is used to enhance the feeling of depth in a scene. Because it is a post-processing effect, it is applied to the image after all lights have been rendered. This means that the shadowing is applied to both the indirect and direct light. As a result, this effect is also not realistic.

Merging Maps

We're only using one channel of the occlusion map, the G channel. The metallic map for circuitry is stored in the R channel, and the smoothness is stored in the alpha channel. This means that we could combine all three maps into a single texture. Here is such a map.

Combining metallic, occlusion, and smoothness in a single map.

The shader doesn't know that we're reusing the texture, so it will still sample it a second time for the occlusion map. But using a single texture does reduce memory and storage requirements. By using DXT5 compression, our three 512×512 maps only require 341KB. This does mean that the metallic and occlusion maps are combined into a single gradient, potentially reducing quality. Fortunately, these maps usually aren't that detailed and don't need to be very accurate. So the results are often acceptable.

Could we reduce it to a single texture sample?

Yes, you'll have to adjust the shader to sample everything from the same map. If you're doing this optimization, you can get rid of the extra texture property as well.

Masking Details

Our circuitry material is lacking details. Let's do something about that. Here is a detail albedo map and normal map.

Detail albedo and normal map.

Import then and set the texture to fade out mipmap. Assign the textures and use full-strength normals. These details shouldn't be too small, a 3 by 3 tiling works well.

Detailed circuitry.

Detail Mask

The details cover the entire surface, but this doesn't look so good. It's better is the details don't cover the metal parts. We could use a mask texture to control where details show up. This works like a binary splat map, like we used in part 3, Combining Textures. The difference is that a value of 0 means no details, and a value of 1 means full details.

Here is a detail mask that prevents details from showing up on the metal parts. For added variety, it also reduces and even eliminates them from the lower regions of the circuit board. And the details got wiped out wherever dents were punched into the board.

Detail mask.

Unity's standard shader uses the alpha channel of the detail mask, so we use that channel as well. The image above has all four color channels set to the same value.

Add a property for this map to our shader.

[NoScaleOffset] _DetailMask ("Detail Mask", 2D) = "white" {}

As many materials won't have a detail mask, give it a shader feature as well. It's needed in both the base and the additive passes.

#pragma shader_feature _DETAIL_MASK

Add the requires variable and a function to get the mask data to our include file.

Albedo Details

To mask the details, we'll have to adjust our include file again. Instead of always multiplying albedo with the details, we have to interpolate between the unmodified and modified albedo, based on the mask. While we're at it, let's put the retrieval of the albedo into its own function, just like for all the other properties.

Normal Details

We have to make the same adjustment for the normal vector. In this case, no details corresponds with an unmodified upward-facing tangent-space normal vector. So we replace the original detail normal by an interpolation between that vector and its original value, once again based on the detail mask.

More Keywords

We've been using shader features to enable shader code that samples and includes various maps into our lighting equation. Unity's standard shader does this as well. That's the idea of an uber shader. It can do many things, but has variants for many flavors of use.

The standard shader also has shader features to toggle the use of normal and detail maps. Normal maps are enabled when either a main or detail normal map is assigned. And details are enabled when either a detail albedo or normal is set.

Let's add these features to our shader as well. But let's keep it simple and toggle each map independently. First, let's set a keyword based on the existence of a detail albedo map.

The amount of shader variants has now increased a lot. However, to activate the keywords in a material, you'll have to change all the relevant maps via the inspector. Otherwise the shader GUI will not properly set the keywords. This isn't an issue when creating new materials, but existing ones need to be refreshed after this change.

Using the Keywords

Now we have to change the include file to take advantage of the new keywords. First, GetAlbedo might be able to leave out the detail map part.

How can you test whether this actually works?

When you're not using a detail albedo map, of course you won't get albedo details. But is that because the code is really omitted, or because the shader is sampling the default texture?

There are two ways you can verify that the keywords work as expected. First, temporarily change the default texture to something obvious, like white for the detail albedo map. If the material becomes too bright after removing the map, that means the code is still included. Alternatively, add a temporary #else block to the code which changes something obvious.

Next, we have to deal with the normal maps. In this case, we have four possible configurations. Either no normal maps, only a main map, only a detail map, or both maps. Let's isolate the code that samples these maps, moving it to a new function.

What about the albedo map, and colors?

Unity's standard shader assumes that there is always an albedo map, so doesn't reserve a keyword for it. As the vast majority of materials use albedo maps, it is a reasonable assumption. So I don't bother with an albedo keyword either. Of course you're free to add it yourself.

The standard shader also always applies the albedo tint. This assumption is more questionable, as many materials do not utilize a tint, sticking with the default white color. You could add a keyword for the tint, enabling it only if the tint is set to something other than white. I prefer not to do this, because a choice of color isn't as binary as using or not using a texture is. It is prone to unexpected issues, like animated colors not being applied, because they're initially white.

The standard shader does set its emission keyword based on the emission color. It leaves it out when the color is set to black. Indeed, this is the cause of quite a few people having trouble animating the emission color. So I don't do this.

Ubershaders are a neat idea. But when working on a specific project, you have the opportunity to create shaders that support exactly – and only – the features that you need, with as few keywords as possible. Take advantage of that, once you get serious about optimizing your shaders.

Editing Multiple Materials

So far, we've only considered editing a single material at a time. But Unity allows us to have multiple materials selected. If those materials all use our shader, then the shader GUI can be used to edit all of them at once. You'll also see the entire selection in the preview panel.

Preview of two selected materials.

Setting Too Few Keywords

So editing multiple materials at the same time already works! However, there is a problem. You'll see this when creating two new materials that use our shader. Select both, then assign a normal map to them. Even though both materials now have a normal map, only the first material ends up using them.

Only the first material with normals.

This happens because our shader gui only sets the keyword of one material. This is the editor's target, which is the first material in the selection.

What determines the order of the selected materials?

For all practical purposes, the order is arbitrary, but consistent. So you cannot rely on a certain material being the first in the selection.

We can solve this problem by adjusting the keywords of all materials in the selection. To do this, we have to adjust our shader GUI's SetKeyword method. Instead of using the target field, we have to iterate through all the material in the editor's targets array. Let's use a foreach loop to do this, as it's concise code, and we don't need to worry about performance here.

How does foreach work?

foreach is a convenient alternative for a for loop. It has some overhead compared to a regular for loop, because it creates a temporary iterator object. So I never use it in app code, or editor code that gets executed very often.

Note that the above code uses a temporary variable to cache the editor.targets property. The foreach loop doesn't need that, because the array is referenced only once directly, to get its iterator. Also, editor.targets is an object array, so we have to explicitly cast each item to a material. The foreach loop performs this cast implicitly.

With this change, the normals will show up in all materials, after changing the map or the bump scale.

Both materials with normals.

Setting Too Many Keywords

Unfortunately, we just created another problem. Consider a selection of two materials. The first material uses a normal map, while the second does not. In this case, the bump scale is shown by the UI, because that is based on the first material. This is not a problem, as the second material will just ignore the bump scale. However, when the bump scale is changed, the UI will update the keywords of both materials. The result will be that both materials have the _NORMAL_MAP keyword set. So the second material ends up with the _NORMAL_MAP keyword enabled, even though it doesn't use a normal map!

This problem wouldn't exist if we only updated the keywords when the texture property is changed. Unfortunately, because TexturePropertySingleLine combines two properties, we cannot distinguish between them with the BeginChangeCheck and EndChangeCheck methods. This was fine before, but no longer.

To fix this problem, we have to keep track of the map's texture reference before it can be changed. Then we only set the keyword if a change was made, and it's the map that's different.

This solves the problem for DoNormals. But it also affects DoMetallic, DoOcclusion, DoEmission, and DoSecondaryNormals. Adjust all these methods like we fixed DoNormals. Now our shader GUI properly supports multi-material editing!