November 28, 2022

Today we’re implementing FPS controls (minus the S) for navigating our LÖVR scenes.

Introduction

So far in the Let’s Learn the LÖVR Framework series, we’ve set up basic scenes with or without VR headset support (we’re focusing on without) and taken our first steps into the world of lighting with custom shader code. It would be nice to bring back a version of the FPS keyboard/mouse controls we lost when we disabled stereoscopic headset rendering so we could explore the scenes we build.

On LÖVR keyboard support

As I understand it, early versions of the LÖVR framework were so VR-focused, there wasn’t much in the way of native keyboard or mouse support at all. Since that time, code has gone in two directions: keyboard and mouse input libraries have been made available and, separately, keypressed and keyreleased callbacks have been added to the framework natively.

While I don’t like tacking on libraries when I don’t have to, this route has a lot going for it, for my purposes. The keyboard library is more fully-featured, I don’t think any callbacks comparable to the keypressed/released events have been added yet, and there’s actually pretty full-featured example code tucked away in the docs that do rely on the libraries. This greatly outweighs my stubbornness. I happily copied over the keyboard and mouse code files and added the inclusion lines to my scene:

lovr.keyboard = require 'lovr-keyboard'
lovr.mouse = require 'lovr-mouse'

About today’s scene

While I’ll always look at the little tree scene from the previous posts fondly, I wanted to mix things up, so we’re trying an interior scene today–name that famous room! I wanted to see how the lighting setup behaved indoors (perfectly, more or less?) and try something of my own with image textures.

I didn’t know a lot about the GLTF format, but apparently you can create a scene in Blender with practically unlimited objects and materials and just export it as a single file. This alone may prevent the need for a custom editor of some kind for building maps–at least right up front.

I thought this would be a nice environment to walk around in for testing before trying to add the props you’re probably expecting, working with colliders, object interactivity, basic UI, etc.

Advertisements

Tweaking the example code

Lovr.org’s FPS controls code is easy to miss (source: I missed it and I’ve seen the same problem brought up on Slack). At the moment, it’s under Examples > Debugging. It’s around 60 lines long, but it only took me a few tweaks to suit my scene just about perfectly. You can sort through the full code at the site if you care to, I’ll highlight some spots I had to look into or alter.

defaultCameraHeight = 1

This first line is actually mine. If you use the site’s example code, you will end up with perfect keyboard and mouse-based movement, but you’ll actually be able to look up or down and walk in those directions as if by magic.

It makes sense for the example code to work this way because controls like this will most often be used in games and scenes with physics and gravity of some kind. Since that’s slightly outside the scope of what I’m trying to accomplish here (for now), I’ve added a quick and dirty default height that I’ll be using to reset my position in vertical space when needed. This allows me to input freely without leaving the floor or sinking through it.

lovr.mouse.setRelativeMode(true)

This line is added to the load() function. The README explains that relative mode makes the mouse cursor invisible and allows it to move infinitely, letting you operate on dx and dy parameters to determine where the player is trying to look. I’m so glad I didn’t need to try to figure this out–or worse, code it–on my own.

  camera = {
    transform = lovr.math.newMat4(),
    position = lovr.math.newVec3(),
    movespeed = 10,
    pitch = 0,
    yaw = 0
  }

This is also added to load(). Here we’re creating a camera object with parameters we can operate on as the player provides keyboard and mouse input to move it. This camera acts for the player, so I had to make a few adjustments away from the beginnings of a player object I had started on my own, particularly as it related to the view position I was passing to my lighting.

For this block, I adjusted movespeed to 5. 10 is pretty quick.

Advertisements
local velocity = vec4()

if lovr.keyboard.isDown('w', 'up') then
    velocity.z = -1
elseif lovr.keyboard.isDown('s', 'down') then
    velocity.z = 1
end

if lovr.keyboard.isDown('a', 'left') then
    velocity.x = -1
elseif lovr.keyboard.isDown('d', 'right') then
    velocity.x = 1
end

This goes in the update(dt) function. We’re setting up a 4D vector variable to store velocity directions and speeds–looking at the later blocks here–in X and Z directions (Y is of course manipulated by the mouse) using simple 1 or -1 values.

Why 4D, I thought to myself? I wish I hadn’t asked. Remember, I’m new to 3D programming. If you know all this, freely skip ahead.

Specifically, I was pretty sure X, Y, and Z were all present, but is the fourth value the friends we met along the way?

The documentation for Vec4:set specifies that you can use the function to set x, y, z, and w values. I was very confident W didn’t stand for width, so I had to keep looking.

So far, my favorite explanation of the W dimension is at software developer Tom Dalling’s blog. Not only does he helpfully summarize that “homogeneous coordinates have an extra dimension called W, which scales the X, Y, and Z dimensions,” he also points out big functional areas where this is used in computer graphics, like translation matrices for 3D coordinates, perspective transformation, and positioning directional lights. Seeing this was the first moment I started to grasp the concept as it applies to 3D worlds.

But W isn’t going to be used at all today, so I slammed the brakes on my brain’s extremely limited learning capacity. Onward!

Note: Of course I do not actually think this way about the learning process. I know very well that this concept is going to be back with even scarier friends almost immediately.

if #velocity > 0 then
    velocity:normalize()
    velocity:mul(camera.movespeed * dt)
    camera.position:add(camera.transform:mul(velocity).xyz)
    camera.position.y = defaultCameraHeight --my height fix
end

In update(dt) we’re using normalize() to keep velocity’s direction the same, but adjusting its values so that its length becomes 1. mul() multiplies camera.movespeed by the tiny amount of time that has passed since the last update loop which will scale our current velocity value to the tiny value we actually need to apply this time around. Finally, we go ahead and use these properly scaled X, Y, and Z values to adjust the actual camera (or player) position.

Again, without gravity, this could result in the player leaving the floor, so I use my defaultCameraHeight value to clamp them back in position vertically wherever they are.

Advertisements
camera.transform:identity()
camera.transform:translate(0, defaultCameraHeight, 0)
camera.transform:translate(camera.position)
camera.transform:rotate(camera.yaw, 0, 1, 0)
camera.transform:rotate(camera.pitch, 1, 0, 0)

Recall that camera.transform is a Mat4 of our own creation. The Mat4 type is a 16-value 4×4 grid, used here for determining exactly what the camera should show using the position we set earlier and the rotation based on mouse offsets we’ll capture separately during their callback. In each update loop, we’re using identity() to clear all translation and rotation, using our own values to set the camera’s height, its X/Z position, and rotate it according to where we’ve gestured with the mouse.

I substituted a default value of 1.7 for my defaultCameraHeight.

function lovr.mousemoved(x, y, dx, dy)
  camera.pitch = camera.pitch - dy * .001
  camera.yaw = camera.yaw - dx * .001
end

The mousemoved(x, y, dx, dy) function allows you to capture cursor movement. It’s pretty simple, but it’s worth pointing out you could introduce a X and Y sensitivity variables in place of the .001 values here, or have one variable control them both. These values felt decent for testing purposes.

lovr.graphics.setViewPose(1, camera.transform)

By draw time, all that’s left to do is set the first view (used when headsets and stereoscopic rendering are disabled) to our camera.transform property. The example uses lovr.graphics.push() at the beginning of the draw loop and lovr.graphics.pop() at the end. Again, beginner here, but I can’t determine if this is strictly necessary at this point for any reason. Out of an abundance of caution, I’m using it.

Where that leaves us

We can move and look around!

Naturally, we can walk through walls and we’re little more than fancy ghosts at this point. But the scene is interactive!

Up next

To get any more interesting at all from this point, we probably need to look seriously at colliders and collision testing. Luckily, this was part of the plan. I hope to add more of the objects you think about when you see this room–yes, doors and toilets–and see if we can come even closer to the real thing.

Stay tuned!

About the writer

Leave a Reply