@displayxr/inline3d 1.13.1 → 1.14.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 CHANGED
@@ -5,6 +5,62 @@ 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.14.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New particle reveals are additive. One default changes pixels
11
+ DURING a transition in a woven 3D session (the old photo is now live); end states and 2D are
12
+ unchanged.
13
+
14
+ ### Changed
15
+
16
+ - **`crossfade` / `wavefront`: the outgoing photo is LIVE in a woven 3D session** (#36). Before, it
17
+ was a frozen frame for the whole window, and on a tracked panel a still image under a moving head
18
+ reads as "eye tracking hung, then resumed". The old asset now stays resident. A second camera
19
+ with its own layer, RenderViews and render target keeps rendering it through the same eye views,
20
+ and the overlay lerps or wipes the two live images per eye with the same maths as before. Measured
21
+ on the GPU with a fake 2-view wall, the old photo at four head poses matches its own plain render
22
+ with MAE 0.000 in both eyes, across the rig switch; 1.13.0 showed one image at every pose. End
23
+ state == `cut`, MAE 0.000, colour and alpha, both eyes. Cost: about 2× splat draw for the
24
+ window, and both assets resident (+73 MB of GPU textures at 1.18M gaussians).
25
+ `outgoing: 'frozen'` restores 1.13.0; it stays the default in 2D. See
26
+ [`docs/splat-effects.md` § Live or frozen outgoing](docs/splat-effects.md#live-or-frozen-outgoing).
27
+ - The frozen-frame texture is freed when a transition ends; it used to stay allocated (7 MB at
28
+ 2560×720).
29
+ - The framing pass (the percentile core per axis, then the window pass) runs in steps with yields
30
+ between them, with bit-identical results. It was a single 60–80 ms main-thread task per 1.18M
31
+ gaussians at 4× CPU. It now runs under 50 ms per step, so the longest task left in a load is the
32
+ engine's own centre readback (≈60 ms at 4×).
33
+
34
+ ### Added
35
+
36
+ - **`handle.prepareSource(src) → Promise<SplatPreparedSource>`**: fetch, decode and upload the next
37
+ asset in the background, without rendering it. The SDK's own passes run in idle periods. Then
38
+ `setSource(prepared, opts)` starts on the next frame, with no load on the transition path.
39
+ `prepared.dispose()`, single use; `remove()` disposes what is left.
40
+ Measured with 4× / 6× CPU throttling on an M1 (1,179,648 gaussians): from the `setSource` call to
41
+ the first transition frame, the longest main-thread task goes from 104 / 159 ms to none over 50 ms, and the
42
+ longest rAF gap from 138 / 196 ms to 18 / 17 ms. Memory: +22.5 MB of GPU textures plus the
43
+ engine's centre array (≈14 MB) per prepared 1.18M SOG, until used.
44
+ - `setSource`'s `outgoing: 'live' | 'frozen'` option; `SplatPreparedSource` in `splat.d.ts`.
45
+ - **Particle reveals** ([`docs/splat-effects.md` §Particle reveals](docs/splat-effects.md#particle-reveals)),
46
+ usable as `reveal: '<name>'`, `playEffect(name)` and `setSource(…, { reveal })`. Every gaussian is
47
+ a particle with its own start time:
48
+ - `assemble`: a swarm that flies home along spiralling curl-noise paths.
49
+ - `dissolve-in`: a dissolve played backwards; dust drifts in and gathers patch by patch.
50
+ - `converge`: a burst from the focus that flies out and settles.
51
+ - `shimmer`: twinkling points that grow into the picture.
52
+ - Options: `order` (`'radial'`, `'depth'`, `'noise'`, `'random'`, or `'layers'` for SHARP grid
53
+ order via `splat.index`, which is entity scope only), `stagger`, `jitter`, `dotSize`, `grow`,
54
+ `flightAlpha`, `color`/`glow`, `noiseScale`, plus per-effect shape options.
55
+ - Comfort cap `maxDisparity` (default 0.004 of the view width, exact at every depth; nominal
56
+ separation in 2D). The swarm never comes nearer the viewer than that.
57
+ - Every effect ends bit-identical to the plain render. Measured on the real GPU: MAE 0.000,
58
+ colour and alpha, mono and fake stereo, tile and entity scope; removal is exact too.
59
+ - In-flight particles are ~1 px dots until nearly home, which hides the engine's
60
+ original-centre sort order.
61
+ - The eye frame that effects receive now carries `tanHalfFovY` (internal; the `'radial'` key spans the
62
+ view actually seen).
63
+
8
64
  ## 1.13.1 — 2026-09-24
9
65
 
10
66
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No pixel change.
@@ -22,7 +78,6 @@ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No pixel
22
78
  frame time at 4× CPU throttling went from 1.9–2.1 ms to 0.2–0.7 ms. GPU-synced frame time at 1×
23
79
  went from 16.0 ms to 14.4–14.6 ms, the same as after a `crossfade` (14.3–14.7 ms), which never
24
80
  had the bug.
25
-
26
81
  ## 1.13.0 — 2026-09-24
27
82
 
28
83
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), additively. A page that
package/README.md CHANGED
@@ -55,9 +55,9 @@ so the unused ~40% of the engine tree-shakes away); its sort workers are Blob UR
55
55
 
56
56
  **Splat effects (preview, `engine: 'playcanvas'`).** Shader effects on the splats themselves,
57
57
  keyed on world position and time so both eyes agree: `addSplat(…, { reveal: 'inflate' | 'sweep' |
58
- 'dissolve' | 'fade' })` (plays once the tile is woven), `handle.playEffect` / `setEffect` /
59
- `stopEffect` / `effects()` (inflate, deflate, sweep, dissolve, fade, pulse, grade, clip, custom
60
- GLSL), and `setSource(src, { transition: 'crossfade' | 'flip' | 'wavefront', reveal })`. Spark
58
+ 'dissolve' | 'fade' | 'assemble' | 'dissolve-in' | 'converge' | 'shimmer' })` (plays once the tile
59
+ is woven; the last four are particle reveals), `handle.playEffect` / `setEffect` / `stopEffect` /
60
+ `effects()` (the reveals, deflate, pulse, grade, clip, custom GLSL), and `setSource(src, { transition: 'crossfade' | 'flip' | 'wavefront', reveal })`. Spark
61
61
  throws on all of them for now. [`docs/splat-effects.md`](docs/splat-effects.md).
62
62
 
63
63
  Splat and model rendering on the PlayCanvas backend uses the PlayCanvas engine (MIT); see
@@ -173,6 +173,7 @@ js/
173
173
  inline3d-model.js the three.js model backend (engine:'three'; ./model/three)
174
174
  inline3d-playcanvas-engine.js named playcanvas imports for both PlayCanvas adapters (tree-shaking)
175
175
  inline3d-splat-effects.js splat shader effects: the runner, the effect registry (PlayCanvas)
176
+ inline3d-splat-live.js setSource's live outgoing photo: its own camera, layer and target (PlayCanvas)
176
177
  from what the asset declares (you serve the decoder files — see the guide)
177
178
  docs/
178
179
  authoring-inline-3d.md the authoring guide
@@ -56,6 +56,8 @@ export {
56
56
  // setSource's frame snapshot
57
57
  RenderTarget,
58
58
  FILTER_NEAREST,
59
+ // setSource's live outgoing asset (./inline3d-splat-live.js)
60
+ Layer,
59
61
  // splats
60
62
  WORKBUFFER_UPDATE_AUTO,
61
63
  WORKBUFFER_UPDATE_ALWAYS,
@@ -26,7 +26,7 @@
26
26
  // pulse → custom. Removing the last effect deletes the chunk (tile) or the modifier (entity),
27
27
  // which restores the engine's own default — the exact baseline, not a no-op look-alike.
28
28
  //
29
- // Adding an effect = one GLSL body + one registry entry in EFFECTS below: `glsl(P)` defines
29
+ // Adding an effect = one GLSL body + one registry entry in EFFECTS below: `glsl(P, opts)` defines
30
30
  // `P##center`, `P##rs`, `P##color`, and `uniforms(ctx, inst, amount)` returns the values.
31
31
  //
32
32
  // ── The stereo rule ───────────────────────────────────────────────────────────────────────────
@@ -552,6 +552,467 @@ void ${P}color(vec3 c, inout vec4 col) {
552
552
  // stays flat until stopEffect.
553
553
  EFFECTS.deflate = { ...EFFECTS.inflate, defaults: { ...EFFECTS.inflate.defaults, direction: 'out', easing: 'easeInOutSine' } };
554
554
 
555
+ // ── particle reveals ──────────────────────────────────────────────────────────────────────────
556
+ //
557
+ // assemble / dissolve-in / converge / shimmer: every gaussian is a PARTICLE with its own start
558
+ // time. A per-gaussian key k ∈ [0, 1] (the `order`: image distance from the origin, depth, fbm
559
+ // noise patches, random, or — entity scope only — SHARP's grid layers via `splat.index`) staggers
560
+ // it: local progress lp = clamp((amount − k·stagger) / (1 − stagger), 0, 1). While lp < 1 the
561
+ // gaussian is shrunk to a DOT (`dotSize`, a fraction of the view width, so ~1–2 px whatever its
562
+ // depth) and only grows back to its own scale over the last `1 − grow` of its flight. That is the
563
+ // answer to the sort caveat: the engine sorts by ORIGINAL centres, so a gaussian far from home
564
+ // blends out of order — but a 1-px dot barely overlaps anything, and by the time it is big it is
565
+ // home. Keys and paths read the ORIGINAL world centre, the time and the effect's fixed frame
566
+ // (taken once at start: the eyes' midpoint and axes, the origin) — never a screen input — so both
567
+ // eyes of a woven tile see the same particle in the same place.
568
+ //
569
+ // COMFORT: no particle is ever nearer to the eyes than its home depth minus the disparity budget
570
+ // `maxDisparity` (a fraction of the eye view's width, 0.004 = the wavefront ridge's cap):
571
+ // sep·(1/d − 1/dh) ≤ maxDisparity · 2·tan(fovX/2), i.e. d ≥ dh / (1 + capK·dh) with
572
+ // capK = maxDisparity · 2·tan(fovX/2) / eyeSeparation — enforced by pushing the particle back
573
+ // along its own ray from the eyes (its place in the picture is kept). In 2D, a nominal separation
574
+ // (64 mm at 1.7 m, scaled to the focus distance) keeps the same look as the woven tile.
575
+ //
576
+ // Every body returns at amount >= 1 (the baseline exactly) and at lp >= 1 (that gaussian is
577
+ // untouched), and the runner removes the effect at the end.
578
+
579
+ /** 2D stand-in for the eye separation, as a fraction of the focus distance (64 mm at 1.7 m). */
580
+ export const PARTICLE_NOMINAL_SEPARATION = 0.064 / 1.7;
581
+
582
+ /** SHARP's per-layer gaussian count (768²), for `order: 'layers'`. */
583
+ export const SHARP_LAYER_SIZE = 768 * 768;
584
+
585
+ const PARTICLE_ORDERS = ['radial', 'depth', 'noise', 'random', 'layers'];
586
+
587
+ // the per-gaussian key's GLSL (v = c − O, d = depth along the axis, k out)
588
+ const orderGlsl = (P, order, layerSize) => {
589
+ const radial = `clamp(length(vec2(dot(v, ${P}X), dot(v, ${P}Y)) / d - ${P}fimg) / ${P}rmax, 0.0, 1.0)`;
590
+ switch (order) {
591
+ case 'depth':
592
+ return `k = clamp((d - ${P}dmin) / max(${P}dmax - ${P}dmin, 1e-4), 0.0, 1.0);`;
593
+ case 'noise':
594
+ return `k = clamp((dxrFxFbm(${P}iq(c) * ${P}freq) - 0.3) / 0.4, 0.0, 1.0);`;
595
+ case 'random':
596
+ return `k = ${P}h(c, 1.0);`;
597
+ case 'layers':
598
+ // layer 0 (the visible surface) first, then layer 1 (the disocclusion fill), each outward
599
+ // from the origin in the picture.
600
+ return `k = 0.5 * min(float(splat.index / ${layerSize >>> 0}u), 1.0) + 0.5 * ${radial};`;
601
+ default:
602
+ return `k = ${radial};`;
603
+ }
604
+ };
605
+
606
+ // The part every particle effect shares: uniforms, the key, local progress, the comfort clamp,
607
+ // the dot, the in-flight colour. `lp` is cached by the centre stage for the later stages.
608
+ const particleCommon = (P, o) => `
609
+ uniform float ${P}amount;
610
+ uniform float ${P}time;
611
+ uniform float ${P}stagger;
612
+ uniform float ${P}jit;
613
+ uniform vec3 ${P}O;
614
+ uniform vec3 ${P}A;
615
+ uniform vec3 ${P}X;
616
+ uniform vec3 ${P}Y;
617
+ uniform vec3 ${P}F;
618
+ uniform vec2 ${P}fimg;
619
+ uniform float ${P}rmax;
620
+ uniform float ${P}dmin;
621
+ uniform float ${P}dmax;
622
+ uniform float ${P}freq;
623
+ uniform float ${P}capK;
624
+ uniform float ${P}dotK;
625
+ uniform float ${P}grow;
626
+ uniform float ${P}tx;
627
+ uniform vec3 ${P}glow;
628
+ uniform float ${P}falpha;
629
+ float ${P}lp = -1.0;
630
+ float ${P}h(vec3 c, float s) { return dxrFxHash(c + vec3(s * 17.13, s * 31.71, s * 7.31)); }
631
+ // where a point sits in the PICTURE (image x, y in half-view-widths) and its log depth: noise is
632
+ // sampled here so its patches are the same size on a 2 cm object and a 40 m street
633
+ vec3 ${P}iq(vec3 c) {
634
+ vec3 v = c - ${P}O;
635
+ float d = max(dot(v, ${P}A), 1e-4);
636
+ return vec3(dot(v, ${P}X) / (d * ${P}tx), dot(v, ${P}Y) / (d * ${P}tx), log(d));
637
+ }
638
+ // one half-view-width at the point's depth: every displacement is sized in picture units
639
+ float ${P}unit(vec3 c) { return max(dot(c - ${P}O, ${P}A), 1e-4) * ${P}tx; }
640
+ float ${P}local(vec3 c) {
641
+ vec3 v = c - ${P}O;
642
+ float d = max(dot(v, ${P}A), 1e-4);
643
+ float k;
644
+ ${orderGlsl(P, o.order, o.layerSize)}
645
+ k = mix(k, ${P}h(c, 1.0), ${P}jit);
646
+ return clamp((${P}amount - k * ${P}stagger) / max(1.0 - ${P}stagger, 1e-3), 0.0, 1.0);
647
+ }
648
+ // never nearer than home − the disparity budget: pushed back along its own ray from the eyes
649
+ vec3 ${P}near(vec3 home, vec3 p) {
650
+ float dh = dot(home - ${P}O, ${P}A);
651
+ if (dh <= 1e-4) return p;
652
+ // the depth whose disparity is exactly the budget more than home's: 1/dn = 1/dh + capK
653
+ float dn = dh / (1.0 + ${P}capK * dh);
654
+ vec3 v = p - ${P}O;
655
+ float d = dot(v, ${P}A);
656
+ if (d >= dn) return p;
657
+ if (d <= 1e-3 * dh) return p + ${P}A * (dn - d);
658
+ return ${P}O + v * (dn / d);
659
+ }
660
+ // rotate v about the unit axis a by angle t
661
+ vec3 ${P}rot(vec3 v, vec3 a, float t) {
662
+ float cs = cos(t);
663
+ float sn = sin(t);
664
+ return v * cs + cross(a, v) * sn + a * dot(a, v) * (1.0 - cs);
665
+ }
666
+ float ${P}lpOf(vec3 c) { return ${P}lp >= 0.0 ? ${P}lp : ${P}local(c); }
667
+ // the gaussian as a dot of ~dotSize of the view width at its CURRENT depth, growing back to its
668
+ // own scale over the last (1 − grow) of its flight
669
+ void ${P}dot(float lp, vec3 mc, inout vec3 sc, float mul) {
670
+ float g = smoothstep(${P}grow, 1.0, lp);
671
+ float d = max(dot(mc - ${P}O, ${P}A), 1e-4);
672
+ vec3 dsc = min(sc, vec3(${P}dotK * d * mul));
673
+ sc = mix(dsc, sc, g);
674
+ }
675
+ `;
676
+
677
+ // in-flight colour: its own colour, lifted by `glow` and faded by `flightAlpha` until it grows
678
+ const particleColor = (P, extra = '') => `
679
+ void ${P}color(vec3 c, inout vec4 col) {
680
+ if (${P}amount >= 1.0) return;
681
+ float lp = ${P}lpOf(c);
682
+ if (lp >= 1.0) return;
683
+ float g = smoothstep(${P}grow, 1.0, lp);
684
+ col.rgb += ${P}glow * (1.0 - g);
685
+ col.a *= mix(${P}falpha, 1.0, g);
686
+ ${extra}
687
+ }
688
+ `;
689
+
690
+ const PARTICLE_VALIDATE = (what, o) => {
691
+ if (o.order !== undefined && !PARTICLE_ORDERS.includes(o.order)) {
692
+ throw new Error(`@displayxr/inline3d/splat: ${what} order must be one of ${PARTICLE_ORDERS.join(', ')}.`);
693
+ }
694
+ if (o.order === 'layers') {
695
+ // splat.index is the asset's own FILE index only in a work-buffer modifier
696
+ if (o.scope === 'tile') throw new Error(`@displayxr/inline3d/splat: ${what} order 'layers' reads splat.index — entity scope only.`);
697
+ o.scope = 'entity';
698
+ }
699
+ if (o.stagger !== undefined) num(o.stagger, `${what} stagger`, 0, 0.95);
700
+ if (o.jitter !== undefined) num(o.jitter, `${what} jitter`, 0, 1);
701
+ if (o.dotSize !== undefined) num(o.dotSize, `${what} dotSize`, 0, 0.05);
702
+ if (o.grow !== undefined) num(o.grow, `${what} grow`, 0, 0.99);
703
+ if (o.flightAlpha !== undefined) num(o.flightAlpha, `${what} flightAlpha`, 0, 1);
704
+ if (o.glow !== undefined) num(o.glow, `${what} glow`, 0, 4);
705
+ if (o.color !== undefined) vec3Of(o.color, `${what} color`);
706
+ if (o.noiseScale !== undefined) num(o.noiseScale, `${what} noiseScale`, 1e-3, 1e3);
707
+ if (o.maxDisparity !== undefined) num(o.maxDisparity, `${what} maxDisparity`, 0, 0.05);
708
+ if (o.layerSize !== undefined && !(Number.isInteger(o.layerSize) && o.layerSize > 0)) {
709
+ throw new RangeError(`@displayxr/inline3d/splat: ${what} layerSize must be a positive integer.`);
710
+ }
711
+ };
712
+
713
+ /**
714
+ * The effect's fixed frame, taken once at start (and again when a gated reveal opens): the eyes'
715
+ * midpoint + axes, the origin, the framing box seen from there (image radius, depth range,
716
+ * extent), and the comfort cap. World units throughout.
717
+ */
718
+ function particleStart(ctx, inst) {
719
+ const eyes = ctx.eyes();
720
+ const O = eyes.origin, A = eyes.axis, X = eyes.right, Y = eyes.up;
721
+ const tanX = eyes.tanHalfFovX > 0 ? eyes.tanHalfFovX : 0.5;
722
+ const F = inst.originPoint(ctx, eyes);
723
+ const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
724
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
725
+ let D = dot3(sub(ctx.focus(), O), A);
726
+ if (!(D > 1e-4)) D = 1;
727
+ const fv = sub(F, O);
728
+ const fd = Math.max(dot3(fv, A), 1e-4);
729
+ const fimg = [dot3(fv, X) / fd, dot3(fv, Y) / fd];
730
+ const box = ctx.framing();
731
+ // image radius: from the origin's image point to the farthest corner of the VIEW (the lens's
732
+ // tangent extents, fixed at start — the same numbers for both eyes), so an 'radial' key spans
733
+ // the picture actually seen; without the lens height, the framing box's corners
734
+ let rmax = 0, dmin = Infinity, dmax = -Infinity;
735
+ const tanY = eyes.tanHalfFovY > 0 ? eyes.tanHalfFovY : 0;
736
+ if (tanY > 0) for (const sx of [-1, 1]) for (const sy of [-1, 1]) rmax = Math.max(rmax, Math.hypot(sx * tanX - fimg[0], sy * tanY - fimg[1]));
737
+ const lensR = rmax;
738
+ if (box) {
739
+ for (const sx of [-0.5, 0.5]) for (const sy of [-0.5, 0.5]) for (const sz of [-0.5, 0.5]) {
740
+ const v = sub([box.center[0] + sx * box.extent[0], box.center[1] + sy * box.extent[1], box.center[2] + sz * box.extent[2]], O);
741
+ const d = dot3(v, A);
742
+ dmin = Math.min(dmin, d);
743
+ dmax = Math.max(dmax, d);
744
+ if (d > 1e-4 && !lensR) rmax = Math.max(rmax, Math.hypot(dot3(v, X) / d - fimg[0], dot3(v, Y) / d - fimg[1]));
745
+ }
746
+ }
747
+ if (!(rmax > 0)) rmax = Math.hypot(1, 1) * tanX;
748
+ rmax = Math.min(rmax, 2.5 * tanX); // a box reaching behind the eyes would blow up the image radius
749
+ dmin = Number.isFinite(dmin) ? Math.max(dmin, 1e-3) : 0.5 * D;
750
+ if (!(dmax > dmin)) dmax = dmin + D;
751
+ const sep = eyes.separation > 0 ? eyes.separation : PARTICLE_NOMINAL_SEPARATION * D;
752
+ Object.assign(inst.state, {
753
+ O, A, X, Y, F, fimg, rmax, dmin, dmax,
754
+ tanX,
755
+ capK: (inst.opts.maxDisparity * 2 * tanX) / sep,
756
+ dotK: inst.opts.dotSize * 2 * tanX,
757
+ });
758
+ }
759
+
760
+ const particleUniforms = (ctx, inst, amount, tMs) => {
761
+ const o = inst.opts, s = inst.state;
762
+ return {
763
+ amount,
764
+ time: inst.elapsedS(tMs),
765
+ stagger: o.stagger,
766
+ jit: o.jitter,
767
+ O: s.O, A: s.A, X: s.X, Y: s.Y, F: s.F, fimg: s.fimg,
768
+ rmax: s.rmax, dmin: s.dmin, dmax: s.dmax,
769
+ freq: o.noiseScale,
770
+ capK: s.capK,
771
+ dotK: s.dotK,
772
+ grow: o.grow,
773
+ tx: s.tanX,
774
+ glow: o.color.map((x) => x * o.glow),
775
+ falpha: o.flightAlpha,
776
+ };
777
+ };
778
+
779
+ const PARTICLE_DEFAULTS = {
780
+ holdMs: 0,
781
+ origin: 'focus',
782
+ direction: 'in',
783
+ maxDisparity: 0.004,
784
+ layerSize: SHARP_LAYER_SIZE,
785
+ noiseScale: 2,
786
+ dotSize: 0.0007,
787
+ grow: 0.6,
788
+ };
789
+
790
+ /** A particle reveal's registry entry: `body(P)` adds the centre/rs stages (and extra uniforms). */
791
+ function particleEffect(name, defaults, { body, color = particleColor, uniforms: extra = () => ({}), validate }) {
792
+ return {
793
+ stage: 'reveal',
794
+ kind: 'transition',
795
+ particle: true,
796
+ defaults: { ...PARTICLE_DEFAULTS, ...defaults },
797
+ validate: (o) => {
798
+ PARTICLE_VALIDATE(name, o);
799
+ validate?.(o);
800
+ },
801
+ glsl: (P, o = {}) => particleCommon(P, { order: 'radial', layerSize: SHARP_LAYER_SIZE, ...o }) + body(P, o) + color(P),
802
+ start: particleStart,
803
+ uniforms: (ctx, inst, amount, tMs) => ({ ...particleUniforms(ctx, inst, amount, tMs), ...extra(ctx, inst, amount, tMs) }),
804
+ };
805
+ }
806
+
807
+ // A divergence-free (curl) field built from sines — cheap, smooth, and it evolves with time.
808
+ const CURL_GLSL = (P) => `
809
+ vec3 ${P}curl(vec3 q, float t) {
810
+ return vec3(
811
+ -cos(q.z * 1.13 + t) + cos(q.y * 0.87 - 0.6 * t),
812
+ -cos(q.x * 1.31 - t) + cos(q.z * 0.79 + 0.4 * t),
813
+ -cos(q.y * 0.97 + 0.7 * t) + cos(q.x * 1.07 - 0.5 * t));
814
+ }
815
+ `;
816
+
817
+ Object.assign(EFFECTS, {
818
+ // A swarm: every gaussian starts somewhere in a cloud around the subject (mostly in the
819
+ // picture's plane, behind rather than in front), then flies home along a curl-noise path that
820
+ // spirals about the view axis through the origin — staggered outward from the origin.
821
+ assemble: particleEffect(
822
+ 'assemble',
823
+ { durationMs: 2600, easing: 'linear', order: 'radial', stagger: 0.6, jitter: 0.3, grow: 0.8, spread: 0.6, swirl: 1.2, turbulence: 0.1, coherence: 0.6, depth: 0.3, color: [0.55, 0.8, 1.0], glow: 0.25, flightAlpha: 0.85 },
824
+ {
825
+ validate: (o) => {
826
+ if (o.spread !== undefined) num(o.spread, 'assemble spread', 0, 10);
827
+ if (o.swirl !== undefined) num(o.swirl, 'assemble swirl', -20, 20);
828
+ if (o.turbulence !== undefined) num(o.turbulence, 'assemble turbulence', 0, 2);
829
+ if (o.coherence !== undefined) num(o.coherence, 'assemble coherence', 0, 1);
830
+ if (o.depth !== undefined) num(o.depth, 'assemble depth', 0, 1);
831
+ },
832
+ uniforms: (ctx, inst) => ({ spread: inst.opts.spread, swirl: inst.opts.swirl, turb: inst.opts.turbulence, coh: inst.opts.coherence, dep: inst.opts.depth }),
833
+ body: (P) => `
834
+ uniform float ${P}spread;
835
+ uniform float ${P}swirl;
836
+ uniform float ${P}turb;
837
+ uniform float ${P}coh;
838
+ uniform float ${P}dep;
839
+ ${CURL_GLSL(P)}
840
+ void ${P}center(inout vec3 c) {
841
+ ${P}lp = -1.0;
842
+ if (${P}amount >= 1.0) return;
843
+ float lp = ${P}local(c);
844
+ ${P}lp = lp;
845
+ if (lp >= 1.0) return;
846
+ float tau = 1.0 - lp;
847
+ float w = tau * tau;
848
+ vec3 home = c;
849
+ float u = ${P}unit(c);
850
+ vec3 r = vec3(${P}h(c, 2.0), ${P}h(c, 3.0), ${P}h(c, 4.0)) * 2.0 - 1.0;
851
+ vec3 q = ${P}iq(c) * ${P}freq;
852
+ vec3 n = vec3(dxrFxNoise(q), dxrFxNoise(q + 19.1), dxrFxNoise(q + 47.3)) * 2.0 - 1.0;
853
+ vec3 dir = mix(r, n * 2.5, ${P}coh);
854
+ float dz = dot(dir, ${P}A);
855
+ dir = dir - ${P}A * dz + ${P}A * abs(dz) * ${P}dep;
856
+ dir /= max(length(dir), 1e-3);
857
+ vec3 p = home + dir * (${P}spread * u * (0.35 + 0.65 * ${P}h(c, 5.0)) * w);
858
+ p = ${P}F + ${P}rot(p - ${P}F, ${P}A, ${P}swirl * w * tau * (0.5 + ${P}h(c, 6.0)));
859
+ p += ${P}curl(q * 1.5 + dir * w * ${P}spread * 3.0, ${P}time * 0.8) * (${P}turb * u * w);
860
+ c = ${P}near(home, p);
861
+ }
862
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
863
+ if (${P}amount >= 1.0) return;
864
+ float lp = ${P}lpOf(oc);
865
+ if (lp >= 1.0) return;
866
+ ${P}dot(lp, mc, sc, 1.0);
867
+ }
868
+ `,
869
+ },
870
+ ),
871
+
872
+ // A dissolve played backwards: loose, faint dust drifts in on a slow wind and noise swirl and
873
+ // gathers into the picture patch by patch (fbm patches, like the dissolve's burn front).
874
+ 'dissolve-in': particleEffect(
875
+ 'dissolve-in',
876
+ { durationMs: 2600, easing: 'linear', order: 'noise', noiseScale: 3, stagger: 0.75, jitter: 0.2, grow: 0.75, lift: 0.12, drift: 0.35, color: [1.0, 0.7, 0.4], glow: 0.1, flightAlpha: 0.5 },
877
+ {
878
+ validate: (o) => {
879
+ if (o.lift !== undefined) num(o.lift, 'dissolve-in lift', 0, 10);
880
+ if (o.drift !== undefined) num(o.drift, 'dissolve-in drift', 0, 2);
881
+ },
882
+ uniforms: (ctx, inst) => ({ lift: inst.opts.lift, drift: inst.opts.drift }),
883
+ body: (P) => `
884
+ uniform float ${P}lift;
885
+ uniform float ${P}drift;
886
+ ${CURL_GLSL(P)}
887
+ void ${P}center(inout vec3 c) {
888
+ ${P}lp = -1.0;
889
+ if (${P}amount >= 1.0) return;
890
+ float lp = ${P}local(c);
891
+ ${P}lp = lp;
892
+ if (lp >= 1.0) return;
893
+ float tau = 1.0 - lp;
894
+ float w = tau * tau;
895
+ vec3 home = c;
896
+ float u = ${P}unit(c);
897
+ vec3 q = ${P}iq(c) * ${P}freq * 0.7;
898
+ float t = ${P}time * 0.25;
899
+ vec3 n = ${P}curl(q * 2.0, t * 4.0) * 0.5; // the cheap sine field, not 3 value noises (stereo cost)
900
+ // mostly each particle's own random drift, bent by a slow shared swirl (a purely coherent field
901
+ // would warp the picture in chunks instead of scattering it)
902
+ vec3 r = vec3(${P}h(c, 3.0), ${P}h(c, 4.0), ${P}h(c, 5.0)) * 2.0 - 1.0;
903
+ n = mix(n * 1.6, r, 0.65);
904
+ n -= ${P}A * dot(n, ${P}A) * 0.8;
905
+ vec3 wind = normalize(${P}Y + 0.35 * ${P}X);
906
+ vec3 p = home + (wind * (${P}lift * (0.5 + ${P}h(c, 2.0))) + n * ${P}drift * 2.0) * (u * w);
907
+ c = ${P}near(home, p);
908
+ }
909
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
910
+ if (${P}amount >= 1.0) return;
911
+ float lp = ${P}lpOf(oc);
912
+ if (lp >= 1.0) return;
913
+ ${P}dot(lp, mc, sc, 1.0);
914
+ }
915
+ `,
916
+ },
917
+ ),
918
+
919
+ // A burst: every gaussian leaves the origin (the focus) — launched outward in the picture,
920
+ // nearest first — and flies out along a spiral about the view axis to settle on its place. Not
921
+ // yet launched = not drawn, so it starts from a single bright point.
922
+ converge: particleEffect(
923
+ 'converge',
924
+ { durationMs: 2400, easing: 'linear', order: 'radial', stagger: 0.55, jitter: 0.25, grow: 0.7, spin: 0.9, burst: 0.04, color: [1.0, 0.85, 0.6], glow: 0.15, flightAlpha: 0.9 },
925
+ {
926
+ validate: (o) => {
927
+ if (o.spin !== undefined) num(o.spin, 'converge spin', -20, 20);
928
+ if (o.burst !== undefined) num(o.burst, 'converge burst', 0, 1);
929
+ },
930
+ uniforms: (ctx, inst) => ({ spin: inst.opts.spin, burst: inst.opts.burst }),
931
+ body: (P) => `
932
+ uniform float ${P}spin;
933
+ uniform float ${P}burst;
934
+ void ${P}center(inout vec3 c) {
935
+ ${P}lp = -1.0;
936
+ if (${P}amount >= 1.0) return;
937
+ float lp = ${P}local(c);
938
+ ${P}lp = lp;
939
+ if (lp >= 1.0) return;
940
+ float tau = 1.0 - lp;
941
+ float e = 1.0 - tau * tau;
942
+ vec3 home = c;
943
+ // Flown in the PICTURE: the image point goes straight out from the origin's image point while
944
+ // the depth goes from the origin's to home's, so the burst reads as radial in the picture
945
+ // whatever the depth (a far point would otherwise jump to its place at once). The launch point
946
+ // is a small ball about the origin: a point would pile a million dots on one pixel.
947
+ vec2 b = (vec2(${P}h(c, 3.0), ${P}h(c, 4.0)) * 2.0 - 1.0) * (${P}burst * ${P}tx * (1.0 - e));
948
+ vec3 fv = ${P}F - ${P}O;
949
+ vec3 hv = home - ${P}O;
950
+ float fd = max(dot(fv, ${P}A), 1e-4);
951
+ float hd = max(dot(hv, ${P}A), 1e-4);
952
+ vec2 fi = vec2(dot(fv, ${P}X), dot(fv, ${P}Y)) / fd;
953
+ vec2 hi = vec2(dot(hv, ${P}X), dot(hv, ${P}Y)) / hd;
954
+ vec2 im = mix(fi + b, hi, e);
955
+ float ang = ${P}spin * (1.0 - e) * (0.6 + 0.8 * ${P}h(c, 2.0));
956
+ im = ${P}fimg + mat2(cos(ang), sin(ang), -sin(ang), cos(ang)) * (im - ${P}fimg);
957
+ float d = mix(fd, hd, e);
958
+ vec3 p = ${P}O + (${P}A + ${P}X * im.x + ${P}Y * im.y) * d;
959
+ c = ${P}near(home, p);
960
+ }
961
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
962
+ if (${P}amount >= 1.0) return;
963
+ float lp = ${P}lpOf(oc);
964
+ if (lp >= 1.0) return;
965
+ if (lp <= 0.0) { sc = vec3(0.0); return; }
966
+ ${P}dot(lp, mc, sc, 1.0);
967
+ }
968
+ `,
969
+ color: (P) => particleColor(P, 'col.a *= smoothstep(0.0, 0.15, lp);'),
970
+ },
971
+ ),
972
+
973
+ // Nothing moves: each gaussian appears at home as a twinkling point, then grows into its full
974
+ // splat — random order with a loose outward drift from the origin.
975
+ shimmer: particleEffect(
976
+ 'shimmer',
977
+ { durationMs: 2600, easing: 'linear', order: 'radial', stagger: 0.8, jitter: 0.85, dotSize: 0.0006, grow: 0.6, twinkle: 14, sparkle: 1.2, color: [1.0, 1.0, 1.0], glow: 0, flightAlpha: 0.7 },
978
+ {
979
+ validate: (o) => {
980
+ if (o.twinkle !== undefined) num(o.twinkle, 'shimmer twinkle', 0, 200);
981
+ if (o.sparkle !== undefined) num(o.sparkle, 'shimmer sparkle', 0, 4);
982
+ },
983
+ uniforms: (ctx, inst) => ({ tw: inst.opts.twinkle, sp: inst.opts.sparkle, sc: inst.opts.color }),
984
+ body: (P) => `
985
+ uniform float ${P}tw;
986
+ uniform float ${P}sp;
987
+ uniform vec3 ${P}sc;
988
+ float ${P}twk(vec3 c) {
989
+ float h = ${P}h(c, 3.0);
990
+ return pow(0.5 + 0.5 * sin(${P}time * ${P}tw * (0.6 + 0.8 * h) + 6.2832 * ${P}h(c, 4.0)), 6.0);
991
+ }
992
+ void ${P}center(inout vec3 c) {
993
+ ${P}lp = -1.0;
994
+ if (${P}amount >= 1.0) return;
995
+ ${P}lp = ${P}local(c);
996
+ }
997
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
998
+ if (${P}amount >= 1.0) return;
999
+ float lp = ${P}lpOf(oc);
1000
+ if (lp >= 1.0) return;
1001
+ if (lp <= 0.0) { sc = vec3(0.0); return; }
1002
+ ${P}dot(lp, mc, sc, 0.7 + 0.9 * ${P}twk(oc));
1003
+ }
1004
+ `,
1005
+ color: (P) =>
1006
+ particleColor(
1007
+ P,
1008
+ `float tw = ${P}twk(c);
1009
+ col.rgb = mix(col.rgb, ${P}sc, min(1.0, tw * ${P}sp) * (1.0 - g));
1010
+ col.a *= smoothstep(0.0, 0.12, lp) * mix(0.55 + 0.45 * tw, 1.0, g);`,
1011
+ ),
1012
+ },
1013
+ ),
1014
+ });
1015
+
555
1016
  /**
556
1017
  * The wavefront's per-column commit, lt ∈ [0, 1], for eased progress `t` at normalised u — the
557
1018
  * prototype's formula: column u starts at t = u·(1 − band) and has committed band later, so
@@ -570,7 +1031,7 @@ export const PUBLIC_EFFECTS = Object.freeze(
570
1031
  .sort(),
571
1032
  );
572
1033
  /** addSplat's `reveal` accepts these (the transition effects). */
573
- export const REVEAL_EFFECTS = Object.freeze(['inflate', 'sweep', 'dissolve', 'fade']);
1034
+ export const REVEAL_EFFECTS = Object.freeze(['inflate', 'sweep', 'dissolve', 'fade', 'assemble', 'dissolve-in', 'converge', 'shimmer']);
574
1035
 
575
1036
  // ── custom GLSL ───────────────────────────────────────────────────────────────────────────────
576
1037
 
@@ -711,7 +1172,7 @@ export function composeModifier(instances) {
711
1172
  for (const inst of list) {
712
1173
  const P = prefixOf(inst.name);
713
1174
  code += `// ── ${inst.name} (${inst.def.stage})\n`;
714
- code += isCustom(inst.name) ? customGlsl(P, inst.opts.glsl) : inst.def.glsl(P);
1175
+ code += isCustom(inst.name) ? customGlsl(P, inst.opts.glsl) : inst.def.glsl(P, inst.opts);
715
1176
  calls.center.push(` ${P}center(center);`);
716
1177
  calls.rs.push(` ${P}rs(originalCenter, modifiedCenter, rotation, scale);`);
717
1178
  calls.color.push(` ${P}color(center, color);`);