Last time we created a simple game where we can rewind the time to a previous point. Now we will solidify this feature and make it much more fun to use.
Everything we do here will build on the previous part, so go check it out! As before, you will need Unity and a basic understanding of it.
Ready? Let's go!
Record Less Data and Interpolate
Right now we record the positions and rotations of the player 50 times a second. This amount of data will quickly become untenable, and this will become especially noticeable with more complex game setups and mobile devices with less processing power.
But what we can do instead is only record 4 times a second and interpolate between those keyframes. That way we save 92% of the processing bandwidth, and get results that are indistinguishable from the 50-frame recordings, as they play out within fractions of a second.
We'll start by only recording a keyframe every x frames. To do this, we first need these new variables:
public int keyframe = 5; private int frameCounter = 0;
The variable keyframe
is the frame in the FixedUpdate
method at which we will record the player data. Currently, it is set to 5, which means every fifth time the FixedUpdate
method cycles through, the data will be recorded. As FixedUpdate
runs 50 times per second, this means 10 frames will be recorded per second, compared to 50 before. The variable frameCounter
will be used to count the frames until the next keyframe.
Now adapt the recording block in the FixedUpdate
function to look like this:
if(!isReversing) { if(frameCounter < keyframe) { frameCounter += 1; } else { frameCounter = 0; playerPositions.Add (player.transform.position); playerRotations.Add (player.transform.localEulerAngles); } }
If you try it out now, you will see that the rewinding takes part in a much shorter time than before. This is because we recorded less data, but played it back at regular speed. Now we need to change that.
First, we need another frameCounter
variable to keep track not of recording the data, but of playing it back.
private int reverseCounter = 0;
Adapt the code that restores the player's position to utilize this the same way we record the data. The FixedUpdate
function should then look like this:
void FixedUpdate() { if(!isReversing) { if(frameCounter < keyframe) { frameCounter += 1; } else { frameCounter = 0; playerPositions.Add (player.transform.position); playerRotations.Add (player.transform.localEulerAngles); } } else { if(reverseCounter > 0) { reverseCounter -= 1; } else { player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1]; playerPositions.RemoveAt(playerPositions.Count - 1); player.transform.localEulerAngles = (Vector3) playerRotations[playerRotations.Count - 1]; playerRotations.RemoveAt(playerRotations.Count - 1); reverseCounter = keyframe; } } }
When you rewind time now, the player will jump back to their previous positions, in real time!
That's not quite what we want, though. We need to interpolate between those keyframes, which will be a bit trickier. First, we need these four variables:
private Vector3 currentPosition; private Vector3 previousPosition; private Vector3 currentRotation; private Vector3 previousRotation;
Those will save the current player data and the one from the recorded keyframe before that so that we can interpolate between those two.
Then we need this function:
void RestorePositions() { int lastIndex = keyframes.Count - 1; int secondToLastIndex = keyframes.Count - 2; if(secondToLastIndex >= 0) { currentPosition = (Vector3) playerPositions[lastIndex]; previousPosition = (Vector3) playerPositions[secondToLastIndex]; playerPositions.RemoveAt(lastIndex); currentRotation = (Vector3) playerRotations[lastIndex]; previousRotation = (Vector3) playerRotations[secondToLastIndex]; playerRotations.RemoveAt(lastIndex); } }
This will assign the corresponding information to the position and rotation variables which we will interpolate between. We need this in a separate function, as we call it in two different spots.
Our data-restoring block should look like this:
if(reverseCounter > 0) { reverseCounter -= 1; } else { reverseCounter = keyframe; RestorePositions(); } if(firstRun) { firstRun = false; RestorePositions(); } float interpolation = (float) reverseCounter / (float) keyframe; player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation); player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation);
We call the function to get the last and second to last information sets from our arrays whenever the counter reaches the keyframe interval we have set (in our case 5), but we also need to call it on the first cycle when the restoring is happening. This is why we have this block:
if(firstRun) { firstRun = false; RestorePositions(); }
In order for this to work, you also need the firstRun
variable:
private bool firstRun = true;
And to reset it when the space button is lifted:
if(Input.GetKey(KeyCode.Space)) { isReversing = true; } else { isReversing = false; firstRun = true; }
Here is how the interpolation works:
Instead of just using the last keyframe we saved, this system gets the last and the second-to-last and interpolates between them. The amount of interpolation is based on how far between the frames we currently are.
This all happens via the Lerp function, where we add the current position (or rotation) and the previous one. Then the fraction of the interpolation is calculated, which can go from 0 to 1. Then the player is placed at the equivalent place between those two saved points, for example, 40% on the route to the last keyframe.
When you slow it down and play it frame by frame, you can actually see the player-character move between those keyframes, but in gameplay, it's not noticeable.
And thus we have greatly reduced the complexity of the time-rewinding setup and made it much more stable.
Only Record a Fixed Number of Keyframes
Now that we have greatly reduced the number of frames we actually save, we can make sure we don't save too much data.
Right now we just pile the recorded data into the array, which will not do long-term. As the array grows, it will become more unwieldy, access will take longer amounts of time, and the entire setup will become more unstable.
In order to fix this, we can institute code that checks if the array has grown over a certain size. If we know how many frames per second we save, we can determine how many seconds of rewindable time we should save, and what would fit our game and its complexity. The somewhat complex Prince of Persia allows for maybe 15 seconds of rewindable time, while the simpler setup of Braid allows for unlimited rewinding.
if(playerPositions.Count > 128) { playerPositions.RemoveAt(0); playerRotations.RemoveAt(0); }
What happens is that once the array grows over a certain size, we remove the first entry of it. Thus it only stays as long as we want the player to rewind, and there is no danger of it becoming too large to use efficiently. Put this in the FixedUpdate
function after the recording and replaying code.
Use a Custom Class to Hold Player Data
Right now we record the player positions and rotations into two separate arrays. While this does work, we have to remember to always record and access the data in two places at the same time, which has the potential for future problems.
What we can do, however. is create a separate class to hold both of these things, and possibly even more (if that should be necessary in your project).
The code for a custom class to act as container for the data looks like this:
public class Keyframe { public Vector3 position; public Vector3 rotation; public Keyframe(Vector3 position, Vector3 rotation) { this.position = position; this.rotation = rotation; } }
You can add it to the TimeController.cs file, right before the class declaration starts. What it does is provide a container to save both the position and rotation of the player. The constructor method allows it to be directly created with the necessary information.
The rest of the algorithm will need to be adapted to work with the new system. In the Start method, the array needs to be initialized:
keyframes = new ArrayList();
And instead of saying:
playerPositions.Add (player.transform.position); playerRotations.Add (player.transform.localEulerAngles);
We can save it directly into a Keyframe object:
keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles));
What we do here is add the position and rotation of the player into the same object, which then gets added into a single array, which greatly reduces the complexity of this setup.
Add a Blurring Effect to Signify That Rewinding Is Happening
We drastically need some kind of signifier telling us the game is currently being rewound. Right now, we know this, but a player might be confused. In such situations it is good to have multiple things telling the player that rewinding is happening, like visually (via the entire screen blurring a bit) and audio (by the music slowing down and reversing).
Let's do something similar to how Prince of Persia does it, and add some blurring.
Unity allows you to add multiple camera effects on top of each other, and with some experimenting you can make one that fits your project perfectly.
Before we can use the basic effects, we need to import them. To do this, go to Assets > Import Package > Effects, and import everything that is offered to you.
Visual effects can be added directly to the main camera. Go to Components > Image Effects and add a Blur and a Bloom effect. The combination of those two should provide a nice effect for what we are going for.
When you try it out now, the game will have this effect on all the time.
Now we need to activate it and deactivate it respectively. For that, the TimeController
needs to import the image effects. Add this line to the very beginning:
using UnityStandardAssets.ImageEffects;
To access the camera from the TimeController
, add this variable:
private Camera camera;
And assign it in the Start
function:
camera = Camera.main;
Then add this code to activate the effects while rewinding time, and have them activated otherwise:
void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; camera.GetComponent<Blur>().enabled = true; camera.GetComponent<Bloom>().enabled = true; } else { isReversing = false; firstRun = true; camera.GetComponent<Blur>().enabled = false; camera.GetComponent<Bloom>().enabled = false; } }
When you press the space button, you now not only rewind the scene, but you also activate the rewind effect on the camera, telling the player that something is happening.
The entire code of the TimeController
should look like this:
using UnityEngine; using System.Collections; using UnityStandardAssets.ImageEffects; public class Keyframe { public Vector3 position; public Vector3 rotation; public Keyframe(Vector3 position, Vector3 rotation) { this.position = position; this.rotation = rotation; } } public class TimeController: MonoBehaviour { public GameObject player; public ArrayList keyframes; public bool isReversing = false; public int keyframe = 5; private int frameCounter = 0; private int reverseCounter = 0; private Vector3 currentPosition; private Vector3 previousPosition; private Vector3 currentRotation; private Vector3 previousRotation; private Camera camera; private bool firstRun = true; void Start() { keyframes = new ArrayList(); camera = Camera.main; } void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; camera.GetComponent<Blur>().enabled = true; camera.GetComponent<Bloom>().enabled = true; } else { isReversing = false; firstRun = true; camera.GetComponent<Blur>().enabled = false; camera.GetComponent<Bloom>().enabled = false; } } void FixedUpdate() { if(!isReversing) { if(frameCounter < keyframe) { frameCounter += 1; } else { frameCounter = 0; keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles)); } } else { if(reverseCounter > 0) { reverseCounter -= 1; } else { reverseCounter = keyframe; RestorePositions(); } if(firstRun) { firstRun = false; RestorePositions(); } float interpolation = (float) reverseCounter / (float) keyframe; player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation); player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation); } if(keyframes.Count > 128) { keyframes.RemoveAt(0); } } void RestorePositions() { int lastIndex = keyframes.Count - 1; int secondToLastIndex = keyframes.Count - 2; if(secondToLastIndex >= 0) { currentPosition = (keyframes[lastIndex] as Keyframe).position; previousPosition = (keyframes[secondToLastIndex] as Keyframe).position; currentRotation = (keyframes[lastIndex] as Keyframe).rotation; previousRotation = (keyframes[secondToLastIndex] as Keyframe).rotation; keyframes.RemoveAt(lastIndex); } } }
Download the attached build package and try it out!
Conclusion
Our time-rewind game is now much better than before. The algorithm is noticeably improved and uses 90% less processing power, it is much more stable, and we have a nice signifier telling us that we are currently rewinding time.
Now go make a game using this!