@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/CHANGELOG.md +112 -0
- package/README.md +5 -5
- package/call.d.ts +53 -4
- package/dist/call.js +4 -4
- package/index.d.ts +92 -0
- package/js/call/signaling.js +66 -9
- package/js/inline3d-call.js +31 -1
- package/js/inline3d-display-metrics.js +51 -0
- package/js/inline3d-model-playcanvas.js +1 -0
- package/js/inline3d-model.js +1 -0
- package/js/inline3d-splat-deferred.js +12 -0
- package/js/inline3d-splat-effects.js +92 -2
- package/js/inline3d-splat-playcanvas.js +284 -6
- package/js/inline3d-splat.js +15 -0
- package/js/inline3d-viewer-ease.js +360 -0
- package/js/inline3d-viewer.js +15 -4
- package/js/inline3d.js +34 -0
- package/model.d.ts +8 -1
- package/package.json +5 -2
- package/splat.d.ts +76 -1
- package/viewer.d.ts +9 -0
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)
|
package/js/call/signaling.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 &&
|
|
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 = () =>
|
|
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
|
-
|
|
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 }));
|
package/js/inline3d-call.js
CHANGED
|
@@ -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
|
});
|
package/js/inline3d-model.js
CHANGED
|
@@ -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: {
|