Austin Morlan


Code Contact LinkedIn



Neverending 2D Side-scrolling Background in Unity

Introduction


My team and I are in the early stages of developing a 2D side-scroller set inside of a Chinese handscroll, and so we wanted to ensure the game’s art looked like ink set on top of a paper background. Unlike a paper camera filter where the paper would appear to be attached to the camera and in the same location relative to the camera regardless of the camera’s position, we wanted the paper to actually be in the background with parallax. In other words, as the camera moves in one direction, the paper moves in the other direction, creating the feeling of viewing a handscroll flat on a table and moving your eyes along its surface.

To achieve this, the paper background needs to be high res for the actual (physical) texture of the paper to come through visually. Rather than duplicating the texture repeatedly throughout the entire level, which would be tedious and could have performance implications, we developed a system to spawn the background on startup and then translate them according to the camera location. With this setup, from the camera’s perspective, there is an infinite sheet of high res paper in the background, but in reality the same sheets are just moving.

The goal was to build scripts that require little configuration and are very simple. So simple in fact that maybe they don’t even warrant a blog post.


In this post I talk about paper textures, paper panels, etc, but everything can be applied to any sort of background that you would want to have dynamically move along with the paper (with minimal changes).

Elements


There are four elements in the scene that work together to achieve the effect:

Camera


Main Camera

The camera has a Box Collider 2D attached so that the BackgroundTranslator script can be notified when the camera is leaving its trigger zone. We chose not to use the player’s collider in case of situations where the camera proceeds beyond the player for a cinematic.

Paper Panel Prefab


Paper Panel Prefab

The Paper Panel prefab is a stack of the desired background texture, so that it is one texture width wide and three texture heights tall. Since our game is a side-scroller where the movement is primarily right-to-left, this prefab works fine because it’s tall enough so that the camera will never go above the top-most or below the bottom-most.

There is a Box Collider 2D in the middle with dimensions 200x300, which is sufficient to cover the majority of the prefab’s width and extend a bit beyond above and below as well. It’s a trigger because we don’t actually want it to collide with anything.

There is also a Rigid Body 2D which is required for the collision events, but it’s set to Kinematic so it isn’t affected by gravity.

Lastly there is the BackgroundTranslator script attached which contains the logic to actually translate the panel according to the camera location. We’ll look at that in a bit.

BackgroundSpawner Script


Paper

There is an empty GameObject that has a BackgroundSpawner script attached to it which is responsible for instantiating five of the paper panels on start and setting their initial positions. As mentioned in the introduction, we wanted as little configuration and setup as possible, so it’s nice having everything spawn and move into position automatically on start.

Each panel’s width is the same as the width of the texture itself, which is retrieved on start so that it can be passed on to the BackgroundTranslator script when it’s spawned.

Then each of the five panels are instantiated (Far Left, Left, Middle, Right, Far Right) in their proper positions relative to the camera’s location, their BackgroundPosition field is set, and the background width is passed on to each object so they can use it in the future when moving themselves.

using UnityEngine;

public class BackgroundSpawner : MonoBehaviour
{
#pragma warning disable 0649
    [SerializeField]
    private GameObject backgroundPrefab;
#pragma warning restore 0649

    private void Start()
    {
        // Width is equal to the width of the prefab's children's texture
        float backgroundWidth = backgroundPrefab.transform.GetChild(0).gameObject
            .GetComponent<Renderer>().bounds.size.x;

        // Spawn far left panel
        GameObject panel01 = Instantiate(backgroundPrefab, transform);
        panel01.transform.position =
            new Vector3(Camera.main.transform.position.x - (2 * backgroundWidth), 0.0f, 0.0f);

        BackgroundTranslator panel01Translator = panel01.GetComponent<BackgroundTranslator>();
        panel01Translator.SetBackgroundPosition(BackgroundPosition.FarLeft);
        panel01Translator.SetBackgroundWidth(backgroundWidth);

        // Spawn left panel
        GameObject panel02 = Instantiate(backgroundPrefab, transform);
        panel02.transform.position =
            new Vector3(Camera.main.transform.position.x - backgroundWidth, 0.0f, 0.0f);

        BackgroundTranslator panel02Translator = panel02.GetComponent<BackgroundTranslator>();
        panel02Translator.SetBackgroundPosition(BackgroundPosition.Left);
        panel02Translator.SetBackgroundWidth(backgroundWidth);

        // Spawn middle panel
        GameObject panel03 = Instantiate(backgroundPrefab, transform);
        panel03.transform.position =
            new Vector3(Camera.main.transform.position.x, 0.0f, 0.0f);

        BackgroundTranslator panel03Translator = panel03.GetComponent<BackgroundTranslator>();
        panel03Translator.SetBackgroundPosition(BackgroundPosition.Middle);
        panel03Translator.SetBackgroundWidth(backgroundWidth);

        // Spawn right panel
        GameObject panel04 = Instantiate(backgroundPrefab, transform);
        panel04.transform.position =
            new Vector3(Camera.main.transform.position.x + backgroundWidth, 0.0f, 0.0f);

        BackgroundTranslator panel04Translator = panel04.GetComponent<BackgroundTranslator>();
        panel04Translator.SetBackgroundPosition(BackgroundPosition.Right);
        panel04Translator.SetBackgroundWidth(backgroundWidth);

        // Spawn far right panel
        GameObject panel05 = Instantiate(backgroundPrefab, transform);
        panel05.transform.position =
            new Vector3(Camera.main.transform.position.x + (2 * backgroundWidth), 0.0f, 0.0f);

        BackgroundTranslator panel05Translator = panel05.GetComponent<BackgroundTranslator>();
        panel05Translator.SetBackgroundPosition(BackgroundPosition.FarRight);
        panel05Translator.SetBackgroundWidth(backgroundWidth);
    }
}

BackgroundTranslator Script


The meat of it all. There are five panels and each can be in one of five positions: Far Left, Left, Middle, Right, Far Right.

The goal is to keep the camera looking at the middle panel so that there is always a buffer zone of two panels on either side to ensure that the camera always has some paper in its field of view (the camera might change size based on Cinemachine functions).

When an OnTriggerExit event occurs and the colliding object is MainCamera, then it checks to see if any translation is needed.

There are only four scenarios we care about:

1) The camera is in the Far Left panel and going left. In this case, we are two panels away from the middle and should move the Right panel and the Far Right panel to the left of the Far Left panel so that we become the Middle panel again:

Scenario 01

2) The camera is in the Left panel and going left. In this case, we are one panel away from the middle and should move the Far Right panel to the left of the Far Left panel so that we become the Middle panel again:

Scenario 02

3) The camera is in the Right panel and going right. In this case, we are one panel away from the middle and should move the Far Left panel to the right of the Far Right panel so that we become the Middle panel again:

Scenario 03

4) The camera is in the Far Right panel and going right. In this case, we are two panels away from the middle and should move the Left panel and the Far Left panel to the right of the Far Right panel so that we become the Middle panel again:

Scenario 04

In all other cases (e.g., in the Right panel going left, or the Far Left panel going right, or already in the Middle panel), we don’t need to do anything because we’re heading in the direction of the Middle panel, which is our goal.

We either translate once or twice the width of the paper texture, depending on the location.

After translating, we finally need to update our position (which should always be Middle) and the positions of all of the other panels, so that they’re accurate the next time.

All of this effort takes place only when the OnTriggerExit2D event fires, and only when the colliding body is MainCamera, so it doesn’t happen very frequently.


The script assumes that it's tracking the Main Camera, but could be easily changed to track a camera set via the inspector.


There is no error-checking for demonstration purposes.

using UnityEngine;

public enum BackgroundPosition
{
    FarLeft,
    Left,
    Middle,
    Right,
    FarRight
}

public class BackgroundTranslator : MonoBehaviour
{
    private BackgroundPosition backgroundPosition;
    private float backgroundWidth;

    public void SetBackgroundPosition(BackgroundPosition position)
    {
        backgroundPosition = position;
    }

    public void SetBackgroundWidth(float width)
    {
        backgroundWidth = width;
    }

    private void OnTriggerExit2D(Collider2D collision)
    {
        if (collision.gameObject.CompareTag("MainCamera"))
        {
            BackgroundTranslator farLeftBackground = null;
            BackgroundTranslator leftBackground = null;
            BackgroundTranslator middleBackground = null;
            BackgroundTranslator rightBackground = null;
            BackgroundTranslator farRightBackground = null;

            // Get a reference to each of the background sections
            foreach (Transform child in transform.parent)
            {
                BackgroundTranslator background = child.GetComponent<BackgroundTranslator>();

                switch (background.backgroundPosition)
                {
                    case BackgroundPosition.FarLeft:
                        farLeftBackground = background;
                        break;

                    case BackgroundPosition.Left:
                        leftBackground = background;
                        break;

                    case BackgroundPosition.Middle:
                        middleBackground = background;
                        break;

                    case BackgroundPosition.Right:
                        rightBackground = background;
                        break;

                    case BackgroundPosition.FarRight:
                        farRightBackground = background;
                        break;
                }
            }

            // Far Left, going left
            if ((backgroundPosition == BackgroundPosition.FarLeft)
                && (Camera.main.velocity.x < 0.0f))
            {
                rightBackground.transform.localPosition = new Vector3(
                    farLeftBackground.transform.localPosition.x - backgroundWidth,
                    transform.localPosition.y,
                    transform.localPosition.z);

                farRightBackground.transform.localPosition = new Vector3(
                    farLeftBackground.transform.localPosition.x - (2.0f * backgroundWidth),
                    transform.localPosition.y,
                    transform.localPosition.z);

                farRightBackground.backgroundPosition = BackgroundPosition.FarLeft;
                rightBackground.backgroundPosition = BackgroundPosition.Left;
                backgroundPosition = BackgroundPosition.Middle;
                leftBackground.backgroundPosition = BackgroundPosition.Right;
                middleBackground.backgroundPosition = BackgroundPosition.FarRight;
            }

            // Left, going left
            else if ((backgroundPosition == BackgroundPosition.Left)
                && (Camera.main.velocity.x < 0.0f))
            {
                farRightBackground.transform.localPosition = new Vector3(
                    farLeftBackground.transform.localPosition.x - backgroundWidth,
                    transform.localPosition.y, transform.localPosition.z);

                farRightBackground.backgroundPosition = BackgroundPosition.FarLeft;
                farLeftBackground.backgroundPosition = BackgroundPosition.Left;
                backgroundPosition = BackgroundPosition.Middle;
                middleBackground.backgroundPosition = BackgroundPosition.Right;
                rightBackground.backgroundPosition = BackgroundPosition.FarRight;
            }

            // Right, going right
            else if ((backgroundPosition == BackgroundPosition.Right)
                && (Camera.main.velocity.x > 0.0f))
            {
                farLeftBackground.transform.localPosition = new Vector3(
                    farRightBackground.transform.localPosition.x + backgroundWidth,
                    transform.localPosition.y,
                    transform.localPosition.z);

                leftBackground.backgroundPosition = BackgroundPosition.FarLeft;
                middleBackground.backgroundPosition = BackgroundPosition.Left;
                backgroundPosition = BackgroundPosition.Middle;
                farRightBackground.backgroundPosition = BackgroundPosition.Right;
                farLeftBackground.backgroundPosition = BackgroundPosition.FarRight;
            }

            // Far Right, going right
            else if ((backgroundPosition == BackgroundPosition.FarRight)
                && (Camera.main.velocity.x > 0.0f))
            {
                leftBackground.transform.localPosition = new Vector3(
                    farRightBackground.transform.localPosition.x + backgroundWidth,
                    transform.localPosition.y,
                    transform.localPosition.z);

                farLeftBackground.transform.localPosition = new Vector3(
                    farRightBackground.transform.localPosition.x + (2.0f * backgroundWidth),
                    transform.localPosition.y,
                    transform.localPosition.z);

                middleBackground.backgroundPosition = BackgroundPosition.FarLeft;
                rightBackground.backgroundPosition = BackgroundPosition.Left;
                backgroundPosition = BackgroundPosition.Middle;
                leftBackground.backgroundPosition = BackgroundPosition.Right;
                farLeftBackground.backgroundPosition = BackgroundPosition.FarRight;
            }
        }
    }
}

Results


Here is a video of everything in action. The camera moves to the left and the right at varying speeds and the background moves accordingly. There is also some early artwork tests to show how the ink-on-paper look feels, although it isn’t very noticeable when not fullscreen.