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

Jon Proietti
5 min readJun 18, 2021

In Pt. 1, I talked about how Selectors can be used to make decisions inside a noise algorithm. There is a lot of decision making done in Obsidian’s overworld generator. One of the most difficult things about recreating Minecraft’s overworld was getting biomes right.

There is a many to one relationship between biomes and terrain types. There are several mountain biomes, plains biomes, hilly biomes and ocean biomes. Furthermore, Minecraft tends to place rivers at biome transitions, and the rivers even have their own biome.

My first stab at implementing biomes was a nightmare that even I could barely understand. It was also really bad, with transitions to biomes being off by several dozen blocks sometimes and hard transitions between desperate biomes (I still haven’t got this fully nailed down yet…) and rivers haphazardly placed without regard of biome transition boundaries.

I recently did a full rewrite utilizing better OOP concepts to clean all of it up and make it comprehensible.

Biome and River Selectors

I started by writing 3 selectors because, at least in my mind, there are 3 attributes to selecting a biome. These are:

  • HumidityNoise
  • TemperatureNoise
  • TerrainNoise

And they all inherit from BaseBiomeNoise. You can find my implementation here: https://github.com/ObsidianMC/Obsidian/tree/master/Obsidian/WorldData/Generators/Overworld/BiomeNoise

Each class actually has two selectors that they’ll output, a BiomeSelector and a RiverSelector. Two selectors that use the same noise but produce very different results. Let’s dive in.

Curves

To address the problem I was having with Biome transitions never lining up, I decided to be very explicit about biome transitions. I used Curves to accomplish this.

It starts with basic Perlin noise, like this one:

Terrain Selector Noise

Then I take the same noise and feed it to a curve. This curve swings high at the points I decided to make transitions between biomes terrain types.

protected virtual Module Rivers()
{
return new Cache
{
Source0 = new Curve
{
Source0 = Noise(),
ControlPoints = new List<ControlPoint> {
new Curve.ControlPoint(-3.000 - CurveOffset, 0.0),
new Curve.ControlPoint(-0.350 - CurveOffset, 0.0),
new Curve.ControlPoint(-0.334 - CurveOffset, 1.0),
new Curve.ControlPoint(-0.332 - CurveOffset, 1.0),
new Curve.ControlPoint(-0.300 - CurveOffset, 0.0),
new Curve.ControlPoint(0.330 - CurveOffset, 0.0),
new Curve.ControlPoint(0.332 - CurveOffset, 1.0),
new Curve.ControlPoint(0.334 - CurveOffset, 1.0),
new Curve.ControlPoint(0.350 - CurveOffset, 0.0),
new Curve.ControlPoint(3.000 - CurveOffset, 0.0),
}
}
};
}
Terrain River Selector

I take the source noise map and run it through two selectors to determine where mountains, hills, and plains terrain will be.

private Module TerrainSelect()
{
return new Cache
{
Source0 = new Select
{
Source1 = mountains.Result,
Source0 = new Select
{
Source0 = hills.Result,
Source1 = plains.Result,
Control = terrain.BiomeSelector,
LowerBound = -2.0,
UpperBound = -0,
EdgeFalloff = 0.1
},
Control = terrain.BiomeSelector,
LowerBound = 0.66,
UpperBound = 2.0,
EdgeFalloff = 0.1
}
};
}

If I did everything right, it should line up perfectly with rivers. Let’s see…

Looks pretty good. Here’s the same map, but with colorized for terrain and using the actual noise for the three types.

Merged Terrain without Rivers

Rivers

I showed earlier how I can use a curve to get river locations from the 3 types of biome maps; Terrain, Humidity and Temperature.

I still need to apply the rivers to the terrain though. First thing to do will be to merge the 3 sets of rivers together.

Here are the 3 sets of rivers so far:

Terrain Rivers
Humidity Rivers
Temperature Rivers

So lets join them together:

new Clamp
{
UpperBound = 1.0,
LowerBound = 0.0,
Source0 = new Max
{
Source0 = humidity.RiverSelector,
Source1 = new Max
{
Source0 = temperature.RiverSelector,
Source1 = terrain.RiverSelector
}
}
}

And the result:

All rivers merged together.

But, there’s a problem. We DON’T want rivers running through our oceans. Fortunately, in the last part, we created a LandOceanSelector, which we can use to scope rivers to only appear on land.

private Module MergedRivers()
{
return new Cache
{
// Use Select to isolate rivers to only land masses
Source0 = new Select
{
Source0 = new Constant { ConstantValue = 0 },
// Just add em all together then clamp it
Source1 = new Clamp
{
UpperBound = 1.0,
LowerBound = 0.0,
Source0 = new Max
{
Source0 = humidity.RiverSelector,
Source1 = new Max
{
Source0 = temperature.RiverSelector,
Source1 = terrain.RiverSelector
}
}
},
Control = LandOceanSelector(),
EdgeFalloff = 0.15,
LowerBound = 0.65,
UpperBound = 2.0
}
};
}
Rivers Scoped to only appear on Land

Bringing it all together

We have all of the components we need now to build something realistic. We just need to merge everything together.

private Module MergedLandOceanRivers()
{
return new Cache
{
Source0 = new Select
{
Source0 = MergedLandOcean(),
Source1 = rivers.Result,
Control = MergedRivers(),
LowerBound = 0.5,
UpperBound = 2.0,
EdgeFalloff = 0.4
}
};
}
Terrain rivers and oceans all combined

In the next part, we’ll look at how we can use the selectors more practically to make decisions about how to stylize each biome. Stay tuned…

--

--