Skip to content

UE5 Dynamic Liquid Shader: Technical Breakdown

Corentin Demougeot edited this page May 10, 2023 · 15 revisions

Introduction

Presentation

Consider this liquid shader as a research project. The main idea was to learn the new Substrate material workflow and upgrade my C++ runtime and compute shader skills with a specific case.

This project is a remake of an old one I did 2 years ago: https://www.artstation.com/artwork/zO61md

They have nothing in common except for the wave movements shared by Ryan Brucks: https://twitter.com/ShaderBits/status/1268968994247249923

This is an open-source plugin created with UE5.2 under the MIT license. You can use, modify, and upgrade it as you wish.

I tried to strike the best balance between ease of use (only needing to drag & drop the component and the shader) and customization possibilities.

That said, let's dive in!


Starting point

The liquid shader is based on a simple Z-position mask like this:

The "Liquid - Offset" parameter will be our main parameter. It determines where the liquid is, and therefore, the quantity of liquid we have.

At this point, we are facing our first issue:

If you rotate a non-centered pivot point object or a non-uniform mesh, the liquid amount is constant and keeps the same world position. So, we need Liquid Adaptation!

Liquid Adaptation

We want to determine how much liquid we have at the start, depending on the "Liquid - Offset" parameter. The liquid amount could be measured with a cubic value, and the easiest way I found to compute how many cubes we have in a mesh is voxelization. If we know how many voxels can be contained in the mesh at begin play from the object's bottom to the liquid offset position, we only need to ensure that we always have the same voxel amount and set our liquid offset where the last Z voxel layer is.

Usually, voxels are computed on the GPU side with a compute shader. Sadly, in our case, we need to control the voxelization direction (from the bottom to the top) and not voxelize all the mesh at the same time, which is difficult with a CS where render threads are 'randomly' executed. So I'm using C++.

The main idea is to have an initial voxelization pass, where we determine how many voxels we need, at begin play, to fill the object until the liquid offset position. We voxelize our mesh until the Z voxel layer is greater than the liquid offset position. See code

Now that we have this voxel amount, all we need to do is executing the voxelization function until the current voxel amount is equal to the initial voxel amount.

Then, we interpolate the last tick liquid offset to the new one and update the "Liquid - Offset" parameter. See code

Cap Compute Shader

Now let's create the liquid cap (or free surface) to fill the liquid top surface. For this, we have two main parts to cover: the first one is the cross-section part. We want to draw the mesh form sliced at the liquid offset position. The second will be to fill the form to create the cap mask. For these, we will use Compute Shaders and Render Targets. The goal is to get rid of procedural mesh. I'm using a simple cube for this part to easily illustrate the concept.

Cross-Section

The first thing to do is to detect which triangles are sliced by the liquid offset. In our cross-section compute shader, we loop through the index buffer and look for the triangles associated with it. If the liquid position is between two Z vertex positions of a triangle, we can go further. See code

At this point, we are storing some information:

  • The triangle shape: When you slice a triangle with a straight line, you have two possibilities: one vertex is above the line and two are below, or two are above and one is below. So, we keep the lone vertex as the starting point and the other two as target points. See code

  • The triangle normal direction: We will use it to draw on the render target. See code

With the triangle shape, we can find the intersection position by getting an alpha scale for each target using the following formula:

Alpha = (LiquidOffset – StartPosition.z) / (TargetPosition.z – StartPosition.z)

And then we can interpolate from the starting position to the target position using: OutPosition = lerp(StartPosition, TargetPosition, Alpha) See code

The last step is to draw these positions, transformed from world space to texture space, on the render target with the triangle normal direction and to draw a line between these positions. See code

Filling the Form

Classic Flood Fill algorithms on the CPU side use stacking or recursion, which are not easy to handle on compute shaders. However, there is a common rendering technique to fill triangles: rasterization. As with classical rasterization, in the last part we drew vertex positions and lines between them, but with normal direction (normal rasterization?).

These normal directions will be useful to determine the drawing direction, which I'll show you with next four main cases. Before, some important information:

  • We have two textures: a Reference Texture with information from the cross-section compute shader and a clean render target, the Output Texture, where we will draw next cases.

  • The current ThreadId has a normal vector value of Nid = (1,0,0).

  • The angle θ in degrees between two vectors can be found using: θ = acos(dot( V1, V2 ) / len( V1 ) * len( V2 ) ) * (180 / PI)

See code


Case 1: Current pixel is empty with a value N = (0,0,0)

Do nothing.

Case 2: Current pixel has the value N = (-1,0,0), but the next right pixel has a value too.

Draw the current pixel.

Case 3: Current pixel has value N = (1,0,0), next right pixel is empty, we can compute the angle θ between Nid and N

θ = acos(dot( (1,0,0), (1,0,0) ) / len(1,0,0) * len(1,0,0) ) * (180 / PI)

θ = 0

θ < 90: Draw the current pixel.

Case 4: Current pixel has value N = (-1, 0,0), next right pixel is empty, we can compute the angle θ between Nid and N

θ = acos(dot( (1,0,0), (-1,0,0) ) / len(1,0,0) * len(-1,0,0) ) * (180 / PI)

θ = 180

θ >= 90: Draw right pixels while current pixel N = (0,0,0).


Clean up Pass

Sometimes, if the render target resolution is not high enough compared to the mesh complexity, imprecise values can be drawn during the cross-section process. This can result in weird lines being drawn outside of the form or black lines not being filled in the form. This cleanup pass detects these errors and fills them with the correct values. See code

Cap Mask Result

SDF Cap

For the Signed Distance Field part of the cap, I just translated this tutorial to C++. See code

https://www.froyok.fr/blog/2018-11-realtime-distance-field-textures-in-unreal-engine-4/

Waves and Movement Strength

To find the movement strength, we only need to get the difference between the current and the last tick object position and/or rotation. See code

Then, we decrease the strength with an Interp from the current value to 0 See code. We only take the highest value between position and rotation movement strength, send it to the MM_RTWaves material, See code

../UTC_LiquidShader/Content/Library/MM/Tools/MM_RTWaves

and draw this material to a render target to get a wave mask (The wave mask result is rendered in the material function MF_WavesMask).

../UTC_LiquidShader/Content/Library/MF/MF_WavesMask

Main Shader

Now that we have generated all the masks we need in C++, let's dive into the material editor.

Glass Shader

This is a classic glass material: I just added roughness details with a dirt mask and a Fresnel to the refraction to add a higher IOR on the glass sides.

../UTC_LiquidShader/Content/Library/MM/MM_LiquidShader

../UTC_LiquidShader/Content/Library/MM/MM_LiquidShader

Liquid Shader

Cap

The UVs of the cap mask and its SDF always face the Z-up direction. I'm using a projection on the object surface and the Substrate vertical layer to create the illusion that the surface is in the mesh. The cap faces the Z-up world space direction for its normal.

../UTC_LiquidShader/Content/Library/MF/MF_LiquidMask

../UTC_LiquidShader/Content/Library/MF/MF_CapUVs

Cap Deformation

I’m reusing the glass fresnel technic to add an offset on liquid edges.

../UTC_LiquidShader/Content/Library/MF/MF_LiquidMask

Front Side Cap (FSC)

This creates the 'line' between the cap and the water side. At the same time, I'm subtracting the liquid offset by the FSC size to create a 'vat.' Its normal goes from the top with the object tangent normal to the down with a -Z world space.

../UTC_LiquidShader/Content/Library/MF/MF_LiquidMask

Back Side Cap (BSC)

I'm using the cap mask at the liquid offset position and at the FSC position, and then subtracting them to create a back thickness effect. Its normal goes from the top with height to normal node to the down with a Z-up world space.

../UTC_LiquidShader/Content/Library/MF/MF_LiquidMask

Liquid Refraction

Apart from the cap, where I'm using the SDF to create a little refraction variation from its center to side to support the vat effect, the water refraction has an IOR of 0.66.

Bubbles Shader

There are 3 different bubble sizes, speeds, and amounts for the side and the cap. The side bubbles always face the camera, and their amount is driven by the movement strength. I'm using a technique I found to randomize tiling, which is useful for hiding bubbles and modulating the maximum amount. I'm adding an X movement with a sine wave to modulate the speed and to achieve a turbulence effect. For cap bubbles, I'm reusing the cap UVs to fit them on the top.

../UTC_LiquidShader/Content/Library/MM/MM_LiquidShader

../UTC_LiquidShader/Content/Library/MF/MF_SideBubbles