This was an idea I've had for a couple of years. A lavalamp-like mass of blobs able to assume shapes to communicate.
Raymarching has been a fascinating field for me ever since I found out about it. It was a good opportunity to explore custom shader nodes in Unreal Engine's Material Editor, and I wanted to see how well I could achieve raymarching outside of inverted cubes and screenspace. I decided early on to keep to a static number of spheres, as I wanted to avoid having to code around dynamic amounts, or get too deep into optimization. 8 blobs were enough to go on to form the shapes I wanted.
The positions were hand-placed, which sufficed for two simple shapes, but for future additions, it would be preferreable to use splines or other point collections.
The start of my project was the raymarching shader. I needed to make custom nodes with HLSL to enable the looping steps.
float3 currPos = WorldPos;
float3 normal = 0.0;
int maxSteps = 48;
float maxDist = 2000.0;
float currDist, gather = 0.0;
float minStepSize = 0.08;
struct blobInputs
{
float4 blobs[8];
};
blobInputs bInp;
bInp.blobs[0] = MaterialCollection0.Vectors[1];
bInp.blobs[1] = MaterialCollection0.Vectors[2];
bInp.blobs[2] = MaterialCollection0.Vectors[3];
bInp.blobs[3] = MaterialCollection0.Vectors[4];
bInp.blobs[4] = MaterialCollection0.Vectors[5];
bInp.blobs[5] = MaterialCollection0.Vectors[6];
bInp.blobs[6] = MaterialCollection0.Vectors[7];
bInp.blobs[7] = MaterialCollection0.Vectors[8];
int i = 0;
while(i < maxSteps)
{
currDist = doEvalCollection(currPos, k, bInp);
gather += currDist;
if(currDist < threshold || gather > maxDist)
{
float hit = step(gather, maxDist);
if(hit >0.0)
{
occlusion = (float)i/(float)maxSteps;
return float4 (currPos.xyz, currDist);
}
return float4(0.0, 0.0,0.0, maxDist);
}
currPos += camVec * max(minStepSize, currDist);
minStepSize += 0.0150;
i++;
}
return float4(0.0, 0.0 , 0.0, maxDist);
The first node gives how far a ray has travelled, and its current position in space, which we break out in order to calculate how to push the pixel's depth value, since the material takes the depth based on the mesh we use.
The second one calculactes the normal of the point, plus returns a mask for the blobs.
struct blobInputs
{
float4 blobs[8];
};
blobInputs bInp;
bInp.blobs[0] = MaterialCollection0.Vectors[1];
bInp.blobs[1] = MaterialCollection0.Vectors[2];
bInp.blobs[2] = MaterialCollection0.Vectors[3];
bInp.blobs[3] = MaterialCollection0.Vectors[4];
bInp.blobs[4] = MaterialCollection0.Vectors[5];
bInp.blobs[5] = MaterialCollection0.Vectors[6];
bInp.blobs[6] = MaterialCollection0.Vectors[7];
bInp.blobs[7] = MaterialCollection0.Vectors[8];
if(currDist < 2000.0)
{
float3 normal = sampleForNormalCollection(currDist, currPos, k , bInp);
return float4(normal.x, normal.y, normal.z, 1.0);
}
return float4(0.0, 0.0, 0.0, 0.0);
Normals
Mask
It also turned out to be a good opportunity to get acquainted with Niagara to handle the dynamics of the blobs.
The emitter only needed to create and keep 8 particles with no set lifetime. It would then change behaviour based on inputs of points and strength based on states in the bluerpint holding it.
The niagara system uses analytical planes for simple collision, as it is intended for to be a closed system. Each particle has a plane calculated to the closest surface inside of a sphere assumed around the center of the niagara simulation.
The system communicates the current positions of its particles to the blueprint by attaching a callback function.
Being able to communicate to-and-fro Blueprint and Niagara was instrumental in keeping the effect cohesive, as I could feed in the positions the blobs could chase as attractors, and toggle their strength by a state in the blueprint.
I developed a system to keep the volume of the blobs constant, both for when the blobs converge into one, and when that one blob scatters again.
When gathering into one, the blob closest to the center gets assigned as "main blob" and takes volume at a set rate from others intersecting with it, as they are all drawn towards the main blob.
When scattering from a main blob, they either have a randomized portion of the total volume, or an equal part if the blobs are about to take a shape.
Blob intersection test
Scatter function of Blueprint
This was fun start to the portfolio, and I am satisfied with the result, despite the concessions I had to make. Niagara was a pleasant surprise, even if I didn't get acquianted with all of its features this time, like scratchpad.
Finding ways to optimize the raymarching to allow for more blobs, looking into compute shaders and tiled rendering.
PBD in Niagara. Instead of particles running simple analytical plane collision with the inside of a sphere, or other global collision testing, Position Based Dynamics would be a better fit for particle interaction in a closed system.
Shape authoring. Some feedback I received brought up letting a user define the shape position for the blobs to attract to. The ultimate ambition would be to be able to assume an arbitrary SDF shape.
A subsystem that lets the blobs wobble, perhaps in tandem with PBD collisions.
A container. The effect feels incomplete without a glass container. Preferably in a way that affects the dynamics of the blobs.