High-Tech Institute // Exploring Rust // Part
x (extra exercises)
Kris van Rens
Similar to how Wolfenstein 3D works.
We will use the terminal as a screen.
Doable in ~200 lines of safe Rust code.
The general idea:
The problem is “flattened” to a 1D problem.
Create the illusion of a 3D environment.
The first person has a “field of view” (FOV) angle, that is represented by the screen width.
For each column (a loop over the screen width), calculate the distance to the nearest wall.
Given this distance, the wall height and wall shade is rendered accordingly.
That’s almost all!
The walls are defined as blocks in an ASCII art map.
For each screen column, we cast a ray from the perspective of the current player location and viewing angle in the map. Each full sweep of the FOV takes exactly the screen width in steps.
For each column step, “cast a ray” by step-wise incrementing the casting distance of the ray and verifying if the current end location hits a wall block on the map. The ray cast distance calculation is done if a wall block was hit.
Top view:
Use basic trigonometry to calculate the ray cast location given a distance/angle:
\[\sin \alpha = \frac{x}{d} \quad \Rightarrow \quad x = d \cdot \sin \alpha\]
\[\cos \alpha = \frac{y}{d} \quad \Rightarrow \quad y = d \cdot \cos \alpha\]
Include current player location and angle.
As a first basic step, implement a program that simply uses the terminal control library.
I used the crossterm
crate to handle low-level terminal interaction.
To get a bit of feel for crossterm
, make the application
print the terminal size in the center of the screen.
First the screen must be cleared, and after the printing of the size, the application must pause to wait on an arbitrary key press (i.e. without ‘return’ required).
Implement the raycasting algorithm. This is the most involved step as we need quite some setup code.
Some of the things that are required:
d
, calculating the ray end point, check if a wall
is hit.
Don’t try to implement it all at once, take it on step by step.
Carefully choose your variable types to prevent range problems, balance correctness and pragmatism.
To increase the illusion of 3D, a light falloff effect can be used for the wall and floor.
The further away a wall is, the darker it appears. Use your level map size to get a feel for value ranges.
The easiest method is to simulate falloff color for walls is by using these Unicode characters:
\u{2588}
, close by),\u{2593}
),\u{2592}
),\u{2591}
),\u{20}
, far away).We will use simple ASCII characters with various ‘densities’ for the floor texture light falloff effect:
#
’ (close by)x
’-
’.
’\u{20}
, far away)Use the console library to handle key press inputs to control the player position and angle.
The solution code uses WSAD input for forward/backward/CCW rotation/CW rotation.
If your code is like mine was, it can benefit from some organization.
In this step, create an abstraction of the “screen”, level map definition and player state. The overall size of the code may become larger, but the main business logic is much easier to read and understand.
Another way to improve the 3D illusion fidelity is to render the wall block boundaries.
The idea in this step is that if we detect a ray cast “wall hit”, we calculate the Euclidian vector dot product of a ray from the player with a normal vector from each one of the four wall block corners. A ray with a dot product approaching 0 indicates a ray perpendicular to the wall block. This way we can signify a screen column that hits a wall to be a wall block boundary.
Because the calculations and visualizations are not very accurate, selecting all four wall block boundaries for visualization is not visually appealing. To fix this, the solution code builds an array of wall block calculations and selects only the two most accurate ones. This is done by sorting the array of dot products by distance.
One of the advantages of having a simple ASCII-based implementation, is that a top-view minimap can easily be visualized. In the solution code, the map is visualized in the top-left corner.
Game frame rate is measured each game loop iteration and visualized in the bottom left corner.
The player location can simply be added as it directly maps to a level map location.
The simplicity of this step breaks down if your level map is very large. In that case, a more advanced approach with taking a “subview” or scaled version of the map is required. Feel free to experiment!
For visualizing player orientation, the solution uses the “double arrows”
from Unicode (i.e. ‘\u{21d0}
’ etc.).
A huge improvement of graphical fidelity can be reached by using terminal color shading rather than just different Unicode block characters for wall visualization. Depending on the terminal configuration, 256 colors, or even true color (32-bit shading) can be used. However, tests show that 16 shades already make a tremendous difference.
In crossterm
colors can be added using the
style::setForegroundColor()
command.
Map
valueThe level map definition is a typical immutable, static aspect of our application. It is defined once, from a string literal, and never modified during runtime.
Find out if it’s possible to have the Map
type be
defined at compile-time using const
.
Let’s reorganize our code into library components
(types/methods/functions), and an executable (fn main
).
Also add documentation for all library components.
High-Tech Institute // Exploring Rust // Part x (extra exercises)