A Noisy World — Obsidian Server’s Minecraft-Like World Generation Pt. 2
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:
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),
}
}
};
}
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.
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:
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:
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
}
};
}
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
}
};
}
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…