A Noisy World — Obsidian Server’s Minecraft-Like World Generation Pt. 3

Jon Proietti
6 min readNov 12, 2021

--

Actually, scratch that

Did I mention that Obsidian is under heavy active development and that things can and will change rapidly? Yeah…

The Problem

Overlaying noise maps to create biomes has a fatal flaw. The curviness of the individual noise maps means that some biomes will come to a point or otherwise end in weird, unnatural shapes, like so:

Noise map biomes :(

So I took a few weeks to give the whole thing a rethink… I need better looking biome edges. I started looking at other options for noise and decided to try out Voronoi Cell noise. I read through this article and thought about how to apply this to Obsidian. Honestly, cell noise already looks a lot like biomes.

Voronoi Noise (credit www.ronja-tutorials.com)

So this looks promising. But one of the great advantages of using noise maps for biomes is that logic like, No deserts next to tundras, was kind of baked in… because the temperature heat map would gradually change from hot to cold. There is no guarantee of that with cell noise. It could easily output a tundra surrounded on all sides by a desert.

There are a great number of rules for biomes to make them more planet-like. Rules like:

  • Beaches should be on the border of an ocean
  • Deep oceans should be surrounded on all sides by normal oceans
  • Mooshroom islands should be isolated in a ocean
  • Some variants are rare
  • If a swamp borders a jungle, a jungle edge will generate.
  • If a swamp borders a desert, snowy taiga, or snowy tundra, a plains biome generates.

Extending SharpNoise

It’s clear at this point that I need to write a custom cell noise algorithm, one in which I can apply all of these rules. Fortunately SharpNoise was very easy to extend, and creating custom cell noise is trivial.

A small primer. Cell noise works by referencing the seed and performing some math with prime numbers to identify specific coordinates where we get a hit, the Point of a Cell. In a perfect grid, these points would be the center of each cell, but the frequency component of the cell noise randomizes the location of these. Picture a 2D map with random dots strewn about. Then, for every coordinate on the map (sample), we calculate the distance to nearby points. The closest one wins, and the sampled point assumes the cell’s value (biome).

To learn about how to create Voronoi Cells from scratch, I recommend reading this article which explains it very well. For the purposes of Obsidian, we’re just going to hijack SharpNoise’s implementation and make it our own.

The first thing to do is to create a struct to store information about each cell.

internal struct VoronoiCell
{
public (int x, int z) Index { get; set; }
public (double x, double z) Point { get; set; }
public double DistanceToPoint { get; set; }
public BaseBiome BaseBiome { get; set; }
public int Variant { get; set; }
public Biomes Biome { get; set; }
}

Of these, Index is just an (X,Z) coordinate that uniquely identifies nearby cells in a particular sampling session. Point is the global (X,Z) coordinate of the point for the world. DistanceToPoint is the distance from where we’re sampling to the Cell Point.

Here’s code to sample a coordinate find all neighboring cells. It’s important that we not only understand which cell a sample point belongs to, but the surrounding cells too. Recall that each cell’s value ends up determining the biome. Understanding the biome for a given cell, and neighboring cells/biomes will assist in writing biome rules later.

Span<VoronoiCell> cells = stackalloc VoronoiCell[25];
int index = 0;

for (var zCur = zSample - 2; zCur <= zSample + 2; zSample++)
{
for (var xCur = xSample - 2; xCur <= xSample + 2; xSample++)
{
var xPos = NoiseGenerator.ValueNoise3D(xCur, 0, zCur, Seed);
xPos += xCur;
var zPos = NoiseGenerator.ValueNoise3D(xCur, 0, zCur, Seed);
zPos += zCur;
var xDist = xPos - x;
var zDist = zPos - z;
double dist = xDist * xDist + zDist * zDist; var cell = new VoronoiCell
{
Index = (xint, zint),
Point = (xPos, zPos),
DistanceToPoint = dist
};

cells[index++] = cell;
}
}

The two for loops should sample a 3x3 grid of cells (not exactly, because of the randomness of voronoi noise, but it’s easier to think of it this way). The 9 cells are then stored into an array of cells.

If we sort the array of cells by distance, we can pull the one with the lowest distance value as the cell the current sample belongs to.

To get the value (which ultimately determines the biome), we can use the same ValueNoise3D function.

MemoryExtensions.Sort(cells, (a, b) => 
{
return a.DistanceToPoint > b.DistanceToPoint ? 1 : -1;
});
var meVal = NoiseGenerator.ValueNoise3D(
(int)Math.Floor(cells[0].Point.x),
0,
(int)Math.Floor(cells[0].Point.z));

Similarly, the nearest neighbor will be at cell[1]

Cell Noise to Biomes

Time to talk Biomes. First order of business is to get the overall land/to sea ratio correct. The noise generator gives us values between -1 and 1, and an easy way to break land and sea up logically is to think of any negative value as sea, and positive value as land. For Obsidian, I decided that I want far more land than sea. By shifting the entire noisemap up by 0.5, then scaling appropriately, I get this affect.

private (BaseBiome, int) GetBaseBiome(double noise)
{
// Shift the whole map up by 1/2 for more land than sea.
noise += 0.5;
// Scale land (now 0 => 1.5) back down to 0 => 1
// Scale sea (now -0.5 => 0) back to -1 => 0
noise = noise > 0 ? noise * 0.667 : noise * 2.0;
}

For many biomes, there are variants, like Birch Forest and Tall Birch Forest. To account for these with a noise generator, I’m going to return a value derived from the noise in addition to the biome. More on this later.

There are 4 base ocean biomes types, Warm, Lukewarm, Cold and Frozen. If the noise value is negative, I scale it by -4, floor it, and return the enum representation of that value, and 0 as the variant value.

if (noise < 0)
{
noise *= 4.0; // 4 ocean types
return ((BaseBiome)Math.Floor(noise), 0);
}

If noise >= 0 though… it’s land, and I’m going to need that variant value. I decided to go for a 2 digit value for variant. Which is to say, a value between 0 and 99. I did this because it makes it a lot easier to specify a percent chance. Ex: if (variant < 15) is tantamount to saying a 15% chance.

// Basically, the first 2 decimals becomes the variant.
// So variant will be b/w 0 => 99
int variant = (int)(noise * 1000.0) % 100;

Like oceans, there are 4 primary types of land biomes: Dry, Medium, Cold and Frozen. I don’t want equal parts of each, though. I went with what Mojang decided and doubled up on Medium and Cold biomes. I even borrowed Mojang’s decisions on chances for rare biomes.

// 4 base overworld types but we want the ratio to be
// 2 parts medium, 2 parts cold, 1 part frozen, 1 part dry
noise *= 6.0;
int val = (int)noise;
return val switch
{
// 18% chance for a rare medium biome.
0 or 1 => variant <= 18 ? (BaseBiome.MediumRare, variant) : (BaseBiome.Medium, variant),
// 15% chance for a rare cold biome.
2 or 3 => variant <= 15 ? (BaseBiome.ColdRare, variant) : (BaseBiome.Cold, variant),
// There are no frozen rare biomes.
4 => (BaseBiome.Frozen, variant),
// 10% chance for a rare dry biome.
5 => variant <= 10 ? (BaseBiome.DryRare, variant) : (BaseBiome.Dry, variant),
_ => (BaseBiome.Medium, variant),
};

This was a lot…

and this article is already pretty long. In the next one, I’ll talk about implementing rules for biomes.

Stay tuned.

--

--

Jon Proietti
Jon Proietti

No responses yet