Unity implementation
Build a prefab. Ship it to a headset.
Everything the Unity side needs: the interaction attributes the web editor reads, the four artifacts an export produces, and the four API calls a headset makes.
Overview
Two deliverables
They share exactly one assembly, and keeping them apart is the most important structural decision in the Unity work.
| Authoring Tools | Runtime Player | |
|---|---|---|
| What | Unity editor package (UPM), com.gvr.authoring | Unity application — APK / Quest build, Windows exe |
| Who runs it | A developer building prefabs | A trainee wearing a headset |
| Output | AssetBundle + GLB + thumbnail + manifest.json | Rendered scenes, fired actions, analytics |
| Depends on | UnityEditor, UnityEditor.Build.Pipeline | Runtime-only Unity, XR plugins |
The shared assembly is com.gvr.interaction — a runtime asmdef with no UnityEditor reference. A prefab that ships to a headset cannot carry editor code, so the attributes and signal types live there and nothing else does.
Step 1
Declare events and actions
Attach a GvrInteractable to the prefab root and mark up what an author is allowed to wire. Whatever you declare is exactly what appears in the web editor's dropdowns — nothing is hardcoded on the web side.
using System.Collections;
using UnityEngine;
using GVR.Interaction;
public sealed class FireDoor : GvrInteractable
{
// EVENTS -> the "When [Fire Door] fires [___]" dropdown
[GvrEvent(Label = "Door opened")]
public GvrSignal onDoorOpen = new GvrSignal();
[GvrEvent(Label = "Door closed")]
public GvrSignal onDoorClose = new GvrSignal();
// ACTIONS -> the "then [___]" dropdown
[GvrAction(Label = "Open door")]
public IEnumerator OpenDoor(float seconds = 1f)
{
yield return Swing(90f, seconds);
onDoorOpen.Raise(); // raising is what the web editor binds to
}
[GvrAction(Label = "Lock door")]
public void SetLocked(bool locked) => enabled = !locked;
}Rules the exporter enforces
- An action must be public and non-static, and return void or IEnumerator (a coroutine is started for you).
- Every parameter must be string, bool, int, float, or a [Serializable] struct of those. There is no way to pass a GameObject. A manifest is JSON written by a browser that has never seen your scene graph — a reference it cannot resolve would fail inside a headset rather than at publish time.
- GvrSignal is a serialized field, not a C# event: it survives Unity serialisation, shows in the inspector, and can be found by reflecting over fields.
Keys are a compatibility surface
Key defaults to the field or method name, and ActionBinding.eventKey stores it in the database as a string. Renaming a field after a prefab has shipped breaks every presentation bound to it. Pin Key explicitly the moment a prefab is in use. finalizeUpload returns orphanedEventKeys so the UI can grey those bindings out — a warning, not a repair.
Step 2
Export four artifacts
One export produces four files. Three are required; the thumbnail is optional.
| Artifact | Who consumes it | Notes |
|---|---|---|
| prefab.bundle | The headset | Unity AssetBundle — the real prefab, scripts and all |
| preview.glb | The browser editor | Visual proxy only: no scripts, no animator, no colliders |
| manifest.json | The web editor | The declared events and actions |
| thumbnail.png | The library drawer | 512×512, transparent background. Optional |
Rendering the prefab in the browser
This is the question that has no clever answer: a browser cannot open an AssetBundle. It is a Unity-specific, platform-specific binary. So the export writes a second representation — a GLB — and Three.js renders that.
- Use UnityGLTF or glTFast's editor export. Pin one; do not hand-roll a GLB writer.
- Decimate. The preview sits in a viewport a few hundred pixels wide. Target ≤ 50k triangles and ≤ 2048² textures. The server caps the GLB at 100 MB and rejects more.
- Flip the winding. Unity is left-handed, glTF is right-handed. Export without correcting it and the preview renders inside-out — you see back faces. Both exporters handle this; verify on your first prefab rather than assuming.
- Bake to one material where you can. Draw calls in the editor viewport are the difference between a snappy gizmo and a stuttering one.
The two representations never need to match pixel for pixel. The GLB is what an author positions; the AssetBundle is what a trainee sees. They only need to agree on bounds and orientation, or an object placed perfectly in the browser will sit wrong in the headset.
Step 3
Upload
Three calls. Bytes go straight to S3 on a presigned PUT — the server never proxies them.
prefab.createUploadUrl -> presigned PUT URLs for each artifact
PUT (browser/editor -> S3 directly, no server in the middle)
prefab.finalizeUpload -> validates manifest.json, records the version
Returns: orphanedEventKeys[] — keys authors had bound that no longer exist.
Re-uploading NEVER deletes PrefabEvent / PrefabAction rows: that
would cascade away an author's wiring, and the first symptom would
be a trainee pulling a lever that does nothing. The version bumps.Step 4
The headset API
Four calls, and only the first is unauthenticated. Everything else carries a device token — not a user session.
Redeem a code
A trainee types a short code. That is the only input the headset ever takes.
POST /api/trpc/runtime.redeemCode
{ "code": "ABC123", "deviceLabel": "Quest 3 — Bay 4" }
-> { "token": "<device session token>", ... }Every later call sends that token. Not a cookie — a headset has no user session, and the tRPC route is deliberately reachable without one for exactly this reason:
Authorization: Bearer <device session token>
runtime.manifest (query) -> the frozen build manifest JSON
runtime.ingestEvents (mutation) -> { events: [...] } batched, offline-safe
runtime.complete (mutation) -> { score?, maxScore?, passed?, durationMs? }Downloading content
runtime.manifest returns the entire contract: scenes, objects, transforms, and the authored logic. Asset and prefab URLs in it are long-lived CDN URLs, not presigned — a headset runs offline for days, and a signed URL that expires in minutes would leave a device holding a manifest full of dead links. Download the bundles it names, cache them on device, and re-fetch by version.
Streaming analytics back
ingestEvents is idempotent by construction. The row's primary key is sha256(participantId + " " + clientEventId), so a retried offline flush collides and is dropped. Send the same batch twice and nothing duplicates — which is what makes a naive retry queue safe.
{
"id": "device-generated, stable across retries", // the dedupe key
"type": "OBJECT_GRAB",
"sceneId": "...", "objectId": "...", "eventKey": "onDoorOpen",
"value": 100,
"clientTimeMs": 48213 // ms since module start, DEVICE clock
}clientTimeMs is milliseconds since module start on the device clock — never epoch time. It is stored for durations and is never used to order events across devices; receivedAt is stamped server-side for that.
Event types:
SESSION_START SCENE_ENTER SCENE_EXIT OBJECT_GAZE
OBJECT_GRAB OBJECT_RELEASE PREFAB_EVENT ACTION_FIRED
SCORE_CHANGE ANSWER HINT_USED ERROR
HEARTBEAT SESSION_ENDReference
Where the contracts live
Nothing on this page invents a field. If one of these changes, this page is wrong until it changes too.
| Contract | File |
|---|---|
| Prefab manifest | packages/api/src/lib/prefab-manifest.ts |
| Build manifest | packages/api/src/lib/manifest.ts |
| Upload flow | packages/api/src/routers/prefab.ts |
| Headset API | packages/api/src/routers/runtime.ts |
| Full specification | docs/UNITY_IMPLEMENTATION.md |
| Starter scripts | gvr-unity/Assets/GVR/ |
One known limitation worth knowing before you plan platforms: Prefab.bundleKey is singular while targetPlatforms is a list, so one prefab cannot currently ship to both Quest and PCVR. docs/UNITY_IMPLEMENTATION.md §3.1 documents two candidate fixes; the schema change should be made deliberately, not unilaterally by the Unity side.