Unity
The Unity (WebGL export) connector. Unity compiles to WebAssembly and renders into a
<canvas>, so it is built on the web-export foundation and
works in two tiers: a JS-only tier (no engine code — pointer heatmaps, FPS, JS
errors) and a bridged tier (a thin copy-in shim adds camera pose, world-space
picks, and replay).
Install
Section titled “Install”npm install @uptimizr/unityimport { trackUnity } from "@uptimizr/unity";
const { client, bridge } = trackUnity({ projectId: "your-project", endpoint: "https://collect.example.com", canvas: () => document.querySelector("#unity-canvas"),});
// later, on teardownawait client.stop("manual");trackUnity creates the client, registers the JS-only tier collector, exposes the
engine bridge (default window.__uptimizr_unity__), and starts the session with
Unity’s connector provenance. The JS-only tier captures immediately; wire the
engine-side shim to bridge to add camera pose, picks, and replay.
Engine-side bridge
Section titled “Engine-side bridge”The bridged tier needs a thin copy-in shim — a .jslib plugin plus a small
MonoBehaviour that samples the active Camera, raycast picks, and FPS and calls the
bridge. It’s a copy-in asset, not an npm dependency, and it does no coordinate math
— it pushes Unity’s own world-space values and the connector normalizes them. Both files
live in the package’s
bridge/
folder.
Set up:
- Copy
Uptimizr.jslibtoAssets/Plugins/WebGL/Uptimizr.jslibin your Unity project (Unity compiles.jslibfiles underPlugins/WebGLinto the WebGL build). - Copy
UptimizrUnityBridge.csanywhere underAssets/and add theUptimizrUnityBridgecomponent to a GameObject. It defaults toCamera.main; assign a specificCameraif you prefer. - Make sure
trackUnity(...)(orclient.use(unityCollector())) runs on the host page before the export starts, sowindow.__uptimizr_unity__exists.
On Start(), the component asserts the bridge protocol version matches the foundation’s
BRIDGE_PROTOCOL_VERSION (1) and disables itself with a warning if the connector is
missing or a different version. The shim’s JS API:
.jslib function |
Bridge call | Notes |
|---|---|---|
UptimizrUnityGetProtocolVersion |
protocolVersion |
-1 when the connector isn’t present yet. |
UptimizrUnityPushPose |
pushPose |
Position / forward / up + vertical FOV (radians). |
UptimizrUnityPushPick |
pushPick |
Named object + world hit point. |
UptimizrUnityPushPerf |
pushPerf |
FPS + long frames. |
UptimizrUnitySetSceneProxy |
setSceneProxy |
JSON array of { name, aabb[6] } nodes. |
See bridge/README.md
for the full contract.
Input backends and picks
Section titled “Input backends and picks”Pick capture reads the primary pointer, so it depends on which input backend the
project enables under Edit → Project Settings → Player → Configuration → Active
Input Handling. The MonoBehaviour compiles a path for each — Mouse /
Touchscreen under ENABLE_INPUT_SYSTEM, the legacy Input API under
ENABLE_LEGACY_INPUT_MANAGER — and prefers the Input System when both are on, so a
click always yields exactly one pick. With neither backend enabled, picks are
silently unavailable; pose and FPS still flow.
Two things to know when running on the Input System backend:
- Picks are dropped for the first few seconds after the export boots. Measured on
Unity 6000.6, a click fired the moment
createUnityInstanceresolves never reaches the bridge, while the same click roughly three seconds later always does — the backend has not begun delivering pointer input yet. Nothing on the page fixes it (focusing the canvas does not help); it clears on its own. The legacy Input Manager has no such delay. Expect a small gap in pick data at the very start of a session. - Leave Active Input Handling explicitly set. An unset value resolves to the Input
System on Unity 6, and any build carrying a pre-1.x-patch copy of the shim — which
read
UnityEngine.Inputunconditionally — throwsInvalidOperationExceptionevery frame and captures no picks at all. Re-copy the shim frombridge/if you see that.
Verifying against a real export
Section titled “Verifying against a real export”Unity is not part of the JS toolchain, so verification is split in two:
-
Always on (CI). A
node:vmsanity test evaluatesUptimizr.jslibwith mocked Emscripten globals and asserts every export forwards towindow.__uptimizr_unity__, declares its__deps, and matches the[DllImport]s inUptimizrUnityBridge.cs. -
One manual step. The sample project
examples/unity-web-export/(Unity 6) ships a scene with a camera and three named cubes, the bridge files already in place, and player settings pre-set to Compression Format: Disabled so the output serves from a plain static server. Open it in Unity Hub, File → Build Profiles → Web → Build intoexamples/unity-web-export/dist/, then run the Playwright spec:Terminal window pnpm --filter @uptimizr/example-playground exec playwright test unity-exportThe harness page starts
trackUnity(...)beforecreateUnityInstance(so the bridge global exists when the C#Start()runs), clicks the centre cube, and assertssession_start.connector.name === "unity",camera_sample,mesh_interaction, andframe_perfreach the collector. Without a build the spec skips — the CI default.
Any WebGL template works as long as it does not stop the host page from adding its own
scripts; the spec bypasses Unity’s generated index.html and loads Build/*.loader.js
itself.
Coordinate frame
Section titled “Coordinate frame”Unity’s native world frame is left-handed, y-up, meters — already Uptimizr’s
canonical wire frame, so world-space payloads need no axis conversion. The session
records Unity’s native frame in connector.coordinateSystem.
Capture
Section titled “Capture”JS-only tier: pointer move/click → screen heatmaps, FPS / long frames → performance, JS errors. Bridged tier: camera pose → view-direction heatmap, world-space picks → object engagement, scene proxy, and replay.
Privacy
Section titled “Privacy”No client-side persistent IDs and no PII by default (ADR 0003). client.stop() tears
down every listener, timer, and animation-frame callback.