@displayxr/inline3d 1.46.0 → 1.47.1
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 +88 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/depth.d.ts +184 -0
- package/dist/call.js +6 -6
- package/docs/making-3d-look-right.md +49 -0
- package/docs/woven-canvas-rules.md +378 -0
- package/index.d.ts +22 -3
- package/js/inline3d-depth-budget.js +342 -0
- package/js/inline3d-model-entry.js +15 -1
- package/js/inline3d-model-playcanvas.js +27 -2
- package/js/inline3d-model.js +22 -2
- package/js/inline3d-splat-deferred.js +2 -0
- package/js/inline3d-splat-pc-entry.js +2 -1
- package/js/inline3d-splat-playcanvas.js +47 -43
- package/js/inline3d-splat-shared.js +311 -0
- package/js/inline3d-splat.js +31 -3
- package/js/inline3d-three.js +30 -10
- package/js/inline3d-viewer.js +13 -0
- package/js/inline3d.js +35 -0
- package/llms.txt +21 -0
- package/model.d.ts +53 -0
- package/package.json +10 -1
- package/splat.d.ts +33 -0
- package/three.d.ts +7 -2
- package/viewer.d.ts +11 -0
|
@@ -119,6 +119,12 @@ import {
|
|
|
119
119
|
normalizeCameraPose,
|
|
120
120
|
coverageExponent,
|
|
121
121
|
declareViewRig,
|
|
122
|
+
parseClearColor,
|
|
123
|
+
validateSetStereo,
|
|
124
|
+
stereoScalarOf,
|
|
125
|
+
attachComfortDepth,
|
|
126
|
+
resolveDepthOption,
|
|
127
|
+
framedSubjectBounds,
|
|
122
128
|
} from './inline3d-splat-shared.js';
|
|
123
129
|
import { resolveCursorOption } from './inline3d-cursor-option.js';
|
|
124
130
|
|
|
@@ -827,6 +833,10 @@ export class PlayCanvasSplatViewer {
|
|
|
827
833
|
cursor = null,
|
|
828
834
|
} = opts;
|
|
829
835
|
this.canvas = canvas;
|
|
836
|
+
// `clearColor` (opt-in): the eye cameras' clear, [r,g,b,a] in 0..1, or null = the transparent
|
|
837
|
+
// clear every tile had before it existed. An OPAQUE clear avoids stale / black pixels where the
|
|
838
|
+
// weave reads an alpha-0 hole (web#198).
|
|
839
|
+
this.clearColor = parseClearColor(opts.clearColor, opts.logTag || '[inline3d/splat]');
|
|
830
840
|
// `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
|
|
831
841
|
// is imported only when asked for (attachEngine), and every per-frame hook is an optional call
|
|
832
842
|
// on `cursorDepth`, which stays null without it.
|
|
@@ -1984,7 +1994,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1984
1994
|
const pc = this.pc;
|
|
1985
1995
|
const e = new pc.Entity(name, this.app);
|
|
1986
1996
|
e.addComponent('camera', {
|
|
1987
|
-
clearColor: new pc.Color(0, 0, 0, 0),
|
|
1997
|
+
clearColor: this.clearColor ? new pc.Color(this.clearColor[0], this.clearColor[1], this.clearColor[2], this.clearColor[3]) : new pc.Color(0, 0, 0, 0),
|
|
1988
1998
|
nearClip: MONO_NEAR,
|
|
1989
1999
|
farClip: MONO_FAR,
|
|
1990
2000
|
fov: MONO_FOV,
|
|
@@ -3125,41 +3135,9 @@ export function validateWarmEffects(list) {
|
|
|
3125
3135
|
});
|
|
3126
3136
|
}
|
|
3127
3137
|
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
*/
|
|
3132
|
-
export function validateSetStereo(o, prev) {
|
|
3133
|
-
if (o === null) return null;
|
|
3134
|
-
if (typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setStereo takes an object or null.');
|
|
3135
|
-
// Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
|
|
3136
|
-
const pairs = [
|
|
3137
|
-
['ipdFactor', 'ipdScale'],
|
|
3138
|
-
['parallaxFactor', 'parallaxScale'],
|
|
3139
|
-
];
|
|
3140
|
-
const keys = pairs.flat();
|
|
3141
|
-
const unknown = Object.keys(o).filter((k) => !keys.includes(k));
|
|
3142
|
-
if (unknown.length) throw new Error(`@displayxr/inline3d/splat: setStereo — unknown option(s) ${unknown.join(', ')}.`);
|
|
3143
|
-
for (const [a, b] of pairs) {
|
|
3144
|
-
if (o[a] != null && o[b] != null) throw new Error(`@displayxr/inline3d/splat: setStereo — ${a} and ${b} are exclusive.`);
|
|
3145
|
-
}
|
|
3146
|
-
const next = { ...(prev || {}) };
|
|
3147
|
-
for (const [a, b] of pairs) {
|
|
3148
|
-
for (const k of [a, b]) {
|
|
3149
|
-
if (!(k in o) || o[k] === undefined) continue;
|
|
3150
|
-
if (o[k] === null) {
|
|
3151
|
-
delete next[k];
|
|
3152
|
-
continue;
|
|
3153
|
-
}
|
|
3154
|
-
if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
|
|
3155
|
-
throw new RangeError(`@displayxr/inline3d/splat: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
|
|
3156
|
-
}
|
|
3157
|
-
next[k] = o[k];
|
|
3158
|
-
delete next[k === a ? b : a];
|
|
3159
|
-
}
|
|
3160
|
-
}
|
|
3161
|
-
return Object.keys(next).length ? next : null;
|
|
3162
|
-
}
|
|
3138
|
+
// validateSetStereo lives in ./inline3d-splat-shared.js now (./model's setStereo shares it); it is
|
|
3139
|
+
// re-exported from here so every name this module exported before still resolves.
|
|
3140
|
+
export { validateSetStereo };
|
|
3163
3141
|
|
|
3164
3142
|
/**
|
|
3165
3143
|
* handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
|
|
@@ -3403,6 +3381,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3403
3381
|
// an advanced caller may reach this function directly.
|
|
3404
3382
|
const ctl = resolveControls(opts);
|
|
3405
3383
|
const pageMode = ctl.page;
|
|
3384
|
+
const depthOption = resolveDepthOption(opts.depth);
|
|
3406
3385
|
if (pageMode && ctl.ignored.length) {
|
|
3407
3386
|
console.info(
|
|
3408
3387
|
`[inline3d/splat] controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ` +
|
|
@@ -3434,10 +3413,27 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3434
3413
|
sky: opts.sky,
|
|
3435
3414
|
viewerEase: opts.viewerEase,
|
|
3436
3415
|
cursor: opts.cursor,
|
|
3416
|
+
// Opt-in; undefined = the viewer's own default (a transparent clear).
|
|
3417
|
+
clearColor: opts.clearColor,
|
|
3437
3418
|
});
|
|
3438
3419
|
|
|
3439
3420
|
let handle = null;
|
|
3440
3421
|
let unbindFocusInput = null;
|
|
3422
|
+
// `depth: 'auto'` (opt-in): the comfort factor, multiplied under setStereo's on the DISPLAY rig
|
|
3423
|
+
// declareDisplay sends (camera rigs and setVideo's own rig never carry it). Without the option:
|
|
3424
|
+
// inert, `depthFactor` 1, nothing measured, nothing declared.
|
|
3425
|
+
const depth = attachComfortDepth(out, {
|
|
3426
|
+
option: depthOption,
|
|
3427
|
+
canvas,
|
|
3428
|
+
getTile: () => handle,
|
|
3429
|
+
getBounds: () => (out.rig && out.rig.type !== 'camera' && !vid?.on ? framedSubjectBounds(viewer) : null),
|
|
3430
|
+
getVH: () => viewer.vH,
|
|
3431
|
+
apply: () => {
|
|
3432
|
+
if (!pageMode && !vid?.on && out.rig?.type !== 'camera' && lastDisplayBase) declareDisplay(lastDisplayBase);
|
|
3433
|
+
},
|
|
3434
|
+
tag: '[inline3d/splat]',
|
|
3435
|
+
});
|
|
3436
|
+
if (pageMode) depth.ignore("controls:'page' (the page's camera rig)");
|
|
3441
3437
|
|
|
3442
3438
|
// ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
|
|
3443
3439
|
const diagCfg = resolveDiag(opts.diag);
|
|
@@ -3705,6 +3701,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3705
3701
|
effects: () => (fx ? fx.list() : []),
|
|
3706
3702
|
remove() {
|
|
3707
3703
|
removed = true;
|
|
3704
|
+
depth.dispose();
|
|
3708
3705
|
dropVideo(); // before the app goes: the plane's rVFC, and a <video> this handle made
|
|
3709
3706
|
current = null;
|
|
3710
3707
|
for (const p of [...livePrepared]) p.dispose();
|
|
@@ -3986,6 +3983,10 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3986
3983
|
// three looks down −z; convergence is the focus's distance along that axis (the PLANE).
|
|
3987
3984
|
const fwd = [-pose[8], -pose[9], -pose[10]];
|
|
3988
3985
|
const d = (f.x - pose[12]) * fwd[0] + (f.y - pose[13]) * fwd[1] + (f.z - pose[14]) * fwd[2];
|
|
3986
|
+
// A focus at or behind the camera has no zero-disparity plane in front of it: a transient
|
|
3987
|
+
// before the first focus lands, or a focus moved behind the camera. Keep the rig as declared;
|
|
3988
|
+
// convergence at infinity would put the whole scene in front of the glass.
|
|
3989
|
+
if (!(d > 0)) return;
|
|
3989
3990
|
if (!force && Math.abs(d - lastConvergence) < 1e-3) return;
|
|
3990
3991
|
lastConvergence = d;
|
|
3991
3992
|
out.rig.convergence = d;
|
|
@@ -3999,7 +4000,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3999
4000
|
fov: viewer.mono.fov,
|
|
4000
4001
|
},
|
|
4001
4002
|
{
|
|
4002
|
-
convergence: d
|
|
4003
|
+
convergence: d,
|
|
4003
4004
|
ipdFactor: out.rig.ipdFactor,
|
|
4004
4005
|
parallaxFactor: out.rig.parallaxFactor,
|
|
4005
4006
|
out: camRigObj,
|
|
@@ -4369,9 +4370,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4369
4370
|
// raw: setVideo's own display rig — not the page's rig, so neither setStereo's factors nor
|
|
4370
4371
|
// the base setStereo re-declares from.
|
|
4371
4372
|
if (!raw) lastDisplayBase = base;
|
|
4373
|
+
// The `depth` option's comfort factor (1 without it), × setStereo's: never on setVideo's rig.
|
|
4374
|
+
const kd = raw ? 1 : depth.factor;
|
|
4372
4375
|
const f =
|
|
4373
|
-
stereoOverride && !raw
|
|
4374
|
-
? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd'), parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') }
|
|
4376
|
+
(stereoOverride || kd !== 1) && !raw
|
|
4377
|
+
? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd') * kd, parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') * kd }
|
|
4375
4378
|
: base;
|
|
4376
4379
|
const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
|
|
4377
4380
|
if (declaredDisplay === key) return;
|
|
@@ -4407,6 +4410,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4407
4410
|
viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
|
|
4408
4411
|
lastConvergence = Number.NaN;
|
|
4409
4412
|
pushViewRig(true);
|
|
4413
|
+
depth.ignore('a camera rig');
|
|
4410
4414
|
return;
|
|
4411
4415
|
}
|
|
4412
4416
|
if (viewer.mono.capture) viewer.useDisplayCamera();
|
|
@@ -4425,6 +4429,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4425
4429
|
resolved.focusDefaultSource = 'frame';
|
|
4426
4430
|
resolved.frame = target;
|
|
4427
4431
|
declareDisplay(disp);
|
|
4432
|
+
depth.resume();
|
|
4428
4433
|
return;
|
|
4429
4434
|
}
|
|
4430
4435
|
// The splat itself, as a display-rig addSplat frames it (the load-time path).
|
|
@@ -4437,6 +4442,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4437
4442
|
}
|
|
4438
4443
|
if (disp) resolved.frame = bounds ? { center: bounds.center.slice(), extent: bounds.extent.slice(), source: 'splat' } : null;
|
|
4439
4444
|
declareDisplay(disp || { vH: bootFraming.vH });
|
|
4445
|
+
if (bounds) depth.resume();
|
|
4440
4446
|
}
|
|
4441
4447
|
|
|
4442
4448
|
// ── handle.setStereo: live stereo strength on whatever rig is declared ──
|
|
@@ -4452,10 +4458,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4452
4458
|
}
|
|
4453
4459
|
/** One scalar under setStereo: its absolute value, else `base` × its scale, else `base`. */
|
|
4454
4460
|
function stereoScalar(base, k) {
|
|
4455
|
-
|
|
4456
|
-
if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
|
|
4457
|
-
if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
|
|
4458
|
-
return base;
|
|
4461
|
+
return stereoScalarOf(stereoOverride, base, k);
|
|
4459
4462
|
}
|
|
4460
4463
|
|
|
4461
4464
|
/**
|
|
@@ -4943,6 +4946,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4943
4946
|
lastConvergence = s.lastConvergence;
|
|
4944
4947
|
state.on = false;
|
|
4945
4948
|
if (stereoOverride !== state.stereoAtEnter) redeclareStereo(); // a setStereo made while the video held the rig
|
|
4949
|
+
depth.refresh(); // the `depth` option holds still under a video; measure again (no-op without it)
|
|
4946
4950
|
|
|
4947
4951
|
}
|
|
4948
4952
|
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// apart on how a drag, a wheel notch, an idle turntable or a focus change feels. Pinned by
|
|
13
13
|
// test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side).
|
|
14
14
|
|
|
15
|
+
import { comfortDepthFactor, isComfortZone, DEPTH_DEFAULT_VIEWER_M } from './inline3d-depth-budget.js';
|
|
16
|
+
|
|
15
17
|
/**
|
|
16
18
|
* Double-attach detection around a scene subpath's public entry (`addSplat`, `addModel`).
|
|
17
19
|
*
|
|
@@ -563,3 +565,312 @@ export function declareViewRig(handle, rig) {
|
|
|
563
565
|
}
|
|
564
566
|
return false;
|
|
565
567
|
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The `clearColor` option of SceneViewer / addModel / addSplat, as `[r, g, b, a]` in 0..1 — or
|
|
571
|
+
* null when absent (the viewer then keeps its transparent clear, exactly as before). Takes
|
|
572
|
+
* `[r, g, b]` / `[r, g, b, a]` (0..1; alpha defaults to 1) or a CSS colour string: `#rgb`,
|
|
573
|
+
* `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` / `rgba()` (comma or space syntax, `/ alpha`),
|
|
574
|
+
* `transparent`, and — where a DOM is present — any other CSS colour the browser can parse.
|
|
575
|
+
* Something it cannot read warns once per call (with `tag`) and returns null: the default clear
|
|
576
|
+
* stays.
|
|
577
|
+
*/
|
|
578
|
+
export function parseClearColor(v, tag = '[inline3d]') {
|
|
579
|
+
if (v === undefined || v === null) return null;
|
|
580
|
+
const bad = () => {
|
|
581
|
+
console.warn(`${tag} clearColor ${JSON.stringify(v)} is not a colour this SDK can read ([r,g,b,a] in 0..1, or a CSS colour); the default (transparent) clear is kept.`);
|
|
582
|
+
return null;
|
|
583
|
+
};
|
|
584
|
+
if (Array.isArray(v) || ArrayBuffer.isView(v)) {
|
|
585
|
+
if (v.length < 3 || v.length > 4) return bad();
|
|
586
|
+
const c = [v[0], v[1], v[2], v.length === 4 ? v[3] : 1];
|
|
587
|
+
if (!c.every((x) => typeof x === 'number' && Number.isFinite(x))) return bad();
|
|
588
|
+
return c.map((x) => clamp(x, 0, 1));
|
|
589
|
+
}
|
|
590
|
+
if (typeof v !== 'string') return bad();
|
|
591
|
+
const s = v.trim().toLowerCase();
|
|
592
|
+
if (s === 'transparent') return [0, 0, 0, 0];
|
|
593
|
+
let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s);
|
|
594
|
+
if (m) {
|
|
595
|
+
let h = m[1];
|
|
596
|
+
if (h.length <= 4) h = [...h].map((ch) => ch + ch).join('');
|
|
597
|
+
const n = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
598
|
+
return [n(0), n(2), n(4), h.length === 8 ? n(6) : 1];
|
|
599
|
+
}
|
|
600
|
+
m = /^rgba?\(\s*([^)]*)\)$/.exec(s);
|
|
601
|
+
if (m) {
|
|
602
|
+
const parts = m[1].split(/\s*[,/]\s*|\s+/).filter(Boolean);
|
|
603
|
+
if (parts.length < 3 || parts.length > 4) return bad();
|
|
604
|
+
const ch = (p) => (p.endsWith('%') ? parseFloat(p) / 100 : parseFloat(p) / 255);
|
|
605
|
+
const al = (p) => (p.endsWith('%') ? parseFloat(p) / 100 : parseFloat(p));
|
|
606
|
+
const c = [ch(parts[0]), ch(parts[1]), ch(parts[2]), parts.length === 4 ? al(parts[3]) : 1];
|
|
607
|
+
if (!c.every(Number.isFinite)) return bad();
|
|
608
|
+
return c.map((x) => clamp(x, 0, 1));
|
|
609
|
+
}
|
|
610
|
+
// Any other CSS colour (a name, hsl(), …): let the browser normalise it, where there is one.
|
|
611
|
+
// A 2D context's fillStyle reads back as #rrggbb or rgba(…), both handled above.
|
|
612
|
+
try {
|
|
613
|
+
const ctx = globalThis.document?.createElement?.('canvas')?.getContext?.('2d');
|
|
614
|
+
if (ctx) {
|
|
615
|
+
ctx.fillStyle = '#010203';
|
|
616
|
+
ctx.fillStyle = v;
|
|
617
|
+
const out = String(ctx.fillStyle);
|
|
618
|
+
if (out !== '#010203') return parseClearColor(out, tag);
|
|
619
|
+
}
|
|
620
|
+
} catch {
|
|
621
|
+
/* no DOM */
|
|
622
|
+
}
|
|
623
|
+
return bad();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* `handle.setStereo`'s argument, validated and merged onto the current override (throws at the
|
|
628
|
+
* call). Returns the new override, or null when nothing is overridden. Shared by `./splat`
|
|
629
|
+
* (PlayCanvas) and `./model` (both engines); `tag` names the subpath in the errors.
|
|
630
|
+
*/
|
|
631
|
+
export function validateSetStereo(o, prev, tag = '@displayxr/inline3d/splat') {
|
|
632
|
+
if (o === null) return null;
|
|
633
|
+
if (typeof o !== 'object') throw new TypeError(`${tag}: setStereo takes an object or null.`);
|
|
634
|
+
// Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
|
|
635
|
+
const pairs = [
|
|
636
|
+
['ipdFactor', 'ipdScale'],
|
|
637
|
+
['parallaxFactor', 'parallaxScale'],
|
|
638
|
+
];
|
|
639
|
+
const keys = pairs.flat();
|
|
640
|
+
const unknown = Object.keys(o).filter((k) => !keys.includes(k));
|
|
641
|
+
if (unknown.length) throw new Error(`${tag}: setStereo — unknown option(s) ${unknown.join(', ')}.`);
|
|
642
|
+
for (const [a, b] of pairs) {
|
|
643
|
+
if (o[a] != null && o[b] != null) throw new Error(`${tag}: setStereo — ${a} and ${b} are exclusive.`);
|
|
644
|
+
}
|
|
645
|
+
const next = { ...(prev || {}) };
|
|
646
|
+
for (const [a, b] of pairs) {
|
|
647
|
+
for (const k of [a, b]) {
|
|
648
|
+
if (!(k in o) || o[k] === undefined) continue;
|
|
649
|
+
if (o[k] === null) {
|
|
650
|
+
delete next[k];
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
|
|
654
|
+
throw new RangeError(`${tag}: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
|
|
655
|
+
}
|
|
656
|
+
next[k] = o[k];
|
|
657
|
+
delete next[k === a ? b : a];
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return Object.keys(next).length ? next : null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** One scalar under a setStereo override: its absolute value, else `base` × its scale, else `base`. */
|
|
664
|
+
export function stereoScalarOf(o, base, k) {
|
|
665
|
+
if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
|
|
666
|
+
if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
|
|
667
|
+
return base;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* `./model`'s `handle.setStereo` + `handle.rig`, for both engines: `out` the model handle,
|
|
672
|
+
* `getTile()` the core window handle (null without a session), `vH` the tile's
|
|
673
|
+
* virtualDisplayHeight, `makeDisplayRig` ./three's displayRig. A model has no stereo of its own,
|
|
674
|
+
* so the "asset's own" values are 1 and 1 — `ipdScale` and `ipdFactor` agree.
|
|
675
|
+
*
|
|
676
|
+
* The rig is the display rig addModel declared with its `virtualDisplayHeight` shorthand; a value
|
|
677
|
+
* set here re-declares it as an explicit display rig with those factors (RELATIVE: 1 = what the
|
|
678
|
+
* display shows by construction). `setStereo(null)` declares the plain rig again — the exact
|
|
679
|
+
* equivalent of the shorthand. Nothing is sent before the first call: a page that never calls it
|
|
680
|
+
* declares exactly what it did before. Under `controls: 'page'` the camera rig the page drives
|
|
681
|
+
* takes the values instead, from its next tick.
|
|
682
|
+
*
|
|
683
|
+
* `setBase(k)` is the `depth` option's comfort factor: both scalars × k, on top of setStereo's,
|
|
684
|
+
* declared like a setStereo call (and, like one, nothing until it is called with k ≠ 1).
|
|
685
|
+
*/
|
|
686
|
+
export function attachModelStereo(out, { getTile, vH, makeDisplayRig, page = false, tag = '@displayxr/inline3d/model' }) {
|
|
687
|
+
let override = null;
|
|
688
|
+
let declared = false;
|
|
689
|
+
// The `depth` option's comfort factor (attachComfortDepth), multiplied under setStereo's values;
|
|
690
|
+
// 1 = nothing to declare on its account.
|
|
691
|
+
let base = 1;
|
|
692
|
+
// controls:'page' declares its own camera rig every tick and reads rig.ipdFactor / parallaxFactor
|
|
693
|
+
// there (ABSOLUTE on a camera rig, as addSplat's); nothing is declared from here.
|
|
694
|
+
const rig = { type: page ? 'camera' : 'display', ipdFactor: 1, parallaxFactor: 1, stereoDefault: Object.freeze({ ipdFactor: 1, parallaxFactor: 1 }) };
|
|
695
|
+
out.rig = rig;
|
|
696
|
+
const declare = () => {
|
|
697
|
+
const tile = getTile();
|
|
698
|
+
if (page || !tile || (!override && !declared && base === 1)) return;
|
|
699
|
+
declared = true;
|
|
700
|
+
out.viewRig = makeDisplayRig({ virtualDisplayHeight: vH, ipdFactor: rig.ipdFactor, parallaxFactor: rig.parallaxFactor, out: out.viewRig || {} });
|
|
701
|
+
declareViewRig(tile, out.viewRig);
|
|
702
|
+
};
|
|
703
|
+
const factors = () => {
|
|
704
|
+
rig.ipdFactor = stereoScalarOf(override, 1, 'ipd') * base;
|
|
705
|
+
rig.parallaxFactor = stereoScalarOf(override, 1, 'parallax') * base;
|
|
706
|
+
};
|
|
707
|
+
out.setStereo = (o) => {
|
|
708
|
+
override = validateSetStereo(o, override, tag);
|
|
709
|
+
factors();
|
|
710
|
+
declare();
|
|
711
|
+
return out;
|
|
712
|
+
};
|
|
713
|
+
return {
|
|
714
|
+
redeclare: declare,
|
|
715
|
+
get override() {
|
|
716
|
+
return override;
|
|
717
|
+
},
|
|
718
|
+
/** The comfort factor (both scalars move together), × setStereo's; re-declares when it changes. */
|
|
719
|
+
setBase(k) {
|
|
720
|
+
if (k === base) return;
|
|
721
|
+
base = k;
|
|
722
|
+
factors();
|
|
723
|
+
declare();
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── the `depth` option: comfort depth (./model, ./splat) ──────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* addModel / addSplat's `depth` option, validated (throws at the call, like every option check):
|
|
732
|
+
* null when absent — nothing about the tile changes — else `{ zone }`. `'auto'` is the Shibata
|
|
733
|
+
* zone; `{ zone }` takes `'shibata'`, `'percival'` or a number of diopters > 0.
|
|
734
|
+
*/
|
|
735
|
+
export function resolveDepthOption(v, tag = '@displayxr/inline3d/splat') {
|
|
736
|
+
if (v === undefined || v === null) return null;
|
|
737
|
+
if (v === 'auto') return { zone: 'shibata' };
|
|
738
|
+
if (typeof v !== 'object' || Array.isArray(v)) {
|
|
739
|
+
throw new TypeError(`${tag}: depth must be 'auto' or { zone } (got ${JSON.stringify(v)}).`);
|
|
740
|
+
}
|
|
741
|
+
const unknown = Object.keys(v).filter((k) => k !== 'zone');
|
|
742
|
+
if (unknown.length) throw new Error(`${tag}: depth — unknown option(s) ${unknown.join(', ')}.`);
|
|
743
|
+
const zone = v.zone === undefined ? 'shibata' : v.zone;
|
|
744
|
+
if (!isComfortZone(zone)) {
|
|
745
|
+
throw new RangeError(`${tag}: depth.zone must be 'shibata', 'percival' or a number of diopters > 0 (got ${JSON.stringify(v.zone)}).`);
|
|
746
|
+
}
|
|
747
|
+
return { zone };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The subject's depth AS FRAMED — getSubjectBounds' `front` / `back` at zoom 1 with no orbit
|
|
752
|
+
* (the fit scale, the subject's half depth, the page's depthOffset). Zoom and orbit are left out
|
|
753
|
+
* on purpose: they are framing choices the viewer (or the page) makes live, and the `depth`
|
|
754
|
+
* option's factor must not breathe with them. Either viewer (SceneViewer, PlayCanvasSplatViewer).
|
|
755
|
+
*/
|
|
756
|
+
export function framedSubjectBounds(viewer) {
|
|
757
|
+
const s = viewer._fitScale;
|
|
758
|
+
const hz = viewer._subjectHalf?.[2];
|
|
759
|
+
const cz = viewer._depthOffset || 0;
|
|
760
|
+
if (!(s > 0) || !Number.isFinite(hz)) return null;
|
|
761
|
+
return { front: cz + s * hz, back: cz - s * hz };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The `depth` option's live part, shared by every engine: measure the comfort factor
|
|
766
|
+
* (comfortDepthFactor) from the framed subject, the canvas's physical height and the NOMINAL viewer
|
|
767
|
+
* distance, and hand it to `apply(k)` — the engine multiplies it into the display rig it declares,
|
|
768
|
+
* on top of setStereo's values. `out.depthFactor` reads it (1 when the option is off or not in
|
|
769
|
+
* force).
|
|
770
|
+
*
|
|
771
|
+
* Re-measured when the engine calls `refresh()` (after it frames a subject), when the tile first
|
|
772
|
+
* weaves (its layer can answer getDisplayInfo from then), on a display event and when the canvas
|
|
773
|
+
* resizes — never per frame, and never on zoom or orbit (the box is framedSubjectBounds'). `apply` runs only when k changes. `ignore(why)` says once that the
|
|
774
|
+
* option does nothing on this rig (a camera rig, controls:'page') and holds the factor at 1;
|
|
775
|
+
* `resume()` measures again (a PlayCanvas splat back on a display rig).
|
|
776
|
+
* Without the option every method is a no-op: no listener, no observer, no async work.
|
|
777
|
+
*
|
|
778
|
+
* `getBounds()` → a framedSubjectBounds() box or null (null = nothing to measure now), `getVH()` →
|
|
779
|
+
* the virtualDisplayHeight that box is in, `getTile()` → the core window handle.
|
|
780
|
+
*/
|
|
781
|
+
export function attachComfortDepth(out, { option, canvas, getTile, getBounds, getVH, apply, tag = '[inline3d]' }) {
|
|
782
|
+
let factor = 1;
|
|
783
|
+
Object.defineProperty(out, 'depthFactor', { get: () => factor, enumerable: true, configurable: true });
|
|
784
|
+
if (!option) return { refresh() {}, ignore() {}, resume() {}, dispose() {}, get factor() { return 1; } };
|
|
785
|
+
let ignoring = false;
|
|
786
|
+
let warned = false;
|
|
787
|
+
let disposed = false;
|
|
788
|
+
let running = false;
|
|
789
|
+
let again = false;
|
|
790
|
+
let bound = null;
|
|
791
|
+
const unbind = [];
|
|
792
|
+
const bind = (tile) => {
|
|
793
|
+
if (bound === tile) return;
|
|
794
|
+
bound = tile;
|
|
795
|
+
tile.firstWoven?.then?.(() => refresh(), () => {});
|
|
796
|
+
for (const type of ['renderingmodechange', 'hardwaredisplaystatechange']) {
|
|
797
|
+
try {
|
|
798
|
+
const off = tile.on?.(type, () => refresh());
|
|
799
|
+
if (typeof off === 'function') unbind.push(off);
|
|
800
|
+
} catch {
|
|
801
|
+
/* a core without display events: resize and refresh() still re-measure */
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (canvas && typeof ResizeObserver === 'function') {
|
|
805
|
+
const ro = new ResizeObserver(() => refresh());
|
|
806
|
+
ro.observe(canvas);
|
|
807
|
+
unbind.push(() => ro.disconnect());
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
async function refresh() {
|
|
811
|
+
if (disposed || ignoring) return;
|
|
812
|
+
const tile = getTile();
|
|
813
|
+
if (!tile || typeof tile.displayMetrics !== 'function') return;
|
|
814
|
+
bind(tile);
|
|
815
|
+
// One measurement at a time; a request during one runs once more after it (a resize burst).
|
|
816
|
+
if (running) {
|
|
817
|
+
again = true;
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
running = true;
|
|
821
|
+
try {
|
|
822
|
+
do {
|
|
823
|
+
again = false;
|
|
824
|
+
let m = null;
|
|
825
|
+
try {
|
|
826
|
+
m = await tile.displayMetrics(); // never rejects; guarded for an older core
|
|
827
|
+
} catch {
|
|
828
|
+
m = null;
|
|
829
|
+
}
|
|
830
|
+
if (disposed || ignoring) return;
|
|
831
|
+
const b = getBounds();
|
|
832
|
+
const vH = getVH();
|
|
833
|
+
if (!m || !b || !(vH > 0)) continue;
|
|
834
|
+
const k = comfortDepthFactor({
|
|
835
|
+
frontH: Math.max(0, b.front / vH),
|
|
836
|
+
backH: Math.max(0, -b.back / vH),
|
|
837
|
+
tileHeightM: m.canvasSizeM?.[1],
|
|
838
|
+
// The NOMINAL distance (what the display is built for), never the live head: depth that
|
|
839
|
+
// breathed with the viewer's head would be a second, unasked-for parallax.
|
|
840
|
+
viewerM: m.source?.viewer === 'display' ? m.nominalViewerM : DEPTH_DEFAULT_VIEWER_M,
|
|
841
|
+
zone: option.zone,
|
|
842
|
+
});
|
|
843
|
+
if (k !== factor) {
|
|
844
|
+
factor = k;
|
|
845
|
+
apply(k);
|
|
846
|
+
}
|
|
847
|
+
} while (again);
|
|
848
|
+
} finally {
|
|
849
|
+
running = false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
refresh,
|
|
854
|
+
/** This rig takes no comfort factor: say so once, and hold 1 (refresh() idle) until resume(). */
|
|
855
|
+
ignore(why) {
|
|
856
|
+
if (!warned) {
|
|
857
|
+
warned = true;
|
|
858
|
+
console.warn(`${tag} depth: 'auto' applies to the display rig only; ignored on ${why} (the rig is sent as declared).`);
|
|
859
|
+
}
|
|
860
|
+
ignoring = true;
|
|
861
|
+
factor = 1;
|
|
862
|
+
},
|
|
863
|
+
/** A display rig again (PlayCanvas setRig / setSource): measure from here. */
|
|
864
|
+
resume() {
|
|
865
|
+
ignoring = false;
|
|
866
|
+
return refresh();
|
|
867
|
+
},
|
|
868
|
+
dispose() {
|
|
869
|
+
disposed = true;
|
|
870
|
+
while (unbind.length) unbind.pop()();
|
|
871
|
+
},
|
|
872
|
+
get factor() {
|
|
873
|
+
return factor;
|
|
874
|
+
},
|
|
875
|
+
};
|
|
876
|
+
}
|
package/js/inline3d-splat.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import * as THREE from 'three';
|
|
22
22
|
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
|
|
23
|
-
import { EyeCamera, EdgeFeather, cameraRigFromCamera, DepthCursor, raycastHitTest } from './inline3d-three.js';
|
|
23
|
+
import { EyeCamera, EdgeFeather, cameraRigFromCamera, DepthCursor, raycastHitTest, displayRig } from './inline3d-three.js';
|
|
24
24
|
import { resolveCursorOption } from './inline3d-cursor-depth.js';
|
|
25
25
|
import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
|
|
26
26
|
import { readSogCamera } from './inline3d-sog.js';
|
|
@@ -40,6 +40,9 @@ import {
|
|
|
40
40
|
resolveControls,
|
|
41
41
|
declareViewRig,
|
|
42
42
|
guardedAttach,
|
|
43
|
+
attachComfortDepth,
|
|
44
|
+
resolveDepthOption,
|
|
45
|
+
framedSubjectBounds,
|
|
43
46
|
} from './inline3d-splat-shared.js';
|
|
44
47
|
import {
|
|
45
48
|
resolveRig,
|
|
@@ -178,6 +181,8 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
178
181
|
// WHO OWNS THE CAMERA. Validated here, synchronously, for both engines (an unknown `controls`
|
|
179
182
|
// or a comfortDepth out of range is a page bug true of every call).
|
|
180
183
|
const { page: pageControls } = resolveControls(opts);
|
|
184
|
+
// Opt-in comfort depth, both engines: a bad value is a page bug, thrown at the call.
|
|
185
|
+
const depthOption = resolveDepthOption(opts.depth);
|
|
181
186
|
// WHICH ENGINE. Unset (or 'spark') is everything below, untouched. 'playcanvas' goes to
|
|
182
187
|
// ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
|
|
183
188
|
// resolves `playcanvas` — see addSplatDeferred.
|
|
@@ -255,6 +260,8 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
255
260
|
feather,
|
|
256
261
|
logTag: '[inline3d/splat]',
|
|
257
262
|
viewerEase: opts.viewerEase,
|
|
263
|
+
// Opt-in; undefined = the viewer's own default (a transparent clear).
|
|
264
|
+
clearColor: opts.clearColor,
|
|
258
265
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
259
266
|
// A gaussian-splat raycast is ~8 ms a ray on Spark: one ray a frame, cycling the footprint.
|
|
260
267
|
// (engine:'playcanvas' hit-tests with the engine's depth picker instead, which is far cheaper.)
|
|
@@ -356,6 +363,7 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
356
363
|
remove() {
|
|
357
364
|
unbindFocusInput?.();
|
|
358
365
|
viewer.onFocusChange = null;
|
|
366
|
+
depth.dispose();
|
|
359
367
|
handle?.remove();
|
|
360
368
|
viewer.dispose();
|
|
361
369
|
out.mesh?.dispose?.();
|
|
@@ -511,6 +519,23 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
511
519
|
// The core handle's `firstWoven`, forwarded; a page with no session is told so at once.
|
|
512
520
|
out.firstWoven = handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 }));
|
|
513
521
|
|
|
522
|
+
// `depth: 'auto'` (opt-in, display rig only): Spark has no setStereo, so the comfort factor is
|
|
523
|
+
// the display rig's whole ipdFactor / parallaxFactor, declared explicitly when it moves off 1
|
|
524
|
+
// (and back to the plain rig — the shorthand's exact equivalent — if it returns to 1).
|
|
525
|
+
// Without the option: inert, `depthFactor` 1, the shorthand stays the only declaration.
|
|
526
|
+
const depth = attachComfortDepth(out, {
|
|
527
|
+
option: depthOption,
|
|
528
|
+
canvas,
|
|
529
|
+
getTile: () => handle,
|
|
530
|
+
getBounds: () => (out.frame && out.rig?.type === 'display' ? framedSubjectBounds(viewer) : null),
|
|
531
|
+
getVH: () => virtualDisplayHeight,
|
|
532
|
+
apply: (k) => {
|
|
533
|
+
out.viewRig = displayRig({ virtualDisplayHeight, ipdFactor: k, parallaxFactor: k, out: out.viewRig || {} });
|
|
534
|
+
declareViewRig(handle, out.viewRig);
|
|
535
|
+
},
|
|
536
|
+
tag: '[inline3d/splat]',
|
|
537
|
+
});
|
|
538
|
+
|
|
514
539
|
|
|
515
540
|
// ── focus: declaring it, and the two gestures that change it ──────────────────────────
|
|
516
541
|
//
|
|
@@ -667,6 +692,7 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
667
692
|
out.frame = bounds;
|
|
668
693
|
|
|
669
694
|
if (resolved.type === 'camera') {
|
|
695
|
+
depth.ignore('a camera rig (this asset carries a capture camera)');
|
|
670
696
|
// A turntable on a photograph is nonsense, so the default spin stops here — but only the
|
|
671
697
|
// DEFAULT: a page that asked for one still gets it.
|
|
672
698
|
if (!('idleSpin' in opts)) viewer.idleSpin = 0;
|
|
@@ -679,8 +705,10 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
679
705
|
});
|
|
680
706
|
pushViewRig(true);
|
|
681
707
|
} else {
|
|
682
|
-
if (bounds)
|
|
683
|
-
|
|
708
|
+
if (bounds) {
|
|
709
|
+
viewer.fitTo(bounds.center, bounds.extent);
|
|
710
|
+
depth.refresh();
|
|
711
|
+
} else {
|
|
684
712
|
// Unframed means drawn at raw MODEL scale, which for a typical capture is several
|
|
685
713
|
// times the tile. Say so: silence here is what made the same condition read as a fit
|
|
686
714
|
// bug.
|
package/js/inline3d-three.js
CHANGED
|
@@ -74,6 +74,17 @@ import {
|
|
|
74
74
|
CURSOR_OUTLINE_COLOR,
|
|
75
75
|
} from './inline3d-cursor-depth.js';
|
|
76
76
|
import { CursorAmortisedPick, CursorDebugStats, cursorDebugMode } from './inline3d-cursor-perf.js';
|
|
77
|
+
import { noteBuiltCameraRig } from './inline3d-depth-budget.js';
|
|
78
|
+
// The depth-budget helpers, for a page that renders its own scene with these rigs: what disparity
|
|
79
|
+
// a camera rig or a display-rig depth gives, as % of the tile width. Pure; see the module.
|
|
80
|
+
export {
|
|
81
|
+
disparityPct,
|
|
82
|
+
cameraRigDisparityPct,
|
|
83
|
+
depthBudgetFromBounds,
|
|
84
|
+
comfortZone,
|
|
85
|
+
comfortDepthFactor,
|
|
86
|
+
cameraRigComfortFactor,
|
|
87
|
+
} from './inline3d-depth-budget.js';
|
|
77
88
|
|
|
78
89
|
/**
|
|
79
90
|
* A reusable three.js camera driven directly by an XRView's matrices. Construct once with
|
|
@@ -202,14 +213,19 @@ function scratch(THREE) {
|
|
|
202
213
|
* glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
|
|
203
214
|
* is meant to be looking at (the subject's centre, a hit-tested surface) — with a moving camera
|
|
204
215
|
* that is usually just the distance to it. Left at 0 it means infinity, which puts the entire
|
|
205
|
-
* scene in front of the display and is comfortable for almost nothing
|
|
216
|
+
* scene in front of the display and is comfortable for almost nothing — so a 0 / unset
|
|
217
|
+
* convergence logs one console warning per page (the descriptor is still built as asked).
|
|
206
218
|
*
|
|
207
|
-
* COMFORT. The
|
|
208
|
-
*
|
|
209
|
-
* content, and past it they diverge, which no one can fuse.
|
|
210
|
-
* metersToVirtual 1) that is `convergence >= ~0.
|
|
211
|
-
*
|
|
212
|
-
*
|
|
219
|
+
* COMFORT. The rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1` (N = the
|
|
220
|
+
* nominal viewer distance the display reports — 0.6 m on a desktop panel): at 1 the viewer's
|
|
221
|
+
* eyes are parallel on infinitely far content, and past it they diverge, which no one can fuse.
|
|
222
|
+
* With the defaults (factors 1, metersToVirtual 1) that is `convergence >= ~0.6` world units.
|
|
223
|
+
* NOTHING ENFORCES IT for a rig the app declares: the runtime takes the gain
|
|
224
|
+
* `metersToVirtual × convergenceDiopters × N` as given (XR_DXR_view_rig §3 — only a legacy
|
|
225
|
+
* camera profile is comfort-clamped) and clamps only values outside a field's sanity range
|
|
226
|
+
* (e.g. convergenceDiopters to [0, 20], verticalFov to [0.01, 3.13] rad), once, with a warning.
|
|
227
|
+
* A scene authored in centimetres with a 0.1-unit convergence is the shape of the mistake;
|
|
228
|
+
* `cameraRigDisparityPct` measures what a rig gives.
|
|
213
229
|
*
|
|
214
230
|
* COMFORT IS NOT A DEPTH METER. It bounds where the depth budget SITS (it guards the background
|
|
215
231
|
* against divergence), not how big the budget is. The budget is
|
|
@@ -300,6 +316,8 @@ function fillCameraRig(out, verticalFovRad, opts) {
|
|
|
300
316
|
out.convergenceDiopters = convergence > 0 ? 1 / convergence : 0;
|
|
301
317
|
out.verticalFov = verticalFovRad;
|
|
302
318
|
out.metersToVirtual = metersToVirtual;
|
|
319
|
+
// Infinity is almost never what was meant: say so once per page. The descriptor is unchanged.
|
|
320
|
+
if (!(out.convergenceDiopters > 0)) noteBuiltCameraRig(out);
|
|
303
321
|
return out;
|
|
304
322
|
}
|
|
305
323
|
|
|
@@ -315,9 +333,11 @@ function fillCameraRig(out, verticalFovRad, opts) {
|
|
|
315
333
|
* The factors are RELATIVE here (unlike a camera rig, where ipd/parallax are absolute):
|
|
316
334
|
* `ipdFactor` and `parallaxFactor` are [0,1] multipliers on what the display would naturally do
|
|
317
335
|
* — 1 is correct-by-construction, 0 is flat/frozen, and the values between are a comfort dial,
|
|
318
|
-
* not a correctness one.
|
|
319
|
-
*
|
|
320
|
-
*
|
|
336
|
+
* not a correctness one. Above 1 exaggerates depth past what the display shows by construction:
|
|
337
|
+
* the runtime accepts it (it clamps ipd/parallax only to [0, 10000]) and `handle.setViewRig` logs
|
|
338
|
+
* one console warning. `perspectiveFactor` is [0.1,10] (the runtime clamps it there, once, with a
|
|
339
|
+
* warning) and exaggerates or flattens the off-axis skew; it is the one knob with no physical
|
|
340
|
+
* justification, so treat it as an effect.
|
|
321
341
|
*
|
|
322
342
|
* @param {object} [opts]
|
|
323
343
|
* @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display (the zoom knob).
|