Lab — Virtual reality engineering and debugging
Develop a structured, instrumented VR application that is easy to debug, applying Unity/XR engineering best practices.
Objective
This lab (~7 hours) aims to teach you how to develop and maintain a VR application with:
- a clear architecture (separation of responsibilities, explicit dependencies),
- an efficient iteration workflow (test as much as possible without a headset, debug builds),
- basic observability (VR overlay, structured logs),
- XR debugging practices,
- an introduction to VR profiling (optional, depending on the time remaining).
The application is a VR solar system visualization workbench: a manipulable tabletop model, a world-space UI, viewing modes and time controls.
Prerequisites
Skills
- C# / Unity basics (scenes, prefabs, scripts)
- Git basics (commit, tag, push)
- VR basics (comfort, world-space UI) are helpful but not essential
Tools
- Unity LTS (2022.3 or 2023 LTS)
- Unity packages:
- OpenXR
- XR Interaction Toolkit (XRI)
- Input System
- An OpenXR-compatible VR headset
Resources
- PlanetData.cs (provides data and orbital calculation functions)
Engineering rules (required)
In this lab, you must explicitly apply the following rules:
No implicit dependencies
- No
FindObjectOfType, noGameObject.Find, no “magic” dependencies. - Required references are injected through the Inspector or a bootstrapper.
- No
No business logic in
Update()by default- Reserve
Update()for continuous operations that are actually necessary. - Prefer events, actions and controlled timers.
- Reserve
Separation of responsibilities
- Data models / services (calculations, time, conversion) ≠ views (GameObjects) ≠ controllers.
Debuggability
- Every critical behavior must produce useful logs.
- Implement a DebugOverlay visible in the headset.
Performance (optional)
- No avoidable per-frame allocations (strings/LINQ).
- Profiling is required (before/after evidence for one improvement).
Scenario: Solar System VR Workbench
You will develop a VR application containing:
- a solar system tabletop model (with adjustable scale),
- planets positioned at a given time t (calculation functions are already provided by
PlanetData), - time controls (date + speed),
- orbits that can be shown or hidden,
- a “planet focus” mode (centering/inspection),
- a world-space VR UI to control the application,
- a debug overlay (FPS, state, recent events, warnings).
Lab structure
The lab consists of 5 main steps and 2 optional extensions.
Required:
1 — XR boot
2 — Architecture
3 — Events / simulation
4 — VR interactions
5 — UI + debug overlay
Optional (if time allows):
6 — Guided XR debugging
7 — Performance optimization
Submission organization (Git)
You must submit:
- a Git repository (link),
- Git tags for each step (v1.0, v1.1, … v2.0),
- a short report (README or PDF):
- screenshots (headset view or mirror),
- implemented features,
- architecture diagram (simple and readable),
- a “debug story” (if you completed step 6),
- a “perf story” (if you completed step 7).
Tag reminder
At each step:
git add .git commit -m "…"git tag -a v1.x -m "…"git push origin maingit push origin --tags
Useful concepts for this lab
Before starting, here are some important concepts we will use throughout the session. They are common in Unity projects and even more important in XR.
1 — Business logic
Business logic means the rules that define how your application works, independently of its visual presentation.
Examples in this lab:
Business logic:
- calculating a planet’s position at a given date
- changing the speed of time
- showing or hiding orbits
- changing the solar system’s scale
Display (not business logic):
- moving a GameObject
- changing UI text
- showing a panel
Bad example
void Update()
{
planet.transform.position = PlanetData.ComputePosition(DateTime.Now);
}
Calculation and display are mixed together.
Good example
DateTime currentTime;
void OnTimeChanged(DateTime t)
{
currentTime = t;
UpdateView();
}
The logic decides when to update; the view decides how to display the result.
2 — Separation of responsibilities
In a Unity project, it is very easy to put a lot of functionality in a single script. This works for small projects but quickly becomes difficult to maintain.
We therefore separate the roles.
Simple structure used in this lab
Data / Model ←→ Services ←→ Controllers ←→ Views
Model (data)
Contains only data.
Example:
TimeModel
- currentTime
- timeSpeed
A model normally does not depend on Unity.
Services
Contain calculations or rules.
Example:
PlanetEphemerisService
- computePosition()
Controllers
Coordinate the application.
They receive events and update views.
Example:
SolarSystemController
Views
Visible Unity objects.
Examples:
- PlanetView
- OrbitRenderer
- UI panels
Summary
- Model → store state
- Service → calculate
- Controller → decide what to do
- View → display
3 — Bootstrapper
The problem
Many Unity projects contain:
- FindObjectOfType
- GameObject.Find
- Singletons used everywhere
These approaches work but make the project fragile.
A simple solution
Create an object that initializes the application.
This is called a Bootstrapper.
Example:
AppBootstrapper
Its role:
- create services
- connect objects
- initialize data
Simple example
public class AppBootstrapper : MonoBehaviour
{
public SolarSystemController controller;
void Start()
{
var timeModel = new TimeModel();
controller.Init(timeModel);
}
}
Dependencies become visible and controlled.
4 — Events
Events allow different objects to communicate without being tightly coupled.
Without events:
UI → Manager → Controller → Camera
Everything depends on everything else.
With events:
UI → Event → Interested objects
Each system remains independent.
Example
public event Action<DateTime> OnTimeChanged;
When the date changes:
OnTimeChanged?.Invoke(newTime);
Another script can listen for this event:
void Start()
{
planetManager.OnTimeChanged += UpdatePosition;
}
5 — Actions
An Action is simply a function stored in a variable.
Example:
Action<DateTime> callback;
In Unity, actions are used for:
- events
- callbacks
- UI interactions
6 — Managing simulation state
In this application, the central variable is not really a technical “timer”. What matters is simply the simulation date.
In other words, we manipulate a global state:
Simulated date → planet positions → display
When this date changes:
- planet positions must be recalculated
- some visualizations must be updated
- the interface can display the new value
Instead of constantly recalculating in Update(), trigger an event only when the state changes.
Bad approach
void Update()
{
UpdatePlanets(currentDate);
}
Each object constantly checks whether something has changed.
Recommended approach
Use an event to signal a state change.
public event Action<DateTime> OnTimeChanged;
When the date changes:
UI → PlanetManager → event → interested objects
Example:
void Start()
{
PlanetManager.current.OnTimeChanged += UpdatePosition;
}
This provides:
- fewer unnecessary calculations
- more readable code
- easier debugging
- better VR performance
7 — Why this matters even more in XR
In VR and XR:
- builds take longer
- bugs are harder to diagnose
- performance is critical
A clear architecture makes it possible to:
- test some parts without a headset
- understand a bug quickly
- modify an interaction without breaking the whole project
8 — ScriptableObject
A ScriptableObject is a Unity asset used to store editable parameters.
Unlike a MonoBehaviour, it is not attached to a GameObject.
Example in this lab:
[CreateAssetMenu(menuName = "XR/Solar System Config")]
public class SolarSystemConfig : ScriptableObject
{
public float distanceScale;
public float planetSizeScale;
public bool showOrbits;
}
Then create the asset in Unity:
Assets → Create → XR → Solar System Config
Assign it in the Inspector.
Benefits:
- centralized configuration
- editable without changing code
- reusable across scenes
What to remember
Throughout the lab, keep asking yourself:
- Does this script do too many things?
- Does this code depend on another object unnecessarily?
- Can I understand this system quickly?
- Could I easily debug this on a VR headset?
If the answer to either of the last two questions is “no”, you probably need to simplify or separate responsibilities more clearly.
Step 1 — XR boot + minimal scene (Tag: v1.0) — ~45 min
Objectives
- Have a working VR scene and a debuggable build pipeline.
Tasks
- Create the Unity project.
- Install and enable:
- OpenXR
- XR Interaction Toolkit
- You can add the Starter Assets (for a complete XR rig) and the XR Simulator (to test without a headset) from the package samples.
- Input System
- Configure the project:
- Enable the plug-in providers required for your headset (OpenXR, Oculus, etc.) in Player Settings / XR Plug-in Management.
- Add a VR rig (XRI):
- XR Origin
- XR Interaction Manager
- Create a
Bootscene:- ground + reference frame (for example, X/Y/Z axes to show the world origin in the scene)
- an interactable cube (grab) to validate XRI
- Configure a debug build:
- Development Build
- Script Debugging
- (if possible) Autoconnect Profiler
Expected deliverables
- Screenshot (headset mirror view or XR simulator) showing the cube being grabbed in VR
- Repository tagged
v1.0-boot(remember the .gitignore)
Step 2 — Architecture: models, services, controllers
Tag: v1.1-architecture — ~1h15
Objective
Before adding VR, interactions and UI, we will structure the application properly.
In XR projects, problems often come from:
- scripts referencing each other in every direction
- logic mixed with display code
- invisible dependencies
- code that cannot be tested without a headset
We will build a simple architecture:
Bootstrapper
↓
Models / Config
↓
Services
↓
Controllers
↓
Views (Unity)
1 — Folder organization
Create the following folders:
Scripts
├── Bootstrap
├── Models
├── Services
├── Controllers
├── Views
└── Config
Purpose:
| Folder | Role |
|---|---|
| Bootstrap | entry point |
| Models | simulation state |
| Services | calculations |
| Controllers | coordination |
| Views | Unity GameObjects |
| Config | editable parameters |
2 — The time model
In our simulation, the central variable is simulated time.
Create:
Scripts/Models/TimeModel.cs
using System;
public class TimeModel
{
public DateTime CurrentTime { get; private set; }
public float TimeScale { get; private set; } = 1f;
public bool IsPlaying { get; private set; } = true;
public event Action<DateTime> OnTimeChanged;
public void SetTime(DateTime t)
{
CurrentTime = t;
OnTimeChanged?.Invoke(CurrentTime);
}
public void SetScale(float scale)
{
TimeScale = scale;
}
public void Play()
{
IsPlaying = true;
}
public void Pause()
{
IsPlaying = false;
}
}
Important:
- this script does not depend on Unity
- it contains only state and events.
3 — Solar system configuration
Some parameters must be editable in Unity.
Create:
Scripts/Config/SolarSystemConfig.cs
using UnityEngine;
[CreateAssetMenu(menuName = "XR/Solar System Config")]
public class SolarSystemConfig : ScriptableObject
{
public float distanceScale = 0.000001f;
public float planetSizeScale = 0.01f;
public bool showOrbits = true;
}
Then create the asset:
Assets → Create → XR → Solar System Config
4 — Using the provided astronomical data
The lab already provides a class:
PlanetData.cs
It contains:
- orbital parameters
- orbit calculations
- the following function:
PlanetData.GetPlanetPosition(PlanetData.Planet planet, DateTime time)
⚠️ This class is the official source for planet positions. Students must not modify this file.
5 — Planet position service
Even though the function already exists, we access it through a service.
Why?
- keep the architecture clean
- allow the implementation to be replaced later
- make testing easier
Create:
Scripts/Services/IPlanetEphemerisService.cs
using System;
using UnityEngine;
public interface IPlanetEphemerisService
{
Vector3 GetPlanetPosition(PlanetData.Planet planet, DateTime date);
}
Implementation:
Scripts/Services/PlanetEphemerisService.cs
using System;
using UnityEngine;
public class PlanetEphemerisService : IPlanetEphemerisService
{
public Vector3 GetPlanetPosition(PlanetData.Planet planet, DateTime date)
{
return PlanetData.GetPlanetPosition(planet, date);
}
}
This service acts as an abstraction layer.
6 — Planet view
Create:
Scripts/Views/PlanetView.cs
using UnityEngine;
public class PlanetView : MonoBehaviour
{
public PlanetData.Planet planet;
public void SetPosition(Vector3 pos)
{
transform.localPosition = pos;
}
}
Role:
- represent a planet in the scene
- apply the calculated positions
7 — Solar system controller
The controller connects:
- the model
- the service
- the views
Create:
Scripts/Controllers/PlanetSystemController.cs
using System;
using UnityEngine;
public class PlanetSystemController
{
TimeModel timeModel;
IPlanetEphemerisService ephemeris;
PlanetView[] planets;
public PlanetSystemController(
TimeModel timeModel,
IPlanetEphemerisService ephemeris,
PlanetView[] planets)
{
this.timeModel = timeModel;
this.ephemeris = ephemeris;
this.planets = planets;
timeModel.OnTimeChanged += UpdatePlanets;
}
void UpdatePlanets(DateTime time)
{
Debug.Log("[TIME] Updating planets " + time);
foreach (var planet in planets)
{
Vector3 pos =
ephemeris.GetPlanetPosition(planet.planet, time);
planet.SetPosition(pos);
}
}
}
Responsibilities:
| Element | Role |
|---|---|
| Model | simulation state |
| Service | position calculation |
| Controller | coordination |
| View | display |
8 — Bootstrapper
Create:
Scripts/Bootstrap/AppBootstrapper.cs
using UnityEngine;
using System;
public class AppBootstrapper : MonoBehaviour
{
public SolarSystemConfig config;
public PlanetView[] planets;
TimeModel timeModel;
PlanetSystemController controller;
void Start()
{
Debug.Log("[BOOT] Initializing application");
timeModel = new TimeModel();
var ephemeris = new PlanetEphemerisService();
controller = new PlanetSystemController(
timeModel,
ephemeris,
planets
);
timeModel.SetTime(DateTime.Now);
}
}
Role:
- create the main objects
- connect dependencies
- start the simulation
This is called the composition root.
9 — Scene setup
Create a GameObject:
App
Add:
AppBootstrapper
In the Inspector:
- assign
SolarSystemConfig - assign the
PlanetViewreferences
Possible structure:
App
SolarSystem
├ Sun
├ Mercury
├ Venus
├ Earth
├ Mars
├ Jupiter
├ Saturn
├ Uranus
└ Neptune
Each planet has a:
PlanetView
10 — Quick test
Run the scene.
Expected result:
- the planets appear
- they are positioned correctly
- a
[BOOT]log appears
If nothing happens, check that:
- the PlanetView references are assigned
- the bootstrapper is active
- the scene is saved
If you follow the steps carefully, you should notice a small bug. Modify the service to fix it.
11 — Architecture check
Your code must meet these requirements:
- no
FindObjectOfType - no
GameObject.Find - visible dependencies
- a model independent of Unity
- logic separated from display
12 — Architecture diagram
Expected result
At the end of this step:
- the application is structured
- the planets use
PlanetData - dependencies are explicit
- the simulation is driven by a model
Git tag:
v1.1-architecture
Step 3 — Planet positioning + events
Tag: v1.2-events — ~1h15
Objective
Implement the main simulation flow:
- time changes,
- an event is triggered,
- the controller updates the planets.
We deliberately avoid running all the logic in Update().
Expected flow:
TimeController
↓
TimeModel
↓
OnTimeChanged
↓
PlanetSystemController
↓
PlanetView
We already created most of these elements in the previous step. Now we need to connect them properly using events.
Responsibilities:
| Element | Role |
|---|---|
| Model | time state |
| Service | position calculation |
| Controller | applies positions |
| View | moves objects |
1 — Trigger updates through an event
This was already implemented in the previous step, but it is important to understand how it works.
In TimeModel:
public void SetTime(DateTime t)
{
CurrentTime = t;
OnTimeChanged?.Invoke(CurrentTime);
}
In the controller:
timeModel.OnTimeChanged += UpdatePlanets;
The planets therefore update only when time changes.
2 — Advancing time
Create a small Unity component:
TimeController
public class TimeController : MonoBehaviour
{
public float secondsPerDay = 1f;
TimeModel model;
DateTime current;
public void Init(TimeModel m)
{
model = m;
current = DateTime.Now;
model.SetTime(current);
}
void Update()
{
if (!model.IsPlaying) return;
current = current.AddDays(Time.deltaTime * secondsPerDay);
model.SetTime(current);
}
}
In AppBootstrapper:
timeController.Init(timeModel);
3 — Verification
When the scene starts, the Console should show:
[BOOT] Initializing application
[TIME] Updating planets ...
The planets should move.
Checklist:
- no
FindObjectOfType - no
GameObject.Find - updates go through OnTimeChanged
- the
PlanetViewreferences are assigned
(Optional) Orbits
To display an orbit:
- add a LineRenderer
- calculate several positions
- do not recalculate every frame
Principle:
for (int i = 0; i < samples; i++)
{
var t = start.AddDays(i * 10);
points[i] = ephemeris.GetPlanetPosition(planet, t);
}
Expected result
At the end:
- the simulation is driven by
TimeModel - the planets correctly follow the simulated time
- updates go through the
OnTimeChangedevent - the architecture remains clear and testable
Your report must also include:
- a simple diagram of the
TimeModel → Controller → PlanetViewflow - a short explanation of how it works
- a screenshot of the application
Tag:
v1.2-events
Step 4 — VR interactions: grab / focus / scale
Tag: v1.3-vr-interactions — ~1h30
Objective
In this step, you will turn your simulation into an object that can be manipulated in VR.
The user must be able to:
- move the solar system,
- change its scale,
- select a planet to obtain information.
The goal is to make these interactions work and integrate them cleanly into the project architecture.
XR interactions should trigger intentions, which are then applied by a clearly defined system (controller / manager), rather than directly manipulating all the objects in the scene.
Recommended flow:
XR Interaction
↓
Interaction script
↓
Controller
↓
Views
1 — Prepare a manipulable tabletop
To make interactions easier, organize the scene around a manipulable root object.
Create a structure similar to:
SolarSystemRoot
├ SolarSystem
│ ├ Sun
│ ├ Mercury
│ ├ Venus
│ └ ...
└ Handle
SolarSystemRoot becomes the object you will:
- move
- rotate
- scale
All planets must be children of this object.
Why?
- it makes interactions much simpler,
- it avoids modifying each planet individually,
- it provides a central point for controllers.
2 — Grab interaction
Objective
Allow the user to grab the solar system and move it through space.
Tasks
Create an object to serve as a manipulation handle.
Add an XR interactable that allows the user to:
- grab the object with the controller,
- move the entire system.
Some necessary components:
- collider
- rigidbody (often kinematic)
- XR interaction component
Expected test:
- the user can grab the object,
- move it,
- release it.
Best practices
- do not attach this interaction directly to the planets,
- keep a dedicated handle object,
- add useful logs:
[XR] Table grabbed
[XR] Table released
3 — Scale control
Objective
Allow the user to zoom in / out on the solar system.
The scale must:
- remain within reasonable limits,
- be centralized in a single script,
- produce useful logs.
Implementation
Create a system responsible for:
- receiving a scale value,
- clamping it if necessary,
- applying the transformation to
SolarSystemRoot.
For example, you could create a dedicated component:
ScaleController
This component could expose a method such as:
SetScale(float value)
This component is responsible for actually applying the scale.
User interaction
You are free to choose how to change the scale:
- a VR UI slider
- a + button
- a rotating knob
- a two-handed gesture (bonus)
In all cases:
- the interaction must not manipulate the transform directly
- it calls a controller
Expected logs
Examples:
[INPUT] Scale requested
[XR] Scale applied
[WARN] Scale clamped
4 — Selecting a planet
Objective
Allow the user to point at a planet with an XR ray and select it.
When a planet is selected:
- an event must be triggered,
- the system must know which planet is active.
Setup
Each planet must be able to:
- be detected by a ray interactor,
- signal its selection.
For example, you could create a script such as:
PlanetSelectable
This script can:
- reference the
PlanetView, - trigger an event or call a central system.
Important: the selection script must not contain all the focus or UI logic.
It only signals that a planet has been selected.
5 — Focus mode
Objective
When a planet is selected:
- the planet must be highlighted (for example, centered, enlarged, etc.),
- an information panel appears.
Implementation
Create a system responsible for focus.
For example:
FocusController
Possible responsibilities:
- move the XR origin or camera,
- activate an information panel,
- record the active planet.
The panel could display:
- planet name
- distance from the Sun
- orbital period
- current simulated time
You can choose the exact content.
6 — Recommended organization
One possible division of responsibilities:
| Element | Role |
|---|---|
| XR Interactable | capture the interaction |
| Interaction script | translate the user’s action |
| Controller | apply the logic |
| View | display the result |
Example flow:
Ray Interactor
↓
PlanetSelectable
↓
PlanetSelectionSystem
↓
FocusController
↓
UI
7 — Checks
Before moving on to the next step, check:
Grab
- the solar system can be moved
- movement is stable
- logs appear
Scale
- the scale can change
- limits are respected
- transformations remain consistent
Focus
- ray selection works
- the correct planet is detected
- visual feedback appears
Expected result
At the end of this step, the user can:
- manipulate the solar system,
- change its scale,
- select a planet,
- see associated information.
The architecture must remain:
- readable,
- decoupled,
- debuggable.
Git tag:
v1.3-vr-interactions
Step 5 — World-space UI + instrumentation (Tag: v1.4) — ~1h00
Objectives
Make the application controllable and debuggable inside the headset.
Tasks
A) World-space UI
Create a VR panel with at least:
- Date (or scrubbing)
- Play/pause + speed (x1, x10, x100…)
- Orbit visibility toggle
- Reset viewpoint / reset scale
The UI panel can be an interactive world-space Canvas using the XR Interaction Toolkit.
B) DebugOverlay (required)
Create a discreet overlay displaying:
- FPS / frame time (approximate)
- Current date + speed
- Last user action
- Key warnings (scale out of bounds, suspected performance issues, event spam)
The DebugOverlay can be a simple TextMeshPro panel updated periodically.
Unity allows you to capture all logs (Debug.Log, warnings, errors) through:
Application.logMessageReceived
You can use this callback to:
- capture important messages
- keep recent logs
- display them in the overlay
Example
using UnityEngine;
using TMPro;
using System.Collections.Generic;
public class DebugOverlay : MonoBehaviour
{
public TextMeshProUGUI text;
Queue<string> lines = new Queue<string>();
public int maxLines = 15;
void OnEnable()
{
Application.logMessageReceived += HandleLog;
}
void OnDisable()
{
Application.logMessageReceived -= HandleLog;
}
void HandleLog(string logString, string stackTrace, LogType type)
{
lines.Enqueue(logString);
while (lines.Count > maxLines)
lines.Dequeue();
text.text = string.Join("\n", lines);
}
}
C) Structured logs
All critical events must produce logs in the following format:
[TIME] …[INPUT] …[XR] …[PERF] …
Expected deliverables
- Screenshot showing the UI panel + debug overlay visible in VR
- Tag
v1.4-ui-debug
Step 6 — Guided XR debugging (Tag: v1.5) — (Optional)
Objectives
Learn a reproducible XR debugging method.
Required work
You must address at least 2 tickets from the following 3 (depending on the starter project provided by the instructor):
- Ticket A — Input works in the Editor but not on the headset
- Ticket B — Events fire twice after reloading the scene
- Ticket C — Inconsistent scale/reference frames: objects appear “far away” or “huge”
For each ticket:
- provide a minimal reproduction
- describe the method (instrumentation → hypothesis → test → fix)
- add a simple regression test (for example, log + guard + assert)
Expected deliverables
- “Debug story” section in the README
- Tag
v1.5-debug-tickets
Step 7 — XR performance (Tag: v2.0) — (Optional)
Objectives
Identify and fix a typical VR performance problem (CPU/GPU/GC).
Tasks
- Measure (Profiler):
- CPU main thread
- GC allocations
- scripts running in Update
- Choose one bottleneck and improve it:
- orbit caching
- pooling
- reducing update frequency
- eliminating allocations
- Demonstrate the improvement:
- before/after profiler screenshots
- describe the change (2–5 lines)
Expected deliverables
- “Perf story” section in the README
- Tag
v2.0-final
Bonus tasks (optional)
- Proper planet rotation (axial + orbital)
- Skybox / celestial sphere
- “Planet surface” mode (viewpoint, constellations)
- Teleportation + comfort (snap turn, vignette)
- Small in-VR “debug console” with category filters
Assessment criteria (indicative)
- Architecture & code quality — 40%
- VR interactions — 30%
- Observability — 20%
Bonus:
- Debugging (tickets + method)
- Performance (measurement + improvement)
Quick checklist (before submission)
- Public/private repository accessible, tags present
- Application works on the headset
- World-space UI works
- DebugOverlay works
- README includes screenshots + diagrams
- No
FindObjectOfType/GameObject.Findin the final code