Dynamic 2D Camera

A simple, extensible camera for 2D games

In this tutorial, we will explore a simple way to make a simple 2D camera that follows a single target. We do this by trying to keep the player within a box drawn in pixel coordinates and try to move the camera as soon as the player leaves the box.

We will also make it so that the relevant parts of the level are shown based on player intention. This is the extensible part of this project that will put the onus on its user to decide how to detect that intention.

This tutorialis divided into 4 sections:

  • Debug Lines
  • Camera Movement
  • Intention Based Anchoring
  • Extensibility

Debug Lines

First, we define a struct in a file called CameraLimits. In it, is an appropriately named struct that we will use to pack 4 values that describe the box in which the player is going to be contained. These values range from 0 to 1 and express a percentage of the screen pixel width/height.


public struct CameraLimits {
public float leftLimit;
public float rightLimit;
public float upLimit;
public float downLimit;

public CameraLimits(float l, float r, float u, float d) {
this.leftLimit = l;
this.rightLimit = r;
this.upLimit = u;
this.downLimit = d;
}

// copy
public CameraLimits(CameraLimits c) {
this.leftLimit = c.leftLimit;
this.rightLimit = c.rightLimit;
this.upLimit = c.upLimit;
this.downLimit = c.downLimit;
}

}

Next, to be able to track our progress, we want to draw debug lines so that we can assess how our attempts to work with the camera code are coming along. We attach a new component to the main camera called DebugLines. In it we describe a function called CameraDebugLines() which will receive the current state of the lines described with the CameraLimits struct and draw them.


private void OnPostRender() {
CameraDebugLines();
}

void CameraDebugLines() {

if (!showCameraLines) return;

CameraLimits cL = cc.GetCurrentCameraLimits();

GL.PushMatrix();
GL.LoadPixelMatrix();

mat.SetPass(0);

GL.Begin(GL.LINES);
// vertical lines to depict horizontal limits
//left
GL.Color(Color.red);
GL.Vertex3(this.cam.pixelWidth * cL.leftLimit, 0f, 0f);
GL.Vertex3(this.cam.pixelWidth * cL.leftLimit, this.cam.pixelHeight, 0f);

//right
GL.Color(Color.green);
GL.Vertex3(this.cam.pixelWidth * cL.rightLimit, 0f, 0f);
GL.Vertex3(this.cam.pixelWidth * cL.rightLimit, this.cam.pixelHeight, 0f);

// horizontal lines to depict vertical limits
// up
GL.Color(Color.green);
GL.Vertex3(0f, this.cam.pixelHeight * cL.upLimit, 0f);
GL.Vertex3(this.cam.pixelWidth, this.cam.pixelHeight * cL.upLimit, 0f);

//down
GL.Color(Color.red);
GL.Vertex3(0f, this.cam.pixelHeight * cL.downLimit, 0f);
GL.Vertex3(this.cam.pixelWidth, this.cam.pixelHeight * cL.downLimit, 0f);

GL.End();
GL.PopMatrix();
}

In the code above, we make the CameraDebugLines() function call in a function called onPostRender() which is a Unity lifecycle function which is called after the Camera finishes rendering a scene.

Camera Movement

With debugging out of the way, we move on to adding another component/script to the main camera called CameraControl. In it, we define 4 variables that can be set in Unity Editor to customize the box. We also define a speed variable which is also between 0 and 1.


public class CameraControl : MonoBehaviour {

/** SETTINGS */
[Range(0.1f, 1f)]
public float leftLimit;
[Range(0.1f, 1f)]
public float rightLimit;
[Range(0.1f, 1f)]
public float upLimit;
[Range(0.1f, 1)]
public float downLimit;
[Range(0f, 1f)]
public float speed;

/** STATE */
private CameraLimits current; // used to hold the current position of the debug lines
private Transform tracking; // the transform of the entity being tracked

...

In the Start() function of the CameraControl script, we initialize current which is used to hold the current state of the camera limits as they are moved around (more on that later).


void Start() {
this.current = new CameraLimits(this.leftLimit, this.rightLimit, this.upLimit, this.downLimit);
}

We then continue to define a function called horizontalCameraMovement, which uses the pixelWidth of the current camera to calculate the left and right limits of the box in pixels. If the player is either past the right or left limit of the box, we calculate how far off the player is beyond these limits. We can then use this value to Lerp between the current position of the camera to the new position of the camera:

The new position of the camera is calculated by converting the current position of the camera to pixel coordinates and adding the difference (which is also in pixel coordinates).

The current world position of the camera is updated by converting the new position back to world coorinates and lerping between the current position and the new position.


private void horizontalCameraMovement() {

Camera cam = Camera.main;

float minLine = this.current.leftLimit * cam.pixelWidth;
float maxLine = this.current.rightLimit * cam.pixelWidth;

Vector3 screenPos = cam.WorldToScreenPoint(this.tracking.position);

float diff = 0;
if (screenPos.x > maxLine) {
diff = screenPos.x - maxLine;
} else if (screenPos.x < minLine) {
diff = screenPos.x - minLine;
} else return;

Vector3 newPosition = cam.WorldToScreenPoint(this.transform.position) + new Vector3(diff, 0f, 0f);
this.transform.position = Vector3.Lerp(this.transform.position, cam.ScreenToWorldPoint(newPosition), this.speed);
}

We also define a vertical version of the above code, with the relevant changes to do with pixelHeight and making sure that the updates are to the y-coordinate of the current position:


private void verticalCameraMovement() {

Camera cam = Camera.main;

float minLine = this.current.downLimit * cam.pixelHeight;
float maxLine = this.current.upLimit * cam.pixelHeight;

Vector3 screenPos = cam.WorldToScreenPoint(this.tracking.position);

float diff = 0;
if (screenPos.y > maxLine) {
diff = screenPos.y - maxLine;
} else if (screenPos.y < minLine) {
diff = screenPos.y - minLine;
} else return;

Vector3 newPosition = cam.WorldToScreenPoint(this.transform.position) + new Vector3(0f, diff, 0f);
this.transform.position = Vector3.Lerp(this.transform.position, cam.ScreenToWorldPoint(newPosition), this.speed);
}

We call our new functions in FixedUpdate to see its effects


private void FixedUpdate() {

verticalCameraMovement();
horizontalCameraMovement();

}

Intention Based Anchoring

We now have simple camera movement which depends on the player's position although we notice that this style of camera is only good enough when the player's objective is to move from left-to-right and bottom-to-top: The initialization of the box at the bottom left means that the rest of the 3 quadrants of the screen are devoted to what the player is supposed to be focusing on in terms of their environment, and that is static.

We can add simple code that would (optionally) allow the camera to dynamically move the player-limiting box to any of the 4 quadrants to shift the focus the environment to the remaining 3.

If the player starts to move right-to-left, we would like to move our box to the right side of the screen so that the rest of the camera focuses on the left. If the player starts to move top-to-bottom, we would like to move the box to the top side of screen so that the rest of the camera focuses on the bottom.


public class CameraControl : MonoBehaviour {

/** SETTINGS */
[Range(0.1f, 1f)]
public float leftLimit;
[Range(0.1f, 1f)]
public float rightLimit;
[Range(0.1f, 1f)]
public float upLimit;
[Range(0.1f, 1)]
public float downLimit;
[Range(0f, 1f)]
public float speed;
[Range(0f, 1f)]

public float switchSpeed;


/** STATE */
private CameraLimits current; // used to hold the current position of the debug lines
private bool switchedH = false;

private bool switchedV = false;


...

We add 3 new variables: switchedH tracks whether the horizontal-anchor of the box has been switched, switchedV tracks whether the vertical-anchor of the box has been switched. The 3rd variable is an adjustable parameter which determines the speed at which the camera position will swing from the bottom-left quadrant to the others.

Next, we add a new function that calculates camera limits depending on the combination of states of switchedH and switchedV.


public CameraLimits GetAnchoredLimits() {
float left = this.switchedH ? 1 - this.rightLimit : this.leftLimit;
float right = this.switchedH ? 1 - this.leftLimit : this.rightLimit;

float down = this.switchedV ? 1 - this.upLimit : this.downLimit;
float up = this.switchedV ? 1 - this.downLimit : this.upLimit;

return new CameraLimits(left, right, up, down);
}

If horizontal anchor has moved, we move the left limit to the opposite of the screen; but we need to keep in mind that the left limit is being treated like the lower limit. This means that we move the left limit to where the right limit should be on the other side. The same is true for the right limit: it is the upper limit of the horizontal axis of the box, hence we move it to where the left limit should be on the other side. In other words, when we switch the horizontal anchor, we mirror the left and right limits.

We apply the same logic to the upper and lower limits of the box depending on whether the vertical anchor has been moved.

Next, we write code to constantly check whether the player's intention is to look the other way and switch anchors.


private void SwitchHorizontalAnchor() {

Camera cam = Camera.main;
CameraLimits cc = this.GetAnchoredLimits();

float left = (cc.leftLimit) * cam.pixelWidth;
float right = (cc.rightLimit) * cam.pixelWidth;

// up-to the user, these details don't matter
Vector3 playerScreenPos = Camera.main.WorldToScreenPoint(player.transform.position);

// when the player is facing left and is moving at 1/2 its full speed, switch the anchor
if (player.facing < 0 && Mathf.Abs(player.rb.velocity.x) >= (player.playerMovement.maxHorizontalSpeed/2f)) {
switchedH = true;
}

// when the player is facing right and is moving at 1/2 of its full speed, switch it back
if (player.facing > 0 && Mathf.Abs(player.rb.velocity.x) >= player.playerMovement.maxHorizontalSpeed/2f) {
switchedH = false;
}
}

private void SwitchVerticalAnchor() {

Camera cam = Camera.main;
CameraLimits cc = this.GetAnchoredLimits();

float up = (cc.upLimit) * cam.pixelHeight;
float down = (cc.downLimit) * cam.pixelHeight;

// up-to the user, these details don't matter
float mid = (up + down)/2f;

Vector3 playerScreenPos = Camera.main.WorldToScreenPoint(player.transform.position);

// when the player is below the mid-point of the box and is moving at full speed, switch the anchor
if (playerScreenPos.y < mid && Mathf.Abs(player.rb.velocity.y) >= player.playerMovement.maxVerticalSpeed) { // on the way down, want to see down
switchedV = true;
}

// when the player is above the mid-point of hte box and is moving at 1/4 of its full speed, switch it back
if (playerScreenPos.y > mid && Mathf.Abs(player.rb.velocity.y) >= player.playerMovement.maxVerticalSpeed/4f) { // on the way up, want to see up
switchedV = false;
};
}

In the code above, we have added player-specific code that checks whether the player is intending to move the other way, and update the switchedH and switchedV variables. This is a problem: we have introduced code relating to the player in the camera script, meaning that if we wanted to use a similar script in another project or on another object, we would have to change the camera code. This will be addressed in the final section of this post, but for now we move on.

In this section, so far we have done 2 things: we have written code to check when the anchors should be switched and we have made calculations to find out where the limits should be when the anchors are switched. But we haven't really switched them yet since the camera movement code relies on the current limits, which remain unchanged. We address that now.


private void MoveCurrentLimits() {

CameraLimits cc = this.GetAnchoredLimits();

this.current.leftLimit = Mathf.Lerp(this.current.leftLimit, cc.leftLimit, this.switchSpeed);
this.current.rightLimit = Mathf.Lerp(this.current.rightLimit, cc.rightLimit, this.switchSpeed);
this.current.downLimit = Mathf.Lerp(this.current.downLimit, cc.downLimit, this.switchSpeed);
this.current.upLimit = Mathf.Lerp(this.current.upLimit, cc.upLimit, this.switchSpeed);

}

We add all these functions in FixedUpdate and look at the result:


private void FixedUpdate() {

verticalCameraMovement();
horizontalCameraMovement();

SwitchHorizontalAnchor();

SwitchVerticalAnchor();


MoveCurrentLimits();

}

With Debug Lines

No Lines, No Stress!

Extensibility

Now that everything is in a working state, we would like to pull all the player specific code out of the camera code. For this, we can use C# Delegates. We define a function signature (delegate), AnchorSwitch, which receives a min-value, a max-value, and a ref to a boolean. We also define 2 new members of the CameraControl class that are of this type. These functions need to be initialized by the client that will be using the CameraControl class, which in our case is the Player class.


public class CameraControl : MonoBehaviour {

/** SETTINGS */
[Range(0.1f, 1f)]
public float leftLimit;
[Range(0.1f, 1f)]
public float rightLimit;
[Range(0.1f, 1f)]
public float upLimit;
[Range(0.1f, 1)]
public float downLimit;
[Range(0f, 1f)]
public float speed;
[Range(0f, 1f)]
public float switchSpeed;

/** STATE */
private CameraLimits current; // used to hold the current position of the debug lines
private bool switchedH = false;
private bool switchedV = false;

/** DELEGATES */

public delegate void AnchorSwitcher(float min, float max, ref bool switchedHV);

public AnchorSwitcher horizontalAnchorSwitcher;

public AnchorSwitcher verticalAnchorSwitcher;

...

We define a public Init function that can be called by the player class to initialize the tracking transform and the function parameters.


public void Init(Transform tracking, AnchorSwitcher hSwitcher, AnchorSwitcher vSwitcher) {
this.tracking = tracking;
this.horizontalAnchorSwitcher = hSwitcher;
this.verticalAnchorSwitcher = vSwitcher;
// init variable to make sure that everything was initialized
this.init = this.tracking != null && this.horizontalAnchorSwitcher != null && verticalAnchorSwitcher != null;
}

Before we initialize these in the Player class, let's look at where these functions fit in our CameraControl class:


private void SwitchHorizontalAnchor() {

Camera cam = Camera.main;
CameraLimits cc = this.GetAnchoredLimits();

float left = (cc.leftLimit) * cam.pixelWidth;
float right = (cc.rightLimit) * cam.pixelWidth;

// up-to the user, these details don't matter

Vector3 playerScreenPos = Camera.main.WorldToScreenPoint(player.transform.position);


// when the player is facing left and is moving at 1/2 its full speed, switch the anchor

if (player.facing < 0 && Mathf.Abs(player.rb.velocity.x) >= (player.playerMovement.maxHorizontalSpeed/2f)) {

switchedH = true;

}


// when the player is facing right and is moving at 1/2 of its full speed, switch it back

if (player.facing > 0 && Mathf.Abs(player.rb.velocity.x) >= player.playerMovement.maxHorizontalSpeed/2f) {

switchedH = false;

}


this.horizontalAnchorSwitcher(left, right, ref this.switchedH);

}

private void SwitchVerticalAnchor() {

Camera cam = Camera.main;
CameraLimits cc = this.GetAnchoredLimits();

float down = (cc.downLimit) * cam.pixelHeight;
float up = (cc.upLimit) * cam.pixelHeight;

// up-to the user, these details don't matter

float mid = (up + down)/2f;


Vector3 playerScreenPos = Camera.main.WorldToScreenPoint(player.transform.position);


// when the player is below the mid-point of the box and is moving at full speed, switch the anchor

if (playerScreenPos.y < mid && Mathf.Abs(player.rb.velocity.y) >= player.playerMovement.maxVerticalSpeed) { // on the way down, want to see down

switchedV = true;

}


// when the player is above the mid-point of hte box and is moving at 1/4 of its full speed, switch it back

if (playerScreenPos.y > mid && Mathf.Abs(player.rb.velocity.y) >= player.playerMovement.maxVerticalSpeed/4f) { // on the way up, want to see up

switchedV = false;

};


this.verticalAnchorSwitcher(up, down, ref this.switchedV);

}

By doing this, we have switched the responsibility from the CameraControl class to the Player class which has to decide whether the anchors should be switched. We have done this by passing a reference to the switchedH and switchedV variables to the appropriate functions.

Finally, we initialize the function of delegate type AnchorSwitcher and pass it on to the CameraControl instance.

Somewhere in the Player class:


void Start () {
...

CameraControl.instance.Init(this.transform, this.HorizontalAnchorSwitcher, this.VerticalAnchorSwitcher);

...
}

// Passed as delegate to camera control (follows AnchorSwitch delegate type)
public void HorizontalAnchorSwitcher(float left, float right, ref bool switchedH) {
Vector3 playerScreenPos = Camera.main.WorldToScreenPoint(this.transform.position);

if (this.facing < 0 && Mathf.Abs(this.rb.velocity.x) >= (this.playerMovement.maxHorizontalSpeed/2f)) {
switchedH = true;
}

if (this.facing > 0 && Mathf.Abs(this.rb.velocity.x) >= this.playerMovement.maxHorizontalSpeed/2f) {
switchedH = false;
}
}

// Passed as delegate to camera control (follows AnchorSwitch delegate type)
public void VerticalAnchorSwitcher(float up, float down, ref bool switchedV) {
float mid = (up + down)/2f;

Vector3 playerScreenPos = Camera.main.WorldToScreenPoint(this.transform.position);

if (playerScreenPos.y < mid && Mathf.Abs(this.rb.velocity.y) >= this.playerMovement.maxVerticalSpeed) { // on the way down, want to see down
switchedV = true;
}

if (playerScreenPos.y > mid && Mathf.Abs(this.rb.velocity.y) >= this.playerMovement.maxVerticalSpeed/4f) { // on the way up, want to see up
switchedV = false;
}
}

The full source and sample scene can be found in this repository. Thanks for tuning in, until next time!