Rewinding Unity particle systems for fun and profit

Over the last couple of weeks I've been working on Hexahedra's rewind system, which allows you to wind the factory backwards and rewatch past steps to help with debugging solutions. Up until recently the system was relatively crude, as it only allowed you to jump to the boundaries between steps and then replay, but I've now added the ability to smoothly move back and forth through time, with all the animations and so on playing in the relevant direction:


Sorry, your browser doesn't support embedded videos.

The Spray Painter and Heater in action, forwards and backwards.

One of the challenges is getting the particle systems, such as the Spray Painter's paint and the Heater's flames above, to play backwards. Unity doesn't have any support for simply asking a ParticleSystem to rewind, so this requires a little bit of fancy footwork. Here I'll use the Spray Painter and Heater as examples. I'll cover a few wrinkles that make things tricky as we go.

There are two main tools we need to make particle systems look like they're going backwards. The first is ParticleSystem.Simulate, which asks Unity to run the particle system for the specified amount of time, rather than just letting it progress naturally each frame. Simulate takes, among other arguments, a boolean that gets the system to restart and simulate from scratch — the way we "rewind" is to restart and simulate up to the point we need.

The second is the ParticleSystemStopBehavior enum. When we tell a particle system to stop, we can either tell it to just stop emitting new particles, and allow the existing ones to carry on until they expire, or we can do a hard reset and wipe everything out now. When we're going back in time to a point before the particle system was triggered, we need to be able to wipe out whatever particles are in place completely, which ParticleSystemStopBehavior.StopEmittingAndClear allows us to do.

We'll start with the Spray Painter's Update method, and then dive down into some of the methods it calls, to show how the whole thing hangs together.


public void Update() {
    float currentTime = SpeedLimiter.accumulatedTime;
    isPaused = SpeedLimiter.IsPaused();
    CornerMovement(currentTime, CORNER_DISTANCE);

    if (animating) {
        if (currentTime > previousTime) {
            if (!paintTriggered && currentTime >= SPRAY_TRIGGER) {
                SprayPaint();
            }
            if (!paintStopped && currentTime >= paintStopTime) {
                paintStopped = true;
            }
        }
        else if (currentTime < previousTime) {
            // We don't need to trigger the spray painter when going backwards.
            // Calls to Simulate will start it when necessary.
            if (paintStopped && currentTime < paintStopTime) {
                paintStopped = false;
            }
            if (paintTriggered && currentTime < SPRAY_TRIGGER) {
                paintTriggered = false;
                StopVFX();
            }
        }

        if (isPaused) {
            HandleRewind();
        }
        if (!isPaused && wasPaused) {
            HandleResume();
        }
    }

    previousTime = currentTime;
    wasPaused = isPaused;
}

private void SprayPaint() {
    if (SpeedLimiter.AtMaxSpeed()) {
        // Don't bother at full tilt
        return;
    }
    paintTriggered = true;
    paint.Play();
}

The actual particle rewinding happens in HandleRewind, which we only bother to call if the factory is paused — if we're rewinding, we don't want the factory to continue running, so we pause it (by setting Time.timeScale to 0) whenever a rewind starts. This means Update only needs to be concerned with tracking our progress through the step and, in the case where the factory is running normally, triggering the particles. HandleResume gets called when we un-pause, since particle systems that have been modified with Simulate are automatically paused, so we need to manually ask them to resume when the factory starts up again.

So, let's look at the functionality in Update itself. Time is controlled centrally in Hexahedra. Rather than each device needing to worry about Time.deltaTime and keeping track of when the current step started and how far through we are, SpeedLimiter does this for us with accumulatedTime, which returns the progress through the current step, in seconds. The normal flow of things, when the factory is just playing forwards, doesn't need to do much. CornerMovement is handled by the base class, and deals with getting the Spray Painter into position if it's been triggered. If the device is triggered, animating will be true, and we'll execute the "if time is going forwards" block, where we trigger the paint VFX (a particle system with a second system as a child) and also set the paintStopped flag when we know the VFX is complete. Before the rewinding was added, these "VFX complete" flags didn't exist, but we'll see that they come in handy later when we're trying to minimize simulating particle systems unnecessarily.

Wrinkle 1: How do we know when we've stopped?

Working out at what point the particle system will have stopped emitting and all the particles will have died can be a bit tricky, though for the Spray Painter it's relatively straightforward. If we were only interested in time moving forward, working out whether the particle system was still doing something would simply be a case of calling isAlive(true) to check on it and its children. However, when we're going backwards the question we're asking is "have we now rewound enough that there ought to be something to see?", which isn't a question that can be answered by the current state of the particle system.

For simple systems, calculating the stop point is easy. Here's the code from the Spray Painter's Start method:


paintStopTime = (paint.main.duration + paint.main.startLifetime.constant) / paint.main.simulationSpeed;
paintStopTime += SPRAY_TRIGGER;

We take the ParticleSystem.main.duration property (which tells us how long the system will emit particles), add on ParticleSystem.main.startLifetime.constant (which is how long the last particle will survive after being emitted), and divide by ParticleSystem.main.simulationSpeed.

SPRAY_TRIGGER is the point at which we start the VFX, so adding that on gives us the absolute time the particle system will have finished. For looping systems that we manually turn on and off, such as the Heater, we instead need to add lifetime/speed to the point we turn the emitter off.

However, it's possible to define particle lifetimes using an AnimationCurve, at which point to get a tight bound on duration we need to start pulling out the keyframes and checking values, and a particle emitted in the middle of a run could outlive one emitted at the end, and things get a bit tricky. If you just want an approximation you can use ParticleSystem.main.startLifetime.curveMultiplier, which will give you an upper bound on the lifetime of the last particle emitted.

Another gotcha to watch out for is particle systems with additional systems as children, which might go on for a bit longer.

So, now that we know where we are in the step and when to stop, how do we rewind when necessary? Here's HandleRewind:


protected override void HandleRewind() {
    float currentTime = SpeedLimiter.accumulatedTime;
    if (currentTime != previousTime) {
        // Keep the spray VFX up to date
        if (paintTriggered) {
            float delta = currentTime - previousTime;
            // If we're going forward, simulate by the delta
            if (delta > 0) {
                // If we were already past the paint end time last time,
                // there won't be anything to do. Otherwise, simulate some paint!
                if (previousTime < paintStopTime) {
                    float simulateTime = Mathf.Min(delta, currentTime - SPRAY_TRIGGER);
                    paint.Simulate(simulateTime, true, false, false);
                }
            }
            else {
                // Don't bother if we're still beyond the end of the spray window
                if (!paintStopped) {
                    paint.Simulate(currentTime - SPRAY_TRIGGER, true, true, false);
                }
            }
        }
    }
}

Clearly, if currentTime and previousTime are the same, we don't need to simulate anything. Otherwise, we work out the difference between them and act appropriately.

Players can grab the timeline handle and rewind time, and then drag forwards again (though it's not possible to go forwards past the point the factory has reached while running normally), so although we're definitely in the past here, we do tackle the case where time is going forwards rather than backwards.

If we are going forwards, we don't need to restart the particle system, and we can just simulate by the delta (or, if currentTime and previousTime are on either side of the trigger point, the difference between currentTime and the trigger).

Simulate takes a lot of boolean arguments. The first is "should we simulate child particle systems as well?", which we want to be true. The second is "should we restart from scratch?", which is this case is false. The final argument is "should we simulate using a fixed time step?" which causes the system to advance in small chunks — in general I've set this to false since it makes the particles move smoothly as we slowly progress through time, although for a couple of particle systems, including the burning wooden panel in the video, this causes some particles to flicker oddly.

If we're going backwards, we just need to simulate from the start up to the current time. Here's where the paintStopped flag saves us resimulating the spray effect from start to finish where the end result will be that there's nothing to see.

However, for the restarted system to look right, we need to make sure that the particles look the same every run.

Wrinkle 2: Randomization

By default, a new ParticleSystem will have a flag called useAutoRandomSeed set to true. This means that every time the system is restarted, it picks a new seed, so that every run of the system is different. Normally this is what we want, but it prevents us rewinding because the particles won't appear to smoothly progress backwards. Here's an example of the problem:


Sorry, your browser doesn't support embedded videos.

The Spray Painter using a random seed for each run. Works fine forwards, but not backwards.

Happily, this is easy to fix. We could set a random seed for each particle system in the prefab, but this would mean that every Spray Painter would have identical VFX. A better solution is to set the seed in Start, so that each Spray Painter has a different seed, but the seed is consistent for the life of that device. Here's the code from Start:


...
paint = GetComponentInChildren<ParticleSystem>();
// Set the colour of both particle systems.
paint.GetComponent<Renderer>().material = VFXFinder.GetSprayPainterMaterial(colour);
ParticleSystem droplets = paint.transform.GetChild(0).gameObject.GetComponent<ParticleSystem>();
droplets.GetComponent<Renderer>().material = VFXFinder.GetSprayPainterDropletMaterial(colour);
// Give constistent but random seeds to support smooth rewinding
paint.randomSeed = (uint)Random.Range(0, Int32.MaxValue);
droplets.randomSeed = (uint)Random.Range(0, Int32.MaxValue);
...

(VFXFinder is in charge of loading up VFX prefabs, materials, etc. asynchronously using the Addressables system when the game starts up, and supplying them to devices on demand.)

Setting the randomSeed property automatically disables useAutoRandomSeed, so this is all we need to do.

Now that we can rewind, we need to make sure everything resumes when the player wants the factory to play forwards again. This means either replaying from some point in the past or, if the timeline handle is all the way over to the right, forging ahead with new progress. Unfortunately, getting everything running again can be slightly awkward.

Wrinkle 3: Resuming normal operation

As I mentioned above, particle systems that are updated with Simulate are automatically paused. This makes sense — if you're doing stuff manually you probably don't want Unity updating the system for you at the same time.

Clearly, then, when the player has finished rewinding and wants to get the factory to start playing forwards again we're going to have to resume the paused systems. The awkward thing here is that there's no Resume method, only Play. If you call Play on a paused system when the system has stopped emitting, will it carry on from where it left off, and let any remaining particles fade out, or will it restart?

Well, it depends.

For particle systems that fire once, such as the Spray Painter, calling Play when the system is flagged with isPaused set to true is "safe", it will never restart the system. Even if you've used Simulate to go way beyond the point where all the particles are dead, Play won't trigger a restart (and, incidentally, isStopped will never be true — you need to resume the particle system and let it do a normal update for it to realize it's finished). That means that HandleResume for the Spray Painter is nice and simple:


private void HandleResume() {
    if (paint.isPaused) { 
        paint.Play(true);
    }
}

However, if the particle system is one that loops forever, and you need to stop it by manually calling Stop, as we do with the Heater, then calling Play will always get the system to start emitting again. This means you can use looping systems with Play and Stop to generate pulses of particles of arbitrary length on command, which of course is handy in some situations, but in this case it means that we can't just call Play on a paused Heater particle system and be done with it; if we were in the final stages of the step where we just needed old flame particles to expire, we'd start emitting again.

Thankfully, the solution isn't too tricky. If we're beyond the point the flames are turned off, calling Play and then immediately calling Stop will resume the system without creating any new particles. So, here's the version for the Heater:


private void HandleResume() {
    float currentTime = SpeedLimiter.accumulatedTime;
    // Have the flames been started?
    // Resume them. If they've also been stopped, we'll stop below,
    // but we need to start and stop in order to get old particles to fade out.
    if (flamesActivated) {
        flames.Play(true);
    }
    // Have they been stopped and just need to play out?
    if (flamesDeactivated) {
        flames.Stop(true);
    }

    // Do we need to resume the pilot lights?
    if (pilotLightsDeactivated == pilotLightsReactivated) {
        pilotLights.Play(true);
    }
}

(The pilot lights are small blue flames that are active whenever we're not using the big flames to actually burn something)

At this point, we've got particles when we want them, and we're not creating them when we don't, but there's one final thing that isn't quite right.

Wrinkle 4: Where is the emitter?

If you look at the video of the Heater in action, you'll see that the body of the device moves back and forth, away from and towards the camera, as it emits flames. This means that, when the factory is running normally, the flames aren't in a single vertical plane, but they wave back and forth across the surface of the cube. (This requires ParticleSystem.main.simulationSpace to be set to ParticleSystemSimulationSpace.World, otherwise the particles would move with the emitters.)

When we're simulating, however, the simulation produces slightly odd results. If time is paused and the player jumps forward half a second, all the extra flames emitted during the half-second simulation will be the same distance from the camera because the emitter has jumped straight to its new position. How can we deal with that?

The short answer is that, in the case of the Heater, I haven't bothered. Given that the motion is directly towards and away from the camera, it isn't particularly obvious. If you rewatch the video at the start of this post, you can tell that the forward and back motions aren't quite identical, but it's not a big deal.

If you wanted to deal with this, you'd need to break the simulation down into steps, and move the emitters slightly between each step. Happily, none of the devices in Hexahedra emit particles in a way where this was necessary, so this isn't a bridge I've had to cross.

However, there is one issue that's much more obvious, which is related to the Heater moving back into the corner at the end of the step. If you watch the video below, you'll see that as the Heater moves back down into the corner, some of the flames are still fading out. Simulating forwards, this is fine, because no new particles are being emitted. However if we're not careful, then when we're going backwards and we resimulate from scratch the flames will move vertically as the body of the device does, because they're being re-emitted while the device is in the wrong place.

Here's a video showing the problem:


Sorry, your browser doesn't support embedded videos.

The Heater with particles attached to the device. When winding backwards, the flames move vertically with the device.

To deal with this, we need to decouple the emitters from the model. The flame emitters come as a set of prefabs (we give the systems slightly different settings depending on whether the flames are coming from the side, above, or below), and prior to adding the smooth rewind we simply attached them to the Heater, but now when we add a new Heater to the factory we attach the particle system to the Workstation (the GameObject parent of all the devices) so that it doesn't move when the model does. This means we also need to manually Destroy the particle system when the Heater is removed:


public new void Start() {
...
    // Which flames VFX we use depends on our position.
    GameObject flamesPrefab = VFXFinder.GetHeaterFlames(Position);
    // Attach the flames to the heater body to set the correct position & orientation.
    flamesObject = Instantiate(flamesPrefab);
    flamesObject.name = flamesPrefab.name;
    flamesObject.transform.SetParent(heaterBody.transform, false);
    // Then attach the transform to the workstation, so that the emitter is always in the correct plane.
    flamesObject.transform.SetParent(transform.root);
    flamesBasePos = flamesObject.transform.position;
...
}

public new void OnDestroy() {
    base.OnDestroy();
    Destroy(flamesObject);
...
}

We then need some extra code to make sure that the emitters move towards and away from the camera when the model does:


private void Translate() {
    if (!moving) return;
    float currentTime = SpeedLimiter.accumulatedTime;
    float lerp = (currentTime - moveStartTime) / (moveEndTime - moveStartTime);
    float smooth = Mathf.SmoothStep(0, 1, Mathf.Clamp01(lerp));
    Vector3 pos = Vector3.Lerp(fromPos, toPos, smooth);
    heaterBody.transform.localPosition = pos;
    // Move the flame emitter back and forth, while still keeping it aligned with the cube.
    // We need to rotate the local body movement since we're working in world space for the flames.
    flamesObject.transform.position = flamesBasePos + flamesObject.transform.rotation * -pos;
}

In total, just over half of the devices in Hexahedra anchor their emitters to the Workstation to avoid this problem.

With these wrinkles dealt with, we've got particles going forwards and backwards. They restart properly, they resume properly, and they're in the right place. Job done!

The smooth rewind has been a major feature, with all kinds of edge cases and gotchas, particle-based and otherwise, but it's now almost complete, and I'm looking forward to getting a new beta build out in the near future, so playtesters will be able to give it a go very soon. I'll be writing some follow-up posts on how the rest of the rewind system works in the coming weeks.

Update: The first post on how the rest of the rewind feature works can be found here

Hexahedra is now live — head to the store page to grab a copy or try the demo.