@displayxr/inline3d 1.19.1 → 1.19.2
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 +28 -0
- package/js/inline3d-splat-effects.js +88 -5
- package/js/inline3d-splat-playcanvas.js +188 -46
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
|
|
|
5
5
|
`./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
|
|
6
6
|
which tier they touch, because that is what tells you whether an upgrade can move your pixels.
|
|
7
7
|
|
|
8
|
+
## 1.19.2 — 2026-09-24
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change and no pixel
|
|
11
|
+
change: every frame of a `crossfade` or `wavefront` is the one 1.19.1 draws, and the end is still a
|
|
12
|
+
plain `cut` (MAE 0.000).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **The live `wavefront` costs about one photo's draw, not two** (#36).
|
|
17
|
+
- Its depth ridge was an entity-scope work-buffer modifier: a full rewrite of the new photo's
|
|
18
|
+
1.18M-gaussian work buffer and a CPU re-sort, every frame (0.9–1.0 of each per frame, measured).
|
|
19
|
+
It now runs at render time on the eye camera's gsplat manager, as the particle transitions do:
|
|
20
|
+
0 rewrites and 0 sorts in the window.
|
|
21
|
+
- Each photo is drawn only on its side of the front: the new one left of where its commit
|
|
22
|
+
starts, the old one right of where it ends, so the two overlap only in the band. A gaussian
|
|
23
|
+
whose on-screen footprint (bounded the way the engine sizes its quad) lies wholly on the far
|
|
24
|
+
side in every eye gets alpha 0, which the engine culls in the vertex stage. Frames with the
|
|
25
|
+
cull on and off are bit-identical (both eyes, colour and alpha, 5 points of the window).
|
|
26
|
+
- An engine whose managers are not reachable keeps the entity-scope ridge (`_transitionPath`
|
|
27
|
+
says which ran).
|
|
28
|
+
- **No shader links inside a `crossfade` or `wavefront` window.** The overlay's two quads compiled
|
|
29
|
+
on the window's first frames (30–180 ms on the M1). `prepareSource()` now warms them whatever
|
|
30
|
+
transition follows, `prepareSource(src, { transition: 'wavefront' })` also builds the render-time
|
|
31
|
+
ridge and cull, and an unprepared `setSource` warms them while its asset loads.
|
|
32
|
+
|
|
33
|
+
Measured pacing and GPU cost: [`docs/splat-effects.md` § Wavefront: one draw's worth](docs/splat-effects.md#wavefront-one-draws-worth).
|
|
34
|
+
The live `crossfade` still draws both photos in full: at mid-fade every pixel needs both images.
|
|
35
|
+
|
|
8
36
|
## 1.19.1 — 2026-09-24
|
|
9
37
|
|
|
10
38
|
Touches packaging only (`./splat` and `./model`, `engine: 'playcanvas'`). No pixels move: the
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
// same splats; a decision keyed on a screen position would differ between the eyes and read as
|
|
36
36
|
// rivalry. So no effect here reads gl_FragCoord or a view matrix, and the custom hook exposes no
|
|
37
37
|
// screen-space input. (The one image-space piece, setSource's frame snapshot, lives in the
|
|
38
|
-
// adapter and samples each eye's own half at the zero-disparity plane.)
|
|
38
|
+
// adapter and samples each eye's own half at the zero-disparity plane.) The one exception,
|
|
39
|
+
// `wipecull`, is not an effect: it only skips gaussians no shown pixel can receive.
|
|
39
40
|
//
|
|
40
41
|
// ── Sort caveat ───────────────────────────────────────────────────────────────────────────────
|
|
41
42
|
//
|
|
@@ -46,7 +47,12 @@
|
|
|
46
47
|
import { coverageExponent, FADE_TRANSMITTANCE_FLOOR } from './inline3d-splat-shared.js';
|
|
47
48
|
|
|
48
49
|
/** The fixed composition order of a generated chunk. */
|
|
49
|
-
export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom']);
|
|
50
|
+
export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'cull']);
|
|
51
|
+
|
|
52
|
+
/** The most eye views setSource's wavefront culls for (more: every gaussian is drawn). */
|
|
53
|
+
export const WIPE_CULL_MAX_VIEWS = 4;
|
|
54
|
+
const IDENTITY16 = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
55
|
+
const ZERO4 = new Float32Array(4);
|
|
50
56
|
|
|
51
57
|
/** Named easings (a function `(x) => y` on [0, 1] is accepted too). */
|
|
52
58
|
export const EASINGS = Object.freeze({
|
|
@@ -529,6 +535,74 @@ void ${P}color(vec3 c, inout vec4 col) {}
|
|
|
529
535
|
}),
|
|
530
536
|
},
|
|
531
537
|
|
|
538
|
+
// Internal: setSource's wavefront, both photos — draw each photo only on ITS side of the image
|
|
539
|
+
// front. NOT an effect: nothing it removes could have reached a pixel the overlay shows (the
|
|
540
|
+
// frame is bit-exact with it on or off), it only saves the fragments of the half nobody sees.
|
|
541
|
+
// Each gaussian whose on-screen footprint lies wholly on the far side of the front in EVERY eye
|
|
542
|
+
// gets alpha 0, which the engine's own alpha clip culls in the vertex stage. The footprint is
|
|
543
|
+
// bounded the way the engine sizes its quad (gsplatCorner): λ₁ ≤ ‖J‖²·s²·σ²max + 0.3 with
|
|
544
|
+
// ‖J‖² = (f/z)²·(1 + (x² + y²)/z²), l₁ = 2·√(2λ₁), and a corner offset ≤ l₁ + l₂ ≤ 2·l₁, plus
|
|
545
|
+
// 4 px. The front is the overlay's own (setSnapshotAlpha's wipe), in each eye's viewport-
|
|
546
|
+
// relative NDC. The eye views are the camera's (its parent's world transform · each view's
|
|
547
|
+
// pose), set by the adapter just before the engine renders — the live outgoing camera has its
|
|
548
|
+
// own. It is the LAST stage, so it sees the final centre and scale of every stage before it.
|
|
549
|
+
// Per mesh instance: `side` +1 keeps the right of `edge` (the outgoing photo), −1 the left
|
|
550
|
+
// (the incoming one); the material default is off.
|
|
551
|
+
wipecull: {
|
|
552
|
+
stage: 'cull',
|
|
553
|
+
kind: 'transition',
|
|
554
|
+
internal: true,
|
|
555
|
+
defaults: { durationMs: 0, easing: 'linear', holdMs: 0, direction: 'in' },
|
|
556
|
+
glsl: (P) => {
|
|
557
|
+
const views = [0, 1, 2, 3];
|
|
558
|
+
return `
|
|
559
|
+
uniform float ${P}on;
|
|
560
|
+
uniform float ${P}side;
|
|
561
|
+
uniform float ${P}edge;
|
|
562
|
+
uniform float ${P}n;
|
|
563
|
+
${views.map((i) => `uniform mat4 ${P}V${i};\nuniform vec4 ${P}X${i};\nuniform vec4 ${P}W${i};\nuniform vec4 ${P}K${i};`).join('\n')}
|
|
564
|
+
bool ${P}cut;
|
|
565
|
+
// true when some of the footprint may reach the kept side of the front in this view
|
|
566
|
+
bool ${P}reach(mat4 V, vec4 X, vec4 Wr, vec4 K, vec3 c, float s) {
|
|
567
|
+
vec4 p = vec4(c, 1.0);
|
|
568
|
+
vec4 v = V * p;
|
|
569
|
+
float z = -v.z;
|
|
570
|
+
float w = dot(Wr, p);
|
|
571
|
+
if (z <= 1e-6 || w <= 1e-6) return true; // at or behind the eye: the engine decides
|
|
572
|
+
float xn = dot(X, p) / w;
|
|
573
|
+
float jz = K.x / z;
|
|
574
|
+
float j2 = jz * jz * (1.0 + dot(v.xy, v.xy) / (z * z));
|
|
575
|
+
float l1 = 2.0 * sqrt(2.0 * (j2 * K.z * s * s + 0.3));
|
|
576
|
+
return ${P}side * (xn - ${P}edge) + (2.0 * l1 + 8.0) * K.y > 0.0;
|
|
577
|
+
}
|
|
578
|
+
void ${P}center(inout vec3 c) {}
|
|
579
|
+
void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
|
|
580
|
+
${P}cut = false;
|
|
581
|
+
if (${P}on < 0.5) return;
|
|
582
|
+
float s = max(sc.x, max(sc.y, sc.z));
|
|
583
|
+
${views.map((i) => ` if (${i > 0 ? `${P}n > ${i}.5 && ` : ''}${P}reach(${P}V${i}, ${P}X${i}, ${P}W${i}, ${P}K${i}, mc, s)) return;`).join('\n')}
|
|
584
|
+
${P}cut = true;
|
|
585
|
+
}
|
|
586
|
+
void ${P}color(vec3 c, inout vec4 col) { if (${P}cut) col.a = 0.0; }
|
|
587
|
+
`;
|
|
588
|
+
},
|
|
589
|
+
// opts.cull() → { side, edge, views: [{ V, X, W, K }] } (at most WIPE_CULL_MAX_VIEWS), or null
|
|
590
|
+
// (off: every gaussian drawn). No `cull` (the material default): off.
|
|
591
|
+
uniforms: (ctx, inst) => {
|
|
592
|
+
const c = typeof inst.opts.cull === 'function' ? inst.opts.cull() : null;
|
|
593
|
+
const on = !!(c && c.views.length && c.views.length <= WIPE_CULL_MAX_VIEWS);
|
|
594
|
+
const out = { on: on ? 1 : 0, side: on ? c.side : 1, edge: on ? c.edge : 0, n: on ? c.views.length : 0 };
|
|
595
|
+
for (let i = 0; i < WIPE_CULL_MAX_VIEWS; i++) {
|
|
596
|
+
const v = on ? c.views[i] : null;
|
|
597
|
+
out['V' + i] = v ? v.V : IDENTITY16;
|
|
598
|
+
out['X' + i] = v ? v.X : ZERO4;
|
|
599
|
+
out['W' + i] = v ? v.W : ZERO4;
|
|
600
|
+
out['K' + i] = v ? v.K : ZERO4;
|
|
601
|
+
}
|
|
602
|
+
return out;
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
|
|
532
606
|
// Internal: setSource's crossfade FALLBACK (no frame snapshot) — the coverage remap on one
|
|
533
607
|
// entity, driven by the adapter.
|
|
534
608
|
xfade: {
|
|
@@ -1528,9 +1602,18 @@ export class SplatEffects {
|
|
|
1528
1602
|
* setSource's shader pre-warm compiles ahead of the transition.
|
|
1529
1603
|
*/
|
|
1530
1604
|
sharedChunkCode(name, effect, opts) {
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1605
|
+
return this.sharedChunkCodeFor([[name, effect, opts]]);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/** sharedChunkCode for several driveShared bodies at once: [[name, effect, opts], …]. */
|
|
1609
|
+
sharedChunkCodeFor(list) {
|
|
1610
|
+
const names = new Set(list.map((x) => x[0]));
|
|
1611
|
+
const insts = [...(this.scopes.get('tile')?.values() ?? [])].filter((i) => !names.has(i.name));
|
|
1612
|
+
for (const [name, effect, opts] of list) {
|
|
1613
|
+
const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
|
|
1614
|
+
insts.push({ name, def: EFFECTS[effect], opts: o });
|
|
1615
|
+
}
|
|
1616
|
+
return composeModifier(insts).code;
|
|
1534
1617
|
}
|
|
1535
1618
|
|
|
1536
1619
|
_setupInstance(inst) {
|
|
@@ -693,6 +693,8 @@ export class PlayCanvasSplatViewer {
|
|
|
693
693
|
this._snap = null;
|
|
694
694
|
/** setSource's live outgoing asset (./inline3d-splat-live.js), made on first use. */
|
|
695
695
|
this._live = null;
|
|
696
|
+
/** (entries, rect) => void: once per drawn frame, after the views are set, before the engine renders. */
|
|
697
|
+
this.onBeforeRender = null;
|
|
696
698
|
this.vH = virtualDisplayHeight;
|
|
697
699
|
this.fit = fit;
|
|
698
700
|
this.margin = margin;
|
|
@@ -1447,6 +1449,34 @@ export class PlayCanvasSplatViewer {
|
|
|
1447
1449
|
return { rig: this.rigMatrix(), pose, c, t: Math.tan((this.mono.fov * DEG) / 2) };
|
|
1448
1450
|
}
|
|
1449
1451
|
|
|
1452
|
+
/**
|
|
1453
|
+
* One camera's eye views as the engine composes them this frame (RenderView.updateTransforms:
|
|
1454
|
+
* the camera node's PARENT world transform · each view's pose), packed for the wavefront's cull
|
|
1455
|
+
* (EFFECTS.wipecull): V = the view matrix, X / W = rows 0 and 3 of proj · V, K = (the engine's
|
|
1456
|
+
* focal f = viewport width · proj[0], 1 / viewport width, the view's squared scale). `into` is
|
|
1457
|
+
* reused frame to frame. Null when a matrix is singular.
|
|
1458
|
+
*/
|
|
1459
|
+
cullViews(cam, entries, rect, into = []) {
|
|
1460
|
+
const parent = cam?.parent?.getWorldTransform?.()?.data ?? null;
|
|
1461
|
+
into.length = entries.length;
|
|
1462
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1463
|
+
const e = entries[i];
|
|
1464
|
+
const V = mat4Invert(parent ? mat4Mul(parent, e.pose) : e.pose);
|
|
1465
|
+
if (!V) return null;
|
|
1466
|
+
const C = mat4Mul(e.proj, V);
|
|
1467
|
+
const w = Math.max(1, rect(e)[2]);
|
|
1468
|
+
const o = (into[i] ||= { V: new Float32Array(16), X: new Float32Array(4), W: new Float32Array(4), K: new Float32Array(4) });
|
|
1469
|
+
o.V.set(V);
|
|
1470
|
+
o.X[0] = C[0]; o.X[1] = C[4]; o.X[2] = C[8]; o.X[3] = C[12];
|
|
1471
|
+
o.W[0] = C[3]; o.W[1] = C[7]; o.W[2] = C[11]; o.W[3] = C[15];
|
|
1472
|
+
o.K[0] = w * e.proj[0];
|
|
1473
|
+
o.K[1] = 1 / w;
|
|
1474
|
+
o.K[2] = V[0] * V[0] + V[1] * V[1] + V[2] * V[2];
|
|
1475
|
+
o.K[3] = 0;
|
|
1476
|
+
}
|
|
1477
|
+
return into;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1450
1480
|
/** Can this tile render a live outgoing asset (the single-camera RenderView path)? */
|
|
1451
1481
|
get canLiveOutgoing() {
|
|
1452
1482
|
return LiveOutgoing.supported(this);
|
|
@@ -1643,6 +1673,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1643
1673
|
placeNode(this.eye, entries[0].pose);
|
|
1644
1674
|
// setSource's live outgoing: the same views on its own camera, into its own target.
|
|
1645
1675
|
if (this._live?.active) this._live.sync(entries, rect, f);
|
|
1676
|
+
// setSource's wavefront: this frame's eye views, as the engine is about to compose them.
|
|
1677
|
+
this.onBeforeRender?.(entries, rect);
|
|
1646
1678
|
} else {
|
|
1647
1679
|
// Fallback: one camera per view, `rect` + `calculateProjection`.
|
|
1648
1680
|
const cams = this._views;
|
|
@@ -3680,7 +3712,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3680
3712
|
const loaded = await loadOne(pcModule, app, src, { background: true });
|
|
3681
3713
|
perfSpan('prepareSource', t0);
|
|
3682
3714
|
// The transition the page declared: compile its shader now, in the dwell, not on its first frame.
|
|
3683
|
-
|
|
3715
|
+
await prewarmTransition(warm); // null (no transition declared): the overlay only
|
|
3684
3716
|
const entry = { loaded, state: 'ready', dispose: null };
|
|
3685
3717
|
const prepared = {
|
|
3686
3718
|
[PREPARED_TAG]: true,
|
|
@@ -3721,46 +3753,61 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3721
3753
|
* engine whose internals differ just compiles on the transition's first frame, as before.
|
|
3722
3754
|
*/
|
|
3723
3755
|
async function prewarmTransition(plan) {
|
|
3724
|
-
|
|
3725
|
-
|
|
3756
|
+
// The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
|
|
3757
|
+
// (crossfade, wavefront and the particles all composite through them; created on the first
|
|
3758
|
+
// capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
|
|
3759
|
+
const chunkKey = plan?.particles
|
|
3760
|
+
? `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`
|
|
3761
|
+
: plan?.transition === 'wavefront'
|
|
3762
|
+
? 'wavefront'
|
|
3763
|
+
: null;
|
|
3764
|
+
const wantChunk = !!(fx && chunkKey && !prewarmed.has(chunkKey));
|
|
3765
|
+
const wantOverlay = !prewarmed.has('overlay');
|
|
3766
|
+
if (!wantChunk && !wantOverlay) return;
|
|
3726
3767
|
await yieldIdle();
|
|
3727
|
-
if (removed
|
|
3768
|
+
if (removed) return;
|
|
3728
3769
|
try {
|
|
3729
3770
|
const pc = pcModule;
|
|
3730
|
-
const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
|
|
3731
|
-
const code = fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order });
|
|
3732
3771
|
const made = [];
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
const
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3772
|
+
if (wantChunk && fx) {
|
|
3773
|
+
const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
|
|
3774
|
+
const code = plan.particles
|
|
3775
|
+
? fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })
|
|
3776
|
+
: fx.sharedChunkCodeFor([
|
|
3777
|
+
['transition', 'wavefront', {}],
|
|
3778
|
+
['transition-cull', 'wipecull', {}],
|
|
3779
|
+
]);
|
|
3780
|
+
let issued = 0;
|
|
3781
|
+
for (const cam of cams) {
|
|
3782
|
+
const mi = managerMi(cam, null);
|
|
3783
|
+
const src = mi?.material;
|
|
3784
|
+
const camera = cam.camera?.camera;
|
|
3785
|
+
if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
|
|
3786
|
+
const m = new pc.ShaderMaterial(src.shaderDesc);
|
|
3787
|
+
src.defines.forEach((v, k) => m.setDefine(k, v));
|
|
3788
|
+
m.shaderChunks.copy(src.shaderChunks);
|
|
3789
|
+
m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
|
|
3790
|
+
m.blendState = src.blendState;
|
|
3791
|
+
made.push(m.getShaderVariant({
|
|
3792
|
+
device: viewer.app.graphicsDevice,
|
|
3793
|
+
scene: viewer.app.scene,
|
|
3794
|
+
objDefs: mi._shaderDefs,
|
|
3795
|
+
cameraShaderParams: camera.shaderParams,
|
|
3796
|
+
pass: 0, // SHADER_FORWARD
|
|
3797
|
+
sortedLights: [],
|
|
3798
|
+
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
3799
|
+
vertexFormat: mi.mesh?.vertexBuffer?.format,
|
|
3800
|
+
}));
|
|
3801
|
+
issued++;
|
|
3802
|
+
}
|
|
3803
|
+
if (issued) prewarmed.add(chunkKey);
|
|
3757
3804
|
}
|
|
3758
|
-
// the overlay's two quads (created on the first capture otherwise, and compiled on its draw)
|
|
3759
3805
|
const cam = viewer.eye?.camera?.camera;
|
|
3760
|
-
if (cam?.shaderParams) {
|
|
3806
|
+
if (wantOverlay && cam?.shaderParams) {
|
|
3761
3807
|
viewer._ensureSnapshotOverlay();
|
|
3808
|
+
let issued = 0;
|
|
3762
3809
|
for (const p of viewer._snap?.parts || []) {
|
|
3763
|
-
|
|
3810
|
+
const v = p.mat.getShaderVariant?.({
|
|
3764
3811
|
device: viewer.app.graphicsDevice,
|
|
3765
3812
|
scene: viewer.app.scene,
|
|
3766
3813
|
objDefs: p.mi._shaderDefs,
|
|
@@ -3769,16 +3816,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3769
3816
|
sortedLights: [],
|
|
3770
3817
|
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
3771
3818
|
vertexFormat: p.mi.mesh?.vertexBuffer?.format,
|
|
3772
|
-
})
|
|
3819
|
+
});
|
|
3820
|
+
if (v) (made.push(v), issued++);
|
|
3773
3821
|
}
|
|
3822
|
+
if (issued) prewarmed.add('overlay');
|
|
3774
3823
|
}
|
|
3775
|
-
if (issued) prewarmed.add(key);
|
|
3776
3824
|
await finalizeWhenLinked(made.filter(Boolean));
|
|
3777
3825
|
} catch (err) {
|
|
3778
3826
|
console.info('[inline3d/splat] transition shader pre-warm skipped', err);
|
|
3779
3827
|
}
|
|
3780
3828
|
}
|
|
3781
3829
|
|
|
3830
|
+
|
|
3782
3831
|
/**
|
|
3783
3832
|
* Creating a program only ISSUES its compile + link; the browser resolves the link when the
|
|
3784
3833
|
* program is first queried — the engine's first draw, blocking (the 35–50 ms GetProgramiv the
|
|
@@ -3845,6 +3894,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3845
3894
|
await first.catch(() => null); // a failed first asset may be replaced
|
|
3846
3895
|
if (!app || removed) return out;
|
|
3847
3896
|
const pc = pcModule;
|
|
3897
|
+
// Not prepared: compile what the transition draws with (its chunk, the overlay) while the asset
|
|
3898
|
+
// loads, rather than on the window's first frames. Best effort, not awaited.
|
|
3899
|
+
if (!prep && current && plan.transition !== 'cut') prewarmTransition(plan);
|
|
3848
3900
|
const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
|
|
3849
3901
|
if (removed || gen !== sourceGen) {
|
|
3850
3902
|
app.assets.remove(loaded.asset);
|
|
@@ -3977,15 +4029,24 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3977
4029
|
});
|
|
3978
4030
|
} else release(prev); // the snapshot shows it from here on
|
|
3979
4031
|
const band = plan.band;
|
|
4032
|
+
finishers.push(() => {
|
|
4033
|
+
viewer._transitionState = null;
|
|
4034
|
+
viewer.setSnapshotAlpha(0);
|
|
4035
|
+
viewer.releaseSnapshot();
|
|
4036
|
+
});
|
|
4037
|
+
if (fx && managerMi(viewer.eye, null)) {
|
|
4038
|
+
playWavefrontRender({ plan, entity, live, finishers, finish, isFinished: () => finished, pumpLive });
|
|
4039
|
+
await done;
|
|
4040
|
+
return out;
|
|
4041
|
+
}
|
|
4042
|
+
// FALLBACK (an engine whose managers are not reachable): the ridge as an entity-scope
|
|
4043
|
+
// modifier — correct, but it rewrites the new photo's work buffer and re-sorts it every frame.
|
|
4044
|
+
viewer._transitionPath = 'entity';
|
|
3980
4045
|
const ridge = fx.play(
|
|
3981
4046
|
'wavefront',
|
|
3982
4047
|
{ durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
|
|
3983
4048
|
{ entity, gate: afterTicks(2), internal: true },
|
|
3984
4049
|
);
|
|
3985
|
-
finishers.push(() => {
|
|
3986
|
-
viewer.setSnapshotAlpha(0);
|
|
3987
|
-
viewer.releaseSnapshot();
|
|
3988
|
-
});
|
|
3989
4050
|
viewer._hooks.push(() => {
|
|
3990
4051
|
if (finished || removed) return false;
|
|
3991
4052
|
if (live && !live.fits) {
|
|
@@ -4064,6 +4125,94 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4064
4125
|
return out;
|
|
4065
4126
|
}
|
|
4066
4127
|
|
|
4128
|
+
/** The mesh instance of the gsplat manager that `cam` draws `layer` with (any layer: null). */
|
|
4129
|
+
const managerMi = (cam, layer) => {
|
|
4130
|
+
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
|
|
4131
|
+
if (!cd?.layersMap) return null;
|
|
4132
|
+
if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
|
|
4133
|
+
for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
|
|
4134
|
+
return null;
|
|
4135
|
+
};
|
|
4136
|
+
|
|
4137
|
+
/**
|
|
4138
|
+
* setSource's wavefront, RENDER-TIME (the default): the ridge is a tile-scope body whose values
|
|
4139
|
+
* sit on the eye camera's manager (the incoming photo), as the particle transitions do — no
|
|
4140
|
+
* work-buffer rewrite and no re-sort per frame. Each photo is drawn only on its side of the
|
|
4141
|
+
* front (EFFECTS.wipecull, the last stage): the incoming one left of where its commit starts,
|
|
4142
|
+
* the outgoing one (on the live camera) right of where it ends, so the two share only the
|
|
4143
|
+
* `band`. The overlay still wipes the same two live images on the same lt; the frame is the
|
|
4144
|
+
* one the full draws give, bit for bit. The clock starts on the second tick (the incoming
|
|
4145
|
+
* asset's first frame builds its work buffer), as the crossfade's does.
|
|
4146
|
+
*/
|
|
4147
|
+
function playWavefrontRender({ plan, entity, live, finishers, finish, isFinished, pumpLive }) {
|
|
4148
|
+
const band = plan.band;
|
|
4149
|
+
const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
|
|
4150
|
+
const eyeMi = () => managerMi(viewer.eye, null);
|
|
4151
|
+
const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
|
|
4152
|
+
const ridgeFx = fx.driveShared('transition', 'wavefront', {});
|
|
4153
|
+
const cullFx = fx.driveShared('transition-cull', 'wipecull', {});
|
|
4154
|
+
const ridgeIn = ridgeFx.side(eyeMi, { band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity });
|
|
4155
|
+
const cull = { in: null, out: null };
|
|
4156
|
+
const cullIn = cullFx.side(eyeMi, { cull: () => cull.in });
|
|
4157
|
+
const cullOut = live ? cullFx.side(liveMi, { cull: () => cull.out }) : null;
|
|
4158
|
+
const views = { in: [], out: [] };
|
|
4159
|
+
let amount = 0;
|
|
4160
|
+
ridgeIn.set(0);
|
|
4161
|
+
viewer._transitionPath = 'render';
|
|
4162
|
+
// Where each photo still shows, in each eye's viewport-relative NDC (setSnapshotAlpha's wipe:
|
|
4163
|
+
// column u has committed lt = clamp((t − u·(1 − band)) / band, 0, 1) to the new photo).
|
|
4164
|
+
const clampEdge = (x) => Math.min(3, Math.max(-3, x));
|
|
4165
|
+
viewer.onBeforeRender = (entries, rect) => {
|
|
4166
|
+
if (isFinished()) return;
|
|
4167
|
+
const span = 1 - band;
|
|
4168
|
+
// band 1: the whole picture is the band — draw both in full. `_wipeCull = false`: the
|
|
4169
|
+
// diagnostics switch the exactness gate compares against (docs §Gates).
|
|
4170
|
+
if (!(span > 1e-6) || viewer._wipeCull === false) {
|
|
4171
|
+
cull.in = cull.out = null;
|
|
4172
|
+
cullIn.set(amount);
|
|
4173
|
+
cullOut?.set(amount);
|
|
4174
|
+
return;
|
|
4175
|
+
}
|
|
4176
|
+
const vin = viewer.cullViews(viewer.eye, entries, rect, views.in);
|
|
4177
|
+
cull.in = vin ? { side: -1, edge: clampEdge((2 * amount) / span - 1), views: vin } : null;
|
|
4178
|
+
cullIn.set(amount);
|
|
4179
|
+
if (cullOut) {
|
|
4180
|
+
const vout = viewer.cullViews(live.cam, entries, rect, views.out);
|
|
4181
|
+
cull.out = vout ? { side: 1, edge: clampEdge((2 * (amount - band)) / span - 1), views: vout } : null;
|
|
4182
|
+
cullOut.set(amount);
|
|
4183
|
+
}
|
|
4184
|
+
};
|
|
4185
|
+
finishers.push(() => {
|
|
4186
|
+
if (viewer.onBeforeRender) viewer.onBeforeRender = null;
|
|
4187
|
+
ridgeFx.remove();
|
|
4188
|
+
cullFx.remove();
|
|
4189
|
+
});
|
|
4190
|
+
let t0 = null;
|
|
4191
|
+
let ticks = 0;
|
|
4192
|
+
viewer._hooks.push((t) => {
|
|
4193
|
+
if (removed) return (finish(), false);
|
|
4194
|
+
if (isFinished()) return false;
|
|
4195
|
+
if (live && !live.fits) {
|
|
4196
|
+
finish(); // a resize or a 2D/3D switch mid-window: end now
|
|
4197
|
+
return false;
|
|
4198
|
+
}
|
|
4199
|
+
pumpLive();
|
|
4200
|
+
if (++ticks >= 2 && t0 === null) {
|
|
4201
|
+
t0 = t;
|
|
4202
|
+
ridgeIn.restart(); // the eyes' frame, now the new rig and first frames are in
|
|
4203
|
+
}
|
|
4204
|
+
const raw = t0 === null ? 0 : plan.durationMs > 0 ? Math.min(1, Math.max(0, (t - t0) / plan.durationMs)) : 1;
|
|
4205
|
+
amount = ease(raw);
|
|
4206
|
+
viewer._transitionState = { raw, live: !!live }; // diagnostics
|
|
4207
|
+
ridgeIn.set(amount);
|
|
4208
|
+
if (!viewer.setSnapshotAlpha(1, { t: amount, band, views: viewer.viewsAcross }) || raw >= 1) {
|
|
4209
|
+
finish();
|
|
4210
|
+
return false;
|
|
4211
|
+
}
|
|
4212
|
+
return true;
|
|
4213
|
+
});
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4067
4216
|
/**
|
|
4068
4217
|
* setSource's particle transitions (swarm, burst, shimmer-cross, dust), once the frame snapshot
|
|
4069
4218
|
* is up. The OUTGOING asset plays its effect in reverse, on the live camera, keyed on its own
|
|
@@ -4083,13 +4232,6 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4083
4232
|
// (outgoing). No work-buffer rewrite and no re-sort per frame. An engine that does not expose
|
|
4084
4233
|
// the managers' mesh instances gets the entity-scope modifiers instead (a full work-buffer
|
|
4085
4234
|
// rewrite + a re-sort per asset per frame: correct, but it hitches on a 1.18M photo).
|
|
4086
|
-
const managerMi = (cam, layer) => {
|
|
4087
|
-
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
|
|
4088
|
-
if (!cd?.layersMap) return null;
|
|
4089
|
-
if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
|
|
4090
|
-
for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
|
|
4091
|
-
return null;
|
|
4092
|
-
};
|
|
4093
4235
|
const eyeMi = () => managerMi(viewer.eye, null);
|
|
4094
4236
|
const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
|
|
4095
4237
|
const shared = particle.out.effect === particle.in.effect && eyeMi() ? fx.driveShared('transition', particle.in.effect, { order: particle.in.opts.order }) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.2",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|