Skip to main content
Code Review

Return to Question

Notice removed Draw attention by Community Bot
Bounty Ended with no winning answer by Community Bot
Tweeted twitter.com/StackCodeReview/status/1203962504629641216
Notice added Draw attention by Heslacher
Bounty Started worth 50 reputation by Heslacher
added 3 characters in body
Source Link
Ethan Bierlein
  • 15.9k
  • 4
  • 60
  • 146

Continuing my trend of (ab)using the Windows command-line interface to do fancy graphicalgraphics-related things with my command-line-graphics library, CLIGL, I've implementedcreated an infinite procedural terrain generator, capable of generating coherent fractal noise to an appreciable distance.

Continuing my trend of (ab)using the Windows command-line interface to do fancy graphical things with my command-line-graphics library, CLIGL, I've implemented an infinite procedural terrain generator, capable of generating coherent fractal noise to an appreciable distance.

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.

Source Link
Ethan Bierlein
  • 15.9k
  • 4
  • 60
  • 146

Generating Infinite Procedural Terrain Using Command-Line Graphics

Continuing my trend of (ab)using the Windows command-line interface to do fancy graphical things with my command-line-graphics library, CLIGL, I've implemented 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.

lang-cs

AltStyle によって変換されたページ (->オリジナル) /