Continuing my trend of (ab)using the Windows command-line interface to do fancy graphics-related things with my command-line-graphics library, CLIGL, I've created an infinite procedural terrain generator, capable of generating coherent fractal noise to an appreciable distance.
The basic structure of the project is as follows:
EntryPoint
: Relatively self-explanatory. This class contains the actual entry point for the program; it performs the initialization of a CLIGLRenderingWindow
andRenderingBuffer
, and creates an instance of theChunkManager
class.Position
: A helper class used to store a two-dimensional position; it implements aTransform()
function and overridesEquals()
andGetHashCode()
.Chunk
: responsible for generating, managing, and rendering a set of chunk data stored in aRenderingPixel[,]
array; it also contains several private helper methods used in the process of terrain generation, as well as a collection of constants used for fractal noise generation and terrain detail generation.ChunkManager
: responsible for loading, maintaining, and rendering a dictionary of chunks, as well as moving the viewport; it will also generate a seed used for individual chunk generation. It will add chunks to the dictionary if necessary (i.e., if there should be a chunk loaded within the current view range, but there isn't) and will delete chunks if necessary (i.e., if there is a chunk loaded, but is outside the current view range); it should be noted that the processes of chunk addition and deletion will only occur if the position of the chunk manager (the viewport) itself is changing.
A brief outline of the specific algorithm used to generate noise values:
Fractal noise values \$a\$ and \$b\$ are generated with specific base frequencies \$f_1\$ and \$f_2\$, respectively (also referred to as "noise scale"; cf.
NOISE_SCALE_A
andNOISE_SCALE_B
).A third noise value, \$t\$, is generated with a different base frequency (again, also referred to as "noise scale"; cf.
NOISE_SCALE_T
).Linear interpolation is then performed between \$a\$ and \$b\$ using \$\frac{t + 1.0}{2.0}\$ as the time value (note: the noise library I am using generates simplex noise in the range \$-1.0 \rightarrow 1.0\$, hence the adjustment of \$t\$).
Basic terrain detail (background color) is then generated (e.g., oceans, beaches, plains, and mountains) based on specified noise thresholds,
OCEAN_THRESHOLD
,BEACH_THRESHOLD
, et al.A fourth noise value is generated using only simplex noise with no scale adjustments; this noise value is then used to generate additional terrain detail (foreground colors and characters)
EntryPoint.cs
using System;
using System.Diagnostics;
using TerrainGenerator.Terrain;
using CLIGL;
namespace TerrainGenerator
{
/// <summary>
/// Contains the entry point for the terrain generator as well as all
/// requisite CLIGL initialization.
/// </summary>
public class EntryPoint
{
public const int WINDOW_WIDTH = 90;
public const int WINDOW_HEIGHT = 50;
/// <summary>
/// Entry point for the program.
/// </summary>
/// <param name="args">The command line arguments.</param>
public static void Main(string[] args)
{
RenderingWindow window = new RenderingWindow("Terrain Generator", WINDOW_WIDTH, WINDOW_HEIGHT);
RenderingBuffer buffer = new RenderingBuffer(WINDOW_WIDTH, WINDOW_HEIGHT);
Stopwatch timeAccumulator = new Stopwatch();
timeAccumulator.Start();
float previousElapsed = (float)timeAccumulator.Elapsed.TotalSeconds;
float currentElapsed;
float elapsedTime;
float deltaTime;
ChunkManager chunkManager = new ChunkManager(0, 0);
while(true)
{
elapsedTime = (float)timeAccumulator.Elapsed.TotalSeconds;
currentElapsed = elapsedTime;
deltaTime = currentElapsed - previousElapsed;
buffer.ClearPixelBuffer(RenderingPixel.EmptyPixel);
chunkManager.Render(ref buffer);
buffer.SetString(1, 1, $" T = {elapsedTime.ToString("F2")} ", ConsoleColor.White, ConsoleColor.Black);
buffer.SetString(1, 2, $" DT = {deltaTime.ToString("F2")} ", ConsoleColor.White, ConsoleColor.Black);
buffer.SetString(1, 3, $" FPS = {(1.0f / deltaTime).ToString("F2")} ", ConsoleColor.White, ConsoleColor.Black);
buffer.SetString(1, 5, $" CMP = ({chunkManager.Position.X}, {chunkManager.Position.Y}) ", ConsoleColor.White, ConsoleColor.Black);
buffer.SetString(1, 6, $" CMCC = {chunkManager.ChunkCollection.Count} ", ConsoleColor.White, ConsoleColor.Black);
buffer.SetString(1, 7, $" CMSD = {chunkManager.Seed} ", ConsoleColor.White, ConsoleColor.Black);
window.Render(buffer);
chunkManager.Update();
previousElapsed = currentElapsed;
}
}
}
}
Position.cs
using System;
namespace TerrainGenerator.Utilities
{
/// <summary>
/// Represents a 2-dimensional position.
/// </summary>
public class Position
{
public int X { get; set; }
public int Y { get; set; }
/// <summary>
/// Constructor for the Position class.
/// </summary>
/// <param name="x">X-coordinate.</param>
/// <param name="y">Y-coordinate.</param>
public Position(int x, int y)
{
this.X = x;
this.Y = y;
}
/// <summary>
/// Translate the X and Y components (addition).
/// </summary>
/// <param name="xOffset">The offset by which to translate X.</param>
/// <param name="yOffset">The offset by which to translate Y.</param>
public void Translate(int xOffset, int yOffset)
{
this.X += xOffset;
this.Y += yOffset;
}
/// <summary>
/// Get the hash code of the position.
/// </summary>
/// <returns>A hash code.</returns>
public override int GetHashCode()
{
unchecked
{
int hash = 17;
hash = hash * 23 + this.X.GetHashCode();
hash = hash * 23 + this.Y.GetHashCode();
return hash;
}
}
/// <summary>
/// Check for equality.
/// </summary>
/// <param name="obj">The object with which to check for equality.</param>
/// <returns>Whether the passed object is equal to the current object.</returns>
public override bool Equals(object obj)
{
if(obj.GetType() == typeof(Position))
{
Position cast = obj as Position;
return
this.X == cast.X &&
this.Y == cast.Y;
}
return false;
}
}
}
Chunk.cs
using System;
using TerrainGenerator.Utilities;
using CLIGL;
namespace TerrainGenerator.Terrain
{
/// <summary>
/// Contains all terrain data for a section of terrain (chunk). Instances of this
/// struct will be managed by the ChunkManager class.
/// </summary>
public class Chunk
{
public const int CHUNK_WIDTH = 16;
public const int CHUNK_HEIGHT = 16;
public const float NOISE_SCALE_A = 0.0015f;
public const float NOISE_SCALE_B = 0.01f;
public const float NOISE_SCALE_T = 0.1f;
public const int NOISE_ITERATIONS = 4;
public const float NOISE_PERSISTENCE = 0.6f;
public const float NOISE_MULTIPLIER = 1.5f;
public const float OCEAN_THRESHOLD = 0.0f;
public const float BEACH_THRESHOLD = 0.1f;
public const float PLAINS_THRESHOLD = 0.3f;
public const float ALPINE_THRESHOLD = 0.4f;
public Position Position { get; private set; }
public RenderingPixel[,] Data { get; private set; }
/// <summary>
/// Constructor for the Chunk struct.
/// </summary>
/// <param name="x">The X position of the chunk.</param>
/// <param name="y">The Y position of the chunk.</param>
public Chunk(int x, int y)
{
this.Position = new Position(x, y);
this.Data = new RenderingPixel[CHUNK_WIDTH, CHUNK_HEIGHT];
}
/// <summary>
/// Constructor for the Chunk struct.
/// </summary>
/// <param name="position"></param>
public Chunk(Position position)
{
this.Position = position;
this.Data = new RenderingPixel[CHUNK_WIDTH, CHUNK_HEIGHT];
}
/// <summary>
/// Generate Chunk data with 3D perlin noise, given a specific seed.
/// </summary>
/// <param name="seed"></param>
public void GenerateData(int seed)
{
for(int y = 0; y < CHUNK_HEIGHT; y++)
{
for(int x = 0; x < CHUNK_WIDTH; x++)
{
float noiseValueA = this.FractalNoise(this.Position.X + x + seed, this.Position.Y + y + seed, seed, NOISE_SCALE_A);
float noiseValueB = this.FractalNoise(this.Position.X + x + seed, this.Position.Y + y + seed, seed, NOISE_SCALE_B);
float noiseValueT = this.FractalNoise(this.Position.X + x + seed, this.Position.Y + y + seed, seed, NOISE_SCALE_T);
float terrainNoise = Lerp(noiseValueA, noiseValueB, (noiseValueT + 1.0f) / 2.0f);
float detailNoise = Noise.Generate(this.Position.X + x + seed, this.Position.Y + y + seed, seed);
(char, ConsoleColor, ConsoleColor) generatedTile = GenerateTile(terrainNoise, detailNoise);
this.Data[x, y] = new RenderingPixel(generatedTile.Item1, generatedTile.Item2, generatedTile.Item3);
}
}
}
/// <summary>
/// Render the generated tile data.
/// </summary>
/// <param name="managerX">The X position of the chunk manager.</param>
/// <param name="managerY">The Y position of the chunk manager.</param>
/// <param name="buffer">The buffer to which the chunk is rendered.</param>
public void RenderData(int managerX, int managerY, ref RenderingBuffer buffer)
{
for(int y = 0; y < CHUNK_HEIGHT; y++)
{
for(int x = 0; x < CHUNK_WIDTH; x++)
{
buffer.SetPixel(this.Position.X + x - managerX, this.Position.Y + y - managerY, this.Data[x, y]);
}
}
}
/// <summary>
/// Generate a tile based on a given noise value.
/// </summary>
/// <param name="terrainNoise">The noise value with which to generate terrain structure.</param>
/// <param name="detailNoise">The nosie value with which to generate terrain detail.</param>
/// <returns>A new tile.</returns>
private static (char, ConsoleColor, ConsoleColor) GenerateTile(float terrainNoise, float detailNoise)
{
if(terrainNoise <= OCEAN_THRESHOLD)
{
char detailCharacter = GenerateDetailCharacter(detailNoise, 0.0f, 0.75f, ' ', '-', '~');
return (detailCharacter, ConsoleColor.Blue, ConsoleColor.DarkBlue);
}
else if(terrainNoise <= BEACH_THRESHOLD)
{
char detailCharacter = GenerateDetailCharacter(detailNoise, -0.25f, 0.75f, ' ', '.', '*');
return (detailCharacter, ConsoleColor.Yellow, ConsoleColor.DarkYellow);
}
else if(terrainNoise <= PLAINS_THRESHOLD)
{
char detailCharacter = GenerateDetailCharacter(detailNoise, -0.25f, 0.75f, ' ', '.', '*');
return (detailCharacter, ConsoleColor.Green, ConsoleColor.DarkGreen);
}
else if(terrainNoise <= ALPINE_THRESHOLD)
{
char detailCharacter = GenerateDetailCharacter(detailNoise, 0.0f, 0.75f, ' ', '`', '.');
return (detailCharacter, ConsoleColor.Gray, ConsoleColor.DarkGray);
}
else
{
char detailCharacter = GenerateDetailCharacter(detailNoise, 0.25f, 0.75f, ' ', '.', ',');
return (detailCharacter, ConsoleColor.White, ConsoleColor.Gray);
}
}
/// <summary>
/// Generate a detail character.
/// </summary>
/// <param name="detailNoise">The noise value with which to generate detail.</param>
/// <param name="threshold1">The first noise threshold for detail.</param>
/// <param name="threshold2">The second noise threshold for additional detail.</param>
/// <param name="char1">First detail character.</param>
/// <param name="char2">Second detail character.</param>
/// <param name="char3">Final detail character.</param>
/// <returns>A detail character.</returns>
public static char GenerateDetailCharacter(float detailNoise, float threshold1, float threshold2, char char1, char char2, char char3)
{
return detailNoise <= threshold1
? char1
: detailNoise <= threshold2
? char2
: char3;
}
/// <summary>
/// Generate a fractal noise value.
/// </summary>
/// <param name="x">The X position for which to generate noise.</param>
/// <param name="y">The Y position for which to generate noise.</param>
/// <param name="z">The Z position for which to generate noise.</param>
/// <param name="frequency">Initial fractal noise frequency. Equivalent to scale.</param>
/// <remarks>
/// This function assumes that a noise value has already been applied to
/// the position values.
/// </remarks>
/// <returns>A fractal noise value.</returns>
private float FractalNoise(float x, float y, float z, float frequency)
{
float noise = 0.0f;
float currentFrequency = frequency;
float currentAmplitude = 1.0f;
float maximumAmplitude = 0.0f;
for(int i = 0; i < NOISE_ITERATIONS; i++)
{
noise += Noise.Generate(x * currentFrequency, y * currentFrequency, z * currentFrequency) * currentAmplitude;
maximumAmplitude += currentAmplitude;
currentAmplitude *= NOISE_PERSISTENCE;
currentFrequency *= NOISE_MULTIPLIER;
}
return noise / maximumAmplitude;
}
/// <summary>
/// Perform linear interpolation between two values.
/// </summary>
/// <param name="a">The first value.</param>
/// <param name="b">The second value.</param>
/// <param name="t">The time value.</param>
/// <returns>An interpolated value.</returns>
private static float Lerp(float a, float b, float t)
{
return a + t * (b - a);
}
}
}
ChunkManager.cs
using System;
using System.Collections.Generic;
using TerrainGenerator.Utilities;
using CLIGL;
namespace TerrainGenerator.Terrain
{
/// <summary>
/// This class is responsible for storing and managing a collection of chunks. It
/// will update the collection of chunks, deleting chunks and adding new chunks based
/// on the current position of the manager.
/// </summary>
public class ChunkManager
{
public const int TRANSLATE_X = 2;
public const int TRANSLATE_Y = 2;
public const int CHUNK_LOADING_RANGE_X = 6;
public const int CHUNK_LOADING_RANGE_Y = 3;
public const int SEED_BOUNDS = 1000000;
public int Seed { get; private set; }
public Position Position { get; private set; }
public Dictionary<Position, Chunk> ChunkCollection { get; private set; }
/// <summary>
/// Constructor for the ChunkManager class.
/// </summary>
/// <param name="x">The X position of the manager.</param>
/// <param name="y">The y position of the manager.</param>
public ChunkManager(int x, int y)
{
Random randomGenerator = new Random();
this.Seed = randomGenerator.Next(-SEED_BOUNDS, SEED_BOUNDS);
this.Position = new Position(x, y);
this.ChunkCollection = new Dictionary<Position, Chunk>(CHUNK_LOADING_RANGE_X * CHUNK_LOADING_RANGE_Y * 4);
this.AddChunks();
}
/// <summary>
/// Render the contents of the chunk collection to a buffer.
/// </summary>
public void Render(ref RenderingBuffer buffer)
{
foreach(KeyValuePair<Position, Chunk> chunk in this.ChunkCollection)
{
chunk.Value.RenderData(this.Position.X, this.Position.Y, ref buffer);
}
}
/// <summary>
/// Update the chunk manager; if no keys are pressed (and as a consequence, the
/// position of the chunk manager remains unchanged), then AddChunks() and
/// DeleteChunks() are not called.
/// </summary>
public void Update()
{
if(this.AdjustPosition())
{
this.DeleteChunks();
this.AddChunks();
}
}
/// <summary>
/// Adjust the position of the chunk manager based on keyboard input.
/// </summary>
/// <returns>Whether or not a key has been pressed.</returns>
private bool AdjustPosition()
{
if(Console.KeyAvailable)
{
ConsoleKey keyPressed = Console.ReadKey(false).Key;
switch(keyPressed)
{
case ConsoleKey.UpArrow:
this.Position.Translate(0, -TRANSLATE_Y);
break;
case ConsoleKey.DownArrow:
this.Position.Translate(0, TRANSLATE_Y);
break;
case ConsoleKey.LeftArrow:
this.Position.Translate(-TRANSLATE_X, 0);
break;
case ConsoleKey.RightArrow:
this.Position.Translate(TRANSLATE_X, 0);
break;
}
return true;
}
return false;
}
/// <summary>
/// If necessary (i.e., there is a chunk that should be loaded, but isn't), add a
/// new chunk into the current collection of chunks.
/// </summary>
private void AddChunks()
{
for(int y = -CHUNK_LOADING_RANGE_Y; y <= CHUNK_LOADING_RANGE_Y; y++)
{
for(int x = -CHUNK_LOADING_RANGE_X; x <= CHUNK_LOADING_RANGE_X; x++)
{
int chunkX = this.Position.X + x * Chunk.CHUNK_WIDTH;
int chunkY = this.Position.Y + y * Chunk.CHUNK_HEIGHT;
int lockedChunkX = (int)(Math.Floor((decimal)chunkX / (decimal)Chunk.CHUNK_WIDTH) * (decimal)Chunk.CHUNK_WIDTH);
int lockedChunkY = (int)(Math.Floor((decimal)chunkY / (decimal)Chunk.CHUNK_HEIGHT) * (decimal)Chunk.CHUNK_HEIGHT);
Position position = new Position(lockedChunkX, lockedChunkY);
if(!this.ChunkCollection.ContainsKey(position))
{
this.ChunkCollection.Add(position, new Chunk(position));
this.ChunkCollection[position].GenerateData(this.Seed);
}
}
}
}
/// <summary>
/// If necessary (i.e., there is a chunk that is loaded, but shouldn't be), delete
/// the chunk from the current collection of chunks.
/// </summary>
private void DeleteChunks()
{
List<Position> chunksToRemove = new List<Position>();
foreach(KeyValuePair<Position, Chunk> chunk in this.ChunkCollection)
{
if(
Math.Abs(chunk.Value.Position.X - this.Position.X) > CHUNK_LOADING_RANGE_X * Chunk.CHUNK_WIDTH * 2 ||
Math.Abs(chunk.Value.Position.Y - this.Position.Y) > CHUNK_LOADING_RANGE_Y * Chunk.CHUNK_HEIGHT * 2
)
{
chunksToRemove.Add(chunk.Key);
}
}
foreach(Position position in chunksToRemove)
{
this.ChunkCollection.Remove(position);
}
}
}
}
With regards to review: I am primarily concerned with memory usage and performance (and if there exist any potential problems related to either of these issues); as of right now the generator uses at least 15% of my CPU across all processors and around 11 MB of memory, with memory usage increasing the longer the program runs, peaking at around 14 MB. That said, however, I welcome any other criticisms as well!
A few notes:
The
Noise
class that I have used throughout this project is an implementation of the simplex noise algorithm in C# by Heikki Törmälä; the original source can be viewed here.This project links a previous project of mine, CLIGL; if you wish to test out this generator, you will need to download and compile CLIGL to a
.DLL
and link it accordingly.I highly recommend that, should you wish to test this project for yourself, that you set the font of your console window to a raster font with a size of
12x16
.For those who may not wish to go to the effort of downloading and compiling CLIGL and this project, I have uploaded a video demonstrating the generator here.
1 Answer 1
Going from top to bottom.
EntryPoint
Stopwatch timeAccumulator = new Stopwatch();
timeAccumulator.Start();
can be simplified by using var
instead of the concrete type and by using the static Stopwatch Stopwatch.StartNew()
method.
Position
public override bool Equals(object obj)
{
if(obj.GetType() == typeof(Position))
{
Position cast = obj as Position;
return
this.X == cast.X &&
this.Y == cast.Y;
}
return false;
}
can be simplified like so
public override bool Equals(object obj)
{
if(obj is Position cast)
{
return
this.X == cast.X &&
this.Y == cast.Y;
}
return false;
}
You don't check wether x
or y
is in a valid range. I don't know if your code get problems if either of this will be negative. If yes you should check these parameters in your constructor and add validation for the property-setter as well.
Chunk
public Chunk(int x, int y)
{
this.Position = new Position(x, y);
this.Data = new RenderingPixel[CHUNK_WIDTH, CHUNK_HEIGHT];
}
can be simplified by using constructor chaining like so
public Chunk(int x, int y) : this(new Position(x, y))
{}
ChunkManager
In AdjustPosition()
and other methods you should place a guarding clause to return early. This saves one indentation level for the whole method like so
private bool AdjustPosition()
{
if(!Console.KeyAvailable) { return false; }
ConsoleKey keyPressed = Console.ReadKey(false).Key;
switch(keyPressed)
{
case ConsoleKey.UpArrow:
this.Position.Translate(0, -TRANSLATE_Y);
break;
case ConsoleKey.DownArrow:
this.Position.Translate(0, TRANSLATE_Y);
break;
case ConsoleKey.LeftArrow:
this.Position.Translate(-TRANSLATE_X, 0);
break;
case ConsoleKey.RightArrow:
this.Position.Translate(TRANSLATE_X, 0);
break;
}
return true;
}
and by adding the ConsoleKey
's and Action<int, int>
to a dictionary you can replace the switch
completely.
General
The usage of this
adds only noise to your code. Just use it only if you really need to use it.
-
\$\begingroup\$ I could make the
Equals
method even shorter:return obj is Point other && X == other.X && Y == other.Y;
-- is that an idiomatic way to write an Equals method? \$\endgroup\$Roland Illig– Roland Illig2019年12月14日 23:08:19 +00:00Commented Dec 14, 2019 at 23:08 -
\$\begingroup\$ @RolandIllig good point. Make it into an answer and get my +1 and if your answer is the only one except mine yiu will earn the bounty as well. \$\endgroup\$Heslacher– Heslacher2019年12月15日 09:19:49 +00:00Commented Dec 15, 2019 at 9:19
static class
, for easier maintainability, and also to ensure you'll have only one instance of them. You can also wrap everything under meaningful name ( for user-experience), it would be possible to achieve something likevar tg = new TerrainGenerator()
and then, everything I need would be accessible inTerrainGenerator
This also will act as publish layer where you'll expose/hide what you need for the user (developer). \$\endgroup\$