Introduction#
My fascination with early 3D graphics, particularly from the era before graphics cards could render realistic lighting and geometry (think games like DOOM and Wolfenstein 3D) led me to explore the mechanics of raycasting. This research sparked a desire to build my own raycasting renderer.
I must emphasise that 3D graphics, rendering, and engine development are not my areas of expertise as of writing this post. The methods and insights I will share here should not be considered the most efficient or optimal approach to creating a raycasting renderer - this is just a fun little project I decided to build in one day.
Setting Up the Project#
To kick off the creation of my raycasting engine, I started by setting up a basic scene. I focused on a top-down map featuring a simple rectangle (to act as a wall) and a player. Additionally, I implemented two essential scripts: raycast_cam.cs
, responsible for handling all aspects of raycasting, and player_controller.cs
, which manages player controls.
Controlling the Player#
Handling Player Rotation#
To start, I wanted to implement basic player rotation using the Q
and E
keys, avoiding the complexity of mouse controls. This was easily accomplished with the following script, which I added to the player_controller.cs
file:
1
2
3
4
5
6
7
8
9
10
11
| void Update()
{
if (Input.GetKeyDown("q"))
{
transform.Rotate(Vector3.forward * turn_angle);
}
else if (Input.GetKeyDown("e"))
{
transform.Rotate(Vector3.forward * -turn_angle);
}
}
|
Handling Player Movement#
To get the player to move, I implemented another simple top-down movement script. Below, you can find the full script for the movement at this point in the project.
player_controller.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class player_controller : MonoBehaviour
{
public int turn_angle = 10;
public int speed = 5;
private Rigidbody2D body;
private float horizontal;
private float vertical;
void Start()
{
body = GetComponent<Rigidbody2D>();
}
void Update()
{
horizontal = Input.GetAxisRaw("Horizontal");
vertical = Input.GetAxisRaw("Vertical");
if (Input.GetKeyDown("q"))
{
transform.Rotate(Vector3.forward * turn_angle);
}
else if (Input.GetKeyDown("e"))
{
transform.Rotate(Vector3.forward * -turn_angle);
}
}
private void FixedUpdate()
{
body.velocity = new Vector2(horizontal * speed, vertical * speed);
}
}
|
Creating my first ray#
To get raycasting to work we first need to create some rays - shocking, I know! To start, I added a basic Debug.DrawRay(transform.position, transform.TransformDirection(Vector2.up) * max_distance, Color.green);
to raycast_cam.cs
, allowing me to visualize the rays. Below is a GIF showing this in action.
With the single ray successfully visualized, I shifted my focus to making the ray interact with objects. In Unity, the DrawRay
method only renders the ray on the screen without enabling any interactions. To address this, I created a RaycastHit2D
object, which allowed the ray to detect collisions. I then implemented functionality to change the color of any object hit by the ray to red, providing a clear visual indicator of the collision.
1
2
3
4
5
6
7
8
9
| void FixedUpdate()
{
RaycastHit2D hit = Physics2D.Raycast(transform.position, transform.TransformDirection(Vector2.up) * max_distance);
if (hit.collider != null)
{
hit.collider.gameObject.GetComponent<SpriteRenderer>().color = Color.red;
}
}
|
Creating multiple rays#
Next, I needed to create a FOV controller that dynamically adjusts the angle of each ray, as there will now be multiple rays. I encountered a bit of a roadblock when trying to figure out how to create rays at specific angles. Fortunately, I found a forum post that provided a solution which worked perfectly for my needs. When developing my raycast renderer, I decided to split the renderer into two “eyes”: one for the left side and one for the right. I focused on getting the left eye to function first using this new method.
To begin, I divided the FOV by two:
1
| float FOV_slice = FOV / 2;
|
I also split the number of rays by two:
1
| float ray_slice = ray_count / 2;
|
To calculate the angle between each ray, I divided the FOV slice (in degrees) by the number of rays in that slice:
1
| float angle_difference = (FOV_slice / ray_slice);
|
Using the method I found on the forum, I wrote the following code to render the rays with the appropriate spacing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| float FOV_slice = FOV / 2;
float ray_slice = ray_count / 2;
float angle_difference = (FOV_slice / ray_slice);
// Draw front ray
Debug.DrawRay(transform.position, transform.up * max_distance, Color.green);
// Draw left side of FOV
for (int i = 1; i < ray_slice; i++)
{
Vector3 lDir = Quaternion.AngleAxis(angle_difference * i, Vector3.forward) * transform.TransformDirection(Vector2.up);
Debug.DrawRay(transform.position, lDir * max_distance, Color.green);
}
|
Here’s a little demonstration of the dynamic FOV and ray counts in action:
To mirror the FOV to the other side, I just had to make the angle difference negative. The final code is shown below.
raycast_cam.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class raycast_cam : MonoBehaviour
{
public float max_distance;
public float FOV;
public int ray_count;
void FixedUpdate()
{
float FOV_slice = FOV / 2;
float ray_slice = ray_count / 2;
float angle_difference = (FOV_slice / ray_slice);
// Draw front ray
Debug.DrawRay(transform.position, transform.up * max_distance, Color.green);
RaycastHit2D hit = Physics2D.Raycast(transform.position, transform.up);
if (hit.collider != null)
{}
// Draw left side of FOV
for (int i = 1; i < ray_slice; i++)
{
Vector3 raydir = Quaternion.AngleAxis(angle_difference * i, Vector3.forward) * transform.TransformDirection(Vector2.up);
Debug.DrawRay(transform.position, raydir * max_distance, Color.green);
hit = Physics2D.Raycast(transform.position, raydir);
if (hit.collider != null)
{
}
}
// Draw right side of FOV
for (int i = 1; i < ray_slice; i++)
{
Vector3 raydir = Quaternion.AngleAxis(-angle_difference * i, Vector3.forward) * transform.TransformDirection(Vector2.up);
Debug.DrawRay(transform.position, raydir * max_distance, Color.green);
hit = Physics2D.Raycast(transform.position, raydir);
if (hit.collider != null)
{
}
}
}
}
|
Rendering the walls#
Rendering the front ray#
After getting the basics of the FOV system working, I moved on to rendering walls with the camera. The first step was to create a wall prefab. I accomplished this by designing a 1x1 square sprite with a “render” tag and adding a box collider so that the raycasts could interact with it. To ensure that the rendered items are cleared on every frame, I added the following code:
1
2
3
4
5
6
| // Clear the render canvas
GameObject[] gos = GameObject.FindGameObjectsWithTag("render");
foreach (GameObject go in gos)
{
Destroy(go);
}
|
Next, I focused on getting the wall rendering to work for the front ray, which was relatively straightforward. Using the ray, I calculated the distance it traveled before intersecting with a wall. Then, I instantiated the wall prefab at the center of the camera and set its height based on the maximum distance minus the distance of the hit.
1
2
3
4
5
6
7
8
9
10
11
12
13
| // Draw front ray
Debug.DrawRay(transform.position, transform.up * max_distance, Color.green);
RaycastHit2D hit = Physics2D.Raycast(transform.position, transform.up);
if (hit.collider != null)
{
float d = hit.distance;
if (d <= max_distance)
{
GameObject wall = Instantiate(wall_render, new Vector2(0, 0), Quaternion.identity);
wall.transform.localScale = new Vector2(wall_width, max_distance - d);
}
}
|
This approach allowed me to visualize the intersection of the ray with the walls dynamically.
Rendering all rays#
With the front ray successfully rendering the wall it collided with, it was time to replicate the code for the side rays. The first challenge was determining the width of each wall. I calculated this value using the following code:
1
| float wall_width = ((Camera.main.aspect * halfHeight) / ray_slice);
|
This approach worked well enough for the time being. For the height of the walls, I used the following code:
1
| float wall_height = halfHeight * (1 - (d / max_distance));
|
This formula takes half of the camera’s height and multiplies it by the percentage of the distance the ray hit is from the camera, effectively scaling the wall’s height based on its distance from the camera.
Creating the illusion of depth#
While navigating the 3D space, it became difficult to discern sharp corners and edges due to a lack of shading. To address this, I implemented a simple luminosity calculator that adjusts the V
(Value) in the HSV color model, enhancing the illusion of depth.
The process involved first converting the wall’s color from RGB to HSV. Then I modified the V
value based on the distance of the ray hit and converted the color back to RGB for Unity to use. Here’s the code that accomplishes this:
1
2
| Color.RGBToHSV(wall_color, out h, out s, out v);
wall.gameObject.GetComponent<SpriteRenderer>().color = Color.HSVToRGB(h, s, (v - (d / max_distance)));
|
This method allowed for more intuitive navigation by making the walls closer to the camera appear brighter, thus giving a better sense of depth.
Fixing the fish-eye effect#
One thing that completely stumped me towards the end this project was the fish-eye effect caused by the method of calculating the distance to the wall.
After reading many articles and forum posts online, I managed to create a solution using the following formula:
\[
\text{distance} = \cos((\text{angle*difference} * \text{ray*iteration}) * ({\pi\over180}))
\]
The way this formula works is best described in this tutorial with the following diagram:
Final touches#
To complete this project, I completely reworked the controls system to allow for better movement, sprinting and mouse control. I also added some colours to the walls along with a yellow cylinder in the center of the room to showcase how the engine handles round edges.
Final code#
player_controller.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class player_controller : MonoBehaviour
{
public int speed;
public int runspeed;
private Rigidbody2D rb;
void Start()
{
rb = GetComponent<Rigidbody2D>();
}
void Update()
{
int usespeed = speed;
Cursor.lockState = CursorLockMode.Locked;
float rot = Input.GetAxis("Mouse X");
transform.Rotate(Vector3.forward * -rot * 2);
if (Input.GetKey(KeyCode.Escape))
{
Cursor.lockState = CursorLockMode.None;
}
if (Input.GetKey(KeyCode.LeftShift))
{
usespeed = runspeed;
}
Debug.Log(usespeed);
if (Input.GetKey(KeyCode.W))
{
rb.AddForce(transform.up * usespeed * 10);
}
if (Input.GetKey(KeyCode.S))
{
rb.AddForce(-transform.up * usespeed * 10);
}
if (Input.GetKey(KeyCode.D))
{
rb.AddForce(-transform.right * usespeed * 10);
}
if (Input.GetKey(KeyCode.A))
{
rb.AddForce(transform.right * usespeed * 10);
}
}
}
|
raycast_cam.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
| using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class raycast_cam : MonoBehaviour
{
public GameObject wall_render;
public Color wall_color;
public float max_distance;
public float FOV;
public int ray_count;
void FixedUpdate()
{
GameObject wall;
float FOV_slice = FOV / 2;
float ray_slice = ray_count / 2;
float h, s, v;
float angle_difference = (FOV_slice / ray_slice);
float halfHeight = Camera.main.orthographicSize;
float wall_width = ((Camera.main.aspect * halfHeight) / (ray_slice));
float wall_height;
// Clear the render canvas
GameObject[] gos = GameObject.FindGameObjectsWithTag("render");
foreach (GameObject go in gos)
{
Destroy(go);
}
// Draw front ray
Debug.DrawRay(transform.position, transform.up * max_distance, Color.green);
RaycastHit2D hit = Physics2D.Raycast(transform.position, transform.up);
if (hit.collider != null && hit.collider.tag != "Player")
{
float d = hit.distance;
if (d > max_distance)
{
// Do nothing
}
else
{
wall = Instantiate(wall_render, new Vector2(0, 0), Quaternion.identity);
wall_height = (halfHeight * 2) * (1 - (d / max_distance));
wall.transform.localScale = new Vector2(wall_width, wall_height);
// Get HSV values of defaul wall colour
Color.RGBToHSV(hit.collider.gameObject.GetComponent<SpriteRenderer>().color, out h, out s, out v);
wall.gameObject.GetComponent<SpriteRenderer>().color = Color.HSVToRGB(h, s, (v - (d * 2 / max_distance)));
}
}
// Draw left side of FOV
for (int i = 1; i < ray_slice; i++)
{
Vector3 raydir = Quaternion.AngleAxis(angle_difference * i, Vector3.forward) * transform.TransformDirection(Vector2.up);
Debug.DrawRay(transform.position, raydir * max_distance, Color.green);
hit = Physics2D.Raycast(transform.position, raydir);
if (hit.collider != null && hit.collider.tag != "Player")
{
float cosined = Mathf.Cos((angle_difference * i) * (Mathf.PI / 180));
float d = hit.distance * cosined;
if (d > max_distance)
{
// Do nothing
}
else
{
wall = Instantiate(wall_render, new Vector2(i * wall_width, 0), Quaternion.identity);
wall_height = (halfHeight * 2) * (1 - (d / max_distance));
wall.transform.localScale = new Vector2(wall_width, wall_height);
Color.RGBToHSV(hit.collider.gameObject.GetComponent<SpriteRenderer>().color, out h, out s, out v);
wall.gameObject.GetComponent<SpriteRenderer>().color = Color.HSVToRGB(h, s, (v - (d * 2 / max_distance)));
}
}
}
// Draw right side of FOV
for (int i = 1; i < ray_slice; i++)
{
Vector3 raydir = Quaternion.AngleAxis(-angle_difference * i, Vector3.forward) * transform.TransformDirection(Vector2.up);
Debug.DrawRay(transform.position, raydir * max_distance, Color.green);
hit = Physics2D.Raycast(transform.position, raydir);
if (hit.collider != null && hit.collider.tag != "Player")
{
float cosined = Mathf.Cos((angle_difference * i) * (Mathf.PI / 180));
float d = hit.distance * cosined;
if (d > max_distance)
{
// Do nothing
}
else
{
wall = Instantiate(wall_render, new Vector2(-i * wall_width, 0), Quaternion.identity);
wall_height = (halfHeight * 2) * (1 - (d / max_distance));
wall.transform.localScale = new Vector2(wall_width, wall_height);
Color.RGBToHSV(hit.collider.gameObject.GetComponent<SpriteRenderer>().color, out h, out s, out v);
wall.gameObject.GetComponent<SpriteRenderer>().color = Color.HSVToRGB(h, s, (v - (d * 2 / max_distance)));
}
}
}
}
}
|