Infinite Runner Prefab Cycling

In one of our levels in White Paw, we have a train that’s constantly in motion. This is similar to how an infinite runner might work at least conceptually. Your perpetually going forward. There are usually only 2 real ways of dealing with this, have the player fixed to the world origin (0,0,0) or move the whole scene back to the origin after a certain point. Both of these are to prevent floating point inaccuracies (FPIs) the further out of the scene you go. A great example of this problem can be seen in (this) post.

The method we decided on was best was keeping the “player” at the world origin. This was because of the way the levels’ stage is designed being turn based. It was easier to simulate the effect of motion without the need to move and track player and game-play logic, especially when its dependant on grid based positions.

They way I will talk about the code will be specific to our circumstance but can be easily adapted to an actual infinite runner stage builder. (One where you’d have to instantiate prefabs instead of “recycling” them by looping the runner stage.

public class InfiniteRunnerController : MonoBehaviour
{
    [SerializeField] private Transform[] objectsToMove;
    [SerializeField] private float movementSpeed;
    [SerializeField] private Transform resetLocation;
    [SerializeField] private Transform endLocation;

    Vector3 direction;
    float distance;

    void Start()
    {
        direction = (endLocation.position - resetLocation.position).normalized;
            distance = Vector3.Distance(endLocation.position, resetLocation.position);

    }

   void Update()
   {
        for (var i = 0; i < objectsToMove.Length; i++)
        {
            objectsToMove[i].transform.position += direction * movementSpeed * Time.deltaTime;
            if (Vector3.Distance(objectsToMove[i].transform.position, resetLocation.position) >= distance)
            {
                objectsToMove[i].transform.position = resetLocation.position;
            }
      }
}

Logically this should work as intended. But when does anything work as intended. When we came to test this, because of what we think are floating point discrepancies, whenever the GameObjects got reset, they would not reset fully to the point where they were supposed to be at and gaps started to appear. This is defiantly not what was intended.

So in comes the over engineered solution. (There’s probably still a better way of doing this and as I’m writing this I have actually just thought of an alt. method. When I test it and verify it does the thing I expect it to, I’ll amend the post to talk about it).

The base premise of the over engineered solution is linked train cars / Lego blocks. By this, pieces of the “track” would attach themselves to the previous piece of track to ensure there isn’t a seem. No I can already see a potential issue in this in that its no longer dependant of the respawn point so theoretically with enough time, speed and accumulated FPIs the track would be offset to a point where it was noticeable that there was an issue with the track (E.g. spawning track pieces at the location of the player instead of 100 units in front of the player). However that’t not so much of a problem especially with the mobile game like ours. The amended code is as follows:

...

    void Start()
    {
        ...
            offset = -direction * Vector3.Distance(offsetCals[0].position, offsetCalcs[1].position);
        ...
    }

   void Update()
   {
        for (var i = 0; i < objectsToMove.Length; i++)
        {
            objectsToMove[i].transform.position += direction * movementSpeed * Time.deltaTime;
            if (Vector3.Distance(objectsToMove[i].transform.position, resetLocation.position) >= distance)
            {
                int idx = i-1;
                idx = idx < 0 ? objectsToMove.Length -1 ? idx;
                objectsToMove[i].transform.position = objectsToMove[idx].transform.position + offset;
            }
      }

Some things to note here, in the stage prefabs, they all have an end location to join on to, at the opposite end of the pivot point. This is to help calculate the length of the prefab segment. The limitation to this is that the segments can only be of that 1 size. If they’re not, then they would either show gaps or overlap and cause z-fighting and other gameplay related issues.

Another thing to bear in mind of is the offsetCalcs array. This should be an array of 2 Transforms, the pivot point and ending join location of the first path segment.

Okay it now works as intended if not a bit forced. But what if we wanted say thousands of objects in that objectsToMove array? The for loop becomes quiet inefficient. That entire loop body can be executed in Unity jobs (ref) since they don’t need to rely on anything. This makes the code transform into:

´╗┐using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
using UnityEngine.Jobs;

namespace WhitePaw
{

    public class InfiniteRunnerController : MonoBehaviour
    {

        [SerializeField] private Transform resetLocation;
        [SerializeField] private float movementSpeed;
        [SerializeField] private Transform endLocation;
        [SerializeField] private Transform[] objectsToMove;
        private float distance;

        private Vector3 direction;

        private TransformAccessArray taa;

        public Transform[] offsetCals;
        private Vector3 offset;
        
        // Start is called before the first frame update
        void Start()
        {
            direction = (endLocation.position - resetLocation.position).normalized;
            distance = Vector3.Distance(endLocation.position, resetLocation.position);
            taa = new TransformAccessArray(objectsToMove);
            offset = -direction * Vector3.Distance(offsetCals[0].position, offsetCals[1].position);
        }

        private void OnDestroy()
        {
            taa.Dispose();
        }

        // Update is called once per frame
        void Update()
        {
            MovementJob mj = new MovementJob()
            {
                direction = direction,
                movementSpeed = movementSpeed,
                dt = Time.deltaTime
            };
            var moveHandle = mj.Schedule(taa);

            ResetCheckJob rj = new ResetCheckJob()
            {
                startPoint = resetLocation.position,
                maxDistance = distance,
                offset = offset,
                taa = new NativeArray<Vector3>(objectsToMove.Select(x => x.position).ToArray(), Allocator.TempJob)
            };
            rj.Schedule(taa, moveHandle);
        }

        struct MovementJob : IJobParallelForTransform
        {
            [ReadOnly]public Vector3 direction;
            [ReadOnly]public float dt;
            [ReadOnly]public float movementSpeed;
            
            public void Execute(int index, TransformAccess transform)
            {
                transform.position += direction * movementSpeed * dt;
            }
        }
        
        struct ResetCheckJob : IJobParallelForTransform
        {
            [ReadOnly]public float maxDistance;
            [ReadOnly]public Vector3 startPoint;
            [ReadOnly]public Vector3 offset;
            [DeallocateOnJobCompletion][ReadOnly]public NativeArray<Vector3> taa;
            
            public void Execute(int index, TransformAccess transform)
            {
                if (Vector3.Distance(transform.position, startPoint) >= maxDistance)
                {
                    var idx = index -1;
                    if (idx < 0)
                        idx = taa.Length-1;
                    transform.position = taa[idx] + offset;
                }
            }
        }
    }

}

It’s fundamentally the same however this time, there are 2 jobs, one to move the objects and one to reset the objects to a location relative to the start point of the previous object in the array. This should have the benefit of now being able to scale across multiple threads.

In Update both jobs are created with the movement reset job reliant on the movement job finishing and then we can just tell it to go. We don’t need to wait for it to complete since no other logic is done after the jobs are created and the deallocation of the temporary native array is handled by the job itself thanks to the [DeallocateOnJobCompletion] attribute.

Leave a Reply