# Third person cam: Over-the-shoulder perspective

Hi there!

We all know the ChaseCamera class that lets us create a nice third person camera for our player character. This class has a method called setLookAtOffset() that lets us set an offset for the camera to look at, instead of the origin of the player model, which in most cases is its feet. This way, we can tell the cam to look at, say, 50 cm to the right of the player character at a height of about 1.5 m.

“Unfortunately” this method works within world space, which means the intended offset will only be right if player and camera are facing in a certain direction. This is usually not what we want when we think of a typcal over-the-shoulder third person camera. For some discussion of this topic I’ll refer you to this thread: Over Shoulder Camera

One common method for solving this problem is creating an additional node in the player character’s .j3o file (or wherever) that’s a child of the player character and thus moves with it. This node is then set to the desired over-the-shoulder position, where it will be the chaseCam’s target. However, this method has only limited applicability. Above all, it requires the camera to always be straight behind the player character, facing its back. As soon an the player moves independently from the camera, or the other way around, the offset is compromised.

I have implemented a small method which computes the right offset, and I thought I’d share in case anyone else is interested. I have put this method in a custom control that gets added to the spatial representing the player character. There it gets called inside the controlUpdate() function, where it computes the camera’s direction vector. The math is actually quite simple.

This is the relevant code:

[java]
private Vector3f calculateCamOffset() {

``````	Vector3f camToPlayer = spatial
.getWorldTranslation()
.subtract(cam.getLocation())
.normalize();

return new Vector3f(camToPlayer.z * -0.8f, 1.5f, camToPlayer.x * 0.8f);
``````

}
[/java]

Spatial is the player character, the returned vector gets passed to ChaseCamera.setLookAtOffset() inside the update() method:

[java]
protected void controlUpdate(float tpf) {

``````	//set the correct lookAtOffset for the chase camera, needs to be updated every frame
chaseCam.setLookAtOffset(calculateCamOffset());
``````

}
[/java]

I have prepared test applications for Windows, Mac and Linux that can be found here:<br><br>

ThirdPersonShoulderCam - Windows<br><br>

ThirdPersonShoulderCam - Mac<br><br>

ThirdPersonShoulderCam - Linux<br><br>

The player character can turn around freely, walking to the left, right and facing the camera while the offset is kept. When walking, the player follows the camera’s movement. When standing still, the camera revolves around the player character. The offset is kept consistent during all this.<br><br>

Controls are:<br><br>

Arrow Keys - character movement<br>
Mouse - camera movement<br>
R - toggle running on/off<br><br><br>

And for those that are interested, I’ll post the two complete classes that make up the code of the whole test app, mainly because I can’t seem to figure out how to upload attachments in this thing. You’ll see that the important parts are extensively commented. Here goes:<br><br>

App.java
[java]
package game;

import com.jme3.app.SimpleApplication;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.renderer.RenderManager;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;

public class App extends SimpleApplication {

``````BulletAppState bullet = new BulletAppState();

public static void main(String[] args) {
App app = new App();
app.start();
}

@Override
public void simpleInitApp() {

//get some physics into the game
stateManager.attach(bullet);

//create ground to walk upon
setScene();

//load the player character and make it visible in-game
//Node player = createPlayerCharacter();
createPlayerCharacter();

//add some light to the scene
setupLighting();
}

@Override
public void simpleUpdate(float tpf) {
}

@Override
public void simpleRender(RenderManager rm) {
}

private void setScene() {
viewPort.setBackgroundColor(new ColorRGBA(.53f, .86f, .98f, 1.0f));

rootNode.attachChild(arena);
}

private void createPlayerCharacter() {
Node node = new Node();
PlayerMovementControl moveControl = new PlayerMovementControl(this);

rootNode.attachChild(node);
}

private void setupLighting() {
AmbientLight al = new AmbientLight();
al.setColor(ColorRGBA.White);

DirectionalLight dl = new DirectionalLight();
dl.setDirection(new Vector3f(.5f, -1.0f, .5f));
dl.setColor(ColorRGBA.White);

dlsr.setLight(dl);
dlsr.setEdgeFilteringMode(EdgeFilteringMode.PCFPOISSON);
dlsr.setEdgesThickness(2);

}
``````

}
[/java]
<br><br>
PlayerMovementControl.java
[java]
package game;

import com.jme3.bullet.control.BetterCharacterControl;
import com.jme3.input.ChaseCamera;
import com.jme3.input.InputManager;
import com.jme3.input.KeyInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Spatial;
import com.jme3.scene.control.AbstractControl;
import java.util.HashMap;
import java.util.Map.Entry;

public class PlayerMovementControl extends AbstractControl implements ActionListener {

``````private BetterCharacterControl characterControl;
private InputManager inputManager;
private ChaseCamera chaseCam;
private App game;
private Camera cam;

private int movementState = 0;

private float
movementSpeed = 2.0f,
characterTurnAmount = 0.0f,
characterTurnSpeed = 0.069813f;

private boolean
forwardBackward = false,
leftRight = false,
running = false;

private Vector2f camDir, charDir;

private Vector3f
walkDirection = new Vector3f(0, 0, 0),
offsetVector = new Vector3f(0f, 1.5f, 0);

private Quaternion rotateBody = new Quaternion();

private static final HashMap&lt;String, KeyTrigger&gt; actions = new HashMap&lt;&gt;();
static {
actions.put("forward", new KeyTrigger(KeyInput.KEY_UP));
actions.put("backward", new KeyTrigger(KeyInput.KEY_DOWN));
actions.put("stepLeft", new KeyTrigger(KeyInput.KEY_LEFT));
actions.put("stepRight", new KeyTrigger(KeyInput.KEY_RIGHT));
actions.put("runToggle", new KeyTrigger(KeyInput.KEY_R));
}

public PlayerMovementControl(App game) {
this.game = game;
this.inputManager = game.getInputManager();
this.cam = game.getCamera();
cam.setFrustumPerspective(25, (float) cam.getWidth() / (float) cam.getHeight(), 0.01f, 1000.0f);
}

@Override
public void setSpatial(Spatial spatial) {
super.setSpatial(spatial);

//make spatial a movable player character
characterControl = new BetterCharacterControl(0.25f, 1.8f, 80f);
characterControl.setGravity(new Vector3f(0, -9.8f, 0));

//initialize key mappings for character movement
for (Entry e: actions.entrySet()) {
}

//initialize third person camera
setChaseCam();
}

@Override
protected void controlUpdate(float tpf) {
Vector3f forwardDir = spatial.getWorldRotation().mult(Vector3f.UNIT_Z);

if (forwardBackward || leftRight) {

setViewDirection(movementState, tpf);

if (running) {
} else {
}
} else {
camDir = new Vector2f(cam.getDirection().x, cam.getDirection().z);
charDir = new Vector2f(characterControl.getViewDirection().x, characterControl.getViewDirection().z);
characterTurnAmount = charDir.angleBetween(camDir);
}

characterControl.setWalkDirection(walkDirection);
walkDirection.set(0, 0, 0);

//set the correct lookAtOffset for the chase camera, needs to be updated every frame
chaseCam.setLookAtOffset(calculateCamOffset());
}

@Override
protected void controlRender(RenderManager rm, ViewPort vp) {
}

@Override
public void onAction(String name, boolean isPressed, float tpf) {

if (name.equals("forward")) {
forwardBackward = isPressed;
movementState = forwardBackward ? movementState + 1 : movementState - 1;
} else if (name.equals("backward")) {
forwardBackward = isPressed;
movementState = forwardBackward ? movementState + 2 : movementState - 2;
}
if (name.equals("stepLeft")) {
leftRight = isPressed;
movementState = leftRight ? movementState + 4 : movementState - 4;
} else if (name.equals("stepRight")) {
leftRight = isPressed;
movementState = leftRight ? movementState + 8 : movementState - 8;
}
if (name.equals("runToggle") &amp;&amp; isPressed) {
running = !running;
}
}

/**
*
* @param direction an integer, abused as a silly implementation of a bitfield
* @param tpf turning speed depends on frames per second. Otherwise, our player
* character would be either a snail or The Flash.
*/
private void setViewDirection(int direction, float tpf) {
characterTurnSpeed = 4.18878f * tpf;
switch (direction) {
/*
* the conditionals within every case could be easily outsourced
* into one method. This would certainly save space. I'd rather
* have them here because I don't want to jump around in the code
* so much when looking over or modifying it.
*
* The case values might seem random. Every case represents one of
* 8 directions the player character can walk in, starting with
* forward and followed by forward-right, right, backward-right etc.
*
* Also, there are a lot of floats floating around. these are all
* fractions or multiples of pi. Beacuse all of them are constant
* values, I decided to use the numbers instead of 'FastMath.PI' and
* 'characterTurnAmount += 2.0f*FastMath.PI'. Especially the latter takes
* more time to compute, and being this is setViewDirection, one of
* the most called function in the game, I feel very lucky.
*
* On a serious note though, this will probably look nicer with lerping.
* Haven't looked intothat yet.
*
*/
case 1:
if (characterTurnAmount &gt; 0.04f &amp;&amp; characterTurnAmount &lt;= 3.14f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -0.04f &amp;&amp; characterTurnAmount &gt;= -3.14f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 3.14f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -3.14f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
case 9:
if (characterTurnAmount &gt; -0.74f &amp;&amp; characterTurnAmount &lt;= 2.35f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -0.82f &amp;&amp; characterTurnAmount &gt;= -3.92f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 2.35f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -3.92f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
case 8:
if (characterTurnAmount &gt; -1.53f &amp;&amp; characterTurnAmount &lt;= 1.57f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -1.61f &amp;&amp; characterTurnAmount &gt;= -4.71f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 1.57f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -4.71f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
case 10:
if (characterTurnAmount &gt; -2.31f &amp;&amp; characterTurnAmount &lt;= 0.78f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -2.39f &amp;&amp; characterTurnAmount &gt;= -5.50f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 0.78f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -5.50f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
case 2:
if (characterTurnAmount &lt; 3.10f &amp;&amp; characterTurnAmount &gt;= 0.0f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; -3.10f &amp;&amp; characterTurnAmount &lt; 0.0f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 3.18f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -3.18f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
case 6:
if (characterTurnAmount &lt; 2.31f &amp;&amp; characterTurnAmount &gt;= -0.78f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 2.39f &amp;&amp; characterTurnAmount &lt;= 5.50f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -0.78f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 5.50f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
case 4:
if (characterTurnAmount &gt; 1.61f &amp;&amp; characterTurnAmount &lt;= 4.71f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; 1.53f &amp;&amp; characterTurnAmount &gt;= -1.57f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -1.57f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 4.71f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
case 5:
if (characterTurnAmount &gt; 0.82f &amp;&amp; characterTurnAmount &lt;= 3.92f) {
characterTurnAmount -= characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; 0.74f &amp;&amp; characterTurnAmount &gt;= -2.35f) {
characterTurnAmount += characterTurnSpeed;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &lt; -2.35f) {
characterTurnAmount += 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
} else if (characterTurnAmount &gt; 3.92f) {
characterTurnAmount -= 6.28f;
rotateBody.fromAngleAxis(characterTurnAmount, Vector3f.UNIT_Y);
}
break;
default:
break;
}
characterControl.setViewDirection(rotateBody.mult(cam.getDirection()));
}

/**
* Basic initial setup of the third person cam. Gets called inside this class'
* setSpatial() method.
*/
private void setChaseCam() {
chaseCam = new ChaseCamera(cam, spatial, game.getInputManager());
chaseCam.setSmoothMotion(false);
chaseCam.setDragToRotate(false);
chaseCam.setDefaultDistance(5.0f);
chaseCam.setMinDistance(4.0f);
}

/**
* This is where the magic happens. This method computes the coordinates for
* the camera to look at in order to achieve the over-the-shoulder perspective.
* It gets called inside the controlUpdate() method. Look inside the function
* for an in-depth explanation of what needs to be done in order for this to work.
*
* @return this frame's correct lookAtOffset, to be passed to ChaseCamera's
* setLookAtOffset() method.
*/
private Vector3f calculateCamOffset() {
/*
* The math is fairly straightforward. First, we need the vector that
* points from the camera to the player. We can't use cam.getDirection(),
* because the cam doesn't point to the player (which, after all, is the
* point of all this offset business). So, we compute it: vec A is the
* cam's position, vec B is the player character's position and vec V
* (for view) is the vector between them, starting at the cam. Which means:
* V = B - A.
*
* This is what we do right here, but before subtracting the cam's
* position, we are adding the constant offset value (the height, which
* doesn't change by moving around) of 1.5 metres in the up-direction to
* the player's position. This is about shoulder height.
*
* After subtracting, we have the vector we want. It points from the cam
* to the player character's neck. Next, we normalize the vector. This
* is important for the next step of computing the offset for the
* to-the-right-over-the-shoulder part.
*
* After all these steps we get the beast that is the vector below. On
* the plus side, this vector can also be used for detecting whether
* an object blocks the view between cam and player character, if one
* chooses to use rays for this. This is not implemented here, but it
* works nonetheless.
*/
Vector3f camToPlayer = spatial
.getWorldTranslation()
.subtract(cam.getLocation())
.normalize();

/*
* The vector we return here is the correct offset vector. we take the
* constant 1.5 metres for the height, as discussed above. For the x- and
* y-value we assume the following: The vector points to the right of the
* player character, at a certain angle and for a certain distance. The
* angle is perpendicular to the vector that goes from cam to player.
* Or, more precise, it is perpendicular to that vector, projected onto
* the x/z-plane. If we look down from above:
*
*		cam --------------------------------------------&gt; player
*			                          perpendicular --&gt; |
*			                                            |
*                                                      |
*                                                      |
*                                                      |
*                                                      --&gt; view offset
*
* All we have to do then, is split the vector that goes from cam to player
* into its x- and z-component and (because of the perpendicularity)
* apply those values to the offset vector's z- and x-value, respectively.
*
* For the vector below, the x- and z-values are multiplied with 0.8f.
* That is the distance the cam will look to the side, in metres. It is
* chosen arbitrarily and can be set to whatever the developer likes.
* The offset vector's x-value is computed using '-0.8'. That's just because
* the player character's local coordinates are set to +z as front, so
* 'right' would be -x, left would be +x. Another useful side effect of
* this is that we can easily switch between the two shoulder views by
* passing the minus to either the x- or the z-coordinate.
*
*/
return new Vector3f(camToPlayer.z * -0.8f, 1.5f, camToPlayer.x * 0.8f);
}
``````

}
[/java]

Feel free to comment and criticize. If people are interested, maybe we can mash this into ChaseCamera for future use.

[EDIT:]
Nevermind the little attempt at drawing in the comments above. The forum software keeps eliminating whitespace characters. I hope it is vaguely clear what I mean, though.

2 Likes
@RamesJandi said: One common method for solving this problem is creating an additional node in the player character's .j3o file (or wherever) that's a child of the player character and thus moves with it. This node is then set to the desired over-the-shoulder position, where it will be the chaseCam's target. However, this method has only limited applicability. Above all, it requires the camera to always be straight behind the player character, facing its back. As soon an the player moves independently from the camera, or the other way around, the offset is compromised.

This part I don’t understand. If the ‘offset’ comes from the node that moves (and rotates) with the player then how is the ‘offset compromised’. It seems like this would do exactly what you want and allow you to position the camera relative to the player in whatever offset or orientation that you like.

1 Like
@pspeed said: If the 'offset' comes from the node that moves (and rotates) with the player then how is the 'offset compromised'.

Okay, let’s assume you start out in your scene with the player character in front of the camera, which is directly behind the playercharacter. The offset node is positioned 1.5m up and 1m to the right. Right now all is well.

Remember I said that this method only works if the camera always stays right behind the player. But in many third person games the player figure can turn to the left, right and backwards without the camera following the rotation. The cam still follows the player’s position, but not its rotation around the y-axis. Think Mass Effect, Hitman, Uncharted or GTA.

What happens then is that the offset node revolves with the player character. The node doesn’t revolve around itself, but - being a child of the player character - around the center of the player character’s model.

If the player figure then turns about 45 degrees while the cam doesn’t, the cam won’t look at 1 m to the model’s right side anymore, it will look at about 0.7 m to the right. The offset node is still at 1 m to the right of the player, but the camera doesn’t look at the player from directly behind anymore.

If the player character keeps turning this way, at 90 degrees the camera looks straight at the player, because now camera, player and offset node all make a perfectly straight line with cam and offset node on both ends and the player between them.

I hope this makes the issues with this kind of setup more clear.

Either you don’t really want a chase cam and you actually want a “keep the camera at this offset from the player”… or you can occasionally (when appropriate) move the target node when the player rotates. The math is pretty trivial… could even add an extra node in the hierarchy and avoid the math altogether.

Anyway, there are about a 1000 other reasons to write a custom camera for a game… so no worries. Everyone ends up there eventually.

1 Like