Particle Metaballs in Unity using URP and Shader Graph Part 1
This series will explain how to draw Metaballs in Unity using the Universal Render Pipeline (URP) and Shader Graph. But that’s not all! It’ll also explain how to use a Particle System to control the size and position of our Metaballs. I wanted to fit everything into a single article, but unfortunately, it was too much. By the end of part 1 we’ll be able to sphere-trace an object using a custom Shader Graph node. Click here to jump ahead to part 2.
What are Metaballs?
Many people have already explained Metaballs at great length, so rather than give a full technical explanation, I’ll jump right into why they’re interesting to me. However, if an in-depth technical overview interests you, then I’ll share some resources later in this post.
What’s cool about Metaballs (also known as Blobbies) is they’re blobs that merge when they’re close to each other and split apart once they’re separated. Imagine two droplets of water pooling together to form a single entity and you’ll get the idea. What’s tricky about them is they’re an implicit surface, which is to say, they aren’t meshes. Instead, we use math functions to represent them and raymarching to unveil them. How this works is we use a mesh to define a mask, then in a shader, we trace a ray from each pixel in the mask until we find a surface.
So with that explanation out of the way, let’s break down the different steps:
- We need a Shader Graph that can display a Metaball.
- We need to feed our shader with data from the Particle System.
- We need to shade our Metaball using our lighting and PBR.
In this first part of the series we’ll cover just the first step. So let’s get started!
Metaball Shader Graph
So first thing’s first, let’s create a Shader Graph to display a single Metaball. Doing so will validate our Sphere-Tracing code and Metaball inside of Shader Graph. We’ll do this using a Custom Function Node.
Shader Graph Custom Function Nodes
Shader Graph supports custom function nodes. These nodes allow you to specify the inputs and outputs and write any arbitrary shader code. In my first implementation, I wrote the entire shader inside a single custom function and plugged that into the shader output. You might be asking, “In that case, what’s the point of using Shader Graph?” which is a valid question. However, I’ve learned that Shader Graph’s power is the ability to break up my custom nodes and allow users to recompose them in a way that suits them. For me, the flexibility to combine custom low-level shader code with pre-existing high-level nodes singlehandedly proved Shader Graph’s value as a single solution to all my future shader development. So from now on, it’s Shader Graph 4 life.
Custom function nodes can take straight text input, or they can read from a file. In our case, we’re just going to create a Metaball.hlsl file and go from there. At this time, we want to validate our work. That is, is the sphere-tracer working? Can we see a Metaball? So, the only input we need is the mesh’s world space position, and our sole output will be an alpha value. The alpha value will either be 0 if we didn’t find a surface or 1 otherwise.
void SphereTraceMetaballs_float(float3 WorldPosition, out float Alpha)
{
#if defined(SHADERGRAPH_PREVIEW)
Alpha = 1;
#else
Alpha = 1;
#endif
}
Sidenote: I always add #if defined(SHADERGRAPH_PREVIEW) block right away because I often take advantage of functions built-in to URP. Those functions appear to be inaccessible from inside the Shader Graph Preview and so using them throws errors. That said, if somebody knows how to include them, I’d love to know.
I’ll breakdown the structure of this custom function. SphereTraceMetaballs is the name, and we use _float to specify the precision. Shader Graph allows users to switch precision, so it’s good practice to include another version called SphereTraceMetaballs_half that uses halfs (halves?) instead of floats, but I don’t worry about that until the end. The first argument WorldPosition is our input, and any arguments prefixed by out are our outputs, so out float Alpha. Now in Shader Graph, you can create a Custom Function node, point it to the Metaball.hlsl file and fill out the inputs, outputs and function name. It should look like this:
SphereTraceMetaballs function
I’m not going to go over the Sphere-Tracer code. Why? Because later in the article, I’m going to share with you where I read about it and found the code that I ported to hlsl. Here’s the code:
void SphereTraceMetaballs_float(float3 WorldPosition, out float Alpha)
{
#if defined(SHADERGRAPH_PREVIEW)
Alpha = 0;
#else
float maxDistance = 100;
float threshold = 0.00001;
float t = 0;
int numSteps = 0;
float outAlpha = 0;
float3 viewPosition = GetCurrentViewPosition();
half3 viewDir = SafeNormalize(WorldPosition - viewPosition);
while (t < maxDistance)
{
float minDistance = 1000000;
float3 from = viewPosition + t * viewDir;
float d = GetDistanceSphere(from, float3(0, 0, 0), 0.3);
if (d < minDistance)
{
minDistance = d;
}
if (minDistance <= threshold * t)
{
outAlpha = 1;
break;
}
t += minDistance;
++numSteps;
}
Alpha = outAlpha;
#endif
}
All that’s missing from this code is the GetDistanceSphere function that we’re about to write. By the way, I know that spheres aren’t the same as Metaballs. We’ll get to that. The other functions GetCurrentViewPosition and SafeNormalize, come from URP’s built-in shaders. How did I know about them? I didn’t. I dug through URPs shaders and found them. Being able to explore URPs code is invaluable. Later, when we start adding PBR support to the Metaballs, I’ll show you how I mine for diamonds in the URP code so that you can do it as well. The gist of the SphereTraceMetaballs function is: check each pixel to see if it contains a sphere’s surface.
GetDistanceSphere function
When I first wrote this shader, I didn’t start with a metaball function. I wanted to validate that the Sphere-Tracer was working before worrying about how Metaballs work. I’m telling you so you know that you could replace the GetDistanceSphere function with any SDF function. To test, I started with a simple SDF Sphere:
float GetDistanceSphere(float3 p, float3 center, float radius)
{
return length(p - center) - radius;
}
This function will return the distance from the point p and the surface of a sphere with a given center and radius. A Metaball is different because it also has a density. The calculation is a bit more complicated, but they still ultimately return a distance from a point. Like with the Sphere-Tracer, I’ll share the explanation and source from where I learned about them, rather than re-explaining it.
Putting it together
Let’s take a break and put together what we currently have:
- Create a Lit Shader Graph.
- Set the Surface type to Transparent.
- Add a Custom Function node:
- Connect it to the Metaball.hlsl include file.
- Add the name SphereTraceMetaballs.
- Add a Vector3 input called World Position.
- Add a Float output called Alpha.
- Connect a world position node to the input.
- Connect the Alpha output to the Fragment Alpha slot.
- Give it an emission color (we don’t have any lighting yet).
- Set the albedo to black (so lighting calculations don’t interfere).
- Create a material from this Shader Graph.
Now in the scene:
- Create a Sphere game object
- Attach our material to the Sphere.
- Set the Sphere to position (0, 0, 0) because we hard-coded that as the center of our SDF sphere.
Now you should see an unlit sphere in your scene. The sphere mesh acts as a kind of mask through which we can see our Sphere-Traced objects, so make sure it’s in the right place.
We made it to the end of part 1! That’s a lot of work to render a simple unlit sphere, but it’ll all be worth it once we reach the end of the series.
You can find the completed part 1 of the series here on Github. As promised, here’s a link to the series where I learned the details of Sphere-Tracing and Metaballs. Sign up to my mailing list here to be notified when the next part of the series comes out.
masakam1
Hello,
I tried this in HDRP.
But this does not work.
Have you tried and worked well?
bronson
I’ve never tried HDRP, but this could be an interesting topic for a future post!
YOO
Hello!
I have a question.
Can’t we use vector3 instead of float3 on the custom function node?
Why can’t I find float3 in HDRP?
bronson
To be honest I haven’t done a lot of shader work in HDRP so I’m not sure? Vector3 and float3 are generally interchangeable names for the same type though. I don’t think vector3 is a type in HLSL, but I think Shader Graph calls them vector3s when defining the inputs and outputs, so it’s a bit confusing.