@displayxr/inline3d 1.13.1 → 1.15.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.
@@ -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,545 @@ 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
+ uniform float ${P}van;
630
+ uniform float ${P}dens;
631
+ float ${P}lp = -1.0;
632
+ float ${P}h(vec3 c, float s) { return dxrFxHash(c + vec3(s * 17.13, s * 31.71, s * 7.31)); }
633
+ // where a point sits in the PICTURE (image x, y in half-view-widths) and its log depth: noise is
634
+ // sampled here so its patches are the same size on a 2 cm object and a 40 m street
635
+ vec3 ${P}iq(vec3 c) {
636
+ vec3 v = c - ${P}O;
637
+ float d = max(dot(v, ${P}A), 1e-4);
638
+ return vec3(dot(v, ${P}X) / (d * ${P}tx), dot(v, ${P}Y) / (d * ${P}tx), log(d));
639
+ }
640
+ // one half-view-width at the point's depth: every displacement is sized in picture units
641
+ float ${P}unit(vec3 c) { return max(dot(c - ${P}O, ${P}A), 1e-4) * ${P}tx; }
642
+ float ${P}local(vec3 c) {
643
+ vec3 v = c - ${P}O;
644
+ float d = max(dot(v, ${P}A), 1e-4);
645
+ float k;
646
+ ${orderGlsl(P, o.order, o.layerSize)}
647
+ k = mix(k, ${P}h(c, 1.0), ${P}jit);
648
+ return clamp((${P}amount - k * ${P}stagger) / max(1.0 - ${P}stagger, 1e-3), 0.0, 1.0);
649
+ }
650
+ // never nearer than home − the disparity budget: pushed back along its own ray from the eyes
651
+ vec3 ${P}near(vec3 home, vec3 p) {
652
+ float dh = dot(home - ${P}O, ${P}A);
653
+ if (dh <= 1e-4) return p;
654
+ // the depth whose disparity is exactly the budget more than home's: 1/dn = 1/dh + capK
655
+ float dn = dh / (1.0 + ${P}capK * dh);
656
+ vec3 v = p - ${P}O;
657
+ float d = dot(v, ${P}A);
658
+ if (d >= dn) return p;
659
+ if (d <= 1e-3 * dh) return p + ${P}A * (dn - d);
660
+ return ${P}O + v * (dn / d);
661
+ }
662
+ // rotate v about the unit axis a by angle t
663
+ vec3 ${P}rot(vec3 v, vec3 a, float t) {
664
+ float cs = cos(t);
665
+ float sn = sin(t);
666
+ return v * cs + cross(a, v) * sn + a * dot(a, v) * (1.0 - cs);
667
+ }
668
+ float ${P}lpOf(vec3 c) { return ${P}lp >= 0.0 ? ${P}lp : ${P}local(c); }
669
+ // the gaussian as a dot of ~dotSize of the view width at its CURRENT depth, growing back to its
670
+ // own scale over the last (1 − grow) of its flight
671
+ void ${P}dot(float lp, vec3 mc, inout vec3 sc, float mul) {
672
+ float g = smoothstep(${P}grow, 1.0, lp);
673
+ float d = max(dot(mc - ${P}O, ${P}A), 1e-4);
674
+ vec3 dsc = min(sc, vec3(${P}dotK * d * mul));
675
+ sc = mix(dsc, sc, g);
676
+ }
677
+ `;
678
+
679
+ // in-flight colour: its own colour, lifted by `glow` and faded by `flightAlpha` until it grows
680
+ const particleColor = (P, extra = '') => `
681
+ void ${P}color(vec3 c, inout vec4 col) {
682
+ if (${P}amount >= 1.0) return;
683
+ float lp = ${P}lpOf(c);
684
+ if (lp >= 1.0) return;
685
+ float g = smoothstep(${P}grow, 1.0, lp);
686
+ col.rgb += ${P}glow * (1.0 - g);
687
+ col.a *= mix(${P}falpha, 1.0, g);
688
+ // setSource's particle transitions: a particle fades over the first "vanish" of its flight, so a
689
+ // swarm gathers out of nothing and a leaving photo's swarm thins out to nothing (0 = off)
690
+ if (${P}van > 0.0) col.a *= smoothstep(0.0, ${P}van, lp);
691
+ // "density" < 1: only that share of the gaussians is drawn in flight (the rest appear as they
692
+ // grow home), so a million-point swarm reads as a swarm, not as snow
693
+ if (${P}dens < 1.0 && ${P}h(c, 9.0) > ${P}dens) col.a *= g;
694
+ ${extra}
695
+ }
696
+ `;
697
+
698
+ const PARTICLE_VALIDATE = (what, o) => {
699
+ if (o.order !== undefined && !PARTICLE_ORDERS.includes(o.order)) {
700
+ throw new Error(`@displayxr/inline3d/splat: ${what} order must be one of ${PARTICLE_ORDERS.join(', ')}.`);
701
+ }
702
+ if (o.order === 'layers') {
703
+ // splat.index is the asset's own FILE index only in a work-buffer modifier
704
+ if (o.scope === 'tile') throw new Error(`@displayxr/inline3d/splat: ${what} order 'layers' reads splat.index — entity scope only.`);
705
+ o.scope = 'entity';
706
+ }
707
+ if (o.stagger !== undefined) num(o.stagger, `${what} stagger`, 0, 0.95);
708
+ if (o.jitter !== undefined) num(o.jitter, `${what} jitter`, 0, 1);
709
+ if (o.dotSize !== undefined) num(o.dotSize, `${what} dotSize`, 0, 0.05);
710
+ if (o.grow !== undefined) num(o.grow, `${what} grow`, 0, 0.99);
711
+ if (o.flightAlpha !== undefined) num(o.flightAlpha, `${what} flightAlpha`, 0, 1);
712
+ if (o.glow !== undefined) num(o.glow, `${what} glow`, 0, 4);
713
+ if (o.color !== undefined) vec3Of(o.color, `${what} color`);
714
+ if (o.noiseScale !== undefined) num(o.noiseScale, `${what} noiseScale`, 1e-3, 1e3);
715
+ if (o.maxDisparity !== undefined) num(o.maxDisparity, `${what} maxDisparity`, 0, 0.05);
716
+ if (o.vanish !== undefined) num(o.vanish, `${what} vanish`, 0, 1);
717
+ if (o.density !== undefined) num(o.density, `${what} density`, 0, 1);
718
+ if (o.layerSize !== undefined && !(Number.isInteger(o.layerSize) && o.layerSize > 0)) {
719
+ throw new RangeError(`@displayxr/inline3d/splat: ${what} layerSize must be a positive integer.`);
720
+ }
721
+ };
722
+
723
+ /**
724
+ * The effect's fixed frame, taken once at start (and again when a gated reveal opens): the eyes'
725
+ * midpoint + axes, the origin, the framing box seen from there (image radius, depth range,
726
+ * extent), and the comfort cap. World units throughout.
727
+ */
728
+ function particleStart(ctx, inst) {
729
+ const eyes = ctx.eyes();
730
+ const O = eyes.origin, A = eyes.axis, X = eyes.right, Y = eyes.up;
731
+ const tanX = eyes.tanHalfFovX > 0 ? eyes.tanHalfFovX : 0.5;
732
+ const F = inst.originPoint(ctx, eyes);
733
+ const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
734
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
735
+ let D = dot3(sub(ctx.focus(), O), A);
736
+ if (!(D > 1e-4)) D = 1;
737
+ const fv = sub(F, O);
738
+ const fd = Math.max(dot3(fv, A), 1e-4);
739
+ const fimg = [dot3(fv, X) / fd, dot3(fv, Y) / fd];
740
+ const box = ctx.framing();
741
+ // image radius: from the origin's image point to the farthest corner of the VIEW (the lens's
742
+ // tangent extents, fixed at start — the same numbers for both eyes), so an 'radial' key spans
743
+ // the picture actually seen; without the lens height, the framing box's corners
744
+ let rmax = 0, dmin = Infinity, dmax = -Infinity;
745
+ const tanY = eyes.tanHalfFovY > 0 ? eyes.tanHalfFovY : 0;
746
+ 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]));
747
+ const lensR = rmax;
748
+ if (box) {
749
+ for (const sx of [-0.5, 0.5]) for (const sy of [-0.5, 0.5]) for (const sz of [-0.5, 0.5]) {
750
+ 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);
751
+ const d = dot3(v, A);
752
+ dmin = Math.min(dmin, d);
753
+ dmax = Math.max(dmax, d);
754
+ if (d > 1e-4 && !lensR) rmax = Math.max(rmax, Math.hypot(dot3(v, X) / d - fimg[0], dot3(v, Y) / d - fimg[1]));
755
+ }
756
+ }
757
+ if (!(rmax > 0)) rmax = Math.hypot(1, 1) * tanX;
758
+ rmax = Math.min(rmax, 2.5 * tanX); // a box reaching behind the eyes would blow up the image radius
759
+ dmin = Number.isFinite(dmin) ? Math.max(dmin, 1e-3) : 0.5 * D;
760
+ if (!(dmax > dmin)) dmax = dmin + D;
761
+ const sep = eyes.separation > 0 ? eyes.separation : PARTICLE_NOMINAL_SEPARATION * D;
762
+ Object.assign(inst.state, {
763
+ O, A, X, Y, F, fimg, rmax, dmin, dmax,
764
+ tanX,
765
+ capK: (inst.opts.maxDisparity * 2 * tanX) / sep,
766
+ dotK: inst.opts.dotSize * 2 * tanX,
767
+ });
768
+ }
769
+
770
+ const particleUniforms = (ctx, inst, amount, tMs) => {
771
+ const o = inst.opts, s = inst.state;
772
+ return {
773
+ amount,
774
+ time: inst.elapsedS(tMs),
775
+ stagger: o.stagger,
776
+ jit: o.jitter,
777
+ O: s.O, A: s.A, X: s.X, Y: s.Y, F: s.F, fimg: s.fimg,
778
+ rmax: s.rmax, dmin: s.dmin, dmax: s.dmax,
779
+ freq: o.noiseScale,
780
+ capK: s.capK,
781
+ dotK: s.dotK,
782
+ grow: o.grow,
783
+ tx: s.tanX,
784
+ glow: o.color.map((x) => x * o.glow),
785
+ falpha: o.flightAlpha,
786
+ van: o.vanish,
787
+ dens: o.density,
788
+ };
789
+ };
790
+
791
+ const PARTICLE_DEFAULTS = {
792
+ holdMs: 0,
793
+ origin: 'focus',
794
+ direction: 'in',
795
+ maxDisparity: 0.004,
796
+ layerSize: SHARP_LAYER_SIZE,
797
+ noiseScale: 2,
798
+ dotSize: 0.0007,
799
+ grow: 0.6,
800
+ vanish: 0,
801
+ density: 1,
802
+ };
803
+
804
+ /**
805
+ * A particle reveal's registry entry: `body(P)` adds the centre/rs stages (and extra uniforms).
806
+ * `hiddenAtZero(opts)`: nothing is drawn at amount 0 (converge / shimmer: not yet launched; the
807
+ * others with `vanish` > 0) — a setSource transition then skips that photo's draw.
808
+ */
809
+ function particleEffect(name, defaults, { body, color = particleColor, uniforms: extra = () => ({}), validate, hiddenAtZero = (o) => o.vanish > 0 }) {
810
+ return {
811
+ hiddenAtZero,
812
+ stage: 'reveal',
813
+ kind: 'transition',
814
+ particle: true,
815
+ defaults: { ...PARTICLE_DEFAULTS, ...defaults },
816
+ validate: (o) => {
817
+ PARTICLE_VALIDATE(name, o);
818
+ validate?.(o);
819
+ },
820
+ glsl: (P, o = {}) => particleCommon(P, { order: 'radial', layerSize: SHARP_LAYER_SIZE, ...o }) + body(P, o) + color(P),
821
+ start: particleStart,
822
+ uniforms: (ctx, inst, amount, tMs) => ({ ...particleUniforms(ctx, inst, amount, tMs), ...extra(ctx, inst, amount, tMs) }),
823
+ };
824
+ }
825
+
826
+ // A divergence-free (curl) field built from sines — cheap, smooth, and it evolves with time.
827
+ const CURL_GLSL = (P) => `
828
+ vec3 ${P}curl(vec3 q, float t) {
829
+ return vec3(
830
+ -cos(q.z * 1.13 + t) + cos(q.y * 0.87 - 0.6 * t),
831
+ -cos(q.x * 1.31 - t) + cos(q.z * 0.79 + 0.4 * t),
832
+ -cos(q.y * 0.97 + 0.7 * t) + cos(q.x * 1.07 - 0.5 * t));
833
+ }
834
+ `;
835
+
836
+ Object.assign(EFFECTS, {
837
+ // A swarm: every gaussian starts somewhere in a cloud around the subject (mostly in the
838
+ // picture's plane, behind rather than in front), then flies home along a curl-noise path that
839
+ // spirals about the view axis through the origin — staggered outward from the origin.
840
+ assemble: particleEffect(
841
+ 'assemble',
842
+ { 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 },
843
+ {
844
+ validate: (o) => {
845
+ if (o.spread !== undefined) num(o.spread, 'assemble spread', 0, 10);
846
+ if (o.swirl !== undefined) num(o.swirl, 'assemble swirl', -20, 20);
847
+ if (o.turbulence !== undefined) num(o.turbulence, 'assemble turbulence', 0, 2);
848
+ if (o.coherence !== undefined) num(o.coherence, 'assemble coherence', 0, 1);
849
+ if (o.depth !== undefined) num(o.depth, 'assemble depth', 0, 1);
850
+ },
851
+ uniforms: (ctx, inst) => ({ spread: inst.opts.spread, swirl: inst.opts.swirl, turb: inst.opts.turbulence, coh: inst.opts.coherence, dep: inst.opts.depth }),
852
+ body: (P) => `
853
+ uniform float ${P}spread;
854
+ uniform float ${P}swirl;
855
+ uniform float ${P}turb;
856
+ uniform float ${P}coh;
857
+ uniform float ${P}dep;
858
+ ${CURL_GLSL(P)}
859
+ void ${P}center(inout vec3 c) {
860
+ ${P}lp = -1.0;
861
+ if (${P}amount >= 1.0) return;
862
+ float lp = ${P}local(c);
863
+ ${P}lp = lp;
864
+ if (lp >= 1.0) return;
865
+ float tau = 1.0 - lp;
866
+ float w = tau * tau;
867
+ vec3 home = c;
868
+ float u = ${P}unit(c);
869
+ vec3 r = vec3(${P}h(c, 2.0), ${P}h(c, 3.0), ${P}h(c, 4.0)) * 2.0 - 1.0;
870
+ vec3 q = ${P}iq(c) * ${P}freq;
871
+ vec3 n = vec3(dxrFxNoise(q), dxrFxNoise(q + 19.1), dxrFxNoise(q + 47.3)) * 2.0 - 1.0;
872
+ vec3 dir = mix(r, n * 2.5, ${P}coh);
873
+ float dz = dot(dir, ${P}A);
874
+ dir = dir - ${P}A * dz + ${P}A * abs(dz) * ${P}dep;
875
+ dir /= max(length(dir), 1e-3);
876
+ vec3 p = home + dir * (${P}spread * u * (0.35 + 0.65 * ${P}h(c, 5.0)) * w);
877
+ p = ${P}F + ${P}rot(p - ${P}F, ${P}A, ${P}swirl * w * tau * (0.5 + ${P}h(c, 6.0)));
878
+ p += ${P}curl(q * 1.5 + dir * w * ${P}spread * 3.0, ${P}time * 0.8) * (${P}turb * u * w);
879
+ c = ${P}near(home, p);
880
+ }
881
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
882
+ if (${P}amount >= 1.0) return;
883
+ float lp = ${P}lpOf(oc);
884
+ if (lp >= 1.0) return;
885
+ ${P}dot(lp, mc, sc, 1.0);
886
+ }
887
+ `,
888
+ },
889
+ ),
890
+
891
+ // A dissolve played backwards: loose, faint dust drifts in on a slow wind and noise swirl and
892
+ // gathers into the picture patch by patch (fbm patches, like the dissolve's burn front).
893
+ 'dissolve-in': particleEffect(
894
+ 'dissolve-in',
895
+ { 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 },
896
+ {
897
+ validate: (o) => {
898
+ if (o.lift !== undefined) num(o.lift, 'dissolve-in lift', 0, 10);
899
+ if (o.drift !== undefined) num(o.drift, 'dissolve-in drift', 0, 2);
900
+ },
901
+ uniforms: (ctx, inst) => ({ lift: inst.opts.lift, drift: inst.opts.drift }),
902
+ body: (P) => `
903
+ uniform float ${P}lift;
904
+ uniform float ${P}drift;
905
+ ${CURL_GLSL(P)}
906
+ void ${P}center(inout vec3 c) {
907
+ ${P}lp = -1.0;
908
+ if (${P}amount >= 1.0) return;
909
+ float lp = ${P}local(c);
910
+ ${P}lp = lp;
911
+ if (lp >= 1.0) return;
912
+ float tau = 1.0 - lp;
913
+ float w = tau * tau;
914
+ vec3 home = c;
915
+ float u = ${P}unit(c);
916
+ vec3 q = ${P}iq(c) * ${P}freq * 0.7;
917
+ float t = ${P}time * 0.25;
918
+ vec3 n = ${P}curl(q * 2.0, t * 4.0) * 0.5; // the cheap sine field, not 3 value noises (stereo cost)
919
+ // mostly each particle's own random drift, bent by a slow shared swirl (a purely coherent field
920
+ // would warp the picture in chunks instead of scattering it)
921
+ vec3 r = vec3(${P}h(c, 3.0), ${P}h(c, 4.0), ${P}h(c, 5.0)) * 2.0 - 1.0;
922
+ n = mix(n * 1.6, r, 0.65);
923
+ n -= ${P}A * dot(n, ${P}A) * 0.8;
924
+ vec3 wind = normalize(${P}Y + 0.35 * ${P}X);
925
+ vec3 p = home + (wind * (${P}lift * (0.5 + ${P}h(c, 2.0))) + n * ${P}drift * 2.0) * (u * w);
926
+ c = ${P}near(home, p);
927
+ }
928
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
929
+ if (${P}amount >= 1.0) return;
930
+ float lp = ${P}lpOf(oc);
931
+ if (lp >= 1.0) return;
932
+ ${P}dot(lp, mc, sc, 1.0);
933
+ }
934
+ `,
935
+ },
936
+ ),
937
+
938
+ // A burst: every gaussian leaves the origin (the focus) — launched outward in the picture,
939
+ // nearest first — and flies out along a spiral about the view axis to settle on its place. Not
940
+ // yet launched = not drawn, so it starts from a single bright point.
941
+ converge: particleEffect(
942
+ 'converge',
943
+ { 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 },
944
+ {
945
+ validate: (o) => {
946
+ if (o.spin !== undefined) num(o.spin, 'converge spin', -20, 20);
947
+ if (o.burst !== undefined) num(o.burst, 'converge burst', 0, 1);
948
+ },
949
+ uniforms: (ctx, inst) => ({ spin: inst.opts.spin, burst: inst.opts.burst }),
950
+ body: (P) => `
951
+ uniform float ${P}spin;
952
+ uniform float ${P}burst;
953
+ void ${P}center(inout vec3 c) {
954
+ ${P}lp = -1.0;
955
+ if (${P}amount >= 1.0) return;
956
+ float lp = ${P}local(c);
957
+ ${P}lp = lp;
958
+ if (lp >= 1.0) return;
959
+ float tau = 1.0 - lp;
960
+ float e = 1.0 - tau * tau;
961
+ vec3 home = c;
962
+ // Flown in the PICTURE: the image point goes straight out from the origin's image point while
963
+ // the depth goes from the origin's to home's, so the burst reads as radial in the picture
964
+ // whatever the depth (a far point would otherwise jump to its place at once). The launch point
965
+ // is a small ball about the origin: a point would pile a million dots on one pixel.
966
+ vec2 b = (vec2(${P}h(c, 3.0), ${P}h(c, 4.0)) * 2.0 - 1.0) * (${P}burst * ${P}tx * (1.0 - e));
967
+ vec3 fv = ${P}F - ${P}O;
968
+ vec3 hv = home - ${P}O;
969
+ float fd = max(dot(fv, ${P}A), 1e-4);
970
+ float hd = max(dot(hv, ${P}A), 1e-4);
971
+ vec2 fi = vec2(dot(fv, ${P}X), dot(fv, ${P}Y)) / fd;
972
+ vec2 hi = vec2(dot(hv, ${P}X), dot(hv, ${P}Y)) / hd;
973
+ vec2 im = mix(fi + b, hi, e);
974
+ float ang = ${P}spin * (1.0 - e) * (0.6 + 0.8 * ${P}h(c, 2.0));
975
+ im = ${P}fimg + mat2(cos(ang), sin(ang), -sin(ang), cos(ang)) * (im - ${P}fimg);
976
+ float d = mix(fd, hd, e);
977
+ vec3 p = ${P}O + (${P}A + ${P}X * im.x + ${P}Y * im.y) * d;
978
+ c = ${P}near(home, p);
979
+ }
980
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
981
+ if (${P}amount >= 1.0) return;
982
+ float lp = ${P}lpOf(oc);
983
+ if (lp >= 1.0) return;
984
+ if (lp <= 0.0) { sc = vec3(0.0); return; }
985
+ ${P}dot(lp, mc, sc, 1.0);
986
+ }
987
+ `,
988
+ color: (P) => particleColor(P, 'col.a *= smoothstep(0.0, 0.15, lp);'),
989
+ hiddenAtZero: () => true,
990
+ },
991
+ ),
992
+
993
+ // Nothing moves: each gaussian appears at home as a twinkling point, then grows into its full
994
+ // splat — random order with a loose outward drift from the origin.
995
+ shimmer: particleEffect(
996
+ 'shimmer',
997
+ { 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 },
998
+ {
999
+ validate: (o) => {
1000
+ if (o.twinkle !== undefined) num(o.twinkle, 'shimmer twinkle', 0, 200);
1001
+ if (o.sparkle !== undefined) num(o.sparkle, 'shimmer sparkle', 0, 4);
1002
+ },
1003
+ uniforms: (ctx, inst) => ({ tw: inst.opts.twinkle, sp: inst.opts.sparkle, sc: inst.opts.color }),
1004
+ body: (P) => `
1005
+ uniform float ${P}tw;
1006
+ uniform float ${P}sp;
1007
+ uniform vec3 ${P}sc;
1008
+ float ${P}twk(vec3 c) {
1009
+ float h = ${P}h(c, 3.0);
1010
+ 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);
1011
+ }
1012
+ void ${P}center(inout vec3 c) {
1013
+ ${P}lp = -1.0;
1014
+ if (${P}amount >= 1.0) return;
1015
+ ${P}lp = ${P}local(c);
1016
+ }
1017
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
1018
+ if (${P}amount >= 1.0) return;
1019
+ float lp = ${P}lpOf(oc);
1020
+ if (lp >= 1.0) return;
1021
+ if (lp <= 0.0) { sc = vec3(0.0); return; }
1022
+ ${P}dot(lp, mc, sc, 0.7 + 0.9 * ${P}twk(oc));
1023
+ }
1024
+ `,
1025
+ color: (P) =>
1026
+ particleColor(
1027
+ P,
1028
+ `float tw = ${P}twk(c);
1029
+ col.rgb = mix(col.rgb, ${P}sc, min(1.0, tw * ${P}sp) * (1.0 - g));
1030
+ col.a *= smoothstep(0.0, 0.12, lp) * mix(0.55 + 0.45 * tw, 1.0, g);`,
1031
+ ),
1032
+ hiddenAtZero: () => true,
1033
+ },
1034
+ ),
1035
+ });
1036
+
1037
+ // ── setSource's particle transitions ─────────────────────────────────────────────────────────
1038
+ //
1039
+ // swarm / burst / shimmer-cross / dust: the OUTGOING photo plays a particle reveal backwards (its
1040
+ // gaussians leave home as dots and thin out to nothing, `vanish`) while the INCOMING one plays it
1041
+ // forwards, on overlapping spans of one clock. Both run as entity-scope effects driven by the
1042
+ // adapter (SplatEffects.drive); the outgoing one is drawn by the LIVE outgoing camera, and the
1043
+ // adapter composites the two live images per eye, the old one OVER the new one.
1044
+
1045
+ /**
1046
+ * The transitions and their defaults. `out` / `in` = the particle effect each side plays (out: in
1047
+ * reverse) with its option overrides; `overlap` = how much of the clock the two spans share (0 =
1048
+ * one after the other, 1 = both over the whole clock): out over [0, (1 + overlap)/2], in over
1049
+ * [(1 − overlap)/2, 1] of the eased clock.
1050
+ */
1051
+ export const PARTICLE_TRANSITIONS = Object.freeze({
1052
+ swarm: {
1053
+ durationMs: 2800,
1054
+ easing: 'linear',
1055
+ overlap: 0.45,
1056
+ out: { effect: 'assemble', opts: { stagger: 0.65, jitter: 0.35, spread: 0.3, swirl: 1.8, density: 0.2, dotSize: 0.0009, vanish: 0.45, glow: 0.1 } },
1057
+ in: { effect: 'assemble', opts: { stagger: 0.65, spread: 0.3, swirl: 1.8, density: 0.2, dotSize: 0.0009, vanish: 0.35, glow: 0.1 } },
1058
+ },
1059
+ burst: {
1060
+ durationMs: 2600,
1061
+ easing: 'linear',
1062
+ overlap: 0.3,
1063
+ out: { effect: 'converge', opts: { stagger: 0.5, density: 0.5, glow: 0.25 } },
1064
+ in: { effect: 'converge', opts: { stagger: 0.55, density: 0.5 } },
1065
+ },
1066
+ 'shimmer-cross': {
1067
+ durationMs: 2600,
1068
+ easing: 'linear',
1069
+ overlap: 0.45,
1070
+ out: { effect: 'shimmer', opts: { stagger: 0.75 } },
1071
+ in: { effect: 'shimmer', opts: { stagger: 0.75 } },
1072
+ },
1073
+ dust: {
1074
+ durationMs: 2800,
1075
+ easing: 'linear',
1076
+ overlap: 0.4,
1077
+ out: { effect: 'dissolve-in', opts: { density: 0.4, vanish: 0.4, drift: 0.45 } },
1078
+ in: { effect: 'dissolve-in', opts: { density: 0.4, vanish: 0.3 } },
1079
+ },
1080
+ });
1081
+
1082
+ /** Where a side stands at eased clock t: 0..1 over its span. */
1083
+ export function particleSpan(t, overlap, side) {
1084
+ const v = Math.min(1, Math.max(0, overlap));
1085
+ const a = side === 'out' ? 0 : (1 - v) / 2;
1086
+ const b = side === 'out' ? (1 + v) / 2 : 1;
1087
+ return Math.min(1, Math.max(0, (t - a) / Math.max(b - a, 1e-6)));
1088
+ }
1089
+
1090
+ /** Options a page may pass through setSource to both sides of a particle transition. */
1091
+ export const PARTICLE_TRANSITION_OPTIONS = Object.freeze(['order', 'stagger', 'jitter', 'maxDisparity', 'dotSize', 'noiseScale', 'layerSize', 'origin']);
1092
+
1093
+
555
1094
  /**
556
1095
  * The wavefront's per-column commit, lt ∈ [0, 1], for eased progress `t` at normalised u — the
557
1096
  * prototype's formula: column u starts at t = u·(1 − band) and has committed band later, so
@@ -570,7 +1109,7 @@ export const PUBLIC_EFFECTS = Object.freeze(
570
1109
  .sort(),
571
1110
  );
572
1111
  /** addSplat's `reveal` accepts these (the transition effects). */
573
- export const REVEAL_EFFECTS = Object.freeze(['inflate', 'sweep', 'dissolve', 'fade']);
1112
+ export const REVEAL_EFFECTS = Object.freeze(['inflate', 'sweep', 'dissolve', 'fade', 'assemble', 'dissolve-in', 'converge', 'shimmer']);
574
1113
 
575
1114
  // ── custom GLSL ───────────────────────────────────────────────────────────────────────────────
576
1115
 
@@ -711,7 +1250,7 @@ export function composeModifier(instances) {
711
1250
  for (const inst of list) {
712
1251
  const P = prefixOf(inst.name);
713
1252
  code += `// ── ${inst.name} (${inst.def.stage})\n`;
714
- code += isCustom(inst.name) ? customGlsl(P, inst.opts.glsl) : inst.def.glsl(P);
1253
+ code += isCustom(inst.name) ? customGlsl(P, inst.opts.glsl) : inst.def.glsl(P, inst.opts);
715
1254
  calls.center.push(` ${P}center(center);`);
716
1255
  calls.rs.push(` ${P}rs(originalCenter, modifiedCenter, rotation, scale);`);
717
1256
  calls.color.push(` ${P}color(center, color);`);
@@ -782,7 +1321,8 @@ export class SplatEffects {
782
1321
  resolve: null,
783
1322
  promise: null,
784
1323
  originPoint: (ctx, eyes) => this._origin(inst, ctx, eyes),
785
- elapsedS: (tMs) => (inst.startedAt === null ? 0 : Math.max(0, (tMs - inst.startedAt) / 1000)),
1324
+ // a driven instance (drive()) is handed its time by the adapter's clock
1325
+ elapsedS: (tMs) => (inst.timeS !== undefined ? inst.timeS : inst.startedAt === null ? 0 : Math.max(0, (tMs - inst.startedAt) / 1000)),
786
1326
  };
787
1327
  inst.promise = new Promise((r) => (inst.resolve = r));
788
1328
  return inst;
@@ -865,6 +1405,134 @@ export class SplatEffects {
865
1405
  this._install(entity);
866
1406
  }
867
1407
 
1408
+ /**
1409
+ * An effect on one entity driven by the ADAPTER's clock, not the runner's (setSource's particle
1410
+ * transitions: two effects on two assets, on overlapping spans of one transition clock). Hidden
1411
+ * from effects() and from a page's stopEffect(). Returns { set(amount, timeS), restart(),
1412
+ * remove() }: `amount` as the GLSL reads it (1 = the untouched asset), `restart()` re-takes the
1413
+ * effect's frame (start()) — the incoming asset's, once its rig and first frames are in.
1414
+ */
1415
+ drive(entity, name, opts) {
1416
+ const o = resolveEffectOptions(name, { ...opts, scope: 'entity', direction: 'in', progress: 0 }, 'set', { internal: true });
1417
+ o.entity = entity;
1418
+ const inst = this._makeInstance(name, o, 'set');
1419
+ inst.hidden = true;
1420
+ inst.timeS = 0;
1421
+ this._replace(entity, name, inst);
1422
+ this._setupInstance(inst);
1423
+ this._apply(entity, inst, this.ctx.now());
1424
+ this._install(entity);
1425
+ const alive = () => !this._disposed && this.scopes.get(entity)?.get(name) === inst;
1426
+ return {
1427
+ set: (amount, timeS = inst.timeS) => {
1428
+ if (!alive()) return;
1429
+ inst.opts.progress = Math.min(1, Math.max(0, amount));
1430
+ inst.timeS = timeS;
1431
+ this._apply(entity, inst, this.ctx.now());
1432
+ },
1433
+ restart: () => {
1434
+ if (!alive()) return;
1435
+ inst.state = {};
1436
+ this._setupInstance(inst);
1437
+ this._apply(entity, inst, this.ctx.now());
1438
+ },
1439
+ remove: () => {
1440
+ if (!alive()) return;
1441
+ this.scopes.get(entity).delete(name);
1442
+ inst.resolve({ finished: true });
1443
+ this._install(entity);
1444
+ },
1445
+ get alive() {
1446
+ return alive();
1447
+ },
1448
+ };
1449
+ }
1450
+
1451
+ /**
1452
+ * ONE tile-scope body for the two photos of a setSource particle transition, with each photo's
1453
+ * values on its OWN mesh instance (the engine draws each photo through its own gsplat manager and
1454
+ * renderer: the eye camera's for the incoming photo, the live camera's for the outgoing one).
1455
+ * Render-time only: no work-buffer rewrite, no per-frame re-sort (an entity-scope modifier forces
1456
+ * both, every frame, for each 1.18M asset — measured as the transitions' hitches). The chunk's
1457
+ * material values are the untouched asset's (amount 1). Returns { side(getMeshInstance, opts),
1458
+ * remove() }; a side is { set(amount, timeS), restart() }. `opts` of the two sides share what
1459
+ * the GLSL is compiled from (the `order`).
1460
+ */
1461
+ driveShared(name, effect, opts) {
1462
+ const def = EFFECTS[effect];
1463
+ const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
1464
+ const inst = this._makeInstance(name, o, 'set');
1465
+ inst.def = def;
1466
+ inst.hidden = true;
1467
+ inst.fixed = true; // its material values never change: no per-frame upload
1468
+ this._replace('tile', name, inst);
1469
+ this._setupInstance(inst);
1470
+ this._apply('tile', inst, this.ctx.now());
1471
+ this._install('tile');
1472
+ const P = prefixOf(name);
1473
+ const alive = () => !this._disposed && this.scopes.get('tile')?.get(name) === inst;
1474
+ const touched = new Set();
1475
+ const hidden = new Set(); // mesh instances this transition turned off (a photo with nothing to draw)
1476
+ return {
1477
+ side: (getMeshInstance, sideOpts) => {
1478
+ const s = { opts: resolveEffectOptions(effect, { ...sideOpts, order: o.order, scope: 'tile', direction: 'in' }, 'set', { internal: true }), state: {}, timeS: 0 };
1479
+ s.originPoint = (ctx, eyes) => this._origin(s, ctx, eyes);
1480
+ s.elapsedS = () => s.timeS;
1481
+ def.start?.(this.ctx, s);
1482
+ return {
1483
+ /** Upload this photo's values; false when its mesh instance is not there (yet). */
1484
+ set: (amount, timeS = s.timeS) => {
1485
+ if (!alive()) return false;
1486
+ const mi = getMeshInstance();
1487
+ if (!mi?.setParameter) return false;
1488
+ s.timeS = timeS;
1489
+ touched.add(mi);
1490
+ // the values first, always: should the engine show the mesh again on its own (a
1491
+ // rebuild sets its visibility), it draws this state, never the untouched photo
1492
+ const values = def.uniforms(this.ctx, s, Math.min(1, Math.max(0, amount)), this.ctx.now());
1493
+ for (const [k, v] of Object.entries(values)) mi.setParameter(P + k, v);
1494
+ // Nothing of this photo is drawn at amount 0 (every particle hidden before its flight):
1495
+ // skip its draw call rather than run the vertex stage for 1.18M invisible gaussians —
1496
+ // the two photos then share the GPU only while their spans overlap.
1497
+ if (amount <= 0 && def.hiddenAtZero?.(s.opts)) {
1498
+ if (mi.visible !== false) {
1499
+ mi.visible = false;
1500
+ hidden.add(mi);
1501
+ }
1502
+ } else if (hidden.delete(mi)) mi.visible = true;
1503
+ return true;
1504
+ },
1505
+ restart: () => {
1506
+ s.state = {};
1507
+ def.start?.(this.ctx, s);
1508
+ },
1509
+ };
1510
+ },
1511
+ remove: () => {
1512
+ for (const mi of hidden) mi.visible = true;
1513
+ hidden.clear();
1514
+ for (const mi of touched) {
1515
+ for (const k of Object.keys(mi.parameters || {})) if (k.startsWith(P)) mi.deleteParameter?.(k);
1516
+ }
1517
+ touched.clear();
1518
+ if (!alive()) return;
1519
+ this.scopes.get('tile').delete(name);
1520
+ inst.resolve({ finished: true });
1521
+ this._install('tile');
1522
+ },
1523
+ };
1524
+ }
1525
+
1526
+ /**
1527
+ * The tile chunk driveShared(name, effect, opts) WOULD install, without installing it — what
1528
+ * setSource's shader pre-warm compiles ahead of the transition.
1529
+ */
1530
+ sharedChunkCode(name, effect, opts) {
1531
+ const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
1532
+ const insts = [...(this.scopes.get('tile')?.values() ?? [])].filter((i) => i.name !== name);
1533
+ return composeModifier([...insts, { name, def: EFFECTS[effect], opts: o }]).code;
1534
+ }
1535
+
868
1536
  _setupInstance(inst) {
869
1537
  // Also for a gated one (its held START state needs the geometry); re-run when the gate opens,
870
1538
  // since the rig/framing may have changed meanwhile (setSource's flip adopts a new asset).
@@ -885,7 +1553,7 @@ export class SplatEffects {
885
1553
  */
886
1554
  stop(name, { finish = false, entity = null } = {}) {
887
1555
  for (const [key, n, inst] of [...this._all()]) {
888
- if (name ? n !== name : inst.def.internal) continue;
1556
+ if (inst.hidden || (name ? n !== name : inst.def.internal)) continue;
889
1557
  if (entity && key !== entity) continue;
890
1558
  const m = this.scopes.get(key);
891
1559
  if (finish && inst.mode === 'play' && inst.opts.direction === 'out') {
@@ -905,7 +1573,7 @@ export class SplatEffects {
905
1573
  list() {
906
1574
  const out = [];
907
1575
  for (const [key, name, inst] of this._all()) {
908
- if (inst.def.internal) continue;
1576
+ if (inst.def.internal || inst.hidden) continue;
909
1577
  out.push({
910
1578
  name,
911
1579
  scope: key === 'tile' ? 'tile' : 'entity',
@@ -937,6 +1605,7 @@ export class SplatEffects {
937
1605
  tick(tMs) {
938
1606
  if (this._disposed) return;
939
1607
  for (const [key, name, inst] of [...this._all()]) {
1608
+ if (inst.fixed) continue;
940
1609
  if (inst.mode === 'play') {
941
1610
  if (inst.startGate) {
942
1611
  this._apply(key, inst, tMs); // hold the start state