diff --git a/CUSTOM_EFFECTS.md b/CUSTOM_EFFECTS.md index 90b9de4..2773a8a 100644 --- a/CUSTOM_EFFECTS.md +++ b/CUSTOM_EFFECTS.md @@ -11,22 +11,25 @@ This guide explains how to create your own shader effects using `ShaderView`, th - Up to **8 float parameters** (mapped to `u.params0.xyzw`, `u.params1.xyzw`) - A **speed** multiplier for animation - An optional **isStatic** flag to render once and stop +- An optional **paramsSynchronizable** channel for live, per-frame input (touch/scroll) ### Props -| Prop | Type | Default | Description | -| ---------------- | -------------- | ------- | --------------------------------------------------------------------- | -| `fragmentShader` | `string` | — | WGSL fragment shader source (must declare the `Uniforms` struct) | -| `colors` | `ColorInput[]` | `[]` | Up to 2 colors — accepts hex strings, named colors, or numeric values | -| `params` | `number[]` | `[]` | Up to 8 shader-specific floats | -| `speed` | `number` | `1.0` | Time multiplier for animation speed | -| `isStatic` | `boolean` | `false` | Render once then stop the animation loop | +| Prop | Type | Default | Description | +| ---------------------- | ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `fragmentShader` | `string` | — | WGSL fragment shader source (must declare the `Uniforms` struct) | +| `colors` | `ColorInput[]` | `[]` | Up to 2 colors — accepts hex strings, named colors, or numeric values | +| `params` | `number[]` | `[]` | Up to 8 shader-specific floats | +| `speed` | `number` | `1.0` | Time multiplier for animation speed | +| `isStatic` | `boolean` | `false` | Render once then stop the animation loop | +| `transparent` | `boolean` | `false` | Clear the canvas to alpha `0` for a transparent background | +| `paramsSynchronizable` | `ParamsSynchronizable` | — | Live 4-float input written into the dedicated `u.live` slot every frame (touch/scroll/audio), independent of the static `params` | `ShaderView` also accepts all standard React Native `View` props (`style`, `onLayout`, etc.). ## Uniform Buffer Layout -Every shader must declare this exact uniform struct: +Every shader must declare the uniform struct with these fields in this order: ```wgsl struct Uniforms { @@ -36,20 +39,26 @@ struct Uniforms { color1: vec4, // colors[1] as normalized RGBA (0..1) params0: vec4, // params[0], params[1], params[2], params[3] params1: vec4, // params[4], params[5], params[6], params[7] + live: vec4, // paramsSynchronizable (touch/scroll/audio); (0,0,0,0) when unused }; @group(0) @binding(0) var u: Uniforms; ``` +You only need to declare the fields you actually read, top-down — a shader that +never uses live input can stop at `params1`. The full struct is shown here so +the offsets are unambiguous. + ### Field Reference -| Field | Components | Description | -| -------------- | -------------------------------------------------------------------- | ----------------------------- | -| `u.resolution` | `.x` = width, `.y` = height, `.z` = aspect ratio, `.w` = pixel ratio | Canvas dimensions | -| `u.time` | `.x` = elapsed seconds (speed-adjusted), `.y` = delta time | Animation timing | -| `u.color0` | `.rgba` | First color, normalized 0..1 | -| `u.color1` | `.rgba` | Second color, normalized 0..1 | -| `u.params0` | `.xyzw` = params[0..3] | First 4 custom parameters | -| `u.params1` | `.xyzw` = params[4..7] | Last 4 custom parameters | +| Field | Components | Description | +| -------------- | -------------------------------------------------------------------- | ------------------------------------- | +| `u.resolution` | `.x` = width, `.y` = height, `.z` = aspect ratio, `.w` = pixel ratio | Canvas dimensions | +| `u.time` | `.x` = elapsed seconds (speed-adjusted), `.y` = delta time | Animation timing | +| `u.color0` | `.rgba` | First color, normalized 0..1 | +| `u.color1` | `.rgba` | Second color, normalized 0..1 | +| `u.params0` | `.xyzw` = params[0..3] | First 4 custom parameters | +| `u.params1` | `.xyzw` = params[4..7] | Last 4 custom parameters | +| `u.live` | `.xyzw` = paramsSynchronizable channel | Live per-frame input; `0` when unused | ## Fragment Shader Contract @@ -164,6 +173,59 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { - Keep the shader string as a module-level `const` — it never changes - Look at the built-in effects (Silk, Aurora, Campfire) in `src/components/` for real-world examples +## Live Input with `paramsSynchronizable` + +Static `params` are re-uploaded whenever the React prop changes — fine for occasional updates, but the render loop runs **off-thread**, so routing fast, per-frame input (a finger drag, scroll progress, an audio level) through React props would be laggy and drop frames. + +`paramsSynchronizable` solves this. It is a 4-float [Synchronizable](https://docs.swmansion.com/react-native-worklets/docs/synchronization/synchronizable) that the off-thread render loop reads on **every frame** and writes into its own dedicated `u.live` slot. Because it has its own slot, it never collides with the 8 static `params` — you keep the full `u.params0`/`u.params1` budget _and_ a live channel at the same time. + +### 1. Create the channel with the hook + +```tsx +import { ShaderView, useParamsSynchronizable } from 'react-native-effects'; + +function TouchReactive() { + // `initial` seeds the resting value, read once: (x, y, active, extra) + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([0.5, 0.5, 0, 0]); + + return ( + { + const { locationX, locationY } = e.nativeEvent; + setParamsSynchronizable(locationX, locationY, 1, 0); + }} + onTouchEnd={() => setParamsSynchronizable(0, 0, 0, 0)} + /> + ); +} +``` + +`setParamsSynchronizable(x, y, active, extra)` runs on the JS thread — call it from gesture, scroll, or any event handler. The four floats are `(x, y, active, extra)` by convention for pointer input, or `(progress, …)` for scroll-driven effects, but the meaning is entirely up to your shader. + +### 2. Read the live values in the shader + +The values land in `u.live` — make sure your `Uniforms` struct declares the `live` field (it comes right after `params1`): + +```wgsl +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let pointer = u.live.xy; // (x, y) you wrote from JS + let active = u.live.z; // 1 while interacting, 0 otherwise + + let glow = (1.0 - distance(uv, pointer)) * active; + return vec4(u.color0.rgb * glow, 1.0); +} +``` + +> **Note:** `u.live` is a separate slot, so all 8 static `params` (`u.params0` + `u.params1`) stay fully available alongside it. + +For a ready-made pan-gesture wrapper that drives a `paramsSynchronizable` for you (with drag + momentum), see [`ShaderViewWithPanGesture`](src/components/ShaderViewWithPanGesture/index.tsx). + ## AI Prompt for Generating Custom Effects Copy and paste the prompt below into ChatGPT, Claude, or any AI assistant. Replace the placeholder description with your desired effect, and the AI will generate a complete component. @@ -182,12 +244,14 @@ ShaderView is a React Native component that renders a WGSL fragment shader. Prop - `params?: number[]` — up to 8 floats mapped to u.params0.xyzw (indices 0-3) and u.params1.xyzw (indices 4-7) - `speed?: number` — animation speed multiplier (default 1.0) - `isStatic?: boolean` — render once then stop (default false) +- `transparent?: boolean` — clear the canvas to alpha 0 for a transparent background (default false) +- `paramsSynchronizable?: ParamsSynchronizable` — optional live 4-float input written into its own dedicated u.live slot every frame, independent of the static params (all 8 params stay available). Create it with the `useParamsSynchronizable` hook and update it from gesture/scroll handlers for touch-, scroll-, or audio-reactive effects. - Also accepts all standard React Native View props (style, onLayout, etc.) Import: `import { ShaderView } from 'react-native-effects';` Color type import: `import type { ColorInput } from 'react-native-effects';` -## Uniform buffer layout (must be declared exactly like this in every shader) +## Uniform buffer layout (declare the fields you read, in this order) ```wgsl struct Uniforms { @@ -197,6 +261,7 @@ struct Uniforms { color1: vec4, // colors[1] as RGBA 0..1 params0: vec4, // params[0..3] params1: vec4, // params[4..7] + live: vec4, // paramsSynchronizable channel (touch/scroll/audio); omit if unused }; @group(0) @binding(0) var u: Uniforms; ``` diff --git a/README.md b/README.md index bd59ae6..5267525 100644 --- a/README.md +++ b/README.md @@ -44,16 +44,55 @@ import { ShaderView } from 'react-native-effects'; />; ``` -| Prop | Type | Default | Description | -| ---------------- | -------------- | ------- | -------------------------------------------------------------- | -| `fragmentShader` | `string` | — | WGSL fragment shader source | -| `colors` | `ColorInput[]` | `[]` | Up to 2 colors mapped to `u.color0` and `u.color1` | -| `params` | `number[]` | `[]` | Up to 8 floats mapped to `u.params0.xyzw` and `u.params1.xyzw` | -| `speed` | `number` | `1.0` | Animation speed multiplier | -| `isStatic` | `boolean` | `false` | Render once then stop the animation loop | +| Prop | Type | Default | Description | +| ---------------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------- | +| `fragmentShader` | `string` | — | WGSL fragment shader source | +| `colors` | `ColorInput[]` | `[]` | Up to 2 colors mapped to `u.color0` and `u.color1` | +| `params` | `number[]` | `[]` | Up to 8 floats mapped to `u.params0.xyzw` and `u.params1.xyzw` | +| `speed` | `number` | `1.0` | Animation speed multiplier | +| `isStatic` | `boolean` | `false` | Render once then stop the animation loop | +| `transparent` | `boolean` | `false` | Clear the canvas to alpha `0` for an overlay-friendly transparent background | +| `paramsSynchronizable` | `ParamsSynchronizable` | — | Live 4-float input written into the dedicated `u.live` slot every frame (touch/scroll/audio). See below | All built-in effects (Silk, Aurora, Campfire, etc.) are thin wrappers around `ShaderView`. You can use it directly to create your own custom effects — see the [Custom Effects Guide](CUSTOM_EFFECTS.md) for a full walkthrough and a ready-to-use AI prompt. +### Live input with `paramsSynchronizable` + +Static `params` are great for values that change on the JS thread occasionally, but the render loop runs off-thread — so feeding it fast, per-frame input (a finger drag, scroll progress, audio level) through React props would be laggy. `paramsSynchronizable` is the bridge: a 4-float [Synchronizable](https://docs.swmansion.com/react-native-worklets/docs/synchronization/synchronizable) that the off-thread render loop reads every frame and writes into its own dedicated `u.live` slot — so it never collides with the static `params` (you keep all 8 _and_ get a live channel). + +Create one with the `useParamsSynchronizable` hook and update it from your gesture or scroll handlers: + +```tsx +import { ShaderView, useParamsSynchronizable } from 'react-native-effects'; + +function TouchReactive() { + // initial resting value, read once: (x, y, active, extra) + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([0.5, 0.5, 0, 0]); + + return ( + { + const { locationX, locationY } = e.nativeEvent; + setParamsSynchronizable(locationX, locationY, 1, 0); + }} + /> + ); +} +``` + +Inside the shader, declare the `live` field on the `Uniforms` struct (right after `params1`) and read the live values from `u.live`: + +```wgsl +let pointer = u.live.xy; // (x, y) you wrote +let active = u.live.z; // 1 while touching, 0 otherwise +``` + +`setParamsSynchronizable(x, y, active, extra)` runs on the JS thread; the four floats are by convention `(x, y, active, extra)` for pointer input or `(progress, …)` for scroll-driven effects, but the meaning is entirely up to your shader. For a ready-made pan-gesture variant, use [`ShaderViewWithPanGesture`](src/components/ShaderViewWithPanGesture/index.tsx). + ## Installation ```sh diff --git a/example/src/components/HoloFoilCard.tsx b/example/src/components/HoloFoilCard.tsx index 809d536..c3f3841 100644 --- a/example/src/components/HoloFoilCard.tsx +++ b/example/src/components/HoloFoilCard.tsx @@ -8,14 +8,14 @@ type Props = Omit< ShaderViewProps, 'fragmentShader' | 'paramsSynchronizable' | 'colors' > & { - /** Tilt channel: `u.params1 = (tiltX, tiltY, active, 0)`, 0.5 = flat. */ + /** Tilt channel: `u.live = (tiltX, tiltY, active, 0)`, 0.5 = flat. */ paramsSynchronizable: ParamsSynchronizable; }; /** * A premium holographic foil surface — curved, desaturated iridescence under a * moving specular glare that blows out to white, with brushed micro-streaks and - * fine sparkle dust. The tilt (from {@link useTilt}, via `u.params1`) sweeps the + * fine sparkle dust. The tilt (from {@link useTilt}, via `u.live`) sweeps the * spectrum and the glare across the surface so it reads like real foil catching * the light. Pure procedural — no texture sampling. */ @@ -38,6 +38,7 @@ struct Uniforms { color1: vec4, params0: vec4, params1: vec4, + live: vec4, }; @group(0) @binding(0) var u: Uniforms; @@ -85,7 +86,7 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let c = (uv - 0.5) * vec2(aspect, 1.0); // Tilt in [-1, 1]; 0.5 in the channel means flat. - let tilt = (u.params1.xy - 0.5) * 2.0; + let tilt = (u.live.xy - 0.5) * 2.0; // Curved iridescence coordinate: warp straight bands with low-freq noise so // the colour flows like brushed foil instead of rigid candy stripes. Wide, diff --git a/example/src/components/ScrollReactive.tsx b/example/src/components/ScrollReactive.tsx index b901654..e878eb0 100644 --- a/example/src/components/ScrollReactive.tsx +++ b/example/src/components/ScrollReactive.tsx @@ -8,7 +8,7 @@ import { type Props = ViewProps & { /** - * Live scroll channel written into `u.params1` every frame: `x` = progress + * Live scroll channel written into `u.live` every frame: `x` = progress * (0..1), `y` = rubber-band overscroll. Drive it with `setParamsSynchronizable` from a scroll * handler (see `useParamsSynchronizable`) so scrolling never re-renders React. */ @@ -58,6 +58,7 @@ struct Uniforms { color1: vec4, params0: vec4, params1: vec4, + live: vec4, }; @group(0) @binding(0) var u: Uniforms; @@ -98,11 +99,11 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let uv = ndc * 0.5 + 0.5; var p = (uv - 0.5) * vec2(aspect, 1.0); - // Live scroll channel (u.params1): x = progress 0..1, y = overscroll. - let prog = clamp(u.params1.x, 0.0, 1.0); + // Live scroll channel (u.live): x = progress 0..1, y = overscroll. + let prog = clamp(u.live.x, 0.0, 1.0); // Rubber-band overscroll (screen-heights): negative at the top, positive at // the bottom. Lets the terrain keep reacting past either end of the list. - let over = u.params1.y; + let over = u.live.y; // A slowly evolving elevation field. Scrolling pans the terrain upward and // lifts the relief, so the contour map drifts and tightens as you read. @@ -119,8 +120,9 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let bands = 25.0 + prog * 2.0; let f = h * bands; let d = abs(fract(f) - 0.5); // 0 exactly on a contour line - let aa = fwidth(f) * 1.1; // screen-space anti-aliased thickness - let lineMask = 1.0 - smoothstep(0.0, aa, d); + let aa = fwidth(f) * 1.1; // screen-space anti-aliasing falloff + let thickness = 0.14; // half-width of each contour line + let lineMask = 1.0 - smoothstep(thickness, thickness + aa, d); // Every 5th line is a brighter "index" contour, like a real survey map. let idx = floor(f); diff --git a/example/src/components/ThinkingOrb.tsx b/example/src/components/ThinkingOrb.tsx index 10aae65..d4bce6e 100644 --- a/example/src/components/ThinkingOrb.tsx +++ b/example/src/components/ThinkingOrb.tsx @@ -27,6 +27,7 @@ struct Uniforms { color1: vec4, params0: vec4, params1: vec4, + live: vec4, }; @group(0) @binding(0) var u: Uniforms; @@ -45,9 +46,9 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { // (otherwise the halo grazes — and gets clipped at — the box outskirts). let p = (uv - 0.5) * vec2(aspect, 1.0) * 1.4; - // Live audio: params1 = (level, bass, treble, listening). - let level = u.params1.x; - let treble = u.params1.z; + // Live audio: live = (level, bass, treble, listening). + let level = u.live.x; + let treble = u.live.z; // Breathing, swollen by your voice. let breathe = 1.0 + 0.04 * sin(t * 1.6) + level * 0.65; diff --git a/example/src/components/TouchField.tsx b/example/src/components/TouchField.tsx index b656eb4..2110caf 100644 --- a/example/src/components/TouchField.tsx +++ b/example/src/components/TouchField.tsx @@ -19,7 +19,7 @@ type Props = Omit< /** * A draggable "liquid light" field. A molten, glowing core follows your finger - * (fed in through `u.params1` by {@link ShaderViewWithPanGesture}); it warps the + * (fed in through `u.live` by {@link ShaderViewWithPanGesture}); it warps the * flowing noise field around it and pushes concentric ripples outward. When you * are not touching, the core auto-orbits so the effect still feels alive. * @@ -54,6 +54,7 @@ struct Uniforms { color1: vec4, params0: vec4, params1: vec4, + live: vec4, }; @group(0) @binding(0) var u: Uniforms; @@ -101,9 +102,9 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let uv = ndc * 0.5 + 0.5; var p = (uv - 0.5) * vec2(aspect, 1.0); - // ShaderViewWithPanGesture remembers the pointer: u.params1.xy is the current + // ShaderViewWithPanGesture remembers the pointer: u.live.xy is the current // finger, or wherever the gesture last ended (center before the first touch). - let home = (u.params1.xy - 0.5) * vec2(aspect, 1.0); + let home = (u.live.xy - 0.5) * vec2(aspect, 1.0); // A small idle drift so it always feels alive. let wobble = vec2(sin(t * 0.7), cos(t * 0.9)) * 0.03; let center = home + wobble; @@ -133,7 +134,7 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { // Hue sweeps with the orb's position — drag it around to recolor the field. // Centered (the resting spot) leaves the colors untouched. - let hue = (u.params1.x - 0.5) * 4.5 + (u.params1.y - 0.5) * 1.8; + let hue = (u.live.x - 0.5) * 4.5 + (u.live.y - 0.5) * 1.8; col = hueRotate(col, hue); // Settle the edges into the dark. diff --git a/example/src/components/VoiceWave.tsx b/example/src/components/VoiceWave.tsx index d8d66bd..db7fd6c 100644 --- a/example/src/components/VoiceWave.tsx +++ b/example/src/components/VoiceWave.tsx @@ -6,7 +6,7 @@ type Props = Omit; * A simulated audio waveform — a glowing oscillating line with a soft filled * envelope, coloured blue→pink across its width, pulsing as if reacting to a * voice. The "amplitude" is faked from layered sines + noise for now; a real - * mic could later drive it through `u.params1`. Render it `transparent`. + * mic could later drive it through `u.live`. Render it `transparent`. * Pure procedural — no texture sampling. */ export default function VoiceWave({ speed = 1.0, ...viewProps }: Props) { @@ -28,6 +28,7 @@ struct Uniforms { color1: vec4, params0: vec4, params1: vec4, + live: vec4, }; @group(0) @binding(0) var u: Uniforms; @@ -55,10 +56,10 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let x = (uv.x - 0.5) * 2.0; // -1 .. 1 let y = uv.y - 0.5; - // Live audio: params1 = (level, bass, treble, listening). - let level = u.params1.x; - let bass = u.params1.y; - let treble = u.params1.z; + // Live audio: live = (level, bass, treble, listening). + let level = u.live.x; + let bass = u.live.y; + let treble = u.live.z; // Loudness envelope: louder in the middle, tapering at the edges. let env = smoothstep(1.0, 0.15, abs(x)); diff --git a/example/src/components/dissolves/EmberDissolve.tsx b/example/src/components/dissolves/EmberDissolve.tsx index 62adf4e..c1feab8 100644 --- a/example/src/components/dissolves/EmberDissolve.tsx +++ b/example/src/components/dissolves/EmberDissolve.tsx @@ -9,7 +9,7 @@ export default createDissolveComponent(/* wgsl */ ` fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let aspect = u.resolution.z; let uv = ndc * 0.5 + 0.5; - let prog = clamp(u.params1.x, 0.0, 1.0); + let prog = clamp(u.live.x, 0.0, 1.0); let base = cardSurface(uv); let n = fbm(uv * vec2(7.0 * aspect, 7.0)); diff --git a/example/src/components/dissolves/PixelDissolve.tsx b/example/src/components/dissolves/PixelDissolve.tsx index b2f11b7..a48991d 100644 --- a/example/src/components/dissolves/PixelDissolve.tsx +++ b/example/src/components/dissolves/PixelDissolve.tsx @@ -10,7 +10,7 @@ export default createDissolveComponent(/* wgsl */ ` fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let aspect = u.resolution.z; let uv = ndc * 0.5 + 0.5; - let prog = clamp(u.params1.x, 0.0, 1.0); + let prog = clamp(u.live.x, 0.0, 1.0); let base = cardSurface(uv); let bias = uv.x * 0.55 + (1.0 - uv.y) * 0.45; diff --git a/example/src/components/dissolves/ShardsDissolve.tsx b/example/src/components/dissolves/ShardsDissolve.tsx index 78eab12..535482b 100644 --- a/example/src/components/dissolves/ShardsDissolve.tsx +++ b/example/src/components/dissolves/ShardsDissolve.tsx @@ -10,7 +10,7 @@ export default createDissolveComponent(/* wgsl */ ` fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let aspect = u.resolution.z; let uv = ndc * 0.5 + 0.5; - let prog = clamp(u.params1.x, 0.0, 1.0); + let prog = clamp(u.live.x, 0.0, 1.0); let base = cardSurface(uv); let bias = uv.x * 0.55 + (1.0 - uv.y) * 0.45; diff --git a/example/src/components/dissolves/common.tsx b/example/src/components/dissolves/common.tsx index 8afd475..8450844 100644 --- a/example/src/components/dissolves/common.tsx +++ b/example/src/components/dissolves/common.tsx @@ -10,7 +10,7 @@ export type DissolveProps = Omit< ShaderViewProps, 'fragmentShader' | 'colors' | 'paramsSynchronizable' > & { - /** Dissolve progress channel: `u.params1.x` 0 (intact) → 1 (gone). */ + /** Dissolve progress channel: `u.live.x` 0 (intact) → 1 (gone). */ paramsSynchronizable: ParamsSynchronizable; /** Base card tone. */ baseColor?: ColorInput; @@ -21,7 +21,7 @@ export type DissolveProps = Omit< /** * Shared WGSL prelude for the dissolve variants: the Uniforms struct plus the * noise / voronoi helpers and the procedural holographic `cardSurface`. Each - * variant appends its own `@fragment main` that reads `u.params1.x` as the + * variant appends its own `@fragment main` that reads `u.live.x` as the * dissolve progress and erodes `cardSurface` in its own style. No texture * sampling — the card is generated in-shader, so it can dissolve itself. */ @@ -33,6 +33,7 @@ struct Uniforms { color1: vec4, params0: vec4, params1: vec4, + live: vec4, }; @group(0) @binding(0) var u: Uniforms; diff --git a/example/src/hooks/useAudioReactive.ts b/example/src/hooks/useAudioReactive.ts index d711d4f..9524592 100644 --- a/example/src/hooks/useAudioReactive.ts +++ b/example/src/hooks/useAudioReactive.ts @@ -13,12 +13,12 @@ const FFT_SIZE = 512; /** * Captures the microphone via react-native-audio-api and feeds a shader's - * `u.params1` with live audio so the visual reacts to your voice: + * `u.live` with live audio so the visual reacts to your voice: * - * - `params1.x` → overall level (RMS, 0..1, boosted + smoothed) - * - `params1.y` → bass energy (0..1) - * - `params1.z` → treble energy (0..1) - * - `params1.w` → 1 while listening, 0 otherwise + * - `live.x` → overall level (RMS, 0..1, boosted + smoothed) + * - `live.y` → bass energy (0..1) + * - `live.z` → treble energy (0..1) + * - `live.w` → 1 while listening, 0 otherwise * * The mic runs through `recorder → adapter → analyser → (muted gain) → * destination`; the muted gain keeps the graph pulling without playing your diff --git a/example/src/hooks/useTilt.ts b/example/src/hooks/useTilt.ts index 1de5c0e..36b97b3 100644 --- a/example/src/hooks/useTilt.ts +++ b/example/src/hooks/useTilt.ts @@ -11,7 +11,7 @@ const SENSITIVITY = 1.4; const DRAG_SCALE = 260; // px of drag for a full tilt sweep /** - * Provides a tilt channel for a foil shader, written into `u.params1` as + * Provides a tilt channel for a foil shader, written into `u.live` as * `(tiltX, tiltY, active, 0)` with `0.5, 0.5` meaning "flat". * * On a device with a motion sensor it reads the accelerometer (tilt the phone → diff --git a/example/src/screens/AiShimmerScreen.tsx b/example/src/screens/AiShimmerScreen.tsx index 07117d9..d1497ce 100644 --- a/example/src/screens/AiShimmerScreen.tsx +++ b/example/src/screens/AiShimmerScreen.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-native/no-inline-styles */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Pressable, diff --git a/example/src/screens/ScrollReactiveScreen.tsx b/example/src/screens/ScrollReactiveScreen.tsx index 23a9f19..d414888 100644 --- a/example/src/screens/ScrollReactiveScreen.tsx +++ b/example/src/screens/ScrollReactiveScreen.tsx @@ -37,7 +37,7 @@ const ENTRIES = [ export default function ScrollReactiveScreen() { const insets = useSafeAreaInsets(); // Drive the shader straight off the render loop — scrolling must never - // re-render React. `setParamsSynchronizable` writes (progress, overscroll) into u.params1. + // re-render React. `setParamsSynchronizable` writes (progress, overscroll) into u.live. const { paramsSynchronizable, setParamsSynchronizable } = useParamsSynchronizable(); @@ -69,6 +69,12 @@ export default function ScrollReactiveScreen() { style={StyleSheet.absoluteFill} /> + {/* Faint scrim to lift text contrast without hiding the shader. */} + + uniformData[0] = width; uniformData[1] = height; @@ -189,18 +189,21 @@ export default function ShaderView({ uniformData[18] = props[IDX_PARAMS + 2]!; uniformData[19] = props[IDX_PARAMS + 3]!; - // params1: vec4 — live input (touch/scroll) overrides these slots + // params1: vec4 — static params[4..7] + uniformData[20] = props[IDX_PARAMS + 4]!; + uniformData[21] = props[IDX_PARAMS + 5]!; + uniformData[22] = props[IDX_PARAMS + 6]!; + uniformData[23] = props[IDX_PARAMS + 7]!; + + // live: vec4 — off-thread input (touch/scroll/audio) from + // paramsSynchronizable, written into its own slot so it never collides + // with the static params. Stays (0,0,0,0) when no channel is attached. if (paramsSynchronizable) { const live = paramsSynchronizable.getDirty(); - uniformData[20] = live[0]!; - uniformData[21] = live[1]!; - uniformData[22] = live[2]!; - uniformData[23] = live[3]!; - } else { - uniformData[20] = props[IDX_PARAMS + 4]!; - uniformData[21] = props[IDX_PARAMS + 5]!; - uniformData[22] = props[IDX_PARAMS + 6]!; - uniformData[23] = props[IDX_PARAMS + 7]!; + uniformData[24] = live[0]!; + uniformData[25] = live[1]!; + uniformData[26] = live[2]!; + uniformData[27] = live[3]!; } device.queue.writeBuffer(uniformBuffer, 0, uniformData); diff --git a/src/components/ShaderView/types.ts b/src/components/ShaderView/types.ts index 520ffe4..e31ac76 100644 --- a/src/components/ShaderView/types.ts +++ b/src/components/ShaderView/types.ts @@ -3,8 +3,9 @@ import type { Synchronizable } from 'react-native-worklets'; import type { ColorInput } from '../../utils/colors'; /** - * A 4-float synchronizable whose values are written into `u.params1` (i.e. - * params[4..7]) every frame, overriding any static `params` in those slots. + * A 4-float synchronizable whose values are written into the dedicated `u.live` + * uniform slot every frame. It has its own slot, so it never collides with the + * 8 static `params` (`u.params0`/`u.params1`). * * This is the bridge for live, per-frame input (touch position, scroll * progress, velocity) coming from the JS thread into the off-thread render @@ -27,8 +28,9 @@ export type ShaderViewProps = ViewProps & { /** Use transparent background (clear to alpha 0). Default: false */ transparent?: boolean; /** - * Optional live input. Its 4 floats are written into `u.params1` every - * frame, taking precedence over static `params[4..7]`. Use for touch/scroll. + * Optional live input. Its 4 floats are written into the dedicated `u.live` + * slot every frame — independent of the static `params`. Use for + * touch/scroll/audio. Create it with `useParamsSynchronizable`. */ paramsSynchronizable?: ParamsSynchronizable; }; diff --git a/src/components/ShaderViewWithPanGesture/index.tsx b/src/components/ShaderViewWithPanGesture/index.tsx index 96fa05c..6b19049 100644 --- a/src/components/ShaderViewWithPanGesture/index.tsx +++ b/src/components/ShaderViewWithPanGesture/index.tsx @@ -10,18 +10,18 @@ import { useParamsSynchronizable } from '../../hooks/useParamsSynchronizable'; import type { ShaderViewProps } from '../ShaderView/types'; /** - * A {@link ShaderView} that feeds touch input into the shader's `u.params1`: + * A {@link ShaderView} that feeds touch input into the shader's `u.live`: * - * - `params1.x` → pointer X, normalized 0..1 (left → right) - * - `params1.y` → pointer Y, normalized 0..1 (bottom → top, matching UV space) - * - `params1.z` → 1.0 while touching, 0.0 when released - * - `params1.w` → 0.0 (reserved) + * - `live.x` → pointer X, normalized 0..1 (left → right) + * - `live.y` → pointer Y, normalized 0..1 (bottom → top, matching UV space) + * - `live.z` → 1.0 while touching, 0.0 when released + * - `live.w` → 0.0 (reserved) * * Dragging moves the pointer **relatively** — it pushes from where the pointer * already is rather than jumping under the finger — and a fling lets it glide to * a stop. The position is **remembered**: it stays wherever it ended and is - * never reset; only the "touched" flag (`params1.z`) toggles on release. A - * shader can read `params1.xy` as a stable resting position and use `params1.z` + * never reset; only the "touched" flag (`live.z`) toggles on release. A + * shader can read `live.xy` as a stable resting position and use `live.z` * purely for touch-driven emphasis, so the effect never snaps back. * * The resting value before the first touch is `[0, 0, 0, 0]` by default; pass @@ -38,7 +38,7 @@ export type ShaderViewWithPanGestureProps = Omit< 'paramsSynchronizable' > & { /** - * Initial value for the gesture channel (`u.params1`) before the first touch. + * Initial value for the gesture channel (`u.live`) before the first touch. * Defaults to `[0, 0, 0, 0]`. Use e.g. `[0.5, 0.5, 0, 0]` to rest a pointer at * screen center. */ diff --git a/src/hooks/useParamsSynchronizable.ts b/src/hooks/useParamsSynchronizable.ts index a8dc151..43906da 100644 --- a/src/hooks/useParamsSynchronizable.ts +++ b/src/hooks/useParamsSynchronizable.ts @@ -3,14 +3,15 @@ import { createSynchronizable } from 'react-native-worklets'; import type { ParamsSynchronizable } from '../components/ShaderView/types'; /** - * Creates a {@link ParamsSynchronizable} — a 4-float channel written into - * `u.params1` (params[4..7]) of a {@link ShaderView} every frame. + * Creates a {@link ParamsSynchronizable} — a 4-float channel written into the + * dedicated `u.live` slot of a {@link ShaderView} every frame. It has its own + * uniform slot, so it leaves all 8 static `params` untouched. * * The returned `setParamsSynchronizable` runs on the JS thread (call it from gesture or scroll * handlers); the values are read by the off-thread render loop. By convention * the four floats carry `(x, y, active, extra)` for pointer input, or * `(progress, ...)` for scroll-driven effects — but the meaning is up to the - * shader consuming `u.params1`. + * shader consuming `u.live`. * * Pass `initial` to seed the channel's starting value (read once on first * render), so the shader has a sane resting state before the first update — diff --git a/src/shaders/uniforms.ts b/src/shaders/uniforms.ts index 1c4d7a6..53226f0 100644 --- a/src/shaders/uniforms.ts +++ b/src/shaders/uniforms.ts @@ -1,8 +1,8 @@ -/** 96 bytes = 6 × vec4 */ -export const UNIFORM_BUFFER_SIZE = 96; +/** 112 bytes = 7 × vec4 */ +export const UNIFORM_BUFFER_SIZE = 112; /** Number of float32 values in the uniform buffer */ -export const UNIFORM_FLOAT_COUNT = UNIFORM_BUFFER_SIZE / 4; // 24 +export const UNIFORM_FLOAT_COUNT = UNIFORM_BUFFER_SIZE / 4; // 28 export const UNIFORMS_WGSL = /* wgsl */ ` struct Uniforms { @@ -12,6 +12,7 @@ struct Uniforms { color1: vec4, // colors[1] RGBA params0: vec4, // params[0..3] params1: vec4, // params[4..7] + live: vec4, // paramsSynchronizable (touch/scroll/audio); (0,0,0,0) when unused }; @group(0) @binding(0) var u: Uniforms; `;