react-native-effects 0.1.0 → 0.3.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.
Files changed (61) hide show
  1. package/README.md +75 -13
  2. package/lib/module/components/ShaderView/index.js +122 -27
  3. package/lib/module/components/ShaderView/index.js.map +1 -1
  4. package/lib/module/components/ShaderViewWithPanGesture/index.js +196 -0
  5. package/lib/module/components/ShaderViewWithPanGesture/index.js.map +1 -0
  6. package/lib/module/hooks/useParamsSynchronizable.js +37 -0
  7. package/lib/module/hooks/useParamsSynchronizable.js.map +1 -0
  8. package/lib/module/hooks/useWGPUSetup.js +62 -13
  9. package/lib/module/hooks/useWGPUSetup.js.map +1 -1
  10. package/lib/module/index.js +3 -1
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/shaders/uniforms.js +4 -3
  13. package/lib/module/shaders/uniforms.js.map +1 -1
  14. package/lib/module/utils/gpuDevice.js +38 -0
  15. package/lib/module/utils/gpuDevice.js.map +1 -0
  16. package/lib/typescript/src/components/Aurora.d.ts +1 -1
  17. package/lib/typescript/src/components/Aurora.d.ts.map +1 -1
  18. package/lib/typescript/src/components/CalicoSwirl.d.ts +1 -1
  19. package/lib/typescript/src/components/CalicoSwirl.d.ts.map +1 -1
  20. package/lib/typescript/src/components/Campfire.d.ts +1 -1
  21. package/lib/typescript/src/components/Campfire.d.ts.map +1 -1
  22. package/lib/typescript/src/components/CircularGradient.d.ts +1 -1
  23. package/lib/typescript/src/components/CircularGradient.d.ts.map +1 -1
  24. package/lib/typescript/src/components/Iridescence.d.ts +1 -1
  25. package/lib/typescript/src/components/Iridescence.d.ts.map +1 -1
  26. package/lib/typescript/src/components/LinearGradient.d.ts +1 -1
  27. package/lib/typescript/src/components/LinearGradient.d.ts.map +1 -1
  28. package/lib/typescript/src/components/LiquidChrome.d.ts +1 -1
  29. package/lib/typescript/src/components/LiquidChrome.d.ts.map +1 -1
  30. package/lib/typescript/src/components/ShaderView/index.d.ts +1 -1
  31. package/lib/typescript/src/components/ShaderView/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/ShaderView/types.d.ts +20 -0
  33. package/lib/typescript/src/components/ShaderView/types.d.ts.map +1 -1
  34. package/lib/typescript/src/components/ShaderViewWithPanGesture/index.d.ts +35 -0
  35. package/lib/typescript/src/components/ShaderViewWithPanGesture/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/Silk.d.ts +1 -1
  37. package/lib/typescript/src/components/Silk.d.ts.map +1 -1
  38. package/lib/typescript/src/hooks/useParamsSynchronizable.d.ts +22 -0
  39. package/lib/typescript/src/hooks/useParamsSynchronizable.d.ts.map +1 -0
  40. package/lib/typescript/src/hooks/useWGPUSetup.d.ts +5 -2
  41. package/lib/typescript/src/hooks/useWGPUSetup.d.ts.map +1 -1
  42. package/lib/typescript/src/index.d.ts +6 -2
  43. package/lib/typescript/src/index.d.ts.map +1 -1
  44. package/lib/typescript/src/shaders/uniforms.d.ts +3 -3
  45. package/lib/typescript/src/shaders/uniforms.d.ts.map +1 -1
  46. package/lib/typescript/src/utils/gpuDevice.d.ts +13 -0
  47. package/lib/typescript/src/utils/gpuDevice.d.ts.map +1 -0
  48. package/package.json +31 -30
  49. package/src/components/ShaderView/index.tsx +140 -32
  50. package/src/components/ShaderView/types.ts +21 -0
  51. package/src/components/ShaderViewWithPanGesture/index.tsx +225 -0
  52. package/src/hooks/useParamsSynchronizable.ts +52 -0
  53. package/src/hooks/useWGPUSetup.tsx +69 -21
  54. package/src/index.tsx +10 -1
  55. package/src/shaders/uniforms.ts +4 -3
  56. package/src/utils/gpuDevice.ts +38 -0
  57. package/lib/module/utils/initWebGPU.js +0 -40
  58. package/lib/module/utils/initWebGPU.js.map +0 -1
  59. package/lib/typescript/src/utils/initWebGPU.d.ts +0 -23
  60. package/lib/typescript/src/utils/initWebGPU.d.ts.map +0 -1
  61. package/src/utils/initWebGPU.ts +0 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-effects",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "WebGPU-powered visual effects running on a background thread in React Native",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -65,45 +65,46 @@
65
65
  "registry": "https://registry.npmjs.org/"
66
66
  },
67
67
  "devDependencies": {
68
- "@commitlint/config-conventional": "^19.8.1",
69
- "@eslint/compat": "^1.3.2",
70
- "@eslint/eslintrc": "^3.3.1",
71
- "@eslint/js": "^9.35.0",
72
- "@react-native/babel-preset": "0.83.1",
73
- "@react-native/eslint-config": "0.83.1",
74
- "@release-it/conventional-changelog": "^10.0.1",
68
+ "@commitlint/config-conventional": "^21.0.2",
69
+ "@eslint/compat": "^2.1.0",
70
+ "@eslint/eslintrc": "^3.3.5",
71
+ "@eslint/js": "^9.39.4",
72
+ "@react-native/babel-preset": "0.85.3",
73
+ "@react-native/eslint-config": "0.85.3",
74
+ "@react-native/jest-preset": "0.85.3",
75
+ "@release-it/conventional-changelog": "^11.0.1",
75
76
  "@types/jest": "^29.5.14",
76
- "@types/react": "^19.1.12",
77
- "@webgpu/types": "0.1.69",
78
- "commitlint": "^19.8.1",
79
- "del-cli": "^6.0.0",
80
- "eslint": "^9.35.0",
77
+ "@types/react": "^19.2.17",
78
+ "@webgpu/types": "0.1.70",
79
+ "commitlint": "^21.0.2",
80
+ "del-cli": "^7.0.0",
81
+ "eslint": "^9.39.4",
81
82
  "eslint-config-prettier": "^10.1.8",
82
- "eslint-plugin-prettier": "^5.5.4",
83
+ "eslint-plugin-prettier": "^5.5.6",
83
84
  "jest": "^29.7.0",
84
- "lefthook": "^2.0.3",
85
- "prettier": "^2.8.8",
86
- "react": "19.2.0",
87
- "react-native": "0.83.1",
88
- "react-native-builder-bob": "^0.40.18",
89
- "react-native-gesture-handler": "2.30.0",
90
- "react-native-reanimated": "4.3.0-rc.0",
91
- "react-native-wgpu": "0.5.6",
92
- "react-native-worklets": "0.8.0-rc.0",
93
- "release-it": "^19.0.4",
94
- "turbo": "^2.5.6",
95
- "typescript": "^5.9.2"
85
+ "lefthook": "^2.1.9",
86
+ "prettier": "^3.8.3",
87
+ "react": "19.2.3",
88
+ "react-native": "0.85.3",
89
+ "react-native-builder-bob": "^0.41.0",
90
+ "react-native-gesture-handler": "2.31.1",
91
+ "react-native-reanimated": "4.3.1",
92
+ "react-native-webgpu": "0.5.15",
93
+ "react-native-worklets": "0.8.3",
94
+ "release-it": "^20.2.0",
95
+ "turbo": "^2.9.16",
96
+ "typescript": "^6.0.3"
96
97
  },
97
98
  "peerDependencies": {
98
99
  "react": "*",
99
100
  "react-native": "*",
100
101
  "react-native-gesture-handler": ">=2.0.0",
101
- "react-native-wgpu": ">=0.5.0",
102
+ "react-native-webgpu": ">=0.5.0",
102
103
  "react-native-worklets": ">=0.8.0"
103
104
  },
104
105
  "resolutions": {
105
- "metro": "patch:metro@npm%3A0.83.3#./.yarn/patches/metro-npm-0.83.3-d09f48ca84.patch",
106
- "metro-runtime": "patch:metro-runtime@npm%3A0.83.3#./.yarn/patches/metro-runtime-npm-0.83.3-c614bbd3b9.patch"
106
+ "metro": "patch:metro@npm%3A0.84.4#./.yarn/patches/metro-npm-0.84.4.patch",
107
+ "metro-runtime": "patch:metro-runtime@npm%3A0.84.4#./.yarn/patches/metro-runtime-npm-0.84.4.patch"
107
108
  },
108
109
  "workspaces": [
109
110
  "example"
@@ -135,7 +136,7 @@
135
136
  "useTabs": false
136
137
  },
137
138
  "jest": {
138
- "preset": "react-native",
139
+ "preset": "@react-native/jest-preset",
139
140
  "modulePathIgnorePatterns": [
140
141
  "<rootDir>/example/node_modules",
141
142
  "<rootDir>/lib/"
@@ -1,6 +1,6 @@
1
- import { PixelRatio, StyleSheet } from 'react-native';
2
- import { Canvas } from 'react-native-wgpu';
3
- import { useEffect, useRef } from 'react';
1
+ import { AppState, PixelRatio, StyleSheet } from 'react-native';
2
+ import { Canvas, installWebGPU } from 'react-native-webgpu';
3
+ import { useEffect, useRef, useState } from 'react';
4
4
  import { createSynchronizable, scheduleOnRuntime } from 'react-native-worklets';
5
5
  import { colorToVec4 } from '../../utils/colors';
6
6
  import { useWGPUSetup } from '../../hooks/useWGPUSetup';
@@ -24,10 +24,27 @@ export default function ShaderView({
24
24
  speed = 1.0,
25
25
  params = [],
26
26
  isStatic = false,
27
+ transparent = false,
28
+ paramsSynchronizable,
27
29
  style,
28
30
  ...viewProps
29
31
  }: ShaderViewProps) {
30
- const { canvasRef, runtime, resources } = useWGPUSetup();
32
+ const { canvasRef, runtime, resources, onCanvasLayout } = useWGPUSetup();
33
+
34
+ // Pause the render loop while the app is backgrounded. The rAF loop otherwise
35
+ // keeps churning frames against a surface that's offscreen (and often
36
+ // transiently invalid), wasting battery/GPU. We only pause on a true
37
+ // 'background' transition — iOS 'inactive' (app switcher peek, notification
38
+ // pulldown) is left running to avoid flicker on brief, foreground interruptions.
39
+ const [appActive, setAppActive] = useState(
40
+ () => (AppState.currentState ?? 'active') !== 'background'
41
+ );
42
+ useEffect(() => {
43
+ const subscription = AppState.addEventListener('change', (state) => {
44
+ setAppActive(state !== 'background');
45
+ });
46
+ return () => subscription.remove();
47
+ }, []);
31
48
 
32
49
  const propsSync = useRef(
33
50
  createSynchronizable<Float64Array>(new Float64Array(SYNC_SIZE))
@@ -79,18 +96,33 @@ export default function ShaderView({
79
96
  };
80
97
  }, [propsSync]);
81
98
 
82
- // Start render loop when GPU resources are ready
99
+ // Start render loop when GPU resources are ready and the app is foregrounded.
100
+ // When the app backgrounds, `appActive` flips false and this effect's cleanup
101
+ // tears the loop down (via the `cancelled` token below); on return to the
102
+ // foreground it re-runs and starts a fresh loop.
83
103
  useEffect(() => {
84
- if (!resources) {
104
+ if (!resources || !appActive) {
85
105
  return;
86
106
  }
87
107
 
88
108
  const { device, context, presentationFormat } = resources;
89
109
  const dpr = PixelRatio.get();
90
110
 
111
+ // Per-run cancellation token. On Fast Refresh / dep change / unmount, React
112
+ // runs this effect's cleanup, which flips the flag and stops *this* loop —
113
+ // otherwise the old worklet RAF loop keeps running forever alongside the new
114
+ // one, stacking a duplicate render loop on every Metro reload.
115
+ const cancelled = createSynchronizable<Float64Array>(new Float64Array(1));
116
+
91
117
  scheduleOnRuntime(runtime, () => {
92
118
  'worklet';
93
119
 
120
+ // Worklet runtimes start without the WebGPU flag constants
121
+ // (GPUBufferUsage, GPUTextureUsage, ...). installWebGPU() captures them
122
+ // into this runtime so they're available below. Idempotent / safe no-op
123
+ // if already installed.
124
+ installWebGPU();
125
+
94
126
  // Create pipeline once
95
127
  const pipeline = device.createRenderPipeline({
96
128
  layout: 'auto',
@@ -120,10 +152,38 @@ export default function ShaderView({
120
152
  const uniformData = new Float32Array(UNIFORM_FLOAT_COUNT);
121
153
  let accumulatedTime = 0;
122
154
  let lastTimestamp = 0;
155
+ let warned = false;
156
+ let bufferDestroyed = false;
157
+
158
+ // Free this loop's uniform buffer when the loop ends (alive=0 / superseded
159
+ // by Fast Refresh / unmount). On a fragmentShader change the effect
160
+ // re-runs and schedules a fresh loop with a new buffer while the device
161
+ // persists, so without this the old buffer leaks every shader swap.
162
+ function destroyBuffer() {
163
+ if (bufferDestroyed) {
164
+ return;
165
+ }
166
+ bufferDestroyed = true;
167
+ try {
168
+ uniformBuffer.destroy();
169
+ } catch {
170
+ // The device may already have been destroyed on unmount — the buffer
171
+ // is gone either way, so there's nothing to recover.
172
+ return;
173
+ }
174
+ }
123
175
 
124
176
  function render(timestamp: number) {
125
177
  const props = propsSync.getDirty();
126
178
  if (props[IDX_ALIVE] === 0) {
179
+ destroyBuffer();
180
+ return;
181
+ }
182
+
183
+ // This loop was superseded (Fast Refresh / unmount) — bail without
184
+ // scheduling another frame so it can be garbage-collected.
185
+ if (cancelled.getDirty()[0] === 1) {
186
+ destroyBuffer();
127
187
  return;
128
188
  }
129
189
 
@@ -144,7 +204,7 @@ export default function ShaderView({
144
204
  const height = canvas.height || 1;
145
205
  const aspect = width / height;
146
206
 
147
- // Fill uniform data (6 × vec4 = 24 floats)
207
+ // Fill uniform data (7 × vec4 = 28 floats)
148
208
  // resolution: vec4<f32>
149
209
  uniformData[0] = width;
150
210
  uniformData[1] = height;
@@ -175,35 +235,61 @@ export default function ShaderView({
175
235
  uniformData[18] = props[IDX_PARAMS + 2]!;
176
236
  uniformData[19] = props[IDX_PARAMS + 3]!;
177
237
 
178
- // params1: vec4<f32>
238
+ // params1: vec4<f32> — static params[4..7]
179
239
  uniformData[20] = props[IDX_PARAMS + 4]!;
180
240
  uniformData[21] = props[IDX_PARAMS + 5]!;
181
241
  uniformData[22] = props[IDX_PARAMS + 6]!;
182
242
  uniformData[23] = props[IDX_PARAMS + 7]!;
183
243
 
184
- device.queue.writeBuffer(uniformBuffer, 0, uniformData);
185
-
186
- const commandEncoder = device.createCommandEncoder();
187
- const textureView = context.getCurrentTexture().createView();
188
- const passEncoder = commandEncoder.beginRenderPass({
189
- colorAttachments: [
190
- {
191
- view: textureView,
192
- clearValue: [0, 0, 0, 1],
193
- loadOp: 'clear',
194
- storeOp: 'store',
195
- },
196
- ],
197
- });
198
-
199
- passEncoder.setPipeline(pipeline);
200
- passEncoder.setBindGroup(0, bindGroup);
201
- passEncoder.draw(3);
202
- passEncoder.end();
203
-
204
- device.queue.submit([commandEncoder.finish()]);
205
- context.present();
244
+ // live: vec4<f32> — off-thread input (touch/scroll/audio) from
245
+ // paramsSynchronizable, written into its own slot so it never collides
246
+ // with the static params. Stays (0,0,0,0) when no channel is attached.
247
+ if (paramsSynchronizable) {
248
+ const live = paramsSynchronizable.getDirty();
249
+ uniformData[24] = live[0]!;
250
+ uniformData[25] = live[1]!;
251
+ uniformData[26] = live[2]!;
252
+ uniformData[27] = live[3]!;
253
+ }
206
254
 
255
+ // GPU work can throw when the surface is transiently invalid — the app
256
+ // backgrounded, the view detached, or the device was lost. Swallow the
257
+ // failed frame so the loop survives and recovers when the surface comes
258
+ // back (rather than the worklet crashing or the loop dying silently).
259
+ try {
260
+ device.queue.writeBuffer(uniformBuffer, 0, uniformData);
261
+
262
+ const commandEncoder = device.createCommandEncoder();
263
+ const textureView = context.getCurrentTexture().createView();
264
+ const passEncoder = commandEncoder.beginRenderPass({
265
+ colorAttachments: [
266
+ {
267
+ view: textureView,
268
+ clearValue: transparent ? [0, 0, 0, 0] : [0, 0, 0, 1],
269
+ loadOp: 'clear',
270
+ storeOp: 'store',
271
+ },
272
+ ],
273
+ });
274
+
275
+ passEncoder.setPipeline(pipeline);
276
+ passEncoder.setBindGroup(0, bindGroup);
277
+ passEncoder.draw(3);
278
+ passEncoder.end();
279
+
280
+ device.queue.submit([commandEncoder.finish()]);
281
+ context.present();
282
+ } catch (e) {
283
+ // Warn once to avoid spamming the console every frame on a persistent
284
+ // failure (e.g. a lost device).
285
+ if (!warned) {
286
+ warned = true;
287
+ console.warn('[react-native-effects] render frame failed:', e);
288
+ }
289
+ }
290
+
291
+ // Always reschedule animated loops, even after a caught failure, so the
292
+ // effect self-heals once the surface is valid again.
207
293
  if (!isStatic) {
208
294
  requestAnimationFrame(render);
209
295
  }
@@ -211,10 +297,32 @@ export default function ShaderView({
211
297
 
212
298
  requestAnimationFrame(render);
213
299
  });
214
- }, [resources, runtime, propsSync, fragmentShader, isStatic]);
300
+
301
+ return () => {
302
+ cancelled.setBlocking(() => Float64Array.of(1));
303
+ };
304
+ }, [
305
+ resources,
306
+ appActive,
307
+ runtime,
308
+ propsSync,
309
+ paramsSynchronizable,
310
+ fragmentShader,
311
+ isStatic,
312
+ transparent,
313
+ ]);
215
314
 
216
315
  return (
217
- <Canvas ref={canvasRef} style={[styles.canvas, style]} {...viewProps} />
316
+ <Canvas
317
+ ref={canvasRef}
318
+ transparent={transparent}
319
+ style={[styles.canvas, style]}
320
+ {...viewProps}
321
+ onLayout={(event) => {
322
+ onCanvasLayout(event);
323
+ viewProps.onLayout?.(event);
324
+ }}
325
+ />
218
326
  );
219
327
  }
220
328
 
@@ -1,6 +1,19 @@
1
1
  import type { ViewProps } from 'react-native';
2
+ import type { Synchronizable } from 'react-native-worklets';
2
3
  import type { ColorInput } from '../../utils/colors';
3
4
 
5
+ /**
6
+ * A 4-float synchronizable whose values are written into the dedicated `u.live`
7
+ * uniform slot every frame. It has its own slot, so it never collides with the
8
+ * 8 static `params` (`u.params0`/`u.params1`).
9
+ *
10
+ * This is the bridge for live, per-frame input (touch position, scroll
11
+ * progress, velocity) coming from the JS thread into the off-thread render
12
+ * loop. Create one with `useParamsSynchronizable` and update it from
13
+ * gesture/scroll handlers. See `ShaderViewWithPanGesture`.
14
+ */
15
+ export type ParamsSynchronizable = Synchronizable<Float64Array>;
16
+
4
17
  export type ShaderViewProps = ViewProps & {
5
18
  /** WGSL fragment shader source (must declare the Uniforms struct) */
6
19
  fragmentShader: string;
@@ -12,4 +25,12 @@ export type ShaderViewProps = ViewProps & {
12
25
  params?: number[];
13
26
  /** Render once then stop the RAF loop. Default: false */
14
27
  isStatic?: boolean;
28
+ /** Use transparent background (clear to alpha 0). Default: false */
29
+ transparent?: boolean;
30
+ /**
31
+ * Optional live input. Its 4 floats are written into the dedicated `u.live`
32
+ * slot every frame — independent of the static `params`. Use for
33
+ * touch/scroll/audio. Create it with `useParamsSynchronizable`.
34
+ */
35
+ paramsSynchronizable?: ParamsSynchronizable;
15
36
  };
@@ -0,0 +1,225 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
3
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
+ import {
5
+ createSynchronizable,
6
+ type Synchronizable,
7
+ } from 'react-native-worklets';
8
+ import ShaderView from '../ShaderView';
9
+ import { useParamsSynchronizable } from '../../hooks/useParamsSynchronizable';
10
+ import type { ShaderViewProps } from '../ShaderView/types';
11
+
12
+ /**
13
+ * A {@link ShaderView} that feeds touch input into the shader's `u.live`:
14
+ *
15
+ * - `live.x` → pointer X, normalized 0..1 (left → right)
16
+ * - `live.y` → pointer Y, normalized 0..1 (bottom → top, matching UV space)
17
+ * - `live.z` → 1.0 while touching, 0.0 when released
18
+ * - `live.w` → 0.0 (reserved)
19
+ *
20
+ * Dragging moves the pointer **relatively** — it pushes from where the pointer
21
+ * already is rather than jumping under the finger — and a fling lets it glide to
22
+ * a stop. The position is **remembered**: it stays wherever it ended and is
23
+ * never reset; only the "touched" flag (`live.z`) toggles on release. A
24
+ * shader can read `live.xy` as a stable resting position and use `live.z`
25
+ * purely for touch-driven emphasis, so the effect never snaps back.
26
+ *
27
+ * The resting value before the first touch is `[0, 0, 0, 0]` by default; pass
28
+ * `initialParamsSynchronizable` to seed it — e.g. `[0.5, 0.5, 0, 0]` to start a
29
+ * pointer at screen center.
30
+ *
31
+ * The drag runs as a **worklet on the UI thread** and writes the synchronizable
32
+ * directly, so pointer updates never hop to the JS thread — matching the rest of
33
+ * the library, which renders off the JS thread. The render runtime reads the
34
+ * same synchronizable each frame.
35
+ */
36
+ export type ShaderViewWithPanGestureProps = Omit<
37
+ ShaderViewProps,
38
+ 'paramsSynchronizable'
39
+ > & {
40
+ /**
41
+ * Initial value for the gesture channel (`u.live`) before the first touch.
42
+ * Defaults to `[0, 0, 0, 0]`. Use e.g. `[0.5, 0.5, 0, 0]` to rest a pointer at
43
+ * screen center.
44
+ */
45
+ initialParamsSynchronizable?: readonly [number, number, number, number];
46
+ };
47
+
48
+ export default function ShaderViewWithPanGesture({
49
+ style,
50
+ initialParamsSynchronizable = [0, 0, 0, 0],
51
+ ...props
52
+ }: ShaderViewWithPanGestureProps) {
53
+ const { paramsSynchronizable } = useParamsSynchronizable(
54
+ initialParamsSynchronizable
55
+ );
56
+
57
+ // View size, read inside the gesture worklets to normalize pointer coords.
58
+ const sizeRef = useRef<Synchronizable<Float64Array> | null>(null);
59
+ if (sizeRef.current === null) {
60
+ sizeRef.current = createSynchronizable<Float64Array>(Float64Array.of(1, 1));
61
+ }
62
+ const sizeSynchronizable = sizeRef.current;
63
+
64
+ // Generation of the current post-release glide; bumped to cancel an old one.
65
+ const momentumRef = useRef<Synchronizable<Float64Array> | null>(null);
66
+ if (momentumRef.current === null) {
67
+ momentumRef.current = createSynchronizable<Float64Array>(
68
+ Float64Array.of(0)
69
+ );
70
+ }
71
+ const momentumSynchronizable = momentumRef.current;
72
+
73
+ // Pointer position when the current drag began — the pan moves the pointer
74
+ // relative to this, so a drag pushes from where it was rather than jumping.
75
+ const panStartRef = useRef<Synchronizable<Float64Array> | null>(null);
76
+ if (panStartRef.current === null) {
77
+ panStartRef.current = createSynchronizable<Float64Array>(
78
+ Float64Array.of(0, 0)
79
+ );
80
+ }
81
+ const panStartSynchronizable = panStartRef.current;
82
+
83
+ const onLayout = useCallback(
84
+ (e: LayoutChangeEvent) => {
85
+ const { width, height } = e.nativeEvent.layout;
86
+ sizeSynchronizable.setBlocking(() =>
87
+ Float64Array.of(width || 1, height || 1)
88
+ );
89
+ },
90
+ [sizeSynchronizable]
91
+ );
92
+
93
+ // Worklet: runs on the UI thread and writes the normalized pointer straight
94
+ // into the synchronizable the render runtime reads, so a pointer move never
95
+ // touches the JS thread.
96
+ const writePointer = (nx: number, ny: number, active: number) => {
97
+ 'worklet';
98
+ const x = Math.min(1, Math.max(0, nx));
99
+ const y = Math.min(1, Math.max(0, ny));
100
+ paramsSynchronizable.setBlocking(() => Float64Array.of(x, y, active, 0));
101
+ };
102
+
103
+ const stopMomentum = () => {
104
+ 'worklet';
105
+ const next = (momentumSynchronizable.getDirty()[0] || 0) + 1;
106
+ momentumSynchronizable.setBlocking(() => Float64Array.of(next));
107
+ };
108
+
109
+ // Drop the touched flag in place — safety net for a gesture cancelled with no
110
+ // onEnd, so the flag never sticks.
111
+ const releaseFlag = () => {
112
+ 'worklet';
113
+ const p = paramsSynchronizable.getDirty();
114
+ const x = p[0] || 0;
115
+ const y = p[1] || 0;
116
+ paramsSynchronizable.setBlocking(() => Float64Array.of(x, y, 0, 0));
117
+ };
118
+
119
+ // After release, drift from the last position along the fling velocity and
120
+ // decay to a stop — a little inertia. Runs on the UI thread via rAF, like the
121
+ // render loop, writing each frame into the same synchronizable.
122
+ const startMomentum = (velX: number, velY: number) => {
123
+ 'worklet';
124
+ const s = sizeSynchronizable.getDirty();
125
+ const w = s[0] || 1;
126
+ const h = s[1] || 1;
127
+
128
+ // Flick speed in normalized units/sec, scaled to a subtle glide (Y flipped).
129
+ const SCALE = 0.12;
130
+ let vx = (velX / w) * SCALE;
131
+ let vy = (-velY / h) * SCALE;
132
+
133
+ const p = paramsSynchronizable.getDirty();
134
+ let x = p[0] || 0;
135
+ let y = p[1] || 0;
136
+
137
+ // Claim this glide; a newer one bumps the generation and this loop bails.
138
+ const gen = (momentumSynchronizable.getDirty()[0] || 0) + 1;
139
+ momentumSynchronizable.setBlocking(() => Float64Array.of(gen));
140
+
141
+ const FRICTION = 2; // 1/s — higher stops sooner
142
+ let last = -1;
143
+
144
+ // Plain closure (no 'worklet') so its accumulators and self-reference
145
+ // survive across frames; a serialized worklet would snapshot them by value.
146
+ const step = (now: number) => {
147
+ if ((momentumSynchronizable.getDirty()[0] || 0) !== gen) {
148
+ return;
149
+ }
150
+ const dt = last < 0 ? 0 : (now - last) / 1000;
151
+ last = now;
152
+
153
+ x = Math.min(1, Math.max(0, x + vx * dt));
154
+ y = Math.min(1, Math.max(0, y + vy * dt));
155
+ const decay = Math.exp(-FRICTION * dt);
156
+ vx = vx * decay;
157
+ vy = vy * decay;
158
+
159
+ paramsSynchronizable.setBlocking(() => Float64Array.of(x, y, 0, 0));
160
+
161
+ if (Math.abs(vx) + Math.abs(vy) > 0.0008) {
162
+ requestAnimationFrame(step);
163
+ }
164
+ };
165
+ requestAnimationFrame(step);
166
+ };
167
+
168
+ // Drag moves the pointer *relatively*: grab anywhere and push it from where it
169
+ // is, rather than snapping it under the finger. A plain tap leaves it put.
170
+ const pan = Gesture.Pan()
171
+ .onBegin(() => {
172
+ 'worklet';
173
+ stopMomentum();
174
+ const p = paramsSynchronizable.getDirty();
175
+ const sx = p[0] || 0;
176
+ const sy = p[1] || 0;
177
+ panStartSynchronizable.setBlocking(() => Float64Array.of(sx, sy));
178
+ writePointer(sx, sy, 1);
179
+ })
180
+ .onUpdate((e) => {
181
+ 'worklet';
182
+ const s = sizeSynchronizable.getDirty();
183
+ const w = s[0] || 1;
184
+ const h = s[1] || 1;
185
+ const start = panStartSynchronizable.getDirty();
186
+ // Add the drag delta; Y is flipped to match the shader's UV space.
187
+ writePointer(
188
+ (start[0] || 0) + e.translationX / w,
189
+ (start[1] || 0) - e.translationY / h,
190
+ 1
191
+ );
192
+ })
193
+ .onEnd((e) => {
194
+ 'worklet';
195
+ const p = paramsSynchronizable.getDirty();
196
+ writePointer(p[0] || 0, p[1] || 0, 0);
197
+ startMomentum(e.velocityX, e.velocityY);
198
+ })
199
+ .onFinalize(() => {
200
+ 'worklet';
201
+ releaseFlag();
202
+ });
203
+
204
+ return (
205
+ <GestureDetector gesture={pan}>
206
+ <View
207
+ style={[styles.fill, style]}
208
+ onLayout={onLayout}
209
+ collapsable={false}
210
+ >
211
+ <ShaderView
212
+ {...props}
213
+ paramsSynchronizable={paramsSynchronizable}
214
+ style={StyleSheet.absoluteFill}
215
+ />
216
+ </View>
217
+ </GestureDetector>
218
+ );
219
+ }
220
+
221
+ const styles = StyleSheet.create({
222
+ fill: {
223
+ flex: 1,
224
+ },
225
+ });
@@ -0,0 +1,52 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { createSynchronizable } from 'react-native-worklets';
3
+ import type { ParamsSynchronizable } from '../components/ShaderView/types';
4
+
5
+ /**
6
+ * Creates a {@link ParamsSynchronizable} — a 4-float channel written into the
7
+ * dedicated `u.live` slot of a {@link ShaderView} every frame. It has its own
8
+ * uniform slot, so it leaves all 8 static `params` untouched.
9
+ *
10
+ * The returned `setParamsSynchronizable` runs on the JS thread (call it from gesture or scroll
11
+ * handlers); the values are read by the off-thread render loop. By convention
12
+ * the four floats carry `(x, y, active, extra)` for pointer input, or
13
+ * `(progress, ...)` for scroll-driven effects — but the meaning is up to the
14
+ * shader consuming `u.live`.
15
+ *
16
+ * Pass `initial` to seed the channel's starting value (read once on first
17
+ * render), so the shader has a sane resting state before the first update —
18
+ * e.g. `[0.5, 0.5, 0, 0]` to start a pointer at screen center. Defaults to all
19
+ * zeros.
20
+ */
21
+ export function useParamsSynchronizable(
22
+ initial: readonly [number, number, number, number] = [0, 0, 0, 0]
23
+ ): {
24
+ paramsSynchronizable: ParamsSynchronizable;
25
+ setParamsSynchronizable: (
26
+ x: number,
27
+ y: number,
28
+ active: number,
29
+ extra: number
30
+ ) => void;
31
+ } {
32
+ // Lazily create once; `initial` is only a seed, so it is read on first render
33
+ // and ignored thereafter.
34
+ const ref = useRef<ParamsSynchronizable | null>(null);
35
+ if (ref.current === null) {
36
+ ref.current = createSynchronizable<Float64Array>(
37
+ Float64Array.of(initial[0], initial[1], initial[2], initial[3])
38
+ );
39
+ }
40
+ const paramsSynchronizable = ref.current;
41
+
42
+ const setParamsSynchronizable = useCallback(
43
+ (x: number, y: number, active: number, extra: number) => {
44
+ paramsSynchronizable.setBlocking(() =>
45
+ Float64Array.of(x, y, active, extra)
46
+ );
47
+ },
48
+ [paramsSynchronizable]
49
+ );
50
+
51
+ return { paramsSynchronizable, setParamsSynchronizable };
52
+ }