Cube Factory
What better example of automation than a factory? Let's create a cube factory!
We will be working in VR with the XR interaction toolkit again, so make sure you have followed the instruction at the start of exercise IV to set up a Unity project for VR or reuse that previous project after creating a new scene.
Create a new empty GameObject in the hierarchy ("Create Empty") and name it CubeFactory
. Place it at (-1, 0, -0.25)
.
Create a plane object, call it "floor", place it at (0, 0, 0)
with an adequate rotation and a reasonable scale.
Now, right-click on this object to create child objects inside: six new cubes. Their names, positions, and scales should be as follows.
To save time...
You can quickly enter these numbers by pressing the Tab key after entering each one, which will move the focus to the next field. You can also save time by omitting the zeros before the decimal points: the editor will automatically fill them in.
- "CubeRight"
- Position
(0, 0.9, 0)
- Scale
(0.5, 1.8, 0.25)
- Position
- "CubeLeft"
- Position
(0, 0.9, 0.5)
- Scale
(0.5, 1.8, 0.25)
- Position
- "CubeBack"
- Position
(-0.2, 0.9, 0.25)
- Scale
(0.1, 1.8, 0.25)
- Position
- "CubeSlope"
- Position
(0, 0.125, 0.25)
- Rotation
(0, 0, 55)
- Scale
(0.1, 0.6, 0.25)
- Position
- "CubeFront"
- Position
(0.2, 1.175, 0.25)
- Scale
(0.1, 1.25, 0.25)
- Position
- "CubeInteract"
- Position
(0.21, 1, 0.25)
- Scale
(0.1, 0.2, 0.2)
- Position
You should end up with a hollow cuboid that has an inclined opening at its lower end — somewhat like a chimney. "CubeInteract" should protrude just a bit:
Adding Text with TextMeshPro
Another very useful "3D Object" that we can add to the factory is Text - TextMeshPro. Create one under the GameObject CubeFactory, and give it the following parameters (it may ask you to import some resources the first time you do this — let it do so):
- In the Rect Transform component:
- Position =
(0.251, 1.25, 0.25)
- Width =
70
, Height =20
- Rotation =
(0, -90, 0)
- Scale =
(0.01, 0.01, 1)
- Position =
- Centered and middle alignment icons in its TextMeshPro - Text component
-
The following text instead of "Sample Text":
Cube Factory
Touch the button with the controller and press the trigger to create a new cube
You should now have a useful label for the cube factory:
Factory Interaction
For now, this is just an empty structure doing nothing. Let's change that with a bit of action.
Create a new script for the cube we called CubeInteract, named IsCollidingChecker
.
using UnityEngine;
public class IsCollidingChecker : MonoBehaviour
{
public bool isColliding;
public Color[] colors;
private Material _material;
void Start()
{
_material = GetComponent<MeshRenderer>().material;
_material.color = colors[0];
}
//Detect if the Cursor starts to pass over the GameObject
public void OnControllerEnter()
{
isColliding = true;
_material.color = colors[1];
}
//Detect when Cursor leaves the GameObject
public void OnControllerExit()
{
isColliding = false;
_material.color = colors[0];
}
}
The main difference is that it now exposes its boolean variable isColliding
to other objects. This variable is set to True
whenever it is in collision, and False
when it is not, while changing color at the same time.
Save the script and assign two colors of your choice to this component in the editor.
The OnControllerEnter and OnControllerExit functions are not called automatically at this stage. For the interaction to take place, we need to add the "XR Simple Interactable" component to the button (CubeInteract).
In this component, you will find interaction events; we are interested in the Hover events that correspond to the controller touching the cube closely (events triggered when the controller touches the cube, and then when it exits). Assign the CubeInteract after clicking on the small "+" symbol, then in the list on the right, choose the correct script and function to call.
By default, you can create a distant interaction between a controller and an object. If you want to allow only close interactions (touch), disable the "Enable Far Casting" option in the "Near-Far Interactor" components that are children of the controllers.
When running the game, you can now change the color of the CubeInteract object by hitting it with your controller, and you will also see its Is Colliding value change accordingly:
Factory Scripting
The factory still does not work as announced above this nice button. Let's change that and create some cubes.
Create a new script for the CubeFactory object itself, called… CubeFactory
.
Script
This will be a longer script, so from now on, we will only show parts of its code at a time — by now, you should be familiar with which parts belong where. Don't worry about the order of variables and functions: as long as they are in the correct scope (braces), it doesn't matter which appear first in your script.
We will use even more libraries now than before, so make sure to include all these using
statements at the top.
In the definition of the CubeFactory
class, declare the following variables:
public IsCollidingChecker isCollidingChecker;
private Stopwatch stopwatch;
public int cooldown = 500;
The last two will help us with timing: we only want to let the factory produce cubes at minimum intervals, so we create a Stopwatch
to measure the elapsed time, and a cooldown
variable that allows us to specify how long it should rest before producing another cube.
At the start of the game, we initialize the stopwatch
variable using the command new Stopwatch()
and call start()
immediately.
Cube Creation
Now let's create the function that will actually generate new cubes that interact with physics, and with random colors to boot!
public Material mat;
public GameObject CreateCube()
{
GameObject cubeGo = GameObject.CreatePrimitive(PrimitiveType.Cube);
Transform cubeTrans = cubeGo.transform;
cubeTrans.position = new Vector3(-1f, 0.9f, 0f);
cubeTrans.localScale = new Vector3(.15f,.15f,.15f);
MeshRenderer mr = cubeGo.GetComponent<MeshRenderer>();
mr.material = Instantiate(mat);
mr.material.color = Random.ColorHSV();
Rigidbody cubeRB = cubeGo.AddComponent<Rigidbody>();
// Add randomness otherwise cubes will all show the exact same trajectory
cubeRB.velocity = new Vector3(Random.value * .1f,-1,Random.value * .1f) * 5f;
return cubeGo;
}
Create a new material in your project (Material), leaving its default settings. Assign this material to the script in the editor.
When the CreateCube
function is called, it creates a new GameObject that we call cubeGo
by invoking the CreatePrimitive
function of the GameObject class, with PrimitiveType.Cube
as the input.
We take the transform of this new object and store a reference in a new Transform object called cubeTrans
, so that we can work directly on it in the next two lines, where we give it a new position and a new scale.
We assign a new color to the default material of cubeGo
, in this case a Random
combination of Hue, Saturation, and Value (brightness).
Finally, we add a Rigidbody component to it, to which we also assign a random velocity (within limits), to spice up their trajectories.
Since this function is declared not as void
, it is supposed to return something, in this case a GameObject
(see the first line of the code above — the function declaration). We obviously want to return
the cubeGo
object we just created, which is done in the last line.
Requesting New Cubes
We can now go to the Update()
loop and call our cube production function:
void Update()
{
if (stopwatch.Elapsed.Milliseconds >= cooldown) {
CreateCube();
stopwatch.Restart();
}
}
To keep our cube production under control, we first need to check that our stopwatch has measured enough milliseconds (more than our defined cooldown period). Only then will a cube be created, and the stopwatch restarted.
Save the code and see it run: the factory production is in full swing!
Issue with UnityEngine.InputSystem?
You may find that your code editor and Unity are complaining about a missing UnityEngine.InputSystem
. This can be easily fixed by going to the Package Manager and searching for InputSystem
in the Unity Registry. Install it, which will also prompt Unity to configure some settings and restart the editor, so make sure to save your changes before doing so.
Controlling Factory Output
To avoid a Trek Tribbles situation, we need to have some control over the factory's cube production.
We can add some additional conditions to our if
statement in the Update()
loop to only allow authorized personnel to initiate production:
private bool isButtonPressed;
public void toggleButtonState(bool state)
{
isButtonPressed = state;
}
void Update()
{
if (stopwatch.Elapsed.Milliseconds >= cooldown &&
isButtonPressed) {
CreateCube();
stopwatch.Restart();
}
}
The And
operator &&
can be used to chain logical statements. You can put everything inside the parentheses of the if()
statement on a single line, but this way, we preserve the readability of an otherwise heavy and long line.
Now, the if()
statement will execute its block if the timer has measured more than cooldown
, AND a collision is detected by the CubeInspect object, AND the button on the side ([5], grab) is pressed. If any of these conditions are not true, no cube will be produced. This should keep the factory floor safe.
Set up the events as follows in CubeInteract so that toggleButtonState
is called when the button is pressed:
Save the code, return to the editor, and assign the Is Colliding Checker component of the CubeInteract object to the script component of the cube factory.
Manual override
It is often good to include a manual override of the conditions as we did above for cube production.
This can be handy for debugging outside of VR.
You can extend the if()
statement with an additional clause, this time an OR operator ||
to make it look like this:
if (stopwatch.Elapsed.Milliseconds >= cooldown
&& isButtonPressed
|| Keyboard.current.spaceKey.wasReleasedThisFrame)
Now, the only condition necessary to trigger the production of the next cube is that the user presses the space key on the computer running Unity (while focused on the game view), bypassing all other rules. Cubes will now be produced as fast as you can press the space bar — don’t break it!
Cube removal
Even with controlled production, cubes still accumulate over time. Let’s clean them up.
Destroyer script
Create a new script in the scripts folder without attaching it to any GameObject and name it DestroyOnTouch
. We want it to come into action whenever our mouse is over a freshly produced cube to make them disappear into oblivion.
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
using UnityEngine.XR.Interaction.Toolkit.Interactables;
using System.Collections;
public class DestroyOnTouch : MonoBehaviour
{
private void Start()
{
XRSimpleInteractable interact = gameObject.AddComponent<XRSimpleInteractable>();
interact.hoverEntered.AddListener(DestroyCube);
}
public void DestroyCube(HoverEnterEventArgs eventArgs)
{
Destroy(gameObject);
}
}
The function DestroyCube()
will simply call the Destroy()
command on gameObject
itself, which refers to the GameObject in which this script operates. As it stands, the code remains in our Scripts folder and does nothing, so let's attach it to all the freshly produced cubes by our CubeFactory.
The function Start()
now contains code to procedurally add the callback linking "touching a cube" to "calling DestroyCube()
". We cannot create this link in the editor in this case since our cubes do not exist yet.
Attaching the destroyer
To attach this script (make sure to save it first!) to the new cubes, we simply need to modify the CreateCube()
function in CubeFactory
:
public Material mat;
public GameObject CreateCube()
{
GameObject cubeGo = GameObject.CreatePrimitive(PrimitiveType.Cube);
Transform cubeTrans = cubeGo.transform;
cubeTrans.position = new Vector3(-1f, 0.9f, 0f);
cubeTrans.localScale = new Vector3(.15f,.15f,.15f);
MeshRenderer mr = cubeGo.GetComponent<MeshRenderer>();
mr.material = Instantiate(mat);
mr.material.color = Random.ColorHSV();
Rigidbody cubeRB = cubeGo.AddComponent<Rigidbody>();
// Add randomness otherwise cubes will all show the exact same trajectory
cubeRB.velocity = new Vector3(Random.value * .1f,-1,Random.value * .1f) * 5f;
cubeGo.AddComponent<DestroyOnTouch>();
return cubeGo;
}
The added line at the bottom shows how the DestroyOnTouch
(publicly known) is added as a component to each new cubeGo
object, just before it is returned.
Save this code and try it out:
Making it pop
Let’s turn the chore of cleaning up cubes into a more fun activity by adding a little pop.
As immersive as virtual reality is, you can still enhance it by engaging additional senses, like our hearing. Unity is of course capable of audio playback, so let’s make it play a short sample every time we destroy a cube, like a simple "pop" sound.
Where to find sound samples?
If you are not inclined to record or synthesize your own audio, the popular site freesound.org is an excellent free source for all kinds of audible material.
To save you time for now, you can also download this .mp3 clip and use it: pop.mp3
Create a new folder in Assets called Resources
, and store your audio sample there, renaming it to pop.mp3
if it is not already called that.
Return to our CreateCube()
function and add two lines:
public GameObject CreateCube()
{
GameObject cubeGo = GameObject.CreatePrimitive(PrimitiveType.Cube);
Transform cubeTrans = cubeGo.transform;
cubeTrans.position = new Vector3(-1f, 0.9f, 0f);
cubeTrans.localScale = new Vector3(.15f,.15f,.15f);
cubeGo.GetComponent<MeshRenderer>().material.color = Random.ColorHSV();
Rigidbody cubeRB = cubeGo.AddComponent<Rigidbody>();
// Add randomness otherwise cubes will all show the exact same trajectory
cubeRB.velocity = new Vector3(Random.value * .1f,-1,Random.value * .1f) * 5f;
cubeGo.AddComponent<DestroyOnTouch>();
AudioSource cubeAS = cubeGo.AddComponent<AudioSource>();
cubeAS.clip = Resources.Load<AudioClip>("pop");
return cubeGo;
}
With this, we are adding another component to the new cubes, this time an AudioSource
, and loading our .mp3 file into its clip
component using Resources.Load<AudioClip>("pop")
.
This last call will actually search in the Resources folder we created above for all audio files named "pop" before their file type extension, which is why it was important to name our clip "pop.mp3".
Return to our DestroyOnTouch
script to modify it. We will add a new private
variable called _audioSource
, and retrieve it from the component that the factory is now adding to the new cubes using the Start()
function.
public class DestroyOnTouch : MonoBehaviour
{
private AudioSource _audioSource;
private void Start()
{
XRSimpleInteractable interact = gameObject.AddComponent<XRSimpleInteractable>();
interact.hoverEntered.AddListener(DestroyCube);
_audioSource = GetComponent<AudioSource>(); // Last part of the tutorial (with pop sound added)
}
}
Now, let’s change the rest of DestroyOnTouch
to actually play the sound upon destruction:
public void DestroyCube(HoverEnterEventArgs eventArgs)
{
// Destroy(gameObject);
StartCoroutine(PlayAudioThenDestroy()); // Last part of the tutorial (with pop sound added)
}
private IEnumerator PlayAudioThenDestroy()
{
// Hide object
Destroy(GetComponent<MeshRenderer>());
// Delete collider component to prevent calling this coroutine twice
Destroy(GetComponent<Rigidbody>());
// Play pop sound
_audioSource.Play();
yield return new WaitUntil(() => !_audioSource.isPlaying);
// Actually destroy the object now
Destroy(gameObject);
}
In DestroyCube()
, we comment out our previous line that simply destroys the object (making it inert) and instead add a call to StartCoroutine()
on the IEnumerator
block that we declare below.
Coroutines
Coroutines are a very powerful feature of Unity (and a computer science concept in general), which allow for deferred execution of code without blocking the entire system — thus enabling parallel execution of multiple behaviors.
This will be explored in more detail in the next exercise, but feel free to try to understand the PlayAudioThenDestroy()
function here from its context and comments.
Save the code, run the game. It works!