Time Travel - Rewinding Hexahedra, Part Two
Hexahedra's rewind system allows players to smoothly scroll back and forth through the past to rewatch what their factory was doing at any given point. This helps substantially when trying to debug or improve a puzzle solution. I've already written about the first version of this system, which only allowed moving back and forth to the boundaries between steps before replaying. The current version allows rewinding within steps as well, giving much more fine-grained control. Here I'm going to cover the process of upgrading to the new system. You can see the rewind system in action here:
The Spray Painter and Heater in action, forwards and backwards.
A major part of getting the smooth rewind working was getting the particle systems for the various bits of VFX to run backwards and forwards, which I've written about here. The other big chunks of work were:
- Getting each device to animate backwards and forwards
- Getting cube movement and the various changes (face colour, burning panels fading out, teleportation, etc) to work back and forth
- Making sure that cube visualizations (popup windows next to cubes in the factory that can be rotated or put into an unfolded net view) also rewind properly.
- Object pooling
There were a couple of gotchas as well, which I'll cover as we go.
1. Rewinding Devices
All of Hexahedra's devices have two parts to their animation when activated. They park themselves in a corner of the workstation when inactive (so that cubes moving between workstations don't collide with them) and move into position when triggered, and then each device performs its own animation and, potentially, VFX.
Some of the devices are animated using actual Animation
data supplied by the 3D modeller, for example the Cleaner which extends its brush on a pair of arms. But some of the simpler ones are animated entirely in code, such as the Cooler — the only part of it that moves is an intake fan on the side, and there's some code that spins it up and down as the device triggers.
Previously, devices didn't care how much time had moved since the last time Update
was called, they simply looked for trigger points within the current step and activated the relevant behaviour at each point. (The code frequently refers to SpeedLimiter.accumulatedTime
, which gets the progress through the current step.) The parent class of all the devices, UDevice
, handled moving to and from the active position at the start and end of the step via the CornerMovement
method, and specific code in each subclass would handle the rest. However, all the devices are now prepared for the possibility that time might go backwards — that the current time might be less than the previous time. Here's the code from the Panel Storer when it's adding a panel to a cube:
private void HandleUpdateAdding() {
float currentTime = SpeedLimiter.accumulatedTime;
// Adding and going forwards
if (currentTime > previousTime) {
if (currentTime >= ANIM_START && !animFired) {
animFired = true;
PlayAnimation(ANIM_START, ANIM_END, ANIM_END - currentTime, true);
}
if (currentTime >= gripTransferTime && !gripFrameHandoverComplete) {
// Make the grip the parent of the panel once it's in the correct position
AttachPanelToGrip();
gripFrameHandoverComplete = true;
}
if (currentTime >= PANEL_HANDOVER && !cubeGripHandoverComplete) {
// We've now moved the panel into position.
CompleteAdd();
}
if (currentTime >= ANIM_END) {
animComplete = true;
}
}
// Adding in reverse
else if (currentTime < previousTime) {
if (currentTime < ANIM_END && animComplete) {
PlayAnimation(ANIM_START, ANIM_END, currentTime - ANIM_START, true);
animComplete = false;
}
if (currentTime < PANEL_HANDOVER && cubeGripHandoverComplete) {
UncompleteAdd();
}
if (currentTime < gripTransferTime && gripFrameHandoverComplete) {
AttachPanelToFrame();
gripFrameHandoverComplete = false;
}
if (currentTime < ANIM_START && animFired) {
animFired = false;
}
}
}
So here we're looking for the various trigger points in both directions, for example the point where the panel being added switches from being attached to the grabber arm to being attached to the cube. Notice that whenever we trigger the animation, even when rewinding, we trigger it running forwards (the final true
in PlayAnimation
). When time is going backwards, code in UDevice
handles simply setting the animation progress to the correct point:
protected void SetAnimationProgress() {
if (animation == null || !animation.isPlaying || animationStartTime == animationEndTime) {
return;
}
// Work out how far through we are, set normalized time.
float duration = animationEndTime - animationStartTime;
float progress = SpeedLimiter.accumulatedTime - animationStartTime;
float proportion = progress / duration;
// Flip the proportion if we're going backwards
AnimationState state = animation[ANIM_NAME];
if (state.speed < 0) {
proportion = 1 - proportion;
}
state.normalizedTime = proportion;
}
Devices where the animation is handled in code have a similar layout, there are just additional trigger points to look for, and the code to move whatever needs moving is able to cope with time going backwards — since there's a lot of lerping, this is generally straightforward, the lerp value simply approaches 0 rather than 1.
2. Rewinding cubes
Since altering cubes to match the output is the aim of each level in Hexahedra, there are naturally a lot of ways cubes can be changed. As well as moving whole cubes around (including via a teleporter), panels can be added and removed, painted, stamped with decorations, burned, and so on. Each of these things needs to be able to happen in reverse as well as forwards.
The cubes, therefore, have a large number of tracking variables for the various transitions — for example to deal with the Spray Painter, we track which face is changing colour, what colour it's changing to, when the transition starts and how long it takes. This is all fed into the panel's shader so that we can blend textures as the paint is sprayed on:
public void SetFaceColourTransition(Colour toColour, int facing, bool front, float startTime, float duration) {
if (front) {
toColourFront = toColour;
transitioningFrontFace = facing;
colourFrontStart = startTime;
colourFrontDuration = duration;
}
else {
toColourBack = toColour;
transitioningBackFace = facing;
colourBackStart = startTime;
colourBackDuration = duration;
}
FaceData fd = faces[facing];
Renderer r = front ? fd.frontRenderer : fd.backRenderer;
r.GetPropertyBlock(propertyBlock);
propertyBlock.SetTexture(MaterialFinder.FACE_TRANSITION_TEXTURE, CubeFinder.GetTextureForColour(toColour, fd.damaged));
propertyBlock.SetFloat(MaterialFinder.FACE_TRANSITION_SHADER_VARIABLE, 0);
r.SetPropertyBlock(propertyBlock);
foreach (var c in childHexahedra) {
c.SetFaceColourTransition(toColour, facing, front, startTime, duration);
}
}
private void TransitionFaceColour(int face, Colour colour, float start, float duration) {
if (face == -1) return;
float currentTime = SpeedLimiter.accumulatedTime;
float lerp = (currentTime - start) / duration;
lerp = Mathf.Clamp01(lerp);
faces[face].frontRenderer.GetPropertyBlock(propertyBlock);
propertyBlock.SetFloat(MaterialFinder.FACE_TRANSITION_SHADER_VARIABLE, lerp);
faces[face].frontRenderer.SetPropertyBlock(propertyBlock);
}
private void EndTransitionFaceColour(ref int face, Colour colour) {
if (face == -1) return;
// We've finished transitioning, set the final colour and clear the face.
underlyingHex.SetFaceFrontColour(colour, face);
// Move the transition texture to the base, remove all others
faces[face].frontRenderer.GetPropertyBlock(propertyBlock);
propertyBlock.SetTexture(MaterialFinder.MAIN_TEXTURE, CubeFinder.GetTextureForColour(colour, faces[face].damaged));
propertyBlock.SetTexture(MaterialFinder.DECORATION_TEXTURE, Texture2D.blackTexture);
propertyBlock.SetTexture(MaterialFinder.FACE_TRANSITION_TEXTURE, Texture2D.blackTexture);
propertyBlock.SetFloat(MaterialFinder.FACE_TRANSITION_SHADER_VARIABLE, 0);
faces[face].frontRenderer.SetPropertyBlock(propertyBlock);
// Clear out the face, to mark ourselves as not transitioning.
face = -1;
}
As with other lerp-based transitions, TransitionFaceColour
doesn't actually care whether time is moving forwards or backwards, it just sets the lerp value as appropriate for the current time. EndTransitionFaceColour
is only called at the very end of the step before we move onto the next one, so that even if we pass the endpoint of the transition within the step we still have the necessary data to reverse the transition if the player starts rewinding.
There are several other things about a cube that can be transitioned: its rotation and position, its opacity (for teleporting cubes, and also when burning wooden faces away), and whether faces are damaged.
Wrinkle - Physics updates
Whenever the player starts dragging the timeline, we set Time.timeScale
to 0, so that Unity itself doesn't advance time for us. This means that things like the VFX just stop in mid-air, which is exactly what we want. However, this caused a bug that had me scratching my head for a while. After a rewind, clicking cubes to bring up their visualizations stopped working. As soon as the player resumed normal factory operation, this started working again.
The reason for this is that when time is halted in Unity, it doesn't run any physics updates. This means that although the rewind system was moving cubes around, the physics system didn't know they'd moved, and the raycasts that check for the player clicking on a cube were using outdated information.
This turned out to be very easy to fix. While the player is still dragging the timeline it doesn't matter if the physics system is out of date, so we can simply manually tell the physics system to update when the player ends the drag. This is handled by a single call to Physics.SyncTransforms
.
3. Visualizations
Each cube in the factory also has an attached visualization — clicking the cube brings up a FactoryHexVisualizer
— a 3D view of the cube that can be freely rotated or unfolded into a net. Changes to the cube in the factory need to be reflected in the visualization.
A cube visualization mirroring the changes made to the factory cube, forwards and backwards.
You may have spotted in the code above, at the bottom of SetFaceColourTransition
, how we tackle keeping the visualizations up to date — a cube can have child cubes that it passes the transition data onto.
This gets the visualizations going backwards within a step, but there's still one problem to overcome. When winding time forwards, we simply apply the stream of events to the cubes (and therefore the visualizations' child cubes) in the factory. However, when going backwards we tear down the existing cubes and recreate them from the stored state. The visualizations also get torn down and rebuilt, which means we need a way to preserve their own state (whether they're currently visible, whether the player is looking at the 3D or the net view, what angle the player had posed the cube at if they'd unlocked the rotation, etc.).
In order to be able to restore the correct visualization state for the earlier version of a cube, each cube needs a unique ID which is preserved with its own state each step. The visualizations also track this ID, so that we can link the visualization state to a particular cube. When we recreate a cube on rewind, we can set its ID from the preserved state, and then retrieve the visualization state from the VisualizationTracker
that stores them. This means that visualizations don't suddenly hide themselves or lose track of their zoom state when rewinding.
4. Object Pooling
This was the final refinement to the rewind system. Previously when rewinding and rebuilding the factory, all the cubes and their panels and visualizations were destroyed and then recreated, which carries a significant overhead. In the levels available in the open beta, using my relatively old machine (a six-year old PC with a GTX1070Ti) everything ran fine, but on a larger level I created to record trailer footage, rewinding caused huge framerate drops. Using Unity's profiler I was able to nail part of this down to a rogue call to FindObjectOfType
in the Start
method of UHexahedronGame
, which is the class used for factory cubes. Rebuilding the cubes during rewind meant this was being called a lot, and it was responsible for half the frame time alone. But even after fixing this, the rewind wasn't smooth when crossing boundaries. My development PC is well above my intended min spec machine, so clearly there was a lot of work to do.
Object pooling has largely fixed this problem (I also need to do a pass over the UI to reduce SetPass
calls and batches, but that's a job for another day and isn't rewind-specific). Cube panels are passed to a pool whenever a cube is destroyed or shipped, or when individual panels are removed, for example when burning wooden panels. The visualizations also frequently drop panels — when a panel is moved into a Panel Storer, the panel in the factory cube isn't thrown away, it's passed to the Panel Storer. But the one in the visualization is returned to the pool since its cube no longer has that panel.
This also means that when we destroy cubes when rewinding the panels now all go into the pool.
The pool contains three sets of cubes, one for each type (metal, wood, glass) since they use different models. When something requests a panel, the pool first searches the relevant set for an exact match (same colour, decoration, damage state, etc.) and returns that if it finds it. Otherwise, it just returns the first panel in the set, which will need textures setting and so on, but this still saves work.
If something requests a pool from the panel but it isn't available, the pool is responsible for creating the panel and passing it back, so the caller doesn't have to worry about this.
public static FaceData GetMatchingFace(Face spec, out bool exactMatch) {
if (instance == null) {
exactMatch = false;
return null;
}
if (spec.substance == Substance.EMPTY) {
exactMatch = false;
return null;
}
// Search the relevant set for an exact match.
HashSet set = GetSetForSubstance(spec.substance);
// Do we have any pooled panels with the correct substance?
if (set.Count == 0) {
// We'll need to make a new one.
exactMatch = true;
return CreateFace(spec);
}
PoolableFace exact = null;
PoolableFace first = null;
foreach (var panel in set) {
if (first == null) {
first = panel;
}
if (panel.Matches(spec)) {
exact = panel;
break;
}
}
// Did we find an exact match?
PoolableFace target;
if (exact != null) {
target = exact;
exactMatch = true;
}
else {
target = first;
exactMatch = false;
}
set.Remove(target);
WipeTransitionData(target.faceData);
target.faceData.face.SetActive(true);
return target.faceData;
}
Prior to this work, panels were only created when initializing new cubes, so there were one or two obscure instances in the code where something would create a UHexahedron
with one panel, take the panel, and then destroy the cube, but now we create panels directly, so another minor source of inefficiency has gone.
We also pool some of the visualizations themselves. The Canvas-based UI prefabs also have an appreciable setup cost (thanks, Unity profiler!), so the FactoryHexVisualizations
are also pooled centrally.
The final significant overhead were the visualizations for the Panel Storer:
A Panel Storer, with the stored panels shown. Each individual panel visualization is an instance of PanelVisualizer
.
Clicking a Panel Storer brings up a view of the stored panels, and these are torn down and set up when rewinding. Creating a central pool for the PanelVisualizers
didn't actually save much time. This is because a lot of the overhead was caused by assigning the visualizations for each panel to the parent PanelStackVisualizer
, which uses a HorizontalLayoutGroup
— adding something to a layout group and triggering a rebuild is expensive, so the central pool didn't help much. Instead we now simply hide the panel visualizations that aren't needed. Each Panel Storer can hold a maximum of 3 panels, so they aren't carrying around huge numbers of disabled GameObjects
with this option.
As always with pooling, the difficulties came with making sure that a reused object isn't carrying around any state from its previous use. I also had to split out the initialization of these objects into a one-time method that was called when creating a new one, and a SetData
method that takes all the necessary panel information etc to set it up for its next outing.
Finally working
With all this in place the smooth rewind was working with an acceptable (if not yet ideal) level of performance.
The rewind feature is genuinely unique within the genre, and makes debugging solutions much easier than restarting and trying to catch the error as it flies past. If you want to give Hexahedra a go (see below) I hope it makes playing through the puzzles particularly satisfying.
Hexahedra is now live — head to the store page to grab a copy or try the demo.