@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 +56 -1
- package/README.md +4 -3
- package/js/inline3d-playcanvas-engine.js +2 -0
- package/js/inline3d-splat-effects.js +464 -3
- package/js/inline3d-splat-live.js +343 -0
- package/js/inline3d-splat-playcanvas.js +204 -26
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +25 -1
- package/package.json +2 -1
- package/splat.d.ts +97 -7
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
|
|
59
|
-
|
|
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
|
|
@@ -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);`);
|