@displayxr/inline3d 1.12.0 → 1.13.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.
@@ -437,3 +437,36 @@ export function normalizeCameraPose(matrixWorld, o = {}) {
437
437
  }
438
438
  return { matrixWorld: m, verticalFovDeg: fov, near, far, convergence };
439
439
  }
440
+
441
+ // ── coverage-linear alpha (setSource's crossfade, the `fade` effect) ────────────────────────────
442
+
443
+ /**
444
+ * Nominal optical depth of a dense photo's splat stack, per pixel: −ln(transmittance) through the
445
+ * whole stack at rest. The remap below is exact for a pixel of optical depth `L`. Real pictures
446
+ * spread widely around any single value (measured 13–25 on SHARP photos), which is why the
447
+ * crossfade blends IMAGES (FRAME_SNAPSHOT in ./inline3d-splat-playcanvas.js) and uses this only
448
+ * to shape ramps — never as the thing that makes a fade linear.
449
+ */
450
+ export const FADE_OPTICAL_DEPTH = 3;
451
+
452
+ /**
453
+ * Smallest per-splat transmittance the remap works from. A fully opaque splat (SOG alpha 255/255
454
+ * decodes to exactly 1, common in photos) has transmittance 0, and 0^k is 0 for every k > 0 — so
455
+ * without a floor it could never fade at all. 0.02 caps one splat's optical depth at ~3.9.
456
+ */
457
+ export const FADE_TRANSMITTANCE_FLOOR = 0.02;
458
+
459
+ /**
460
+ * The per-splat alpha exponent `k` that puts a stack of nominal optical depth `L` at screen
461
+ * coverage `c` (0..1).
462
+ *
463
+ * Scaling every splat's alpha by `c` saturates: a pixel under n near-opaque splats is covered
464
+ * `1 − (1 − c·α)^n`. Raising each splat's transmittance to a power instead, `α' = 1 − (1 − α)^k`,
465
+ * multiplies the stack's optical depth by k: coverage is `1 − e^{−kL}`. Solving for coverage =
466
+ * c·(1 − e^{−L}) gives this k. Exact ends: k(0) = 0 (α' = 0, invisible) and k(1) = 1 (untouched).
467
+ */
468
+ export function coverageExponent(c, L = FADE_OPTICAL_DEPTH) {
469
+ if (!(c > 0)) return 0;
470
+ if (c >= 1) return 1;
471
+ return -Math.log(1 - c * (1 - Math.exp(-L))) / L;
472
+ }
@@ -23,6 +23,7 @@ import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
23
23
  import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js';
24
24
  import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
25
25
  import { readSogCamera } from './inline3d-sog.js';
26
+ import { resolveRevealOption, effectsNotOnSpark, validateEffectCall } from './inline3d-splat-effects.js';
26
27
  import { applySplatPerf, splatPerfMeshOptions } from './inline3d-splat-perf.js';
27
28
  import {
28
29
  toArray3,
@@ -172,6 +173,8 @@ export function addSplat(wall, canvas, src, opts = {}) {
172
173
  // ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
173
174
  // resolves `playcanvas` — see addSplatDeferred.
174
175
  if (resolveSplatEngine(opts) === 'playcanvas') {
176
+ // A bad `reveal` is a page bug true of every call: throw at the call.
177
+ resolveRevealOption(opts.reveal);
175
178
  // A format that engine provably cannot read (a .spz URL, gzip bytes, a Spark-only fileType)
176
179
  // is a page bug: say so NOW rather than fail inside a loader later.
177
180
  const why = playcanvasCannotRead(src, opts);
@@ -182,6 +185,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
182
185
  // controls:'page' is a PlayCanvas-backend feature (docs/playcanvas-adapter.md §controls:'page'):
183
186
  // on Spark it would need SceneViewer — which ./viewer and ./model share — to take an external
184
187
  // camera in both its mono and eye paths. Refused by name rather than half-supported.
188
+ if (opts.reveal !== undefined && opts.reveal !== false) throw effectsNotOnSpark('reveal');
185
189
  if (pageControls) {
186
190
  throw new Error(
187
191
  "@displayxr/inline3d/splat: controls:'page' is not supported on Spark (the default engine) — " +
@@ -365,6 +369,17 @@ export function addSplat(wall, canvas, src, opts = {}) {
365
369
  "only; with the Spark backend, remove() this handle and addSplat() the new asset.",
366
370
  );
367
371
  },
372
+ /** Not on this backend (splat effects are PlayCanvas-only in this version). */
373
+ playEffect() {
374
+ throw effectsNotOnSpark('playEffect()');
375
+ },
376
+ setEffect() {
377
+ throw effectsNotOnSpark('setEffect()');
378
+ },
379
+ stopEffect() {
380
+ throw effectsNotOnSpark('stopEffect()');
381
+ },
382
+ effects: () => [],
368
383
  };
369
384
 
370
385
  // `src` may be a URL or the bytes themselves.
@@ -691,6 +706,19 @@ function addSplatDeferred(wall, canvas, src, opts) {
691
706
  // A swap requested before the first asset has landed runs once it has (the adapter's own
692
707
  // setSource replaces this stub on the same object by then).
693
708
  setSource: (...args) => out.ready.then(() => out.setSource(...args)),
709
+ // Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
710
+ // then run once the first asset is on screen.
711
+ playEffect: (name, o) => {
712
+ validateEffectCall(name, o, 'play');
713
+ return out.ready.then(() => out.playEffect(name, o));
714
+ },
715
+ setEffect: (name, params) => {
716
+ validateEffectCall(name, params, 'set');
717
+ pending.push(['setEffect', [name, params]]);
718
+ return out;
719
+ },
720
+ stopEffect: queue('stopEffect'),
721
+ effects: () => [],
694
722
  getFocus: () => null,
695
723
  // A plain data slot the adapter reads at call time, so a callback assigned on the very next
696
724
  // line after addSplat — before the module has loaded — is the one that fires.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
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",
@@ -50,6 +50,7 @@
50
50
  "js/inline3d-splat-rig.js",
51
51
  "js/inline3d-splat-playcanvas.js",
52
52
  "js/inline3d-splat-shared.js",
53
+ "js/inline3d-splat-effects.js",
53
54
  "js/inline3d-model.js",
54
55
  "js/inline3d-model-entry.js",
55
56
  "js/inline3d-model-playcanvas.js",
@@ -61,7 +62,8 @@
61
62
  "model.d.ts",
62
63
  "CHANGELOG.md",
63
64
  "README.md",
64
- "LICENSE"
65
+ "LICENSE",
66
+ "THIRD_PARTY_NOTICES.md"
65
67
  ],
66
68
  "keywords": [
67
69
  "displayxr",
package/splat.d.ts CHANGED
@@ -327,6 +327,133 @@ export interface SplatOptions {
327
327
  * caught and warned once; frames keep rendering.
328
328
  */
329
329
  onBeforeFrame?: (frame: SplatFrameInfo) => void;
330
+ /**
331
+ * `engine: 'playcanvas'` only (throws on Spark): play a reveal on the first asset. Installed at
332
+ * its start state before the first frame, played once `firstWoven` settles (at once in 2D) —
333
+ * docs/splat-effects.md. A name uses that effect's defaults; an object overrides them.
334
+ */
335
+ reveal?: SplatRevealName | false | SplatRevealSpec;
336
+ }
337
+
338
+ /** The transition effects `reveal` accepts. */
339
+ export type SplatRevealName = 'inflate' | 'sweep' | 'dissolve' | 'fade';
340
+
341
+ /** Named easings; a function `(x) => y` on [0, 1] also works. */
342
+ export type SplatEasing =
343
+ | 'linear'
344
+ | 'easeInQuad'
345
+ | 'easeOutQuad'
346
+ | 'easeInOutQuad'
347
+ | 'easeInCubic'
348
+ | 'easeOutCubic'
349
+ | 'easeInOutCubic'
350
+ | 'easeInOutSine'
351
+ | ((x: number) => number);
352
+
353
+ /**
354
+ * Where an effect is centred: `'focus'` (the focus point), `'eyes'` (the eyes' midpoint — the
355
+ * inflate default), a point in the splat's OWN space `[x, y, z]`, or a canvas point
356
+ * `[clientX, clientY]` / `{ clientX, clientY }` resolved with `pick()` when the effect starts.
357
+ * All effects are keyed on world position and time only, so both eyes agree.
358
+ */
359
+ export type SplatEffectOrigin = 'focus' | 'eyes' | [number, number, number] | [number, number] | { clientX: number; clientY: number };
360
+
361
+ /** Options every played effect takes. */
362
+ export interface SplatEffectTiming {
363
+ durationMs?: number;
364
+ /** Wait this long at the start state before the clock runs. */
365
+ holdMs?: number;
366
+ easing?: SplatEasing;
367
+ origin?: SplatEffectOrigin;
368
+ /** `'in'` (default): arrive, then the effect is removed. `'out'`: leave; the end state is held until `stopEffect`. */
369
+ direction?: 'in' | 'out';
370
+ /** `'tile'` (default, every splat of the tile, render time) or `'entity'` (the current asset only, work buffer). */
371
+ scope?: 'tile' | 'entity';
372
+ }
373
+
374
+ /** `reveal: { type, … }`. */
375
+ export interface SplatRevealSpec extends Omit<SplatEffectTiming, 'direction' | 'scope'> {
376
+ type: SplatRevealName;
377
+ [param: string]: unknown;
378
+ }
379
+
380
+ /** A custom effect's GLSL (PlayCanvas-shaped) — docs/splat-effects.md §custom. */
381
+ export interface SplatCustomEffect extends SplatEffectTiming {
382
+ /**
383
+ * Any of `void modifySplatCenter(inout vec3 center)`, `void modifySplatRotationScale(vec3
384
+ * originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale)`, `void
385
+ * modifySplatColor(vec3 center, inout vec4 color)` — world-space centres. `dxrProgress` (0..1)
386
+ * and `dxrTime` (s) are defined; `splat.index` / `splat.uv` are the asset's own file index in
387
+ * `scope: 'entity'`. Screen-space inputs are refused.
388
+ */
389
+ glsl: string;
390
+ /** Tile scope only: `void modifySplatColor(vec2 gaussianUV, inout vec4 color)` per fragment. */
391
+ fragmentGlsl?: string;
392
+ /** Uniform values, set every frame; a function gets the frame time in ms. */
393
+ uniforms?: Record<string, number | number[] | ((tMs: number) => number | number[])>;
394
+ /** `setEffect` only: hold `dxrProgress` here (default 1). */
395
+ progress?: number;
396
+ /** `playEffect` only: keep the effect at its end instead of removing it. */
397
+ hold?: boolean;
398
+ }
399
+
400
+ export type SplatEffectName = 'inflate' | 'deflate' | 'sweep' | 'dissolve' | 'fade' | 'pulse' | 'grade' | 'clip' | 'custom' | `custom:${string}`;
401
+
402
+ /** `setEffect('grade', …)`. */
403
+ export interface SplatGradeParams {
404
+ /** Stops; default 0. */
405
+ exposure?: number;
406
+ contrast?: number;
407
+ saturation?: number;
408
+ tint?: [number, number, number];
409
+ scope?: 'tile' | 'entity';
410
+ }
411
+
412
+ /** `setEffect('clip', …)` — in the splat's own space, like setFocus. Exactly one shape. */
413
+ export interface SplatClipParams {
414
+ box?: { min: [number, number, number]; max: [number, number, number] };
415
+ sphere?: { center: [number, number, number]; radius: number };
416
+ /** Keep the outside instead. */
417
+ invert?: boolean;
418
+ scope?: 'tile' | 'entity';
419
+ }
420
+
421
+ /** One row of `handle.effects()`. */
422
+ export interface SplatEffectState {
423
+ name: string;
424
+ scope: 'tile' | 'entity';
425
+ stage: 'grade' | 'clip' | 'reveal' | 'pulse' | 'custom';
426
+ /** Played (has a clock) vs set (held). */
427
+ playing: boolean;
428
+ /** Held at its start state until a gate settles (a reveal waiting for `firstWoven`). */
429
+ waiting: boolean;
430
+ /** 0..1, before easing. */
431
+ progress: number;
432
+ }
433
+
434
+ /** `setSource`'s options. */
435
+ export interface SplatSourceOptions {
436
+ /** > 0 = a crossfade of this length (the 1.10 option; same as `transition: 'crossfade'`). */
437
+ fadeMs?: number;
438
+ resetPose?: boolean;
439
+ /**
440
+ * `'cut'` (default), `'crossfade'` (images lerp, see setSource), `'flip'` (the old photo
441
+ * flattens to its convergence plane — zero disparity — the swap happens there, the new one
442
+ * inflates out of its own; default 2200 ms), `'wavefront'` (a soft front crosses left → right
443
+ * with a thin depth ridge riding it; default 2000 ms, ease-in-out; falls back to the crossfade
444
+ * in a hidden tab). For photo slideshows: `crossfade` or `wavefront`.
445
+ */
446
+ transition?: 'cut' | 'crossfade' | 'flip' | 'wavefront';
447
+ durationMs?: number;
448
+ easing?: SplatEasing;
449
+ /** `cut`/`crossfade` only: reveal the INCOMING asset (entity scope) while the old one fades. */
450
+ reveal?: SplatRevealName | false | SplatRevealSpec;
451
+ /** `wavefront`: the soft band, a fraction of the picture width (default 0.18). */
452
+ band?: number;
453
+ /** `wavefront`: the ridge's pull toward the eyes, world units (default 0.03 — 3 cm on a metric photo). */
454
+ ridge?: number;
455
+ /** `wavefront`: the ridge's disparity cap, a fraction of the eye view's width (default 0.004, max 0.05). */
456
+ ridgeMaxDisparity?: number;
330
457
  }
331
458
 
332
459
  /** What `onBeforeFrame` receives. */
@@ -466,15 +593,35 @@ export interface SplatHandle {
466
593
  * Swap the asset (URL or bytes) in place. PlayCanvas backend only — throws on Spark.
467
594
  *
468
595
  * The new file loads BEHIND the current one; then the two crossfade over `fadeMs` (0 = a cut)
469
- * and the old one is released. The rig waterfall re-runs for the new file (rig, lens, focus and
596
+ * and the old one is released. The crossfade blends the two IMAGES linearly: the last frame of
597
+ * the old asset is frozen (both eyes) and lerped with the live new one, so the mix is exactly
598
+ * `t` whatever the two photos' depth order (the old asset's own motion stops during the fade). The rig waterfall re-runs for the new file (rig, lens, focus and
470
599
  * frame update; `onFocusChange` fires). The pose (yaw/pitch/zoom/depth) is kept unless
471
600
  * `resetPose`. A newer call supersedes an older one still loading. Resolves once the fade has
472
601
  * finished; rejects if the new asset cannot be loaded (the current one stays on screen).
473
602
  */
474
- setSource(
475
- src: string | Blob | ArrayBuffer | Uint8Array,
476
- opts?: { fadeMs?: number; resetPose?: boolean },
477
- ): Promise<SplatHandle>;
603
+ setSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatSourceOptions): Promise<SplatHandle>;
604
+ /**
605
+ * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
606
+ * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —
607
+ * false when stopped or replaced. docs/splat-effects.md.
608
+ */
609
+ playEffect(
610
+ name: Exclude<SplatEffectName, 'grade' | 'clip'>,
611
+ opts?: SplatEffectTiming & Record<string, unknown>,
612
+ ): Promise<{ finished: boolean }>;
613
+ /**
614
+ * `engine: 'playcanvas'` only. Set a persistent effect (`grade`, `clip`, `custom`), hold a
615
+ * transition at `{ progress }`, or pass `null` to remove it (the exact baseline).
616
+ */
617
+ setEffect(name: 'grade', params: SplatGradeParams | null): SplatHandle;
618
+ setEffect(name: 'clip', params: SplatClipParams | null): SplatHandle;
619
+ setEffect(name: 'custom' | `custom:${string}`, params: SplatCustomEffect | null): SplatHandle;
620
+ setEffect(name: SplatEffectName, params: (SplatEffectTiming & { progress?: number } & Record<string, unknown>) | null): SplatHandle;
621
+ /** Stop one effect (all with no name): `finish: true` jumps to its end state, else removes it. */
622
+ stopEffect(name?: SplatEffectName, opts?: { finish?: boolean }): SplatHandle;
623
+ /** What is on right now. */
624
+ effects(): SplatEffectState[];
478
625
  /**
479
626
  * Called with the live focus (the splat's own space) whenever it moves — easing included — and
480
627
  * which waterfall step it came from. PlayCanvas backend; assign any time, even before `ready`.