Skip to content

Coroutines: Parallel Execution and Experiment Protocols

We have mastered automated, simple events with the Cube Factory. Now let's create more complex flows and dive deeper into coroutines!

Reuse previous Unity project

We can once again save some time by continuing to use the previous project. Just make sure to delete or deactivate the CubeFactory object and you should be good to go.

Archiving and copying Unity projects

Another way to continue work without sacrificing previously finished projects is to making a copy of a whole Unity project in your file explorer, e.g., by going to the Unity hub, right-clicking your project to "Show in Finder/Explorer" and copying its whole folder. You can rename the copied folder to anything you want, then go back to the Hub and clicking Open instead of new New Project, then point it to your newly copied folder.

Setting the scene

We create again a few new objects.

Create as children of the floor plane object two new Plane 3D objects and configure them like this:

  • "StandingMark"
    • Position: (-2.333, .001, -2.333)
    • Scale: (.1, 1, .1)
  • "OriginMark"
    • Position (0, .001, 0)
    • Scale (.1, 1, .1)

Assign StandingMark our old BlueBox material, and the RedBox material to OriginMark.

Outside the floor object, at empty spaces in the hierarchy of our scene, create:

  • A Sphere 3D object and name it "Zeppelin":
    • Position (-1, 1.5, -1)
    • Rotation (0, 90, 0)
    • Scale (.5, .2, .2)
  • A new Cube:
    • Position (0, 1.5, 1)
    • Scale (.2, .1, .2)

Attach to the new Sphere (Zeppelin) and the new cube our existing IsCollidingChecker script as a component: Click Add Component and start typing in its name.

Give the Is Colliding Checker components you've just attached to the Cube and Zeppelin two new colors of your choosing. They can be different to spice it up a bit!

Setting objects in motion

Now should have the two last objects simply hovering in the air. Let's write new scripts to get them moving.

Create a script called Moving and attach it to the Zeppelin object:

Moving.cs
using UnityEngine;

public class Moving : MonoBehaviour
{
    [Tooltip("Units per second")] public float speed;
    [Tooltip("Start position in 3D space")] public Vector3 startPoint;
    [Tooltip("End position in 3D space")] public Vector3 endPoint;

    public float interpolator = 1f;
    public bool isDone => interpolator > .999f;

    void Update()
    {
        print(isDone);
        if (isDone) return;

        interpolator += speed * Time.deltaTime;
        transform.position = Vector3.Lerp(startPoint, endPoint, interpolator);
    }
}

It takes speed and two Vector3 variables for its moving speed (via interpolation) and for start and end points of its journey.

isDone?

The expression isDone => interpolator > .999f is a handy shortcut: the => operator assigns to isDone the result of evaluating interpolator > .999f, similar to how an if statement would do. You can read more about this at the C# documentation.

What this effectively does is to constantly check if the interpolator is greater than .999f, and setting isDone to true if so, and false if not.

interpolator being already 1 at the beginning will set isDone to true, thus aborting the execution of Update(). It will need to be set to a smaller value to get it going.

This function also outputs the state of isDone to the Console at each update using the print() command: you can see it when the program is running, as it will be filling up the console with printouts quickly and keep scrolling down.

Printing to console

It can be a good practice to output the state of variables to the console to have a clear understanding of what is going on. Doing it as above (printing at each Update()) is one way, but usually it is used only at specific events, like when a variable is changed. Using print() — or the Unity equivalent Debug.Log() — well is a powerful helper to understanding and debugging your code, so feel free to try it on other variables and other positions in your code by yourself!

Set speed in the inspector to 0.25, and give (-1, 1.5, -1) as the start point and (-1, 1.5, 1) as the end point for its trajectory.

Now create a script called Rotating for the new cube object:

Rotating.cs
using UnityEngine;

public class Rotating : MonoBehaviour
{
    [Tooltip("Units per second")] public float speed;
    [Tooltip("Axis to rotate around")] public Vector3 axis;

    public bool isRotating;

    void Update()
    {
        if (!isRotating) return;

        transform.Rotate(axis, speed * Time.deltaTime);
    }
}

Very similar to our first rotation script, it differs only by having a public boolean called isRotating that is checked before performing the rotation — it acts basically as an on/off switch.

Set the speed to 180 and the rotation axis to (1, 0, 0) in the inspector.

If you run the game now, only the the cube object should be rotating, and then only if you set its isRotating parameter to true in its Rotating component in the inspector. Try it out, and play around with the parameters in the inspector to see their effects:

Scripting a protocol

With the two moving objects and two areas to step on have set the stage to introduce more complex flows, such as might be needed for actual experiments (or even games).

Variables and assignments

Create a new script for the [CameraRig] object and name it Protocol:

Protocol.cs
using System.Collections;
using UnityEngine;
using Valve.VR;

public class Protocol : MonoBehaviour
{
    public SteamVR_Action_Boolean Trigger; // Set to \actions\default\in\InteractUI in editor

    public Moving movingComp;
    public Rotating rotatingComp;

    public IsCollidingChecker zeppelinColliderChecker;
    public IsCollidingChecker cubeColliderChecker;

    public Transform headCamera;
    public Transform standingMark;

    public float positionTolerance = 0.15f;

    private bool isRunning = true;
}

At this point, nothing in this first part of the declaration should be unfamiliar: Protocol.cs holds a number of public variables to store a SteamVR action, four components from other objects, two transforms, a floating point number, and a boolean.

Save the script as it is so far, and make sure it's attached to our [CameraRig]. Go back to the Unity editor to assign the unfilled variables in the inspector.

Challenge: assign it yourself!

Can you figure out yourself how to fill the fields in the inspector for the [CameraRig]'s Protocol component correctly?

The names and expected types should make this easy. As for the Trigger assignment, try to remember what you did the two previous times.

Continue only after you're confident you set it up correctly. Don't hesistate to ask if you're having trouble here.

Interaction functions

Let's give us the ability to interact with Protocol.cs within the VR world. Add these functions:

Protocol.cs
private void Update()
{
    if (Trigger.state && isRunning)
    {
        isRunning = false;
    }
}

private bool IsStandingOnTarget(Vector2 targetPos)
{
    Vector3 pos3D = headCamera.position;
    Vector2 standingPos = new Vector2(pos3D.x, pos3D.z);

    return Vector2.Distance(standingPos, targetPos) < positionTolerance;
}

The Update() loop should be clear: if the trigger is pressed AND isRunning is already true, THEN turn the variable isRunning to false.

IsStandingOnTarget(Vector2 targetPos) takes a given target position (a 2D vector) and measures its distance to a 2D projection of the headCamera — if it's below the positionTolerance, it return true, otherwise false.

Scripted flow

Let's now put these variables and functions to work.

We will use the Start() call for that, but first change it from a basic private void type function, we will turn it into an IEnumerator — this way, it acts as a coroutine and we can stop and continue its execution using WaitWhile(), WaitUntil(), and WaitForSecondsRealtime() commands.

Replace the current Start() function with this:

Protocol.cs
 private IEnumerator Start()
{
    while (isRunning)
    {
        // Wait until user has moved onto square on the floor
        Vector3 standingMarkPos = standingMark.position;
        yield return new WaitWhile(() => !IsStandingOnTarget(new Vector2(standingMarkPos.x, standingMarkPos.z)));
        print("Stepped on the first square");
        standingMark.gameObject.SetActive(false); // Hide

        // Wait until user has touched the zeppelin
        yield return new WaitUntil(() => zeppelinColliderChckr.isColliding);
        print("Touched the Zeppelin");

        movingComp.interp = 0; // This triggers the start of Zeppelin's animation
        // Wait for moving animation to end
        yield return new WaitUntil(() => movingComp.isDone);
        print("Zeppelin's animation is done");

        // Move to center of the room
        yield return new WaitWhile(() => !IsStandingOnTarget(Vector2.zero));
        print("Stepped on the center square");

        // Wait until user has touched the cube
        yield return new WaitUntil(() => cubeColliderChckr.isColliding);
        rotatingComp.isRotating = true; // Start rotating cube
        print("Touched the cube");

        // Wait one second while it rotates
        yield return new WaitForSecondsRealtime(1f);
        rotatingComp.isRotating = false; // Stop rotating cube
        print("One second has passed");

        // RESET everything
        standingMark.gameObject.SetActive(true); // Show
        movingComp.transform.position = movingComp.startPoint;
        rotatingComp.transform.rotation = Quaternion.identity;
        print("Everything's reset!");
    }
}

You should be able to read this script and understand what it's doing. The various yield return new lines halt the execution of the function (stopping the advancement to the next lines) until their Wait clauses are fulfilled, as explained in the comments for every line.

Save the script, go back to Unity, and make sure the cube is not set to rotate already.

Run the game, and try to advance through the different steps as you can see in the protocol! The console will print updates on the user's progress through the steps.

Challenge: more triggering

For now, the controller's trigger isn't actually doing much other than interrupting the flow. Can you think of a way to use it more creatively here?