Wasd and Qe

How to 3D

Chapter 7: Camera

Wasd and Qe

Many games let the player move the camera with W, A, S, D, or the cursor keys. Some let the player yaw the camera with other keys, like Q and E. A key listener responds to each of these events with a single call to a camera method. It might look like this in TypeScript:

function onKeyDown(event: KeyboardEvent) {
  const key = event.key;
  if (key === 'w' || key === 'ArrowUp') {
    camera.advance(moveDelta);
  } else if (key === 's' || key === 'ArrowDown') {
    camera.advance(-moveDelta);
  } else if (key === 'a' || key === 'ArrowLeft') {
    camera.strafe(-moveDelta);
  } else if (key === 'd' || key === 'ArrowRight') {
    camera.strafe(moveDelta);
  } else if (key === 'q') {
    camera.yaw(turnDelta);
  } else if (key === 'e') {
    camera.yaw(-turnDelta);
  }
  render();
}
function onKeyDown(event: KeyboardEvent) {
  const key = event.key;
  if (key === 'w' || key === 'ArrowUp') {
    camera.advance(moveDelta);
  } else if (key === 's' || key === 'ArrowDown') {
    camera.advance(-moveDelta);
  } else if (key === 'a' || key === 'ArrowLeft') {
    camera.strafe(-moveDelta);
  } else if (key === 'd' || key === 'ArrowRight') {
    camera.strafe(moveDelta);
  } else if (key === 'q') {
    camera.yaw(turnDelta);
  } else if (key === 'e') {
    camera.yaw(-turnDelta);
  }
  render();
}

This method is a callback that must be registered with the browser like this:

window.addEventListener('keydown', onKeyDown);
window.addEventListener('keydown', onKeyDown);

This callback has been added to the first-person grid-walker below. Try it out. Before pressing the keys, click on the canvas to give it keyboard focus.

There are no Easter eggs hidden in this renderer. You'll find nothing but a grid in all directions. As you look around, you might notice that the movement is choppy. The severity of the choppiness depends on your operating system's key repeat rate. If the key repeat rate is fast, then new keydown events will be generated more quickly and the movement will be smoother.

Probably we do not want to force our users to change the system-wide repeat rate in order to get smooth rendering. Smoothness can be achieved in other ways. For example, we could switch to continuous rendering, in which a new frame is drawn as frequently as possible, and not just on an explicit request. In the browser, we schedule continuous rendering with the builtin requestAnimationFrame method:

function initialize() {
  // ...
  animate();
}

function animate() {
  // update animated state
  render();
  requestAnimationFrame(animate);
}
function initialize() {
  // ...
  animate();
}

function animate() {
  // update animated state
  render();
  requestAnimationFrame(animate);
}

The animate method updates any state that changes over time and then re-renders the scene. It then schedules itself to run again in the very near future.

For smooth camera motion, we update the camera in animate based on which keys are currently held down. The browser doesn't let us query the state of a key, so we'll have to track their state manually through event listeners. These TypeScript functions track the key state in three global variables:

// These will be -1, 0, or 1.
let horizontal = 0;
let vertical = 0;
let turn = 0;

function onKeyDown(event: KeyboardEvent) {
  const key = event.key;
  if (key === 'w' || key === 'ArrowUp') {
    vertical = 1;
  } else if (key === 's' || key === 'ArrowDown') {
    vertical = -1;
  } else if (key === 'a' || key === 'ArrowLeft') {
    horizontal = -1;
  } else if (key === 'd' || key === 'ArrowRight') {
    horizontal = 1;
  } else if (key === 'q') {
    turn = 1;
  } else if (key === 'e') {
    turn = -1;
  }
  render();
}

function onKeyUp(event: KeyboardEvent) {
  const key = event.key;
  if (key === 'w' || key === 's' || key === 'ArrowUp') {
    vertical = 0;
  } else if (key === 'a' || key === 'd' || key === 'ArrowLeft') {
    horizontal = 0;
  } else if (key === 'q' || key === 'e') {
    turn = 0;
  }
}
// These will be -1, 0, or 1.
let horizontal = 0;
let vertical = 0;
let turn = 0;

function onKeyDown(event: KeyboardEvent) {
  const key = event.key;
  if (key === 'w' || key === 'ArrowUp') {
    vertical = 1;
  } else if (key === 's' || key === 'ArrowDown') {
    vertical = -1;
  } else if (key === 'a' || key === 'ArrowLeft') {
    horizontal = -1;
  } else if (key === 'd' || key === 'ArrowRight') {
    horizontal = 1;
  } else if (key === 'q') {
    turn = 1;
  } else if (key === 'e') {
    turn = -1;
  }
  render();
}

function onKeyUp(event: KeyboardEvent) {
  const key = event.key;
  if (key === 'w' || key === 's' || key === 'ArrowUp') {
    vertical = 0;
  } else if (key === 'a' || key === 'd' || key === 'ArrowLeft') {
    horizontal = 0;
  } else if (key === 'q' || key === 'e') {
    turn = 0;
  }
}

The animate callback inspect the three key-state variables and updates the camera accordingly. To ensure the changes aren't dependent on the speed of the computer and browser, the amount of rotation or movement should tempered by how much time has actually passed since the last frame. To compute the elapsed time, we'll need two times:

  1. The time at which the current frame is being drawn. We could query this manually with the builtin performance.now() method, but we don't have to. The browser passes the time to the callback we register with requestAnimationFrame.
  2. The time at which the previous frame was drawn. The current time will be the previous time for the next frame, so we prepare for it by recording the current time at the end of animate. To prepare for the first call, we initialize the previous time with performance.now().

This TypeScript code shows the pattern of rendering animations independent of the frame rate:

let previousMillis: number;

function initialize() {
  // ...
  previousMillis = performance.now();
  animate(previousMillis);
}

function animate(currentMillis) {
  let elapsedMillis = currentMillis - previousMillis;
  // use ellapsedMillis to temper the motion...
  previousMillis = currentMillis;
}
let previousMillis: number;

function initialize() {
  // ...
  previousMillis = performance.now();
  animate(previousMillis);
}

function animate(currentMillis) {
  let elapsedMillis = currentMillis - previousMillis;
  // use ellapsedMillis to temper the motion...
  previousMillis = currentMillis;
}

Try moving and turning the camera in this renderer that uses continuous rendering to smooth out the camera changes:

Continuous rendering is vital for smooth animations. However, it also draws more energy than discrete, event-driven rendering.

← Rotate AroundLooking with the Mouse →