Guide to creating your own shaders in Unreal Engine

Original author: Tommy Tran
  • Transfer
  • Tutorial
image

Thanks to the node system, the material editor is an excellent tool for creating shaders. However, it has its limitations. For example, it is not possible to create switch loops and constructions there.

Fortunately, you can get around these limitations by writing your own code. To do this, there is a Custom node that allows you to write HLSL code.

In this tutorial you will learn the following:

  • Create Custom nodes and configure its inputs
  • Convert Material Nodes to HLSL
  • Modify shader files with an external text editor
  • Create HLSL Functions

To demonstrate all of these features, we will use HLSL to reduce the saturation of the scene image, output various scene textures, and create Gaussian blur.

Note: this implies that you are already familiar with the basics of using Unreal Engine. If you're new to Unreal Engine, then check out our ten-part series of Unreal Engine tutorials for beginners .

The tutorial also assumes that you are familiar with C-like languages ​​like C ++ or C #. If you are familiar with syntactically similar languages, such as Java, then you too can figure it out.

Note: this tutorial is part of a series of tutorials on shaders:


Getting to work


Start by downloading the materials in this tutorial (download them here ). Unzip them, go to CustomShadersStarter and open CustomShaders.uproject . You will see the following scene:


First, we will use HLSL to reduce the saturation of the scene image. To do this, we need to create and apply the Custom node in the post-processing material.

Creating a Custom Node


Go to the Materials folder and open PP_Desaturate . We will edit this material to get the effect of reducing the saturation.


To get started, create a Custom node . Like other nodes, it can have several inputs, but only one output.


Then select the Custom node and go to the Details panel. You will see the following:


Here is what each of the properties does:

  • Code: here we put our HLSL code
  • Output Type: the output can range from a single value ( CMOT Float 1 ) to a four- channel vector ( CMOT Float 4 ).
  • Description: the text that will be displayed on the node itself. This is a good way to name Custom nodes. Type Desaturate here .
  • Inputs: here you can add and call the contacts of the inputs. Then, using these names, we can refer to these inputs in the code. Give input 0 the name SceneTexture .


To reduce image saturation, replace the text inside Code with the following:

return dot(SceneTexture, float3(0.3,0.59,0.11));

Note:dot() - This is a predefined function . Such features are built into HLSL. If you need a function like atan()or lerp(), first check to see if there is already such a predefined function.

Finally, connect everything as follows:


Summarize:

  1. SceneTexture: PostProcessInput0 will output the color of the current pixel
  2. Desaturate will receive color and reduce its saturation. Then it displays the result in Emissive Color

Click on Apply and close PP_Desaturate . Now the saturation of the scene image will be reduced.


You may be wondering where the saturation reduction code came from. When we use a material node, it is converted to HLSL. If you look at the generated code, you can find the corresponding fragment and copy-paste it. This is how I converted the Desaturation node to HLSL.

In the next section, we will learn how to convert a material node to HLSL.

Convert Material Nodes with HLSL


In this tutorial, we will convert the SceneTexture node to the HLSL . This will come in handy later when we create the Gaussian blur.

To get started, go to the Maps folder and open GaussianBlur . Then go back to Materials and open PP_GaussianBlur .


Unreal generates HLSL for all nodes involved in the final output. In our case, Unreal will generate HLSL for the SceneTexture node .

To view the HLSL code of all material, select Window \ HLSL Code . This will open a separate window with the generated code.


Note: if the HLSL Code window is empty, you will need to enable it in the Toolbar Live Preview .


Since the generated code has a length of several thousand lines, it is quite difficult to navigate through it. To simplify the search, click on the Copy button and paste the code into a text editor (I use Notepad ++ ). Then close the HLSL Code window.

Now we need to find where the SceneTexture code is located . The easiest way to do this is by finding a definition CalcPixelMaterialInputs(). This is a function in which the engine calculates all material yields. If you look at the bottom of the function, you can see the final values ​​for each output:

PixelMaterialInputs.EmissiveColor = Local1;
PixelMaterialInputs.Opacity = 1.00000000;
PixelMaterialInputs.OpacityMask = 1.00000000;
PixelMaterialInputs.BaseColor = MaterialFloat3(0.00000000,0.00000000,0.00000000);
PixelMaterialInputs.Metallic = 0.00000000;
PixelMaterialInputs.Specular = 0.50000000;
PixelMaterialInputs.Roughness = 0.50000000;
PixelMaterialInputs.Subsurface = 0;
PixelMaterialInputs.AmbientOcclusion = 1.00000000;
PixelMaterialInputs.Refraction = 0;
PixelMaterialInputs.PixelDepthOffset = 0.00000000;

Since this is post-processing material, only EmissiveColor is important to us . As you can see, its value is the value of Local1 . Variables of the form LocalX are local variables that the function uses to store intermediate values. If you look just above the outputs, you can see how the engine calculates each local variable.

MaterialFloat4 Local0 = SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 14), 14, false);
MaterialFloat3 Local1 = (Local0.rgba.rgb + Material.VectorExpressions[1].rgb);

The final local variable (in our case, Local1 ) is usually a dummy calculation, so it can be ignored. This means that the function for the SceneTexture node is the function SceneTextureLookup().

Now that we have the right function, let's test it.

Using the SceneTextureLookup Function


First, let's ask ourselves - what do the parameters do? Here is the signature SceneTextureLookup():

float4 SceneTextureLookup(float2 UV, int SceneTextureIndex, bool Filtered)

Here is what each parameter does:

  • UV: The UV coordinate from which to sample. For example, UV with coordinates (0.5, 0.5) will sample the average pixel.
  • SceneTextureIndex: determines from which scene texture to sample. Below is a table of each scene texture and its index. For example, to sample Post Process Input 0, we will use 14 as the index .
  • Filtered: determines whether bilinear filtering should be applied to the scene texture. Usually set to false .


For testing, we will output World Normal. Go to the material editor and create a Custom node named Gaussian Blur . Then insert the following into the Code field :

return SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 8), 8, false);

So we will output World Normal into the current pixel. GetDefaultSceneTextureUV()will get the UV of the current pixel.

Note: prior to version 4.19, it was possible to obtain UV by passing TextureCoordinate nodes as an input . In 4.19, the correct way would be to use GetDefaultSceneTextureUV()and pass the desired index.

This is an example of how hand-written HLSL code may not be compatible with different versions of Unreal.

Далее отсоедините нод SceneTexture. Затем присоедините Gaussian Blur к Emissive Color и нажмите на Apply.


На этом этапе вы получите следующую ошибку:

[SM5] /Engine/Generated/Material.ush(1410,8-76):  error X3004: undeclared identifier 'SceneTextureLookup'

She tells us that in our material does not exist SceneTextureLookup(). Why does this work when using the SceneTexture node, but does not work with the Custom node? When using SceneTexture, the compiler includes a definition in the code SceneTextureLookup(). Since we do not use this node, we cannot use the function.

Fortunately, solving this problem is simple. Select the same texture for the SceneTexture node that we are sampling from. In our case, you need to select WorldNormal .

Then connect it to the Gaussian Blur . Finally, we need to give the login contact a name different from None . In this tutorial we will choose SceneTexture .


Note: at the time of writing, there was a bug in the engine: if the scene textures are not the same, the editor crashes. However, since this works, we can safely change the texture of the scene in the Custom node.

Now the compiler will include the definition SceneTextureLookup().

Click Apply and return to the main editor. Now you will see the normal of the world for each pixel.


While editing the code in the Custom node is quite convenient, because we work with small fragments. However, when our code begins to grow, it will be more difficult to maintain it.

To optimize the workflow, Unreal allows us to add external shader files. Thanks to this, we can write code in our own text editor, and then return back to Unreal for compilation.

Using External Shader Files


First we need to create a Shaders folder . Unreal will view this folder when you use the directive in the Custom node #include.

Open the project folder and create a new Shaders folder . The project folder should look something like this:


Then go to the Shaders folder and create a new file. Name it Gaussian.usf . It will be our shader file.


Note: shader files must have the extension .usf or .ush .

Open Gaussian.usf in a text editor and paste the code shown below. After each change, save the file.

return SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 2), 2, false);

This is the same code as before, but it displays Diffuse Color .

In order for Unreal to recognize the new folder and shaders, we need to restart the editor. After restarting, go to GaussianBlur . Then reopen PP_GaussianBlur and replace the code in Gaussian Blur with the following:

#include "/Project/Gaussian.usf"
return 1;

Now, after compilation, the compiler will replace the first line with the contents of Gaussian.usf . Note that we should not replace with the Projectname of our project.

Click on Apply and return to the main editor. Now, instead of the normals of the world, you will see diffuse colors.


Now that everything is set up for convenient shader development, it's time to create a Gaussian blur.

Note: since this is not a Gaussian blur tutorial, I will not explain it in detail. If you want to know the details, check out the Gaussian Smoothing and Calculating Gaussian Kernels articles .

Create Gaussian Blur


As in the tutorial on toon contours, a convolution will be used in this effect. The final output is the average of all the pixels in the core.

With ordinary linear blur, all pixels have the same weight. With wide blur, this leads to artifacts. Gaussian blurring avoids this by reducing the weight of the pixel as it moves farther away from the center. This gives more importance to the center pixels.


When using material nodes, convolution is imperfect due to the large number of required samples. For example, with a 5 × 5 core, we need 25 samples. Double the size to 10 × 10 and the number of samples will increase to 100! At this point, the node graph will look like a plate of spaghetti.

And here the Custom node comes to our aid. With it, we can write a small loop forsampling every pixel in the kernel. The first step is to set a parameter that controls the sampling radius.

Create radius parameter


First, back to the material editor and create a new ScalarParameter called Radius . Set it to the default value of 1 .


The radius determines the level of image blur.

Next, create a new entry for Gaussian Blur and name it Radius . Then create a Round node and connect everything as follows:


Round is necessary to ensure that the size of the kernel is always integers.

Now it's time to start coding! Since for each pixel we need to calculate the Gaussian blur twice (vertical and horizontal offsets), it will be logical to turn this into a function.

When using the Custom node, we cannot create functions in the standard way, because the compiler copies our code into the function. Since we cannot define functions inside a function, we get an error.

Fortunately, we can use such copy-paste to create global functions.

Creating Global Functions


As stated above, the compiler literally copies the text from the Custom node to the function. That is, if we have the following:

return 1;

then the compiler will insert this into the CustomExpressionX function . He will not even indent!

MaterialFloat3 CustomExpression0(FMaterialPixelParameters Parameters)
{
return 1;
}

See what happens if we use this code:

    return 1;
}
float MyGlobalVariable;
int MyGlobalFunction(int x)
{
    return x;

The generated HLSL will turn into this:

MaterialFloat3 CustomExpression0(FMaterialPixelParameters Parameters)
{
    return 1;
}
float MyGlobalVariable;
int MyGlobalFunction(int x)
{
    return x;
}

As you can see, MyGlobalVariableand MyGlobalFunction()not within the function. This makes them global, that is, we can use them anywhere.

Note: Note that the last bracket is missing in the input code. This is important because the compiler inserts a bracket at the end. If you leave the bracket, then as a result we will have two brackets and we will get an error.

Now let's use this behavior to create a Gaussian function.

Creating a Gaussian Function


The function for a simplified Gaussian in one dimension looks like this:


As a result, it gives a bell-shaped curve that receives input values ​​in the range from about -1 to 1. It outputs a value from 0 to 1 to the output.


In this tutorial, we put the Gaussian function in a separate Custom node. Create a new Custom node and name it Global .

Then replace the Code text with the following:

    return 1;
}
float Calculate1DGaussian(float x)
{
    return exp(-0.5 * pow(3.141 * (x), 2));

Calculate1DGaussian()- This is a simplified 1D-Gaussian in the form of code.

For this feature to be available, we need to use Global somewhere in the material graph. The easiest way to do this is by multiplying Global by the first node of the graph. So we guarantee that global functions are defined before we use them in other Custom nodes.

First, set the Output Type of the Global node to CMOT Float 4 . We must do this because we will be doing multiplication by a SceneTexture of type float4 .


Next, create Multiply and connect everything as follows:


Click on Apply to compile. Now all subsequent Custom nodes will be able to use the functions defined in Global .

The next step is to use a loop forto sample each pixel in the kernel.

Multiple Pixel Sampling


Open Gaussian.usf and replace the code with the following:

static const int SceneTextureId = 14;
float2 TexelSize = View.ViewSizeAndInvSize.zw;
float2 UV = GetDefaultSceneTextureUV(Parameters, SceneTextureId);
float3 PixelSum = float3(0, 0, 0);
float WeightSum = 0;

Here is what each of the variables is for:

  • SceneTextureId: contains the texture index of the scene that we want to sample. Thanks to it, we are not required to hardcode the index in function calls. In our case, the index is used for Post Process Input 0 .
  • TexelSize: contains the texel size. Used to convert offsets to UV space.
  • UV: UV for the current pixel
  • PixelSum: used to accumulate the color of each pixel in the core
  • WeightSum: used to accumulate the weight of each pixel in the kernel

Next, we need to create two cycles for, one for vertical displacements, the other for horizontal ones. Add the following under the list of variables:

for (int x = -Radius; x <= Radius; x++)
{
    for (int y = -Radius; y <= Radius; y++)
    {
    }
}

This way we create a grid centered on the current pixel. Its dimensions are set as 2r + 1 . For example, if the radius is 2 , then the grid will have dimensions (2 * 2 + 1) by (2 * 2 + 1) or 5 × 5 .

Next, we need to accumulate the colors and weights of the pixels. To do this, add the following code to the inner loop for:

float2 Offset = UV + float2(x, y) * TexelSize;
float3 PixelColor = SceneTextureLookup(Offset, SceneTextureId, 0).rgb;
float Weight = Calculate1DGaussian(x / Radius) * Calculate1DGaussian(y / Radius);
PixelSum += PixelColor * Weight;
WeightSum += Weight;

Here is what each line does:

  1. Вычисляет относительное смещение сэмплируемого пикселя и преобразует его в UV-пространство
  2. На основе смещения сэмплирует текстуру сцены (в нашем случае это Post Process Input 0)
  3. Вычисляет вес сэмплированного пикселя. Для вычисления 2D-гауссианы достаточно перемножить две 1D-гауссианы. Деление на Radius производится потому, что упрощённая гауссиана ожидает на входе значение от -1 до 1. Это деление нормализует x и y в нужном интервале.
  4. Прибавляет взвешенный цвет к PixelSum
  5. Прибавляет вес к WeightSum

Наконец, нам нужно вычислить результат, являющийся взвешенным средним. Для этого добавим в конец файла (за пределами циклов for) следующее:

return PixelSum / WeightSum;

And so we realized the Gaussian blur! Close Gaussian.usf and return to the material editor. Click on Apply and close PP_GaussianBlur . Use PPI_Blur to test different blur radii.


Note: sometimes the Apply button may be inactive. Just make a change that does not affect anything (for example, move the node), and it will become active again.

Limitations


Despite the power of the Custom node, it has its drawbacks. In this section, I will talk about some of the limitations and limitations of its use.

Access to rendering


Custom nodes cannot access many parts of the rendering pipeline, for example, information about lighting and motion vectors. When using direct rendering, the situation is slightly different.

Engine Version Compatibility


HLSL code written in one version of Unreal does not necessarily work in another. As stated in the tutorial, before version 4.19, we could use TextureCoordinate to get the UV texture of the scene . In version 4.19, you need to use this GetDefaultSceneTextureUV().

Optimization


Here's what Epic says about optimization:

Использование нодов Custom делает невозможным сворачивание констант и может приводить к значительно большему количеству инструкций по сравнению с аналогичной версией, построенной на нодах! Сворачивание констант — это оптимизация, используемая UE4 для снижения при необходимости количества шейдерных инструкций.

Например, цепочка выражений Time >Sin >Mul by parameter > Add к чему-то может и будет свёрнута движком UE4 в одну инструкцию, конечное Add. Это возможно, потому что все входы этого выражения (Time, parameter) являются константами на протяжении всего вызова отрисовки, то есть не меняются для каждого пикселя. UE4 не может сворачивать ничего в ноде Custom, что может приводит к созданию менее эффективных шейдеров по сравнению с аналогичными версиями на основе готовых нодов.

Therefore, it is best to use a Custom node only when it provides access to functionality that is not available in ready-made nodes.

Where to go next?


The finished project can be downloaded here .

If you want to understand the Custom node more deeply, I recommend that you study Ryan Brook's blog . He has posts explaining in detail the use of the Custom node to create raymarching and other effects.

Also popular now: