Skip to content

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 ToolsRuntime Player
WhatUnity editor package (UPM), com.gvr.authoringUnity application — APK / Quest build, Windows exe
Who runs itA developer building prefabsA trainee wearing a headset
OutputAssetBundle + GLB + thumbnail + manifest.jsonRendered scenes, fired actions, analytics
Depends onUnityEditor, UnityEditor.Build.PipelineRuntime-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.

FireDoor.cscsharp
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.

ArtifactWho consumes itNotes
prefab.bundleThe headsetUnity AssetBundle — the real prefab, scripts and all
preview.glbThe browser editorVisual proxy only: no scripts, no animator, no colliders
manifest.jsonThe web editorThe declared events and actions
thumbnail.pngThe library drawer512×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.

upload flowtext
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.

runtime.redeemCode — publicProcedurehttp
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:

the other three — deviceProcedurehttp
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.

one eventjson
{
  "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:

event typestext
SESSION_START   SCENE_ENTER   SCENE_EXIT     OBJECT_GAZE
OBJECT_GRAB     OBJECT_RELEASE PREFAB_EVENT  ACTION_FIRED
SCORE_CHANGE    ANSWER        HINT_USED      ERROR
HEARTBEAT       SESSION_END

Reference

Where the contracts live

Nothing on this page invents a field. If one of these changes, this page is wrong until it changes too.

ContractFile
Prefab manifestpackages/api/src/lib/prefab-manifest.ts
Build manifestpackages/api/src/lib/manifest.ts
Upload flowpackages/api/src/routers/prefab.ts
Headset APIpackages/api/src/routers/runtime.ts
Full specificationdocs/UNITY_IMPLEMENTATION.md
Starter scriptsgvr-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.