Exercise: Raycasting


High-Tech Institute // Exploring Rust // Part x (extra exercises)


Kris van Rens

Raycasting intro

Goal: Create an interactive 3D maze

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:

(source: Wikipedia, public domain)

The concept is surprisingly simple

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 raycasting part

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.

The raycasting part

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.

A possible result

Exercise steps

Version 00: Basic terminal application

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

Version 01: Ray casting

Implement the raycasting algorithm. This is the most involved step as we need quite some setup code.

Some of the things that are required:

  • A level map representation.
  • Looping over the player FOV (i.e. the screen columns).
  • The ray casting itself: iteratively increment distance d, calculating the ray end point, check if a wall is hit.
  • Visualization of floor/wall/ceiling.

 

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.

Version 02: Wall and floor shades

To increase the illusion of 3D, a light falloff effect can be used for the wall and floor.

 

Walls

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}),
  • ‘ ’ (space, \u{20}, far away).

Floor

We will use simple ASCII characters with various ‘densities’ for the floor texture light falloff effect:

  • #’ (close by)
  • x
  • -
  • .
  • ‘ ’ (space, \u{20}, far away)

Version 03: Moving around

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.

Version 04: Various abstractions

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.

Version 05: Wall boundary visualization

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.

Version 06: Mini-map and FPS visualization

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

Version 07: Wall shades using colors

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.

Version 08: A compile-time level Map value

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

Version 09: Reorganizing and documentation

Let’s reorganize our code into library components (types/methods/functions), and an executable (fn main).

 

Also add documentation for all library components.

End of part

High-Tech Institute // Exploring Rust // Part x (extra exercises)

 

All emoji in this presentation are part of the Twemoji set, licensed under CC-BY 4.0. All other images are mine, unless specified otherwise.