0
\$\begingroup\$

I am trying to achieve smooth biomes blending for my procedural 3D terrain.

I've been doing some research on this creating biomes topic and I found some good sources that talk about creating biomes by the use of generated moisture and temperature maps.

I found this table, that I use as an example.

enter image description here

So far I've done something like this. I've defined regions that are the horizontal row (temperature)

[Serializable]
public struct Region
{
    [SerializeField] string type;

    [Range(0, 1)]
    public float temperature;
    public RegionBiome[] biomes;
}
[Serializable]
public class RegionBiome
{
    [SerializeField] string type;

    [Range(0, 1)]
    public float moisture;
    public Biome biome;
}

And biomes that are associated with the region

[CreateAssetMenu]
public class Biome : ScriptableObject
{
    [SerializeField] new string name;

    public Color color;

    [Header("Noise")]
    public float scale;
    public Wave[] waves;

    public float Sample(float x, float y)
    {
        return Noise.GetPerlinNoise(x, y, scale, 0, 0, waves);
    }
}

I have set up the exact biomes table like in the picture.

enter image description here

Then to generate a heightmap I have firstly generated heat and moisture maps and looped through the entire map and sampled each coordinate to get the evaluation

void Generate()
{
    float[,] temperatureMap = GenerateNoiseMap(size, 50f, 0f, 0f, new Wave[2] { new Wave(seed * -2, 1, 2), new Wave(seed * 2, .5f, 1) });
    float[,] moistureMap = GenerateNoiseMap(size, 35f, 0f, 0f, new Wave[2] { new Wave(seed * -.5f, 1, 2), new Wave(seed * .5f, .5f, 1) });

    float[,] heightMap = new float[size, size];

    Color[] colorMap = new Color[size * size]; Color color;
    for (int y = 0; y < size; y++)
    {
        for (int x = 0; x < size; x++)
        {
            float heat = temperatureMap[x, y];
            float moisture = moistureMap[x, y];

            heightMap[x, y] = GetEvaluation(x, y, heat, moisture, out color);
            colorMap[x + (y * size)] = color;
        }
    }
    CreateOutput(colorMap, colorOutput, result1);
    for (int i = 0; i < colorMap.Length; i++)
    {
        float value = heightMap[i % size, i / size];
        colorMap[i] = new Color(value, value, value, 1);
    }
    CreateOutput(colorMap, noiseOutput, result2);
}

And this is the evaluation function

float GetEvaluation(float x, float y, float heat, float moisture, out Color color)
{
    int regionIndex = -1;
    for (int i = 0; i < regions.Length; i++)
    {
        if (i < regions.Length - 1)
        {
            if (heat >= regions[i].temperature && heat < regions[i + 1].temperature)
            {
                regionIndex = i;
                break;
            }
        }
        else
        {
            if (heat == 1)
            {
                regionIndex = i;
                break;
            }
            regionIndex = i - 1;
        }
    }
    int biomeIndex = -1;
    for (int i = 0; i < regions[regionIndex].biomes.Length; i++)
    {
        if (i < regions[regionIndex].biomes.Length - 1)
        {
            if (moisture >= regions[regionIndex].biomes[i].moisture && moisture < regions[regionIndex].biomes[i + 1].moisture)
            {
                biomeIndex = i;
                break;
            }
        }
        else
        {
            if (moisture == 1)
            {
                biomeIndex = i;
                break;
            }
            biomeIndex = i - 1;
        }
    }
    color = regions[regionIndex].biomes[biomeIndex].biome.color;

    float evaluation = regions[regionIndex].biomes[biomeIndex].biome.Sample(x, y);

    float currHeat = regions[regionIndex].temperature;
    float destHeat = heat == 1 ? currHeat : regions[regionIndex + 1].temperature;

    float heatBlend = 0;
    if (destHeat != currHeat)
    {
        heatBlend = Mathf.InverseLerp(currHeat, destHeat, heat);
        evaluation = Mathf.Lerp(evaluation, regions[regionIndex + 1].biomes[biomeIndex].biome.Sample(x, y), heatBlend);
    }

    float currMoisture = regions[regionIndex].biomes[biomeIndex].moisture;
    float destMoisture = moisture == 1 ? currMoisture : regions[regionIndex].biomes[biomeIndex + 1].moisture;

    float moistureBlend = 0;
    if (destMoisture != currMoisture)
    {
        moistureBlend = Mathf.InverseLerp(currMoisture, destMoisture, moisture);
        evaluation = Mathf.Lerp(evaluation, regions[regionIndex].biomes[biomeIndex + 1].biome.Sample(x, y), moistureBlend);
    }

    if (heat < 1 && moisture < 1)
    {
        float cornerBlend = Mathf.Clamp01(heatBlend + moistureBlend);
        evaluation = Mathf.Lerp(evaluation, regions[regionIndex + 1].biomes[biomeIndex + 1].biome.Sample(x, y), cornerBlend);
    }
    return evaluation;
}

And these are some utility functions that I used

private float[,] GenerateNoiseMap(int size, float scale, float offsetX, float offsetZ, Wave[] waves)
{
    return Noise.GeneratePerlinNoiseMap(size, size, scale, offsetX, offsetZ, waves);
}
void CreateOutput(Color[] colorMap, RawImage image, Texture2D tex)
{
    tex.SetPixels(colorMap); tex.Apply();
    image.texture = tex;
}

And the noise class for generating Perlin noise maps

public static class Noise
{
    public static float[,] GeneratePerlinNoiseMap(int mapDepth, int mapWidth, float scale, float offsetX, float offsetZ, Wave[] waves)
    {
        float[,] noiseMap = new float[mapDepth, mapWidth];

        for (int zIndex = 0; zIndex < mapDepth; zIndex++)
        {
            for (int xIndex = 0; xIndex < mapWidth; xIndex++)
            {
                float sampleX = (xIndex + offsetX) / scale;
                float sampleZ = (zIndex + offsetZ) / scale;

                float noise = 0f;
                float normalization = 0f;
                foreach (Wave wave in waves)
                {
                    noise += wave.amplitude * Mathf.PerlinNoise(sampleX * wave.frequency + wave.seed, sampleZ * wave.frequency + wave.seed);
                    normalization += wave.amplitude;
                }
                noise /= normalization;

                noiseMap[zIndex, xIndex] = noise;
            }
        }
        return noiseMap;
    }
    public static float GetPerlinNoise(float x, float y, float scale, float offsetX, float offsetZ, Wave[] waves)
    {
        float sampleX = (x + offsetX) / scale;
        float sampleZ = (y + offsetZ) / scale;

        float noise = 0f;
        float normalization = 0f;
        foreach (Wave wave in waves)
        {
            noise += wave.amplitude * Mathf.PerlinNoise(sampleX * wave.frequency + wave.seed, sampleZ * wave.frequency + wave.seed);
            normalization += wave.amplitude;
        }
        noise /= normalization;

        return noise;
    }
}

The way I get the final height doesn't seem to be working correctly as the biomes noises are not interpolated correctly. Also, the color map doesn't seem right as well...

The color map

enter image description here

The height map

enter image description here

How do I blend the biomes? I'm definitely missing something here and can't wrap my head around it... What do I and how do I weigh the final evaluation value?

\$\endgroup\$
2
  • \$\begingroup\$ Multiple 1 ? in your code suggest it may be bugged. \$\endgroup\$ Apr 20 at 0:00
  • \$\begingroup\$ You'd be more likely to get a suitable answer if we knew exactly what you need to achieve in terms of your design. Do you need continuous sampling as from perlin noise? Would a grid-based approach do the job? - as this would be much easier to implement, though you would need attempt to smooth out axis-aligned artifacts as @KdotJPG mentions. \$\endgroup\$
    – Engineer
    Sep 17 at 13:31
1
\$\begingroup\$

Blending biomes is tricky. Especially if you want it to be fast and not show any grid structure. I wrote an article about this recently: https://noiseposti.ng/posts/2021-03-13-Fast-Biome-Blending-Without-Squareness.html The java code is linked at the bottom, is under CC0, and should be fairly easy to port to C#.

Basically the algorithm is as follows:

  • Define a function GetBiomeAt(x, z) which queries your biome map at that particular location. It is important that it be implemented like this, and not as a pre-populated array.
  • Scatter a bunch of points a la Voronoi noise. I used a triangular grid with a constant jitter length with random jitter direction.
  • Pick a radius r big enough to always contain points. Define a falloff function based on squared Euclidean distance, which goes to zero at the radius and stays there. I use max(0, r² - Δx² - Δz²)², where Δx,Δz are the relative offsets of your evaluation point to a given scattered point.
  • Have some way of querying at least all points that are in range of a given generation chunk, but not too many. Sample the biome at each point.
  • For each point, add its weight from the falloff function to its specific biome's accumulating weight value for that coordinate. Also add it to the total. At the end, divide each biome's weight by that total.
  • For each biome and weight at a given world coordinate, do a weighted addition of each biome's noise formula.

point sampling blended biome heightmap

As an accompanying note to all of this, I don't recommend using the Mathf.PerlinNoise function in Unity, or any unmitigated Perlin function. Notice how, in your images, almost every feature is aligned 45 or 90 degrees relative to the coordinate space. This is an artifact of the Perlin function, and is an unneccessary compromise. Patterns that emulate nature like this should ideally be as isotropic (directionally unbiased) as possible.

It's better to import a library that supports better noise. FastNoiseLite is the one I recommend, and I described particular usage patterns in more detail in this other answer of mine.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.