My method of creating a raycasting engine in the Unity2D engine.

Written by Tinos Psomadakis

23/05/2021

Introduction

I've always been fascinated by the way 3D graphics were rendered before graphics cards were able to render realistic lighting and geometry in games such as DOOM and Wolfenstein 3D. I decided to do some research into how raycasting works and attempt to create my own raycasting renderer. I'd like to preface this article by stating that 3D graphics, rendering and engines are absolutely not my speciality and everything I'm going to be showing you should not be seen as the most efficient and effective way of creating a raycasting renderer.


Setting up

The first step I took to creating my raycasting engine was to set up a really basic scene. I decided to focus on the top-down map with a simple rectangle and player. I also added the two scripts that the project will need: raycast_cam.cs which will handle anything to do with raycasting and player_controller.cs which will handle player controls.

A simple Unity2D workspace with a simple top-down map
Fig.1 - My initial setup for the player and one wall to use when initially testing my raycaster.

Controlling the player

Rotating the player

Initially, I wanted to get my player rotating with the q and e buttons since I did not want to have to figure out and worry about getting mouse controls working. This was easily achieved with the script below which was placed into the player_controller.cs file.

void Update()
    {
        if (Input.GetKeyDown("q"))
        {
            transform.Rotate(Vector3.forward * turn_angle);
        }

        else if (Input.GetKeyDown("e"))
        {
            transform.Rotate(Vector3.forward * -turn_angle);
        }

    }
Simple top-down player rotating
Fig.2 - The rotation method I implemented in action.

Moving the player

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

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, believe it or not, we need to create some rays. To get started I simply added a really simple Debug.DrawRay(transform.position, transform.TransformDirection(Vector2.up) * max_distance, Color.green); to my raycast_cam.cs file just so that I could visualize my rays.

Creating a single ray in Unity2D
Fig.3 - Drawing a singular ray shooting out of the front of my top-down player.

Now that I had managed to get a singular ray to be drawn, I wanted to work on getting the ray to actually do something. In Unity, the DrawRay method simply draws the ray onto the screen without it being able to do anything. For that reason, I had to create a RaycastHit2D object. I then decided to make it so that when that ray hit an object, it would change the object's colour to red. This allows me to visualize exactly what's going on when my ray hits an object.


A gif showing a ray hitting a white rectangle and turning it red
Fig.3 - A single ray interacting with the object it is hitting by turning it red.

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 which dynamically changes the spread of each ray (which there will now be multiple of). I found myself at a bit of a roadblock when trying to figure out how to create rays at angles but thankfully I found this forum answer which gave an answer that worked really well for me. When creating my raycast renderer, I decided to split the renderer into two "eyes": one for the left and one for the right. I focused on getting the left eye working using this new method so first I needed to divide the FOV by two float FOV_slice = FOV / 2; and also divide the number of rays by two float ray_slice = ray_count / 2;

To calculate the size of the angle between the each ray, I divided the FOV slice (degrees) by the number of rays in a slice. float angle_difference = (FOV_slice / ray_slice); Using the forum answer I had found, I created the following code that would render in my rays with appropriate spacing between them:

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);
}

Number of rays and angle of rays changing dynamically with sliders
Fig.4 - Number of rays and angle of rays changing dynamically with sliders

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

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

Having gotten the basics of the FOV system working, I began working on getting some walls rendering with the camera. The first thing to do was to create a wall prefab. To do this, I created a sprite which was just a 1x1 square with a tag "render" and a box collider so that the raycasts can interact with the wall. I then added a simple line of code to clear the rendered items on every frame which was accomplished with the code below:

// Clear the render canvas
GameObject[] gos = GameObject.FindGameObjectsWithTag("render");
foreach (GameObject go in gos)
{
    Destroy(go);
}

Next, I wanted to just get wall rendering for the front ray working which was actually really easy to do. Using the ray, I could calculate the distance it travelled before intersecting with a wall. I then would Instantiate my wall prefab at the center of the camera and set the height of the wall to the maximum distance subtracted by the distance of the hit.

// 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)
    {

    }
    else
    {
        wall = Instantiate(wall_render, new Vector2(0, 0), Quaternion.identity);
        wall.transform.localScale = new Vector2(wall_width, max_distance - d);
    }

}
Raycasting renderer rendering just the front ray
Fig.5 - Raycasting renderer rendering just the front ray

Rendering the side rays

With the front ray successfully rendering the wall it is colliding with, it was time to replicate the code for the side rays. The first thing that had to be figured out was the width of each wall. To calculate this value, I used the following code: float wall_width = ((Camera.main.aspect * halfHeight) / (ray_slice)); which ended up working well enough for the time being. As for the height of the walls, I used the following code: wall_height = halfHeight * (1 - (d / max_distance)); which now takes half of the height of the camera and multiplies that by the percentage distance the ray hit is from the camera.

Raycasting renderer using two eyes at the same time to render in simple scene
Fig.6 - Raycasting renderer using two eyes at the same time to render in simple scene

Creating depth

While trying to navigate the 3D space, it was really hard to tell where sharp corners and edges were because of the lack of shading. To fix this issue, I implemented a really simple luminosity calculator which would use the HSV values to change the V (Value) of the wall. The first step was to take the wall's colour and convert it to HSV. Then I needed to change the V value and convert it back into RGB for the Unity engine to use instead.

Color.RGBToHSV(wall_color, out h, out s, out v);
wall.gameObject.GetComponent<SpriteRenderer>().color = Color.HSVToRGB(h, s, (v - (d / max_distance)));
Example of new luminsoity calculations
Fig.7 - Example of renderer using new luminosity calculator.

Fixing the fisheye effect

One thing that completely stumped me for the most part of this project was the fisheye effect created by the method of calculating the distance to the wall.

Fisheye effect caused with simple method of raycasting implemented
Fig.8 - Example of fisheye effect clearly visisble at high FOVs.

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 here with the following diagram:

Example of cosine being used to fix fisheye effect found in simple raycasting engines
This is not my diagram. Please visit https://www.permadi.com/tutorial/raycast/rayc8.html for more information.

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 demonstration of my raycasting renderer made in Unity2D
Final outcome of my raycasting renderer made in Unity2D.

Final code

player_controller.cs
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
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)
            {

            }
            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)
                {

                }
                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)
                {

                }
                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)));
                }

            }
        }



    }
}