@plasius/gpu-xr 0.1.6 → 0.1.7

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.
package/src/index.js CHANGED
@@ -2,6 +2,11 @@ const DEFAULT_VR_SESSION_INIT = Object.freeze({
2
2
  requiredFeatures: ["local-floor"],
3
3
  optionalFeatures: ["bounded-floor", "hand-tracking", "layers"],
4
4
  });
5
+ const DEFAULT_MODE_FRAME_RATES = Object.freeze({
6
+ inline: 60,
7
+ "immersive-vr": 90,
8
+ "immersive-ar": 72,
9
+ });
5
10
 
6
11
  export const xrSessionModes = Object.freeze([
7
12
  "inline",
@@ -16,6 +21,9 @@ export const xrReferenceSpaceTypes = Object.freeze([
16
21
  "bounded-floor",
17
22
  "unbounded",
18
23
  ]);
24
+ export const xrWorkerQueueClass = "render";
25
+ export const xrWorkerSchedulerMode = "dag";
26
+ export const defaultXrWorkerBudgetProfile = "xr";
19
27
 
20
28
  function toStringArray(values) {
21
29
  if (!Array.isArray(values)) {
@@ -31,6 +39,39 @@ function dedupeStrings(values) {
31
39
  return [...new Set(toStringArray(values))];
32
40
  }
33
41
 
42
+ function readPositiveNumber(value) {
43
+ return typeof value === "number" && Number.isFinite(value) && value > 0
44
+ ? value
45
+ : null;
46
+ }
47
+
48
+ function normalizeFrameRates(values) {
49
+ if (!values || typeof values === "string") {
50
+ return Object.freeze([]);
51
+ }
52
+
53
+ let collected;
54
+ try {
55
+ collected = Array.from(values);
56
+ } catch {
57
+ return Object.freeze([]);
58
+ }
59
+
60
+ return Object.freeze(
61
+ [...new Set(collected.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0))].sort(
62
+ (left, right) => right - left
63
+ )
64
+ );
65
+ }
66
+
67
+ function getDefaultFrameRateForMode(mode) {
68
+ return DEFAULT_MODE_FRAME_RATES[mode] ?? DEFAULT_MODE_FRAME_RATES["immersive-vr"];
69
+ }
70
+
71
+ function getWorkerBudgetProfileForMode(mode) {
72
+ return mode === "inline" ? "realtime" : defaultXrWorkerBudgetProfile;
73
+ }
74
+
34
75
  function readNavigator(navigatorOverride) {
35
76
  const currentNavigator = navigatorOverride ?? globalThis.navigator;
36
77
  if (!currentNavigator || typeof currentNavigator !== "object") {
@@ -135,6 +176,137 @@ export async function requestXrSession(options = {}) {
135
176
  return xr.requestSession(mode, init);
136
177
  }
137
178
 
179
+ export function readXrFrameRateCapabilities(session, options = {}) {
180
+ const {
181
+ mode = "immersive-vr",
182
+ fallbackFrameRates = [],
183
+ defaultFrameRate,
184
+ } = options;
185
+
186
+ assertSessionMode(mode);
187
+
188
+ const sessionFrameRate = readPositiveNumber(session?.frameRate);
189
+ const supportedFrameRates = normalizeFrameRates(session?.supportedFrameRates);
190
+ const fallbackRates = normalizeFrameRates(fallbackFrameRates);
191
+ const mergedSupported = supportedFrameRates.length
192
+ ? [...supportedFrameRates]
193
+ : [...fallbackRates];
194
+
195
+ if (sessionFrameRate && !mergedSupported.includes(sessionFrameRate)) {
196
+ mergedSupported.push(sessionFrameRate);
197
+ mergedSupported.sort((left, right) => right - left);
198
+ }
199
+
200
+ const refreshRateHz =
201
+ sessionFrameRate ??
202
+ mergedSupported[0] ??
203
+ readPositiveNumber(defaultFrameRate) ??
204
+ getDefaultFrameRateForMode(mode);
205
+
206
+ return Object.freeze({
207
+ mode,
208
+ currentFrameRate: sessionFrameRate,
209
+ supportedFrameRates: Object.freeze(mergedSupported),
210
+ refreshRateHz,
211
+ canUpdateTargetFrameRate:
212
+ Boolean(session) && typeof session.updateTargetFrameRate === "function",
213
+ });
214
+ }
215
+
216
+ export function createXrPerformanceHint(options = {}) {
217
+ const {
218
+ session = null,
219
+ mode = "immersive-vr",
220
+ preferredFrameRates = [],
221
+ fallbackFrameRates = [],
222
+ defaultFrameRate,
223
+ } = options;
224
+
225
+ const capabilities = readXrFrameRateCapabilities(session, {
226
+ mode,
227
+ fallbackFrameRates,
228
+ defaultFrameRate,
229
+ });
230
+
231
+ const filteredPreferredFrameRates = normalizeFrameRates(preferredFrameRates).filter(
232
+ (frameRate) =>
233
+ capabilities.supportedFrameRates.length === 0 ||
234
+ capabilities.supportedFrameRates.includes(frameRate)
235
+ );
236
+
237
+ const derivedPreferredFrameRates = filteredPreferredFrameRates.length
238
+ ? filteredPreferredFrameRates
239
+ : capabilities.supportedFrameRates.length
240
+ ? capabilities.supportedFrameRates
241
+ : Object.freeze([capabilities.refreshRateHz]);
242
+ const targetFrameRate =
243
+ derivedPreferredFrameRates[0] ?? capabilities.refreshRateHz;
244
+ const rationale = [];
245
+
246
+ if (capabilities.currentFrameRate) {
247
+ rationale.push(
248
+ `XR session reports a current frame rate of ${capabilities.currentFrameRate}Hz.`
249
+ );
250
+ } else {
251
+ rationale.push("XR session does not expose a current frame rate; using defaults.");
252
+ }
253
+
254
+ if (capabilities.supportedFrameRates.length) {
255
+ rationale.push(
256
+ `XR runtime exposes supported frame rates: ${capabilities.supportedFrameRates.join(", ")}Hz.`
257
+ );
258
+ } else {
259
+ rationale.push("XR runtime does not expose supported frame rates; using fallback targets.");
260
+ }
261
+
262
+ if (filteredPreferredFrameRates.length) {
263
+ rationale.push("Preferred XR frame rates were filtered against runtime-supported values.");
264
+ } else {
265
+ rationale.push("XR target frame rate defaults to the highest available runtime target.");
266
+ }
267
+
268
+ return Object.freeze({
269
+ ...capabilities,
270
+ preferredFrameRates: Object.freeze([...derivedPreferredFrameRates]),
271
+ targetFrameRate,
272
+ targetFrameTimeMs: 1000 / targetFrameRate,
273
+ workerBudget: Object.freeze({
274
+ queueClass: xrWorkerQueueClass,
275
+ schedulerMode: xrWorkerSchedulerMode,
276
+ profile: getWorkerBudgetProfileForMode(mode),
277
+ }),
278
+ rationale: Object.freeze(rationale),
279
+ });
280
+ }
281
+
282
+ export async function updateXrTargetFrameRate(session, frameRate) {
283
+ if (!session || typeof session !== "object") {
284
+ throw new Error("XR session is required to update target frame rate.");
285
+ }
286
+
287
+ const requestedFrameRate = readPositiveNumber(frameRate);
288
+ if (!requestedFrameRate) {
289
+ throw new Error("XR target frame rate must be a finite number greater than zero.");
290
+ }
291
+
292
+ if (typeof session.updateTargetFrameRate !== "function") {
293
+ throw new Error("XR session does not support updateTargetFrameRate(frameRate).");
294
+ }
295
+
296
+ const supportedFrameRates = normalizeFrameRates(session.supportedFrameRates);
297
+ if (
298
+ supportedFrameRates.length > 0 &&
299
+ !supportedFrameRates.includes(requestedFrameRate)
300
+ ) {
301
+ throw new Error(
302
+ `XR target frame rate ${requestedFrameRate}Hz is not supported by the active session.`
303
+ );
304
+ }
305
+
306
+ await session.updateTargetFrameRate(requestedFrameRate);
307
+ return requestedFrameRate;
308
+ }
309
+
138
310
  export function createXrStore(initialState = {}) {
139
311
  const listeners = new Set();
140
312
  let state = {
@@ -143,6 +315,11 @@ export function createXrStore(initialState = {}) {
143
315
  isEntering: false,
144
316
  lastError: null,
145
317
  supportedModes: {},
318
+ currentFrameRate: null,
319
+ targetFrameRate: null,
320
+ supportedFrameRates: [],
321
+ canUpdateTargetFrameRate: false,
322
+ workerBudgetProfile: null,
146
323
  ...initialState,
147
324
  };
148
325
 
@@ -176,6 +353,11 @@ export function createXrStore(initialState = {}) {
176
353
  isEntering: false,
177
354
  lastError: null,
178
355
  supportedModes: {},
356
+ currentFrameRate: null,
357
+ targetFrameRate: null,
358
+ supportedFrameRates: [],
359
+ canUpdateTargetFrameRate: false,
360
+ workerBudgetProfile: null,
179
361
  ...initialState,
180
362
  };
181
363
  notify();
@@ -215,6 +397,11 @@ export function createXrManager(options = {}) {
215
397
  activeSession: null,
216
398
  mode: null,
217
399
  isEntering: false,
400
+ currentFrameRate: null,
401
+ targetFrameRate: null,
402
+ supportedFrameRates: [],
403
+ canUpdateTargetFrameRate: false,
404
+ workerBudgetProfile: null,
218
405
  });
219
406
  if (typeof onSessionEnd === "function") {
220
407
  onSessionEnd();
@@ -233,6 +420,63 @@ export function createXrManager(options = {}) {
233
420
 
234
421
  const subscribe = (listener) => store.subscribe(listener);
235
422
 
423
+ const getFrameRateCapabilities = (options = {}) => {
424
+ const state = store.getSnapshot();
425
+ return readXrFrameRateCapabilities(
426
+ options.session ?? state.activeSession,
427
+ {
428
+ mode: options.mode ?? state.mode ?? defaultMode,
429
+ fallbackFrameRates:
430
+ options.fallbackFrameRates ?? state.supportedFrameRates,
431
+ defaultFrameRate:
432
+ options.defaultFrameRate ??
433
+ state.targetFrameRate ??
434
+ state.currentFrameRate ??
435
+ undefined,
436
+ }
437
+ );
438
+ };
439
+
440
+ const getPerformanceHint = (options = {}) => {
441
+ const state = store.getSnapshot();
442
+ return createXrPerformanceHint({
443
+ session: options.session ?? state.activeSession,
444
+ mode: options.mode ?? state.mode ?? defaultMode,
445
+ preferredFrameRates:
446
+ options.preferredFrameRates ??
447
+ (state.targetFrameRate ? [state.targetFrameRate] : []),
448
+ fallbackFrameRates:
449
+ options.fallbackFrameRates ?? state.supportedFrameRates,
450
+ defaultFrameRate:
451
+ options.defaultFrameRate ??
452
+ state.targetFrameRate ??
453
+ state.currentFrameRate ??
454
+ undefined,
455
+ });
456
+ };
457
+
458
+ const syncSessionPerformanceState = (session, mode, preferredFrameRates = []) => {
459
+ const hint = createXrPerformanceHint({
460
+ session,
461
+ mode,
462
+ preferredFrameRates,
463
+ });
464
+
465
+ store.set({
466
+ activeSession: session,
467
+ mode,
468
+ isEntering: false,
469
+ lastError: null,
470
+ currentFrameRate: hint.currentFrameRate,
471
+ targetFrameRate: hint.targetFrameRate,
472
+ supportedFrameRates: hint.supportedFrameRates,
473
+ canUpdateTargetFrameRate: hint.canUpdateTargetFrameRate,
474
+ workerBudgetProfile: hint.workerBudget.profile,
475
+ });
476
+
477
+ return hint;
478
+ };
479
+
236
480
  const probeSupport = async (modes = [defaultMode]) => {
237
481
  const supportedModes = {};
238
482
  for (const mode of modes) {
@@ -264,13 +508,7 @@ export function createXrManager(options = {}) {
264
508
  });
265
509
 
266
510
  attachSessionEndHandler(session);
267
-
268
- store.set({
269
- activeSession: session,
270
- mode,
271
- isEntering: false,
272
- lastError: null,
273
- });
511
+ syncSessionPerformanceState(session, mode);
274
512
 
275
513
  if (typeof onSessionStart === "function") {
276
514
  onSessionStart(session, mode);
@@ -292,6 +530,32 @@ export function createXrManager(options = {}) {
292
530
  return enterSession("immersive-vr", sessionInit);
293
531
  };
294
532
 
533
+ const setTargetFrameRate = async (frameRate) => {
534
+ const state = store.getSnapshot();
535
+ const activeSession = state.activeSession;
536
+ if (!activeSession) {
537
+ throw new Error(
538
+ "Cannot update XR target frame rate without an active XR session."
539
+ );
540
+ }
541
+
542
+ try {
543
+ const appliedFrameRate = await updateXrTargetFrameRate(
544
+ activeSession,
545
+ frameRate
546
+ );
547
+ syncSessionPerformanceState(activeSession, state.mode ?? defaultMode, [
548
+ appliedFrameRate,
549
+ ]);
550
+ return appliedFrameRate;
551
+ } catch (error) {
552
+ const message =
553
+ error instanceof Error ? error.message : String(error ?? "Unknown XR error");
554
+ store.set({ lastError: message });
555
+ throw error;
556
+ }
557
+ };
558
+
295
559
  const exitSession = async () => {
296
560
  const { activeSession } = store.getSnapshot();
297
561
  if (!activeSession) {
@@ -321,8 +585,11 @@ export function createXrManager(options = {}) {
321
585
  getState,
322
586
  subscribe,
323
587
  probeSupport,
588
+ getFrameRateCapabilities,
589
+ getPerformanceHint,
324
590
  enterSession,
325
591
  enterVr,
592
+ setTargetFrameRate,
326
593
  exitSession,
327
594
  dispose,
328
595
  };