Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 82 additions & 17 deletions CUSTOM_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -36,20 +39,26 @@ struct Uniforms {
color1: vec4<f32>, // colors[1] as normalized RGBA (0..1)
params0: vec4<f32>, // params[0], params[1], params[2], params[3]
params1: vec4<f32>, // params[4], params[5], params[6], params[7]
live: vec4<f32>, // paramsSynchronizable (touch/scroll/audio); (0,0,0,0) when unused
};
@group(0) @binding(0) var<uniform> 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

Expand Down Expand Up @@ -164,6 +173,59 @@ fn main(@location(0) ndc: vec2<f32>) -> @location(0) vec4<f32> {
- 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 (
<ShaderView
fragmentShader={MY_SHADER}
paramsSynchronizable={paramsSynchronizable}
style={{ width: '100%', height: 300 }}
onTouchMove={(e) => {
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<f32>) -> @location(0) vec4<f32> {
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<f32>(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.
Expand All @@ -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 {
Expand All @@ -197,6 +261,7 @@ struct Uniforms {
color1: vec4<f32>, // colors[1] as RGBA 0..1
params0: vec4<f32>, // params[0..3]
params1: vec4<f32>, // params[4..7]
live: vec4<f32>, // paramsSynchronizable channel (touch/scroll/audio); omit if unused
};
@group(0) @binding(0) var<uniform> u: Uniforms;
```
Expand Down
53 changes: 46 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ShaderView
fragmentShader={myShader}
paramsSynchronizable={paramsSynchronizable}
style={{ width: '100%', height: 300 }}
onTouchMove={(e) => {
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
Expand Down
7 changes: 4 additions & 3 deletions example/src/components/HoloFoilCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -38,6 +38,7 @@ struct Uniforms {
color1: vec4<f32>,
params0: vec4<f32>,
params1: vec4<f32>,
live: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;

Expand Down Expand Up @@ -85,7 +86,7 @@ fn main(@location(0) ndc: vec2<f32>) -> @location(0) vec4<f32> {
let c = (uv - 0.5) * vec2<f32>(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,
Expand Down
14 changes: 8 additions & 6 deletions example/src/components/ScrollReactive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -58,6 +58,7 @@ struct Uniforms {
color1: vec4<f32>,
params0: vec4<f32>,
params1: vec4<f32>,
live: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;

Expand Down Expand Up @@ -98,11 +99,11 @@ fn main(@location(0) ndc: vec2<f32>) -> @location(0) vec4<f32> {
let uv = ndc * 0.5 + 0.5;
var p = (uv - 0.5) * vec2<f32>(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.
Expand All @@ -119,8 +120,9 @@ fn main(@location(0) ndc: vec2<f32>) -> @location(0) vec4<f32> {
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);
Expand Down
7 changes: 4 additions & 3 deletions example/src/components/ThinkingOrb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct Uniforms {
color1: vec4<f32>,
params0: vec4<f32>,
params1: vec4<f32>,
live: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;

Expand All @@ -45,9 +46,9 @@ fn main(@location(0) ndc: vec2<f32>) -> @location(0) vec4<f32> {
// (otherwise the halo grazes — and gets clipped at — the box outskirts).
let p = (uv - 0.5) * vec2<f32>(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;
Expand Down
Loading
Loading