Poly Coding

Marching Cubes Part 4: Level of detail with marching cubes

0. Introduction

1. Level of detail

2. Possible problems

3. Downloads

Marching Cubes Terrain

The same terrain at a different level of detail

0. Introduction

This is the fourth part of a series on marching cubes. This time we will make it possible to pick between the level of detail (or resolution) of a mesh while sticking to a fixed physical size for the mesh.

1. Level of detail

The level of detail determines the complexity of a mesh. When a mesh is very far away from us, there is no need for the terrain mesh to be highly detailed, so we replace it with a less detailed mesh that still represents the same outlines as the higher detailed one.

Detailed here indicates the amount of vertices and triangles in a mesh.

1.1 Cleaning up

Before starting with a level of detail system, we should make it possible to regenerate a chunk whenever we want. Currently we only generate a chunk at start.

In our chunk.cs, rename our start method to “Create”. Call this new create method from start.

public class Chunk : MonoBehaviour
{
    private void Start() {
        Create();
    }

    void Create() {
         _weights = NoiseGenerator.GetNoise();

        _mesh = new Mesh();

        UpdateMesh();
    }
}

To allow for chunks and noise to be regenerated, we remove our Awake and OnDestroy method, and instead we create and release the buffers at the start and end of the create method.

private void Awake() {
    CreateBuffers();
}

private void OnDestroy() {
    ReleaseBuffers();
}
public class Chunk : MonoBehaviour
{
    
    void Create() {
        CreateBuffers();

         _weights = NoiseGenerator.GetNoise();

        _mesh = new Mesh();

        UpdateMesh();

        ReleaseBuffers();
    }

}

The same goes for the EditWeights method.

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

        ...

        ReleaseBuffers();
    }

}

Lastly, we can do the same in the noise generator.

private void Awake() {
    CreateBuffers();
}

private void OnDestroy() {
    ReleaseBuffers();
}
public class NoiseGenerator : MonoBehaviour
{
    
    public float[] GetNoise() {
        CreateBuffers();

        ...


        ReleaseBuffers();

        return noiseValues;
    }

}

Next up, we will simplify the dispatching of compute shaders. As you might have noticed, currently we repeat the line

GridMetrics.PointsPerChunk / GridMetrics.NumThreads

quite often. We could however reduce this. In GridMetrics create a new constant variable for this.

public static class GridMetrics {
    public const int NumThreads = 8;
    public const int PointsPerChunk = 32;
    public const int ThreadGroups = PointsPerChunk / NumThreads;
}

Now, we have to replace every instance of the PointsPerChunk / NumThreads with ThreadGroups.

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

    Mesh ConstructMesh() {
        ...
        MarchingShader.Dispatch(kernel, GridMetrics.ThreadGroups, GridMetrics.ThreadGroups, GridMetrics.ThreadGroups);
        ...
    }
}

public class NoiseGenerator : MonoBehaviour {

    public float[] GetNoise() {
        ...
        NoiseShader.Dispatch(
            0, GridMetrics.ThreadGroups, GridMetrics.ThreadGroups, GridMetrics.ThreadGroups
        );
        ...
    }
}

1.2 Multiple chunk sizes

The chunk size currently represents both the amount of points along one dimension (width, depth and height) as well as the physical size.

We will start with being able to increase the amount of points and than later make sure these points stay within a fixed physical size.

For this we will predefine a list of possible chunk sizes.

public static class GridMetrics {
    public const int NumThreads = 8;
    public const int PointsPerChunk = 32;
    public const int ThreadGroups = PointsPerChunk / NumThreads;

    public static int[] LODs = {
		8,
		16,
		24,
		32,
		40
	};
}

Remember, to avoid the GPU doing to much work, we should stick to chunk size that are divisible by 8.

Of course, now our ThreadGroups and PointsPerChunk don’t interact with the LODs, to fix this, we can make them into a static function.

public static class GridMetrics {
    public const int NumThreads = 8;

    public static int[] LODs = {
		8,
		16,
		24,
		32,
		40
	};

    public static int PointsPerChunk(int lod) {
        return LODs[lod];
    }

    public static int ThreadGroups(int lod) {
        return LODs[lod] / NumThreads;
    }
}

Now we have to tell the chunk what level of detail it is on.

public class Chunk : MonoBehaviour {
    [Range(0, 4)]
    public int LOD;
}

And of course after all these changes we have many errors. Currently our code still asumes we are working with a variable called PointsPerChunk, but this is now a function. So we have to give the function its parameter, the LOD.

public class Chunk : MonoBehaviour {

    public void EditWeights(Vector3 hitPosition, float brushSize, bool add) {
        ...
        MarchingShader.SetInt("_ChunkSize", GridMetrics.PointsPerChunk(LOD));
        ...
        MarchingShader.Dispatch(kernel, GridMetrics.ThreadGroups(LOD), GridMetrics.ThreadGroups(LOD), GridMetrics.ThreadGroups(LOD));
        ...        
    }

    Mesh ConstructMesh() {
        ...

        MarchingShader.SetInt("_ChunkSize", GridMetrics.PointsPerChunk(LOD));

        ...

        MarchingShader.Dispatch(kernel, GridMetrics.ThreadGroups(LOD), GridMetrics.ThreadGroups(LOD), GridMetrics.ThreadGroups(LOD));

        ...
    }

    void CreateBuffers() {
        _trianglesBuffer = new ComputeBuffer(5 * (GridMetrics.PointsPerChunk(LOD) * GridMetrics.PointsPerChunk(LOD) * GridMetrics.PointsPerChunk(LOD)), Triangle.SizeOf, ComputeBufferType.Append);
        _trianglesCountBuffer = new ComputeBuffer(1, sizeof(int), ComputeBufferType.Raw);
        _weightsBuffer = new ComputeBuffer(GridMetrics.PointsPerChunk(LOD) * GridMetrics.PointsPerChunk(LOD) * GridMetrics.PointsPerChunk(LOD), sizeof(float));
    }
}

At this point we can remove the OnDrawGizmos method if you haven’t already, as we will no longer use it.

    private void OnDrawGizmos() {
        if (_weights == null || _weights.Length == 0) {
            return;
        }
        for (int x = 0; x < GridMetrics.PointsPerChunk; x++) {
            for (int y = 0; y < GridMetrics.PointsPerChunk; y++) {
                for (int z = 0; z < GridMetrics.PointsPerChunk; z++) {
                    int index = x + GridMetrics.PointsPerChunk * (y + GridMetrics.PointsPerChunk * z);
                    float noiseValue = _weights[index];
                    Gizmos.color = Color.Lerp(Color.black, Color.white, noiseValue);
                    Gizmos.DrawCube(new Vector3(x, y, z), Vector3.one * .2f);
                }
            }
        }
    }

The remaining errors are in the noise generator. The generator is a however a singleton (in other words, there only exist one, even if there would be many chunks). To solve this, simply give our LOD as a parameter.

public class NoiseGenerator : MonoBehaviour {

    public float[] GetNoise(int lod) {
        ...
    }
}

Just as before, use the parameter to get the appropriate values.

public class NoiseGenerator : MonoBehaviour {

    public float[] GetNoise(int lod) {
        ...

        float[] noiseValues =
            new float[GridMetrics.PointsPerChunk(lod) * GridMetrics.PointsPerChunk(lod) * GridMetrics.PointsPerChunk(lod)];

        ...

        NoiseShader.SetInt("_ChunkSize", GridMetrics.PointsPerChunk(lod));

        ...

        NoiseShader.Dispatch(
            0, GridMetrics.ThreadGroups(lod), GridMetrics.ThreadGroups(lod), GridMetrics.ThreadGroups(lod)
        );
    }
}

The create buffers also requires the lod value, so provide a parameter for it as well.

public class NoiseGenerator : MonoBehaviour {

    public float[] GetNoise(int lod) {
        CreateBuffers(lod);
        
        ...
    }

    void CreateBuffers(int lod) {
        _weightsBuffer = new ComputeBuffer(
            GridMetrics.PointsPerChunk(lod) * GridMetrics.PointsPerChunk(lod) * GridMetrics.PointsPerChunk(lod), sizeof(float)
        );
    }
}

Back in the Chunk script we now have to provide the GetNoise call with the LOD.

public class Chunk : MonoBehaviour {

    void Create() {
        ...
        
        _weights = NoiseGenerator.GetNoise(LOD);
        
        ...
    }

Before going into game, add an OnValidate function to the chunk. The OnValidate method is a unity built-in method that gets called whenever a value in our script changes via the editor. In other words, when we change the LOD slider in the editor the OnValidate method is called.

public class Chunk : MonoBehaviour {

    private void OnValidate() {
        if (Application.isPlaying) {
            Create();
        }
    }
}

1.3 Fixed size

While we now have the ability to increase the size of a chunk via a slider, this is not actually level of detail. Level of detail means we cover a fixed physical chunk size with a variable amount of points. To facilitate a fixed size, create a new constant in our metrics, call it Scale. This scale represents the physical size. So if we have a scale of 20, our mesh always spans 20 meters, regardless of the amount of points along a dimension (represented by the chunk size in our case).

public static class GridMetrics {

    public const int Scale = 32;
}

We now have to pass this variable to our MarchingShader in the chunk script.

public class Chunk : MonoBehaviour {

    Mesh ConstructMesh() {
        ...
        
        MarchingShader.SetInt("_Scale", GridMetrics.Scale);

        ...

    }
}

Similarly, add a _Scale function to the MetricsCompute.compute.

int _Scale;

All that is left to do is to scale down (or up) our points when adding them to a triangle inside the MarchingCubesCompute.

The formula is as follows:

scaledPoint = unscaledPoint / ( ChunkSize - 1 ) * Scale

In the first step we take an unscaledPoint and scale it into the range of [0, 1] by dividing the unscaled point by the ChunkSize - 1 (note: Our points range from 0 to ChunkSize - 1, not from 1 to ChunkSize).

We then scale this [0, 1] range to a [0, Scale] range by multiplying with the Scale variable.

void March(uint3 id : SV_DispatchThreadID) {

    ...

    for (int i = 0; edges[i] != -1; i += 3)
    {
        ...

        Triangle tri;
        tri.a = (interp(cornerOffsets[e00], cubeValues[e00], cornerOffsets[e01], cubeValues[e01]) + id) / (_ChunkSize - 1) * _Scale;
        tri.b = (interp(cornerOffsets[e10], cubeValues[e10], cornerOffsets[e11], cubeValues[e11]) + id) / (_ChunkSize - 1) * _Scale;
        tri.c = (interp(cornerOffsets[e20], cubeValues[e20], cornerOffsets[e21], cubeValues[e21]) + id) / (_ChunkSize - 1) * _Scale;
        _Triangles.Append(tri);         
    }
}

While the marching cubes is now scaled, the noise is not.

public class NoiseGenerator : MonoBehaviour {

    public float[] GetNoise(int lod) {
        ...
        
        NoiseShader.SetInt("_Scale", GridMetrics.Scale);

        ...

    }
}

The formula can be reused in the NoiseCompute as well.

void GenerateNoise(uint3 id : SV_DispatchThreadID)
{
    ...

    float3 pos = (id * _NoiseScale) / (_ChunkSize - 1) * _Scale;
    
    ...
}

Note that it is not the id that should be scaled, just like with the triangles we first apply all variables like we did before and only once that is done we scale the point.

Both the noise and mesh are scaled appropriatly to fill a fixed size using a variable amount of points. However there is one problem with the way we generate noise. Take a close look at the video above and then at this line of the noise compute.

float ground = -pos.y + (_GroundPercent * _ChunkSize);

You might notice that when the LOD increases, so does the ground level. This is because the ChunkSize variable increases.

Assume we have a GroundPercent of 0.5. This means that our ground level at LOD 0 (ChunkSize = 8) is equal to 4. When we use LOD 4 (ChunkSize = 40), the ground level is equal to 20. When in a later tutorial we want to implement an infinite terrain system, we of course want the chunks to match up and so the noise requires a stable ground level.

The fix is simple, the ground level should not be relative to the chunk size.

public static class GridMetrics {
    public const int GroundLevel = Scale / 2;
}
public class NoiseGenerator : MonoBehaviour
{
    public float[] GetNoise(int lod) {
        ...

        NoiseShader.SetInt("_GroundLevel", GridMetrics.GroundLevel);
    
        ...
    }
}

Then in the noise compute.

int _GroundLevel;

[numthreads(numThreads, numThreads, numThreads)]
void GenerateNoise(uint3 id : SV_DispatchThreadID)
{
    ...
    
    float ground = -pos.y + (_GroundPercent * _GroundLevel);
    
    ...
}

For ease, I decided on using a ground level relative to the scale. This can of course can be relative to any value or not relative to anything at all.

1.4 Interactivity

At this point when painting our terrain, we can get some unexpected behaviours, this is because we dit not yet down- or upscale any positions in the painting process.

Note: Currently I will not be going into painting a chunk and keeping track of its changes in other LOD’s, this will be covered later when we start working with multiple chunks.

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

        MarchingShader.SetInt("_Scale", GridMetrics.Scale);

        ...
    }        
}

Before using the formula

scaledPoint = unscaledPoint / ( ChunkSize - 1 ) * Scale

We need to make sure that one of the variables of the formula is a float, otherwise we will not be able to get floating point numbers. I decided on casting the id to a float3.

Note: In the March method this float3 value is achieved by the call to the interp method.

void UpdateWeights( uint3 id : SV_DispatchThreadID )
{
    if ( distance((float3(id) / (_ChunkSize - 1) * _Scale), _HitPosition ) <= _BrushSize )
    {
        _Weights[indexFromCoord( id.x, id.y, id.z )] += _TerraformStrength;
    }
}

That concludes the level of detail system for this tutorial.

2. Possible problems

While at this point you should have a working LOD system, there seems to exist a bug in the Unity engine that makes it so that the NoiseCompute and the MarchingCompute seem unable to find the _Scale variable defined in the MetricsCompute.

There can be two solutions:

1) You made a typo somewhere in the shader and the problem is not actually related to the _Scale.

2) Try removing the line(s) with the _Scale variable and putting it back afterwards.

3. Downloads

Final project