Website powered by

Niagara Visualize Audio FX and Sample Submixes

Tutorial / 11 May 2023


Outline

  • Introduction

  • Particle System Initial Setup

  • Sample Audio

  • Sampled Value Uses Examples

  • Fade Sampled Audio

  • Sample from Custom Submix

  • Conclusion

---

Introduction

In this post I will cover some techniques I found useful when developing Niagara FX that reacts to audio, I made one that you can check out here → Equalizer FX - Niagara UE5
This post will cover a more simple setup, showing how you can create a basic Visualize Audio FX that can be useful for debugging purposes. One other thing I will also go over is how you can create your own custom Submix so that you can make the FX react only to the audio coming from a target Submix instead of using the built-in MasterSubmixDefault, which contains every sound that plays in the level.

The following video shows the final result of the Niagara System this tutorial will breakdown:

---

Particle System Initial Setup

Before starting to sample audio we need some particles. Below you can find the initial setup of the Emitter I've made and all the User Parameters I've exposed.

User Parameters

  • AudioOscilloscpe → target submix to sample audio from
  • AudioSpectrum → target submix to sample audio from
  • FadeSpeed → speed rate at which the sampled audio value should fade , explained in the "Fade Sampled Audio" section
  • isSpectrum → this is a boolean that switches the logic from using the AudioOscilloscope and the AudioSpectrum, two different methods to sample audio
  • VisualizerHeight → used to set the particles' height
  • VisualizerResolution → how many particles , which consequentially defines the resolution and precision of the sampled audio's visualization
  • VisualizerWidth → used to set the particles' width


Emitter Properties

The emitter is GPU based, make sure you set some Min and Max bounds



Emitter State

Life Cycle Mode is set to Self
Loop Behaviour to Once
Loop Duration Mode to Infinite



Spawn Particles in Grid

the particles are disposed on a horizontal line, to achieve this I've used Spawn Particles in Grid and Grid Location modules.
Y and Z Counts are set to 0, while X is defined by the VisualizerResolution User Parameter



Gird Location

The *Grid Location* module works hand-in-hand with the Spawn Particles in Grid.
There are some Z offset values, this is to move the particles up by half the size of the particles (defined by VisualizerHeight), doing so the particles will be placed above the Niagara System's Pivot Point.
For the Dimensions, I'm doing a simple operation on X so that the width's result perfectly matches the VisualizerWidth User Parameter in units. Using the VisualizerWidth value directly doesn't give a perfect result, it's going to be wider by one particle width.

What the X Dimensions fixes:
Before → the particles are wider than 256 units and there is a small gap between them


After → the particles perfectly match the 256 units in width and there is no gap between them



Initialize Particle

For the Initialize Particle module, the most important part is to set the Mesh Scale X and Z.
Z is simply using VisualizerHeight User Parameter, 
To calculate the correct size in X I'm dividing VisualizerWidth by VisualizerResolution.
Both of them are divided by 100 because the mesh used is 1x1x1 meter, the division gives us the value in millimeters.
I'm also doing a little trick on the colour just to get a random value for each particle and distinguish them from one to another.


Set Particles Attribute UVs

I made 3 custom Particle Attributes, the first one is a Vector2D and is called UVs.
This is a useful technique to get a 0-1 gradient value when using the Spawn Particles in Grid and Grid Location modules, they output a GridUVW value, which we can store in the particles.
In this FX I'm actually only using X, so you could store it to a Float instead of a Vector2D.

This is what you would get if the particles' colour was set to the U value


Set Particles Attribute AudioSpectrumLerp and AudioOscilloscopeBufferLerp

The other 2 attributes are called AudioSpectrumLerp and AudioOscilloscopeBufferLerp, they will get used later in the Fade Sampled Audio section.
They are both set to 0 just to get them initialised, if this isn't done when they get referenced the Emitter will have to give compiler errors.


Particle State

This is the only module in "Particle Update" section for now, here I just turned off *Kill Particles When Lifetime Has Elapsed*, this way the particles are persistent once spawned.


Mesh Renderer

In the Renderer I've added a Mesh Renderer and removed the Sprite one.
I'm just using the engine's Cube which is 1x1x1 meters and overwriting the material with M_VisualizeAudio

M_VisualizeAudio is just an unlit Material that uses The Particle Colour as Emissive.


---

Sample Audio

Now the Emitter is ready to go and we can start working on the Sample Audio Module.
The module is added to the Emitter in the Particle Update section and has a Map Get node with the following inputs:

  • New Audio Spectrum → Audio Spectrum → uses the matching User Parameter shown previously in the post
  • New Audio Oscilloscope → Audio Oscilloscope → uses the matching User Parameter shown previously in the post
  • UVs → vector2D → uses the particle's UV Attribute shown previously in the post
  • Sensitivity → float → could also be exposed as a User Parameter

Both the Audio Spectrum and Audio Oscilloscope get sampled using the Audio Spectrum and Sample Audio Buffer nodes respectively which are also using the U value of the particles' UVs Attribute for sampling.
Then they get multiplied with the Sensitivity float input to change their intensity, I'm also multiplying the Audio Oscilloscope by an extra value of 10 because I noticed that its result is generally smaller compared with the Audio Spectrum's.
Then I'm clamping the values between 0 and 1 with Saturate nodes and set them as new Particles Attributes

Looking back I think I would have added the switch logic here, so that the module only sets one attribute and based on a Boolean it switches from using the Audio Spectrum and Oscilloscope. This is especially useful if you need to reference the sampled audio attribute in multiple modules in the FX.

---

Sampled Value Uses Examples

Now you can use the sampled audio value as a lerp to animate the particles in various ways, a very simple example is to just change their colour.
Below you can see how it can be used in a Color module to animate their brightness, in this case I've done it with a multiply operation but you can lerp between 2 distinctive colours as well with a lerp. The logic also includes if it should be using the Audio Spectrum or Oscilloscope sampled value.

Result:

For my Equalizer Classic FX I've used the sampled audio value to scale and offset particles as well.

---

Fade Sampled Audio

A technique I found useful is to fade out the sampled value over time so that it slowly reaches 0 instead of being hard set to it when there is no sound playing.
I've duplicated the emitter and moved the particles down from the Grid Location module, so that I can compare the 2 results.

The module takes the Sampled Audio and Lerp attributes for both the Audio Spectrum and Oscilloscope. These could actually be hard referenced within the module, but in this case I've exposed them as inputs and manually input them in the Module's Emitter.
The module also takes the User Parameter FadeSpeed.


The logic looks a little bit convoluted but what it's doing is comparing the Sampled audio value with the Lerp one.
If the sampled audio is bigger the lerp value it gets hard set to it, so that they match.
Otherwise a small float amount (FadeSpeed) chips away from it with a subtraction operation, so that it slowly reaches 0 and when it does the value keeps getting set to 0 until a new sound is heard.
On the right the same logic is duplicated, but applied to the Oscilloscope attributes.

Result:

If you know a better way to achieve this please let me know!

---

Sample from Custom Submix

At the moment when the Niagara System is placed in the level, I've always used the MasterSubmixDefault as the input for the Audio Oscilloscope and Spectrum sampler, which is what every tutorial online tells you to use.
The problem I had with this is that every sound that is played in the level is sent to the MasterSubmixDefault, which means you can't use it when you have multiple sounds playing in the level and you want the FX to react to only specific cues.
I've never really used audio much in Unreal so this was all new for me, I wanted to share my findings because I noticed there isn't much information online.

To achieve this you need to set up your own Submixes, for testing purposes I created 3 of them:

  • MasterSubmix → my new custom Master Submix
  • BackgroundSubmix → Submix that will contain background noise sounds
  • MusicSubmix → the Submix that will contain the Music I want my FX to react to

I've then connected them as follows inside the MasterSubmix

One important thing to do is to change the Project's default Master Submix in the Project Settings

Now we need to tell the various Cue assets to which Submix they should be sent to. This can be done by creating Classes Actors that then get applied to the Sound Cue Assets.
Same as the Submixes, I made 3 of them:

  • SC_Master
  • SC_Background
  • SC_Music

I've connected them as follows but I noticed that it doesn't really matter, so I'm not sure what this actually does (let me know if you do!)

However what you do need to do is to change their Default Submix to the matching one for both SC_Background and SC_Music

Now you can open up the Sound Cue Assets and set their Class based on what Submix you want to send them to.

I think this is the approach you would want to follow when working on a big project, having Sound Classes feels like the way to go to better manage sound.
However, I found you can actually set the Submix directly in each Sound Cue asset avoiding having to set up Sound Classes, if this parameter is set then the value overwrites the Class' settings.

The main issue I've encountered doing this is that when everything was setup when I was feeding my custom Submix to the Niagara FX the particles wouldn't react at all.
I posted on Unreal's Forum about it and I have to thank @James.Wizard for replying with a solution.

Turns out the Submixes have the option to get automatically disabled when they are silent as a CPU optimization, the problem is that they should get re-enabled if the sound is sent to them, but it doesn't look like that's happening because by turning off the feature the Niagara FX starts reacting as expected (this project was done using UE5.1.1)


In the video below there are 3 Niagara systems, and each of them is assigned to a different Submix

---

Conclusion

I hope you found this tutorial useful, if you do create an FX that is driven by audio please share it with me I would love to see it!
Thanks for reading and have a great rest of the day!


Report

Simple Billboard Baker Tool - Documentation & Dev Log - Unreal Engine

Article / 20 April 2023

Check out the project if you haven't seen it yet:
Simple Billboard Baker - Unreal Engine

You can get the plugin from here:
Simple Billboard Baker Tool - Unreal Engine

Outline

  • Introduction
  • Setup
  • How to Use it
  • How it Works and Encountered Issues (dev log)
  • Wishlist Features
  • Conclusion
  • Resources

---

Introduction

I recently picked up again a Vines Roots Generator tool I worked on years ago, to finish developing some features and went down a rabbit hole when looking into the Spline Thicken feature, I wanted to bake a branch texture.

I also recently tried to use Ryan Brucks' Impostor Baker, I found it to be overengineered for just baking general billboards, so I wanted to make a simpler version, but I did take inspiration from it.
Another thing that bothered me about his Impostor Baker is a couple of its limitations that I wanted to get away from:

  1. The input only takes single static meshes 
    It's mostly fine for simple assets like foliage, but what if you are trying to bake, for example, a complex building made out of multiple instanced meshes? 
    I managed to get around this limitation by using the Merge Actors tool, but I think using SceneCapture2D together with Render Targets and Post Process Materials would make my baker more versatile
  2. To make it work you need to add a material function to each material used by the static mesh 
    Using SceneCapture2D and RT also helps with this limitation, because it allows you to bake what Unreal is rendering directly. Black pixels bleeding from the alpha edge might cause problems tho, I will need to look into doing some dilation.


If you need to render GBuffer Layers for some quick and easy captures you could also look into the following console commands to dump the GBuffer Layers:

r.BufferVisualizationDumpFrames 1 → tells High Resolution Screenshot tool to dump all the layers of the GBuffer

r.BufferVisualizationDumpFramesAsHDR 1 → dumps them in HDR format

I found them watching Unreal Engine's Live Training Baking Materials to Textures.

However, this tool I made bakes the various GBuffer layers as textures using Render Targets.

--- --- --- --- ---

Setup

The following graph shows the tools' setup flow and how the various elements are interacting with each other:

Below you can find an explanation and description of all the elements:

  • EUW_SimpleBillboardBaker is used to control BP_SimpleBillboardBaker

  • EUW_SimpleBillboardBaker is used to gather the information needed from the user and control BP_SimpleBillboardBaker to bake the textures
    • Defines:
      1. Target Bakers
      2. Final output Resolution, Name, and Directory
      3. SceneCaptures2D's Camera Projection Type, Ortho Width/FOV, and Location
    • Ability to Bake Multiple Textures at once using the selected bakers
    • Ability to Bake Single Textures using target baker
    • Ability to preview GBuffers
    • Generates Material Instance from M_Billboard_Preset (contains a Camera Facing Billboard Function)
  • BP_SimpleBillboardBaker contains all the elements necessary for the bake
    • 3 SceneCpature2D components that use 3 different generated Render Targets based on input resolution
      1. Flex → with PP material to switch between GBuffer layers. Capture Source set to: Final Color (HDR) in Linear sRGB gamut
      2. Alpha → uses the default Capture Source setting to store Alpha: SceneColor (HDR) in RGB, Inv Opacity in A
      3. Normal → to store World Normals, more accurate results compared to World Normal GBuffer (SceneTexture ID8). Capture Source set to: Normal in RGB (Deferred Renderer only)
    • 1 SceneCapture2D used to preview the GBuffers
      • Same setup as Flex
      • Useful to adjust the SceneCaptures2D to frame the subjects (using Material ID)
      • Render Target is not generated, uses a 2k RT from the Content Browser
    • StaticMesh Component to preview generated Material Instance that contains the baked textures
    • Render Target Textures are used in M_Drawer, an instance of it is used to draw onto the secondary Render Targets before they get saved as Static Textures. It contains parameters to activate the desired baker:
      1. Scene Color → uses RT from Alpha SceneCapture2D
      2. Base Color → uses RT from both Flex and Alpha SceneCapture2D (RGBA)
      3. Metallic → uses RT from Flex SceneCapture2D
      4. Specular → uses RT from Flex SceneCapture2D
      5. Roughness → uses RT from Flex SceneCapture2D
      6. Emissive → uses RT from Flex SceneCapture2D, and lights needs to be turned Off
      7. Opacity → uses RT from Alpha SceneCapture2D
      8. World Normal → uses RT from Normal SceneCapture2D
      9. Subsurface Color → uses RT from Flex SceneCapture2D
      10. Ambient Occlusion → uses Flex RT from SceneCapture2D

---

How to use it

Billboards can be baked with some simple steps

  1. Run the Simple Baker EUW 
  2. Click on the top left button "Load Billboard Baker Level"
  3. Place the assets you want to bake in front of the camera
  4. Open RT_Preview to check what the output will be
  5. Adjust the Scene Capture Settings such as the Projection Type, FOW/Ortho Width, and its location (keep looking at **RT_Preview** to check the result)
  6. Select the GBuffers you want to bake and set the textures output size
  7. Give it a Name and change the Target Directory if necessary, once done press the "Bake Multiple Textures" button
  8. Once the bake is completed you should be able to see in the level the Material Instance created with all the textures assigned


---

How it Works and Encountered Issues (dev log)

Here's a list of all the topics I've had to deal with and thought would be good to keep track of for the future while making the tool. 

  • GBuffers Layers to SceneCaptureComponent2D
  • Drawer Material Setup
  • Setup Alpha for Output Render Target 
  • Ambient Occlusion Baker
  • Emissive Baker
  • Camera Facing Material
  • World Normals Baker
  • Draw on Multiple Render Targets
  • Save RT as Texture 
  • EUW Fetch Assets Filepaths from Directory function
  • Hide Actors From Scene Capture 2D
  • Update Material Instance using EUW


GBuffers Layers to SceneCaptureComponent2D

To render the various GBuffer passes I used a post-process material and added it to the SceneCapture2D in the construction script and changed the Capture Source of the SceneCaptureComponent2D

PP Material
The Material's Domain is just set to Post Process and the graph looks like this:

Return SceneTexture is a custom node, more information can be found in the resources

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

Set PP Material to SceneCaptureComponent2D
The following logic creates a Dynamic Material of the Post Process, sets which GBuffer Layer should be activated using an ENUM, and then adds the material to the SceneCapture2D component

If you look at the Post Process Material Settings of the SceneCaptureComponent2D you won't see them update, but the material does get set to it

Visualize PP Materials on SceneCaptureComponent2D
To make the Post Process Materials work on a SceneCaptureComponent2D, the Capture Source needs to be set to:
   Final Color (LDR) in RGB
    or
   Final Color (HDR) in Linear sRGB gamut
In this case, I'm selecting the second one to get linear values.

Drawer Material Setup

The Drawer Material is used as a Material Instance in the tool.
The setup is very simple, it has some Texture Parameters that take in the Render Targets used by the 3 SceneCapture2Ds, then there is a scalar parameter for each GBuffer layer plugged in a chain of "multiply" nodes, their default value is 0, this way nothing is visible. They then get turned on one by one based on the GBuffer that needs to be baked.
The Material also contains a Colour parameter that gets used for the BGs of the bakes.


Setup Alpha for Output Render Target

I found storing the alpha to the output Render Target tricky.
Because the main SceneCaptureComponent2D is using a post-process material it won't be rendering the empty space as transparent, to counter that I've set up a second SceneCaptureComponent2D with the default SceneColor (HDR) in RGB Inv Opacity in A Capture Source.
The Alpha will be inverted, but that's fine because when using the "draw Material to RT" node it gets inverted again.

Then they are both used in the M_Drawer material, it's important to set the Blend Mode to AlphaComposite (Premultiplied Alpha) and have the Shading Model setting set to Unlit

Below you can find a screenshot of the graph for the first M_Drawer test.
In the final setup, the Alpha gets turned on only when capturing Base Colour or Scene Capture Layer.

Another important thing is to clear the RT before drawing into it, with RGB set to 0 and alpha to 1


Ambient Occlusion Baker

I managed to get the **Ambient Occlusion** output out of Scene Texture Ambient Occlusion (number 24) when setting the Global Illumination Method to `Screen Space (Beta)`, this can be done in the Project Settings but I've changed the SceneCapture Flex PostProcess settings instead.

It's not really working when the scene capture is set to Orthographic, using Prospective might be a generally better solution, since it makes the look of the capture more natural as well.

Emissive Baker

Emissive is not a GBuffer Layer, but it can be captured by getting all the values above 1 in the Final Scene renderer. However, before capturing it all the lights in the level need to be turned off. (Check out resources)

During the first tests I encountered a bug where I had to press the baker button twice when capturing the emissive, that's because all the logic was triggered at once, both the turning off of the lights and the Render Target capture. When I started developing the logic in the EUW this wasn't an issue anymore because I could add a delay node between the two actions, which it's not possible when triggering a "Call in Editor" custom event from a Blueprint.

Camera Facing Material

I wanted to create a function that deals with both WPO and Normal calculation, this became a whole separate rabbit hole. Calculating the correct Normal was a bit challenging, the exploration and result deserved its own blog post article, which you can find here.

What you need to know for the "World Normal Baker" section is that the function can take in a Normal texture, but it needs to be in Tangent Space.

World Normals Baker

I found the result of a Normal texture rendered using a SceneCapture2D set to "Normal in RGB (Deferred Render only)" to be better than taking the GBuffer from the Post Process. So I added a third SceneCapture2D to the blueprint, just for capturing the Normal.

Capturing a Tangent Space Normal texture from a World Normal output (which the Billboard Material function I created requires) can be done by aligning the Scene Capture facing down the Z axis.

However this is annoying to work with in the level, so instead I've rotated both the camera and the subject by 90 degrees around X, so that they are perpendicular to the XY axes plane:


I then accounted for it in the Drawer material by rotating the vectors of the captured image, I'm Normalizing it just in case, and remapping the values from -1 and 1 to 0 and 1.
The remapping is needed because the Scene Capture with the Capture Source set to "Normal in RGB" is able to store negative values in the render targets, but once the drawer material is drawn on the target RT (using the Draw Material to Render Target node) negative values get discarded so Normal needs to be remapped first. 

They then need to get remapped mapped back from -1 to 1 in the Billboard Material because the texture Sampler Type is set to "Linear Color" and not to "Normal", this is because the texture contains the blue channel directions already calculated. 
I'm not using the Normal Sampler Type because it ditches the blue channel and calculates it from the Red and Green ones, but the baked texture already has already the information needed stored in the blue channel. Just FIY the Normal Sampler Type also does the -1 to1 remapping automatically.


Draw on Multiple Render Targets

Getting the tool to draw different GBuffers on separate Render Targets using a single button was quite challenging.
Up until a certain point, I was drawing them one by one using an ENUM and a switch node.

The weirdest behaviourI found while expanding the functionality was that by exposing the "Target Texture to Bake" parameter as a function input, the Drawer material wasn't updating before the drawing to the render target happened, causing the captured image not being the right GBuffer selected.
For this reason, I kept the hardcode reference inside the function instead of exposing it

The Initialize Baker function you can see in the screenshot above prepares all the elements needed before the baking happens:

  • Sets the PP Material to pick the target GBuffer Layer
  • Prepares the Drawer Material by first resetting all the parameters and then setting only the ones needed (the correct multiply Scalar Parameter, BG colour and alpha if needed)
  • Clears the output RT with the colour RGBA 0,0,0,1


Another painful thing to deal with was chaining the capture of the GBuffer one after the other.
I initially tried to have them all in the blueprint attached to one "Call in Editor" Custom Event, but similar to the issue explained before, the result textures didn't match the desired GBuffer selected.
So I started looking into making an Editor Utility Widget, after more attempts I found that the only way to reliably capture them was by having a small delay between the updating of the Post Process/SceneCapture2D's RT and the drawing of the Drawer Material (another option could have been to have multiple SceneCapture2D with their own PP Material Instance for each GBuffer, but I wanted to keep the single "Flex" SceneCpature2D setup).

In the EUW I first managed to recreate the baking of the various passes one by one duplicating multiple times the following logic:

I then recreated the engine "For Each Loop" Macro in the EUW, copying the graph from the "StandardMacros" Blueprint Macro Library and added a delay feature to it (check out the Resources section for making your own).
This way I could loop over the logic without having to duplicate the graph multiple times. Because there is a delay node in the Loop Body as well, the Delay value on the Macro needs to account for that, the two timers will start at the same time, if they are of the same duration the next Loop Body will trigger before the previous one is ended. Setting the For Each Loop's Delay double the time of the Delay node used in the Body fixed the issue.

I found that ENUM Arrays are unsupported by the EUW' SinglePropertyView, (I'll probably make a tutorial on how you can expose variables in a EUW at some point, I'll post the link here if I do make it)


Because of this, I've set up a Boolean parameter for each GBuffer, and then just before starting using the ForEachLoopDelay I add the baker to the ENUM based if the Boolean is true or false:


Save RT as Texture

I found 3 nodes that can potentially save a Render Target as a Texture, I've only tried the first because it was working as expected:

  • Render Target Create Static Texture Editor Only
  • Export to Disk
  • Export Render Target

The only caveat I found is that I couldn't target the plugin folder as the output, so the default file path I'm using is /Game/SimpleBillboardBakerBakes/ instead of /Plugins/SimpleBillboardBaker/Bakes, I've also tried /SimpleBillboardBaker/Bakes but it also doesn't work.
Using /Content/SimpleBillboardBakerBakes/ is also an alternative to /Game/SimpleBillboardBakerBakes/.
The baker then adds a subfolder folder using a specified name: `/Game/SimpleBillboardBakerBakes/MyTexture/` for example

Initially, I had the logic inside Billboard Baker's blueprint, however, I moved it to the EUW so that I could save the Texture2D asset that it creates. Otherwise, the asset fetching was failing when trying to turn on sRGB on the baked textures that require it.


EUW Fetch Assets Filepaths from Directory Function

I made the following function to fetch the filepaths of assets that live in a specific folder, this way they I can then manipulate their information by using Find Asset Data and Cast To nodes 

However, when using the Find Asset Data node on my target asset, I was getting the following warning

`LogPackageName: Warning: TryConvertFilenameToLongPackageName was passed an ObjectPath (/Game/SimpleBillboardBakerBakes/MyTexture/T_MyTexture_COLA.T_MyTexture_COLA) rather than a PackageName or FilePath; it will be converted to the PackageName. Accepting ObjectPaths is deprecated behavior and will be removed in a future release; TryConvertFilenameToLongPackageName will fail on ObjectPaths.`

This is because the fetched filepath it's actually a reference to the asset itself and Unreal adds .AssetName at the end of the filepath, but the Find Asset Data node doesn't like that, but it can be easily fixed by splitting the string before the filepath gets used


Hide Actors From Scene Capture 2D

Actors can be hidden from a Scene Capture 2D using the following nodes.
This technique it's used to hide the ground plane from the baker, but it only works for the SceneCapture2D_Normal and SceneCapture2D_Alpha

It doesn't work for SceneCapture2D_Flex because it uses a Post Process Material to capture the GBuffer passes. So instead I've set up the PostProcess material to mask the GBuffer using the CustomDepth information.
This way all the Actors that have the toggle "Render CustomDepth Pass" turned on will be ignored

I've exposed an array called ActorsToHide to the baker's blueprint, then in the construction script it hides the selected Actors with both techniques so that they disappear from all the bakers.


Update Material Instance using EUW

When updating the parameters of an existing Material Instance through the EUW, for example, when adding the newly baked texture to the Material Instance used for Previewing the result, sometimes the Material wasn't actually showing the textures, as if they were not set at all, even if by opening the asset you could see the parameter being edited.
I found using the Update Material Instance node fixes the issue.


---

Wishlist Features

The tool is currently in a solid state, and all the core features have been added. 
However, here's a list of the things I also wanted to get around exploring and include with the baker, but I feel like I've spent enough time on it already, and if I don't wrap it up it will just continue forever, after all, it should be a "Simple" Billboard Baker.
I'll leave them here for my future self:

  • Add the control to select a target actor and automatically calculate the bounds so that the camera can frame it
  • Look into dilation and if it can be applied to the texture the baker generates
  • Make a texture packer tool that can take the baker's outputs and create packed textures based on the user's controls
  • Render multiple sides of the subject and pack them in a flipbook, then switch between them in the Material based on camera position relative to the billboard plane mesh

---

Conclusion

I started writing all this just for my future self when coming back to the tool to reference the various techniques I used, but I thought there might be something useful for everyone.
I hope you found this post insightful, have a great rest of the day!

---

Resources

Here's a list of the various resources I've used during the exploration and development of this Simple Billboard Baker Tool:

Report

Billboard Material Function - Unreal Engine

Article / 30 March 2023

Outline

- Introduction
- Geometry Plane Setup
- Function
    - Inputs
    - Free Rotation Method
    - Z-Axis Constrained Method
- Conclusion
- Resources

---

Introduction

This post is not a step-by-step tutorial, but more like a documentation of a Material Function I created. However, it does contain screenshots of the entire graph, so feel free to recreate your own version.

I'm currently working on a simple billboard baker that bakes the various GBuffer layers as textures using Render Targets, which can then be used in conjunction with a plane and a material that makes it always faces the camera. 
Of course, the material needed some logic that rotates the plane to align it toward the camera, I wanted a function that would deal with calculating both the World Position Offset and the correct Normals. There are a lot of different ways to achieve this, I found a lot of resources online about it and after some exploration, I gathered together the logic needed for 2 types of billboards:

  1. Free Rotation
  2. Z-Axis Constrained


Geometry Plane Setup

Both logics are set up to work with the default engine plane rotated by 90 degrees on X, but you can make your own one, the only thing to be careful about it's the plane orientation and UVs. 
For example, I made a version where the pivot is at the bottom so that it's easier to place it around the scene.


The function


Here's the graph:

It's divided into 2 parts, one logic makes the plane face the camera from all angles (Free Rotation), while the second one has the plane's Z-Axis locked so that it doesn't pitch (Z-Axis Constrained). For both versions, there is a switch that calculates the correct normals with and without using a texture.
The function outputs both the WorldPositionOffset and Normal to plug into the Material Attribute


Inputs

The function has 4 inputs, one of them is a Vector3 for the texture input, while the other 3 are booleans that toggle various switch nodes.
Here's a description fo each of them:

  1. Free Axis (StaticBool) → Whether or not the function should use the Free Rotation or the Z-Axis Constrained logic
  2. Tangent Space Normal (StaticBool) → Defines if the output Normal should be in Tangent Space or World Space
  3. Use Normal Texture (StaticBool) → Tells the function if you're feeding a Normal Texture, or if it should calculate only the Normals of the plane
  4. Texture (Vector 3) → Tangent Space Normal Texture input


Free Rotation Method

For the free rotation setup, I've followed the logic showcased by Visual Tech Art YouTube channel in one of his video, I highly recommend it if you want to understand better billboards, his other videos also cover interesting topics in a great way!

The logic basically takes the UVs of the plane and aligns them to the UVs of the Camera, for the Normals when using a tangent space texture I've rotated the vectors by 180 degrees around X to have them as if they were also camera space. I tried to use the TransformVector node set from TangentSpace to CameraSpace but it wasn't working as expected.

Texture Normals before and after the rotation:


Z-Axis Constrained Method


The constrained logic is inspired by Epic's stylized sample project, I think I've just cleaned it up a little bit and replaced the custom node used for the rotation with "Arctangent2Fast".

For calculating the correct Normal I've followed the same video mentioned before from Visual Tech Art. While for the Texture Normal I'm applying the same rotation done for the WPO.


Conclusion

Thanks for your attention, I've always found confusing the fact that you also need to recalculate Normals when using World Position Offset. This exercise, and exploration helped me to have a better understanding of it and I'm now more confident when dealing with Normals.
I hope you've found this post useful, have a great rest of the day!


Resources

Here's a list of the various resources I've used during the exploration and development of the function:


Report

Create Content Plugin - Unreal Engine

Tutorial / 16 February 2023


Introduction

Sometimes when working on a project you might be creating something useful that you want to use again in the future, creating a Content Plugin is an easy and clean way to do that, an alternative to just migrating the assets from project to project.

This is a very simple step-by-step guide aimed at beginners.


Plugin Creation

The plugin can be created from the Plugins Window, to open it go to Edit → Plugins


Now press on the Add button, which should open another pop-up window called "New Plugin"


From here you can create the plugin, click on "Content Only", then add a name, fill up the descriptions prompts, and press the "Create Plugin" in the bottom right.


This should make the folder "Plugins" appear if it wasn't there already, and a new folder called "YourPluginName Content" gets created.
If you don't see them make sure you have both "Show Engine Content" and "Show Plugin Content" on, you can check them by clicking under "Settings" on the top right of the "Content Browser".


Now that it's created you will be able to see it in the "Plugins" windows, under the "Other" tab.


Pressing the Edit button a pop-up window appears where you can change the Plugin's Icon, Details and even add Dependency plugins to it so that once it's activated Unreal prompts you to activate other plugins that need to be on for it to work. A good example is if your plugin contains HDAs, Houdini Engine Plugin can be added as a dependency.



Populate the Plugin

In the project, I have a folder called MyAssets which contains everything I want to include in my Content Plugin


To populate the plugin with this content you can just move all the files into it


And just in case, I would Fixup Redirectors to prevent references issues



Moving Plugin to Another Project

Once you added content to the plugin you can keep working on it, editing, adding, and removing things.
When it's completed and you want to reuse it in another project you can package it by finding it in the Plugins Windows

After selecting the saving directory and a couple of pretty and futuristic compiling and competition sounds, a new folder with the name of your plugin will be created, that contains all the uasset.
Move it to the new project's directory, in the "Plugins" folder, following the same folder structure it was in the original project:

MyProject → Plugins → MyPlugin


If you open now the project the plugin will be already installed and activated automatically, if it had dependencies Unreal will ask you to activate the respective plugins and then probably restart.
If you don't see it make sure again both "Show Engine Content" and "Show Plugin Content" are turned on.


Conclusion

I hope you find this post a useful resource to get introduced to plugins and that you'll use this approach when reusing content for future projects!
Have a great rest of the day!

Report

Houdini - Switch When Input is Empty

Tutorial / 08 December 2022


Introduction

This post will show various ways you can use a switch node to automatically trigger when one input is empty, a very useful technique when working with HDAs.
Especially important when working with complex generators, this way you can still get a preview of your output, or even prevent the node to fail cooking or getting stuck in infinite loops when using the HDA in Unreal Engine.



Setup

For showing off the technique, I prepared a simple setup with an HDA that applies a Polywire operation on the input curve

The HDA graph:


Switch node

You can see the Switch node has 2 inputs, one is the input curve and the other one is another curve used as a fallback option.
The easiest way to make the switch activate and use the second input is to check the number of points of the first one, if they are equal to we can trigger the switch.
For more complex cases you might want to check if the points are less than a certain value, in this case since I'm working with a curve I want the input to have at least more than 2 points, and use the fallback option for anything less than that.
This can be done with a simple expression:

if (npoints("../Curve") < 2, 1, 0)


The "if" function takes in 3 floats, in the first one we can input the check expression we need to do, while the second one is the output value if the expression is true and the third one when it's false.

if(<expression>, <true_value>, <false_value>)


Now, if I remove the input to the HDA or if I feed to it something that contains only 1 point it will use the Fallback Curve which in my case it's a simple straight line.


Simplify

We can actually simplify it further, at the moment the input for the "npoints" function is a hard reference to one of the nodes in the graph, but we can simply replace it with "0" since the node we want to check is one of the inputs. The same way you would do when using a Wrangle Node.
So the below expression will give us the same result:

if (npoints(0) < 2, 1, 0)



Trigger from Other Nodes

If you do need to drive the switch using another node you can do it using the first example shown, having the name hard coded:

if (npoints("../Check") < 2, 1, 0)

But better than that, to make it more clearly visible for anyone looking at your graph or for your future self when coming back to it, you can use a Spare Input.
Create by clicking on the cog icon:

Now if you click and drag your Check node in the Spare Input Slot you will create a reference and now you can use "-1" in the expression to access it.

if (npoints(-1) < 2, 1, 0)



Switch-If node

This node only takes in 2 inputs but it gives a lot of options and operations to trigger the switch, like checking if attributes or groups are present or equal to specific values.
I haven't played much with it myself with these options, but the same expressions shown above work with this node as well.



Here I found you could even just use "npoints(1)" and switch the order of the inputs, because everything above 1 gets clamped.
So if there are no points in input 1, the result of the expression will be 0 and the first input will be used instead.



Conclusions

I hope you found this post useful for your future HDAs, have a great rest of the day!

Report

2D SDF - Gradient/Flowmap and AA technique - Material Function Library UE5

Tutorial / 04 October 2022


Introduction

In this post, I'll show you how you can create a gradient from a 2D SDF and how it can be used as a flowmap in the material to warp a texture or in Niagara to drive the location of particles! I've also added a bonus section about Anti Aliasing, something that I haven't covered before.
If you haven't seen my previous posts about SDF I highly recommend them:

  1. Basic Shapes and Visualization
  2. Combine Shapes
  3. Manipulate Space


Gradient / Flowmap

Shape

For the rest of the post I will use an SDF of a sphere and a triangle combined together that looks like this:

Here's the graph, you can find how to create these functions in my previous posts:


Explanation

For understanding how to make gradients form SDFs I've read this post, have a look at it if you want some more in detail explanation.

If you've read my first post about SDFs it's probably you should know that each point in the field of an SDF stores the distance to the closest edge of the shape, we can use this information to calculate a gradient, which will be a direction that points either directly towards or away from the closest edge. This is done by comparing the value of each point in the field to the value of their neighbors on the X and on the Y axes.

The arrows in the image above show the gradient.
Inside the field, where the distance is negative, the gradient points directly towards the edge.
Outside it, the gradient points directly away from the edge.
These directions can be expressed as a 2D vector, which is referred to as the gradient vector.


Logic

The only thing to keep in mind is that this gradient doesn't come cheap, to generate it we need to sample the sdf two extra times, with some offset on the X and on the Y to calculate the derivatives of the two axes. This just means that we need the change of rate in both X and Y.

Because we need to sample the SDF three times I would highly recommend creating a Function that contains the shapes of the SDF you are working on, especially if you're manipulating and combining multiple shapes.

We first need to sample the distance as usual, then to calculate the change of rate in the X axis we need to add a slight offset on the position and then sample the SDF again, this way we are essentially finding the Right Neighbour value for each point in the SDF. Then subtracting it from the distance calculated earlier, we get the change of rate on X. We can do the same for getting the Top Neighbour of the SDF and the change of rate in Y.
If we then make a Vector2 appending the two results and normalize it, we get the gradient.

And here it is in all its glory:



Warp Texture

This gradient can be used as a flow map to warp and distort textures, for my examples I will be using this texture with some tiling:

For demonstration purposes, I've only set up a simple example.
You can multiply the gradient to control the strength of the warp, then multiply again with Time and add it to the UVs of your texture. I'm also controlling the speed multiplying Time and I'm feeding it to a Frac node to have the distortion happen in a repeating loop:

You can see that the texture will move outward from the center of the SDF, just like the direction of the arrows shown earlier:


Of course, we can mask this distortion using the distance value of the SDF, in the example below I'm inverting the direction of the flow for everything that is outside of the shape and keeping it the same as before for the inside. This can be easily done with a Lerp node, in this case, I'm also using a Smooth Step function to remap the SDF (again, more about this in my previous posts)


Render Target

What's cool is that we can draw both the distance and the gradient of the SDF to a Render Target and read the texture in Niagara to control the location and low of particles!!

When drawing the Material to a Render Target we need to keep in mind that we can only have values in the range of 0 to 1. However, we know that the distance of the SDF contains negative values for the points inside the shape and the directions of the gradient also contain some negative values. So we need to lift them all to be in the range needed, this can simply be done by adding 1 and multiplying by 0.5.

What I also like to do is to combine them both in a vector3, having the distance in the Red Channel and the gradient in the Green and Blue ones.

This is what it will look like:


Niagara

In Niagara you can use the built-in Sample Texture module, here you can feed your texture directly or have it as a user parameter, the only other thing needed is some UVs!
If you're spawning the particles with the combination of "Spawn Particles in Grid" and "Grid Location" modules, you can use the attribute GridUVW that gets created. Otherwise, if you're simply using a Shape Location module set to Box/Plane, like I'm doing, we need to make the UVs based on their position.

To do that I made a vector2D particle attribute called "UVs" and I'm setting it using the Scratch Dynamic Input. Below you can find the graph, I'm getting the position and dividing by the size of my plane (which is 100 in my case), then I'm making the Vector2D getting only the 2 axes I'm interested in and adding 0.5 to offset the origin from being in the center of the plane to a corner in the bottom.
Note that to make this setup work you need to have the Emitter be in Local Space, if that's not possible you need to take into account the World Position of the Actor in the level and offset the particle position value accordingly (I believe you can do it by using the Engine Emitter Attribute "SimulationPosition", but I'm not 100% sure).

Of course, if you need this setup in multiple Niagara Systems I suggest creating an actual Dynamic Input asset for it, with the size of the plane exposed as a parameter.

I might write something more in detail about this topic in a future post, for now, let's get back to our SDF.

Now that we sorted out the UVs we can sample the texture with the Sample Texture module mentioned earlier, this module creates a particle attribute called SampledColor that contains the values of our packed texture. We can use a custom module to unpack it and remap these values back to -1 to 1 and create particle attributes for both the distance and the gradient.
This can be done by multiplying by 2 and subtracting 1, then since I want to make particles flow around the SDF shape I've rotated the gradient by 90 degrees and saved it as another attribute.
To rotate a 2D vector by 90 degrees you can invert the order of x and y and multiply x by -1:

(x, y) --> (y, -x)

If we visualize the vectors of the rotated gradient they look like this:

Now if we align the direction of our particles using the gradient vector as is, we'll get the result I showed during the explanation part of this post. What we can do is use the distance information as well to invert the direction base on if the particles are inside or outside the SDF shape:

We can use these vectors to attract the particles towards the shape's edge, multiplying it by a force and adding it to the Transient Attribute PhysicsForce so that it gets applied to the velocity in the Solve Forces and Velocity module. We can do the same for the rotated gradient.

That's the main logic needed to have particles move along the SDF edge:


The power of this setup is that if we can have the texture baked out if the SDF doesn't move, but if it's animated we can feed the RT directly and calculate the new SDF distance and gradient in real-time getting some very cool results like this one:


Anti Aliasing

Introduction

When working with these shapes and you're using a Step function to visualize them, you might have noticed some aliasing or flickering around the edges, it's not much since we are working with procedural shapes instead of using textures but in some cases, it might be more evident. Fortunately, there is an easy solution!

I did some research online on the topic and I would recommend you to read these 3 sources:

  1. numb3r23 - Using fwidth for distance based anti-aliasing - the technique I used and will explain below 
  2. mortoray - Antialiasing with a signed distance field - interesting read, but the SDFs, in this case, need to be calculated in pixels
  3. drewcassidy - Antialiasing for SDF textures - might be useful if you're working with SDF textures

Function

We can introduce some anti-aliasing using the Smooth Step function, we want the interpolation to only happen on the edge of the SDF shape, basically the size of one pixel. To achieve this we need to calculate the "anti-aliasing-factor", which will basically tell us the rate of change in the distance field.
This can be calculated by feeding the distance of the SDF directly in the fwidth function: 

float aaf = fwidth(dst);

Unreal's Material Graph doesn't have a node for that, but fwidth is simply the sum of the absolute values of ddx and ddy - check out this video to learn more about these three functions.

dx = ddx(v);

dy = ddy(v);

fwidth(abs(dx) + abs(dy));

Now, the size of the rate of change in the distance field given us by the "anti-aliasing-factor", determines how wide the Smooth Step interpolation needs to be in order to fade the edge of the SDF shape:

return 1.0 - smoothstep(radius - aaf, radius, dst)

I would replace the step function with this one almost in every scenario, it's very useful when using the SDF as the opacity for a masked material or the alpha when lerping between two colours.


Conclusion

For now, this is everything I wanted to share about 2D SDF, in the future I would like to experiment more with 3D SDFs to create some interesting FXs with Niagara.
I hope you found this post insightful, have a good rest of the day!


Report

2D SDF - Manipulate Space - Material Function Library UE5

Tutorial / 15 September 2022


Introduction

This is the third part of a series of posts about 2D SDF, check out the first one where I show some simple shapes and explain the basics to visualize them, and the second one where I show different ways to combine them!
I think it's very useful and valuable knowledge for every Technical or Environment Artist, but really for anyone that works with materials.
In this post, I will cover ways to manipulate the space used to sample these shapes, as well as share some extra tips I've learned along the way.
As I mentioned before, everything I will cover is not new knowledge, I've learned it all by reading Ronja and Inigo Quilez's articles which I highly recommend for getting a better understanding.
The focus of my posts is to show how their content can be recreated in Unreal Engine and its Material Graph.

I'm sorry again if this is another long post, but I hope it's worth reading, enjoy!!


Distortion

I found a couple of ways to add some distortion to the perimeter of the shapes.
The first one involves sine waves, while the second one 2D or 3D noises. The only thing to flag is that doing this will make the distances of the sdf functions less precise, but if the intensity and the amount of these distortions are low, in most cases this is not a problem.
For all the direction examples shown below, I'm going to use a simple square shape.

Wobble


Distortion can be added using sine waves, we can do this by calculating two different sine waves, one for each axis, and adding it to the position used to sample the sdf shape. All the inputs expect a vector2, this is so that you can have separate control on the two axes.
To calculate the wobble we need to flip the x and y components, then it can be multiplied to control the frequency, feed the result to a sine node and multiply it again to control the intensity. The distortion can be animated by adding time before flipping the position, and the speed can be controlled with another multiplication.

void wobble(input float2 Position, float2 Frequency, float2 Amount)
{
    float2 wobble = sin(Position.yx * Frequency) * Amount;
    Position = Position + Wobble;
}

Multiple wobble functions can be added together to get more variation and a more complex result


Noise

For the noise, the logic it's the same, do some multiplications to control its intensity and speed and then add it to the position before sampling the sdf shape.
Compared to the wobble, I noticed that with this method you might get even more imprecise results and artifacts on the perimeter of the shape, so be careful with it.

You can use a 2d or 3d texture based on your options, I prefer using a 3d noise because this way I can animate it through the depth axis and easily get it to look like it's morphing.
In the example below I've used the built-in procedural noise node, I've just reduced the number of levels to 3, and its scale.
I didn't create a function for this since you might need a different one for each type of noise setup you are going for, however you should definitely make one if you're using the same setup multiple times.


Extra

Now, I want to point out that you should definitely play around and experiment. For example, I asked myself what would happen if I did similar operations after sampling the sdf shape instead of manipulating the position.
Well, you get slightly different results of course, but is that the correct way of doing it? Who cares, if you like the result it doesn't matter. Tech Art is not always about knowing and correctly applying techniques and math, in the end, our aim is to get some beautiful results. You don't always need to understand the logic behind it, sometimes it's fun to just chuck together some nodes and values to see what comes out, you never know what you are going to get!

Below you can find some examples of some experiments, with both sine waves and noises.

Sine Waves:

Comparison of adding or subtracting noise;

Two similar setups where the noise is combined in a way so that it only removes information or inflates the shape:



Mirroring Options

Like in the way we do the shape's deformation we can manipulate the position to get some other results. Below I'll show some useful mirroring options.
Relying on manipulating the space and getting mirrored results it's a lot cheaper than sampling shapes multiple times, this way you can achieve some quite complex but cheap effects.
For all the mirroring options examples shown below, I'm going to use always the same sdf combination of a triangle and a circle, they both have a slight offset from the center, added using the basic transformation functions shown in the first post I made about sdfs.



Mirror

Simple Mirror


Doing a basic mirror it's very simple, we can just get the absolute values of one of the two axes:

void mirror(input float2 Position)
{
    Position.x = abs(Position.x);
}
For the function, I've also added a Static Switch to flip between mirroring on the X or on the Y axis.


Rotate Mirror

You can combine this with some basic transformation functions showcased here, I'll show you some tests with the rotation.
If you add a Rotate function before and after the Mirroring one, and you multiply by -1 the Rotation input for the second Rotate function, you can isolate the rotation to the Mirror axis:


Rotate only Shapes

Adding the Rotation function only after the Mirror one keeps the axis stationary and only rotates the position resulting in an interesting effect:


Rotate Everything

On the contrary, putting the Rotation function before the Mirror makes both the axis and shapes rotate:


Normal Cells

We can also tile the position in a way so that it creates cells, this function gives 2 different outputs, the first one duplicates the position like a classic tiling you can find working with textures, while the second one it's also mirroring the position.
The problem with the first output is that if the shape overlaps the edge of the cells it creates some artifacts, which doesn't happen with the mirroring option, something to keep in mind.

They both use some fmod trickery, the duplicated position function is:

void cells(inout float2 Position, float2 Period)
{
    newPosition = fmod(Position, Period);
            //negative positions lead to negative modulo
    newPosition  += Period;
            //negative positions now have correct cell coordinates, positive input positions too high
   newPosition  = fmod(Position, Period);
            //second mod doesn't change values between 0 and period, but brings down values that are above period.
}

For the Mirror output, we need to calculate the Cell Index to know which cells need to be flipped, we can add this to the previous code:

float2 CellIndex = floor(Position / Period)
float2 flip = abs(fmod(CellIndex, 2));
Position = lerp(newPosition , period - newPosition , flip);

Here's the comparison of the two outputs, duplicated position on the left and mirrored version on the right:


Radial Cells

This one is a little bit more complex, as it always is when rotations are involved, as I mentioned before check out Ronja's tutorial for a more in-depth explanation of how this works.
Regarding how to recreate it as an Unreal Function, like before, the function has two outputs, one for the duplicated position and the other one with the version with mirrors.

I need to mention that I've run into some complications when using Unreal's Sine and Cosine built-in nodes, I mentioned it in one of my previous posts as well. I realized that the input they take is normalized, so that when you feed a 0-1 value it outputs one wavelength. This makes these nodes a lot more useful and intuitive when using UV coordinates as inputs. However, the hlsl sin() and cos() functions to output one wavelength need 2pie as the input:

So, for the calculations we need in our function we need everything in radiants, so the hlsl function would give us the expected result.
However, you can find on Unreal's Documentation that using custom nodes "prevents constant folding and may use significantly more instructions than tan equivalent version done with built nodes", so for something simple like this I would try to avoid the custom node and instead try to get the same result using the built-in nodes.
In conclusion, this entire explanation was just to say that we need to divide by 2pie the inputs that go into our sine and cosine nodes, to recreate the same result of the hlsl functions.

The function is:

float radial_cells(input float2 Position, float Cells)
{
    const float PI = 3.14159;
    float cellSize = PI * 2 / cells;
    float2 radialPosition = float2(atan2(Position.x, Position.y), length(position));
    float cellIndex = fmod(floor(radialPosition.x / cellSize) + Cells, Cells);
    radialPosition.x = fmod(fmod(radialPosition.x, cellSize) + cellSize, cellSize);
    // Add this to make the version with mirrors
        // float flip = fmod(cellIndex, 2);
        // flip = abs(flip-1);
        // radialPosition.x = lerp(cellSize - radialPosition.x, radialPosition.x, flip);
    sincos(radialPosition.x, Position.x, Position.y);
    Position = Position * radialPosition.y;
    return cellIndex;
}

And here's the graph, where the sine and cosine inputs are divided by 2pie:

Here's the comparison of the two outputs, duplicated position on the left and mirrored version on the right:


Rotation Examples

Like before, we can add some fun rotations and get different results based on if we do the radial mirroring of the UVs before or after the Rotate function.
For the sdf shape in these examples I've only used a triangle, with some offset to move the shape in the visible UV space:

The graph:

And here are the results,
rotation added before the mirroring:

Rotation added after the mirroring:


Library Examples

Here you can find a couple of examples of how to use together the sdf functions I've shown in these past 3 blog posts, to create some random morphing shapes.

For the first example, there are a couple of shapes floating around with their position animated and then merged together.


While this second one also has some shapes with their position animated, I've also added a mirror function to get a more complex result.



Conclusion

The next post will probably be the last one about this topic, I'll show how to make a flow map using a Sign Distance Field, which can then be used in the material to warp a texture or in Niagara to drive the location of particles. I'll also mention something about Anti Aliasing.
I hope you found this post useful, have a great rest of the day!

Report

2D SDF - Combine Shapes - Material Function Library UE5

Tutorial / 08 September 2022


Introduction

This is the second part of a series of posts about 2D SDF, check out the first one where I show some simple shapes and explain the basics to visualize them!
I think it's very useful and valuable knowledge for every Technical or Environment Artist, but really for anyone that works with materials.
In this post, I will cover ways to combine and merge these shapes, as well as share some extra tips I've learned along the way.
As I mentioned before, everything I will cover is not new knowledge, I've learned it all by reading Ronja and Inigo Quilez's articles which I highly recommend for getting a better understanding.
The focus of my posts is to show how their content can be recreated in Unreal Engine and its Material Graph.

I'm sorry again if this is another long post, but I hope it's worth reading, enjoy!!


Shape Morphing / Interpolate

I want to cover this first because, even if it's probably the simplest function, I think it's the more interesting and visually powerful.
Just by lerping between two sdf you can smoothly morph from one shape to the other.

float interpolate(float ShapeA, float ShapeB, float Alpha)
{
    return lerp(Shape1, Shape2, Alpha);
}

In the example below I'm lerping from a circle to a rectangle using a remapped sine wave as the alpha:



Combine Shapes

For this section, I'm going to use two shapes, a circle, and a square. The settings will always be the same so that the different results can be more easily understood.


Merge, Intersect, Subtract

With some simple operations, the shapes can be merged, intersected, and subtracted from each other. There are 3 simple functions, but they are extremely useful to create more complex shapes.
Below you can see them in action:


Merge

The Merging result can be easily achieved with a Min operation:

float merge(float ShapeA, float ShapeB)
{
    return min(ShapeA, ShapeB);
}


Intersect

On the other hand, intersecting can be done using a Max node:

float intersect(float ShapeA, float ShapeB)
{
    return max(ShapeA, ShapeB);
}


Subtract

While a subtraction can be done using the Intersect function and feeding the subtracting shape after multiplying it by -1.
So it's essentially doing a Max operation but with one of the shapes inverted.

float subtract(float Base, float Subtraction)
{
    return intersect(Base, -Subtraction);
}


Rounded Versions

Using the functions shown before, we can create rounded versions as well, with control on the Roundness.
I'll try to explain them as best as I can but, If you want a more in-depth explanation of how this works, check out Ronja's tutorial.


Round Merge

By creating a vector2 using the sdf of the two shapes, getting the min value with 0 as the second input, and then the get its length, we can define an intersection Space. Then by growing the two shapes with a Radius parameter, the intersection becomes more rounded, but the two shapes are also getting bigger, which can be countered by subtracting the Radius again after the length function.
This gives us the result we wanted, but at this point, the sdf outside of the shape is currently broken, which can be fixed by doing the same operations but with inverted math to calculate the outside separately. Then two results can easily be combined for the output.

Here's the function:

float round_merge(float ShapeA, float ShapeB, float Radius)
{
    float2 intersectionSpace = float2(ShapeA - Radius, ShapeB - Radius);
    intersectionSpace = min(intersectionSpace, 0);
    float insideDistance = -length(intersectionSpace);
    float simpleUnion = merge(ShapeA, ShapeB);
    float outsideDistance = max(simpleUnion, Radius);
    return  outsideDistance + insideDistance;
}



Round Intersect

For the round intersect function the logic is the same, we just need to change a couple of things.
For the intersectionSpace we subtract Radius instead of adding it and we replace min with max.
We invert the length result for the inside distance and replace the Merge function with an Intersect one.

So that the function becomes:

float round_intersect(float ShapeA, float ShapeB, float Radius)
{
    float2 intersectionSpace = float2(ShapeA + Radius, ShapeB + Radius);
    intersectionSpace = max(intersectionSpace, 0);
    float outsideDistance = length(intersectionSpace);
    float simpleIntersection = intersect(ShapeA, ShapeB);
    float insideDistance = min(simpleIntersection, -Radius);
    return outsideDistance + insideDistance;
}


Round Subtract

For the Round Subtract function you can just use the Round Intersect one and invert the sdf of the subtraction shape:

float round_subtract(float Base, float Subtraction, float Radius)
{
    round_intersect(Base, -Subtraction, Radius);
}


Champfer Versions

Similarly, we can create a champfer at the transition points, with control on its position.

Champfer Merge

The champfer can be calculated by adding the sdf of the two shapes and dividing the result by the square root of 2, which it's the same as multiplying by the square root of 0.5. You probably already know this, but computers and software are faster at calculating multiplications rather than divisions (even if in this case it probably wouldn't really make a difference, I prefer using multiplication where I can).
To make things even faster, we could even feed directly the result as a float value, which is 0.70710678118. This way the shader doesn't need to calculate the square root at all.
After that, the champfer location can be controlled by subtracting a value from it, then by merging it with the 2 merged shapes it gives the desired result.

Function:

float champfer_merge(float ShapeA, float ShapeB, float ChampferSize)
{
    const float SQRT_05 = 0.70710678118;
    float simpleMerge = merge(ShapeA, ShapeB);
    float champfer = (ShapeA + ShapeB) * SQRT_05;
    champfer = champfer - ChampferSize;
    return merge(simpleMerge, champfer);
}


Champfer Intersect

For the intersection version, we just need to replace the simple Merge functions with the Intersect ones, and to control the location of the champfer we do an addition instead of a subtraction.

Function:

float champfer_intersect(float ShapeA, float ShapeB, float ChampferSize)
{
    const float SQRT_05 = 0.70710678118;
    float simpleIntersect = intersect(ShapeA, ShapeB);
    float champfer = (ShapeA + ShapeB) * SQRT_05;
    champfer = champfer + ChampferSize;
    return intersect(simpleIntersect, champfer);
}


Champfer Subtract

For the Champfer Subtract, like the Round Subtract, you can just use the Champfer Intersect function and invert the sdf of the subtraction shape.

Function:

float champfer_subtract(float Base, float Subtraction, float ChampferSize)
{
    return champfer_intersect(Base, -Subtraction, VhampferSize);
}


Groove and Round Intersection

Groove

This function creates a groove in one shape at the position of the border of another shape.
We need 4 inputs, the first two will take the sdf of the shapes, one will be the Base, while the other one will be the shape that drives the Groove location. The other 2 inputs are Width and Depth.
First, we subtract the Width from the absolute value of the Groove shape. Then we feed the result in the simple Combine Subtract function I showed earlier, and for the subtraction input of the function, we use the Base shape with the Depth added to it.
To get the final result we just need to use another simple Combine Subtract function, subtracting what we just calculated to the Base sdf shape:

float groove_border(float Base, float Groove, float Width, float Depth)
{
    float circleBorder = abs(Groove) - Width;
    float grooveShape = subtract(circleBorder, Base + Depth);
    return subtract(Base, grooveShape);
}


Round Intersection

This function, instead of combining the two shapes using boolean operations, creates new round shapes where the borders of the two sdf overlap.
To achieve this, as I showed in the Round Merge function, we need to define the Intersection Space by interpreting the two shapes as the X and Y axis of vector2, this way the intersection points will be coordinate 0;0.

Then, as we do for a simple circle shape, we can create rounded shapes by calculating the distance from the origin and subtracting an arbitrary value to control the Radius:

float round_intersection(float ShapeA, float ShapeB, float Radius)
{
    float2 position = float2(ShapeA, ShapeB);
    float distanceFromBorderIntersection = length(position);
    return distanceFromBorderIntersection - Radius;
}


Conclusion

The next part will be about manipulating, in more advanced ways, the coordinate space used to sample the SDFs.
I hope you found this post useful, have a great rest of the day!


Report

2D SDF - Basic Shapes and Visualization - Material Function Library UE5

Tutorial / 01 September 2022

Introduction

I've been working on a Material Function Library for 2D Sign Distance Field that contains shapes and relative merging and manipulating operations.
I think it's very useful and valuable knowledge for any Technical or Environment Artist, but really for anyone that works with materials.

There will be a few posts about this topic, in this first one, I will show how to make some simple shapes and explain the basics to visualize them.
I will then cover different ways to combine these shapes, more advanced techniques to manipulate them and then how you can use distance fields to create gradients and flow maps.
Everything I will cover is not new knowledge, I've learned most of it by reading Ronja and Inigo Quilez's articles which I highly recommend for getting a better understanding.
The focus of my posts is to show how their content can be recreated in Unreal Engine, as well as share some extra tips I've learned along the way.

I'm sorry in advance if these posts will be long, but I think they are worth reading, enjoy!!


Basic Shapes

Let's start with some basic shapes, the screenshots and gifs you will see below are using a function I made that helps visualize distance fields.
For now, all you need to know is that the thicker white line represents the SDF shape, red is for the inside, and light blue is for what's outside of the shape.

One thing that all the shape functions share is that they require a Position input, to keep things simple I'm just using the UVs and then visualize the result on a simple plane. To center them I just subtract 0.5 from the UVs, I didn't include the subtraction node in the functions themselves so that they are more versatile and can be used, for example, with Wolrd Position values.

Circle

This is for sure the easiest distance field function:

float circle(float2 Position, float Radius)
{
   return length(Position) - Radius;
}
[snippet]

The size of the circle can be controlled with the Radius parameter.



Rectangle

Here's the rectangle's function:

float rectangle(float2 Position, float2 Size)
{
    float halfSize = Size * 0.5;
    float2 d = abs(Position) - halfSize;
    float outsideDistance = length(max(d, 0));
    float insideDistance = min(max(d.x, d.y), 0);
    return outsideDistance + insideDistance;
}
[snippet]

Size can be controlled with a vector2, this allows independent control on the 2 axes or just input the same value for both to get a square.



Triangle

The triangle's a little bit more complicated:

float EquilateralTriangle(  in vec2 p, in float r )
{
    const float k = sqrt(3.0);
    p.x = abs(p.x) - r;
    p.y = p.y + r/k;
    if( p.x+k*p.y > 0.0 ) p=vec2(p.x-k*p.y, -k*p.x-p.y) / 2.0;
    p.x = p.x - clamp( p.x, -2.0*r, 0.0 );
    return -length(p) * sign(p.y);
}
[snippet]

Similar to the circle, the size can be controlled with a single float.



Segment

The segment's also another very useful shape:

float Segment( in vec2 p, in vec2 a, in vec2 b )
{
    vec2 pa = p-a, ba = b-a;
    float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 );
    return length( pa - ba*h );
}
[snippet]

This function actually draws a line between two points, so the inputs are two vector2 parameters, and each vector2 defines the location of one of the points.
Regarding the thickness of the line, it's not defined by an input but it can still be adjusted by using another technique explained below.




Rounded Corners

Each point in a distance field only stores one value: the distance to the closest point of the perimeter of the shape we're representing. You probably noticed on the images and video shown above that the lines "outside" of the shapes are getting rounder and rounder the further away they are from the shape perimeter. If you go far enough any distance field becomes a perfect circle.
This can be used to our advantage, subtracting a value to the SDF before visualizing it will round the edges of any shape, this is also how I changed the thickness of the Segment shape earlier.

The only thing you need to be aware of when doing this is that in the process of rounding the edges the shape also changes in size and becomes bigger since now to visualize the shape we're essentially using points further away from the original perimeter.
So, you will have to scale it down if you wanted the shape to remain of the previous size. I'll cover both visualization and scaling methods later in the post.

The following graph and the video shows an example of a square SDF in both its original and rounded state, trying to keep them both the same size.

Here's a comparison of the graphs:

  • Basic Shape
  • Rounded Edges
  • Rounded Edges with Scale Compensation




Basic Visualization Methods

I would say there are 3 basic methods to visualize sign distance fields, which involve Step, Smooth Step, and Linear Step functions.
If you want to learn more about them you can check out one of my previous blog posts where you can find more information about them.

Step

The most straightforward and basic way to visualize a distance field.
Simply drop a step node, and use the SDF as the first input and a value of 0 as the second input. If you want to invert the inside and outside of the shape, just invert the two inputs.


Smooth Step

The Smooth Step it's probably the function I use the most to visualize SDF because It creates a nice gradient and you can define its start and end position.
Similar to the Step function, you can invert the Min and Max value to invert the inside and outside of the shape.
The example below shows a Min value of 0.1 and a Max value of -0.1. This makes the gradient start where values are -0.1 in the SDF, which are inside the perimeter of the shape, and it ends where the values are 0.1, just outside of the perimeter.



Linear Step

The unwanted and a little bit ugly brother, it's exactly the same as the Smooth Step function, the only difference is that the interpolation is linear instead of smooth. I've never really found it very useful, but I wanted to mention it there since it might be a good option in some specific cases.
Of course, there isn't a built-in node for it in Unreal's Material Graph, so you will have to make your own function, again, more about it here.


The screenshot below gathers all methods in one image.
Using values inside the perimeter of the shape for the Smooth Step and Linear Step functions also gives an interesting result, something similar to a bevel node in Substance Designer.



Animate Shapes

As you noticed in the videos above, the shapes can be animated. That can be easily done just by animating the parameters using time nodes and sine and cosine functions, the shapes will automatically update.

Below you can see a couple of examples.
For the Circle I'm using a Sine node and ConstantBiasScale to remap the value between 0 to 1, then I just used the result as the Alpha value for a Lerp, this way I can easily control the min and max value of the animation. Time is also multiplied by a float to control the speed.
In the Rectangle example, since it needs a vector2 and I wanted the animation of the 2 values to have an offset I've added an arbitrary value to one of the two and then did the same as before, but using a cosine instead of sine. Because the output it's a vector2 I can't use it directly as an Alpha for a lerp to do control the animation, so instead, I remapped the values using a multiply and an add node, essentially creating a "ConstantScaleBias" having the multiply control the overall size and the addition node to offset the animation away from 0 (different from BiasScale since the two operations are swapped). Again, Time is multiplied by a float to control speed.




Annular Shapes

SDF can be visualized in a way that gives annular shapes.
All the values outside of a shape are positive, while the ones inside its perimeter are negative.
This means that if we absolute the SDF so that we only have positive values we're left only with the perimeter of the shape, where the values are 0. If we then subtract from it we can make all the values near the perimeter negative, creating annular shapes. The subtraction amount controls the thickness of the shape.



Waves Patterns

The SDF can be used to create some interesting wave patterns, I'll cover them now.
Frequency can be controlled by multiplying the SDF. In all the examples, I'm also adding time to animate them.



Frac

Returns the fraction part of the SDF. You should get the same result using a % node and value of 1 for the second input.


Sine and Cosine

Of course, they both give basically the same result, the output will be a gradient that goes between -1 and 1, if you want only positive values you can try an abs or ConstantBiasScale node.


Triangle Wave

Very similar to the previous example, but it returns a linear gradient.
Check out one of my previous posts about it.
In the example below the gradient goes from 0 to 1.


Smooth Stepping Curve

I tried to experiment with this function as well.
You can find more about it here to use it in Unreal Engineand here if you're interested in it for remapping noises in Substance Designer.


Other - Tangent

I wanted to share a couple of more interesting findings using tangent nodes.
The first one is just using the tangent after a ConstBiasScale of a Sine node:

The second one is a little bit more trippy, this is the setup:



Advanced Visualization

If you've read everything up until here you are now ready to create your own custom advanced visualizer like the one you saw earlier.
You can use positive and negative values to colour the inside and outside of the shape, use the annular technique to visualize its perimeter, wave patterns to show the outer and inner space distances and use Smooth Step functions to create some nice subtle shadow gradients.
Here you can find the snippet of the one I made, but I highly recommend you try and make your own one, it's a fun challenge!



Basic Transformations

One last thing I wanted to cover in this post is how SDF can be manipulated using basic transformations such as offset, rotate and scale.
The way this is done is by transforming the position values that are fed in the SDF Shapes functions.

Offset

The offset is quite simple, just subtract from the Position. We're not doing an addition because we are manipulating the space. If we move the space towards a direction, the shape we draw into it will move the opposite way.

float2 translate(float2 Position, float2 Offset)
{
    return Position - Offset;
}


Rotate

The rotation's definitely worth having inside a function, unlike offset and scale.

I noticed some weird behavior from both the Sine and Cosine nodes in Unreal, they don't output the same value sin() and cos() do in hlsl. To get the same result you need to divide the input of the nodes by 2pie. I think this was done to make the nodes give better out-of-the-box results when TexCoord and Time nodes are plugged in as the input.
So unlike Ronja, to have the rotation being from a 0 to 1 value I didn't have to remap it to radiants. I found this very interesting since this affects any rotational math done in the material graph, so I've left the nodes inside a comment for future reference.

float2 rotate(float2 Position, float Rotation)
{
    float sine = sine(Rotation);
    float cosine = cosine(Rotation);
    return float2(cosine * Position.x + sine * Position.y, cosine * Position.y - sine * Position.x);
}


Another thing to mention is that if you're using both Rotate and Offset functions, the order you use them gives different results. If you want the shape to always rotate at its own center the Rotate function should be used after the Offset one.
Below you can find a comparison, when the rotation is done before the offset the result it's weird, but might be desired in some cases.


Scale

You might find this a little bit useless since most of the SDF Shapes functions I showed you at the start of this post have already controls for their size. However, when you have multiple of them and start using some merging functions this is a quick way to rescale them all at the same time.
The function's simple, get the position and divide it by the scale.

float2 scale(float2 Position, float Scale)
{
    return Position / Scale;
}



Conclusion

If you didn't realize it yet, I fell in love with distance fields and I hope you can now see their potential as well!!
However, if that's not the case and you're still resisting their magnificent charm, don't worry, there will be a few more posts about them that will cover more advanced techniques!

I know this post was quite long, but I really appreciate you've read the entirety of it!!
(Eheh yes, I'm looking at you scroller, you should feel ashamed, go back up and look at all the pretty pictures at least!!)
Jokes aside, thanks a lot, I hope I managed to teach you something new, have a good rest of the day!

Report

Copy To Points Randm Input - Houdini

Tutorial / 18 August 2022


Introduction

Here's a very simple but quite useful tip for Houdini, let's say you are working on a landscape and you want to scatter some random rocks on it, in this post I'll show you how you can do that!

To achieve this we need to give a unique ID attribute for each object you want to scatter and then use a Copy to Points node.
In the example I'm going to show you, the unique ID will be called variant.


Objects Pool

Let's start with putting together all the objects we want to randomly pick from, creating an "Object Pool" that we can use later to reference the objects from.
The only thing we need is to give them a unique integer attribute. If all the objects are made out of single meshes then we can easily use a Connectivity node.
In the example below I used this node, only changing the default name it creates from "class" to "variant", and then I'm driving a color node to pick a random value using this attribute:

However, if your object is composed of multiple meshes, like Houdini's test geometry Rubber Toy is,  this won't work since the Connectivity node creates the attribute for each set of connected primitives or points.
You would get this result instead:

I found two alternatives to this problem, but please reach out if you know a better way of doing this.
The first method is a little bit manual, you can create and set the variant attribute on each object before they get merged, but this means you have to type the various numbers one by one and edit them by hand if you add or remove objects:

The other alternative is to use the Pack node before merging, and the Unpack one after creating the variant attribute.
The only thing to remember when using Pack and Unpack nodes is that by default it doesn't keep any of the attributes or groups your geometry has, so just remember to use the Transfer option of these nodes accordingly. You can just type an asterisk to select all the attributes the mesh has, and do the same for the groups if needed.

Once that's done we need to create another parameter that tells us how many different objects we gathered together, which will be needed for the next step.
We can easily to that with the Attribute Promote node, just change the Promotion Method to Maximum, I'm also storing this information as a Detail Attribute and renaming it to "max".


Target Points

Now we can finally start defining the points we are going to copy these objects into.
For this example, I'm just using the points of a simple grid, the only thing we need to do is to assign a unique ID for each of these points, which will correspond to the different objects in our Pool.
I'll show you two different ways of doing it, one with VEX and the other one just using built-in nodes, however they both use a neat technique to reference our Objects Pool node, more specifically, we need to read the max Detail Attribute we made earlier.

Spare Input

We can add a Spare Input to any node you want, just select your node and add one from the menu the cog icon opens. A "Sapre Input 0" Parameter will be added.

Now if you drag and drop the Object Pool node into the Spare Input slot a purple dotted line appears, which means we can now reference it directly.



The two methods

The first method I'll show you uses two built-in nodes
First create an Attribute Randomize, change the Attribute Name to "variant" and under the Distribution tab set it to "Uniform (Discrete)" (to get only whole numbers) and for the Max Value we want to reference max attribute from the Objects Pool,  since we created that purple connection earlier we can simply write:

detail(-1, "max", 0)

Because the surface node -1 it's the Spare Input 0

Under the Options tab you can also change the seed.
Now the only problem is that the attribute created it's a float, but we need it to be an integer, to convert it we can use an Attribute Cast, just write the parameter's name and change the Precision to 16-bit integer.


For the VEX alternative, we can run over the Points using an Attribute Wrangle:

i@variant = set(rint(rand(@P + chi("Seed")) * chi("Amount")));

In this case, I'm driving the randomization using the point's position and adding a float as a seed. However the rand function gives a value between 0 and 1, but we need a value between 0 and the max amount of objects we have in our Pool, to get that value we can reference it the same way as I showed before.
One last thing to note is that the rand function will give us a decimal numeral, using set() we can explicitly tell Houdini to convert the float to an integer, but that will only truncate the number, which means we'll never get the last variant ID of the Pool. To include it, we'll have to round the number using rint().


Final Step and Last Notes

We are finally ready to put together the Pool with our Points with a Copy To Point node, we just need to turn on Piece Attribute and select our attribute variant.


Here's the result with our beautiful graph:

Randomize the result with the seed parameters mentioned earlier

I hope you found this useful, have a great rest of the day!!


Report