How the Unreal Engine Translates a Material Graph to HLSL

How the Unreal Engine Translates a Material Graph to HLSL

Article written by Matt O.

This article will shed light on the process by which Unreal translates a material graph into the HLSL code which is ultimately used to compile the shaders for a given material.

When diving into the nitty gritty of materials, or working with the Custom material expression (which lets you write your own HLSL directly), it’s helpful to understand what happens after you hit the “Apply” button in the Material Editor. How does that network of nodes turn into something that the GPU can use? There’s two key parts: 1. Translating the material graph to HLSL and 2. Compiling the HLSL into the bytecode that the GPU needs.

For the purposes of this article (and the series as a whole), it is important to to understand the former process, but not necessary to go into great detail about the process of converting the translated HLSL into the machine code used by the GPU. Matt Pettineo, Lead Graphics and Engine Programmer at Ready at Dawn recently posted a great series of articles titled The Shader Permutations Problem on his blog that goes into more detail about what happens to the HLSL files on their way to the GPU.

When you hit “Apply” in the material editor, Unreal kicks off a process that gathers information about your blend mode, shading model, and usages, as well as all the nodes that you’ve created to describe your surface. This information is then inserted into certain points in a template which then creates a recipe for compiling the necessary bytecode. These recipes, sometimes referred to as shaders, are written in HLSL, or High-Level Shading Language.

However Unreal doesn’t just create one shader for each material. There’ll be a few shaders created for each material depending on the rendering features, platforms, and the usages a material needs to support. You can learn more about shader permutations in Understanding Shader Permutations.

Overview

You can see the shader that a material generates by going to Window → Shader Code → HLSL Code. This is a read-only display, and you can copy it into something like Notepad++ with HLSL syntax highlighting, Rider, or Visual Studio (instructions for all of these options can be found in the references section) for further inspection and dissection.

The generated HLSL is based on Engine/Shaders/Private/MaterialTemplate.ush. In there you’ll see structures and functions with no inputs or bodies, just “%s”. That’s because behind the scenes Unreal is doing text replacements to drop in all the necessary code and values. These functions then get called by the pixel shader (BasePassPixelShader.usf), which is what ultimately handles the calculations to draw a given pixel on screen.

MaterialTemplate.ush is filled out by an FMaterialCompiler, which itself is an interface that gets implemented a few different ways depending on the situation (Standard, Lightmass, or Proxy). This article will focus on the standard case, which uses FHLSLMaterialTransltor, which inherits from FMaterialCompiler.

Unreal Engine kicks off the process to generate the HLSL through the following path:

FMaterial::CacheShaders

  • FMaterial::BeginCompileShaderMap
    • FHLSLMaterialTranslator::Translate
    • FHLSLMaterialTranslator::GetMaterialEnvironment
    • FHLSLMaterialTranslator::GetMaterialShaderCode
    • FPartialShaderMap::Compile

FHLSLMaterialTranslator::Translate handles preparing each material input pin’s shader graph along with ensuring that the resulting HLSL has all the data it needs under the hood (vertex interpolators, texture coordinates, material parameter collections, virtual texture samples, and so on). It also handles some error reporting for certain combinations of features and shading models, amongst other potential issues (for example, if you’re using per-pixel shading models with SingleLayerWater).

GetMaterialEnvironment sets defines that’ll be used in the final HLSL (for example, many shading models use #ifdefs to handle switching functionality, as well as certain shader features).

FHLSLMaterialTranslator::GetMaterialShaderCode then takes all that data and compiled functionality and prints it into a string using the template.

If everything above succeeded, Unreal will take the resulting HLSL and use it to further compile into the bytecode that’s used by the graphics hardware to actually draw the pixel. You can see this process in action in Tim Jones’ Shader Playground.

Turning the Graph into HLSL

For the purposes of this series of articles, the most important thing to understand about the material compilation process is how all the interconnected nodes in your material graph get turned into the HLSL that populates the various input pin functions (GetBaseColorRaw, etc.).

Starting at the most granular level, each of the differently-colored nodes in the Material Editor inherit from UMaterialExpression. The base class defines a few key methods, which are then overridden and implemented by each individual Material Expression.

  1. Compile
  2. GetCaption
  3. GetKeywords

In the engine source code, individual MaterialExpressions are declared in their own header files, and defined in Engine/Source/Runtime/Engine/Private/Materials/MaterialExpressions.cpp. Each material expression can define its own constant and graph inputs (FExpressionInput).

Compile

The one most pertinent to this discussion is the Compile function. Let’s take a look at UMaterialExpressionAdd::Compile.

int32 UMaterialExpressionAdd::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex)

{

// if the input is hooked up, use it, otherwise use the internal constant

int32 Arg1 = A.GetTracedInput().Expression ? A.Compile(Compiler) : Compiler->Constant(ConstA);

// if the input is hooked up, use it, otherwise use the internal constant

int32 Arg2 = B.GetTracedInput().Expression ? B.Compile(Compiler) : Compiler->Constant(ConstB);

return Compiler->Add(Arg1, Arg2);

}

This function exhibits a number of key features of the inner workings of a UMaterialExpression:

  1. The Compile method returns an int32. This is an array index which corresponds to the compiled result of a given expression.
  2. The Compile method only accepts an FMaterialCompiler as the argument Compiler. As mentioned before, there’s a few versions of these for certain use-cases, and the one most directly applicable to us is the FHLSLMaterialTranslator.
  3. The Compile method contains some logic to ensure that the user has input the correct values (and raises an error if not), and determines which inputs to use based on the state of the node’s input pins.
  4. If input pins have been connected, compile those first so the translator knows where the result is that it should be working with.

For the UMaterialExpressionAdd::Compile function, if nothing is input to either the A or B pins, this function will effectively return a “pointer” to the result of Compiler->Add(ConstA, ConstB). If any of the input pins are connected, then the compile function will return a pointer to the result of Compiler->Add(InputA,) and so on.

One step deeper is FHLSLMaterialTranslator::Add, which looks a bit more complicated than just formatting “%s + %s”. That’s because this is where the engine determines how to tell the shader to add A and B together. If, for example, both A and B are constant then the compiler will simply add those two values together ahead of time and just write to the shader “TheResultOfAPlusB = 3;”. If one or the other inputs are constant, and another input is a parameter, then Unreal will create an additional, hidden parameter called “MyParameterPlusTwo” that, on the CPU, will add your constant value to your parameter, so that all the GPU sees is a fixed, constant number. Finally, if both A and B are non-constant, the compiler will simply write out “TheResultOfAPlusB = A + B;”. This is what’s known as Constant Folding, an optimization to ensure that the GPU doesn’t have to waste cycles calculating values that don’t change from one pixel to the next.

This material graph will be used to show how each of those cases translates to HLSL.

The bulk of our graph ends up in the CalcPixelMaterialInputs, so Unreal can reuse calculations across different parts of the graph. Scrolling through this function, the relevant portions start at the comment “// Now the rest of the inputs”, which for this graph is excerpted below.

Starting from the bottom of the material graph, it looks like PixelMaterialInputs.Roughness compiles down to (1.00000000 + 2.00000000). Whether a constant values is created from the add node or from a standalone constant node is irrelevant, it just compiles down to this:

PixelMaterialInputs.Roughness = (1.00000000 + 2.00000000);

For Metallic, the HLSL is a single line pointing to a value in the Material.ScalarExpressions list.

PixelMaterialInputs.Metallic = Material.ScalarExpressions[0].z;

This is our constant-folded value of “MyParameterPlusOne” as was discussed earlier in this article. That comes from FHLSLMaterialTranslator::AccessUniformExpression, where the compiler determines which ScalarExpression to access using the index of the expression. Scalar expressions are packed into the Material.ScalarExpressions list as float4s. In the example of our Roughness value the uniform expression it’s looking for is the fifth one for the material. The first four are packed into Material.ScalarExpressions[0]’s x, y, z, and w values. How these values are packed will be explored further in an upcoming KB article Understanding the Implementation of Material Parameter Collections.

Finally, Base Color, which highlights an important feature of the translation process. The relevant lines for which are excerpted below:

MaterialFloat Local1 = (Parameters.TexCoords[0].xy.r + Material.ScalarExpressions[0].y);

// ...

PixelMaterialInputs.BaseColor = Local1;

For non-constant expressions, the translator will store the results of those in variables named “Local” plus a number. These expressions are then hashed so if you placed another TexCoords.r + MyParameter elsewhere in your node, the translator will know to reuse the results of Local1 instead of running the same calculation twice.

EmissiveColor

In your further explorations of the translated HLSL, you may notice that the results of your EmissiveColor graph don’t quite follow the above conventions. In the example above, even though nothing is connected to the EmissiveColor input pin, but the excerpted code for PixelMaterialInputs.EmissiveColor look like this:

MaterialFloat3 Local0 = lerp(MaterialFloat3(0.00000000,0.00000000,0.00000000),Material.VectorExpressions[1].rgb,MaterialFloat(Material.ScalarExpressions[0].x));

//...

PixelMaterialInputs.EmissiveColor = Local0;

This is because the engine has compiled a version of the material for use in the editor, and that version includes this lerp between the user’s desired emissive color (the translated results of everything connected to the Emissive Color input put) and the final VectorExpression in the material, and lerps between the two based on an engine-controlled value. This value is used for selection highlighting and is optimized away in cooked shaders.

References and Resources

HLSL Tools for Visual Studio

This plugin developed by Tim G Jones, is available for free and supports HLSL syntax highlighting in VS 2017/19/22.

Then add .usf and .ush to the list of supported file extensions for HLSL under Tools > Text Editor > File Extensions

3 Likes