Skip to content

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.

Installing the Input System Package

This may trigger a warning and a prompt to restart the Unity Editor. Allow it:

Warning

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

Signpost object

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.

Signpost prefab

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:

SignpostData.txt
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:

LoadFromResources.cs
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:

LoadFromResources.cs
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():

LoadFromResources.cs
// 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:

Thread.CurrentThread.CurrentCulture = System.Globalization.CultureInfo.InvariantCulture;

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:

LoadFromResources.cs
// 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:

Wooden signpost

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:

playlists.txt
0,1,2,3,4,5
5,4,3,2,1,0

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:

LoadFromExternalFolder.cs
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:

LoadFromExternalFolder.cs
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:

SaveToExternalFolder.cs
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:

SaveToExternalFolder.cs
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!