a-simple-triangle / Part 30 - Basic user input

This will be the final technical article of the series where we will add some very basic user input to allow us to move around in our 3D scene with the keyboard. This article felt like a nice way to finish this series as our scene has been pretty static so far. The goals are:


The ‘player’ class

Our player class will represent the model of the entity in our 3D world which can move around or perform actions. A player will have a position and an orientation. We will also give it some functions to mutate its state such as move forward or turn left. We will be keeping the player class very simple though in a more complex application we may instead choose to generalise it as an entity and compose it with position and movement traits. If you are interested in this kind of idea you can read up on Entity Component Systems which I actually find quite fascinating: https://www.gamedev.net/articles/programming/general-and-gameplay-programming/understanding-component-entity-systems-r3013.

Let’s not muck about, create scene/player.hpp and scene/player.cpp. We are placing the player class in the scene folder as it isn’t really a core concept. Edit player.hpp with the following structure:

#pragma once

#include "../core/glm-wrapper.hpp"
#include "../core/internal-ptr.hpp"

namespace ast
{
    struct Player
    {
        Player(const glm::vec3& position);

        void moveForward(const float& delta);

        void moveBackward(const float& delta);

        void moveUp(const float& delta);

        void moveDown(const float& delta);

        void turnLeft(const float& delta);

        void turnRight(const float& delta);

        glm::vec3 getPosition() const;

        glm::vec3 getDirection() const;

    private:
        struct Internal;
        ast::internal_ptr<Internal> internal;
    };
} // namespace ast

Most of this should be fairly intuitive. We initialise the player with a position and can command it to move around in different ways. Notice that we pass in a delta to all the move operations which allows us to perform frame rate independent calculations. The player class will also expose its position and direction.

Edit player.cpp to fill in the implementation:

#include "player.hpp"

using ast::Player;

namespace
{
    glm::mat4 computeOrientation(const glm::mat4& identity, const float& rotationDegrees, const glm::vec3& up)
    {
        return glm::rotate(identity, glm::radians(rotationDegrees), up);
    }

    glm::vec3 computeForwardDirection(const glm::mat4& orientation)
    {
        return glm::normalize(orientation * glm::vec4(0, 0, 1, 0));
    }
} // namespace

struct Player::Internal
{
    const glm::mat4 identity;
    const glm::vec3 up;
    const float moveSpeed{5.0f};
    const float turnSpeed{120.0f};

    float rotationDegrees;
    glm::vec3 position;
    glm::mat4 orientation;
    glm::vec3 forwardDirection;

    Internal(const glm::vec3& position)
        : identity(glm::mat4(1)),
          up(glm::vec3{0.0f, 1.0f, 0.0f}),
          rotationDegrees(0.0f),
          position(position),
          orientation(::computeOrientation(identity, rotationDegrees, up)),
          forwardDirection(::computeForwardDirection(orientation)) {}

    void moveForward(const float& delta)
    {
        position -= forwardDirection * (moveSpeed * delta);
    }

    void moveBackward(const float& delta)
    {
        position += forwardDirection * (moveSpeed * delta);
    }

    void turnLeft(const float& delta)
    {
        rotate(turnSpeed * delta);
    }

    void turnRight(const float& delta)
    {
        rotate(-turnSpeed * delta);
    }

    void moveUp(const float& delta)
    {
        position.y += (moveSpeed * delta);
    }

    void moveDown(const float& delta)
    {
        position.y -= (moveSpeed * delta);
    }

    void rotate(const float& amount)
    {
        rotationDegrees += amount;

        if (rotationDegrees > 360.0f)
        {
            rotationDegrees -= 360.0f;
        }
        else if (rotationDegrees < 0.0f)
        {
            rotationDegrees += 360.0f;
        }

        orientation = ::computeOrientation(identity, rotationDegrees, up);
        forwardDirection = ::computeForwardDirection(orientation);
    }
};

Player::Player(const glm::vec3& position) : internal(ast::make_internal_ptr<Internal>(position)) {}

void Player::moveForward(const float& delta)
{
    internal->moveForward(delta);
}

void Player::moveBackward(const float& delta)
{
    internal->moveBackward(delta);
}

void Player::turnLeft(const float& delta)
{
    internal->turnLeft(delta);
}

void Player::turnRight(const float& delta)
{
    internal->turnRight(delta);
}

glm::vec3 Player::getPosition() const
{
    return internal->position;
}

glm::vec3 Player::getDirection() const
{
    return internal->forwardDirection;
}

void Player::moveUp(const float& delta)
{
    internal->moveUp(delta);
}

void Player::moveDown(const float& delta)
{
    internal->moveDown(delta);
}

Each of the command functions will change either the position or the orientation. The position is fairly simple, holding x, y, z coordinates in 3D space. To move forward we need to know what the current orientation is based on the angle we are pointing at. We use the following formula to calculate the orientation matrix using the current value of rotationDegrees and an up vector:

glm::mat4 computeOrientation(const glm::mat4& identity, const float& rotationDegrees, const glm::vec3& up)
{
    return glm::rotate(identity, glm::radians(rotationDegrees), up);
}

We need to convert the rotationDegrees into radians as GLM works with radians. The up vector for us will be fixed as (0, 1, 0) meaning we are always rotating around the y axis. In a game that allows more free form rotation you might instead use quaternions but we won’t bother for our use case. The result of performing the glm::rotate operation using the identity matrix as a base is the orientation matrix. We can use the orientation matrix to perform direction based calculations such as finding out which way is forward.

The formula to calculate which direction is forward given an orientation matrix is below:

glm::vec3 computeForwardDirection(const glm::mat4& orientation)
{
    return glm::normalize(orientation * glm::vec4(0, 0, 1, 0));
}

The orientation matrix is multiplied with a vec4 where we place the value of 1 in the coordinate we care about, which is the z coordinate at the third position. The fourth argument to the vec4 is required as the orientation matrix is a 4 x 4 matrix so we need 4 components. The result is a vec3 which includes the magnitude of x, y, z that represents the direction. Is is also very important that we normalize the resulting vector so it becomes a unit vector meaning that the magnitude of the vector along any of its axes never exceeds 1 - all magnitudes in the vector are scaled appropriately to meet this criteria. This is really useful when using a vector to perform other calculations such as the next ones about moving the player.

The code to move forward or backward is almost the same but essentially we take the moveSpeed multiplied by the delta which gives us the speed to apply in a frame independent way, multiplied by the forwardDirection vector which as mentioned is a unit vector. The resulting vector is then added or subtracted from the current position to translate it:

void moveForward(const float& delta)
{
    position -= forwardDirection * (moveSpeed * delta);
}

void moveBackward(const float& delta)
{
    position += forwardDirection * (moveSpeed * delta);
}

To rotate the player we will do a very simple calculation by adding the amount to rotate to our rotationDegrees field, wrapping it so it stays within the 0..360 range then subsequently regenerating our orientation matrix and forward direction to sync with the new rotation:

void rotate(const float& amount)
{
    rotationDegrees += amount;

    if (rotationDegrees > 360.0f)
    {
        rotationDegrees -= 360.0f;
    }
    else if (rotationDegrees < 0.0f)
    {
        rotationDegrees += 360.0f;
    }

    orientation = ::computeOrientation(identity, rotationDegrees, up);
    forwardDirection = ::computeForwardDirection(orientation);
}

The rest of this class is fairly self explanatory.


Update perspective camera

Currently our perspective camera class has a hard coded position and orientation that is calculated during its construction. We don’t yet expose a way to tell our camera to move somewhere else, or look somewhere else. We’d like to do this so we can feed in the player’s position and direction so the camera effectively tracks the player. We could have put all the code from the player class directly into our camera, but that means our camera could never do anything except represent the player - imagine if we wanted the camera to detach itself from the player and fly around the scene on its own - by decoupling the player (model) from the camera (view) we can do all sorts of tricks with the camera later without affecting the player itself.

We’ll be making a reasonable number of changes to our camera so I’ll put the entire code again here. First up, edit core/perspective-camera.hpp and replace with the following:

#pragma once

#include "../core/glm-wrapper.hpp"
#include "../core/internal-ptr.hpp"

namespace ast
{
    struct PerspectiveCamera
    {
        PerspectiveCamera(const float& width, const float& height);

        void configure(const glm::vec3& position, const glm::vec3& direction);

        glm::mat4 getProjectionMatrix() const;

        glm::mat4 getViewMatrix() const;

    private:
        struct Internal;
        ast::internal_ptr<Internal> internal;
    };
} // namespace ast

The configure function has been introduced which allows the camera to be given a new position and direction. The other functions are the same as before except I’ve removed the const& qualifiers from the getters. This is mainly because internally the view matrix will calculated dynamically instead of at construction time and to be consistent I’d done the same thing for the projection matrix.

Edit perspective-camera.cpp and replace with the following:

#include "perspective-camera.hpp"

using ast::PerspectiveCamera;

struct PerspectiveCamera::Internal
{
    const glm::mat4 projectionMatrix;
    const glm::vec3 up;
    glm::vec3 position;
    glm::vec3 target;

    Internal(const float& width, const float& height)
        : projectionMatrix(glm::perspective(glm::radians(60.0f), width / height, 0.01f, 100.0f)),
          up(glm::vec3{0.0f, 1.0f, 0.0f}) {}
};

PerspectiveCamera::PerspectiveCamera(const float& width, const float& height)
    : internal(ast::make_internal_ptr<Internal>(width, height)) {}

void PerspectiveCamera::configure(const glm::vec3& position, const glm::vec3& direction)
{
    internal->position = position;
    internal->target = position - direction;
}

glm::mat4 PerspectiveCamera::getProjectionMatrix() const
{
    return internal->projectionMatrix;
}

glm::mat4 PerspectiveCamera::getViewMatrix() const
{
    return glm::lookAt(internal->position, internal->target, internal->up);
}

There are a fair few more changes in the implementation. We still compute the projectionMatrix and up vector during construction but we no longer keep a constant position or target. The configure function takes a new position and stores it, then computes a new target by taking the current camera position and subtracting the direction. The target is still used when calculating the view matrix which we do using the glm::lookAt function as before, only this time it will be computed any time the getViewMatrix function is invoked.


Update main scene

We now have a player and a camera that can be mutated so we can update our main scene to use them. Edit scene/main-scene.cpp and add the following header includes:

#include "../core/sdl-wrapper.hpp"
#include "player.hpp"

Now go to the Internal structure and add a new field to hold our player instance, initialising it with a position of (0, 0, 2):

struct SceneMain::Internal
{
    ...
    ast::Player player;

    Internal(const ast::WindowSize& size)
        : ...
          player(ast::Player(glm::vec3{0.0f, 0.0f, 2.0f})) {}

Edit the update function, adding a new call to the camera.configure function before we get the camera matrix. Note that we pass in the player position and direction:

struct SceneMain::Internal
{
    ...

    void update(const float& delta)
    {
        camera.configure(player.getPosition(), player.getDirection());

        const glm::mat4 cameraMatrix{camera.getProjectionMatrix() * camera.getViewMatrix()};

Run the application and everything should render exactly the same as before though now we are feeding our camera with the position and direction of our player. This means that any time we adjust the position or direction of our player the camera will automatically be updated to follow along with it.


Adding input events

To move our player we will add a new function to our main scene to handle input, looking for particular key scan codes to know which commands to run on our player.

We will be using an SDL function named SDL_GetKeyboardState to find out what keys are currently being pressed on the keyboard. The keyboard state itself is held internally inside SDL - the SDL_GetKeyboardState function simply returns to us a pointer to it. We will grab this pointer and store it in a field so we don’t have to keep asking for it. Add the following internal field to hold the keyboard state pointer and initialise it in the constructor like so:

struct SceneMain::Internal
{
    ...
    const uint8_t* keyboardState;

    Internal(const ast::WindowSize& size)
        : ...
          keyboardState(SDL_GetKeyboardState(nullptr)) {}

Note: We do not need to destroy the keyboard state as we do not own it - the main SDL runtime owns it and will manage its lifecycle. We just need to keep a pointer to it so we can access it.

Add the following function to use our keyboard state:

struct SceneMain::Internal
{
    ...

    void processInput(const float& delta)
    {
        if (keyboardState[SDL_SCANCODE_UP])
        {
            player.moveForward(delta);
        }

        if (keyboardState[SDL_SCANCODE_DOWN])
        {
            player.moveBackward(delta);
        }

        if (keyboardState[SDL_SCANCODE_A])
        {
            player.moveUp(delta);
        }

        if (keyboardState[SDL_SCANCODE_Z])
        {
            player.moveDown(delta);
        }

        if (keyboardState[SDL_SCANCODE_LEFT])
        {
            player.turnLeft(delta);
        }

        if (keyboardState[SDL_SCANCODE_RIGHT])
        {
            player.turnRight(delta);
        }
    }

I think this code should explain itself pretty clearly, we are just looking in the keyboard state for various SDL_SCANCODE values and firing the appropriate commands on the player object as needed. Lastly we need to call our new processInput function when we update the scene. Edit the update function and invoke processInput as the first thing that we do like this:

void update(const float& delta)
{
    processInput(delta);

    camera.configure(player.getPosition(), player.getDirection());

    const glm::mat4 cameraMatrix{camera.getProjectionMatrix() * camera.getViewMatrix()};

Run your application again and you should now be able to use the arrow keys to move around and press A or Z to move up and down! Try it below in the Emscripten version:


Summary

This concludes the A Simple Triangle series at least for now. I am writing this article in October 2019 which is over a year since I started on the research and development represented throughout this series. I have learned a massive amount about OpenGL, Vulkan, SDL and cross platform C++ development spanning MacOS, Windows, Android, iOS and browsers and I hope that by authoring my learnings I can help others who might be interested in these topics too.

It took a long to time to complete my goals as I only had pockets of spare time to invest in it outside my normal day job and family life. To be sure there were absolutely points in time where I lost interest and motivation and I had to take a break from it, but I’m happy that I followed through to its conclusion.

There are many subsequent and related topics that I’d like to explore further including:

In the future I might pick up some of these topics and add some more articles to the series about them but for now I’ll stop at this point.

Happy coding!!!

The code for this article can be found here.

End of part 30