A Java multithreaded program that uses line segments to generate an image of a random 2D virtual terrain. The program generates these terrains as heightmaps, where each pixel of the image has a brightness that corresponds to a height value. This program was made as part of a course assignment for COMP 409 Concurrent Programming in winter 2021 at McGill University.
In the context of this project, the term fault line will be used to describe a line segment that intersects two different edges of a rectangle. That rectangle is the shape of the terrain image that the program will generate. To randomize the height values assigned to each pixel of the rectangular terrain image, a random fault line is chosen on the image and the height values of all the pixels to the left or right of the line are all increased by a random integer value. By repeating this process with multiple fault lines, the height values of each pixel gradually become more distinct. Once finished, the program generates the image by assigning a grayscale color to each pixel such that the higher the height value of the pixel, the brighter the pixel will be.
This is a sample image of a terrain generated by the program. The image has pixel resolution 720x576 and was generated using 5,000 fault lines.
The following are six sample terrain images generated by the program with an increasing number of fault lines. Each image has pixel resolution 176x144. As the number of fault lines increases, it becomes more difficult to see the individual fault lines and the terrain appears to be smoother and more detailed.
As explained above, the rectangular terrain image generated by the program is a heightmap, where each pixel of the image has a brightness that corresponds to a height value. The brighter the pixel, the higher the height value associated with it. A 2D heightmap can be used to render a 3D terrain since each pixel on the map has three components: the horizontal and vertical (x, y) coordinates of the pixel and its height value; thus, each pixel can be mapped to a point in 3D space in order to render a 3D terrain.
The ability to randomly generate a 2D heightmap is useful for video games as it allows complex and detailed 3D terrain to be generated procedurally. Procedural generation is a process in which content is generated by a computer rather than being created manually. Heightmaps are an efficient method of procedural terrain generation because they can store a large amount of detail without using a significant amount of memory.
First, the user enters command-line arguments to define the pixel width and height of the image, the number of threads to use (t), and the total number of fault lines to create (k). Then, the program creates a grid to store the height values of each pixel in the image that will be created. The grid is a 2D matrix of integers, with a width and height equal to the pixel width and height of the image. Each element of the grid is a height value, and the grid is initially filled with 0s.
Once the grid is initialized, the program starts all t threads. Each thread randomizes the height values of the grid by creating fault lines. The following process is carried out by each thread for each fault line created:
- Randomly choose an entry point and exit point for the fault line. These are any two points that are on different edges of the rectangular image/grid, except for corner points. If one of the points is a corner point, then the other point must be on a non-adjacent edge.
- Choose a random integer value between 0 and 10, which is called the heightAdjustment.
- Choose a random side of the fault line: either left or right.
- For every point on the grid that is on the chosen side of the fault line (left or right), increase the height value of the point by the number heightAdjustment.
This process is repeated multiple times by each thread for a total of k times. After the process has been completed k times, the threads exit. At this stage, the height values of all points on the grid are final and the image is ready to be constructed.
Then, only one thread performs the following tasks to construct the image:
- Traverse the grid to determine the minimum and maximum height value in the grid.
- Create a blank image with a width and height equal to that of the grid.
- For each point on the grid, convert the height value of the point to a grayscale RGB value and set the RGB value of the corresponding pixel in the image to that grayscale RBG value. A grayscale RGB value has all three components--red, green, and blue--equal values. For example, one grayscale value would be (r, g, b) = (55, 55, 55). The higher the height value of the point, the higher the RBG value will be, and the brighter the pixel will be. The height value is converted to an RBG value between 0 and 255 with the following formula:
RBG_value = 255 * (height_value - min_height_value) / (max_height_value - min_height_value)
Finally, the image is written to a file with the name "terrain.png."
The program must be executed with four integer command-line arguments in the following order:
Argument | Description |
---|---|
width | The pixel width of the image |
height | The pixel height of the image |
t | The number of threads that will use to create fault lines |
k | The total number of fault lines that will be created by the threads |
To execute the program, you must have the Java Development Kit (JDK) installed on your computer. You may execute it with an IDE (such as Eclipse) or using the command line. For example, following commands may be used to compile and execute the program.
$ javac TerrainGenerator.java
$ java TerrainGenerator width height t k
You must replace width, height, t, and k with the integer arguments.
The following is an example of the program being executed in the command line and its output. Once the program is finished, it outputs an image file named terrain.png in the same directory where the program is located.
$ java TerrainGenerator 720 576 6 5000
Parameters: width = 720, height = 576, number of threads = 6, number of fault lines = 5000
Execution time: 1851 ms
The height values of pixels in the image are randomized by repeatedly creating a fault line and increasing the height values to the left or right of the line. The program will perform this process for up to k fault lines before terminating. Rather than creating fault lines one at a time, it is possible to have multiple threads create fault lines concurrently to reduce the amount of time it takes for the program to complete all k fault lines. This would reduce the amount of time it takes to produce the image of the terrain.
To parallelize this process of randomizing height values, it must be possible for threads to modify the values without causing data races. To ensure this, the program stores the height values of each pixel on the rectangular image in a 2D matrix where each element of the matrix is an AtomicInteger. An AtomicInteger is a type of integer (available in the java.util.concurrent package) that can be accessed and modified in a thread-safe manner. This ensures that multiple threads can attempt to access or modify the values of the same elements of the matrix at the same time without causing data races; thus, multiple threads are able to increase the height values of the pixels simultaneously without interfering with each other.
The program uses threads to parallelize the process of creating fault lines and modifying the height values, as mentioned above. The number of threads used is specified by the argument t, and the total number of fault lines that will be created is specified by the argument k. All t threads create fault lines and increase the height values simultaneously until a total of k fault lines have been completed. Then, the threads terminate and only a single thread performs the final task of creating the image from the matrix of height values.
To test whether the parallelization mentioned above has a significant impact on the program's performance, an experiment was conducted to measure the program's execution time with increasing numbers of threads, while keeping the width, height, and total number of fault lines constant.
The experiment was performed on a computer with a 6-core, 12-thread CPU (with hyperthreading).
The following program parameters were used: width = 720, height = 576, number of fault lines (k) = 5000.
The execution time of the program was measured for t = 1, 2, 3, 4, 6, 8, 12, and 16. Five trials were carried out for each value of t, and the average execution time was computed for each set of trials. Before the first trial of each value of t, the program was executed once and the result was discarded. The reason for discarding the initial run is that it may be slower than the successive runs.
Once the average execution times were computed, the relative speedup was computed for each value of t.
This is the formula for relative speedup:
Relative speedup with n threads = execution time with one thread / execution time with n threads
For example, a speedup of 2 at t = 2 threads means that the execution time of the program using two threads is twice as fast (takes half the time) as the execution time using one thread.
The following is a plot of results of the experiment, which displays the computed speedup for each value of t:
The plot above shows that as the number of threads increases up to 16 threads, speedup always increases. This means that increasing the number of threads increases the program's performance, which is the goal of using threads. As can be seen from the plot, using just two threads instead of one achieves a speedup of 2.056, which means that the use of two threads decreases the execution time of the program by just over 50%. It can, therefore, be concluded that the parallelization implemented in the program is successful at achieving significant speedup.
It appears from the plot that between one and four threads, the increase in speedup is approximately linear; then, beyond four threads, the speedup appears to increase at a slower, logarithmic rate. The speedup appears to eventually reach a maximum value of around 7.1 beyond 12 threads. It is expected for the speedup to be non-linear because not 100% of the program is parallelized--only the process of creating fault lines and increasing height values is parallelized. Amdahl's law explains that all programs have a sequential component and a parallel component, and the greater the sequential component is compared to the parallel component, the less linear the speedup curve will appear. Since the speedup curve from the plot above appears linear for up to four threads, it can be concluded that the parallel component of the program is significantly greater than the sequential component.
Another factor that contributes to a speedup curve being less linear is the overhead of switching between threads and preemptive multitasking. The computer used to carry out this experiment has six cores but is capable of running two threads simultaneously per core due to hyperthreading; so, it can run a total of 12 threads simultaneously. If the program attempts to run more than 12 threads simultaneously when the CPU can only support a maximum of 12 threads, then the computer will perform preemptive multitasking, which means that some threads will be temporarily interrupted while other threads execute. Each thread will be executed for a period of time known as a time slice before being interrupted to allow another thread to execute. So, some threads once after another, but not all at the same time. As a result, the use additional threads will not necessarily improve the performance of the program because the additional threads will not truly be running simultaneously, as there will still be at most 12 threads that can truly run simultaneously. This can be seen above in the plot: the speedup with 12 threads and 16 threads are roughly equal, at around 7.
This repository is released under the MIT License (see LICENSE).