We're back! With Unity, compute shaders and some delicious metaballs

Published May 31, 2019
Advertisement

It has been quite some time since we posted something. And it's true, for the most part we didn't really do much. Work on Lyfe just wasn't such a high priority as university for example. But we're back.
And with that we switched engines. To be fair, there wasn't much work we had to replicate. Most of the time we invested so far was learning how to handle simple things in Unreal when we all had way more experience in working with Unity. A mistake. But I think we're on the right path now.
This of course means there is no real gameplay footage to show. The actual gameplay is simply less of a priority compared to the editor so we're focusing on that and trying to get a stable version running.

For more information on the concept behind the cell editor or at least the basic sculpting check out this entry. I won't go into detail about it here. Today I will focus on the technical side of implementing metaballs and having the calculation run on your GPU rather than your CPU. I'm doing this partly to show we're actually working again but als since it took me way too long to figure out how this works and I didn't have good resources. So feel free to use our code and adapt it to your needs.

Anybody here who doesn't know how metaballs work or what they even are? Here are some links for you if you're interested:

metaballs (what even are they?)

metaballs in blender (a bit more detailed)

maths behind metaballs

 

Let's start with the basics:

The basic shape of the cell is formed by nodes that are connected to each other. Each of these nodes is the center/anchor for a metaball. This means each of these nodes exudes a charge in the field around it. This field is simply represented by points in 3d space. If two nodes are close to each other the charges sum up. This results in smooth transitions between blobs/metaballs. For more details click on the third link or write a comment.

 

On to the C# script of our editor. This is where we prepare our data and read the chargefield once it's done computing.


private float[] CalculateCharges(Vector3Int _resolution)
    {
        float[] charges = new float[_resolution.z * _resolution.y * _resolution.x];

        List<SNode> nodes = baseNode.GetAllChildNodeMetaData(new List<SNode>());

        int kernel = cs_Charges.FindKernel("CSMain");
        ComputeBuffer nodeBuffer = new ComputeBuffer(nodes.Count, SNODE_SIZE);
        nodeBuffer.SetData(nodes.ToArray());

        ComputeBuffer chargeBuffer = new ComputeBuffer(charges.Length, sizeof(float));
        chargeBuffer.SetData(charges);

        cs_Charges.SetBuffer(kernel, "nodes", nodeBuffer);
        cs_Charges.SetBuffer(kernel, "charges", chargeBuffer);
        cs_Charges.SetVector("EDITOR_GRID_DIMENSION", new Vector4(_resolution.x, _resolution.y, _resolution.z, Meta_CellEditor.SCULPTING.GRID.SCALE));
        cs_Charges.SetVector("basePos", transform.position);
        cs_Charges.SetInt("numNodes", nodes.Count);

        cs_Charges.Dispatch(kernel, _resolution.x / 8, _resolution.y / 8, _resolution.z / 8);

        chargeBuffer.GetData(charges);


        chargeBuffer.Dispose();
        nodeBuffer.Dispose();


        return charges;
    }

So. Since the basis for metaballs is chargefield this method takes a Vector3 for the resolution of this field. We then make a float array of that size to give to our compute shader and have it filled there.


List<SNode> nodes = baseNode.GetAllChildNodeMetaData(new List<SNode>());

This line simply collects all child nodes and stores their metadata in a simple struct that can be transferred to the shader.


public struct SNode
{
      public Vector3 position;
      public Vector3 distortion;
      public float cubePortion;
      public float radius;
}

This is how the struct looks. The information in cludes the position within the charge field with 0/0/0 being the center. The distortion/deformation of the sphere so if it's more of an oval. The cube portion, so if it's more of a sphere or a cube (not fully implemented yet) and the radius. Pretty simple stuff.


int kernel = cs_Charges.FindKernel("CSMain");

Next we find our kernel within our shader so we can pass values to it and run it. The actually initialized this shader in an Init method beforehand.


cs_Charges = Resources.Load<ComputeShader>("Shaders/cs_Charges");

ComputeBuffer nodeBuffer = new ComputeBuffer(nodes.Count, SNODE_SIZE);
nodeBuffer.SetData(nodes.ToArray());

For this next part I can only recommend using ComputeBuffers. Just initialize it with the count of elements and the size of each element. In our case this is the size of 8 floats. After that we push our data into the buffer.


ComputeBuffer chargeBuffer = new ComputeBuffer(charges.Length, sizeof(float));
chargeBuffer.SetData(charges);

We do exactly the same thing for our charges. And this is where buffers come in handy: You can not only use them to get data into your shader but also to read it from there if you write it into a buffer.


cs_Charges.SetBuffer(kernel, "nodes", nodeBuffer);
cs_Charges.SetBuffer(kernel, "charges", chargeBuffer);
cs_Charges.SetVector("EDITOR_GRID_DIMENSION", new Vector4(_resolution.x, _resolution.y, _resolution.z, Meta_CellEditor.SCULPTING.GRID.SCALE));
cs_Charges.SetVector("basePos", transform.position);
cs_Charges.SetInt("numNodes", nodes.Count);

Next we set the buffers in our shader. It's important that every one of these strings corresponds to a variable of the same type in your shader. If you only want to transfer a single value, you don't need to use the big ComputeBuffers. The simple methods SetVector/SetInt/SetFloat will do.

This is actually where the naming of your variables is shown to not be the best: resolution isn't actually the relsolution but rather the dimension and together with the scale makes up the resolution.


cs_Charges.Dispatch(kernel, _resolution.x / 8, _resolution.y / 8, _resolution.z / 8);

And this is where we actually run our shader code. I'll behonest here and tell you that I'm not quite sure how these last 3 parameters work but so much: each one stands for the number of core groups. So basically we're using 8 groups of 8 groups of 8. So we divide what we need to calculate to split it evenly on all these cores. This becomes really important if you want the parallelization to work.


chargeBuffer.GetData(charges);


chargeBuffer.Dispose();
nodeBuffer.Dispose();


return charges;

At the end we simply read the data our shader stored in the buffer and dispose our buffers to free the memory. Anybody familiar with C or C++ knows this is to prevent memory leaks.

 

That is all the C# code basically. Simple stuff and no maths. It only get's worse from here on.


// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel CSMain

struct Node
{
	float3 position;
	float3 distortion;
	float cubePortion;
	float radius;
};

StructuredBuffer<Node> nodes; //node positions in world space
int numNodes;
float4 EDITOR_GRID_DIMENSION; //xyz are dimension, w is scale
float3 basePos; //position of the base node
RWStructuredBuffer<float> charges;


float3 GridPosToLocalPos(float3 _gridPos)
{
	_gridPos -= EDITOR_GRID_DIMENSION.xyz / 2.f;

	_gridPos *= EDITOR_GRID_DIMENSION.w;

	return _gridPos + basePos;
}

float CalculateCubeCharge(Node _node, float3 _voxelPos)
{
	return 0;
}

float CalculateSphereCharge(Node _node, float3 _voxelPos)
{
	float3 d;

	d.x = pow(_voxelPos.x - _node.position.x, 2);
	d.y = pow(_voxelPos.y - _node.position.y, 2);
	d.z = pow(_voxelPos.z - _node.position.z, 2);

	float r_squared = (d.x * _node.distortion.x + d.y * _node.distortion.y * 12.f + d.z * _node.distortion.z) / pow(_node.radius * 12, 2);

	if (r_squared < 0.5f)
	{
		return 1.f - r_squared + pow(r_squared, 2);
	}
	else
	{
		return 0;
	}
}

float CalculateCharge(Node _node, float3 _voxelPos)
{
	float r = (_node.cubePortion * CalculateCubeCharge(_node, _voxelPos)) + ((1.f - _node.cubePortion) * CalculateSphereCharge(_node, _voxelPos));

	return r;
}

[numthreads(8,8,8)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
	float3 voxel = id;

	float3 voxelPos = GridPosToLocalPos(voxel);

	for (int n = 0; n < numNodes; n++)
	{
		int index = id.z * (EDITOR_GRID_DIMENSION.x * EDITOR_GRID_DIMENSION.y)
			+ id.y * EDITOR_GRID_DIMENSION.x + id.x;

		charges[index] += CalculateCharge(nodes[n], voxelPos);
	}
}

So. This is the entirety of our shader code for the metaballs. We still need to tweak some of the static values so please, feel free to experiment when using this code.

The struct at the top is basically our SNode.

After that our buffers and set values follow. Note that nodes is just a StructuredBuffer while charges is a RWStructuredBuffer. The RW means read-write. So the shader can not only read the values stored but also write in there.

I think the best way to go through this is from top to bottom.


float3 GridPosToLocalPos(float3 _gridPos)
{
	_gridPos -= EDITOR_GRID_DIMENSION.xyz / 2.f;

	_gridPos *= EDITOR_GRID_DIMENSION.w;

	return _gridPos + basePos;
}

This method simply turns a gridposition to a position in local space. Easy as that.

Calculate cube charges is not implemented yet. (sorryyyyyy... We'll add how that works once we got it figured out.)


float CalculateSphereCharge(Node _node, float3 _voxelPos)
{
	float3 d;

	d.x = pow(_voxelPos.x - _node.position.x, 2);
	d.y = pow(_voxelPos.y - _node.position.y, 2);
	d.z = pow(_voxelPos.z - _node.position.z, 2);

	float r_squared = (d.x * _node.distortion.x + d.y * _node.distortion.y + d.z * _node.distortion.z) / pow(_node.radius, 2);

	if (r_squared < 0.5f)
	{
		return 1.f - r_squared + pow(r_squared, 2);
	}
	else
	{
		return 0;
	}
}

And here we simply calculate the charge one node exudes on one point in space. I myself am not sure about all the maths in there but again, you can read up on that by clicking on the third link at the begininng of this.


float CalculateCharge(Node _node, float3 _voxelPos)
{
	float r = (_node.cubePortion * CalculateCubeCharge(_node, _voxelPos)) + ((1.f - _node.cubePortion) * CalculateSphereCharge(_node, _voxelPos));

	return r;
}

This function again calculates the charge on one point from one node but factors in the cube and sphere portion of the corresponding node.


[numthreads(8,8,8)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
	float3 voxel = id;

	float3 voxelPos = GridPosToLocalPos(voxel);

	for (int n = 0; n < numNodes; n++)
	{
		int index = id.z * (EDITOR_GRID_DIMENSION.x * EDITOR_GRID_DIMENSION.y)
			+ id.y * EDITOR_GRID_DIMENSION.x + id.x;

		charges[index] += CalculateCharge(nodes[n], voxelPos);
	}
}

And finally we come to the actual core method. And here we find our 8/8/8 again. These values need to correspond to the ones when dispatching the shader. Otherwise you won't be able to use the id correctly. In your case the id points to the point in space we want to calculate the charge for.

We use our GridPosToLocalPos here. This is important or all our beautiful metaball would be anywhere but where we want them to be.

Below that we iterate over all nodes and sum up their influences on this one point in space.

For a 200 * 200 * 10 chargefield with 10 nodes in it this code runs in less than a second. And that's why we use compute shaders, children. But for real, this would not be feasable when running on a CPU. But I'm 100% sure this code could be improved even further.

Anyway, I hope this code helps someone who wanted to use compute shaders but didn't know how or wanted to implement metaballs.

 

Cheers and keep on evolving.

2 likes 0 comments

Comments

Nobody has left a comment. You can be the first!
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Profile
Author
Advertisement
Advertisement