@uptimizr/three
v1.0.4
Published
three.js connector for Uptimizr — captures camera, pointer, mesh, and perf events as an sdk-core collector.
Maintainers
Readme
@uptimizr/three
The three.js connector for Uptimizr. It registers as an
@uptimizr/sdk-core collector and captures:
- camera pose (position + forward direction) → view-direction heatmap
- pointer move / click / button transitions (normalized screen + optional raycast hit) → screen heatmaps
- camera gestures → navigation-intent analytics
- mesh picks → object-engagement analytics
- FPS and context loss → performance / reliability
- mesh visibility / hover dwell / gaze / resource samples / node transforms (opt-in) → attention, footprint, and replay fidelity
- WebXR controller/gaze input (via
trackScene, enabled by default) → XR interaction analytics
three is a peer dependency: the connector reads from the host application's
three.js instance and never bundles or mutates the scene. It tears down all DOM
listeners, timers, and animation-frame callbacks on stop. World-space data is
normalized from three's native right-handed, y-up frame to the canonical wire
frame (left-handed, y-up) at the emission boundary.
Install
npm install @uptimizr/three threethree is a peer dependency — the connector reads your existing three.js
instance and never bundles its own.
Usage
The quickest integration is a single call. Because three.js has no
scene.activeCamera and the connector reads FPS and the canvas from the renderer,
camera and renderer are explicit arguments:
import { trackScene } from "@uptimizr/three";
const client = trackScene(scene, camera, renderer, {
projectId: "your-project",
endpoint: "https://collect.example.com",
});
// ... later, on teardown
await client.stop("manual");trackScene creates the client, registers the collector, reads device/GPU caps,
and starts the session. It returns the @uptimizr/sdk-core
UptimizrClient, so you can read client.sessionId, emit custom events, or stop it.
Advanced: wire it up yourself
For a custom transport, a beforeSend hook, or registering multiple collectors,
compose the pieces directly:
import { UptimizrClient } from "@uptimizr/sdk-core";
import { threeCollector, readDeviceCaps } from "@uptimizr/three";
const client = new UptimizrClient({
projectId: "your-project",
endpoint: "https://collect.example.com",
});
client.use(threeCollector({ scene, camera, renderer }));
// device/GPU caps ride along on session_start
client.start({ device: readDeviceCaps(renderer) });
// ... later, on teardown
client.stop("manual");Options
trackScene accepts the one-call project/endpoint, sampling, capture, gaze, actors,
keyBindings, cameraType, connector, and xr options. Use threeCollector directly for
collector-only tuning such as meshVisibility, hoverDwell, resourceSample, raycast, or
cameraGestureSensitivity:
threeCollector({
scene,
camera,
renderer,
sampleCameraMs: 1000, // camera-pose sampling interval
samplePerfMs: 2000, // FPS sampling interval (derived from renderer.info)
pointerMoveThrottleMs: 250, // min gap between pointer_move samples
capture: {
camera: true,
pointerMove: true,
clicks: true,
buttons: true,
cameraGesture: true,
meshPicks: true,
perf: true,
},
});Capture fidelity
The sampling profile sets the per-channel fidelity dial in Hz (0 = off,
"frame" = every render tick). It governs continuous channels only — camera pose,
pointer move, and perf; discrete events (clicks, picks, custom) are always
captured. "frame"-cadence channels are driven by requestAnimationFrame
(rAF ≈ render cadence), since three exposes no per-frame hook the connector owns.
threeCollector({ scene, camera, renderer, sampling: { camera: 10, pointerMove: 60, perf: 0.5 } });WebXR
trackScene registers xrCollector by default. It stays idle until renderer.xr enters an
immersive session, then maps controller/gaze rays plus select/squeeze actions onto the shared
pointer and mesh_interaction events. Pass xr: false to disable it, or xr: { sampleMs,
capture, raycast } to tune XR pose sampling and hit resolution. createXrRaycaster(scene)
builds a ready-made raycast probe (world-space ray → nearest named hit) over the live scene
graph, so controller/gaze rays carry hitPoint/hitMesh and select/squeeze attach to the
object hit.
Opt-in dwell capture (mesh_visibility #37, hover_dwell #48)
Two attention signals are off by default (privacy) and emit one bucketed summary per window/episode:
mesh_visibility— per-object on-screen time, time near the view centre (a gaze proxy), and the max screen fraction reached. Enablecapture.meshVisibilityand tune viameshVisibility. WithboundingBox: trueit rides each object's world AABB along (#53) so the dashboard can draw a coarse scene "ghost"; the box is sent once and only re-sent when it moves. three has nomesh.isInFrustumor world-AABB reader (Babylon does), so the connector computes both import-free fromgeometry.boundingBox+matrixWorldand a clip-space frustum test.hover_dwell— fires when the pointer rests on an object for at leastminDwellMswithout clicking it (a click is engagement, not hesitation). Enablecapture.hoverDwelland tune viahoverDwell.
threeCollector({
scene,
camera,
renderer,
capture: { meshVisibility: true, hoverDwell: true },
meshVisibility: { windowMs: 5000, boundingBox: true, centeredAngleDeg: 12, maxMeshes: 50 },
hoverDwell: { minDwellMs: 500 },
});three.js adaptations
three.js differs from Babylon in a few places; each is handled at the connector boundary so the emitted events are identical:
- Camera forward: three cameras look along local −Z (canonical looks along
+Z). The connector reads the true world-space forward via
camera.getWorldDirection(...)before converting, so the canonical Z-negation is correct (it never reconstructs orientation from the local quaternion). - Pointer/raycast: three has no pointer observable, so DOM listeners are
attached to
renderer.domElementand hits are resolved withTHREE.Raycaster. - Pointer lock (ADR 0034): when
renderer.domElementholds the pointer lock (PointerLockControls, first-person/FPS scenes), the OS cursor freezes, so the connector reportspointer_move/pointer_clickfrom the viewport centre (screen = [0.5, 0.5]) and raycasts from NDC(0, 0)— the crosshair. Read the spatial story from the gaze/floor-plan heatmaps, not the 2D pointer heatmap. - FPS: derived from the
renderer.info.render.framedelta over the sample interval (three has nogetFps()). - Mesh visibility: three has no
mesh.isInFrustum(...)orgetBoundingInfo().boundingBoxwith world min/max. The connector accumulates on-screen time once perrequestAnimationFrame(so dwell pauses with the tab, matching Babylon'sonBeforeRender), computes each object's world AABB fromgeometry.boundingBox+matrixWorld, and tests the view frustum fromprojectionMatrix · matrixWorldInverse— all without importingthree. - Coordinate frame: fixed right-handed, y-up (three has no per-scene handedness
flag like Babylon's
useRightHandedSystem). - Compile stalls (
compile_stall, #42): not captured. Babylon exposes an engine-levelonBeforeShaderCompilationObservable/onAfterShaderCompilationObservablepair the connector can time; three'sWebGLRendererhas no equivalent public compile hook and compiles lazily on first render, so there is no boundary the connector can measure without monkey-patching the renderer (which would violate the "never mutate the engine" rule). Compile-stall capture is therefore Babylon-only for now. - Resource footprint (
resource_sample, #44): captured, but with fewer metrics than Babylon. three'srenderer.info.render.trianglesgives thetrianglessubmitted last frame; there is no public per-frame vertex count, and resident texture/geometry bytes aren't exposed, so those fields are omitted (the read API's averages skip unreported metrics).jsHeapBytescomes from the Chromium-onlyperformance.memory. Opt-in (capture.resourceSample), low-rate, and read-only like the rest. - Capability changes (
capability_change, #49): not auto-captured — by design. A WebGPU→WebGL2 downgrade, a quality/LOD auto-downgrade, or a re-init after a lost device is an app/engine decision with no reliable runtime hook the connector could observe (three picks its backend at construction). Report these transitions from your app with the engine-neutralclient.reportCapabilityChange({ kind, from?, to?, reason? })(sdk-core). The raw GPU lifecycle (context_lost/context_restored) is still captured by the connector;capability_changeis the higher-level companion. - Engine diagnostics — WebGPU
device.lost(graphics_diagnostic, #20): opt-in viacaptureGraphicsDiagnostics: trueon the client (off by default). On aWebGPURenderer, the connector subscribes torenderer.backend.device.lostand emits onegraphics_diagnosticwithcategory: "device-lost"andbackend: "webgpu"—severityisinfofor a requested loss (reason: "destroyed") andfatalotherwise; the optional length-cappedmessageruns throughbeforeSend. Engine-parity with the Babylon connector. AWebGLRendereris a no-op (no device-lost concept; its interruption is the always-oncontext_lost). - Engine diagnostics — WebGPU
uncapturederror(rate-limited rollup, #19): also undercaptureGraphicsDiagnostics, the connector listens foruncapturederroronrenderer.backend.deviceand aggregates a burst into onegraphics_diagnosticwithcount: N+ firstmessage, flushed on an interval and on dispose — never N events. Subtype maps toout-of-memory(GPUOutOfMemoryError,severity: error) orvalidation(severity: warning);messageis length-capped viabeforeSend. WebGL no-op. - Engine diagnostics — context-creation failure (
graphics_diagnostic, #18): also opt-in viacaptureGraphicsDiagnostics: true. At init the connector checks whether the renderer obtained a GL context (getContext()returns null on failure) and, if not, emits onegraphics_diagnosticwithcategory: "context-loss",severity: "fatal", andbackend: "unknown". It fires before the first flush yet queues in order aftersession_start, so the decisive marker always lands. - Engine diagnostics — shader compile/link failures + sampled
gl.getError()(#17): also undercaptureGraphicsDiagnostics, shader compile/link failures →category: "shader-compile"(error; WebGL info logs, WebGPU shader-module compilation info), and sampled WebGLgl.getError()→category: "validation"as a low-rate rollup (never per-frame — it forces a sync GPU stall; no-op on WebGPU). Raw shader source is stripped unless the separatecaptureShaderSource: truesub-opt-in is set (off by default — application IP). All text is length-capped viabeforeSend. Engine-parity with the Babylon connector.
License
Apache-2.0 © Uptimizr.
