@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
|
@@ -51,8 +51,10 @@ import {
|
|
|
51
51
|
} from './inline3d-splat-perf.js';
|
|
52
52
|
import { boundsFromPositions, boundsFromPositionsAsync } from './inline3d-viewer.js';
|
|
53
53
|
import { cameraRigFromPose, displayRig } from './inline3d-three.js';
|
|
54
|
+
import { displayMetricsFrom } from './inline3d-display-metrics.js';
|
|
54
55
|
import {
|
|
55
56
|
SplatEffects,
|
|
57
|
+
ENVELOPE_MAX_RECTS,
|
|
56
58
|
EFFECTS,
|
|
57
59
|
EASINGS,
|
|
58
60
|
resolveRevealOption,
|
|
@@ -67,6 +69,7 @@ import {
|
|
|
67
69
|
sequenceSpans,
|
|
68
70
|
} from './inline3d-splat-effects.js';
|
|
69
71
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
72
|
+
import { viewerEaseFor, frameTrackingState } from './inline3d-viewer-ease.js';
|
|
70
73
|
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
|
|
71
74
|
import { LayerRigCameras, validateLayerRig, validateLayerRigOptions, DEFAULT_VIEWER_DISTANCE_M } from './inline3d-splat-layer-rig.js';
|
|
72
75
|
import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
|
|
@@ -817,8 +820,13 @@ export class PlayCanvasSplatViewer {
|
|
|
817
820
|
sky = false,
|
|
818
821
|
pageCamera = false,
|
|
819
822
|
toneMapping = 'none',
|
|
823
|
+
viewerEase,
|
|
820
824
|
} = opts;
|
|
821
825
|
this.canvas = canvas;
|
|
826
|
+
// The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
|
|
827
|
+
// createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
|
|
828
|
+
this._viewerEaseOpt = viewerEase;
|
|
829
|
+
this.viewerEase = null;
|
|
822
830
|
// controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
|
|
823
831
|
// rig node carries the page's matrix instead of the inverse pivot.
|
|
824
832
|
this.pageCamera = pageCamera === true;
|
|
@@ -1194,7 +1202,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1194
1202
|
}
|
|
1195
1203
|
|
|
1196
1204
|
/** wall.addScene's frame callback. Validate BEFORE drawing; replay the last good frame else. */
|
|
1197
|
-
onFrame(views, layer) {
|
|
1205
|
+
onFrame(views, layer, frame) {
|
|
1198
1206
|
if (this._disposed) return;
|
|
1199
1207
|
if (this._mode !== '3d') this.stopMono();
|
|
1200
1208
|
// The rig these views were located with: Blink chained the rig declared BEFORE this callback,
|
|
@@ -1224,6 +1232,9 @@ export class PlayCanvasSplatViewer {
|
|
|
1224
1232
|
vps.push(vp);
|
|
1225
1233
|
}
|
|
1226
1234
|
this._cacheGood(views, vps);
|
|
1235
|
+
// Ease a tracking acquisition/loss on the copies (never the XRViews), before anything —
|
|
1236
|
+
// the rig map, the layer rig, the live outgoing photo — reads them.
|
|
1237
|
+
(this.viewerEase ||= viewerEaseFor(frame, this._viewerEaseOpt)).apply(this._lastGood.entries, frameTrackingState(frame));
|
|
1227
1238
|
this._drawEntries(this._lastGood.entries, this._lastGood);
|
|
1228
1239
|
}
|
|
1229
1240
|
|
|
@@ -2139,6 +2150,7 @@ export class PlayCanvasSplatViewer {
|
|
|
2139
2150
|
if (this._disposed) return;
|
|
2140
2151
|
const box = this.canvas.getBoundingClientRect();
|
|
2141
2152
|
if (box.width < 1 || box.height < 1) return;
|
|
2153
|
+
this.viewerEase?.reset(); // a new window size moves every projection: not a viewer jump
|
|
2142
2154
|
const dpr = Math.min((typeof window !== 'undefined' && window.devicePixelRatio) || 1, 2) * this.renderScale;
|
|
2143
2155
|
// Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js). Measured on an
|
|
2144
2156
|
// Adreno 740 tablet: MAX_TEXTURE_SIZE 4096, a 5120-wide request silently became a 4096-wide
|
|
@@ -2302,6 +2314,26 @@ export class PlayCanvasSplatViewer {
|
|
|
2302
2314
|
};
|
|
2303
2315
|
}
|
|
2304
2316
|
|
|
2317
|
+
/**
|
|
2318
|
+
* The MONO camera's frame in WORLD (content) space — the rig the page declared, not the tracked
|
|
2319
|
+
* eyes: `origin`, unit `axis`/`right`/`up`, and `scale` = world units per rig unit (the rig
|
|
2320
|
+
* node's uniform scale). The depth envelope keys on it, so it holds still under head motion.
|
|
2321
|
+
*/
|
|
2322
|
+
monoFrame() {
|
|
2323
|
+
const M = mat4Mul(this.rigMatrix(), this.mono.pose);
|
|
2324
|
+
const len = (x, y, z) => Math.hypot(x, y, z) || 1;
|
|
2325
|
+
const sx = len(M[0], M[1], M[2]);
|
|
2326
|
+
const sy = len(M[4], M[5], M[6]);
|
|
2327
|
+
const sz = len(M[8], M[9], M[10]);
|
|
2328
|
+
return {
|
|
2329
|
+
origin: [M[12], M[13], M[14]],
|
|
2330
|
+
axis: [-M[8] / sz, -M[9] / sz, -M[10] / sz],
|
|
2331
|
+
right: [M[0] / sx, M[1] / sx, M[2] / sx],
|
|
2332
|
+
up: [M[4] / sy, M[5] / sy, M[6] / sy],
|
|
2333
|
+
scale: Math.cbrt(sx * sy * sz),
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2305
2337
|
_easeFocus() {
|
|
2306
2338
|
if (this._focusSettled) return;
|
|
2307
2339
|
const f = this._focus;
|
|
@@ -2801,6 +2833,95 @@ const PAGE_SETRIG_ERROR =
|
|
|
2801
2833
|
"@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
|
|
2802
2834
|
'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
|
|
2803
2835
|
|
|
2836
|
+
/** handle.setDepthEnvelope's defaults, metres on the panel (Ride Spatial's depth rules v2). */
|
|
2837
|
+
export const DEPTH_ENVELOPE_DEFAULTS = Object.freeze({ maxFrontM: 0.02, edgeM: 0.001, bandM: 0.016 });
|
|
2838
|
+
|
|
2839
|
+
/**
|
|
2840
|
+
* handle.setDepthEnvelope's argument, validated and merged onto the current one (throws at the
|
|
2841
|
+
* call). A key given replaces; `null` (the whole argument) turns the envelope off. Rects are kept
|
|
2842
|
+
* as given (canvas fractions, y DOWN) plus `f` — [l, r, b, t] canvas-normalised, y UP — for the
|
|
2843
|
+
* shader; more than ENVELOPE_MAX_RECTS keeps the heaviest (then largest) ones.
|
|
2844
|
+
*/
|
|
2845
|
+
export function validateDepthEnvelope(o, prev) {
|
|
2846
|
+
if (o === null) return null;
|
|
2847
|
+
const E = '@displayxr/inline3d/splat: setDepthEnvelope';
|
|
2848
|
+
if (typeof o !== 'object' || Array.isArray(o)) throw new TypeError(`${E} takes an object or null.`);
|
|
2849
|
+
const keys = ['maxFrontM', 'edgeM', 'bandM', 'stage', 'rects', 'viewerM', 'canvasSizeM'];
|
|
2850
|
+
const unknown = Object.keys(o).filter((k) => !keys.includes(k));
|
|
2851
|
+
if (unknown.length) throw new Error(`${E} — unknown option(s) ${unknown.join(', ')}.`);
|
|
2852
|
+
const next = { ...DEPTH_ENVELOPE_DEFAULTS, stage: { x: 0, y: 0, w: 1, h: 1 }, rects: [], viewerM: null, canvasSizeM: null, ...(prev || {}) };
|
|
2853
|
+
const num = (k, v, min, max = Infinity) => {
|
|
2854
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < min || v > max) throw new RangeError(`${E} — ${k} must be a finite number in [${min}, ${max}] (got ${v}).`);
|
|
2855
|
+
return v;
|
|
2856
|
+
};
|
|
2857
|
+
const rect = (r, what) => {
|
|
2858
|
+
if (!r || typeof r !== 'object') throw new TypeError(`${E} — ${what} must be { x, y, w, h }.`);
|
|
2859
|
+
const x = num(`${what}.x`, r.x, -10, 10);
|
|
2860
|
+
const y = num(`${what}.y`, r.y, -10, 10);
|
|
2861
|
+
const w = num(`${what}.w`, r.w, 0, 20);
|
|
2862
|
+
const h = num(`${what}.h`, r.h, 0, 20);
|
|
2863
|
+
return { x, y, w, h, f: [2 * x - 1, 2 * (x + w) - 1, 1 - 2 * (y + h), 1 - 2 * y] };
|
|
2864
|
+
};
|
|
2865
|
+
for (const k of ['maxFrontM', 'edgeM', 'bandM']) if (k in o && o[k] !== undefined) next[k] = num(k, o[k], k === 'bandM' ? 1e-5 : 0, 1);
|
|
2866
|
+
if ('viewerM' in o && o.viewerM !== undefined) next.viewerM = o.viewerM === null ? null : num('viewerM', o.viewerM, 0.05, 10);
|
|
2867
|
+
if ('canvasSizeM' in o && o.canvasSizeM !== undefined) {
|
|
2868
|
+
const c = o.canvasSizeM;
|
|
2869
|
+
if (c === null) next.canvasSizeM = null;
|
|
2870
|
+
else {
|
|
2871
|
+
if (!Array.isArray(c) || c.length !== 2) throw new TypeError(`${E} — canvasSizeM must be [width, height] in metres, or null.`);
|
|
2872
|
+
next.canvasSizeM = [num('canvasSizeM[0]', c[0], 1e-4, 10), num('canvasSizeM[1]', c[1], 1e-4, 10)];
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
if ('stage' in o && o.stage !== undefined) next.stage = o.stage === null ? { x: 0, y: 0, w: 1, h: 1 } : rect(o.stage, 'stage');
|
|
2876
|
+
next.stage = next.stage.f ? next.stage : rect(next.stage, 'stage');
|
|
2877
|
+
if ('rects' in o && o.rects !== undefined) {
|
|
2878
|
+
if (o.rects !== null && !Array.isArray(o.rects)) throw new TypeError(`${E} — rects must be an array.`);
|
|
2879
|
+
next.rects = (o.rects || [])
|
|
2880
|
+
.map((r, i) => ({ ...rect(r, `rects[${i}]`), weight: r.weight === undefined ? 1 : num(`rects[${i}].weight`, r.weight, 0, 1) }))
|
|
2881
|
+
.filter((r) => r.weight > 0 && r.w > 0 && r.h > 0)
|
|
2882
|
+
.sort((a, b) => b.weight - a.weight || b.w * b.h - a.w * a.h)
|
|
2883
|
+
.slice(0, ENVELOPE_MAX_RECTS);
|
|
2884
|
+
}
|
|
2885
|
+
if (next.edgeM > next.maxFrontM) next.edgeM = next.maxFrontM;
|
|
2886
|
+
return next;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/**
|
|
2890
|
+
* handle.setStereo's argument, validated and merged onto the current override (throws at the
|
|
2891
|
+
* call). Returns the new override, or null when nothing is overridden.
|
|
2892
|
+
*/
|
|
2893
|
+
export function validateSetStereo(o, prev) {
|
|
2894
|
+
if (o === null) return null;
|
|
2895
|
+
if (typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setStereo takes an object or null.');
|
|
2896
|
+
// Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
|
|
2897
|
+
const pairs = [
|
|
2898
|
+
['ipdFactor', 'ipdScale'],
|
|
2899
|
+
['parallaxFactor', 'parallaxScale'],
|
|
2900
|
+
];
|
|
2901
|
+
const keys = pairs.flat();
|
|
2902
|
+
const unknown = Object.keys(o).filter((k) => !keys.includes(k));
|
|
2903
|
+
if (unknown.length) throw new Error(`@displayxr/inline3d/splat: setStereo — unknown option(s) ${unknown.join(', ')}.`);
|
|
2904
|
+
for (const [a, b] of pairs) {
|
|
2905
|
+
if (o[a] != null && o[b] != null) throw new Error(`@displayxr/inline3d/splat: setStereo — ${a} and ${b} are exclusive.`);
|
|
2906
|
+
}
|
|
2907
|
+
const next = { ...(prev || {}) };
|
|
2908
|
+
for (const [a, b] of pairs) {
|
|
2909
|
+
for (const k of [a, b]) {
|
|
2910
|
+
if (!(k in o) || o[k] === undefined) continue;
|
|
2911
|
+
if (o[k] === null) {
|
|
2912
|
+
delete next[k];
|
|
2913
|
+
continue;
|
|
2914
|
+
}
|
|
2915
|
+
if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
|
|
2916
|
+
throw new RangeError(`@displayxr/inline3d/splat: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
|
|
2917
|
+
}
|
|
2918
|
+
next[k] = o[k];
|
|
2919
|
+
delete next[k === a ? b : a];
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
return Object.keys(next).length ? next : null;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2804
2925
|
/**
|
|
2805
2926
|
* handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
|
|
2806
2927
|
* @returns {{type:'display'|'camera'|'auto', o?:object}}
|
|
@@ -3072,6 +3193,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3072
3193
|
nearClip: opts.nearClip,
|
|
3073
3194
|
farClip: opts.farClip,
|
|
3074
3195
|
sky: opts.sky,
|
|
3196
|
+
viewerEase: opts.viewerEase,
|
|
3075
3197
|
});
|
|
3076
3198
|
|
|
3077
3199
|
let handle = null;
|
|
@@ -3207,6 +3329,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3207
3329
|
setSource,
|
|
3208
3330
|
prepareSource,
|
|
3209
3331
|
setRig,
|
|
3332
|
+
setStereo,
|
|
3333
|
+
setDepthEnvelope,
|
|
3334
|
+
/**
|
|
3335
|
+
* This tile in physical units (core TileHandle.displayMetrics, from getDisplayInfo()). Never
|
|
3336
|
+
* rejects: defaults, flagged in `source`, where the display cannot say.
|
|
3337
|
+
*/
|
|
3338
|
+
async displayMetrics() {
|
|
3339
|
+
let info = null;
|
|
3340
|
+
try {
|
|
3341
|
+
info = (await handle?.getDisplayInfo?.()) ?? null;
|
|
3342
|
+
} catch {
|
|
3343
|
+
/* no display API / no live layer: the defaults */
|
|
3344
|
+
}
|
|
3345
|
+
return displayMetricsFrom(info, canvas.getBoundingClientRect(), globalThis.devicePixelRatio || 1);
|
|
3346
|
+
},
|
|
3210
3347
|
setVideo,
|
|
3211
3348
|
/**
|
|
3212
3349
|
* Draw a layer of this tile's engine through the DISPLAY rig (round, physical-depth stage
|
|
@@ -3394,9 +3531,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3394
3531
|
let initialViewRig = null; // the rig addScene is created with (controls:'page'), else the shorthand
|
|
3395
3532
|
if (wall && wall.supported) {
|
|
3396
3533
|
const onFrame = diag
|
|
3397
|
-
? (views, layer) => {
|
|
3534
|
+
? (views, layer, frame) => {
|
|
3398
3535
|
const t = performance.now();
|
|
3399
|
-
viewer.onFrame(views, layer);
|
|
3536
|
+
viewer.onFrame(views, layer, frame);
|
|
3400
3537
|
diag.frame(views, t); // after the draw: the overlay state is what this frame showed
|
|
3401
3538
|
}
|
|
3402
3539
|
: viewer.onFrame;
|
|
@@ -3939,6 +4076,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3939
4076
|
canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
|
|
3940
4077
|
});
|
|
3941
4078
|
if (type !== 'auto') resolved.typeSource = 'setRig';
|
|
4079
|
+
// What the asset (or addSplat's options) asked for, kept so setStereo(null) can go back to it.
|
|
4080
|
+
resolved.stereoDefault = { ipdFactor: resolved.ipdFactor, parallaxFactor: resolved.parallaxFactor };
|
|
4081
|
+
applyStereoTo(resolved);
|
|
3942
4082
|
resolved.focusDefault = resolved.focus.slice();
|
|
3943
4083
|
resolved.focusDefaultSource = resolved.focusSource;
|
|
3944
4084
|
return resolved;
|
|
@@ -3955,7 +4095,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3955
4095
|
}
|
|
3956
4096
|
|
|
3957
4097
|
/** Declare a DISPLAY rig to the runtime, unless that exact one is already declared. */
|
|
3958
|
-
function declareDisplay(
|
|
4098
|
+
function declareDisplay(base, { raw = false } = {}) {
|
|
4099
|
+
// raw: setVideo's own display rig — not the page's rig, so neither setStereo's factors nor
|
|
4100
|
+
// the base setStereo re-declares from.
|
|
4101
|
+
if (!raw) lastDisplayBase = base;
|
|
4102
|
+
const f =
|
|
4103
|
+
stereoOverride && !raw
|
|
4104
|
+
? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd'), parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') }
|
|
4105
|
+
: base;
|
|
3959
4106
|
const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
|
|
3960
4107
|
if (declaredDisplay === key) return;
|
|
3961
4108
|
// Back at the boot rig from the shorthand it was built with: nothing to say.
|
|
@@ -4022,6 +4169,132 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4022
4169
|
declareDisplay(disp || { vH: bootFraming.vH });
|
|
4023
4170
|
}
|
|
4024
4171
|
|
|
4172
|
+
// ── handle.setStereo: live stereo strength on whatever rig is declared ──
|
|
4173
|
+
/** { ipdFactor?, parallaxFactor? } set by handle.setStereo — sticky across setSource; null = none. */
|
|
4174
|
+
let stereoOverride = null;
|
|
4175
|
+
/** The display rig declareDisplay was last asked for, BEFORE setStereo's factors (to re-declare). */
|
|
4176
|
+
let lastDisplayBase = null;
|
|
4177
|
+
/** setStereo's factors onto a resolved rig, over the asset's own (stereoDefault). */
|
|
4178
|
+
function applyStereoTo(r) {
|
|
4179
|
+
const d = r.stereoDefault;
|
|
4180
|
+
r.ipdFactor = stereoScalar(d.ipdFactor, 'ipd');
|
|
4181
|
+
r.parallaxFactor = stereoScalar(d.parallaxFactor, 'parallax');
|
|
4182
|
+
}
|
|
4183
|
+
/** One scalar under setStereo: its absolute value, else `base` × its scale, else `base`. */
|
|
4184
|
+
function stereoScalar(base, k) {
|
|
4185
|
+
const o = stereoOverride;
|
|
4186
|
+
if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
|
|
4187
|
+
if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
|
|
4188
|
+
return base;
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
/**
|
|
4192
|
+
* handle.setStereo({ ipdFactor, parallaxFactor }) — re-declare the rig in force with new stereo
|
|
4193
|
+
* scalars, next frame, with no cut: the pose, focus and framing stay. A key given replaces, a key
|
|
4194
|
+
* set to null goes back to the asset's own value, `null` clears both. Sticky across setSource and
|
|
4195
|
+
* setRig. On a camera rig the values are ABSOLUTE (like addSplat's options); on a display rig
|
|
4196
|
+
* they replace the declared display rig's factors.
|
|
4197
|
+
*/
|
|
4198
|
+
function setStereo(o) {
|
|
4199
|
+
const next = validateSetStereo(o, stereoOverride);
|
|
4200
|
+
stereoOverride = next;
|
|
4201
|
+
if (out.rig?.stereoDefault) applyStereoTo(out.rig);
|
|
4202
|
+
redeclareStereo();
|
|
4203
|
+
return out;
|
|
4204
|
+
}
|
|
4205
|
+
/** Re-declare the rig in force with setStereo's factors (deduplicated on a display rig). */
|
|
4206
|
+
function redeclareStereo() {
|
|
4207
|
+
if (pageMode || vid?.on || !out.rig) return; // pageTick reads out.rig; video exit calls this
|
|
4208
|
+
if (out.rig.type === 'camera') pushViewRig(true);
|
|
4209
|
+
else if (lastDisplayBase) declareDisplay(lastDisplayBase);
|
|
4210
|
+
}
|
|
4211
|
+
|
|
4212
|
+
// ── handle.setDepthEnvelope: the frame envelope (./inline3d-splat-effects.js EFFECTS.envelope) ──
|
|
4213
|
+
/** validateDepthEnvelope's result, or null (off). */
|
|
4214
|
+
let envelope = null;
|
|
4215
|
+
/** displayMetricsFrom(getDisplayInfo()) — metres per CSS px and the viewer; defaults until it lands. */
|
|
4216
|
+
let envMetrics = null;
|
|
4217
|
+
let envMetricsSeq = 0;
|
|
4218
|
+
function refreshEnvelopeMetrics() {
|
|
4219
|
+
const seq = ++envMetricsSeq;
|
|
4220
|
+
const done = (info) => {
|
|
4221
|
+
if (seq === envMetricsSeq) envMetrics = displayMetricsFrom(info ?? null, null, globalThis.devicePixelRatio || 1);
|
|
4222
|
+
};
|
|
4223
|
+
let p = null;
|
|
4224
|
+
try {
|
|
4225
|
+
p = handle?.getDisplayInfo?.();
|
|
4226
|
+
} catch {
|
|
4227
|
+
/* no display API: the defaults */
|
|
4228
|
+
}
|
|
4229
|
+
if (p && typeof p.then === 'function') p.then(done, () => done(null));
|
|
4230
|
+
else done(null);
|
|
4231
|
+
}
|
|
4232
|
+
/**
|
|
4233
|
+
* The envelope's inputs for this frame, all from the DECLARED rig (never the tracked eyes): null
|
|
4234
|
+
* = off — no envelope, not a camera rig (a display rig is a portal; its depth is the page's), a
|
|
4235
|
+
* stereo video holding the rig, no disparity to cap (ipdFactor 0), or no canvas box.
|
|
4236
|
+
*/
|
|
4237
|
+
function envelopeState() {
|
|
4238
|
+
const E = envelope;
|
|
4239
|
+
const r = out.viewRig;
|
|
4240
|
+
if (!E || !r || r.type !== 'camera' || vid?.on) return null;
|
|
4241
|
+
const k = (r.ipdFactor ?? 1) * (r.metersToVirtual ?? 1);
|
|
4242
|
+
if (!(k > 0) || !(r.verticalFov > 0)) return null;
|
|
4243
|
+
// clientWidth/Height: no layout flush on a clean frame (a real canvas always has them)
|
|
4244
|
+
const box = canvas.clientWidth === undefined ? canvas.getBoundingClientRect() : null;
|
|
4245
|
+
const cw = box ? box.width : canvas.clientWidth;
|
|
4246
|
+
const ch = box ? box.height : canvas.clientHeight;
|
|
4247
|
+
if (!(cw > 0 && ch > 0)) return null;
|
|
4248
|
+
const m = envMetrics || displayMetricsFrom(null, null, 1);
|
|
4249
|
+
const W = E.canvasSizeM ? E.canvasSizeM[0] : cw * m.metersPerCssPx;
|
|
4250
|
+
const H = E.canvasSizeM ? E.canvasSizeM[1] : ch * m.metersPerCssPx;
|
|
4251
|
+
const tanY = Math.tan(r.verticalFov / 2);
|
|
4252
|
+
const fr = viewer.monoFrame();
|
|
4253
|
+
return {
|
|
4254
|
+
origin: fr.origin,
|
|
4255
|
+
axis: fr.axis,
|
|
4256
|
+
right: fr.right,
|
|
4257
|
+
up: fr.up,
|
|
4258
|
+
tanX: tanY * (cw / ch),
|
|
4259
|
+
tanY,
|
|
4260
|
+
halfW: W / 2,
|
|
4261
|
+
halfH: H / 2,
|
|
4262
|
+
// world units = rig units × fr.scale: the convergence and the eye baseline both scale with it
|
|
4263
|
+
invD: (r.convergenceDiopters || 0) / fr.scale,
|
|
4264
|
+
kfp: fr.scale * k * (H / (2 * tanY)),
|
|
4265
|
+
viewer: E.viewerM ?? m.nominalViewerM,
|
|
4266
|
+
maxFront: E.maxFrontM,
|
|
4267
|
+
stage: E.stage.f,
|
|
4268
|
+
band: E.bandM,
|
|
4269
|
+
edge: E.edgeM,
|
|
4270
|
+
rects: E.rects,
|
|
4271
|
+
};
|
|
4272
|
+
}
|
|
4273
|
+
/** Install / update / remove the tile-scope envelope effect (no-op until the effects exist). */
|
|
4274
|
+
function syncEnvelope() {
|
|
4275
|
+
if (!fx) return;
|
|
4276
|
+
fx.setInternal('tile', 'envelope', envelope ? { state: envelopeState } : null);
|
|
4277
|
+
const inst = fx.scopes.get('tile')?.get('envelope');
|
|
4278
|
+
if (inst) inst.hidden = true; // the SDK's, not the page's: never in effects(), never stopEffect()ed
|
|
4279
|
+
}
|
|
4280
|
+
/**
|
|
4281
|
+
* handle.setDepthEnvelope(opts | null) — cap how far the splat may come OUT of the glass by where
|
|
4282
|
+
* it lands on the canvas (docs/splat-effects.md §Depth envelope). Tile-wide and LIVE: every asset
|
|
4283
|
+
* shown — an incoming one included, from its first frame, and both sides of a crossfade — is
|
|
4284
|
+
* judged through the rig declared right now. A key given replaces; null turns it off.
|
|
4285
|
+
*/
|
|
4286
|
+
function setDepthEnvelope(o) {
|
|
4287
|
+
const next = validateDepthEnvelope(o, envelope);
|
|
4288
|
+
if (next && !envelope) {
|
|
4289
|
+
refreshEnvelopeMetrics();
|
|
4290
|
+
// a lazy layer may not answer getDisplayInfo yet: ask again once the tile is woven
|
|
4291
|
+
Promise.resolve(out.firstWoven).then(() => envelope && refreshEnvelopeMetrics());
|
|
4292
|
+
}
|
|
4293
|
+
envelope = next;
|
|
4294
|
+
syncEnvelope();
|
|
4295
|
+
return out;
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4025
4298
|
// ── handle.setRig: a live, reversible rig switch (docs/playcanvas-adapter.md §setRig) ──
|
|
4026
4299
|
/** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
|
|
4027
4300
|
let rigOverride = null;
|
|
@@ -4230,6 +4503,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4230
4503
|
const kept = applyLoaded(loaded);
|
|
4231
4504
|
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
4232
4505
|
fx = makeEffects(pcModule);
|
|
4506
|
+
syncEnvelope(); // a setDepthEnvelope before the first asset
|
|
4233
4507
|
// `reveal`: installed at its START state before the asset's first frame, played once the
|
|
4234
4508
|
// tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
|
|
4235
4509
|
if (revealSpec) {
|
|
@@ -4363,7 +4637,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4363
4637
|
v._placeMonoForFit();
|
|
4364
4638
|
v._updateMonoProjection();
|
|
4365
4639
|
}
|
|
4366
|
-
declareDisplay({ vH: state.vH });
|
|
4640
|
+
declareDisplay({ vH: state.vH }, { raw: true });
|
|
4641
|
+
state.stereoAtEnter = stereoOverride;
|
|
4367
4642
|
state.on = true;
|
|
4368
4643
|
}
|
|
4369
4644
|
|
|
@@ -4397,6 +4672,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4397
4672
|
} else out.viewRig = s.viewRig;
|
|
4398
4673
|
lastConvergence = s.lastConvergence;
|
|
4399
4674
|
state.on = false;
|
|
4675
|
+
if (stereoOverride !== state.stereoAtEnter) redeclareStereo(); // a setStereo made while the video held the rig
|
|
4676
|
+
|
|
4400
4677
|
}
|
|
4401
4678
|
|
|
4402
4679
|
/** Tear the video down now (setVideo(null) / remove()). */
|
|
@@ -4455,6 +4732,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4455
4732
|
// Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
|
|
4456
4733
|
state.saved = live.saved;
|
|
4457
4734
|
state.on = true;
|
|
4735
|
+
state.stereoAtEnter = live.stereoAtEnter;
|
|
4458
4736
|
// S2: the plane outlives this swap; the replaced video's rect subscriptions go with it.
|
|
4459
4737
|
for (const off of live.rectOffs) off();
|
|
4460
4738
|
live.rectOffs.clear();
|
|
@@ -4463,7 +4741,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4463
4741
|
viewer.vH = state.vH;
|
|
4464
4742
|
viewer._placeMonoForFit();
|
|
4465
4743
|
viewer._updateMonoProjection();
|
|
4466
|
-
declareDisplay({ vH: state.vH });
|
|
4744
|
+
declareDisplay({ vH: state.vH }, { raw: true });
|
|
4467
4745
|
}
|
|
4468
4746
|
} else enterVideo(state);
|
|
4469
4747
|
vid = state;
|
package/js/inline3d-splat.js
CHANGED
|
@@ -246,6 +246,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
246
246
|
renderScale,
|
|
247
247
|
feather,
|
|
248
248
|
logTag: '[inline3d/splat]',
|
|
249
|
+
viewerEase: opts.viewerEase,
|
|
249
250
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
250
251
|
|
|
251
252
|
// Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
|
|
@@ -400,6 +401,20 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
400
401
|
"(addSplat(…, { engine:'playcanvas' })).",
|
|
401
402
|
);
|
|
402
403
|
},
|
|
404
|
+
/** Not on this backend: the depth envelope is a PlayCanvas-backend feature. */
|
|
405
|
+
setDepthEnvelope() {
|
|
406
|
+
throw new Error(
|
|
407
|
+
"@displayxr/inline3d/splat: setDepthEnvelope() is implemented on the PlayCanvas backend only " +
|
|
408
|
+
"(addSplat(…, { engine:'playcanvas' })).",
|
|
409
|
+
);
|
|
410
|
+
},
|
|
411
|
+
/** Not on this backend: live stereo strength is a PlayCanvas-backend feature. */
|
|
412
|
+
setStereo() {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"@displayxr/inline3d/splat: setStereo() is implemented on the PlayCanvas backend only " +
|
|
415
|
+
"(addSplat(…, { engine:'playcanvas' })).",
|
|
416
|
+
);
|
|
417
|
+
},
|
|
403
418
|
/** Not on this backend (splat effects are PlayCanvas-only in this version). */
|
|
404
419
|
playEffect() {
|
|
405
420
|
throw effectsNotOnSpark('playEffect()');
|