7
\$\begingroup\$

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 CLIGL RenderingWindow and RenderingBuffer, and creates an instance of the ChunkManager class.

  • Position: A helper class used to store a two-dimensional position; it implements a Transform() function and overrides Equals() and GetHashCode().

  • Chunk: responsible for generating, managing, and rendering a set of chunk data stored in a RenderingPixel[,] 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:

  1. 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 and NOISE_SCALE_B).

  2. A third noise value, \$t\$, is generated with a different base frequency (again, also referred to as "noise scale"; cf. NOISE_SCALE_T).

  3. 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\$).

  4. 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.

  5. 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.

\$\endgroup\$
  • \$\begingroup\$ Is there a particular reason for using chunks? It looks like each pixel can be updated individually... so we could use a single toroidal buffer, and update only the exact pixels that need it due to movement (i.e. a strip along the side of our window for movement in one direction, or an L-shape for movement in two). \$\endgroup\$ – user673679 Dec 9 '19 at 10:55
  • \$\begingroup\$ @user673679 You’re definitely right in that regard; a toroidal buffer would have been a more apt solution for this problem. I did initially try to implement something to that effect, but I could not, for the life of me, get it to work; I’ve done a pretty significant amount of work with video engines in the past, and I am used to a chunk-based model, so I stuck with that. \$\endgroup\$ – Ethan Bierlein Dec 9 '19 at 13:20
  • \$\begingroup\$ Position and Chunck classes, could possibly merged. as Position is a struct and not a class for current configuration. Also, consider moving all constants to its own 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 like var tg = new TerrainGenerator() and then, everything I need would be accessible in TerrainGenerator This also will act as publish layer where you'll expose/hide what you need for the user (developer). \$\endgroup\$ – iSR5 Dec 13 '19 at 2:54
  • \$\begingroup\$ @iSR5 please don't answer in comments. Write an answer instead. \$\endgroup\$ – Heslacher Dec 15 '19 at 9:17
2
\$\begingroup\$

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.

| improve this answer | |
\$\endgroup\$
  • \$\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 Dec 14 '19 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 Dec 15 '19 at 9:19

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.