@wonderlandengine/ar-provider-zappar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,827 @@
1
+ /// <reference path="./types/global.d.ts" />
2
+ import { Emitter } from '@wonderlandengine/api';
3
+ import { mat4, quat, vec3 } from 'gl-matrix';
4
+ import { ARProvider, TrackingType, } from '@wonderlandengine/ar-tracking';
5
+ import { loadZappar, setOptions as zapparSetOptions } from './zappar-module.js';
6
+ import { WorldTracking_Zappar } from './world-tracking-mode-zappar.js';
7
+ import { FaceTracking_Zappar } from './face-tracking-mode-zappar.js';
8
+ import { ImageTracking_Zappar } from './image-tracking-mode-zappar.js';
9
+ /**
10
+ * ARProvider implementation backed by the Zappar Universal AR JavaScript SDK.
11
+ */
12
+ export class ZapparProvider extends ARProvider {
13
+ static _cvWorkerConfigured = false;
14
+ static _cvWorker = null;
15
+ /** Mirror Zappar THREE.js `CameraPoseMode` for SLAM pose output. */
16
+ slamPoseMode = 'anchor-origin';
17
+ _xrSession = null;
18
+ _gl = null;
19
+ _zappar = null;
20
+ pipeline = null;
21
+ cameraSource = null;
22
+ instantTracker = null;
23
+ _faceTracker = null;
24
+ _faceMesh = null;
25
+ _faceResourcesPromise = null;
26
+ _imageTracker = null;
27
+ _imageTargetDescriptors = [];
28
+ _imageTargetsChanged = new Emitter();
29
+ cameraStarted = false;
30
+ hasInitializedAnchor = false;
31
+ _anchorWarmupFramesRemaining = 0;
32
+ preRenderRegistered = false;
33
+ _slamStateValid = false;
34
+ _slamProjectionMatrix = new Float32Array(16);
35
+ _slamAnchorMatrix = mat4.create();
36
+ _slamFrameNumber = 0;
37
+ _videoTextureUnit = null;
38
+ _videoTextureProgram = null;
39
+ _videoTextureUniform = null;
40
+ _videoTextureTransformUniform = null;
41
+ _videoTextureBindErrorLogged = false;
42
+ _missingCameraTextureFrames = 0;
43
+ _missingCameraTextureWarningLogged = false;
44
+ _slamCameraMatrix = mat4.create();
45
+ _slamCameraPosition = vec3.create();
46
+ _slamCameraRotation = quat.create();
47
+ _debugCameraPosition = vec3.create();
48
+ _debugAnchorPosition = vec3.create();
49
+ _debugCameraPositionDelta = vec3.create();
50
+ _debugAnchorPositionDelta = vec3.create();
51
+ _debugLastSampleFrameNumber = null;
52
+ _debugLastSampleCameraPosition = vec3.create();
53
+ _debugLastSampleAnchorPosition = vec3.create();
54
+ _zapparDebugLogIntervalId = null;
55
+ _preRenderErrorLogged = false;
56
+ static Name = 'Zappar';
57
+ get name() {
58
+ return ZapparProvider.Name;
59
+ }
60
+ get supportsInstantTracking() {
61
+ return true;
62
+ }
63
+ get onImageTargetsChanged() {
64
+ return this._imageTargetsChanged;
65
+ }
66
+ get xrSession() {
67
+ return this._xrSession;
68
+ }
69
+ static registerTrackingProviderWithARSession(arSession) {
70
+ const provider = new ZapparProvider(arSession.engine);
71
+ arSession.registerTrackingProvider(provider);
72
+ return provider;
73
+ }
74
+ constructor(engine) {
75
+ super(engine);
76
+ if (typeof document === 'undefined') {
77
+ return;
78
+ }
79
+ engine.onXRSessionStart.add((session) => {
80
+ this._xrSession = session;
81
+ this.onSessionStart.notify(this);
82
+ });
83
+ engine.onXRSessionEnd.add(() => {
84
+ this.onSessionEnd.notify(this);
85
+ });
86
+ }
87
+ async startSession() {
88
+ this._slamStateValid = false;
89
+ this._missingCameraTextureFrames = 0;
90
+ this._missingCameraTextureWarningLogged = false;
91
+ // Warm-up for the InstantWorldTracker anchor (~2 seconds at 60fps).
92
+ this._anchorWarmupFramesRemaining = 120;
93
+ await this.ensureZapparLoaded();
94
+ await this._zappar.loadedPromise();
95
+ this.ensurePipeline();
96
+ await this.ensureCameraRunning();
97
+ this.startZapparDebugLogging();
98
+ // For instant tracking providers, we should emit session start here.
99
+ // (Unlike WebXR, there is no XRSessionStart event.)
100
+ this.onSessionStart.notify(this);
101
+ if (this.instantTracker) {
102
+ this.instantTracker.enabled = true;
103
+ }
104
+ }
105
+ startZapparDebugLogging() {
106
+ if (this._zapparDebugLogIntervalId !== null)
107
+ return;
108
+ const canAccessWindow = typeof window !== 'undefined' &&
109
+ typeof window.setInterval ===
110
+ 'function';
111
+ if (canAccessWindow) {
112
+ this._zapparDebugLogIntervalId = window.setInterval(() => {
113
+ const debug = window.ZapparDebug;
114
+ // Keep this intentionally simple: user asked to log ZapparDebug.
115
+ console.log('[ZapparDebug]', debug ?? null);
116
+ }, 2000);
117
+ return;
118
+ }
119
+ // WL Editor / non-browser fallback
120
+ const canAccessGlobalIntervals = typeof globalThis.setInterval === 'function';
121
+ if (canAccessGlobalIntervals) {
122
+ const globalAny = globalThis;
123
+ this._zapparDebugLogIntervalId = globalAny.setInterval(() => {
124
+ console.log('[ZapparDebug]', globalAny.ZapparDebug ?? null);
125
+ }, 2000);
126
+ }
127
+ }
128
+ stopZapparDebugLogging() {
129
+ if (this._zapparDebugLogIntervalId === null)
130
+ return;
131
+ const id = this._zapparDebugLogIntervalId;
132
+ this._zapparDebugLogIntervalId = null;
133
+ if (typeof window !== 'undefined' && typeof window.clearInterval === 'function') {
134
+ window.clearInterval(id);
135
+ return;
136
+ }
137
+ if (typeof globalThis.clearInterval === 'function') {
138
+ globalThis.clearInterval(id);
139
+ }
140
+ }
141
+ async ensureZapparLoaded() {
142
+ if (this._zappar)
143
+ return;
144
+ // Must be called before Zappar initializes, otherwise the CV worker option
145
+ // may be ignored and Zappar CV will fall back to requesting `./worker`.
146
+ await this.configureCvWorkerIfNeeded();
147
+ this._zappar = await loadZappar();
148
+ }
149
+ /** Used by tracking modes that need the Zappar namespace. */
150
+ async ensureZapparNamespace() {
151
+ await this.ensureZapparLoaded();
152
+ return this._zappar;
153
+ }
154
+ async configureCvWorkerIfNeeded() {
155
+ if (ZapparProvider._cvWorkerConfigured)
156
+ return;
157
+ // Hard-coded paths for local development
158
+ const workerUrl = './zappar-cv/zappar-cv.worker.js';
159
+ const wasmUrl = './zappar-cv/zappar-cv.wasm';
160
+ const debugWorker = typeof window !== 'undefined' &&
161
+ window
162
+ .__ZAPPAR_WORKER_DEBUG__;
163
+ if (debugWorker) {
164
+ console.log('[ZapparProvider] configureCvWorkerIfNeeded()', {
165
+ workerUrl,
166
+ wasmUrl,
167
+ hasWorkerGlobal: typeof Worker !== 'undefined',
168
+ });
169
+ }
170
+ if (typeof Worker === 'undefined')
171
+ return;
172
+ try {
173
+ if (debugWorker) {
174
+ console.log('[ZapparProvider] Creating CV worker:', workerUrl);
175
+ }
176
+ const worker = new Worker(workerUrl);
177
+ // Keep a reference for debugging / inspection.
178
+ ZapparProvider._cvWorker = worker;
179
+ if (typeof window !== 'undefined') {
180
+ window.__ZapparCvWorker =
181
+ worker;
182
+ }
183
+ // Diagnostics: if the worker fails to load/respond, the pipeline can get stuck with
184
+ // `frameNumber() === 0` and no camera texture.
185
+ const resolvedWorkerUrl = typeof window !== 'undefined'
186
+ ? new URL(workerUrl, window.location.href).toString()
187
+ : workerUrl;
188
+ const resolvedWasmUrl = typeof window !== 'undefined'
189
+ ? new URL(wasmUrl, window.location.href).toString()
190
+ : wasmUrl;
191
+ console.log('[ZapparProvider] CV worker created', {
192
+ workerUrl,
193
+ resolvedWorkerUrl,
194
+ wasmUrl,
195
+ resolvedWasmUrl,
196
+ });
197
+ let workerMessagesSeen = 0;
198
+ worker.addEventListener('message', (event) => {
199
+ workerMessagesSeen++;
200
+ if (workerMessagesSeen === 1) {
201
+ console.log('[ZapparProvider] CV worker first message received', event.data);
202
+ return;
203
+ }
204
+ // Keep a couple more messages for context, but avoid spamming.
205
+ if (workerMessagesSeen <= 5) {
206
+ console.log('[ZapparProvider] CV worker message', {
207
+ index: workerMessagesSeen,
208
+ data: event.data,
209
+ });
210
+ }
211
+ });
212
+ worker.addEventListener('error', (event) => {
213
+ console.warn('[ZapparProvider] CV worker error', event);
214
+ });
215
+ worker.addEventListener('messageerror', (event) => {
216
+ console.warn('[ZapparProvider] CV worker messageerror', event);
217
+ });
218
+ // If the worker never sends anything (not even the initial "loaded"), call it out.
219
+ // This usually means the worker script or wasm URL is 404, blocked, or CSP/COEP issues.
220
+ if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
221
+ window.setTimeout(() => {
222
+ if (workerMessagesSeen > 0)
223
+ return;
224
+ console.warn('[ZapparProvider] CV worker produced no messages (timeout)', {
225
+ workerUrl,
226
+ resolvedWorkerUrl,
227
+ wasmUrl,
228
+ resolvedWasmUrl,
229
+ });
230
+ }, 5000);
231
+ }
232
+ if (wasmUrl) {
233
+ if (debugWorker) {
234
+ console.log('[ZapparProvider] Sending WASM URL to worker:', wasmUrl);
235
+ }
236
+ worker.postMessage({
237
+ t: 'wasm',
238
+ url: new URL(wasmUrl, window.location.href).toString(),
239
+ });
240
+ }
241
+ await zapparSetOptions({ worker });
242
+ ZapparProvider._cvWorkerConfigured = true;
243
+ if (debugWorker) {
244
+ console.log('[ZapparProvider] CV worker configured');
245
+ }
246
+ }
247
+ catch (e) {
248
+ console.warn('[ZapparProvider] Failed to create CV worker:', e);
249
+ }
250
+ }
251
+ ensurePipeline() {
252
+ if (this.pipeline)
253
+ return;
254
+ if (!this._zappar) {
255
+ throw new Error('Zappar is not loaded yet. Call startSession() first.');
256
+ }
257
+ const Zappar = this._zappar;
258
+ const gl = this.engine.canvas.getContext('webgl2');
259
+ if (!gl) {
260
+ throw new Error('Zappar requires a WebGL2 context.');
261
+ }
262
+ this._gl = gl;
263
+ const pipeline = new Zappar.Pipeline();
264
+ pipeline.glContextSet(gl);
265
+ this.pipeline = pipeline;
266
+ if (typeof window !== 'undefined') {
267
+ window.ZapparPipeline = pipeline;
268
+ }
269
+ else if (typeof WL_EDITOR !== 'undefined' && WL_EDITOR) {
270
+ globalThis.ZapparPipeline = pipeline;
271
+ }
272
+ if (typeof window !== 'undefined') {
273
+ console.log('[ZapparProvider] Using device camera source');
274
+ }
275
+ const deviceId = Zappar.cameraDefaultDeviceID();
276
+ this.cameraSource = new Zappar.CameraSource(pipeline, deviceId);
277
+ this.instantTracker = new Zappar.InstantWorldTracker(pipeline);
278
+ if (!this.preRenderRegistered) {
279
+ this.engine.scene.onPreRender.add(this.onPreRender);
280
+ this.preRenderRegistered = true;
281
+ }
282
+ }
283
+ getPipeline() {
284
+ if (!this.pipeline) {
285
+ throw new Error('Zappar pipeline not initialized. Call startSession() first.');
286
+ }
287
+ return this.pipeline;
288
+ }
289
+ async ensureFaceResources() {
290
+ if (this._faceResourcesPromise) {
291
+ await this._faceResourcesPromise;
292
+ return;
293
+ }
294
+ await this.ensureZapparLoaded();
295
+ const Zappar = this._zappar;
296
+ await Zappar.loadedPromise();
297
+ this.ensurePipeline();
298
+ this._faceResourcesPromise = (async () => {
299
+ const faceTracker = new Zappar.FaceTracker(this.pipeline);
300
+ // Zappar's default loaders fetch model files relative to `import.meta.url` of the
301
+ // module that contains them. After bundling, that usually points at the app bundle
302
+ // in the deploy root, which causes 404s.
303
+ //
304
+ // We stage these assets into `static/zappar-cv/` via this package's postinstall.
305
+ // Load explicitly from there, and fall back to the SDK defaults for flexibility.
306
+ try {
307
+ await faceTracker.loadModel('./zappar-cv/face_tracking_model.zbin');
308
+ }
309
+ catch (e) {
310
+ console.warn('[ZapparProvider] Failed to load face tracking model from ./zappar-cv; falling back to Zappar defaults.', e);
311
+ await faceTracker.loadDefaultModel();
312
+ }
313
+ const faceMesh = new Zappar.FaceMesh();
314
+ try {
315
+ await faceMesh.load('./zappar-cv/face_mesh_face_model.zbin', true, true, true, false);
316
+ }
317
+ catch (e) {
318
+ console.warn('[ZapparProvider] Failed to load face mesh model from ./zappar-cv; falling back to Zappar defaults.', e);
319
+ await faceMesh.loadDefaultFace(true, true, true);
320
+ }
321
+ this._faceTracker = faceTracker;
322
+ this._faceMesh = faceMesh;
323
+ })();
324
+ await this._faceResourcesPromise;
325
+ }
326
+ getFaceTracker() {
327
+ if (!this._faceTracker) {
328
+ throw new Error('Face tracker not initialized. Call ensureFaceResources() first.');
329
+ }
330
+ this._faceTracker.enabled = true;
331
+ return this._faceTracker;
332
+ }
333
+ getFaceMesh() {
334
+ if (!this._faceMesh) {
335
+ throw new Error('Face mesh not initialized. Call ensureFaceResources() first.');
336
+ }
337
+ return this._faceMesh;
338
+ }
339
+ ensureImageTracker() {
340
+ this.ensurePipeline();
341
+ if (!this._zappar) {
342
+ throw new Error('Zappar is not loaded yet. Call startSession() first.');
343
+ }
344
+ const Zappar = this._zappar;
345
+ if (!this._imageTracker) {
346
+ this._imageTracker = new Zappar.ImageTracker(this.pipeline);
347
+ }
348
+ this._imageTracker.enabled = true;
349
+ return this._imageTracker;
350
+ }
351
+ async registerImageTarget(source, options) {
352
+ if (!options.name) {
353
+ throw new Error('Image target registration requires a name.');
354
+ }
355
+ // Allow registering targets before an AR session starts.
356
+ // This is useful so image targets are known for ImageScanningEvent
357
+ // and so apps can prefetch/prepare targets without requesting camera access.
358
+ await this.ensureZapparLoaded();
359
+ await this._zappar.loadedPromise();
360
+ this.ensurePipeline();
361
+ const Zappar = this._zappar;
362
+ if (!this._imageTracker) {
363
+ this._imageTracker = new Zappar.ImageTracker(this.pipeline);
364
+ }
365
+ this._imageTracker.enabled = true;
366
+ await this._imageTracker.loadTarget(source);
367
+ const targets = this._imageTracker.targets;
368
+ const targetIndex = targets.length - 1;
369
+ const target = targets[targetIndex];
370
+ const inferredType = options.type ?? this._inferImageTargetType(target);
371
+ const geometry = this._buildImageTargetGeometry(target, options.physicalWidthInMeters);
372
+ const descriptor = {
373
+ name: options.name,
374
+ type: inferredType,
375
+ geometry,
376
+ properties: null,
377
+ metadata: options.metadata ?? null,
378
+ };
379
+ this._imageTargetDescriptors[targetIndex] = descriptor;
380
+ this._imageTargetsChanged.notify();
381
+ }
382
+ _inferImageTargetType(target) {
383
+ if (target.topRadius !== undefined || target.bottomRadius !== undefined) {
384
+ if (target.topRadius !== undefined &&
385
+ target.bottomRadius !== undefined &&
386
+ Math.abs(target.topRadius - target.bottomRadius) > 1e-5) {
387
+ return 'conical';
388
+ }
389
+ return 'cylindrical';
390
+ }
391
+ return 'flat';
392
+ }
393
+ _buildImageTargetGeometry(target, physicalWidth) {
394
+ const geometry = {};
395
+ const hasPhysicalScale = target.physicalScaleFactor !== undefined;
396
+ const planarScale = hasPhysicalScale
397
+ ? target.physicalScaleFactor
398
+ : physicalWidth !== undefined
399
+ ? physicalWidth
400
+ : undefined;
401
+ if (planarScale !== undefined) {
402
+ geometry.scaleWidth = planarScale;
403
+ geometry.scaledHeight = planarScale;
404
+ }
405
+ const radiusFactor = hasPhysicalScale ? (target.physicalScaleFactor ?? 1) : 1;
406
+ if (target.topRadius !== undefined) {
407
+ geometry.radiusTop = target.topRadius * radiusFactor;
408
+ }
409
+ if (target.bottomRadius !== undefined) {
410
+ geometry.radiusBottom = target.bottomRadius * radiusFactor;
411
+ }
412
+ if (target.sideLength !== undefined) {
413
+ geometry.height = target.sideLength * radiusFactor;
414
+ }
415
+ return geometry;
416
+ }
417
+ getImageScanningEvent() {
418
+ return {
419
+ imageTargets: this._imageTargetDescriptors.map((descriptor) => ({
420
+ name: descriptor.name,
421
+ type: descriptor.type,
422
+ metadata: descriptor.metadata ?? null,
423
+ geometry: descriptor.geometry,
424
+ properties: descriptor.properties,
425
+ })),
426
+ };
427
+ }
428
+ getImageTargetDescriptors() {
429
+ return [...this._imageTargetDescriptors];
430
+ }
431
+ getImageTargetDescriptor(index) {
432
+ return this._imageTargetDescriptors[index];
433
+ }
434
+ async ensureCameraRunning() {
435
+ if (!this.cameraSource || this.cameraStarted)
436
+ return;
437
+ await this.ensureZapparLoaded();
438
+ const Zappar = this._zappar;
439
+ await Zappar.loadedPromise();
440
+ const granted = await Zappar.permissionRequestUI();
441
+ if (!granted) {
442
+ Zappar.permissionDeniedUI();
443
+ return;
444
+ }
445
+ this.cameraSource.start();
446
+ if (typeof document !== 'undefined') {
447
+ document.addEventListener('visibilitychange', this.onVisibilityChange);
448
+ }
449
+ this.cameraStarted = true;
450
+ }
451
+ onVisibilityChange = () => {
452
+ if (!this.cameraSource || typeof document === 'undefined')
453
+ return;
454
+ switch (document.visibilityState) {
455
+ case 'hidden':
456
+ this.cameraSource.pause();
457
+ break;
458
+ case 'visible':
459
+ this.cameraSource.start();
460
+ break;
461
+ }
462
+ };
463
+ onPreRender = () => {
464
+ if (!this.pipeline)
465
+ return;
466
+ const gl = this._gl;
467
+ if (!gl) {
468
+ this.pipeline.processGL();
469
+ this.pipeline.cameraFrameUploadGL();
470
+ this.pipeline.frameUpdate();
471
+ return;
472
+ }
473
+ const previousPackBuffer = gl.getParameter(gl.PIXEL_PACK_BUFFER_BINDING);
474
+ const previousUnpackBuffer = gl.getParameter(gl.PIXEL_UNPACK_BUFFER_BINDING);
475
+ if (previousPackBuffer)
476
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
477
+ if (previousUnpackBuffer)
478
+ gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, null);
479
+ try {
480
+ // Mirror Zappar THREE.js update order:
481
+ // - tracking: processGL() -> frameUpdate()
482
+ // - texture upload: cameraFrameUploadGL() is performed separately (cameraTexture.ts)
483
+ this.pipeline.processGL();
484
+ this.pipeline.frameUpdate();
485
+ // Update SLAM state every frame (one loop in the provider).
486
+ this.updateTracking();
487
+ // Upload the latest camera frame to a WebGL texture (for background rendering).
488
+ this.pipeline.cameraFrameUploadGL();
489
+ // Bind the Zappar camera texture for Wonderland's sky material.
490
+ // Wonderland Engine will handle drawing; we only ensure that:
491
+ // - `videoTexture` sampler uniform points to the last texture unit
492
+ // - that texture unit is bound to the Zappar camera texture
493
+ this.bindVideoTextureForSkyMaterial();
494
+ }
495
+ catch (error) {
496
+ // Ensure exceptions aren't swallowed by the engine render loop.
497
+ // Log once per session to avoid spamming every frame.
498
+ if (!this._preRenderErrorLogged) {
499
+ this._preRenderErrorLogged = true;
500
+ console.error('[ZapparProvider] onPreRender pipeline error', error);
501
+ }
502
+ // Avoid consumers using stale matrices.
503
+ this._slamStateValid = false;
504
+ }
505
+ finally {
506
+ if (previousPackBuffer) {
507
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, previousPackBuffer);
508
+ }
509
+ if (previousUnpackBuffer) {
510
+ gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, previousUnpackBuffer);
511
+ }
512
+ }
513
+ };
514
+ /** Latest projection matrix for the SLAM camera (valid only if {@link hasSlamTrackingState} is true). */
515
+ get slamProjectionMatrix() {
516
+ return this._slamStateValid ? this._slamProjectionMatrix : null;
517
+ }
518
+ /** Latest camera pose matrix for the SLAM camera (valid only if {@link hasSlamTrackingState} is true). */
519
+ get slamCameraPoseMatrix() {
520
+ return this._slamStateValid ? this._slamCameraMatrix : null;
521
+ }
522
+ /** Latest anchor pose matrix (valid only if {@link hasSlamTrackingState} is true). */
523
+ get slamAnchorPoseMatrix() {
524
+ return this._slamStateValid ? this._slamAnchorMatrix : null;
525
+ }
526
+ get hasSlamTrackingState() {
527
+ return this._slamStateValid;
528
+ }
529
+ get slamFrameNumber() {
530
+ return this._slamFrameNumber;
531
+ }
532
+ bindVideoTextureForSkyMaterial() {
533
+ try {
534
+ const pipeline = this.pipeline;
535
+ const gl = this._gl;
536
+ if (!pipeline || !gl)
537
+ return;
538
+ const cameraTexture = pipeline.cameraFrameTextureGL();
539
+ if (!cameraTexture) {
540
+ this._missingCameraTextureFrames++;
541
+ if (!this._missingCameraTextureWarningLogged &&
542
+ this._missingCameraTextureFrames > 30) {
543
+ this._missingCameraTextureWarningLogged = true;
544
+ console.warn('[ZapparProvider] No camera texture available yet');
545
+ }
546
+ return;
547
+ }
548
+ // Got a texture: reset missing-texture diagnostics.
549
+ this._missingCameraTextureFrames = 0;
550
+ this._missingCameraTextureWarningLogged = false;
551
+ // Choose the last texture unit to avoid colliding with engine bindings.
552
+ if (this._videoTextureUnit === null) {
553
+ const maxFragmentUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS);
554
+ const maxCombinedUnits = gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS);
555
+ const maxUnits = Math.min(maxFragmentUnits | 0, maxCombinedUnits | 0);
556
+ this._videoTextureUnit = Math.max(0, maxUnits - 1);
557
+ }
558
+ // Grab the engine's sky material pipeline program (provided by WLE).
559
+ const skyMaterial = this.engine.scene.skyMaterial;
560
+ if (!skyMaterial)
561
+ return;
562
+ const pipelineName = skyMaterial.pipeline;
563
+ const enginePipeline = this.engine.pipelines.findByName(pipelineName);
564
+ const program = enginePipeline?.webglProgram;
565
+ if (!program)
566
+ return;
567
+ // Ensure the sampler uniform points at our dedicated unit.
568
+ if (this._videoTextureProgram !== program) {
569
+ this._videoTextureProgram = program;
570
+ this._videoTextureUniform = gl.getUniformLocation(program, 'videoTexture');
571
+ this._videoTextureTransformUniform = gl.getUniformLocation(program, 'videoTextureTransform');
572
+ }
573
+ if (this._videoTextureUniform || this._videoTextureTransformUniform) {
574
+ const prevProgram = gl.getParameter(gl.CURRENT_PROGRAM);
575
+ gl.useProgram(program);
576
+ if (this._videoTextureUniform) {
577
+ gl.uniform1i(this._videoTextureUniform, this._videoTextureUnit);
578
+ }
579
+ if (this._videoTextureTransformUniform) {
580
+ const mirror = pipeline.cameraFrameUserFacing();
581
+ const m = pipeline.cameraFrameTextureMatrix(this.engine.canvas.width, this.engine.canvas.height, mirror);
582
+ gl.uniformMatrix4fv(this._videoTextureTransformUniform, false, m);
583
+ }
584
+ gl.useProgram(prevProgram);
585
+ }
586
+ // Bind the texture for the upcoming render. Do not unbind it, we need it
587
+ // to still be bound when the sky material draws.
588
+ const prevActiveTex = gl.getParameter(gl.ACTIVE_TEXTURE);
589
+ gl.activeTexture(gl.TEXTURE0 + this._videoTextureUnit);
590
+ gl.bindTexture(gl.TEXTURE_2D, cameraTexture);
591
+ gl.activeTexture(prevActiveTex);
592
+ }
593
+ catch (error) {
594
+ if (!this._videoTextureBindErrorLogged) {
595
+ this._videoTextureBindErrorLogged = true;
596
+ const skyMaterial = this.engine.scene.skyMaterial;
597
+ console.error('[ZapparProvider] bindVideoTextureForSkyMaterial exception', {
598
+ error,
599
+ pipelineName: skyMaterial?.pipeline,
600
+ hasGl: !!this._gl,
601
+ hasZapparPipeline: !!this.pipeline,
602
+ videoTextureUnit: this._videoTextureUnit,
603
+ });
604
+ }
605
+ }
606
+ }
607
+ updateTracking() {
608
+ if (!this.pipeline || !this.instantTracker)
609
+ return;
610
+ if (!this._zappar)
611
+ return;
612
+ const Zappar = this._zappar;
613
+ const mirrorPoses = this.pipeline.cameraFrameUserFacing();
614
+ // Let Zappar continuously refine a stable surface point briefly, then lock.
615
+ // If we lock immediately at startup, we can end up with effectively 3DoF behavior.
616
+ // If we *never* lock, the origin follows the camera and the camera appears frozen.
617
+ if (this._anchorWarmupFramesRemaining > 0) {
618
+ this.instantTracker.setAnchorPoseFromCameraOffset(0, 0, -5);
619
+ this._anchorWarmupFramesRemaining--;
620
+ this.hasInitializedAnchor = true;
621
+ }
622
+ else if (!this.hasInitializedAnchor) {
623
+ this.instantTracker.setAnchorPoseFromCameraOffset(0, 0, -5);
624
+ this.hasInitializedAnchor = true;
625
+ }
626
+ // Use the active view's near/far when available; incorrect near/far can make tracking feel
627
+ // visually "unrooted" even if the pose is correct.
628
+ const activeView = this.engine.scene.activeViews[0];
629
+ if (!activeView)
630
+ return;
631
+ const zNear = activeView.near;
632
+ const zFar = activeView.far;
633
+ const projectionMatrix = Zappar.projectionMatrixFromCameraModel(this.pipeline.cameraModel(), this.engine.canvas.width, this.engine.canvas.height, zNear, zFar);
634
+ let origin;
635
+ let cameraPoseMatrix;
636
+ // Match zappar-threejs `CameraPoseMode` behavior.
637
+ switch (this.slamPoseMode) {
638
+ case 'default':
639
+ cameraPoseMatrix = this.pipeline.cameraPoseDefault();
640
+ break;
641
+ case 'attitude':
642
+ cameraPoseMatrix = this.pipeline.cameraPoseWithAttitude(mirrorPoses);
643
+ break;
644
+ case 'anchor-origin':
645
+ default:
646
+ // For SLAM camera movement, we treat the InstantWorld anchor as the world origin.
647
+ origin = this.instantTracker.anchor.poseCameraRelative(mirrorPoses);
648
+ cameraPoseMatrix = this.pipeline.cameraPoseWithOrigin(origin);
649
+ break;
650
+ }
651
+ const anchorPoseMatrix = this.instantTracker.anchor.pose(cameraPoseMatrix, mirrorPoses);
652
+ // Diagnostics: track whether translation is meaningfully changing over time.
653
+ // Zappar matrices are column-major; translation lives at indices 12..14.
654
+ this._debugCameraPosition[0] = cameraPoseMatrix[12];
655
+ this._debugCameraPosition[1] = cameraPoseMatrix[13];
656
+ this._debugCameraPosition[2] = cameraPoseMatrix[14];
657
+ this._debugAnchorPosition[0] = anchorPoseMatrix[12];
658
+ this._debugAnchorPosition[1] = anchorPoseMatrix[13];
659
+ this._debugAnchorPosition[2] = anchorPoseMatrix[14];
660
+ const currentFrameNumber = this.pipeline.frameNumber();
661
+ if (this._debugLastSampleFrameNumber === null) {
662
+ this._debugLastSampleFrameNumber = currentFrameNumber;
663
+ vec3.copy(this._debugLastSampleCameraPosition, this._debugCameraPosition);
664
+ vec3.copy(this._debugLastSampleAnchorPosition, this._debugAnchorPosition);
665
+ vec3.zero(this._debugCameraPositionDelta);
666
+ vec3.zero(this._debugAnchorPositionDelta);
667
+ }
668
+ else {
669
+ vec3.sub(this._debugCameraPositionDelta, this._debugCameraPosition, this._debugLastSampleCameraPosition);
670
+ vec3.sub(this._debugAnchorPositionDelta, this._debugAnchorPosition, this._debugLastSampleAnchorPosition);
671
+ // Refresh the baseline every ~2 seconds worth of frames at 60fps.
672
+ // This matches the ZapparDebug interval logger, so deltas are easy to interpret.
673
+ if (currentFrameNumber - this._debugLastSampleFrameNumber >= 120) {
674
+ this._debugLastSampleFrameNumber = currentFrameNumber;
675
+ vec3.copy(this._debugLastSampleCameraPosition, this._debugCameraPosition);
676
+ vec3.copy(this._debugLastSampleAnchorPosition, this._debugAnchorPosition);
677
+ }
678
+ }
679
+ this._slamProjectionMatrix.set(projectionMatrix);
680
+ mat4.copy(this._slamCameraMatrix, cameraPoseMatrix);
681
+ mat4.copy(this._slamAnchorMatrix, anchorPoseMatrix);
682
+ this._slamFrameNumber = currentFrameNumber;
683
+ this._slamStateValid = true;
684
+ const motionPermissionGranted = typeof Zappar.permissionGranted === 'function' &&
685
+ Zappar.Permission?.MOTION !== undefined
686
+ ? Zappar.permissionGranted(Zappar.Permission.MOTION)
687
+ : undefined;
688
+ if (typeof window !== 'undefined') {
689
+ window.ZapparDebug = {
690
+ projectionMatrix,
691
+ cameraPoseMatrix,
692
+ anchorPoseMatrix,
693
+ originMatrix: origin,
694
+ frameNumber: this._slamFrameNumber,
695
+ instantTrackerEnabled: this.instantTracker.enabled,
696
+ slamPoseMode: this.slamPoseMode,
697
+ mirrorPoses,
698
+ motionPermissionGranted,
699
+ cameraPosition: [
700
+ this._debugCameraPosition[0],
701
+ this._debugCameraPosition[1],
702
+ this._debugCameraPosition[2],
703
+ ],
704
+ anchorPosition: [
705
+ this._debugAnchorPosition[0],
706
+ this._debugAnchorPosition[1],
707
+ this._debugAnchorPosition[2],
708
+ ],
709
+ cameraPositionDelta: [
710
+ this._debugCameraPositionDelta[0],
711
+ this._debugCameraPositionDelta[1],
712
+ this._debugCameraPositionDelta[2],
713
+ ],
714
+ anchorPositionDelta: [
715
+ this._debugAnchorPositionDelta[0],
716
+ this._debugAnchorPositionDelta[1],
717
+ this._debugAnchorPositionDelta[2],
718
+ ],
719
+ cameraPositionDeltaLength: vec3.length(this._debugCameraPositionDelta),
720
+ anchorPositionDeltaLength: vec3.length(this._debugAnchorPositionDelta),
721
+ };
722
+ }
723
+ else if (typeof WL_EDITOR !== 'undefined' && WL_EDITOR) {
724
+ globalThis.ZapparDebug = {
725
+ projectionMatrix,
726
+ cameraPoseMatrix,
727
+ anchorPoseMatrix,
728
+ originMatrix: origin,
729
+ frameNumber: this._slamFrameNumber,
730
+ instantTrackerEnabled: this.instantTracker.enabled,
731
+ slamPoseMode: this.slamPoseMode,
732
+ mirrorPoses,
733
+ motionPermissionGranted,
734
+ cameraPosition: [
735
+ this._debugCameraPosition[0],
736
+ this._debugCameraPosition[1],
737
+ this._debugCameraPosition[2],
738
+ ],
739
+ anchorPosition: [
740
+ this._debugAnchorPosition[0],
741
+ this._debugAnchorPosition[1],
742
+ this._debugAnchorPosition[2],
743
+ ],
744
+ cameraPositionDelta: [
745
+ this._debugCameraPositionDelta[0],
746
+ this._debugCameraPositionDelta[1],
747
+ this._debugCameraPositionDelta[2],
748
+ ],
749
+ anchorPositionDelta: [
750
+ this._debugAnchorPositionDelta[0],
751
+ this._debugAnchorPositionDelta[1],
752
+ this._debugAnchorPositionDelta[2],
753
+ ],
754
+ cameraPositionDeltaLength: vec3.length(this._debugCameraPositionDelta),
755
+ anchorPositionDeltaLength: vec3.length(this._debugAnchorPositionDelta),
756
+ };
757
+ }
758
+ }
759
+ async endSession() {
760
+ this.stopZapparDebugLogging();
761
+ this._preRenderErrorLogged = false;
762
+ if (this.cameraSource && this.cameraStarted) {
763
+ this.cameraSource.pause();
764
+ if (typeof document !== 'undefined') {
765
+ document.removeEventListener('visibilitychange', this.onVisibilityChange);
766
+ }
767
+ this.cameraStarted = false;
768
+ }
769
+ if (this.preRenderRegistered) {
770
+ this.engine.scene.onPreRender.remove(this.onPreRender);
771
+ this.preRenderRegistered = false;
772
+ }
773
+ this._videoTextureProgram = null;
774
+ this._videoTextureUniform = null;
775
+ this._videoTextureTransformUniform = null;
776
+ this._videoTextureUnit = null;
777
+ if (this._faceTracker) {
778
+ this._faceTracker.enabled = false;
779
+ }
780
+ if (this._imageTracker) {
781
+ this._imageTracker.enabled = false;
782
+ }
783
+ if (this.instantTracker) {
784
+ this.instantTracker.enabled = false;
785
+ }
786
+ if (this._xrSession) {
787
+ try {
788
+ await this._xrSession.end();
789
+ }
790
+ catch {
791
+ /* session already closed */
792
+ }
793
+ this._xrSession = null;
794
+ }
795
+ this.hasInitializedAnchor = false;
796
+ this._anchorWarmupFramesRemaining = 0;
797
+ this._slamStateValid = false;
798
+ this.onSessionEnd.notify(this);
799
+ }
800
+ async load() {
801
+ this.loaded = true;
802
+ }
803
+ /** Whether this provider supports given tracking type */
804
+ supports(type) {
805
+ switch (type) {
806
+ case TrackingType.SLAM:
807
+ case TrackingType.Face:
808
+ case TrackingType.Image:
809
+ return true;
810
+ default:
811
+ return false;
812
+ }
813
+ }
814
+ /** Create a tracking implementation */
815
+ createTracking(type, component) {
816
+ switch (type) {
817
+ case TrackingType.SLAM:
818
+ return new WorldTracking_Zappar(this, component);
819
+ case TrackingType.Face:
820
+ return new FaceTracking_Zappar(this, component);
821
+ case TrackingType.Image:
822
+ return new ImageTracking_Zappar(this, component);
823
+ default:
824
+ throw new Error('Tracking mode ' + type + ' not supported.');
825
+ }
826
+ }
827
+ }