diff --git a/example/assets/components/particle-dispersion.png b/example/assets/components/particle-dispersion.png
new file mode 100644
index 0000000..18821b5
Binary files /dev/null and b/example/assets/components/particle-dispersion.png differ
diff --git a/example/assets/dispersion/cat-atlas.png b/example/assets/dispersion/cat-atlas.png
new file mode 100644
index 0000000..6fa0c78
Binary files /dev/null and b/example/assets/dispersion/cat-atlas.png differ
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 5cc67ec..3b8fbc8 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -35,6 +35,7 @@ import BeerScreen from './screens/BeerScreen';
import LighterScreen from './screens/LighterScreen';
import TelegramDissolveScreen from './screens/TelegramDissolveScreen';
import BirthdayCandleScreen from './screens/BirthdayCandleScreen';
+import ParticleDispersionScreen from './screens/ParticleDispersionScreen';
import FoamLabScreen from './screens/FoamLabScreen';
import type { RootStackParamList } from './types';
@@ -123,6 +124,10 @@ export default function App() {
name="BirthdayCandleExample"
component={BirthdayCandleScreen}
/>
+
diff --git a/example/src/components/dispersion/ParticleDispersion.tsx b/example/src/components/dispersion/ParticleDispersion.tsx
new file mode 100644
index 0000000..5489f75
--- /dev/null
+++ b/example/src/components/dispersion/ParticleDispersion.tsx
@@ -0,0 +1,319 @@
+import { useMemo } from 'react';
+import type { ImageSourcePropType } from 'react-native';
+import {
+ ShaderView,
+ type ColorInput,
+ type ParamsSynchronizable,
+ type ShaderViewProps,
+} from 'react-native-effects';
+
+type ParticleDispersionProps = Omit<
+ ShaderViewProps,
+ | 'fragmentShader'
+ | 'paramsSynchronizable'
+ | 'params'
+ | 'texture'
+ | 'colors'
+ | 'transparent'
+> & {
+ /** `u.live = (dissolve progress 0→1, rim shimmer 0→1, zoom, seed)`, `u.liveData[0].x` = lift 0→1. */
+ paramsSynchronizable: ParamsSynchronizable;
+ /**
+ * One image, two panels side by side: the photo on the left, and on the
+ * right a data panel with R = subject mask, G = distance outside the
+ * subject in photo px / 255, B = that distance normalised to its maximum.
+ */
+ atlas: ImageSourcePropType;
+ /** Size of ONE panel (the photo), in px. */
+ photoWidth: number;
+ photoHeight: number;
+ /** Centre of the subject in photo px (the shimmer and wind revolve around it). */
+ subject: { x: number; y: number };
+ /** Flat colour left behind once the background has dispersed. */
+ backdrop?: ColorInput;
+ /** Colour of the rim light that traces the subject afterwards. */
+ rimColor?: ColorInput;
+};
+
+/**
+ * iOS-style "lift subject" reveal: the photo's background breaks up into a
+ * fine dust that rises, glitters and fades, leaving the cut-out subject on a
+ * flat colour with a soft contact shadow. A light then traces the silhouette
+ * from the top down both sides while the cut-out shrinks a little and settles
+ * in the middle of the screen.
+ *
+ * Same fragment-only particle trick as the Telegram dissolve: each ~1.3pt
+ * cell of the background is one grain, released in order of its distance
+ * from the subject (precomputed into the atlas), carried by a smooth wind
+ * field that is inverted per fragment, and gathered from a 5×5 cell window.
+ */
+export default function ParticleDispersion({
+ paramsSynchronizable,
+ atlas,
+ photoWidth,
+ photoHeight,
+ subject,
+ backdrop = '#6e625a',
+ rimColor = '#fff3e4',
+ ...rest
+}: ParticleDispersionProps) {
+ const params = useMemo(
+ () => [photoWidth, photoHeight, subject.x, subject.y],
+ [photoWidth, photoHeight, subject.x, subject.y]
+ );
+ const colors = useMemo(() => [backdrop, rimColor], [backdrop, rimColor]);
+ return (
+
+ );
+}
+
+const SHADER = /* wgsl */ `
+struct Uniforms {
+ resolution: vec4,
+ time: vec4,
+ color0: vec4,
+ color1: vec4,
+ params0: vec4,
+ params1: vec4,
+ live: vec4,
+ liveData: array, 96>,
+};
+@group(0) @binding(0) var u: Uniforms;
+@group(0) @binding(1) var samp: sampler;
+@group(0) @binding(2) var tex: texture_2d;
+
+// Timeline in progress units (0..1 over the dissolve).
+const SWEEP: f32 = 0.5; // the front travels from the subject to the far corner
+const JITTER: f32 = 0.06; // per-cell start scatter
+const LIFE: f32 = 0.6; // a grain's life once released
+const GHOST: f32 = 0.2; // how far ahead of the front the photo fades to the flat colour
+const CELL: f32 = 1.35; // grain grid, logical px
+const GATHER: i32 = 2; // neighbourhood half-width in cells
+const SPREAD: f32 = 1.6; // per-grain random offset, logical px (< GATHER*CELL - slack)
+const DRIFT: f32 = 150.0; // upward travel over a life, logical px
+const SPARKLE: f32 = 0.35; // share of grains that flash white
+const DROP: f32 = 0.3; // share of background cells that never become grains
+const LIFT: f32 = 0.55; // how far grain colour is pulled toward warm sand
+const SHADOW: f32 = 0.22; // contact shadow strength under the lifted subject
+const SHADOW_R: f32 = 30.0; // shadow falloff, logical px
+const RIM_W: f32 = 7.5; // rim glow width outside the silhouette, logical px
+const LIFT_SCALE: f32 = 0.86; // subject scale once lifted
+const LIFT_Y: f32 = 0.5; // where the lifted subject's centre settles (fraction of height)
+
+fn hash21(p: vec2) -> f32 {
+ var p3 = fract(vec3(p.xyx) * 0.1031);
+ p3 = p3 + dot(p3, p3.yzx + 33.33);
+ return fract((p3.x + p3.y) * p3.z);
+}
+fn hash22(p: vec2) -> vec2 {
+ var p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
+ p3 = p3 + dot(p3, p3.yzx + 33.33);
+ return fract((p3.xx + p3.yz) * p3.zy);
+}
+
+// Canvas size in logical px.
+fn canvasSize() -> vec2 {
+ return u.resolution.xy / u.resolution.w;
+}
+// "cover" scale from photo px to logical px, including the live zoom.
+fn coverScale() -> f32 {
+ let cs = canvasSize();
+ let pw = u.params0.xy;
+ return max(cs.x / pw.x, cs.y / pw.y) * max(u.live.z, 0.01);
+}
+fn toPhoto(q: vec2) -> vec2 {
+ return (q - canvasSize() * 0.5) / coverScale() + u.params0.xy * 0.5;
+}
+fn toLogical(pp: vec2) -> vec2 {
+ return (pp - u.params0.xy * 0.5) * coverScale() + canvasSize() * 0.5;
+}
+fn inPhoto(pp: vec2) -> bool {
+ let pw = u.params0.xy;
+ return pp.x >= 0.0 && pp.y >= 0.0 && pp.x <= pw.x && pp.y <= pw.y;
+}
+
+// The atlas holds the photo on the left half and a data panel on the right:
+// R = subject mask, G = distance outside the subject in photo px / 255,
+// B = that distance / maxDist. Both lookups stay half a texel away from the
+// seam so the linear sampler never blends the panels together.
+fn photoAt(pp: vec2) -> vec3 {
+ let pw = u.params0.xy;
+ let c = clamp(pp, vec2(0.5), pw - 0.5);
+ let uv = vec2(c.x / (pw.x * 2.0), c.y / pw.y);
+ return textureSampleLevel(tex, samp, uv, 0.0).rgb;
+}
+fn dataAt(pp: vec2) -> vec3 {
+ let pw = u.params0.xy;
+ let c = clamp(pp, vec2(0.5), pw - 0.5);
+ let uv = vec2((pw.x + c.x) / (pw.x * 2.0), c.y / pw.y);
+ return textureSampleLevel(tex, samp, uv, 0.0).rgb;
+}
+
+// When a background cell lets go: mostly by distance from the subject, with a
+// bias so the release starts low and finishes at the top-right corner.
+fn startAt(pp: vec2, far: f32, k: vec2) -> f32 {
+ let n = pp / u.params0.xy;
+ let order = far * 0.62 + (1.0 - n.y) * 0.24 + n.x * 0.14;
+ return SWEEP * order + JITTER * hash21(k + 7.3);
+}
+
+fn travel(tau: f32) -> f32 {
+ return 1.0 - pow(1.0 - tau, 2.2);
+}
+
+// Smooth, invertible wind in logical px: rise, gentle expansion away from the
+// subject and two low-frequency swirl layers (every gradient well under 1).
+fn wind(o: vec2, tau: f32, seed: f32) -> vec2 {
+ let e = travel(tau);
+ let c = toLogical(u.params0.zw);
+ let s = seed * 6.2831;
+ let lowA = sin(o.x * 0.030 + s) * cos(o.y * 0.037 + s * 1.7);
+ let lowB = sin(o.y * 0.028 - s) * cos(o.x * 0.026 + s * 0.6);
+ let hiA = sin(o.x * 0.10 + o.y * 0.08 + s * 2.0);
+ let hiB = cos(o.x * 0.07 - o.y * 0.12 + s * 3.0);
+ var d = vec2(0.0, -DRIFT) * e;
+ d = d + (o - c) * vec2(0.22, 0.10) * e;
+ d = d + vec2(lowA, lowB) * 16.0 * e;
+ d = d + vec2(hiA, hiB) * 3.0 * e;
+ return d;
+}
+
+fn cellStart(o: vec2) -> f32 {
+ let k = floor(o / CELL);
+ let c = (k + 0.5) * CELL;
+ let pp = toPhoto(c);
+ let d = dataAt(pp);
+ return startAt(pp, d.b, k);
+}
+
+@fragment
+fn main(@location(0) ndc: vec2) -> @location(0) vec4 {
+ let p = u.live.x;
+ let rimP = u.live.y;
+ let seed = u.live.w;
+ let liftE = clamp(u.liveData[0].x, 0.0, 1.0);
+ let uv = ndc * 0.5 + 0.5;
+ let cs = canvasSize();
+ let q = vec2(uv.x, 1.0 - uv.y) * cs; // logical px, y-down
+ let s = coverScale();
+ let pp = toPhoto(q);
+
+ let photo = photoAt(pp);
+ let flat = u.color0.rgb;
+
+ // The lifted subject: once the background is gone it shrinks a little and
+ // settles in the middle of the screen. Sample it through its own mapping.
+ let cL = toLogical(u.params0.zw);
+ let cT = cs * vec2(0.5, LIFT_Y);
+ let cNow = mix(cL, cT, liftE);
+ let sNow = mix(1.0, LIFT_SCALE, liftE);
+ let qs = cL + (q - cNow) / sNow;
+ let pps = toPhoto(qs);
+ let subj = photoAt(pps);
+ let dataS = dataAt(pps);
+ let mask = dataS.r;
+ let near = dataS.g * 255.0 * s * sNow;
+
+ // Contact shadow under the subject, longer and softer as it lifts.
+ let shOff = mix(10.0, 20.0, liftE) / (s * sNow);
+ let shR = mix(SHADOW_R, SHADOW_R * 1.6, liftE);
+ let nearS = dataAt(pps - vec2(0.0, shOff)).g * 255.0 * s * sNow;
+ let shadow = 1.0 - SHADOW * exp(-nearS / shR);
+ let flatShaded = flat * shadow;
+
+ // Background under this fragment: intact photo, ghosting toward the flat
+ // colour as the front approaches, then the flat colour once released.
+ var bg = photo;
+ if (p > 0.0) {
+ let kq = floor(q / CELL);
+ let cq = (kq + 0.5) * CELL;
+ let st = cellStart(cq);
+ let ghost = smoothstep(st - GHOST, st, p);
+ bg = mix(photo, flatShaded, ghost);
+ }
+ if (p >= 1.0) {
+ bg = flatShaded;
+ }
+
+ // Grains: gather the origin cells whose wind could land them here.
+ var dust = vec3(0.0);
+ var dustA = 0.0;
+ if (p > 0.0 && p < 1.0) {
+ var o = q;
+ for (var i = 0; i < 4; i = i + 1) {
+ let tauO = clamp((p - cellStart(o)) / LIFE, 0.0, 1.0);
+ o = q - wind(o, tauO, seed);
+ }
+ let k0 = floor(o / CELL);
+ for (var dy = -GATHER; dy <= GATHER; dy = dy + 1) {
+ for (var dx = -GATHER; dx <= GATHER; dx = dx + 1) {
+ let k = k0 + vec2(f32(dx), f32(dy));
+ let c = (k + 0.5) * CELL;
+ let cpp = toPhoto(c);
+ if (!inPhoto(cpp)) {
+ continue;
+ }
+ let d = dataAt(cpp);
+ if (d.r > 0.5) {
+ continue;
+ }
+ let tau = (p - startAt(cpp, d.b, k)) / LIFE;
+ if (tau <= 0.0 || tau >= 1.0) {
+ continue;
+ }
+ let h = hash22(k);
+ if (h.x < DROP) {
+ continue;
+ }
+ var col = mix(photoAt(cpp), vec3(0.93, 0.86, 0.76), LIFT);
+ if (h.y < SPARKLE) {
+ let flash = 0.5 + 0.5 * sin(tau * 40.0 + h.y * 6.2831);
+ col = mix(col, vec3(1.0, 0.98, 0.93), 0.55 + 0.45 * flash);
+ }
+ let jit = (hash22(k + 3.1) - 0.5) * 2.0 * SPREAD * smoothstep(0.0, 1.0, tau);
+ let pos = c + wind(c, tau, seed) + jit;
+ let r = CELL * (0.72 - 0.25 * tau);
+ let dist = length(q - pos);
+ let cov = 1.0 - smoothstep(r - 0.5, r + 0.5, dist);
+ if (cov <= 0.0) {
+ continue;
+ }
+ let twinkle = 0.75 + 0.25 * sin(tau * 26.0 + h.y * 6.2831);
+ let fade = (1.0 - smoothstep(0.45, 1.0, tau)) * twinkle;
+ let w = cov * fade;
+ dust = dust + col * w;
+ dustA = dustA + w;
+ }
+ }
+ dustA = min(dustA, 1.0);
+ }
+
+ var outCol = mix(bg, dust, dustA);
+ // The subject is drawn on top, untouched.
+ outCol = mix(outCol, subj, mask);
+
+ // Rim shimmer: a soft light that grows from the top of the subject down
+ // both sides, then fades out.
+ if (rimP > 0.0) {
+ let dir = q - cNow;
+ let ang = abs(atan2(dir.x, -dir.y)) / 3.14159; // 0 at top, 1 at bottom
+ let reach = rimP * 1.3;
+ let front = smoothstep(reach, reach - 0.18, ang);
+ let trail = mix(0.3, 1.0, exp(-(reach - ang) * 2.5));
+ let life = smoothstep(0.0, 0.08, rimP) * (1.0 - smoothstep(0.7, 1.0, rimP));
+ let outer = exp(-near / RIM_W) * (1.0 - mask);
+ let edge = mask * (1.0 - mask) * 3.5;
+ let glow = (outer * 1.25 + edge) * front * trail * life;
+ outCol = outCol + u.color1.rgb * glow;
+ }
+
+ return vec4(outCol, 1.0);
+}
+`;
diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx
index 3f74438..2a94e2e 100644
--- a/example/src/screens/HomeScreen.tsx
+++ b/example/src/screens/HomeScreen.tsx
@@ -11,6 +11,14 @@ import { useNavigation } from '@react-navigation/native';
import type { ExampleCategory, HomeScreenNavigationProp } from '../types';
const EXAMPLE_CATEGORIES: ExampleCategory[] = [
+ {
+ id: 'particle-dispersion',
+ title: 'Particle Dispersion',
+ description: 'Reveal the subject — the background blows away as dust',
+ screen: 'ParticleDispersionExample',
+ color: '#6e625a',
+ image: require('../../assets/components/particle-dispersion.png'),
+ },
{
id: 'birthday-candle',
title: 'Birthday Candle',
diff --git a/example/src/screens/ParticleDispersionScreen.tsx b/example/src/screens/ParticleDispersionScreen.tsx
new file mode 100644
index 0000000..00bd7c2
--- /dev/null
+++ b/example/src/screens/ParticleDispersionScreen.tsx
@@ -0,0 +1,265 @@
+import { useCallback, useState } from 'react';
+import {
+ Platform,
+ Pressable,
+ StatusBar,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import Animated, {
+ Easing,
+ FadeIn,
+ FadeOut,
+ runOnJS,
+ useAnimatedReaction,
+ useSharedValue,
+ withDelay,
+ withTiming,
+} from 'react-native-reanimated';
+import { useParamsSynchronizable } from 'react-native-effects';
+import ParticleDispersion from '../components/dispersion/ParticleDispersion';
+
+// Photo + subject data in one image: an orange tabby on fallen leaves
+// (Wikimedia Commons, Hisashi, CC BY-SA 2.0), with its Vision "lift subject"
+// mask and a distance-to-subject field packed into the right-hand panel.
+const ATLAS = require('../../assets/dispersion/cat-atlas.png');
+const PHOTO_W = 960;
+const PHOTO_H = 2080;
+const SUBJECT = { x: 480, y: 1166 };
+const BACKDROP = '#6e625a';
+
+// Timeline, ms. Slower than the reference clip on purpose: a small zoom, the
+// background drifts off as dust over 5 s, then the cut-out settles in the
+// middle while a light traces its silhouette for 4 s.
+const ZOOM_MS = 420;
+const ZOOM_TO = 1.05;
+const DISSOLVE_DELAY_MS = 220;
+const DISSOLVE_MS = 5000;
+const RIM_DELAY_MS = 350;
+const RIM_MS = 4000;
+// The cut-out starts settling as soon as the visible glitter is gone, which
+// is well before the last far-corner grains time out.
+const LIFT_LEAD_MS = 1100;
+const LIFT_MS = 900;
+
+type Phase = 'idle' | 'running' | 'done';
+
+const LABEL: Record = {
+ idle: 'Reveal the subject to find out.',
+ running: 'Removing the background…',
+ done: 'Yep. Cat.',
+};
+
+export default function ParticleDispersionScreen() {
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const [phase, setPhase] = useState('idle');
+ // The question shows up once the background is gone.
+ const [revealed, setRevealed] = useState(false);
+
+ const progress = useSharedValue(0);
+ const rim = useSharedValue(0);
+ const zoom = useSharedValue(1);
+ const seed = useSharedValue(0.37);
+ const lifted = useSharedValue(0);
+ // 8 floats: (progress, rim, zoom, seed) land in u.live, lift in u.liveData[0].
+ const { paramsSynchronizable } = useParamsSynchronizable([
+ 0, 0, 1, 0.37, 0, 0, 0, 0,
+ ]);
+ useAnimatedReaction(
+ () =>
+ [
+ progress.value,
+ rim.value,
+ zoom.value,
+ seed.value,
+ lifted.value,
+ ] as const,
+ ([p, r, z, s, l]) => {
+ 'worklet';
+ paramsSynchronizable.setBlocking(() =>
+ Float64Array.of(p, r, z, s, l, 0, 0, 0)
+ );
+ }
+ );
+
+ const reveal = useCallback(() => {
+ setPhase('running');
+ seed.value = Math.random();
+ zoom.value = withTiming(ZOOM_TO, {
+ duration: ZOOM_MS,
+ easing: Easing.out(Easing.cubic),
+ });
+ lifted.value = withDelay(
+ DISSOLVE_DELAY_MS + DISSOLVE_MS - LIFT_LEAD_MS,
+ withTiming(1, { duration: LIFT_MS, easing: Easing.out(Easing.cubic) })
+ );
+ progress.value = withDelay(
+ DISSOLVE_DELAY_MS,
+ withTiming(
+ 1,
+ { duration: DISSOLVE_MS, easing: Easing.linear },
+ (finished) => {
+ if (!finished) {
+ return;
+ }
+ runOnJS(setRevealed)(true);
+ rim.value = withDelay(
+ RIM_DELAY_MS,
+ withTiming(
+ 1,
+ { duration: RIM_MS, easing: Easing.inOut(Easing.sin) },
+ (done) => {
+ if (done) {
+ runOnJS(setPhase)('done');
+ }
+ }
+ )
+ );
+ }
+ )
+ );
+ }, [lifted, progress, rim, seed, zoom]);
+
+ const reset = useCallback(() => {
+ progress.value = 0;
+ rim.value = 0;
+ lifted.value = 0;
+ zoom.value = withTiming(1, { duration: 300 });
+ setRevealed(false);
+ setPhase('idle');
+ }, [lifted, progress, rim, zoom]);
+
+ const onPrimary = phase === 'idle' ? reveal : phase === 'done' ? reset : null;
+
+ return (
+
+
+
+
+ navigation.goBack()}
+ hitSlop={12}
+ style={[styles.back, { top: insets.top + 6 }]}
+ >
+ ‹
+
+
+ {revealed && (
+
+ Is this a cat?
+
+ )}
+
+
+
+ {LABEL[phase]}
+
+ [
+ styles.primary,
+ !onPrimary && styles.primaryBusy,
+ pressed && styles.primaryPressed,
+ ]}
+ >
+
+ {phase === 'idle' ? 'Reveal' : phase === 'done' ? 'Again' : '…'}
+
+
+
+
+ );
+}
+
+const SERIF = Platform.select({ ios: 'Georgia', default: 'serif' });
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: BACKDROP },
+ back: {
+ position: 'absolute',
+ left: 14,
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ backGlyph: {
+ color: '#fff',
+ fontSize: 34,
+ lineHeight: 36,
+ marginTop: -4,
+ textShadowColor: 'rgba(0,0,0,0.4)',
+ textShadowRadius: 6,
+ },
+ header: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ alignItems: 'center',
+ },
+ title: {
+ color: '#fff',
+ fontFamily: SERIF,
+ fontSize: 34,
+ letterSpacing: -0.5,
+ textShadowColor: 'rgba(0,0,0,0.45)',
+ textShadowRadius: 10,
+ textShadowOffset: { width: 0, height: 2 },
+ },
+ bar: {
+ position: 'absolute',
+ left: 14,
+ right: 14,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 14,
+ paddingLeft: 20,
+ paddingRight: 8,
+ paddingVertical: 8,
+ borderRadius: 30,
+ backgroundColor: 'rgba(22, 18, 16, 0.78)',
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: 'rgba(255,255,255,0.14)',
+ },
+ status: {
+ flex: 1,
+ color: 'rgba(255,255,255,0.86)',
+ fontSize: 14,
+ lineHeight: 18,
+ },
+ primary: {
+ minWidth: 78,
+ height: 44,
+ paddingHorizontal: 18,
+ borderRadius: 22,
+ backgroundColor: '#fff',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ primaryBusy: { backgroundColor: 'rgba(255,255,255,0.35)' },
+ primaryPressed: { opacity: 0.8 },
+ primaryText: { color: '#1e1917', fontSize: 15, fontWeight: '600' },
+});
diff --git a/example/src/types.ts b/example/src/types.ts
index 37c6f23..d75940a 100644
--- a/example/src/types.ts
+++ b/example/src/types.ts
@@ -35,6 +35,7 @@ export type RootStackParamList = {
LighterExample: undefined;
TelegramDissolveExample: undefined;
BirthdayCandleExample: undefined;
+ ParticleDispersionExample: undefined;
FoamLabExample: undefined;
};