Raymarching from Scratch (Part 3)

First Person Shooter (FPS) style camera

In this chapter we will step away from our Raymarcher renderer. Instead we are going to implement a camera movement. We will be able to move forward, strafe, fly and look around using our mouse and keyboard.

This chapter is Godot-heavy. I assume you have some passing experience with the modern game engines (not Godot specifically). This means that I will not explain you what a script is. But I will guide you step-by-step on what to do.

Godot uses GDScript programming language for scripting. It is a Python-like language, very simple to learn and use. If you are not familliar with it, don’t worry. You will learn it by the end of this tutorial – it is that simple!

Actions

We will bind WASD keyboard keys for the basic movement and QE to move up and down. We could poll these keys directly (KEY_W, KEY_S, …). But there is a better way to do it. We will use Godot actions instead. Actions allow us to bind multiple inputs (like keyboard keys, mouse buttons, gamepad axis) to the same ‘action’. In the script we can ask Godot whether some action was triggered or not.

Open your project and go to Project -> Project Settings -> Input Map tab. You will see a list of existing actions. Add 6 more actions

  • camera_left
  • camera_right
  • camera_forward
  • camera_backward
  • camera_up
  • camera_down.

Here is my configuration:

Rotating the Camera around

Lets create a script that will move and rotate our camera around. I like to use many small scripts instead of a one large blob of code. So I created a subnode CameraMovement and attached a script to it.

Do not change any parameters when you create a script – just press Create button. A script file with code will appear. Delete everything except the extends Node line.

I took ideas from the Godot FPS Tutorial and LearnOpengl.com post. You can follow along this post and later read those articles to understand a theory behind our code.

But before we start, select the ColorRect node and set the cameraPos parameter of our ColorRect shader to the (-3, 0, 0) and the front vector to the (1, 0, 0).

Don’t do it and all you will see is the black void instead of a sphere…

First we will define script variables. Enter the code below:

extends Node

# expose variables to the editor (and other scripts)
export(float) var MOUSE_SENSITIVITY = 0.05

# private variables
var mouse_offsets: Vector2 = Vector2() # in degrees

mouse_offsets store the pitch (x attribute) and yaw (y attribute) angles. We will update them as we move the mouse. Please note that we store angles in degrees.

Next add the following function:

func compute_direction(pitch_rad, yaw_rad):
    """
    Get front unit vector from the pitch and yaw angles
    """
    return Vector3(
        cos(pitch_rad) * cos(yaw_rad),
        sin(pitch_rad),
        cos(pitch_rad) * sin(yaw_rad)
    )

compute_direction converts our yaw and pitch angles into a front vector. To understand what is going on please follow this link.

Now add the _input(event) function. This is a callback function for the Godot. It is called when some event occurs. Events include:

  • Keyboard key was pressed or released
  • Mouse button was pressed
  • Mouse has moved
  • Action was triggered

Type in the following code:

func _input(event):
# update the pitch and yaw angles
    # intercept the mouse motion event and ignore all the others
    if event is InputEventMouseMotion:
        mouse_offsets += event.relative * MOUSE_SENSITIVITY
        # keep pitch in (-90, 90) degrees range to prevent reversing the camera
        mouse_offsets.y = clamp(mouse_offsets.y, -87.0, 87.0)
        var new_direction = compute_direction(
            deg2rad(-mouse_offsets.y),
            deg2rad(mouse_offsets.x)
        )
        
        # update front vector in the shader
        var color_rect = get_parent()
        color_rect.material.set_shader_param("front", new_direction)

First of all we check what is the type of our new event. We want to process the InputEventMouseMotion event. It occurs when we move the mouse.

Then we check how far the mouse moved (event.relative) and scale it down. Then we make sure that the pitch angle is in the [-87, 87] degree range. If we fail to do so we will see the world upside down when we have moved mouse up too much.

We are computing new front direction using our utility function compute_direction and pass it to the shader.

Save the script and run the application. Move the mouse (or trackpad) around. You should get a smooth camera rotation, just like in the FPS games.

Pro tip: if you are using a laptop, plug it in. Otherwise the movement might be choppy.

However, there is an inconvenience: when you move mouse outside of the app borders you can no longer move the camera. Lets fix this issue.

Godot provides a few mouse modes. We are interested in MOUSE_MODE_CAPTURED and MOUSE_MODE_VISIBLE. The later is a default mode. We want to change that. Add the following function:

func _ready():
    # we want to capture the mouse cursor at the start of the app
    Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

_ready() is yet another callback function. It is called when the script starts running.

But what if we do want the mouse to leave our app? Lets toggle the mouse mode on the ui_cancel action. By default it is bound to the Escape key.

In the _input function replace

if event is InputEventMouseMotion:

with

if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED

Append the following code to the _input function:

func _input(event):
    ...
    
    # capture/release mouse cursor on the 'Escape' button
    if event.is_action_pressed("ui_cancel"):
        if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
            Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
        else:
            Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

Save and run. Now the mouse should not be visible and it should not leave the borders of our app. Hit Escape on your keyboard and the mouse cursor will appear. Hit it again and you will regain control of the camera. Perfect!

If it is not perfect, check out my GitHub project. git checkout part3-rotation will do the trick.

Moving the Camera

Now lets utilize the keyboard to move around our world. Add 2 more helper functions to the CameraMovement script:

func compute_direction_forward(yaw_rad):
    """
    Get front unit direction on the XZ plane (do not cosidering the height)
    """
    return Vector3(
        cos(yaw_rad),
        0,
        sin(yaw_rad)
    )


func compute_direction_right(yaw_rad):
    """
    Get right unit direction on the XZ plane (do not cosidering the height)
    """
    return Vector3(
        -sin(yaw_rad),
        0,
        cos(yaw_rad)
    )

We want to obtain the ‘forward’ and the ‘right’ relative directions. All we need is the yaw angle.

Note where are the x and z axis

Lets rename forward as d. Than we can obtain the projections of d on the x and z axis using our yaw angle:

|d| is the length of d

If we move ‘forward’ (in the direction of d) 1 unit ahead, we move (1 * cos(yaw)) on the x axis and (1 * sin(yaw)) on the z axis. That’s where the cos and sin come from in our helper functions. Note that we leave y coordinate alone – we don’t want to fly up or down when we are running!

‘Right’ direction (it is orthogonal to the ‘forward’) is obtained through the following fact: if you have a 2d vector (a, b), then the vector (-b, a) is orthogonal to it.

Let’s implement this idea in our code. Add MAX_SPEED and velocity variables to the script:

# expose variables to the editor (and other scripts)
export(float) var MOUSE_SENSITIVITY = 0.05
export(float) var MAX_SPEED = 300

# private variables
var velocity: Vector3 = Vector3()
var mouse_offsets: Vector2 = Vector2() # in degrees

Add yet another helper function:

func update_velocity(delta):
    """
    Update velocity vector using actions (keyboard or gamepad axis)
    """
    # get current step size
    var delta_step = MAX_SPEED * delta
    var direction_forward = compute_direction_forward(deg2rad(mouse_offsets.x))
    var direction_right = compute_direction_right(deg2rad(mouse_offsets.x))
    # we will have no intertion
    # if we release buttons, we will stop immediately
    self.velocity = Vector3()

    ...

delta is the time (in seconds) since the last update. Long story short – you have to scale the step to make movement consitent across different computers (or consoles).

Lets process the camera_forward and camera_backward events:

func update_velocity(delta):
    ...
    # go forward/backward
    if Input.is_action_pressed("camera_forward"):
        self.velocity += delta_step * direction_forward
    elif Input.is_action_pressed("camera_backward"):
        self.velocity -= delta_step * direction_forward
    ...

By the magic of copy-pasting I have processed other actions as well

func update_velocity(delta):
    ...

    # strafe left/right
    if Input.is_action_pressed("camera_left"):
        self.velocity -= delta_step * direction_right
    elif Input.is_action_pressed("camera_right"):
        self.velocity += delta_step * direction_right

    # fly up/down
    if Input.is_action_pressed("camera_up"):
        self.velocity.y = delta_step
    elif Input.is_action_pressed("camera_down"):
        self.velocity.y = -delta_step

Lets update cameraPos shader variable in separate function:

func update_camera_position(delta):
    """
    Update camera position and pass it to the shader
    """
    var color_rect = get_parent()
    var cur_pos = color_rect.material.get_shader_param("cameraPos")
    var new_pos = cur_pos + self.velocity * delta
    color_rect.material.set_shader_param("cameraPos", new_pos)

And use the Godot’s _process_physics callback to actually obtaint movement:

func _physics_process(delta):
    """
    Move the camera with the steady 60 FPS loopback
    """
    update_velocity(delta)
    update_camera_position(delta)

Save the script and run the app. Press WASD to move around and QE to elevate the camera.

In case you are stuck, git checkout part3-movement.

Bonus: Field of View

And while we are at it, lets add zooming with the mouse wheel! Add another child to the ColorRect node and name it Fov. Attach a script to it.

Add 2 more actions. Go to the Project -> Project Settings -> Input Map tab and add camera_zoom_in and camera_zoom_out actions. Bind the Wheel Up and Wheel Down mouse buttons to them, respectively.

Edit the Fov.gd script. Type in the following code:

extends Node

export(float) var WHEEL_STEP = 4

# onready will execute when the script is starting to run
onready var fov = get_parent().material.get_shader_param("fov")

func update_fov(new_fov):
    # keep FOV in the [15, 135] range
    self.fov = clamp(new_fov, 15, 135)
    get_parent().material.set_shader_param("fov", self.fov)

func _input(event):
    """
    Process the mouse wheel scroll event to change the FOV
    """
    # intercept the mouse wheel up/down event
    if event.is_action_pressed("camera_zoom_in"):
        update_fov(fov - WHEEL_STEP)
    elif event.is_action_pressed("camera_zoom_out"):
        update_fov(fov + WHEEL_STEP)

Save and run the app. Try scrolling the mouse wheel (or panning on a touchpad). You should observe zooming effect.

If you are stuck, git checkout part3-fov to get the full project.

Links

Published by pavleeto

I am a software engineer obsessed with experimentation and learning new things. I've done lots of pet project in various topics like video games, graphics, math etc. Feel free to say Hi in on LinkedIn or GitHub!

Leave a comment

Design a site like this with WordPress.com
Get started