Multiplayer VR application for a surgical instrument handover study. Two players share a virtual workspace and hand over objects while multisensory feedback is applied based on the study condition.
Stack: Unity, C#, Photon Fusion 2 (Shared Mode), OpenXR
Describes how to build a new study scene. The system is structured in four layers — each layer depends on the one below it. The order corresponds to the recommended setup sequence.
These ScriptableObject assets must be present before a scene can run. They are already assigned in the relevant prefabs and do not need to be re-assigned per scene.
| Asset | Type | Purpose |
|---|---|---|
GrabSettings |
ScriptableObject | Physics tuning for grabbing and handover: grab/release/drop thresholds, spring constant, damping, fingertip radius. Shared by PlayerAvatar, interactable objects, and Local_XR_Rig. |
AvatarConfig |
ScriptableObject | Tracking offsets (position/rotation) per joint for calibrating hardware tracking to avatar bones. Different per avatar type. |
AvatarSet |
ScriptableObject | Holds references to male and female avatar mesh prefabs (Medical_Male_prefab, Medical_Female_prefab). PlayerVisuals uses this to select the correct mesh based on the gender string. |
StimulusDefinition (×4) |
ScriptableObject | One asset per visual stimulus. The filename must exactly match the backend stimulus name (loaded via Resources.Load). Located under Assets/App/Resources/Stimuli/. |
StudySessionConfig |
ScriptableObject | Configures backend URL, offline mode, and offline trial sequence. Located at Assets/App/Resources/Sessions/. When offlineMode = true, the system runs fully without a backend. |
StimulusDefinition assets:
| Filename | Rendering Mode | Technique |
|---|---|---|
inner_hand |
IH | RenderingMode |
outer_hand |
OH | RenderingMode |
finger_color |
OH | ColorMapping |
object_color |
OH | ColorMapping |
Provides network connection, experiment context, and hardware tracking. Must be initialized before all other layers.
NetworkManager
| Component | Origin | Purpose |
|---|---|---|
NetworkRunner |
Photon Fusion | Fusion runtime; manages network tick, clients, shared state |
ConnectionManager |
Project | Builds the Fusion Shared-Mode room; connects automatically on start |
PlayerManager |
Project | Spawns PlayerAvatar at SpawnPoint_P1/SpawnPoint_P2 when a player joins; despawns on leave; calls SetGender() on the spawned avatar |
StudyManager
| Component | Origin | Purpose |
|---|---|---|
BackendService |
Project | Single HTTP layer. Polls GET /experiments/next until data is available; loads stimuli via GET /trials/{id}/stimuli; fires OnSessionReady(SessionState) when complete data is ready. When offlineMode = true, serves trial data from StudySessionConfig — no backend required. |
Inspector: Assign the StudySessionConfig asset. For VR testing without backend: set offlineMode = true.
Local_XR_Rig
├── Hand_Left ← HardwareHand, GrabHand
├── Hand_Right ← HardwareHand, GrabHand
└── Head ← HardwareHeadset
Root
| Component | Origin | Purpose |
|---|---|---|
HardwareRig |
Project | Collects local XR tracking data (headset + hands) and exposes it as RigState; found and subscribed to by NetworkRig on spawn |
Children: Hand_Left / Hand_Right
| Component | Origin | Purpose |
|---|---|---|
HardwareHand |
Project | Reads position, rotation, and joint poses of the physical hand from the OpenXR system |
GrabHand |
Project | Detects fingertip contact with objects; computes EffectiveGrip; writes to NetworkedGrabber on the spawned avatar |
Child: Head
| Component | Origin | Purpose |
|---|---|---|
HardwareHeadset |
Project | Reads HMD position and rotation |
Spawned at runtime by PlayerManager — once per connected player.
PlayerAvatar ← NetworkObject, NetworkTransform, NetworkRig,
AvatarConfigReference, PlayerVisuals, AvatarDriver,
HandRenderingController, HandVisualFeedback,
AudioSource, ToneAuditoryFeedback, TactileFeedbackPlaceholder
├── Head ← NetworkTransform, NetworkHeadset
├── Hand_Left ← NetworkHand (Left)
├── Hand_Right ← NetworkHand (Right)
└── Visuals ← empty; avatar mesh is spawned here at runtime by PlayerVisuals
Root: PlayerAvatar
| Component | Origin | Purpose |
|---|---|---|
NetworkObject |
Photon Fusion | Makes the GameObject a network object; assigns state authority (who may write the state) |
NetworkTransform |
Photon Fusion | Syncs root transform position and rotation across all clients |
AvatarConfigReference |
Project | Holds a reference to the AvatarConfig ScriptableObject; provides tracking offsets to AvatarDriver |
NetworkRig |
Project | On the local client, reads XR hardware data (HardwareRig) and writes it to the network components; read-only on remote clients |
PlayerVisuals |
Project | Spawns the correct avatar mesh (male/female) from AvatarSet under Visuals based on gender set via SetGender(); fires AvatarInitialized with AvatarBoneReference when the mesh is ready |
AvatarDriver |
Project | Drives avatar bones every frame from the synced HandState; runs on all clients for all players |
HandRenderingController |
Project | Overrides wrist bone position in LateUpdate for OH mode (Outer Hand — hand stays visually outside the object) |
HandVisualFeedback |
Project | Activates IH/OH rendering mode and color-mapping shader based on the active stimulus |
AudioSource |
Unity | Controlled by ToneAuditoryFeedback |
ToneAuditoryFeedback |
Project | Generates a procedural sine tone; volume scales with the local player's grip strength |
TactileFeedbackPlaceholder |
Project | No-op placeholder; replaceable with a hardware-specific haptic implementation later |
Child: Head
| Component | Origin | Purpose |
|---|---|---|
NetworkTransform |
Photon Fusion | Syncs head transform across all clients |
NetworkHeadset |
Project | Carrier component for the head transform in the network rig |
Child: Hand_Left / Hand_Right
| Component | Origin | Purpose |
|---|---|---|
NetworkHand |
Project | Syncs HandState (all joint positions + grip strength) of the respective hand over the network |
Child: Visuals
Empty in the prefab. PlayerVisuals spawns the avatar mesh here at runtime and registers AvatarBoneReference.
All graspable objects are based on Block_Original as the base prefab. Specific variants are derived as prefab variants — they only override mesh and collider shape.
[ObjectName]
├── Collider ← BoxCollider (or appropriate collider shape)
└── Visuals ← MeshFilter, MeshRenderer
Root
| Component | Origin | Purpose |
|---|---|---|
Rigidbody |
Unity | Physics body for grabbing and dropping |
NetworkObject |
Photon Fusion | Makes the object network-capable; state authority transfers to the grabbing player |
NetworkTransform |
Photon Fusion | Syncs position/rotation; uses the Visuals child as the interpolation target for smooth rendering without moving the physics collider |
Grabbable |
Project | Detects grabbing via hand tracking contact; prerequisite for NetworkGrabbableObject |
NetworkGrabbableObject |
Project | Dual-grip during handover, spring physics, authority transfer after full handover |
HandoverTracker |
Project | Detects the four handover phases (GiverGrabbed → ReceiverTouched → ReceiverGrabbed → GiverReleased) and broadcasts them via Fusion RPC to all clients; must be on the same GameObject as NetworkGrabbableObject |
HandoverReporter |
Project | Persists handover events to the backend; only the giver client sends HTTP |
Child: Collider
Contains the actual physics collider (BoxCollider, MeshCollider, etc.). Separated as a child so scale and shape can be adjusted independently from the root.
Child: Visuals
Contains MeshFilter + MeshRenderer. Used as NetworkTransform interpolation target — visually smooth movement even under network latency.
Minimum requirement: Without HandoverTracker and HandoverReporter, the object can be grabbed but no study events are triggered and nothing is recorded.
FeedbackManager ← HandoverFeedbackController
HandoverFeedbackController reads slot and stimulus data from SessionState via BackendService.OnSessionReady.
| Component | Origin | Purpose |
|---|---|---|
HandoverFeedbackController |
Project | Subscribes to HandoverTracker events; activates the correct HandVisualFeedback and ToneAuditoryFeedback on the relevant PlayerAvatar per phase; calls UpdateGrip() every frame on all active providers |
Inspector: Set the BackendService reference (StudyManager in the scene) on HandoverFeedbackController.
| Scene | Purpose |
|---|---|
Operational Room |
Main VR environment (operating room) |
StudyScene |
Full production scene for running studies with backend |
| Scene | Purpose |
|---|---|
01_Avatar |
Avatar rendering and tracking calibration |
02_Grabbing |
Grab and release mechanics |
03_Feedback |
Multisensory feedback (visual, auditory, tactile) |
04_Network |
Presence + Multiplayer networking |
05_Handover |
Presence + Multiplayer + handover interaction |
06_Experiment |
Presence + Multiplayer + Study (offline, no backend) |
07_OnlineExperiment |
Full experiment with live backend connection |
Arduino |
Arduino/tactile hardware integration test |
Test scenes 01–06 run without a backend (offlineMode = true). 07_OnlineExperiment and the production scenes require a running backend.
-
GrabSettingsasset present and assigned in prefabs -
AvatarConfigasset present and assigned inPlayerAvatar -
AvatarSetasset present with male/female prefab references and assigned inPlayerAvatar -
StimulusDefinitionassets underAssets/App/Resources/Stimuli/:inner_hand,outer_hand,finger_color,object_color -
StudySessionConfigasset present atAssets/App/Resources/Sessions/Session_Default.asset
- Drag prefab
NetworkManagerinto the scene - Drag prefab
StudyManagerinto the scene — assignStudySessionConfigasset - Drag prefab
Local_XR_Riginto the scene
- Assign prefab
PlayerAvataras spawn prefab inPlayerManager - Place two empty GameObjects
SpawnPoint_P1andSpawnPoint_P2and assign them inPlayerManager
- Place at least one interactable object (based on
Block_Original) in the scene -
HandoverReporter— setBackendServicereference (StudyManager)
- Drag prefab
FeedbackManagerinto the scene — setBackendServicereference onHandoverFeedbackController