Poly Coding

Marching Cubes Part 3: Terraforming a custom terrain mesh with marching cubes

0. Introduction

1. Interactivity and Collision

2. Terraforming

3. Downloads

Marching Cubes Terrain

Terraformed terrain

0. Introduction

In the previous part we introduced a simple static mesh, made by applying marching cubes to a 3D grid of weights. To make the mesh a little more interesting, let’s allow our users to interact with it by clicking somewhere on the mesh and lowering or raising the mesh inside a radius.

1. Interactivity and Collision

1.1 Casting a ray from the camera

We will start with the ability to click on our mesh and displaying a brush with a given radius at the precise location that was clicked. To do this, create a new C# script, call it TerraformingCamera. Attach the newly created script to the main camera in the scene hierarchy.

The terraformingCamera is responsible for casting a ray from the mouse onto the mesh.

Start by defining 2 private fields: hit point and camera.

public class TerraformingCamera : MonoBehaviour
{
    Vector3 _hitPoint;
    Camera _cam;
}

On awake we get and assign the reference to our camera.

public class TerraformingCamera : MonoBehaviour
{
    private void Awake() {
        _cam = GetComponent<Camera>();
    }
}

To interact with the mesh, we have to shoot a ray from the mouse position towards the mesh. We can use Physics.Raycast for this.

Create a new method called terraform. This method requires a bool parameter called “add”. This parameter is responsible for telling us whether to raise/add to the terrain or lower/substract from it.

public class TerraformingCamera : MonoBehaviour
{
    private void Terraform(bool add) {
        RaycastHit hit;

        if (
            Physics.Raycast(_cam.ScreenPointToRay(Input.mousePosition), 
            out hit, 1000)
        ) {
            Chunk hitChunk = hit.collider.gameObject.GetComponent<Chunk>();

            _hitPoint = hit.point;

            print(_hitPoint.ToString());
        }
    }
}

The above code casts a ray from the mouse into the scene. When a collider is hit, we get the Chunk monobehaviour from the hit gameobject.

Note: Currently there is only a single type of collider we can hit, that being the chunk. In case you intent to have multiple non-chunk colliders in your scene, it would be best to specify a layermask for your raycast.

We also set the _hitPoint, this we can use to visualise where we hit our mesh.

To shoot this ray and start terraforming, call the Terraform method inside of the update method. When pressing the left mouse button, we want to raise the mesh. Right mouse will lower it.

public class TerraformingCamera : MonoBehaviour
{
    private void LateUpdate() {
        if (Input.GetMouseButton(0)) {
            Terraform(true);
        }
        else if (Input.GetMouseButton(1)) {
            Terraform(false);
        }
    }
}

To finish our terraforming script, create a new public field, “BrushSize”. The brush size is responsible for telling that what we want to terraform (chunk) how large the radius to terraform must be.

public class TerraformingCamera : MonoBehaviour
{
    public float BrushSize = 2f;
}

To visualise where we hit and what the radius of our hit is, draw a wiresphere at the hitpoint with radius brushsize.

public class TerraformingCamera : MonoBehaviour
{
    private void OnDrawGizmos() {
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(_hitPoint, BrushSize);
    }
}

Make sure Gizmos is enabled inside the scene viewport!

1.1 Collision detection when casting a ray

If we now start the game, we see that, even if we click on the mesh: nothing happens. This is because we have not yet enabled collision on our mesh. To do this we have to add a mesh collider to the Chunk object in the scene hierarchy.

File Structure

Now, open the Chunk script. Create a new field for the MeshCollider.

public class Chunk : MonoBehaviour
{
    public MeshCollider MeshCollider;
}

Don’t forget to assign the mesh collider in the scene view to the Chunk component.

Let’s rewrite some of our code. Whenever we call the ConstructMesh method, we create a new mesh, instead we are going to reuse the mesh each time we call the method.

To do this, once again, create a Mesh field and initialize it on start.

public class Chunk : MonoBehaviour
{
    Mesh _mesh;

    void Start() {
        ...

        _mesh = new Mesh();

        ...
    }
}

In the CreateMeshFromTriangles, instead of creating a mesh, clear our mesh field and assign the vertices and triangles to this field.

public class Chunk : MonoBehaviour
{
    Mesh CreateMeshFromTriangles(Triangle[] triangles) {
        ...

        // Mesh mesh = new Mesh();
        // mesh.vertices = verts;
        // mesh.triangles = tris;
        // mesh.RecalculateNormals();

        _mesh.Clear();
        _mesh.vertices = verts;
        _mesh.triangles = tris;
        _mesh.RecalculateNormals();
        return _mesh;
    }
}

Create a new method called UpdateMesh, this method is responsible for recreating the mesh and assigning the newly created mesh to the collider and filter.

public class Chunk : MonoBehaviour
{
    void UpdateMesh() {
        Mesh mesh = ConstructMesh();
        MeshFilter.sharedMesh = mesh;
        MeshCollider.sharedMesh = mesh;
    }
}

Call UpdateMesh on start.

public class Chunk : MonoBehaviour
{
    void Start() {
        _weights = NoiseGenerator.GetNoise();

        _mesh = new Mesh();

        // MeshFilter.sharedMesh = ConstructMesh();
        UpdateMesh();
    }
}

When entering play mode, we should now be able to see clicked positions printed to the console, aswell as a yellow sphere on the mesh (make sure Gizmos is enabled).

Collision

2. Terraforming

2.1 Update weights in the chunk

The only thing now left to do is ofcourse actually raising and lowering terrain.

Inside the chunk script add a new method, EditWeights.

public class Chunk : MonoBehaviour
{
    public void EditWeights(Vector3 hitPosition, float brushSize, bool add) {

    }

}

This method accepts a position, a brush size and a boolean add.

The hit position is the point we hit the mesh with our mouse, this is where we want to edit the mesh. The brush size is simply the radius we want to edit (in other words, we have a spherical brush that is the same as being drawn on screen by the OnDrawGizmos inside TerraformingCamera). And the “add” tells us whether to lower or raise the mesh.

The idea behind lowering and raising is simple: all we need to do is increase or decrease our weights (stored in the _weights property inside the chunk). In doing so, the new weights will be either above the iso threshold or below it. If you are unsure what an iso threshold is, have a look at the first part: Understanding the algorithm

We are going to reuse the marching cubes compute shader to edit the weights. So we are going to need a new kernel to pass data to and dispatch to. For now, assume there is a kernel (≈ method) called “UpdateWeights”.

We can find the index of the kernel by using the .FindKernel(“UpdateWeights”) method. This returns an int.

public class Chunk : MonoBehaviour
{
    public void EditWeights(Vector3 hitPosition, float brushSize, bool add) {
        int kernel = MarchingShader.FindKernel("UpdateWeights");
    }
}

Before moving on, we should also do this for the ConstructMesh method, this will avoid us headaches if we ever without knowing change the indexes of the kernels.

public class Chunk : MonoBehaviour
{
    Mesh ConstructMesh() {
        int kernel = MarchingShader.FindKernel("March");

        MarchingShader.SetBuffer(kernel, "_Triangles", _trianglesBuffer);
        MarchingShader.SetBuffer(kernel, "_Weights", _weightsBuffer);

        MarchingShader.SetInt("_ChunkSize", GridMetrics.PointsPerChunk);
        MarchingShader.SetFloat("_IsoLevel", .5f);

        _weightsBuffer.SetData(_weights);
        _trianglesBuffer.SetCounterValue(0);

        MarchingShader.Dispatch(kernel, GridMetrics.PointsPerChunk / GridMetrics.NumThreads, GridMetrics.PointsPerChunk / GridMetrics.NumThreads, GridMetrics.PointsPerChunk / GridMetrics.NumThreads);

        Triangle[] triangles = new Triangle[ReadTriangleCount()];
        _trianglesBuffer.GetData(triangles);

        return CreateMeshFromTriangles(triangles);
    }
}

To update the weights, the compute shader should be aware of a few things, namely: the current weights, the size of a chunk, where inside the chunk we are updating and how large the radius we are updating in is, whether to substract or add to the terrain.

Start by placing the current weights onto the weightsbuffer that we created last part. And binding the buffer to the UpdateWeights kernel.

public class Chunk : MonoBehaviour
{
    public void EditWeights(Vector3 hitPosition, float brushSize, bool add) {
        int kernel = MarchingShader.FindKernel("UpdateWeights");

        _weightsBuffer.SetData(_weights);
        MarchingShader.SetBuffer(kernel, "_Weights", _weightsBuffer);
    }
}

Now, we set the other parameters.

public class Chunk : MonoBehaviour
{
    public void EditWeights(Vector3 hitPosition, float brushSize, bool add) {
        ...
    
        MarchingShader.SetInt("_ChunkSize", GridMetrics.PointsPerChunk);
        MarchingShader.SetVector("_HitPosition", hitPosition);
        MarchingShader.SetFloat("_BrushSize", brushSize);
    }
}

Not to forget, we need to tell the shader to add or substract from the terrain. In this case I opted to use a very simple method of simple passing a single float: -1f or 1f. Every grid points weight that is inside of the brush will be increased or decreased with this “TerraformStrength” value.

public class Chunk : MonoBehaviour
{
    public void EditWeights(Vector3 hitPosition, float brushSize, bool add) {
        ...
    
        MarchingShader.SetFloat("_TerraformStrength", add ? 1f : -1f);
    }
}

Dispatch the shader like we did last part and take the now updated weights from the weights buffer. Update the mesh with our new weights.

public class Chunk : MonoBehaviour
{
    public void EditWeights(Vector3 hitPosition, float brushSize, bool add) {
        ...
    
        MarchingShader.Dispatch(kernel, GridMetrics.PointsPerChunk / GridMetrics.NumThreads, GridMetrics.PointsPerChunk / GridMetrics.NumThreads, GridMetrics.PointsPerChunk / GridMetrics.NumThreads);

        _weightsBuffer.GetData(_weights);

        UpdateMesh();
    }
}

2.2 Updating weights via compute shader

Now that the compute shader is dispatched, let’s actually implement it. Open the MarchingCubesCompute.compute. Create a new kernel called “UpdateWeights”.

#pragma kernel March
#pragma kernel UpdateWeights

Below the includes, add the parameters we passed along from inside the C# chunk script.

float3 _HitPosition;
float _BrushSize;
float _TerraformStrength;

At the bottom of the compute file, create the UpdateWeights kernel method.

[numthreads( numThreads, numThreads, numThreads )]
void UpdateWeights( uint3 id : SV_DispatchThreadID ) 
{

}

Just like in the March method, first check if the id is actually within the valid chunk size range.

[numthreads( numThreads, numThreads, numThreads )]
void UpdateWeights( uint3 id : SV_DispatchThreadID ) 
{
    if ( id.x >= _ChunkSize - 1 || id.y >= _ChunkSize - 1 || id.z >= _ChunkSize - 1 )
    {
        return;
    }
}

Next, we want to check if the distance between the hit position and the position we are currently evaluating in the compute shader execution (which is defined by the id parameter) is inside the radius of our brush.

For this, we use the already built-in function: distance. If the distance is smaller or equal to the brush size, we are currently editing a weight inside the brush, and so we have to add or substract from the weight.

[numthreads( numThreads, numThreads, numThreads )]
void UpdateWeights( uint3 id : SV_DispatchThreadID ) 
{
    ...

    if ( distance( id, _HitPosition ) <= _BrushSize )
    {
        _Weights[indexFromCoord( id.x, id.y, id.z )] += _TerraformStrength;
    }
}

2.3 Finishing touch

Return to the TerraformingCamera and call the EditWeights function.

public class TerraformingCamera : MonoBehaviour
{
    private void Terraform(bool add) {
        RaycastHit hit;

        if (Physics.Raycast(_cam.ScreenPointToRay(Input.mousePosition), out hit, 1000)) {
            Chunk hitChunk = hit.collider.gameObject.GetComponent<Chunk>();

            _hitPoint = hit.point;

            hitChunk.EditWeights(_hitPoint, BrushSize, add);
        }
    }
}

That is it! Return to Unity and start the game. You should now be able edit the terrain by clicking on it.

3. Downloads

Final project