@displayxr/inline3d 1.30.0 → 1.32.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.
package/index.d.ts CHANGED
@@ -123,6 +123,35 @@ export interface XRDisplayInfo {
123
123
  nominalViewerPosition?: DOMPointReadOnly;
124
124
  }
125
125
 
126
+ /** {@link TileHandle.displayMetrics}: a window in physical units. Metres throughout. */
127
+ export interface DisplayMetrics {
128
+ /** The canvas's CSS box on the panel, [width, height]. */
129
+ canvasSizeM: [number, number];
130
+ /** One CSS px on the panel (physical pixel pitch × devicePixelRatio). */
131
+ metersPerCssPx: number;
132
+ displaySizeM: [number, number];
133
+ /** `nominalViewerPosition.z`, else the default. */
134
+ nominalViewerM: number;
135
+ /** Always the default today: no browser or runtime surface reports one. */
136
+ eyeSeparationM: number;
137
+ source: { size: 'display' | 'default'; viewer: 'display' | 'default'; eyeSeparation: 'default' };
138
+ }
139
+
140
+ /** What {@link TileHandle.displayMetrics} fills in when the display cannot say. */
141
+ export const DISPLAY_METRICS_DEFAULTS: Readonly<{
142
+ displaySizeM: readonly [number, number];
143
+ displayPixels: readonly [number, number];
144
+ nominalViewerM: number;
145
+ eyeSeparationM: number;
146
+ }>;
147
+
148
+ /** {@link TileHandle.displayMetrics} as a pure function of an XRDisplayInfo, a client rect and devicePixelRatio. */
149
+ export function displayMetricsFrom(
150
+ info: XRDisplayInfo | null,
151
+ rect: { width: number; height: number } | null,
152
+ dpr?: number,
153
+ ): DisplayMetrics;
154
+
126
155
  /**
127
156
  * One rendering mode the DISPLAY can be put in, as reported by the runtime.
128
157
  *
@@ -310,6 +339,13 @@ export interface TileHandle {
310
339
  * tile off screen).
311
340
  */
312
341
  getDisplayInfo(): Promise<XRDisplayInfo | null>;
342
+ /**
343
+ * This window in physical units — canvas size in metres, metres per CSS px, the panel, the
344
+ * nominal viewer distance, the eye separation — from {@link getDisplayInfo}, with anything not
345
+ * measurable filled from {@link DISPLAY_METRICS_DEFAULTS} and flagged in `source`. Never rejects.
346
+ * The canvas size is read at the call: call again after a resize.
347
+ */
348
+ displayMetrics(): Promise<DisplayMetrics>;
313
349
  /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
314
350
  getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
315
351
  /**
@@ -555,7 +591,63 @@ export interface CreateInline3DOptions {
555
591
  * `trackingstatechange` and do the same for a scene.
556
592
  */
557
593
  untrackedFallback?: 'none' | 'mono';
594
+ /**
595
+ * The TRACKING EASE, as the default for every SDK renderer drawing through this session
596
+ * (`./viewer`'s SceneViewer, `./splat` and `./model` on both engines). When a viewer is
597
+ * acquired — the views jump from the runtime's nominal viewer to the tracked eyes — or lost,
598
+ * the eye cameras glide from where they were drawn to the new views over `durationMs` instead
599
+ * of snapping in one frame; everything parented to the views (parallax, display-rig layers)
600
+ * follows. Triggered by a jump in the views around a `trackingState` edge (or, where the browser
601
+ * reports no state, by a large one-frame jump); a no-op when the vendor already animates the eyes. On by default; `false` restores
602
+ * the snap; a renderer's own `viewerEase` option overrides this. A page rendering its own
603
+ * `addScene` can use the exported {@link ViewerEase}.
604
+ */
605
+ viewerEase?: ViewerEaseOption;
606
+ }
607
+
608
+ /** `true`/unset = on with defaults, `false` = off, or tune it. */
609
+ export type ViewerEaseOption =
610
+ | boolean
611
+ | {
612
+ /** Default true. */
613
+ enabled?: boolean;
614
+ /** The glide, in ms (default 300). 0 disables. */
615
+ durationMs?: number;
616
+ /** 'smoothstep' (default) | 'linear' | 'easeOutCubic'. */
617
+ easing?: 'smoothstep' | 'linear' | 'easeOutCubic' | string;
618
+ };
619
+
620
+ /** One frame's matrices as an SDK renderer copies them out of an XRView (column-major). */
621
+ export interface ViewerEaseEntry {
622
+ proj: Float32Array | Float64Array | number[];
623
+ pose: Float32Array | Float64Array | number[];
624
+ }
625
+
626
+ /**
627
+ * The tracking ease on its own, for a page that renders its own `addScene`: copy each view's
628
+ * `projectionMatrix` and `transform.matrix` into an entry, call `apply(entries,
629
+ * frame.session.trackingState)` once per LIVE frame, and render from the entries (keep using the
630
+ * real XRView for `layer.getViewport`). It rewrites the entries only while an ease is running.
631
+ */
632
+ export declare class ViewerEase {
633
+ constructor(opts?: ViewerEaseOption & object);
634
+ readonly enabled: boolean;
635
+ readonly durationMs: number;
636
+ readonly easing: string;
637
+ /** Is an ease running right now? */
638
+ readonly active: boolean;
639
+ /** What the last apply() did: the weight of the old views (0 = passed through), the trigger, the jump (eye separations). */
640
+ readonly last: { weight: number; reason: null | 'armed-jump' | 'jump' | 'easing'; jump: number };
641
+ apply(entries: ViewerEaseEntry[], trackingState?: XRTrackingState | string | null, timeMs?: number): number;
642
+ configure(opts?: ViewerEaseOption): void;
643
+ /** Forget the previous frame (call after a resize or a view-count change you handle yourself). */
644
+ reset(): void;
558
645
  }
646
+ export function resolveViewerEaseOption(opt?: ViewerEaseOption): { enabled: boolean; durationMs: number; easing: string };
647
+ /** `frame.session.trackingState`, or null when the browser does not report one. */
648
+ export function frameTrackingState(frame: unknown): XRTrackingState | null;
649
+ /** 300. */
650
+ export const VIEWER_EASE_DEFAULT_MS: number;
559
651
 
560
652
  /**
561
653
  * The eased 2D<->3D transition — on by default, and the same sequencer (and the same defaults)
@@ -53,6 +53,18 @@ export async function roomKey(room) {
53
53
  */
54
54
  export const DXR_SIGNAL_DEFAULT = 'wss://dxr-signal.displayxr.workers.dev';
55
55
 
56
+ /**
57
+ * Every host the hosted server answers on. The SAME Worker serves all of them (rooms are shared),
58
+ * so the adapter fails over between them when one cannot be reached: the first entry is tried
59
+ * first. `signal.displayxr.org` is the target of the domain move (RFC 0003 §5g); the workers.dev
60
+ * host stays an alias through the transition. Only applies to the hosted default — a self-hosted
61
+ * URL is tried as given (pass `aliases` to add your own fallbacks).
62
+ */
63
+ export const DXR_SIGNAL_ALIASES = Object.freeze([DXR_SIGNAL_DEFAULT, 'wss://signal.displayxr.org']);
64
+
65
+ /** Join-time error codes from the server that mean "do not retry with the same inputs". */
66
+ const FATAL_JOIN_CODES = ['bad-room', 'bad-id', 'bad-version', 'bad-key', 'origin-not-allowed', 'blocked'];
67
+
56
68
  export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
57
69
  if (!url || typeof url !== 'string') {
58
70
  throw codedError(
@@ -60,16 +72,21 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
60
72
  'dxrSignaling(url): url must be a ws:// or wss:// string (omit it for the hosted DisplayXR server)'
61
73
  );
62
74
  }
63
- const base = url.replace(/\/+$/, '');
75
+ const strip = (u) => String(u).replace(/\/+$/, '');
76
+ const base = strip(url);
64
77
  const WS = opts.WebSocket || globalThis.WebSocket;
65
78
  const pingMs = opts.pingMs || 20000;
79
+ const extra = Array.isArray(opts.aliases) ? opts.aliases.filter((a) => typeof a === 'string' && a).map(strip) : DXR_SIGNAL_ALIASES.includes(base) ? [...DXR_SIGNAL_ALIASES] : [];
80
+ const bases = [base, ...extra.filter((b) => b !== base)];
81
+ let baseIdx = 0;
66
82
 
67
83
  return {
68
84
  name: 'dxr',
69
85
  url: base,
70
86
  async join(room, hooks) {
71
87
  const key = await roomKey(room);
72
- const endpoint = `${base}/v1/connect?k=${key}${typeof opts.key === 'string' && opts.key ? `&key=${encodeURIComponent(opts.key)}` : ''}`;
88
+ const query = `k=${key}${typeof opts.key === 'string' && opts.key ? `&key=${encodeURIComponent(opts.key)}` : ''}`;
89
+ const endpointAt = (i) => `${bases[i]}/v1/connect?${query}`;
73
90
  let ws = null;
74
91
  let left = false;
75
92
  let attempt = 0;
@@ -77,11 +94,15 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
77
94
  let known = new Set();
78
95
  let queue = [];
79
96
 
80
- const open = (isReconnect) =>
97
+ // One open() tries every alias once (in order from the last one that worked) before it
98
+ // reports `signaling-unreachable`; a host that cannot be reached is skipped, not waited on.
99
+ const open = (isReconnect) => openAt(isReconnect, bases.length);
100
+ const openAt = (isReconnect, triesLeft) =>
81
101
  new Promise((resolve, reject) => {
82
102
  let settled = false;
83
103
  let joined = false;
84
- const sock = new WS(endpoint);
104
+ let opened = false;
105
+ const sock = new WS(endpointAt(baseIdx));
85
106
  ws = sock;
86
107
  const fail = (err) => {
87
108
  if (!settled) {
@@ -95,6 +116,7 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
95
116
  }
96
117
  };
97
118
  sock.onopen = () => {
119
+ opened = true;
98
120
  sock.send(JSON.stringify({ t: 'join', v: 1, room, id: hooks.id, max: hooks.maxPeers }));
99
121
  };
100
122
  sock.onmessage = (ev) => {
@@ -121,17 +143,42 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
121
143
  } else known = new Set(peers);
122
144
  settled = true;
123
145
  joined = true;
124
- resolve({ id: msg.id, peers, iceServers: Array.isArray(msg.iceServers) ? msg.iceServers : undefined, max: msg.max });
146
+ resolve({
147
+ id: msg.id,
148
+ peers,
149
+ iceServers: Array.isArray(msg.iceServers) ? msg.iceServers : undefined,
150
+ max: msg.max,
151
+ // Hosted-service fields (RFC 0003 §5): the tier this session was admitted as,
152
+ // its key id, and the TURN decision — `turn.reason === 'cap'` is the SDK's
153
+ // `turn-cap`. A server that predates them sends none; nothing here is required.
154
+ tier: msg.tier === 'key' ? 'key' : 'anon',
155
+ key: typeof msg.key === 'string' ? msg.key : undefined,
156
+ turn: msg.turn && typeof msg.turn === 'object' ? msg.turn : undefined,
157
+ });
125
158
  break;
126
159
  }
127
160
  case 'full':
128
161
  left = true;
129
162
  fail(codedError('room-full', `this call is full (${msg.max} participants)`));
130
163
  break;
164
+ case 'expired': {
165
+ // The room's lifetime is up (hosted limits): no reconnect — the call reports it.
166
+ left = true;
167
+ clearInterval(pingTimer);
168
+ const e = codedError('expired', 'this room has reached its lifetime on the signalling server');
169
+ if (!settled) fail(e);
170
+ else hooks.onDisconnect?.(e);
171
+ break;
172
+ }
131
173
  case 'error':
132
- if (!settled && ['bad-room', 'bad-id', 'bad-version'].includes(msg.code)) {
174
+ if (!settled && FATAL_JOIN_CODES.includes(msg.code)) {
133
175
  left = true;
134
176
  fail(codedError(msg.code, msg.message || msg.code));
177
+ } else if (!settled && (msg.code === 'rate-limited' || msg.code === 'quota')) {
178
+ // Refused by a limit, not by the inputs: a reconnect may retry after `retryMs`.
179
+ const e = codedError(msg.code, msg.message || msg.code);
180
+ /** @type {any} */ (e).retryMs = msg.retryMs > 0 ? msg.retryMs : 0;
181
+ fail(e);
135
182
  } else if (!settled && msg.code === 'id-taken') {
136
183
  // A reconnect raced the server noticing the old socket close: retry shortly.
137
184
  fail(codedError('id-taken', 'peer id still held; retrying'));
@@ -154,7 +201,16 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
154
201
  break;
155
202
  }
156
203
  };
157
- sock.onerror = () => fail(codedError('signaling-unreachable', `cannot reach ${base}`));
204
+ sock.onerror = () => {
205
+ if (!opened && !settled && triesLeft > 1) {
206
+ // This host is unreachable: fail over to the next alias right away.
207
+ settled = true;
208
+ baseIdx = (baseIdx + 1) % bases.length;
209
+ openAt(isReconnect, triesLeft - 1).then(resolve, reject);
210
+ return;
211
+ }
212
+ fail(codedError('signaling-unreachable', `cannot reach ${bases[baseIdx]}`));
213
+ };
158
214
  sock.onclose = () => {
159
215
  clearInterval(pingTimer);
160
216
  fail(codedError('signaling-closed', 'signalling closed before joining'));
@@ -172,11 +228,12 @@ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
172
228
  const tryAgain = () => {
173
229
  if (left) return;
174
230
  open(true).catch((err) => {
175
- if (left || (err && err.code === 'room-full')) {
231
+ if (left || (err && (err.code === 'room-full' || FATAL_JOIN_CODES.includes(err.code)))) {
176
232
  hooks.onDisconnect?.(err);
177
233
  return;
178
234
  }
179
- setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 500, maxMs: 15000 }));
235
+ // A `rate-limited` / `quota` refusal says when to come back; never sooner.
236
+ setTimeout(tryAgain, Math.max(backoffMs(attempt++, { baseMs: 500, maxMs: 15000 }), (err && err.retryMs) || 0));
180
237
  });
181
238
  };
182
239
  setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 300, maxMs: 15000 }));
@@ -362,8 +362,9 @@ class Call {
362
362
  log: (tag, obj) => this.log(tag, obj),
363
363
  });
364
364
  this.transport = t;
365
+ let session = null;
365
366
  try {
366
- await t.start({
367
+ session = await t.start({
367
368
  room: this.room,
368
369
  localStream: this.local.stream,
369
370
  sendFormat: this.local.format,
@@ -410,9 +411,38 @@ class Call {
410
411
  globalThis.addEventListener?.('pagehide', this._pagehide);
411
412
  this.log('joined', { id: this.id, peers: this.tiles.size });
412
413
  this.emit('joined', { room: this.room, id: this.id });
414
+ // The TURN decision is reported on the next task, after join() / mountCall() have resolved:
415
+ // a page (or <dxr-call>) subscribes once it has the handle, and an event raised mid-join
416
+ // would reach nobody.
417
+ setTimeout(() => {
418
+ if (this.transport === t) this._reportTurn(session);
419
+ }, 0);
413
420
  return this.handle;
414
421
  }
415
422
 
423
+ /**
424
+ * The signalling server's TURN decision for this session (hosted service, RFC 0003 §5c), once
425
+ * per join. `turn-cap` (an `error`): the service's monthly relay budget is spent, nobody gets
426
+ * TURN — direct connections still work. `turn-shed` (a `warning`): only THIS session has no
427
+ * TURN (an anonymous session near the budget, or too many credential requests from this
428
+ * address). A server with no TURN at all (self-hosted) says nothing here: that is a setup
429
+ * choice, documented in docs/call.md.
430
+ */
431
+ _reportTurn(session) {
432
+ const turn = session && session.turn;
433
+ if (!turn || typeof turn !== 'object') return;
434
+ if (turn.status === 'off' && turn.reason === 'cap') {
435
+ this.error('turn-cap', 'relay (TURN) capacity for this month is used up; direct connections still work, but a participant behind a strict NAT may be unreachable', null);
436
+ } else if (turn.reason === 'budget' || turn.reason === 'rate') {
437
+ this.warning(
438
+ 'turn-shed',
439
+ turn.reason === 'rate'
440
+ ? 'no relay (TURN) for this session: too many credential requests from this address this hour'
441
+ : 'no relay (TURN) for this anonymous session: the hosted service is near its monthly relay budget (a publishable key keeps TURN)'
442
+ );
443
+ }
444
+ }
445
+
416
446
  _onPeer(id) {
417
447
  if (this.tiles.has(id)) return;
418
448
  const tile = new Tile(this, id);
@@ -0,0 +1,51 @@
1
+ // inline3d-display-metrics.js — a tile in PHYSICAL units, from XRDisplayInfo.
2
+ //
3
+ // What TileHandle.displayMetrics() resolves, as a pure function, so ./splat's depth envelope (and
4
+ // a page) can share it without pulling in the core. Dependency-free; re-exported by ./inline3d.js.
5
+
6
+ /**
7
+ * The defaults displayMetrics() fills in what the display cannot report: a 15.6" 16:9 panel at
8
+ * 1920x1080 CSS px, the viewer at 650 mm, eyes 63 mm apart. EYE SEPARATION IS ALWAYS A DEFAULT —
9
+ * no browser or runtime surface reports one (the runtime tracks eye POSITIONS; a page cannot read
10
+ * them) — so `source.eyeSeparation` is always 'default' today.
11
+ */
12
+ export const DISPLAY_METRICS_DEFAULTS = Object.freeze({
13
+ displaySizeM: Object.freeze([0.3456, 0.1944]),
14
+ displayPixels: Object.freeze([1920, 1080]),
15
+ nominalViewerM: 0.65,
16
+ eyeSeparationM: 0.063,
17
+ });
18
+
19
+ /**
20
+ * displayMetrics() as a pure function: `info` an XRDisplayInfo (or null), `rect` the canvas's
21
+ * client rect (CSS px), `dpr` window.devicePixelRatio. Physical px pitch = displayWidthMeters /
22
+ * displayPixelWidth (the same per axis), and one CSS px is `dpr` physical px — exact when the
23
+ * browser window is on the panel at its native resolution, which is the only place a woven tile
24
+ * draws in 3D.
25
+ */
26
+ export function displayMetricsFrom(info, rect, dpr = 1) {
27
+ const D = DISPLAY_METRICS_DEFAULTS;
28
+ const ok =
29
+ !!info &&
30
+ info.displayWidthMeters > 0 &&
31
+ info.displayHeightMeters > 0 &&
32
+ info.displayPixelWidth > 0 &&
33
+ info.displayPixelHeight > 0;
34
+ const displaySizeM = ok ? [info.displayWidthMeters, info.displayHeightMeters] : [...D.displaySizeM];
35
+ const px = ok ? [info.displayPixelWidth, info.displayPixelHeight] : [...D.displayPixels];
36
+ // Per axis, then their mean: a panel's pixels are square to well under a percent, and one
37
+ // number per CSS px is what a page converting a DOM rect wants.
38
+ const pitch = ((displaySizeM[0] / px[0]) + (displaySizeM[1] / px[1])) / 2;
39
+ const d = dpr > 0 ? dpr : 1;
40
+ const metersPerCssPx = ok ? pitch * d : pitch * (px[1] / Math.max(1, globalThis.innerHeight || px[1]));
41
+ const z = info?.nominalViewerPosition?.z;
42
+ const viewerOk = Number.isFinite(z) && z > 0.1;
43
+ return {
44
+ canvasSizeM: [(rect?.width || 0) * metersPerCssPx, (rect?.height || 0) * metersPerCssPx],
45
+ metersPerCssPx,
46
+ displaySizeM,
47
+ nominalViewerM: viewerOk ? z : D.nominalViewerM,
48
+ eyeSeparationM: D.eyeSeparationM,
49
+ source: { size: ok ? 'display' : 'default', viewer: viewerOk ? 'display' : 'default', eyeSeparation: 'default' },
50
+ };
51
+ }
@@ -464,6 +464,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
464
464
  nearClip: opts.nearClip,
465
465
  farClip: opts.farClip,
466
466
  sky: false,
467
+ viewerEase: opts.viewerEase,
467
468
  // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
468
469
  toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
469
470
  });
@@ -380,6 +380,7 @@ export function addModel(wall, canvas, src, opts = {}) {
380
380
  renderScale,
381
381
  feather,
382
382
  logTag: '[inline3d/model]',
383
+ viewerEase: opts.viewerEase,
383
384
  }).useEyeCamera(EyeCamera, EdgeFeather);
384
385
 
385
386
  if (envMap) viewer.scene.environment = envMap;
@@ -91,6 +91,18 @@ export function addSplatDeferred(wall, canvas, src, opts) {
91
91
  validateSetRigArgs(type, o);
92
92
  return out.ready.then(() => out.setRig(type, o));
93
93
  },
94
+ // Stereo strength / the depth envelope before the adapter has loaded: queued, replayed in
95
+ // order once it has (both are sticky handle state, applied to the first asset from its first
96
+ // frame). Validated by the adapter at replay.
97
+ setStereo: (...args) => {
98
+ pending.push(['setStereo', args]);
99
+ return out;
100
+ },
101
+ setDepthEnvelope: (...args) => {
102
+ pending.push(['setDepthEnvelope', args]);
103
+ return out;
104
+ },
105
+ displayMetrics: () => out.ready.then(() => out.displayMetrics()),
94
106
  // A video before the first asset has landed: controls:'page' throws at the call's own line,
95
107
  // the rest is validated by the adapter's setVideo, which runs once the asset is on screen.
96
108
  setVideo: page ? pageOnly('setVideo') : (src, o) => out.ready.then(() => out.setVideo(src, o)),
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // The SDK owns the chunk: every active effect of a scope is ONE function body with its own
25
25
  // uniform prefix, and the chunk calls them in a FIXED order, STAGE_ORDER: grade → clip → reveal →
26
- // pulse → custom. Removing the last effect deletes the chunk (tile) or the modifier (entity),
26
+ // pulse → custom → envelope (→ cull). Removing the last effect deletes the chunk (tile) or the modifier (entity),
27
27
  // which restores the engine's own default — the exact baseline, not a no-op look-alike.
28
28
  //
29
29
  // Adding an effect = one GLSL body + one registry entry in EFFECTS below: `glsl(P, opts)` defines
@@ -47,7 +47,16 @@
47
47
  import { coverageExponent, FADE_TRANSMITTANCE_FLOOR } from './inline3d-splat-shared.js';
48
48
 
49
49
  /** The fixed composition order of a generated chunk. */
50
- export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'cull']);
50
+ export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'envelope', 'cull']);
51
+
52
+ /** handle.setDepthEnvelope's flat rects the shader takes (the largest-weight first beyond it). */
53
+ export const ENVELOPE_MAX_RECTS = 8;
54
+ const ENVELOPE_NO_RECT = Object.freeze([1, -1, 1, -1]);
55
+ const ENVELOPE_OFF = (() => {
56
+ const o = { on: 0, O: [0, 0, 0], A: [0, 0, -1], R: [1, 0, 0], U: [0, 1, 0], T: [1, 1, 1, 1], K: [0, 1, 1, 0], S: [-1, 1, -1, 1], E: [1, 0, 0, 0], W0: [0, 0, 0, 0], W1: [0, 0, 0, 0] };
57
+ for (let i = 0; i < ENVELOPE_MAX_RECTS; i++) o['F' + i] = ENVELOPE_NO_RECT;
58
+ return Object.freeze(o);
59
+ })();
51
60
 
52
61
  /** The most eye views setSource's wavefront culls for (more: every gaussian is drawn). */
53
62
  export const WIPE_CULL_MAX_VIEWS = 4;
@@ -603,6 +612,87 @@ void ${P}color(vec3 c, inout vec4 col) { if (${P}cut) col.a = 0.0; }
603
612
  },
604
613
  },
605
614
 
615
+ // Internal: handle.setDepthEnvelope — THE FRAME ENVELOPE. Content may come OUT of the glass only
616
+ // well inside the frame: a gaussian whose panel depth would exceed the cap at its screen spot
617
+ // F(x, y) = edge + (maxFront − edge) · e, e = smoothstep(0, band, distance to the stage edge or
618
+ // to a flat rect (weighted)), in metres on the panel,
619
+ // slides back ALONG ITS OWN RAY from the declared camera rig's centre (centre and scale × λ), so
620
+ // the rig's mono picture is unchanged and the engine's sort order stays valid. On a camera rig a
621
+ // point at depth d has panel disparity k·e·f_p·(1/D − 1/d) (k = ipdFactor·metersToVirtual per
622
+ // world unit, f_p = canvas height / (2·tan(vfov/2)), D the convergence), and F metres out needs
623
+ // −e·F/(n − F) — so the deepest-out allowed depth is 1/d_env = 1/D + F / (k·f_p·(n − F)); the
624
+ // viewer's eye separation e cancels. Behind the glass is never touched. Keyed on world position
625
+ // and the RIG (not the tracked eyes), so both eyes and every head position agree. The adapter
626
+ // supplies everything per tick through opts.state() (null = off); world space throughout.
627
+ envelope: {
628
+ stage: 'envelope',
629
+ kind: 'persistent',
630
+ internal: true,
631
+ defaults: {},
632
+ glsl: (P) => `
633
+ uniform float ${P}on;
634
+ uniform vec3 ${P}O;
635
+ uniform vec3 ${P}A;
636
+ uniform vec3 ${P}R;
637
+ uniform vec3 ${P}U;
638
+ uniform vec4 ${P}T; // tan half-fov x, y; canvas half-size x, y (m)
639
+ uniform vec4 ${P}K; // 1/D (world), k·f_p (world·m), viewer n (m), maxFront (m)
640
+ uniform vec4 ${P}S; // stage l, r, b, t (canvas-normalised, y up)
641
+ uniform vec4 ${P}E; // band (m), edge (m), rect count, -
642
+ ${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `uniform vec4 ${P}F${i};`).join('\n')}
643
+ uniform vec4 ${P}W0;
644
+ uniform vec4 ${P}W1;
645
+ float ${P}lambda;
646
+ float ${P}flat(vec2 f, vec4 C, float w) {
647
+ if (w <= 0.0 || C.x >= C.y || C.z >= C.w) return 1.0;
648
+ vec2 o = max(vec2(max(C.x - f.x, f.x - C.y) * ${P}T.z, max(C.z - f.y, f.y - C.w) * ${P}T.w), vec2(0.0));
649
+ return mix(1.0, smoothstep(0.0, ${P}E.x, length(o)), w);
650
+ }
651
+ void ${P}center(inout vec3 c) {
652
+ ${P}lambda = 1.0;
653
+ if (${P}on < 0.5) return;
654
+ vec3 v = c - ${P}O;
655
+ float d = dot(v, ${P}A);
656
+ if (d <= 1e-4) return;
657
+ vec2 f = vec2(dot(v, ${P}R) / (d * ${P}T.x), dot(v, ${P}U) / (d * ${P}T.y));
658
+ vec4 S = ${P}S;
659
+ float inside = min(min(f.x - S.x, S.y - f.x) * ${P}T.z, min(f.y - S.z, S.w - f.y) * ${P}T.w);
660
+ float e = smoothstep(0.0, ${P}E.x, inside);
661
+ ${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ` e = min(e, ${P}flat(f, ${P}F${i}, ${P}W${i >> 2}.${'xyzw'[i & 3]}));`).join('\n')}
662
+ float F = ${P}E.y + max(${P}K.w - ${P}E.y, 0.0) * e;
663
+ float invEnv = ${P}K.x + F / (${P}K.y * max(${P}K.z - F, 1e-3));
664
+ if (1.0 / d <= invEnv) return;
665
+ ${P}lambda = 1.0 / (invEnv * d);
666
+ c = ${P}O + v * ${P}lambda;
667
+ }
668
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) { sc *= ${P}lambda; }
669
+ void ${P}color(vec3 c, inout vec4 col) {}
670
+ `,
671
+ uniforms: (ctx, inst) => {
672
+ const st = typeof inst.opts.state === 'function' ? inst.opts.state() : null;
673
+ if (!st) return ENVELOPE_OFF;
674
+ const out = {
675
+ on: 1,
676
+ O: st.origin,
677
+ A: st.axis,
678
+ R: st.right,
679
+ U: st.up,
680
+ T: [st.tanX, st.tanY, st.halfW, st.halfH],
681
+ K: [st.invD, st.kfp, st.viewer, st.maxFront],
682
+ S: st.stage,
683
+ E: [st.band, st.edge, st.rects.length, 0],
684
+ W0: [0, 0, 0, 0],
685
+ W1: [0, 0, 0, 0],
686
+ };
687
+ for (let i = 0; i < ENVELOPE_MAX_RECTS; i++) {
688
+ const r = st.rects[i];
689
+ out['F' + i] = r ? r.f : ENVELOPE_NO_RECT;
690
+ if (r) out[i < 4 ? 'W0' : 'W1'][i & 3] = r.weight;
691
+ }
692
+ return out;
693
+ },
694
+ },
695
+
606
696
  // Internal: setSource's crossfade FALLBACK (no frame snapshot) — the coverage remap on one
607
697
  // entity, driven by the adapter.
608
698
  xfade: {