Chapter 03: The Material Class
Create a wrapper class to help manage shaders
In Chapter 24 of the original 2D Game Series, you learned about MonoGame's Effect class. When .fx shaders are compiled, the compiled code is then loaded into MonoGame as an Effect instance. The Effect class provides a few powerful utilities for setting shader parameters, but otherwise, it is a fairly bare-bones container for the compiled shader code.
Note
MonoGame also ships with standard Effect sub-classes that can be useful for bootstrapping a game without needing to write any custom shader code. However, all of these standard Effect types are geared towards 3D games, except for 1, called the SpriteEffect. This will be discussed in Chapter 7: Sprite Vertex Effect.
In this chapter, we will create a small wrapper class, called the Material, that will handle shader parameters, hot-reload, and serve as a baseline for future additions.
If you are following along with code, here is the code from the end of the previous chapter.
Note
The tutorial assumes you have the watch process running automatically as you start the DungeonSlime game. Otherwise, make sure to start it manually in the terminal in VSCode:
dotnet build -t:WatchContent --tl:off
The Material Class
The _grayscaleEffect serves a very specific purpose, but imagine instead of just decreasing the saturation, the effect could also increase the saturation. In that hypothetical, then calling it a "grayscale" effect only captures some of the shader's value. Setting the Saturation to 0 would configure the shader to be a grayscale effect, but setting the Saturation really high would configure the shader to be a super-saturation effect. A single shader can be configured to create multiple distinct visuals. Many game engines use the term, Material, to recognize each configuration of a shader effect.
A material definition represents a compiled Effect and the runtime configuration for the Effect. For example, the _grayscaleEffect shader has a single property called Saturation. The value of the property is essential to the existence of the _grayscaleEffect. It would also be useful to have logic that owns these shader parameter values.
We will create a class called Material that manages all of our shader related metadata:
Start by creating a new file in the MonoGameLibrary/Graphics folder called
Material.cs:using Microsoft.Xna.Framework.Graphics; using MonoGameLibrary.Content; namespace MonoGameLibrary.Graphics; public class Material { /// <summary> /// The hot-reloadable asset that this material is using /// </summary> public WatchedAsset<Effect> Asset; /// <summary> /// The currently loaded Effect that this material is using /// </summary> public Effect Effect => Asset.Asset; public Material(WatchedAsset<Effect> asset) { Asset = asset; } }In order to help create instances of the
Materialwe will add the following method in theContentManagerExtensionsfile we created in the previous chapter:/// <summary> /// Load an Effect into the <see cref="Material"/> wrapper class /// </summary> /// <param name="manager"></param> /// <param name="assetName"></param> /// <returns></returns> public static Material WatchMaterial(this ContentManager manager, string assetName) { return new Material(manager.Watch<Effect>(assetName)); }And add the following
usingstatements to the top of theContentManagerExtensionsclass so that theMaterialandEffectclasses can be recognized:using System; using System.Reflection; using System.IO; using System.Diagnostics; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using MonoGameLibrary.Graphics;Note
The
Materialhas been built to be hot-reloadable. Later in this chapter, we will see how the performance cost for supporting hot-reload is negligible by using the[Conditional("DEBUG")]attribute. However, if you do not want theMaterialto be hot-reloadable, then change theMaterial'sAssetfield to be anEffectrather than aWatchedAsset<Effect>. There will be some minor differences in the rest of the tutorial series, but the only major difference is that theMaterialwill not be hot-reloadable during development.Next, in the
GameSceneclass, adjust the_grayscaleEffectproperty to use the newMaterialclass:// The grayscale shader effect. private Material _grayscaleEffect;
Changing the _grayscaleEffect from an Effect to Material is going to cause a few compilation errors. The fixes are listed below.
When instantiating the
_grayscaleEffectin theLoadContentmethod, use the new method:// Load the grayscale effect _grayscaleEffect = Content.WatchMaterial("effects/grayscaleEffect");In the
UpdateMethod, when checking if the asset needs to be reloaded for hot-reload, use the.Assetsub property:// Update the grayscale effect if it was changed _grayscaleEffect.Asset.TryRefresh(out _);And in the
Draw()method, update it to use the.Effectshortcut property:// We are in a game over state, so apply the saturation parameter. _grayscaleEffect.Effect.Parameters["Saturation"].SetValue(_saturation); // And begin the sprite batch using the grayscale effect. Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp, effect: _grayscaleEffect.Effect);
Setting Shader Parameters
You already saw how to set a shader property by using the Saturation value in the _grayscaleEffect shader. However, as you develop shaders in MonoGame, you will eventually "accidentally" try to set a shader property that does not exist in your shader. When this happens, the code will throw a NullReferenceException rather than fail silently.
For example, if you tried to add this line of the code to the Update loop:
_grayscaleEffect.Effect.Parameters["DoesNotExist"].SetValue(0);
You will see this type of NullReference error when the project tries to start and draw the scene:
System.NullReferenceException: Object reference not set to an instance of an object.
Caution
Do not actually add the DoesNotExist sample, because it will break your code.
On its own this would not be too difficult to accept. However, MonoGame's shader compiler will aggressively remove properties that are not actually being used in your shader code. Even if you wrote a shader that had a DoesNotExist property, if it was not being used to compute the return value of the shader, it will be removed. The compiler is good at optimizing away unused variables.
To test this better and then update the code to handle this better, change the following in the grayscaleEffect.fx file, update the last few lines of the MainPS function to the following:
float4 MainPS(VertexShaderOutput input) : COLOR
{
// Sample the texture
float4 color = tex2D(SpriteTextureSampler, input.TextureCoordinates) * input.Color;
// Calculate the grayscale value based on human perception of colors
float grayscale = dot(color.rgb, float3(0.3, 0.59, 0.11));
// create a grayscale color vector (same value for R, G, and B)
float3 grayscaleColor = float3(grayscale, grayscale, grayscale);
// Linear interpolation between he grayscale color and the original color's
// rgb values based on the saturation parameter.
float3 finalColor = lerp(grayscale, color.rgb, Saturation);
// overwrite all existing operations and set the final color to white
finalColor.rgb = 1;
// Return the final color with the original alpha value
return float4(finalColor, color.a);
}
If you run the game, enter the GameScene, and wait for the game over screen to appear, you will see a NullReferenceException (and the game will hard-crash) when the greyscale effect is used, e.g. Game Over. The Saturation shader parameter no longer exists in the shader because it was stripped out, so when the Draw() method tries to set it, the game crashes.
Note
Leave this change in for now, to demonstrate the way to handle this error through extensions in the Material class.
Note
You may not actually see the NullReferenceException, because the DungeonSlime.csproj is configured to be a WinExe, which means the standard error and output of the application are hidden. The game may appear to just crash with no warning. You can change the OutputType to Exe, which means you will see the standard error directed to the terminal.
The aggressive optimization is good for your game's performance, but when combined with the hot-reload system, it will lead to unexpected bugs. As you iterate on shader code, it is likely that at some point a shader parameter will be optimized out of the compiled shader. The hot-reload system will automatically load the newly compiled shader, and if the C# code attempts to set the previously available parameter, the game may crash.
To solve this problem, the Material class can encapsulate the setting of shader properties and handle the potential error scenario.
Note
The Effect.Parameters variable is an instance of the EffectParameterCollection class.
For reference, here is what the EffectParameterCollection class looks like from the MonoGame Source:
public class EffectParameterCollection : IEnumerable<EffectParameter>, IEnumerable
{
internal static readonly EffectParameterCollection Empty = new EffectParameterCollection(new EffectParameter[0]);
private readonly EffectParameter[] _parameters;
private readonly Dictionary<string, int> _indexLookup;
// ...
MonoGame is free and open source, so the entire source code is always available online, EffectParameterCollection.
The _indexLookup is a Dictionary<string, int> contains a mapping of property name to parameter, the Dictionary class has methods for checking if a given property name exists, but unfortunately we cannot access it due to the private access modifier.
Luckily, the entire EffectParameterCollection inherits from IEnumerable, so we can use the existing dotnet utilities to convert the entire structure into a Dictionary. Once we have the parameters in a Dictionary structure, we will be able to check what parameters exist before trying to access them, thus avoiding potential NullReferenceExceptions.
Add this new property to the
Materialclass:/// <summary> /// A cached version of the parameters available in the shader /// </summary> public Dictionary<string, EffectParameter> ParameterMap;Along with the corresponding
usingstatements we will need for the implementation:using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework.Graphics; using MonoGameLibrary.Content;Now you can add the following method that will convert the
EffectParameterCollectioninto the newDictionaryproperty:/// <summary> /// Rebuild the <see cref="ParameterMap"/> based on the current parameters available in the effect instance /// </summary> public void UpdateParameterCache() { ParameterMap = Effect.Parameters.ToDictionary(p => p.Name); }And we must not forget to invoke this method during the constructor of the
Material:public Material(WatchedAsset<Effect> asset) { Asset = asset; UpdateParameterCache(); }With the new
ParameterMapproperty, create an additional helper function that checks if a given parameter exists in the shader:/// <summary> /// Check if the given parameter name is available in the compiled shader code. /// Remember that a parameter will be optimized out of a shader if it is not being used /// in the shader's return value. /// </summary> /// <param name="name">The parameter name</param> /// <param name="parameter">The effect parameter if found</param> /// <returns>True if the parameter was found, otherwise false</returns> public bool TryGetParameter(string name, out EffectParameter parameter) { return ParameterMap.TryGetValue(name, out parameter); }And another helper method that sets a parameter value for the
Material, but will not crash if the parameter does not actually exist:/// <summary> /// Set a float parameter on the shader /// </summary> /// <param name="name">The parameter name</param> /// <param name="value">The float value to set</param> public void SetParameter(string name, float value) { if (TryGetParameter(name, out var parameter)) { parameter.SetValue(value); } else { Console.WriteLine($"Warning: cannot set parameter=[{name}] as it does not exist in the shader=[{Asset.AssetName}]"); } }Now, instead of setting the
Saturationfor the_grayscaleEffectmanually as before, update theDraw()code to use the new method in theGameSceneclass, replacingSetValuewithSetParameteron theMaterialsafely:// We are in a game over state, so apply the saturation parameter. _grayscaleEffect.SetParameter("Saturation", _saturation);
To verify it is working, re-run the game, and instead of seeing a crash, you should see the GameScene's game over menu show a completely white background. This is because the shader is setting the finalColor to 1. Remove the finalColor.rgb = 1; line from the grayscaleEffect.fx shader, wait for the hot-reload system to kick in, and the game should return to normal.
| Figure 3-1: The game does not crash |
Note
Make sure to remove the change to the grayscaleEffect.fx file if you want your program to continue working as normal, it was only a test (not a trap).
Reloading Properties
When the hot-reload system loads a new compiled shader into the game's memory, the new shader does not have any of the shader parameter values that the previous shader instance had. To demonstrate the problem, we will purposefully break the _grayscaleEffect a bit.
For now, comment out the line the
GameScene'sDraw()method to prevent the parameter being set in every draw call (as it should be):// _grayscaleEffect.SetParameter("Saturation", _saturation);Instead, add the following line to the end of the
LoadContent()method:// Load the grayscale effect _grayscaleEffect = Content.WatchMaterial("effects/grayscaleEffect"); _grayscaleEffect.SetParameter("Saturation", 1);
The net outcome is that the _grayscaleEffect will not actually work for its designed purpose, instead it is now fully saturated with a value of 1, and will never change.
Run the game, enter the GameScene, and hit the pause button, the greyscale effect does not seem to apply and the background remains in color. Now, if you cause any reload of the shader (editing a comment line and saving it), the background of the GameScene will immediately desaturate and switch to grayscale. The newly compiled shader instance has no value for the Saturation parameter (because it is not being updated as normal in the Draw method), and since 0 is the default value for numbers, it appears grayscale, forever (or until the next run).
To solve this problem, the Material class can encapsulate the handling of applying new hot-reload updates. Anytime a new shader is available to swap in, the Material class needs to handle re-applying the old shader parameters to the new instance.
Add the following method to the Material class:
public void Update()
{
if (Asset.TryRefresh(out var oldAsset))
{
UpdateParameterCache();
foreach (var oldParam in oldAsset.Parameters)
{
if (!TryGetParameter(oldParam.Name, out var newParam))
{
continue;
}
switch (oldParam.ParameterClass)
{
case EffectParameterClass.Scalar:
newParam.SetValue(oldParam.GetValueSingle());
break;
default:
Console.WriteLine("Warning: shader reload system was not able to re-apply property. " +
$"shader=[{Effect.Name}] " +
$"property=[{oldParam.Name}] " +
$"class=[{oldParam.ParameterClass}]");
break;
}
}
}
}
And now instead of using the TryRefresh() method directly on the _grayscaleEffect, use the new Update() method in the GameScene.Update() call:
public override void Update(GameTime gameTime)
{
// Update the grayscale effect if it was changed
_grayscaleEffect.Update();
// Ensure the UI is always updated
_ui.Update(gameTime);
If you repeat the same test as before, the game will not become grayscale after a new shader is loaded. Once you have validated this, make sure to undo the changes made earlier in the LoadContent() and Draw() methods, so that the _grayscaleEffect will use the Saturation value in the Draw() method as intended as it remembers the state of the material BEFORE it was reloaded.
Note
Do not forget to UNDO the changes made at the beginning of this section and restore the original SetParameter call for the _grayscaleEffect material. (and "obviously" remove it from LoadContent, but that goes without saying.)
Debug Builds
When the DungeonSlime game is published, it would not make sense to run the new Material.Update() method, because no shaders would ever be hot-reloaded in a release build. We can strip the method from the game when it is being built for Release. Add the following attribute to the Material.Update() method:
[Conditional("DEBUG")]
public void Update()
{
// implementation left out for brevity
}
And add the required using statement as well:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Microsoft.Xna.Framework.Graphics;
using MonoGameLibrary.Content;
The built DungeonSlime executable will no longer contain the compiled code for the Material.Update() method, or any place in the code that invoked the method. This means that the hot-reload system will never attempt to read the file timestamps of your .xnb files. There is still a tiny cost for keeping the extra fields on the WatchedAsset type, rather than using the Effect directly. However, given the huge wins for your shader development workflow, paying the memory cost for a few mostly unused fields is a worthwhile trade-off.
Tip
Adding the [Conditional] attribute is optional. It will only slightly increase the performance of your game, and disable Material hot-reload for released games automatically.
Supporting more Parameter Types
In the previous sections, we have only dealt with the grayscaleEffect.fx shader, and that shader only has a single shader parameter called Saturation. The Saturation parameter is a float, and so far, the Material class only handles float based parameters. However, shaders may have many more types of parameters. In this tutorial, we will add support for the following parameter types, and leave it as an exercise to the reader to add support for more.
Matrix,Vector2,Texture2D
Add the following methods to the Material class:
public void SetParameter(string name, Matrix value)
{
if (TryGetParameter(name, out var parameter))
{
parameter.SetValue(value);
}
else
{
Console.WriteLine($"Warning: cannot set shader parameter=[{name}] because it does not exist in the compiled shader=[{Asset.AssetName}]");
}
}
public void SetParameter(string name, Vector2 value)
{
if (TryGetParameter(name, out var parameter))
{
parameter.SetValue(value);
}
else
{
Console.WriteLine($"Warning: cannot set shader parameter=[{name}] because it does not exist in the compiled shader=[{Asset.AssetName}]");
}
}
public void SetParameter(string name, Texture2D value)
{
if (TryGetParameter(name, out var parameter))
{
parameter.SetValue(value);
}
else
{
Console.WriteLine($"Warning: cannot set shader parameter=[{name}] because it does not exist in the compiled shader=[{Asset.AssetName}]");
}
}
And then in the Material.Update() method, change the switch statement to handle the following cases:
switch (oldParam.ParameterClass)
{
case EffectParameterClass.Scalar:
newParam.SetValue(oldParam.GetValueSingle());
break;
case EffectParameterClass.Matrix:
newParam.SetValue(oldParam.GetValueMatrix());
break;
case EffectParameterClass.Vector when oldParam.ColumnCount == 2: // float2
newParam.SetValue(oldParam.GetValueVector2());
break;
case EffectParameterClass.Object:
newParam.SetValue(oldParam.GetValueTexture2D());
break;
default:
Console.WriteLine("Warning: shader reload system was not able to re-apply property. " +
$"shader=[{Effect.Name}] " +
$"property=[{oldParam.Name}] " +
$"class=[{oldParam.ParameterClass}]");
break;
}
And an additional using to cover the extra types used:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MonoGameLibrary.Content;
Conclusion
Excellent work! Our new Material class makes working with shaders much safer and more convenient. In this chapter, you accomplished the following:
- Created a
Materialclass to encapsulate shader effects and their parameters. - Solved the
NullReferenceExceptionthat can happen when the compiler optimizes away unused parameters. - Handled the state of shader parameters so they are automatically reapplied during a hot-reload.
- Added support for multiple parameter types like
Matrix,Vector2, andTexture2D.
Now that we have a solid and safe foundation for our effects, we will make them easier to tweak. In the next chapter, we will build a real-time debug UI that will let us change our shader parameters with sliders and buttons right inside the game!
You can find the complete code sample for this chapter - here.
Continue to the next chapter, Chapter 04: Debug UI.