Saving, Loading, and Writing
Let's see how we have Unity create new files by itself, like we would do for recording data from our experiments.
New scene
We still don't need VR specifically at this stage so you can stay on the previous Unity project. Simply create a new "scene". In the "Project" view, go to the "Scenes" sub-directory, right-click in there then navigate "Create > Scene > Scene". Give it a name, then double-click the scene file to load it. The previous scene may ask to be saved before unloading and loading the new one.
We're going to need the functionality of accessing a user's input. We will rely on a package called Input System that makes handling mouse and keyboard inputs easy. It normally comes install in new project by default in Unity 6. If it is not installed go to the Package Manager to install it.
This may trigger a warning and a prompt to restart the Unity Editor. Allow it:
We should now be all set to get going with this new scene!
Set the scene
Let's add some objects to the scene.
A signpost
Create an empty GameObject and name it "Signpost", making sure it sits at the world origin (Position =(0,0,0)
).
Right-clicking on this object, create two new cubes as its children with the following parameters:
- First cube:
- Name = "Post"
- Position =
(0,.5,0)
- Scale =
(.09,1,.09)
- Second cube:
- Name = "Sign"
- Position =
(0,1.3,0)
- Scale =
(.1,.7,1)
Now add a Text - TextMeshPro (in "3D Object") object as a child to sign object in the hierarchy and give it the following parameters (if Unity asks you click "Import TMP Essentials"):
- Width = 40, height = 30
- Pos X, Y, Z =
(0.55,0,0)
- Rotation =
(0,-90,0)
- Scale =
(.02,.02,1)
- Give its text a centered alignment
Prefabs
Let's now turn this signpost into a Prefab — a reusable, preset object that can be easily re-created by Unity.
Make a new Resources
folder within our assets, and simply drag our whole Signpost GameObject from the hierarchy directly in it — a prefab with its parameters is created, giving our Signpost a blue icon!
The Resources
folder
The Resources
folder is a special folder for Unity. Its name must be exactly written like this so it is considered a repository of assets that will bundled with our project when built, and accessible via a set of functions.
Remove the Signpost now from our hierarchy — we will use its prefab later.
Reading files: texts and textures
Let's get Unity to read some external files to work with in our project — in our case to spice up the appearance of our signpost.
Create a text file inside the Resources folder: right-click it, the click "Show in explorer" (or "Reveal in Finder" on a Mac device), there create a new text file and use any text editor to do what comes next.
Name it SignpostData.txt
, and give it the following contents:
pos:-2,0,-1
col1:0.9,0.67,0.42
col2:1,1,1
txt:<i>NOTICE</i><br>Some text<br>To display<br>for information
As you may guess, we will use this to put text on the signpost and adjust its position.
To give it some texture instead of the elegant but simple white, download this image and also place it into Resources, renaming it SignpostTexture.jpg
in the process.
Create a Scripts folder inside Assets (there shouldn't be one there yet), make a new script there called LoadFromResources
("Create > Scripting > Monobehaviour Script") and attach it to our Floor object as a component:
using System.Collections.Generic;
using System.Linq;
using TMPro;
using UnityEngine;
public class LoadFromResources : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
// Fill this next!
}
}
For now it doesn't do anything, so let's give is some functionality — all within its Start()
method, step by step.
Create variables that can hold a GameObject, a string, and a Texture2D:
GameObject signpostPrefab = Resources.Load<GameObject>("Signpost");
string signpostData = Resources.Load<TextAsset>("SignpostData").text;
Texture2D signpostTexture = Resources.Load<Texture2D>("SignpostTexture");
These variables are immediately assigned the output of their respective Resources.Load<Type>("File name")
methods, which look for files with a specific name within the Resources folder we created before, reads them, and converts them into the specified type: a GameObject fashioned after the Signpost prefab, a Texture2D from our supplied picture, and a string from our SignpostData.txt file.
This last variable, the signpostData
string is still somehow vague in its use, so let's change that with a parsing function. Add it right after the previous code block, still inside Start()
:
// Parse text file to get custom signpost data
Dictionary<string, string> SPdataParsed = new Dictionary<string, string>();
foreach (string line in signpostData.Split('\n'))
{
if (line.Length == 0) continue;
// Split each line by key:value and store in dictionary
string[] lineSplit = line.Split(':');
SPdataParsed.Add(lineSplit[0], lineSplit[1]);
}
This creates a Dictionary
from our text file, taking the colon (:) as a separator for each line: the left part of a line before it (e.g., pos
) becomes a key and the right part (e.g., -2,0,-1
) the value.
Parsing issues
Depending on your operating system's language settings, the parser may mess up this format: in some languages, a comma is used in place of the decimal point in mixed numbers, etc. — this can cause problems when parsing strings.
A possible solution is to set the CultureInfo.InvariantCulture
default, as explained in this Stack Overflow entry. You need to include another library that your script uses: using System.Threading;
, which will allow you to add this line to the beginning of your Start()
function:
Instantiating a prefab
We can now use the information gotten from our signpostData.txt file to instantiate Signpost objects from the Signpost prefab and assign to them parameters that we store in our SPdataParsed
dictionary. Still in the Start()
function, after the previous bit:
// Instantiate prefab as a new gameobject
GameObject signpostGo = GameObject.Instantiate(signpostPrefab);
Transform signpostTrans = signpostGo.transform;
// Set signpost world position
float[] posRaw = SPdataParsed["pos"].Split(',').Select(float.Parse).ToArray();
signpostTrans.position = new Vector3(posRaw[0], posRaw[1], posRaw[2]);
MeshRenderer[] meshRenderers = signpostGo.GetComponentsInChildren<MeshRenderer>();
// Set colour of first child in signpost object (post)
float[] col1Raw = SPdataParsed["col1"].Split(',').Select(float.Parse).ToArray();
meshRenderers[0].material.color = new Color(col1Raw[0], col1Raw[1], col1Raw[2]);
// Set colour of second child in signpost object (sign)
float[] col2Raw = SPdataParsed["col2"].Split(',').Select(float.Parse).ToArray();
meshRenderers[1].material.color = new Color(col2Raw[0], col2Raw[1], col2Raw[2]);
meshRenderers[0].material.mainTexture = signpostTexture;
meshRenderers[1].material.mainTexture = signpostTexture;
// Set text
TextMeshPro signpostTMP = signpostGo.GetComponentInChildren<TextMeshPro>();
signpostTMP.text = SPdataParsed["txt"];
These last lines of the Start()
function should mostly speak for themselves, even the highlighted operations performed the string values from in the dictionary, though they might contain so far unseen methods like Split()
and Select()
— try to understand what they do (the links should help).
Save the now completed "LoadFromResources" script, assign it to an empty object in your scene, then run the game. You will now see a wooden signpost with some text on it appear at the coordinates given in the text file! It even changed the colors of the different parts of the post, making it even more realistic. You may need to move the camera bit too see it well, though:
Challenge: moving the camera, too
Can you apply the same method to also move the camera to a new different position?
You would need to add a new line to the text file, and amend your parser to turn this instruction into action.
Playlists
Our LoadFromResources
script has done its job — disable or remove it from the Floor object.
Lets get a bit more fancy and create a playlist to sequence events; in this case we want a number of images to load and show up on the scene, one after another.
Placing external resources
Create a new folder outside the Assets folder (root directory of your project) called ExternalData
. Within that new folder, place a new text file called playlists.txt
with exactly the following content:
Form vs content
The format is important here, meaning the number of items in one line, them being separated by commas, etc.
You can change the order of the numbers, add more lines in the same format, etc. — this flexibility is the whole point of creating a simple playlist file format like this!
Within the ExternalData folder, create a new folder called images
. Download the six images from here (1, 2, 3, 4, 5, 6) and place them inside images
in the project browser — make sure to keep their filenames intact (img_00.jpg
, etc.).
Parsing and playing playlists
We want these images to displayed on a Quad 3D object in the scene, so create one in the hierarchy with the following parameters:
- Name =
DisplayQuad
- Position =
(-1.5,1.3,0)
- Rotation =
(0,-90,0)
- Scale =
(1.92,1.08,1)
- 1.92 and 1.08 — multiply by 1,000 and you get 1920 and 1080. Looks familiar?
Additionally create a Plane 3D object called "floor" at position (0,0,0)
.
Create a new script for the floor named LoadFromExternalFolder
to put them into action:
using System.Collections;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
public class LoadFromExternalFolder : MonoBehaviour
{
public static string dirpathname = "ExternalData";
public static string dirpath;
public MeshRenderer displayQuadMR;
private Material displayQaudMat;
public int subj_index = 0;
private int[] playlist;
public Texture2D LoadImageAsTexture(string filePath)
{
// Load raw data from file
byte[] fileData = File.ReadAllBytes(filePath);
Texture2D tmpTex = new Texture2D(1920, 1080, TextureFormat.RGB24, false);
tmpTex.LoadImage(fileData);
return tmpTex;
}
}
Assumptions
The LoadImageAsTexture()
function makes a lot of assumptions here. First, we assume that there is a valid file path stored in filePath
at all, and not just gibberish. Then we further assume that the data at that location is an image that can be loaded in a texture, even with the specific dimensions 1920, 1080
and in RGB24
color format.
While getting the dimensions and color format wrong wouldn't cause that much trouble other than a potentially garbled image, trying to read a file that's not there can cause the game to crash!
Usually, you would guard against this by first checking if the file exists at all, and wouldn't you know it, there's a function for that: File.Exists()
.
To be on the safe side, you can check the result of File.Exists(filePath)
in an if()
statement and only proceed with loading if it's positive. Keep in mind that you still have to return some Texture2D
and can't just return from you function with nothing to show for…
All this to say: Playing it safe is not for lazy people, but it can sure give you a feeling of security (and save you from some crashes).
Being very similar in structure to our previous script, it should be quite clear what it does. The difference so far are the variables to hold our Quad's MeshRenderer and Material, and and index to keep track of our subject. Let's use these in the Start()
method:
IEnumerator Start()
{
displayQaudMat = displayQuadMR.material;
dirpath = Directory.GetParent(Application.dataPath).ToString() + Path.DirectorySeparatorChar + dirpathname;
// image order playlist
// A line represent the order to display images
// There is one line per subject
// Load entire image order playlist file segmened by line return
playlist = File.ReadAllLines($"{dirpath}/playlists.txt")
// Skip all lines to go to the one we want
.Skip(subj_index)
// Get one line
.Take(1).First()
// Split line by comma
.Split(',')
// Convert string to int
.Select(int.Parse).ToArray();
for (int ipl = 0; ipl < playlist.Length; ipl++)
{
int iImg = playlist[ipl];
displayQaudMat.mainTexture = LoadImageAsTexture($"{dirpath}/images/img_{iImg:D2}.jpg");
yield return new WaitForSeconds(2f);
}
Application.Quit();
#if UNITY_EDITOR
EditorApplication.isPlaying = false;
#endif
}
Use of Application.dataPath
The propertyApplication.dataPath
returns the path to the Asset folder of your project.
By using Directory.GetParent(Application.dataPath)
we go up to its parent, i.e. the root of your project.
Save the script, make sure its attached as a component to our floor, and assign our Quad to the appropriate field.
It will now cycle through the images it loads in the given sequence read from the playlists file, which we can specify in the components inspector with the Subj_index field (starting with 0), and then quit the game!
Preprocessor Directives — Conditional Compilation in Unity
There are specially highlighted lines in the code snipped above which start with the number sign #
, called Preprocessor Directives in C#.
They are used for Conditional Compilation — typical uses are to let Unity check if the game is running in the editor or as a compiled standalone application, or what the current operating system is. This is important for dertain functions which may behave differently in different conditions, and using these directives you can choose which blocks of code to use for which scenario.
Here, we issue the command EditorApplication.isPlaying = false
only if we run it inside the editor to end the game — this wouldn't work in a standalone application, where Application.Quit();
would be required to get the same result.
Writing to files
Let's now turn the tables on our file system and write something to it, instead of only reading!
Deactivate the display Quad, deactivate the LoadFromExternalFolder component from our Floor, and create yet another new script for it. Call it SaveToExternalFolder
:
using System;
using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.InputSystem;
public class SaveToExternalFolder : MonoBehaviour
{
public static string dirpathname = "ExternalData/subjData/";
public static string dirpath;
public int subj_index = 0;
private StreamWriter expeDataWriter;
public static long GetTimestamp()
{
return DateTimeOffset.Now.ToUnixTimeMilliseconds();
}
private void OnApplicationQuit()
{
expeDataWriter.Flush();
expeDataWriter.Close();
}
}
For now it only contains the now-familiar variables to hold the names of file paths and subject index, as well as a StreamWriter
and a simple getter method that returns a time stamp using the DateTimeOffset
structure.
There is now also a method called OnApplicationQuit()
— similar to how Start()
is automatically executed by Unity when you press the Play button, this one is run once just as the game is stopped, be it by pressing the play button again or quitting the final built (binary executable).
This is important if you're writing to external files: the Flush()
and Close()
commands make sure that all potentially unwritten contents of the StreamWriter are flushed out to the file, and that the file is then properly closed — this is just basic file system hygiene, perhaps not too dissimilar from the one you practice yourself.
Let's put these things together in a Start()
IEnumerator:
IEnumerator Start()
{
dirpath = Directory.GetParent(Application.dataPath).ToString() + Path.DirectorySeparatorChar + dirpathname;
Directory.CreateDirectory($"{dirpath}");
expeDataWriter = new StreamWriter($"{dirpath}/subj_{subj_index}.txt");
while (true)
{
yield return new WaitUntil(() => Keyboard.current.anyKey.wasReleasedThisFrame);
for (int ik = 0; ik < Keyboard.KeyCount; ik++)
{
string keyName = Keyboard.current.allKeys[ik].ToString();
bool keyState = Keyboard.current.allKeys[ik].wasReleasedThisFrame;
if (!keyState) continue;
string message = $"{GetTimestamp()}: user released \"{keyName}\"";
expeDataWriter.WriteLine(message);
print(message);
}
}
}
First, it creates a new directory at the constructed dirpath
if it doesn't exist yet, then instantiates a new StreamWriter
with a new file, which itself is constructed from the dirpath
and the subj_index
using String Interpolation (the $
sign). Try to understand how the final file name and directory are being assembled!
Then an endless loop (while (true)
) is being run that keeps waiting for the user to hit (or rather release after hitting) keys on the keyboard using WaitUntil
and the new Unity Input System in the shape of Keyboard.current.anyKey.wasReleasedThisFrame
.
It then looks at which keys were being pressed and puts their names in a string. As we can't easily access the exact key that was pressed, we just write down all current keys (Keyboard.current.allKeys
). We then check if one of these current keys was just released in the last frame — if not, we go to the next iteration of this loop with the continue
jump statement.
If the key was just released, we prepare a message including the name of the key and the current time stamp (from our GetTimeStamp()
method), and hand it over to the expeDataWriter
to write a line in our external file using WriteLine()
. Additionally, we print
this message to the console for debugging.
And that's it for external files!
Warning
If you are using an autonomous XR headset running Android then you cannot use Application.dataPath
. It will not make sense in that context.
Instead use Application.persistentDataPath
.
Challenge: elapsed time
Instead of writing the time stamp (the absolute, real-world time), how about writing down the time that has elapsed between two key releases? To calculate that, you would need to keep track of the time the last key was pressed and subtract that from the current time. Give it a try if you want!