diff --git a/example/app.json b/example/app.json index 1cf3411..405f2a5 100644 --- a/example/app.json +++ b/example/app.json @@ -35,6 +35,12 @@ { "iosMicrophonePermission": "RN Effects uses the microphone so the shaders react to your voice." } + ], + [ + "expo-camera", + { + "cameraPermission": "RN Effects uses the camera to shoot instant prints on the Polaroid screen." + } ] ] } diff --git a/example/assets/bubblewrap/walnut.jpg b/example/assets/bubblewrap/walnut.jpg new file mode 100644 index 0000000..5bfab2a Binary files /dev/null and b/example/assets/bubblewrap/walnut.jpg differ diff --git a/example/assets/components/bubble-wrap.png b/example/assets/components/bubble-wrap.png new file mode 100644 index 0000000..c75f198 Binary files /dev/null and b/example/assets/components/bubble-wrap.png differ diff --git a/example/assets/components/cracked-screen.png b/example/assets/components/cracked-screen.png new file mode 100644 index 0000000..d1a6ab1 Binary files /dev/null and b/example/assets/components/cracked-screen.png differ diff --git a/example/assets/components/crt-off.png b/example/assets/components/crt-off.png new file mode 100644 index 0000000..ca44d2f Binary files /dev/null and b/example/assets/components/crt-off.png differ diff --git a/example/assets/components/duo-crackgate.png b/example/assets/components/duo-crackgate.png new file mode 100644 index 0000000..62af278 Binary files /dev/null and b/example/assets/components/duo-crackgate.png differ diff --git a/example/assets/components/ferrofluid.png b/example/assets/components/ferrofluid.png new file mode 100644 index 0000000..25132b6 Binary files /dev/null and b/example/assets/components/ferrofluid.png differ diff --git a/example/assets/components/flashlight.png b/example/assets/components/flashlight.png new file mode 100644 index 0000000..b2174b2 Binary files /dev/null and b/example/assets/components/flashlight.png differ diff --git a/example/assets/components/lava-lamp.png b/example/assets/components/lava-lamp.png new file mode 100644 index 0000000..6290e3a Binary files /dev/null and b/example/assets/components/lava-lamp.png differ diff --git a/example/assets/components/namedrop.png b/example/assets/components/namedrop.png new file mode 100644 index 0000000..8ae8863 Binary files /dev/null and b/example/assets/components/namedrop.png differ diff --git a/example/assets/components/polaroid.png b/example/assets/components/polaroid.png new file mode 100644 index 0000000..7b37f2b Binary files /dev/null and b/example/assets/components/polaroid.png differ diff --git a/example/assets/components/scratch-off.png b/example/assets/components/scratch-off.png new file mode 100644 index 0000000..158241e Binary files /dev/null and b/example/assets/components/scratch-off.png differ diff --git a/example/assets/components/wallet.png b/example/assets/components/wallet.png new file mode 100644 index 0000000..2e95aa2 Binary files /dev/null and b/example/assets/components/wallet.png differ diff --git a/example/assets/cracks/camera-fill.png b/example/assets/cracks/camera-fill.png new file mode 100644 index 0000000..824eb89 Binary files /dev/null and b/example/assets/cracks/camera-fill.png differ diff --git a/example/assets/cracks/flashlight-off-fill.png b/example/assets/cracks/flashlight-off-fill.png new file mode 100644 index 0000000..6da5922 Binary files /dev/null and b/example/assets/cracks/flashlight-off-fill.png differ diff --git a/example/assets/cracks/lock-fill.png b/example/assets/cracks/lock-fill.png new file mode 100644 index 0000000..59689c9 Binary files /dev/null and b/example/assets/cracks/lock-fill.png differ diff --git a/example/assets/cracks/message-fill.png b/example/assets/cracks/message-fill.png new file mode 100644 index 0000000..2430cc7 Binary files /dev/null and b/example/assets/cracks/message-fill.png differ diff --git a/example/assets/cracks/palms.jpg b/example/assets/cracks/palms.jpg new file mode 100644 index 0000000..569403f Binary files /dev/null and b/example/assets/cracks/palms.jpg differ diff --git a/example/assets/cracks/swing.jpg b/example/assets/cracks/swing.jpg new file mode 100644 index 0000000..8c584f3 Binary files /dev/null and b/example/assets/cracks/swing.jpg differ diff --git a/example/assets/ferrofluid/marble.jpg b/example/assets/ferrofluid/marble.jpg new file mode 100644 index 0000000..5ae5dbf Binary files /dev/null and b/example/assets/ferrofluid/marble.jpg differ diff --git a/example/assets/flashlight/camera.png b/example/assets/flashlight/camera.png new file mode 100644 index 0000000..7d0e7cd Binary files /dev/null and b/example/assets/flashlight/camera.png differ diff --git a/example/assets/flashlight/corridor.jpg b/example/assets/flashlight/corridor.jpg new file mode 100644 index 0000000..cdd951b Binary files /dev/null and b/example/assets/flashlight/corridor.jpg differ diff --git a/example/assets/flashlight/flashlight-off.png b/example/assets/flashlight/flashlight-off.png new file mode 100644 index 0000000..f274b46 Binary files /dev/null and b/example/assets/flashlight/flashlight-off.png differ diff --git a/example/assets/flashlight/torch-glyph.png b/example/assets/flashlight/torch-glyph.png new file mode 100644 index 0000000..c061934 Binary files /dev/null and b/example/assets/flashlight/torch-glyph.png differ diff --git a/example/assets/lavalamp/lava-lamp.jpg b/example/assets/lavalamp/lava-lamp.jpg new file mode 100644 index 0000000..8878e2d Binary files /dev/null and b/example/assets/lavalamp/lava-lamp.jpg differ diff --git a/example/assets/namedrop/avatar.jpg b/example/assets/namedrop/avatar.jpg new file mode 100644 index 0000000..6e9d919 Binary files /dev/null and b/example/assets/namedrop/avatar.jpg differ diff --git a/example/assets/namedrop/home-atlas.jpg b/example/assets/namedrop/home-atlas.jpg new file mode 100644 index 0000000..a887b8f Binary files /dev/null and b/example/assets/namedrop/home-atlas.jpg differ diff --git a/example/assets/polaroid/photo-beach.jpg b/example/assets/polaroid/photo-beach.jpg new file mode 100644 index 0000000..880f4c2 Binary files /dev/null and b/example/assets/polaroid/photo-beach.jpg differ diff --git a/example/assets/polaroid/photo-lake.jpg b/example/assets/polaroid/photo-lake.jpg new file mode 100644 index 0000000..438fb8d Binary files /dev/null and b/example/assets/polaroid/photo-lake.jpg differ diff --git a/example/assets/polaroid/photo-mountains.jpg b/example/assets/polaroid/photo-mountains.jpg new file mode 100644 index 0000000..8eba133 Binary files /dev/null and b/example/assets/polaroid/photo-mountains.jpg differ diff --git a/example/assets/polaroid/table.jpg b/example/assets/polaroid/table.jpg new file mode 100644 index 0000000..aa488f7 Binary files /dev/null and b/example/assets/polaroid/table.jpg differ diff --git a/example/package.json b/example/package.json index 3f9229f..b732f34 100644 --- a/example/package.json +++ b/example/package.json @@ -3,6 +3,15 @@ "version": "1.0.0", "private": true, "main": "index.js", + "expo": { + "autolinking": { + "ios": { + "buildFromSource": [ + "ExpoCamera.*" + ] + } + } + }, "scripts": { "start": "expo start", "android": "expo run:android", @@ -15,6 +24,7 @@ "@react-navigation/native-stack": "7.16.0", "expo": "~56.0.9", "expo-build-properties": "~56.0.17", + "expo-camera": "~56.0.8", "expo-sensors": "~56.0.5", "react": "19.2.3", "react-native": "0.85.3", diff --git a/example/src/App.tsx b/example/src/App.tsx index d48d492..596d680 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -44,6 +44,17 @@ import DandelionScreen from './screens/DandelionScreen'; import HourglassScreen from './screens/HourglassScreen'; import TelegramSpoilerScreen from './screens/TelegramSpoilerScreen'; import IMessageScreen from './screens/IMessageScreen'; +import CrackedScreenScreen from './screens/CrackedScreenScreen'; +import DuoCrackgateScreen from './screens/DuoCrackgateScreen'; +import PolaroidScreen from './screens/PolaroidScreen'; +import ScratchOffScreen from './screens/ScratchOffScreen'; +import BubbleWrapScreen from './screens/BubbleWrapScreen'; +import LavaLampScreen from './screens/LavaLampScreen'; +import FerrofluidScreen from './screens/FerrofluidScreen'; +import CrtOffScreen from './screens/CrtOffScreen'; +import FlashlightScreen from './screens/FlashlightScreen'; +import WalletPayScreen from './screens/WalletPayScreen'; +import NameDropScreen from './screens/NameDropScreen'; import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -149,6 +160,23 @@ export default function App() { component={TelegramSpoilerScreen} /> + + + + + + + + + + + diff --git a/example/src/components/bubblewrap/BubbleWrap.tsx b/example/src/components/bubblewrap/BubbleWrap.tsx new file mode 100644 index 0000000..6444835 --- /dev/null +++ b/example/src/components/bubblewrap/BubbleWrap.tsx @@ -0,0 +1,223 @@ +import { useMemo } from 'react'; +import type { ImageSourcePropType, ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; +import type { BubbleGrid } from '../../hooks/useBubbleWrap'; + +type Props = ViewProps & { + /** What the sheet lies on; seen through the film and bent by each dome. */ + surface: ImageSourcePropType; + /** `[scaleX, scaleY]` mapping screen uv onto the photo, cover-fit. */ + coverScale: [number, number]; + grid: BubbleGrid; + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A sheet of clear bubble wrap lying on a photographed surface. + * + * Every bubble is a shallow dome of clear film: it magnifies and bends the + * surface under it, throws a soft shadow and a little focused light onto it, + * and carries a soft window highlight and a bright Fresnel rim. The flat + * film between bubbles is faintly crinkled. A popped bubble (its float in the + * live channel goes 0 to 1) slumps into a flat, creased, slightly whitened + * disc whose highlight breaks into crumbs. + */ +export default function BubbleWrap({ + surface, + coverScale, + grid, + paramsSynchronizable, + ...viewProps +}: Props) { + const params = useMemo( + () => [ + grid.spacing, + grid.rowHeight, + grid.stride, + grid.rows, + coverScale[0], + coverScale[1], + ], + [grid, coverScale] + ); + return ( + + ); +} + +const BUBBLE_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; + +const RADIUS = 0.44; + +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 vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +// Screen points (y down) -> the photo. +fn surfaceAt(pt: vec2) -> vec3 { + let size = u.resolution.xy / u.resolution.w; + let suv = pt / size; + let iuv = (suv - 0.5) * u.params1.xy + 0.5; + return textureSampleLevel(tex, samp, iuv, 0.0).rgb; +} + +fn bubbleState(idx: i32) -> f32 { + let i = clamp(idx, 0, 383); + let v = u.liveData[i / 4]; + return v[i % 4]; +} + +struct Nearest { + center: vec2, + idx: i32, + valid: f32, +}; + +fn nearestBubble(pt: vec2) -> Nearest { + let sp = u.params0.x; + let rowH = u.params0.y; + let stride = i32(u.params0.z); + let rows = i32(u.params0.w); + let r0 = i32(floor(pt.y / rowH - 0.5)); + var best: Nearest; + best.valid = 0.0; + best.idx = 0; + best.center = vec2(-1000.0); + var bestD = 1e9; + for (var k = 0; k < 2; k = k + 1) { + let row = r0 + k; + let shift = select(0.0, 0.5, (row & 1) == 0); + let col = i32(round(pt.x / sp - shift)); + let center = vec2((f32(col) + shift) * sp, (f32(row) + 0.5) * rowH); + let d = distance(pt, center); + let ok = row >= 0 && row < rows && col >= 0 && col < stride; + if (ok && d < bestD) { + bestD = d; + best.center = center; + best.idx = row * stride + col; + best.valid = 1.0; + } + } + return best; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let size = u.resolution.xy / u.resolution.w; + let pt = vec2(uv.x, 1.0 - uv.y) * size; + let sp = u.params0.x; + let R = sp * RADIUS; + let aa = 1.2 / (R * u.resolution.w); + + let nb = nearestBubble(pt); + let popRaw = select(0.0, bubbleState(nb.idx), nb.valid > 0.5); + let pop = popRaw * popRaw * (3.0 - 2.0 * popRaw); + let seed = hash21(vec2(f32(nb.idx), 7.0)); + // Local bubble coords, y up, 1.0 at the dome's foot. + let d = pt - nb.center; + let o = vec2(d.x, -d.y) / R; + let rr = length(o); + let inside = smoothstep(1.0 + aa, 1.0 - aa, rr) * nb.valid; + + // Dome: a squashed hemisphere that slumps to almost nothing when popped. + let domeH = mix(0.72 + 0.16 * seed, 0.07, pop); + let foot = sqrt(max(1.0 - rr * rr, 0.035)); + var n = vec3(o * domeH / foot, 1.0); + // Crumple: creases radiate from the middle; a popped film is a mess of + // small facets. + let ang = atan2(o.y, o.x); + let creaseA = vnoise(vec2(ang * 2.6 + seed * 40.0, rr * 2.0)); + let creaseB = vnoise(o * 4.2 + seed * 13.0); + let creaseC = vnoise(o * 4.2 + seed * 13.0 + 31.7); + let crumple = vec2(creaseB - 0.5, creaseC - 0.5) * 2.2 + vec2(cos(ang), sin(ang)) * (creaseA - 0.5) * 2.4; + n = vec3(n.xy + crumple * pop * smoothstep(1.02, 0.55, rr), n.z); + n = normalize(n); + + // Film between bubbles: barely crinkled. + let fw = vec2(vnoise(pt * 0.07), vnoise(pt * 0.07 + 9.1)) - 0.5; + let fw2 = vec2(vnoise(pt * 0.23 + 3.3), vnoise(pt * 0.23 + 5.9)) - 0.5; + let nFilm = normalize(vec3(fw * 0.22 + fw2 * 0.08, 1.0)); + let nrm = normalize(mix(nFilm, n, inside)); + + // The surface under the film: magnified and bent by each intact dome, + // shadowed beside it, brightened where the dome focuses light. + let lens = (1.0 - pop) * inside; + let refr = nb.center + d * mix(1.0, 0.84, lens) + vec2(nrm.x, -nrm.y) * 5.0 * (lens + pop * inside * 0.4); + var base = surfaceAt(refr); + let sh = distance(pt, nb.center + vec2(R * 0.22, R * 0.34)) / R; + let shadow = 0.24 * (1.0 - 0.8 * pop) * smoothstep(1.08, 0.72, sh) * (1.0 - lens * 0.7) * nb.valid; + let focus = 0.18 * lens * smoothstep(0.75, 0.0, distance(o, vec2(0.15, -0.2))); + base = base * (1.0 - shadow) * (1.0 + focus); + base = base * 0.92 + vec3(0.012, 0.014, 0.018); + + // Light: a window up and to the left, a soft room fill. + // A camera and a window at finite distances, so the reflection slides and + // stretches across the sheet instead of repeating on every bubble. + let fromEye = vec2(pt.x - size.x * 0.5, size.y * 0.5 - pt.y); + let v = normalize(vec3(-fromEye, 1400.0)); + let r = reflect(-v, nrm); + let onCeiling = r.xy / max(r.z, 0.1) * 420.0 + vec2(pt.x, -pt.y); + let windowCenter = vec2(-0.7 * size.x, 0.45 * size.y); + let wq = (onCeiling - windowCenter) / vec2(0.8 * size.x, 0.7 * size.y); + let streak = smoothstep(1.0, 0.6, length(wq)) * (0.7 + 0.3 * smoothstep(-0.4, 0.6, wq.y)); + let toLamp = vec3(-0.4 * size.x - pt.x, pt.y + 0.5 * size.y, 900.0); + let sunH = normalize(normalize(toLamp) + v); + let sun = pow(clamp(dot(nrm, sunH), 0.0, 1.0), 320.0); + let k = 1.0 - clamp(nrm.z, 0.0, 1.0); + let fres = 0.04 + 0.96 * k * k * k; + + var col = base; + col = col + vec3(1.0, 0.99, 0.97) * streak * (0.5 + fres) * mix(1.0, 0.45, pop) * (0.2 + 0.8 * inside); + col = col + vec3(1.0) * min(sun * 1.1, 1.0); + let lit = dot(o / max(rr, 1e-3), vec2(-0.6, 0.8)); + let crescent = smoothstep(0.62, 0.84, rr) * smoothstep(0.98, 0.88, rr) * smoothstep(0.1, 0.85, lit); + col = col + vec3(1.0, 0.98, 0.95) * 0.42 * crescent * inside * (1.0 - pop); + col = col + vec3(0.85, 0.9, 1.0) * fres * 0.55 * inside; + // Total internal reflection darkens the dome's steep foot; the welded + // seam around it shows as a thin bright line. + col = col * (1.0 - 0.22 * smoothstep(0.72, 0.97, rr) * inside * (1.0 - pop)); + let away = dot(o, vec2(0.6, -0.8)); + col = col * (1.0 - 0.16 * smoothstep(0.2, 0.95, away) * inside * (1.0 - pop)); + let bounce = smoothstep(0.55, 0.85, away) * smoothstep(1.0, 0.9, rr); + col = col + vec3(0.9, 0.85, 0.75) * 0.1 * bounce * inside * (1.0 - pop); + let seam = smoothstep(0.06, 0.0, abs(rr - 1.03)) * nb.valid; + col = col + vec3(0.10) * seam; + // A popped film is stretched and whitened a little. + col = mix(col, vec3(0.86, 0.88, 0.9), 0.16 * pop * inside * (0.4 + creaseB)); + + col = col + (hash21(uv * u.resolution.xy) - 0.5) / 255.0; + return vec4(col, 1.0); +}`; diff --git a/example/src/components/cracks/CrackedGlass.tsx b/example/src/components/cracks/CrackedGlass.tsx new file mode 100644 index 0000000..0cba2e7 --- /dev/null +++ b/example/src/components/cracks/CrackedGlass.tsx @@ -0,0 +1,571 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +export const MAX_IMPACTS = 6; +/** `u.live` + two vec4 per impact in `u.liveData`. */ +export const CRACK_CHANNEL_LENGTH = 4 + MAX_IMPACTS * 8; + +type CrackedGlassProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'texture' +> & { + /** Snapshot of the content under the glass, sized like this view. */ + textureUri: string; + /** + * `u.live = (impactCount, tiltX, tiltY, 0)`, then per impact + * `(x, y, ageSeconds, seed), (strength, deadPixels, 0, 0)` in logical px, + * y-down. + */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A shattered cover glass over a snapshot of the screen. Each impact jolts + * the screen, flashes and throws glass dust, then races kinked radial cracks + * with side branches out from a crushed centre and lets polygonal ring cracks + * run between neighbouring rays. Every shard (sector between two rays x band + * between two rings) refracts the content by its own offset with a chromatic + * fringe at its edges, and crack segments facing the light glint as the phone + * tilts. Some impacts also kill the panel underneath: a spreading ink blot + * and a dead pixel column. + */ +export default function CrackedGlass({ + textureUri, + paramsSynchronizable, + ...rest +}: CrackedGlassProps) { + 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; + +const TAU: f32 = 6.2831853; +const PI: f32 = 3.14159265; +const MAX_IMPACTS: i32 = 6; +const RAYS: i32 = 17; +const SPLINTERS: i32 = 43; +const RINGS: i32 = 4; +const REACH: f32 = 980.0; // longest radial crack, logical px +const SPEED: f32 = 3400.0; // crack front speed, logical px per second +const CORE: f32 = 30.0; // crushed centre radius, logical px +const RAY_SEG: f32 = 34.0; +const BRANCH_SEG: f32 = 22.0; +const DEBRIS: i32 = 22; + +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); +} +fn wrapPi(a: f32) -> f32 { + return a - TAU * floor((a + PI) / TAU); +} +fn dir(a: f32) -> vec2 { + return vec2(cos(a), sin(a)); +} +fn wrapRay(j: i32) -> i32 { + return ((j % RAYS) + RAYS) % RAYS; +} +fn easeOut(x: f32) -> f32 { + let t = clamp(x, 0.0, 1.0); + return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t); +} + +// A crack is a polyline whose nodes sit at fixed steps along a base +// direction and are pushed sideways: a random kink per node plus a slow bend +// that is zero at the origin. Kinks are sharp, like real fracture paths. +struct Polyline { + o: vec2, + d: vec2, + key: vec2, + seg: f32, + len: f32, +}; + +fn kink(p: Polyline, i: i32) -> f32 { + if (i <= 0) { + return 0.0; + } + let fi = f32(i); + let h = hash21(vec2(fi * 1.37 + p.key.x, p.key.y * 17.0 + fi)) - 0.5; + let ph = hash21(p.key + 3.1) * TAU; + return h * p.seg * 0.42 + p.seg * 0.55 * (sin(fi * 0.27 + ph) - sin(ph)); +} +fn node(p: Polyline, i: i32) -> vec2 { + let t = min(f32(i) * p.seg, p.len); + return p.o + p.d * t + vec2(-p.d.y, p.d.x) * kink(p, i); +} +fn pointAt(p: Polyline, along: f32) -> vec2 { + let x = clamp(along, 0.0, p.len) / p.seg; + let i = i32(floor(x)); + return mix(node(p, i), node(p, i + 1), fract(x)); +} + +struct Hit { + dist: f32, + side: f32, + along: f32, + nrm: vec2, + segId: f32, +}; + +fn trace(q: vec2, p: Polyline) -> Hit { + var h: Hit; + h.dist = 1e5; + h.side = 1.0; + h.along = 0.0; + h.nrm = vec2(0.0, 1.0); + h.segId = 0.0; + let last = i32(ceil(p.len / p.seg)) - 1; + if (last < 0) { + return h; + } + let k = clamp(i32(floor(dot(q - p.o, p.d) / p.seg)), 0, last); + for (var s = k - 1; s <= k + 1; s = s + 1) { + if (s < 0 || s > last) { + continue; + } + let a = node(p, s); + let b = node(p, s + 1); + let ab = b - a; + let t = clamp(dot(q - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0); + let v = q - (a + ab * t); + let l = length(v); + if (l < h.dist) { + let sn = normalize(vec2(-ab.y, ab.x)); + let sd = select(-1.0, 1.0, dot(q - a, sn) >= 0.0); + h.dist = l; + h.side = sd; + h.along = (f32(s) + t) * p.seg; + h.nrm = select(sn * sd, v / l, l > 0.001); + h.segId = f32(s); + } + } + return h; +} + +struct Glass { + edge: f32, // distance to the nearest crack edge, device px + nrm: vec2, // that crack's normal, pointing at this pixel + glint: f32, // how strongly that crack segment catches the light + tone: f32, // how visible that crack segment is + offset: vec2, // refraction of the content, logical px + shade: f32, // per-shard sheen + crush: f32, // milky crushed-glass amount + dust: f32, // crushed shard borders + sparkle: f32, // crushed-cell and debris glitter + ink: f32, // dead LCD under the impact + inkEdge: f32, + stripe: vec3, // dead pixel column colour + flash: f32, +}; + +fn takeLine(g: ptr, h: Hit, width: f32, grown: f32, light: vec2, salt: f32) { + let reveal = clamp((grown - h.along) * u.resolution.w, 0.0, 1.0); + if (reveal <= 0.0) { + return; + } + let e = h.dist * u.resolution.w - width * 0.5 + (1.0 - reveal) * 2.0; + if (e < (*g).edge) { + (*g).edge = e; + (*g).nrm = h.nrm; + let facing = abs(dot(h.nrm, light)); + let lucky = hash21(vec2(h.segId, salt)); + (*g).glint = pow(facing, 10.0) * step(0.55, lucky); + let wave = 0.5 + 0.5 * sin(h.along * 0.045 + lucky * 30.0); + (*g).tone = mix(0.45, 1.0, hash21(vec2(h.segId + 0.5, salt + 1.0))) * mix(0.6, 1.0, wave); + } +} + +fn rayLine(c: vec2, j: i32, seed: f32, strength: f32) -> Polyline { + let fj = f32(j); + let h = hash22(vec2(fj, seed * 31.0)); + let ang = (fj + 0.5 + (h.x - 0.5) * 0.75) * TAU / f32(RAYS) + seed * TAU; + var p: Polyline; + p.o = c; + p.d = dir(ang); + p.key = vec2(fj * 7.3, seed * 91.0); + p.seg = RAY_SEG; + p.len = REACH * strength * mix(0.18, 1.0, h.y * h.y); + return p; +} +fn raySpeed(j: i32, seed: f32) -> f32 { + return SPEED * mix(0.65, 1.0, hash21(vec2(f32(j) + 0.7, seed * 13.0))); +} +fn branchLine(ray: Polyline, j: i32, b: i32, seed: f32) -> Polyline { + let h = hash22(vec2(f32(j) * 3.0 + f32(b), seed * 17.0 + 4.0)); + let at = ray.len * mix(0.12, 0.62, h.x); + let side = select(-1.0, 1.0, hash21(vec2(f32(j) + f32(b) * 5.0, seed)) > 0.5); + let base = atan2(ray.d.y, ray.d.x); + var p: Polyline; + p.o = pointAt(ray, at); + p.d = dir(base + side * mix(0.35, 0.95, h.y)); + p.key = vec2(f32(j) * 11.0 + f32(b) * 3.0, seed * 57.0); + p.seg = BRANCH_SEG; + p.len = ray.len * mix(0.12, 0.38, h.y) + 20.0; + return p; +} +fn ringRadius(j: i32, k: i32, seed: f32, strength: f32) -> f32 { + let fk = f32(k); + let base = CORE * strength * (2.0 + fk * 1.7 + fk * fk * 0.55); + return base * (0.84 + 0.32 * hash21(vec2(f32(j) + 0.5, fk + seed * 13.0))); +} +fn ringInSector(s: i32, k: i32, seed: f32) -> bool { + return hash21(vec2(f32(s) * 3.0 + f32(k), seed * 7.0 + 2.0)) > 0.22 + f32(k) * 0.1; +} + +fn segDist(q: vec2, a: vec2, b: vec2) -> vec2 { + let ab = b - a; + let t = clamp(dot(q - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0); + return vec2(length(q - (a + ab * t)), t); +} + +struct Cells { + edge: f32, + id: vec2, +}; + +fn voronoi(p: vec2, seed: f32) -> Cells { + let cell = floor(p); + var f1 = 9.0; + var f2 = 9.0; + var id = vec2(0.0); + for (var y = -1; y <= 1; y = y + 1) { + for (var x = -1; x <= 1; x = x + 1) { + let cid = cell + vec2(f32(x), f32(y)); + let pt = cid + 0.1 + 0.8 * hash22(cid + seed * 41.0); + let dd = length(p - pt); + if (dd < f1) { + f2 = f1; + f1 = dd; + id = cid; + } else if (dd < f2) { + f2 = dd; + } + } + } + var out: Cells; + out.edge = (f2 - f1) * 0.5; + out.id = id; + return out; +} + +// Crushed centre: angular shards, finer toward the point of impact. +fn crushed(g: ptr, d: vec2, r: f32, core: f32, seed: f32, light: vec2) { + let cz = 1.0 - smoothstep(core * 0.6, core * 1.4, r); + if (cz <= 0.0) { + return; + } + let fineness = smoothstep(core * 0.35, core * 0.8, r); + let cellPx = mix(3.2, 8.5, fineness); + let v = voronoi(d / cellPx, seed + floor(fineness + 0.5) * 3.0); + let edgePx = v.edge * cellPx * u.resolution.w; + let h = hash22(v.id + seed * 7.0); + let drawn = select(0.15, 0.75, h.y > 0.45); + (*g).dust = max((*g).dust, (1.0 - smoothstep(0.3, 1.1, edgePx)) * cz * drawn); + (*g).crush = max((*g).crush, cz * h.x * h.x); + (*g).offset = (*g).offset + (h - 0.5) * 7.0 * cz; + (*g).shade = (*g).shade + (h.y - 0.5) * 0.35 * cz; + let facet = normalize(h - 0.5 + vec2(0.0001)); + (*g).sparkle = max((*g).sparkle, pow(max(dot(facet, light), 0.0), 24.0) * cz * step(0.5, h.x)); +} + +fn debris(g: ptr, q: vec2, c: vec2, age: f32, seed: f32, strength: f32) { + if (age > 0.7) { + return; + } + for (var i = 0; i < DEBRIS; i = i + 1) { + let h = hash22(vec2(f32(i) * 1.9, seed * 77.0)); + let v = mix(220.0, 900.0, h.y * h.y) * strength; + let travel = v * (1.0 - exp(-age * 4.0)) / 4.0; + let p = c + dir(h.x * TAU) * (travel + 6.0); + let life = 1.0 - smoothstep(0.25, 0.7, age + h.y * 0.15); + let d = (q - p) * u.resolution.w; + let size = mix(1.6, 4.0, hash21(vec2(f32(i), seed))); + let star = exp(-dot(d, d) / (size * size)) + 0.35 * exp(-abs(d.x) * 0.9 - abs(d.y) * 0.18 / size) * exp(-abs(d.y) * 0.25) + + 0.35 * exp(-abs(d.y) * 0.9 - abs(d.x) * 0.18 / size) * exp(-abs(d.x) * 0.25); + let twinkle = 0.6 + 0.4 * sin(age * 70.0 + h.x * 40.0); + (*g).sparkle = max((*g).sparkle, star * life * twinkle); + } +} + +// Smooth random value around the circle with n knots, 0..1. +fn loopNoise(th: f32, n: f32, seed: f32) -> f32 { + let x = fract(th / TAU + 1.0) * n; + let i = floor(x); + let f = x - i; + let a = hash21(vec2(i, seed * 61.0)); + let b = hash21(vec2(i + 1.0 - n * step(n - 1.5, i), seed * 61.0)); + return mix(a, b, f * f * (3.0 - 2.0 * f)); +} + +fn deadPixels(g: ptr, q: vec2, c: vec2, r: f32, th: f32, age: f32, seed: f32, strength: f32) { + let spread = 1.0 - exp(-age * 1.8); + let wobble = loopNoise(th, 5.0, seed) * 0.32 + loopNoise(th, 13.0, seed + 0.5) * 0.12 - 0.22; + let radius = (9.0 + 34.0 * spread) * strength * (1.0 + wobble); + let inside = 1.0 - smoothstep(radius - 2.5, radius + 0.5, r); + (*g).ink = max((*g).ink, inside); + (*g).inkEdge = max((*g).inkEdge, exp(-max(r - radius * 0.7, 0.0) / (6.0 + 10.0 * spread)) * (1.0 - inside)); + + let col = hash21(vec2(seed * 3.0, 1.7)); + let x0 = c.x + (hash21(vec2(seed, 9.2)) - 0.5) * 30.0; + let w = mix(1.0, 2.2, hash21(vec2(seed, 4.4))); + let shown = smoothstep(0.15, 0.3, age); + let line = (1.0 - smoothstep(w * 0.5 - 0.5, w * 0.5 + 0.5, abs(q.x - x0))) * shown; + var tint = vec3(0.1, 1.0, 0.35); + if (col > 0.66) { + tint = vec3(0.85, 0.2, 1.0); + } else if (col > 0.33) { + tint = vec3(0.95, 0.97, 1.0); + } + (*g).stripe = max((*g).stripe, tint * line); +} + +fn impact(g: ptr, q: vec2, light: vec2, i: i32) { + let a = u.liveData[i * 2]; + let b = u.liveData[i * 2 + 1]; + let c = a.xy; + let age = a.z; + let seed = a.w; + let strength = b.x; + if (age <= 0.0) { + return; + } + + let d = q - c; + let r = length(d); + let th = atan2(d.y, d.x); + let rel = fract((th - seed * TAU) / TAU); + let core = CORE * strength * (1.0 + 0.25 * sin(th * 3.0 + seed * 20.0) + 0.12 * sin(th * 7.0 + seed * 9.0)); + let front = age * SPEED; + + (*g).flash = (*g).flash + exp(-age / 0.04) * exp(-r / 120.0) + exp(-age / 0.025) * 0.1; + debris(g, q, c, age, seed, strength); + if (b.y > 0.5) { + deadPixels(g, q, c, r, th, age, seed, strength); + } + + // Radial cracks: the nearest three, each with two side branches. + let k = i32(floor(rel * f32(RAYS))); + var centreSide = 1.0; + for (var jj = -1; jj <= 1; jj = jj + 1) { + let j = wrapRay(k + jj); + let ray = rayLine(c, j, seed, strength); + let grown = age * raySpeed(j, seed); + let h = trace(q, ray); + let taper = 1.0 - clamp(h.along / ray.len, 0.0, 1.0); + let w = (0.8 + 1.7 * taper * taper * taper) * mix(1.0, 1.6, smoothstep(core * 3.0, core, r)); + takeLine(g, h, w, grown, light, f32(j) + seed * 10.0); + if (jj == 0) { + centreSide = h.side; + } + + for (var bi = 0; bi < 2; bi = bi + 1) { + let br = branchLine(ray, j, bi, seed); + let startAt = length(br.o - c); + let bg = grown - startAt * 1.05; + if (bg > 0.0 && length(q - br.o) < br.len + br.seg) { + let hb = trace(q, br); + let bt = 1.0 - clamp(hb.along / br.len, 0.0, 1.0); + takeLine(g, hb, 0.75 + 1.1 * bt * bt, bg, light, f32(j * 2 + bi) + seed * 20.0); + } + } + } + let lower = select(wrapRay(k - 1), k, centreSide > 0.0); + let upper = wrapRay(lower + 1); + let sector = lower; + + // Ring cracks: kinked chords between neighbouring rays, so the rings read + // as the polygonal web real glass makes. Each one crossed outward starts a + // new band of shards. + let rayA = rayLine(c, lower, seed, strength); + let rayB = rayLine(c, upper, seed, strength); + var band = 0; + for (var rk = 0; rk < RINGS; rk = rk + 1) { + if (!ringInSector(sector, rk, seed)) { + continue; + } + let ra = ringRadius(lower, rk, seed, strength); + let rb = ringRadius(upper, rk, seed, strength); + if (ra > rayA.len || rb > rayB.len) { + continue; + } + if (r > max(ra, rb) * 1.6) { + band = band + 1; + continue; + } + let pa = pointAt(rayA, ra); + let pb = pointAt(rayB, rb); + let mh = hash21(vec2(f32(sector) + f32(rk) * 7.0, seed * 5.0)) - 0.5; + let pm = mix(pa, pb, 0.5 + mh * 0.3) + normalize(mix(pa, pb, 0.5) - c) * mh * length(pb - pa) * 0.22; + let s1 = segDist(q, pa, pm); + let s2 = segDist(q, pm, pb); + let firstHalf = s1.x < s2.x; + let e0 = select(pm, pa, firstHalf); + let e1 = select(pb, pm, firstHalf); + var outward = vec2(-(e1 - e0).y, (e1 - e0).x); + if (dot(outward, e0 - c) < 0.0) { + outward = -outward; + } + if (dot(q - e0, outward) > 0.0) { + band = band + 1; + } + + let startAt = max(ra / raySpeed(lower, seed), rb / raySpeed(upper, seed)) + 0.025 + f32(rk) * 0.022; + let runPx = (age - startAt) * 1800.0; + if (runPx > 0.0) { + let l1 = length(pm - pa); + let l2 = length(pb - pm); + let fromA = hash21(vec2(f32(sector), f32(rk) + seed)) > 0.5; + let pos = select(l1 + s2.y * l2, s1.y * l1, firstHalf); + let along = select(l1 + l2 - pos, pos, fromA); + var rh: Hit; + rh.dist = min(s1.x, s2.x); + rh.along = along; + let sd = q - select(mix(pm, pb, s2.y), mix(pa, pm, s1.y), firstHalf); + rh.nrm = select(normalize(outward), sd / max(rh.dist, 0.0001), rh.dist > 0.001); + rh.segId = f32(rk) + select(0.5, 0.0, firstHalf); + rh.side = 1.0; + takeLine(g, rh, 1.0 + 0.5 * f32(RINGS - rk) / f32(RINGS), runPx, light, f32(sector) + seed * 30.0); + } + } + + let settled = smoothstep(0.0, 0.08, age - r / SPEED); + let fade = smoothstep(min(REACH * strength, 800.0), 120.0, r) * settled; + let hs = hash22(vec2(f32(sector) * 5.0 + f32(band), seed * 23.0)) - 0.5; + let near = 0.3 + 0.7 * exp(-r / 240.0); + (*g).offset = (*g).offset + hs * 3.6 * strength * near * fade; + let tilt = hash22(vec2(f32(band) + 9.1, f32(sector) + seed * 3.0)) - 0.5; + (*g).shade = (*g).shade + dot(tilt, light) * 0.18 * fade; + + let fk = i32(floor(fract((th - seed * 3.1) / TAU) * f32(SPLINTERS))); + for (var jj = -1; jj <= 1 && r < core * 3.8; jj = jj + 1) { + let j = ((fk + jj) % SPLINTERS + SPLINTERS) % SPLINTERS; + let hh = hash22(vec2(f32(j) + 0.3, seed * 53.0)); + var sp: Polyline; + sp.o = c; + sp.d = dir((f32(j) + 0.5 + (hh.x - 0.5) * 0.9) * TAU / f32(SPLINTERS) + seed * 3.1); + sp.key = vec2(f32(j) * 5.1 + 40.0, seed * 19.0); + sp.seg = 9.0; + sp.len = core * mix(1.1, 3.4, hh.y * hh.y); + let hs2 = trace(q, sp); + takeLine(g, hs2, 0.6 + 0.9 * (1.0 - hs2.along / sp.len), age * SPEED * 0.5, light, f32(j) + seed * 40.0); + } + + crushed(g, d, r, core * min(1.0, 0.4 + age * 25.0), seed, light); +} + +fn shake(age: f32, seed: f32, strength: f32) -> vec2 { + if (age <= 0.0 || age > 0.5) { + return vec2(0.0); + } + let env = exp(-age * 11.0) * strength; + let a = seed * TAU; + return dir(a) * sin(age * 95.0) * 6.0 * env + dir(a + 1.7) * sin(age * 61.0 + 1.0) * 3.0 * env; +} + +fn sampleRgb(q: vec2, size: vec2, offset: vec2, spread: vec2) -> vec3 { + let lo = vec2(0.001); + let hi = vec2(0.999); + let rr = textureSampleLevel(tex, samp, clamp((q + offset + spread) / size, lo, hi), 0.0).r; + let gg = textureSampleLevel(tex, samp, clamp((q + offset) / size, lo, hi), 0.0).g; + let bb = textureSampleLevel(tex, samp, clamp((q + offset - spread) / size, lo, hi), 0.0).b; + return vec3(rr, gg, bb); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let size = u.resolution.xy / u.resolution.w; + let count = i32(u.live.x + 0.5); + let light = normalize(vec2(-0.45, -0.9) + (u.live.yz - 0.5) * vec2(2.2, -2.2)); + + var jolt = vec2(0.0); + for (var i = 0; i < MAX_IMPACTS; i = i + 1) { + if (i >= count) { + break; + } + jolt = jolt + shake(u.liveData[i * 2].z, u.liveData[i * 2].w, u.liveData[i * 2 + 1].x); + } + let q = vec2(uv.x, 1.0 - uv.y) * size + jolt; + + var g: Glass; + g.edge = 1000.0; + g.nrm = vec2(0.0, 1.0); + g.glint = 0.0; + g.tone = 1.0; + g.offset = vec2(0.0); + g.shade = 0.0; + g.crush = 0.0; + g.dust = 0.0; + g.sparkle = 0.0; + g.ink = 0.0; + g.inkEdge = 0.0; + g.stripe = vec3(0.0); + g.flash = 0.0; + for (var i = 0; i < MAX_IMPACTS; i = i + 1) { + if (i >= count) { + break; + } + impact(&g, q, light, i); + } + + let bevel = exp(-max(g.edge, 0.0) / 2.4); + let offset = g.offset - g.nrm * bevel * 1.1; + let spread = g.nrm * bevel * 1.3 + g.offset * 0.25 + vec2(g.crush * 1.5, 0.0); + var col = sampleRgb(q, size, offset, spread); + + let bleed = mix(vec3(0.05, 0.35, 0.3), vec3(0.35, 0.06, 0.45), g.inkEdge); + let inkCol = mix(bleed, vec3(0.01, 0.008, 0.015), g.ink); + col = col * (1.0 + g.shade); + let milky = vec3(0.82, 0.85, 0.88); + col = mix(col, milky, g.crush * 0.28); + col = mix(col, inkCol, max(g.ink, g.inkEdge * 0.85)); + col = max(col, g.stripe); + col = mix(col, vec3(0.96), g.dust * 0.75); + + let facing = dot(g.nrm, light); + let lit = clamp(facing, 0.0, 1.0); + let dark = clamp(-facing, 0.0, 1.0); + col = col + bevel * (lit * 0.2 - dark * 0.22); + + let coreA = 1.0 - smoothstep(-0.55, 0.65, g.edge); + let silver = mix(vec3(0.62, 0.66, 0.7), vec3(1.0), 0.45 + 0.55 * lit); + col = mix(col, silver, coreA * g.tone * 0.92); + col = col + vec3(1.0, 0.98, 0.95) * min(g.glint, 1.0) * (coreA + bevel * 0.3) * 0.6; + + col = col + vec3(1.0, 0.98, 0.94) * min(g.sparkle, 1.5); + col = col + vec3(0.9, 0.95, 1.0) * min(g.flash, 1.2); + + return vec4(clamp(col, vec3(0.0), vec3(1.0)), 1.0); +} +`; diff --git a/example/src/components/cracks/HingeFailure.tsx b/example/src/components/cracks/HingeFailure.tsx new file mode 100644 index 0000000..15ac9c8 --- /dev/null +++ b/example/src/components/cracks/HingeFailure.tsx @@ -0,0 +1,264 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** Where the crack starts on the hinge, as a fraction of the panel height. */ +export const CRACK_Y = 0.58; +export const FAILURE_STAGES = 4; + +type HingeFailureProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'texture' +> & { + /** `u.live = (damage 0..4, glitch 0..1, 0, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A foldable inner display dying along its crease, as a transparent layer + * over the live screen, after the viral owner photos: a crack runs the length + * of the hinge, stuck white, magenta, green and cyan pixel columns shoot out of + * it, black ink bleeds out from the failure point, and finally the lines + * spread across the panel and the damage flickers. The hinge is the vertical + * centre line of the view. `damage` walks through the four stages, `glitch` + * is a short tearing burst played as each stage lands. + */ +export default function HingeFailure({ + paramsSynchronizable, + ...rest +}: HingeFailureProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +const CRACK_Y: f32 = ${CRACK_Y}; +const BLACK: vec3 = vec3(0.0, 0.0, 0.0); +const WHITE: vec3 = vec3(1.0, 1.0, 1.0); +const MAGENTA: vec3 = vec3(1.0, 0.22, 0.88); +const GREEN: vec3 = vec3(0.3, 1.0, 0.42); +const CYAN: vec3 = vec3(0.25, 0.9, 1.0); +const YELLOW: vec3 = vec3(1.0, 0.95, 0.35); + +fn hash11(x: f32) -> f32 { + return fract(sin(x * 12.9898) * 43758.5453); +} + +fn hash21(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn noise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p: vec2) -> f32 { + return 0.55 * noise(p) + 0.3 * noise(p * 2.1 + 5.2) + 0.15 * noise(p * 4.3 + 1.7); +} + +fn palette(k: f32) -> vec3 { + if (k < 0.3) { return WHITE; } + if (k < 0.55) { return MAGENTA; } + if (k < 0.75) { return GREEN; } + if (k < 0.9) { return CYAN; } + return YELLOW; +} + +// Premultiplied "over": paints a layer of colour rgb and coverage a on top. +fn over(dst: vec4, rgb: vec3, a: f32) -> vec4 { + let k = clamp(a, 0.0, 1.0); + return vec4(rgb * k + dst.rgb * (1.0 - k), k + dst.a * (1.0 - k)); +} + +fn segDist(q: vec2, a: vec2, b: vec2) -> f32 { + let ab = b - a; + let t = clamp(dot(q - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0); + return length(q - (a + ab * t)); +} + +// The crack wanders a couple of points around the crease line. +fn crackX(y: f32, hx: f32) -> f32 { + return hx + 2.2 * (noise(vec2(y * 0.08, 3.1)) - 0.5) + + 0.8 * (noise(vec2(y * 0.45, 7.3)) - 0.5); +} + +// Signed distance to the ink: a blot at the failure point stretched along the +// hinge, a smaller one higher up, both with ragged noisy edges. +fn inkDist(q: vec2, hx: f32, y0: f32, size: vec2, s3: f32, s4: f32) -> f32 { + let k = clamp(size.x / 900.0, 0.6, 1.0); + let h = size.y; + let warp = (fbm(q * 0.035) - 0.5) * k; + let r1 = (30.0 * smoothstep(0.0, 1.0, s3) + 60.0 * s4) * k; + let p1 = vec2(q.x - hx, (q.y - y0) * 0.33); + let d1 = length(p1) + warp * (14.0 + r1 * 0.8) - r1; + let r2 = 26.0 * s4 * k; + let p2 = vec2(q.x - hx, (q.y - (y0 - h * 0.36)) * 0.45); + let d2 = length(p2) + warp * (10.0 + r2 * 0.8) - r2; + return min(d1, select(1000.0, d2, s4 > 0.0)); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let dpr = u.resolution.w; + let size = u.resolution.xy / dpr; + let q = vec2(uv.x, 1.0 - uv.y) * size; + let damage = u.live.x; + let t = u.time.x; + + let hx = floor(size.x * 0.5 * dpr) / dpr; + let y0 = floor(size.y * CRACK_Y * dpr) / dpr; + let dx = q.x - hx; + let vy = abs(q.y - y0); + + let s1 = clamp(damage, 0.0, 1.0); + let s2 = clamp(damage - 1.0, 0.0, 1.0); + let s3 = clamp(damage - 2.0, 0.0, 1.0); + let s4 = clamp(damage - 3.0, 0.0, 1.0); + + // Glitch: the stage burst from JS plus rare short tears once fully broken. + let slot = floor(t * 9.0); + let rare = step(0.9, hash11(slot + 0.37)) * s4 * 0.7; + let g = clamp(max(u.live.y, rare), 0.0, 1.0); + let band = floor(q.y / 12.0); + let torn = step(0.65, hash21(vec2(band + 3.0, slot))); + let tear = (hash21(vec2(band, slot)) - 0.5) * 26.0 * g * torn; + let qx = q.x + tear; + + var o = vec4(0.0); + + let crease = exp(-(dx * dx) / 100.0); + o = over(o, BLACK, crease * 0.08); + + // Backlight flicker around the damage once it is fully broken. + let wobble = max(0.06 * sin(t * 13.0) * sin(t * 2.9 + 1.0), 0.0); + let near = exp(-length(vec2(dx * 0.4, q.y - y0)) / 160.0); + o = over(o, BLACK, s4 * (wobble + 0.2 * g) * near); + + // Stage 3: black ink bleeding out of the failure point, a dead column down + // the whole hinge, and a dithered fringe of dead pixels around the blot. + if (s3 > 0.0) { + let sd = inkDist(vec2(qx, q.y), hx, y0, size, s3, s4); + let core = 1.0 - smoothstep(-1.0, 1.0, sd); + let strip = (1.5 + 4.5 * s4) * s3 * (0.6 + 0.8 * noise(vec2(q.y * 0.04, 1.0))); + let column = 1.0 - smoothstep(-0.5, 0.8, abs(qx - hx) - strip); + let fringe = (1.0 - smoothstep(0.0, 12.0, sd)) * step(0.0, sd); + let cell = floor(q * dpr / 2.0); + let dead = step(1.0 - fringe * 0.75, hash21(cell)); + o = over(o, vec3(0.2, 0.02, 0.28), fringe * 0.4 * s3); + o = over(o, BLACK, max(max(core, column), dead)); + } + + // Stage 2: stuck pixel columns shooting up and down from the failure point, + // and a white row out to the right edge. Stage 4 spreads them sideways. + if (s2 > 0.0) { + let col = floor(qx * dpr) - floor(hx * dpr); + let id = floor(col / 2.0); + let spread = (24.0 + size.x * 0.17 * s4) * dpr; + let lit = min(s2 * 2.5, 1.0); + let reach = size.y * smoothstep(0.0, 0.9, s2); + var rgb = BLACK; + var a = 0.0; + if (col >= 3.0 && col < 9.0) { + rgb = WHITE; + a = 1.0; + } else if (col >= -7.0 && col < -3.0) { + rgb = GREEN; + a = 0.95; + } else if (col >= 14.0 && col < 19.0) { + rgb = MAGENTA; + a = 0.95; + } else if (col >= -20.0 && col < -16.0) { + rgb = CYAN; + a = 0.9; + } else if (abs(col) < spread) { + let shown = hash11(id + 9.1) < s2 * 0.6 + s4 * 0.9; + if (hash11(id * 1.37 + 5.0) > 0.88 && shown) { + rgb = palette(hash11(id + 2.3)); + a = 0.85; + let partial = hash11(id + 4.2); + if (partial > 0.55) { + let mid = size.y * hash11(id + 6.6); + a = a * step(abs(q.y - mid), size.y * 0.45 * partial); + } + } + } + let flicker = 0.88 + 0.12 * sin(t * 31.0 + id * 1.7); + o = over(o, rgb, a * lit * flicker * step(vy, reach) * (1.0 - 0.4 * g)); + + let row = floor(q.y * dpr) - floor(y0 * dpr); + let rowReach = hx + (size.x - hx) * smoothstep(0.0, 0.8, s2); + if (row >= -3.0 && row < 3.0 && qx > hx && qx < rowReach) { + o = over(o, WHITE, lit); + } + if (row >= 6.0 && row < 9.0 && qx > hx && qx < rowReach) { + o = over(o, MAGENTA, lit * 0.9); + } + } + + // Stage 1: the crease cracks open from the failure point to both edges, + // with a few short fractures branching out where it started. + if (s1 > 0.0) { + if (vy < size.y * smoothstep(0.0, 1.0, s1)) { + let d = q.x - crackX(q.y, hx); + let shadow = 1.0 - smoothstep(1.0, 5.0, abs(d)); + let core = 1.0 - smoothstep(0.6, 1.4, abs(d)); + let glint = 1.0 - smoothstep(0.0, 0.7, abs(d - 1.7)); + o = over(o, BLACK, shadow * 0.35); + o = over(o, BLACK, core * 0.95); + o = over(o, vec3(0.92, 0.93, 0.95), glint * 0.6); + } + let c = vec2(crackX(y0, hx), y0); + var bd = 1000.0; + for (var i = 0; i < 6; i = i + 1) { + let fi = f32(i); + let ang = fi * 1.047 + 0.4 + (hash11(fi) - 0.5) * 0.7; + let dir = vec2(cos(ang), sin(ang)); + let len = (14.0 + 30.0 * hash11(fi + 3.0)) * s1; + let mid = c + dir * len * 0.5 + vec2(-dir.y, dir.x) * (hash11(fi + 7.0) - 0.5) * 6.0; + bd = min(bd, min(segDist(q, c, mid), segDist(q, mid, c + dir * len))); + } + o = over(o, BLACK, (1.0 - smoothstep(0.4, 1.1, bd)) * 0.9); + o = over(o, WHITE, (1.0 - smoothstep(0.0, 0.6, abs(bd - 1.3))) * 0.3); + o = over(o, BLACK, 1.0 - smoothstep(3.0, 5.0, length(q - c))); + } + + // Glitch bursts: a few torn rows of coloured garbage across the damage. + if (g > 0.0 && damage > 0.0) { + let pick = hash21(vec2(band, slot + 11.0)); + if (pick > 0.8 && abs(dx) < size.x * (0.08 + 0.3 * s4)) { + let block = hash21(vec2(floor(q.x * dpr / 6.0), band)); + let on = step(0.5, hash21(vec2(floor(q.x * dpr / 3.0), band + slot))); + o = over(o, palette(block), 0.6 * g * on); + } + } + + return o; +} +`; diff --git a/example/src/components/cracks/LockScreen.tsx b/example/src/components/cracks/LockScreen.tsx new file mode 100644 index 0000000..b253095 --- /dev/null +++ b/example/src/components/cracks/LockScreen.tsx @@ -0,0 +1,280 @@ +import { type ComponentRef, type Ref, useEffect, useState } from 'react'; +import { + Image, + type ImageSourcePropType, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const GLYPHS = { + lock: require('../../../assets/cracks/lock-fill.png'), + flashlight: require('../../../assets/cracks/flashlight-off-fill.png'), + camera: require('../../../assets/cracks/camera-fill.png'), + message: require('../../../assets/cracks/message-fill.png'), +}; + +const DAYS = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +function pad(n: number) { + return n < 10 ? `0${n}` : `${n}`; +} + +const TICK_MS = 5_000; + +/** Ticks only when `live`: a snapshot of the screen cannot follow the time. */ +function useClock(live: boolean) { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + if (!live) { + return; + } + const id = setInterval(() => setNow(new Date()), TICK_MS); + return () => clearInterval(id); + }, [live]); + return { + time: `${pad(now.getHours())}:${pad(now.getMinutes())}`, + date: `${DAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`, + }; +} + +function Glyph({ + source, + height, + aspect, + color = '#fff', +}: { + source: ImageSourcePropType; + height: number; + aspect: number; + color?: string; +}) { + return ( + + ); +} + +type LockScreenProps = { + wallpaper: ImageSourcePropType; + ref?: Ref>; + /** Side by side layout of the unfolded iPhone Duo inner panel. */ + landscape?: boolean; + /** Show a Messages notification above the bottom buttons. */ + notification?: { title: string; body: string }; + clockColor?: string; + /** Black wash over the wallpaper, 0..1. */ + dim?: number; + /** Keep the clock ticking (only when the screen is not snapshotted). */ + liveClock?: boolean; + onLayout?: () => void; +}; + +/** + * A static clone of the iOS 26 lock screen (lock glyph, date, + * clock, flashlight and camera buttons, home indicator) over a wallpaper + * photo. Rendered as plain views so it can be snapshotted into a texture. + */ +export default function LockScreen({ + wallpaper, + ref, + landscape = false, + notification, + clockColor = 'rgba(255,255,255,0.94)', + dim = 0.12, + liveClock = false, + onLayout, +}: LockScreenProps) { + const insets = useSafeAreaInsets(); + const { time, date } = useClock(liveClock); + const top = Math.max(insets.top, 20); + const clockSize = landscape ? 104 : 96; + + return ( + + + + + + + {date} + + {time} + + + + {notification ? ( + + + + + + + {notification.title} + now + + + {notification.body} + + + + ) : null} + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + ...StyleSheet.absoluteFill, + backgroundColor: '#000', + overflow: 'hidden', + }, + fill: { + ...StyleSheet.absoluteFill, + width: '100%', + height: '100%', + }, + shade: { + ...StyleSheet.absoluteFill, + backgroundColor: '#000', + }, + head: { + alignItems: 'center', + }, + date: { + marginTop: 8, + color: 'rgba(255,255,255,0.92)', + fontSize: 20, + fontWeight: '600', + }, + clock: { + fontWeight: '700', + letterSpacing: -2, + fontVariant: ['tabular-nums'], + }, + notification: { + position: 'absolute', + left: 12, + right: 12, + flexDirection: 'row', + alignItems: 'center', + padding: 13, + gap: 11, + borderRadius: 24, + backgroundColor: 'rgba(40,40,46,0.55)', + }, + appIcon: { + width: 38, + height: 38, + borderRadius: 9, + backgroundColor: '#34C759', + alignItems: 'center', + justifyContent: 'center', + }, + notificationText: { + flex: 1, + }, + notificationRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + notificationTitle: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + notificationTime: { + color: 'rgba(255,255,255,0.6)', + fontSize: 13, + }, + notificationBody: { + color: '#fff', + fontSize: 15, + marginTop: 1, + }, + bottomRow: { + position: 'absolute', + left: 46, + right: 46, + flexDirection: 'row', + justifyContent: 'space-between', + }, + bottomRowWide: { + left: 60, + right: 60, + }, + roundButton: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(30,30,34,0.42)', + alignItems: 'center', + justifyContent: 'center', + }, + homeIndicator: { + position: 'absolute', + bottom: 8, + alignSelf: 'center', + width: 140, + height: 5, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.9)', + }, +}); diff --git a/example/src/components/cracks/useCrackSound.ts b/example/src/components/cracks/useCrackSound.ts new file mode 100644 index 0000000..51c43af --- /dev/null +++ b/example/src/components/cracks/useCrackSound.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { AudioContext, type AudioBuffer } from 'react-native-audio-api'; + +const NOISE_SECONDS = 0.25; +const TINKLES = 12; + +type Synth = { ctx: AudioContext; noise: AudioBuffer }; + +function createSynth(): Synth { + const ctx = new AudioContext(); + const length = Math.floor(ctx.sampleRate * NOISE_SECONDS); + const noise = ctx.createBuffer(1, length, ctx.sampleRate); + const data = new Float32Array(length); + for (let i = 0; i < length; i++) { + data[i] = Math.random() * 2 - 1; + } + noise.copyToChannel(data, 0); + return { ctx, noise }; +} + +function between(lo: number, hi: number): number { + return lo + Math.random() * (hi - lo); +} + +function noiseBurst( + { ctx, noise }: Synth, + t: number, + freq: number, + level: number, + decay: number +) { + const src = ctx.createBufferSource(); + src.buffer = noise; + src.playbackRate.value = between(0.8, 1.2); + const filter = ctx.createBiquadFilter(); + filter.type = 'highpass'; + filter.frequency.value = freq; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(level, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + decay); + src.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + src.start(t); + src.stop(t + decay + 0.02); +} + +function ping({ ctx }: Synth, t: number, freq: number, level: number) { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.value = freq; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(level, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.06); +} + +function thud({ ctx }: Synth, t: number, level: number) { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(between(110, 150), t); + osc.frequency.exponentialRampToValueAtTime(45, t + 0.12); + const gain = ctx.createGain(); + gain.gain.setValueAtTime(level, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.14); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.16); +} + +/** + * A synthesized cover glass breaking: a dull thud of the hit, a sharp + * high-passed snap as the crack runs, then a scatter of fading glassy ticks + * and pings for the splinters settling. + */ +export function useCrackSound() { + const synthRef = useRef(null); + + useEffect(() => { + return () => { + synthRef.current?.ctx.close(); + synthRef.current = null; + }; + }, []); + + return useCallback(() => { + if (synthRef.current === null) { + synthRef.current = createSynth(); + } + const synth = synthRef.current; + const t = synth.ctx.currentTime + 0.005; + thud(synth, t, 0.7); + noiseBurst(synth, t, between(1800, 2600), 1.1, 0.07); + noiseBurst(synth, t + 0.02, 4500, 0.5, 0.18); + for (let i = 0; i < TINKLES; i++) { + const at = t + 0.04 + Math.pow(Math.random(), 1.6) * 0.45; + const fade = 1 - (at - t) / 0.55; + if (Math.random() < 0.5) { + ping(synth, at, between(3800, 7500), 0.12 * fade); + } else { + noiseBurst(synth, at, between(5000, 8000), 0.25 * fade, 0.015); + } + } + }, []); +} diff --git a/example/src/components/cracks/useCrackedGlass.ts b/example/src/components/cracks/useCrackedGlass.ts new file mode 100644 index 0000000..97baca6 --- /dev/null +++ b/example/src/components/cracks/useCrackedGlass.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Vibration } from 'react-native'; +import { Accelerometer } from 'expo-sensors'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { CRACK_CHANNEL_LENGTH, MAX_IMPACTS } from './CrackedGlass'; +import { useCrackSound } from './useCrackSound'; + +/** Shake, cracks, debris and the spreading ink blot all settle by then. */ +const SETTLE_S = 2.5; +const DEAD_PIXEL_CHANCE = 0.45; +const TILT_SMOOTHING = 0.15; + +type Impact = { + x: number; + y: number; + seed: number; + strength: number; + deadPixels: boolean; + start: number; +}; + +type Tilt = { x: number; y: number }; +type ImpactOverrides = Partial< + Pick +>; + +function ageOf(impact: Impact, now: number, slowdown: number) { + return Math.min(SETTLE_S, (now - impact.start) / 1000 / slowdown); +} + +/** + * Impacts and tilt for {@link CrackedGlass}. `hit(x, y)` cracks the glass at + * a point (logical px) with a short buzz and a breaking-glass sound; past + * {@link MAX_IMPACTS} the oldest impact is replaced. Tilt moves the light the + * crack edges catch: the accelerometer on a device, or `__crackTilt(x, y)` in + * `__DEV__` (0.5, 0.5 is flat; `null` hands control back to the sensor). + * `__crackHit(x, y, { seed, strength, deadPixels })` repeats a given impact + * and `__crackSlow(factor)` slows every impact animation by that factor. + */ +export function useCrackedGlass() { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(CRACK_CHANNEL_LENGTH).fill(0)); + const impacts = useRef([]); + const tilt = useRef({ x: 0.5, y: 0.5 }); + const tiltOverride = useRef(null); + const raf = useRef(0); + const slowdown = useRef(1); + const playCrack = useCrackSound(); + + const write = useCallback( + (now: number) => { + const t = tiltOverride.current ?? tilt.current; + const data = [impacts.current.length, t.x, t.y, 0]; + for (const impact of impacts.current) { + data.push(impact.x, impact.y, ageOf(impact, now, slowdown.current)); + data.push(impact.seed, impact.strength, impact.deadPixels ? 1 : 0); + data.push(0, 0); + } + while (data.length < CRACK_CHANNEL_LENGTH) { + data.push(0); + } + setParamsSynchronizable(...data); + }, + [setParamsSynchronizable] + ); + + const animate = useCallback(() => { + const step = (now: number) => { + write(now); + const growing = impacts.current.some( + (i) => ageOf(i, now, slowdown.current) < SETTLE_S + ); + raf.current = growing ? requestAnimationFrame(step) : 0; + }; + if (!raf.current) { + raf.current = requestAnimationFrame(step); + } + }, [write]); + + const hit = useCallback( + (x: number, y: number, overrides?: ImpactOverrides) => { + const impact: Impact = { + x, + y, + seed: Math.random(), + strength: 0.85 + Math.random() * 0.3, + deadPixels: Math.random() < DEAD_PIXEL_CHANCE, + start: performance.now(), + ...overrides, + }; + impacts.current = [...impacts.current, impact].slice(-MAX_IMPACTS); + Vibration.vibrate(40); + playCrack(); + animate(); + }, + [animate, playCrack] + ); + + const reset = useCallback(() => { + impacts.current = []; + write(performance.now()); + }, [write]); + + useEffect(() => { + let sub: { remove: () => void } | null = null; + Accelerometer.isAvailableAsync() + .then((ok) => { + if (!ok) { + return; + } + Accelerometer.setUpdateInterval(33); + sub = Accelerometer.addListener(({ x, y }) => { + const t = tilt.current; + t.x += (0.5 + x * 0.5 - t.x) * TILT_SMOOTHING; + t.y += (0.5 + (y + 0.55) * 0.5 - t.y) * TILT_SMOOTHING; + if (!raf.current && !tiltOverride.current) { + write(performance.now()); + } + }); + }) + .catch(() => {}); + return () => sub?.remove(); + }, [write]); + + useEffect(() => { + if (!__DEV__) { + return; + } + const g = globalThis as { + __crackTilt?: (x: number | null, y?: number) => void; + __crackHit?: (x: number, y: number, overrides?: ImpactOverrides) => void; + __crackSlow?: (factor: number) => void; + }; + g.__crackTilt = (x, y = 0.5) => { + tiltOverride.current = x === null ? null : { x, y }; + write(performance.now()); + }; + g.__crackHit = hit; + g.__crackSlow = (factor) => { + slowdown.current = Math.max(1, factor); + }; + return () => { + delete g.__crackSlow; + delete g.__crackTilt; + delete g.__crackHit; + }; + }, [hit, write]); + + useEffect(() => () => cancelAnimationFrame(raf.current), []); + + return { paramsSynchronizable, hit, reset }; +} diff --git a/example/src/components/cracks/useHingeFailure.ts b/example/src/components/cracks/useHingeFailure.ts new file mode 100644 index 0000000..40e6aca --- /dev/null +++ b/example/src/components/cracks/useHingeFailure.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Vibration } from 'react-native'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { FAILURE_STAGES } from './HingeFailure'; + +/** How long each stage takes to set in: crack, colour lines, ink, spread. */ +const STAGE_MS = [650, 700, 1300, 1700]; +/** The panel looks healthy for a beat before it starts to go. */ +const START_DELAY_MS = 900; +const GLITCH_MS = 320; + +type State = { + target: number; + damage: number; + damageOverride: number | null; + glitch: number; + last: number; +}; + +function approach(value: number, target: number, step: number) { + return value < target + ? Math.min(target, value + step) + : Math.max(target, value - step); +} + +/** + * Drives {@link HingeFailure}. `play()` shows a healthy panel for a moment and + * then lets the hinge fail through every stage on its own, `advance()` skips + * to the next stage (or replays once fully broken), `reset()` puts a fresh + * panel in. Every new stage lands with a short glitch. In `__DEV__`, + * `__crackgateDamage(0..4)` pins the damage and `null` hands it back. + */ +export function useHingeFailure() { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([0, 0, 0, 0]); + const state = useRef({ + target: 0, + damage: 0, + damageOverride: null, + glitch: 0, + last: 0, + }); + const raf = useRef(0); + const startTimer = useRef | undefined>( + undefined + ); + + const publish = useCallback(() => { + const s = state.current; + setParamsSynchronizable(s.damageOverride ?? s.damage, s.glitch, 0, 0); + }, [setParamsSynchronizable]); + + const step = useCallback( + (now: number) => { + const s = state.current; + const dt = Math.min(64, now - (s.last || now)); + s.last = now; + const before = Math.floor(s.damage); + const stageMs = STAGE_MS[Math.min(before, 3)] ?? 1000; + s.damage = approach(s.damage, s.target, dt / stageMs); + if (Math.floor(s.damage) > before) { + s.glitch = 1; + Vibration.vibrate(25); + } + s.glitch = Math.max(0, s.glitch - dt / GLITCH_MS); + publish(); + if (s.damage === s.target && s.glitch === 0) { + raf.current = 0; + s.last = 0; + return; + } + raf.current = requestAnimationFrame(step); + }, + [publish] + ); + + const kick = useCallback(() => { + if (!raf.current) { + raf.current = requestAnimationFrame(step); + } + }, [step]); + + const reset = useCallback(() => { + clearTimeout(startTimer.current); + const s = state.current; + s.target = 0; + s.damage = 0; + s.glitch = 0; + publish(); + }, [publish]); + + const play = useCallback(() => { + reset(); + startTimer.current = setTimeout(() => { + state.current.target = FAILURE_STAGES; + kick(); + }, START_DELAY_MS); + }, [kick, reset]); + + const advance = useCallback(() => { + const s = state.current; + if (s.damage >= FAILURE_STAGES) { + play(); + return; + } + clearTimeout(startTimer.current); + s.damage = Math.floor(s.damage) + 1; + s.target = FAILURE_STAGES; + s.glitch = 1; + Vibration.vibrate(25); + kick(); + }, [kick, play]); + + useEffect(() => { + if (!__DEV__) { + return; + } + const g = globalThis as { + __crackgateDamage?: (value: number | null) => void; + }; + g.__crackgateDamage = (value) => { + state.current.damageOverride = + value === null ? null : Math.max(0, Math.min(FAILURE_STAGES, value)); + publish(); + }; + return () => { + delete g.__crackgateDamage; + }; + }, [publish]); + + useEffect( + () => () => { + cancelAnimationFrame(raf.current); + clearTimeout(startTimer.current); + }, + [] + ); + + return { paramsSynchronizable, play, advance, reset }; +} diff --git a/example/src/components/cracks/useSnapshotTexture.ts b/example/src/components/cracks/useSnapshotTexture.ts new file mode 100644 index 0000000..5764af8 --- /dev/null +++ b/example/src/components/cracks/useSnapshotTexture.ts @@ -0,0 +1,84 @@ +import { + type ComponentRef, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import type { View } from 'react-native'; +import { captureRef, releaseCapture } from 'react-native-view-shot'; + +/** Wallpaper decode and layout settle before the snapshot is taken. */ +const SETTLE_MS = 450; + +async function snapshot(view: RefObject | null>) { + const raw = await captureRef(view, { + format: 'png', + quality: 1, + result: 'tmpfile', + }); + return raw.startsWith('file://') ? raw : `file://${raw}`; +} + +/** + * Snapshots a view into a PNG for a ShaderView `texture`. Call `schedule()` + * once the view has laid out, or `invalidate()` when its size changes so a + * stretched stale snapshot is never shown. The latest snapshot wins and the + * previous file is released. + */ +export function useSnapshotTexture( + view: RefObject | null> +) { + const [uri, setUri] = useState(null); + const timer = useRef | null>(null); + const generation = useRef(0); + + const capture = useCallback(async () => { + const gen = ++generation.current; + try { + const next = await snapshot(view); + if (gen !== generation.current) { + releaseCapture(next); + return; + } + setUri((prev) => { + if (prev) { + releaseCapture(prev); + } + return next; + }); + } catch (e) { + console.warn('[cracks] snapshot failed:', e); + } + }, [view]); + + const schedule = useCallback(() => { + if (timer.current) { + clearTimeout(timer.current); + } + timer.current = setTimeout(capture, SETTLE_MS); + }, [capture]); + + const invalidate = useCallback(() => { + generation.current++; + setUri((prev) => { + if (prev) { + releaseCapture(prev); + } + return null; + }); + schedule(); + }, [schedule]); + + useEffect( + () => () => { + if (timer.current) { + clearTimeout(timer.current); + } + }, + [] + ); + + return { uri, schedule, invalidate }; +} diff --git a/example/src/components/crt/CrtTube.tsx b/example/src/components/crt/CrtTube.tsx new file mode 100644 index 0000000..a1a3f83 --- /dev/null +++ b/example/src/components/crt/CrtTube.tsx @@ -0,0 +1,179 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +type CrtTubeProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' +> & { + /** `u.live = (powerOff 0|1, seconds since the last toggle, 0, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A snapshot of a screen shown on an old CRT tube. At rest it matches the + * snapshot exactly; barrel curvature, scanlines, an RGB phosphor mask and a + * rolling band only ramp in during a transition. Power off squashes the picture into a white-hot horizontal line, pulls the line + * into a dot and lets the dot's phosphor fade; power on runs it back with an + * overshoot and a decaying degauss wobble. + */ +export default function CrtTube({ + paramsSynchronizable, + ...rest +}: CrtTubeProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +// Barrel curvature at rest and at full squash: at rest the tube shows the +// app untouched, the glass bulges as the picture collapses. +const CURVE_REST: f32 = 0.0; +const CURVE_SQUASH: f32 = 0.16; +const PHOSPHOR: vec3 = vec3(0.86, 0.93, 1.0); + +// Picture scale (x, y), the collapsed dot's brightness and the degauss +// wobble, all as functions of the seconds since the last toggle. +struct Beam { + scale: vec2, + spot: f32, + wobble: f32, +}; + +fn sdBox(p: vec2, b: vec2) -> f32 { + let d = abs(p) - b; + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0); +} + +fn easeIn(x: f32) -> f32 { + let c = clamp(x, 0.0, 1.0); + return c * c * c; +} + +fn easeOut(x: f32) -> f32 { + let c = 1.0 - clamp(x, 0.0, 1.0); + return 1.0 - c * c * c; +} + +fn powerOff(e: f32) -> Beam { + let squash = easeIn(e / 0.24); + let pinch = smoothstep(0.25, 0.46, e); + var b: Beam; + b.scale = vec2((1.0 + 0.04 * squash) * (1.0 - pinch), 1.0 - squash); + let fade = exp(-max(e - 0.46, 0.0) / 0.2) * 0.8 + exp(-max(e - 0.46, 0.0) / 0.6) * 0.2; + b.spot = smoothstep(0.38, 0.46, e) * fade; + b.wobble = 0.0; + return b; +} + +fn powerOn(e: f32) -> Beam { + let t = max(e - 0.2, 0.0); + var b: Beam; + b.scale = vec2( + easeOut((e - 0.07) / 0.15), + select(0.0, 1.0 - exp(-t * 12.0) * cos(t * 13.0), e > 0.2) + ); + b.spot = smoothstep(0.0, 0.05, e) * (1.0 - smoothstep(0.08, 0.2, e)); + b.wobble = smoothstep(0.18, 0.28, e) * exp(-max(e - 0.26, 0.0) * 3.2) * (1.0 - smoothstep(1.5, 2.1, e)); + return b; +} + +fn screen(cuv: vec2, wob: f32) -> vec3 { + let tuv = vec2(cuv.x, 1.0 - cuv.y); + let shift = vec2(wob * 0.006, 0.0); + let g = textureSampleLevel(tex, samp, tuv, 0.0).g; + let r = textureSampleLevel(tex, samp, tuv + shift, 0.0).r; + let bl = textureSampleLevel(tex, samp, tuv - shift, 0.0).b; + return vec3(r, g, bl); +} + +fn phosphorMask(px: f32) -> vec3 { + let k = floor(px % 3.0); + return vec3( + select(0.93, 1.1, k < 0.5), + select(0.93, 1.1, k > 0.5 && k < 1.5), + select(0.93, 1.1, k > 1.5) + ); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let res = u.resolution.xy; + let dpr = u.resolution.w; + let e = u.live.y; + var b: Beam; + if (u.live.x > 0.5) { + b = powerOff(e); + } else { + b = powerOn(e); + } + + let p = (uv - 0.5) * res; + let box = res * 0.5 * b.scale; + let d = sdBox(p, box); + let compress = 1.0 - clamp(b.scale.y, 0.0, 1.0); + let lit = step(0.001, b.scale.x); + + var col = vec3(0.0); + + if (d < 0.0 && b.scale.y > 0.002) { + var local = p / box; + local.x = local.x + b.wobble * 0.035 * sin(local.y * 9.0 + e * 38.0); + var c = local * 0.5; + let curve = mix(CURVE_REST, CURVE_SQUASH, smoothstep(0.0, 0.5, compress + b.wobble)); + c = c * (1.0 + curve * (dot(c, c) - 0.25)); + let cuv = c + 0.5; + let edge = smoothstep(0.0, 0.006, cuv.x) * smoothstep(1.0, 0.994, cuv.x) + * smoothstep(0.0, 0.004, cuv.y) * smoothstep(1.0, 0.996, cuv.y); + + var img = screen(clamp(cuv, vec2(0.0), vec2(1.0)), b.wobble); + let t = u.time.x; + let crt = smoothstep(0.0, 0.4, compress + b.wobble); + let scan = 1.0 - 0.06 * crt * (0.5 - 0.5 * sin(cuv.y * res.y * 2.0944)); + let flick = 1.0 + 0.012 * crt * sin(t * 9.0) * sin(t * 2.3); + let band = 0.035 * crt * exp(-pow((fract(cuv.y - t * 0.11) - 0.5) * 9.0, 2.0)); + let vign = 1.0 - 0.35 * crt * dot(c, c); + let mask = mix(vec3(1.0), phosphorMask(uv.x * res.x), crt); + img = img * mask * scan * flick * vign + band; + img = img * (1.0 + b.wobble * 0.22 * vec3(sin(e * 21.0), sin(e * 21.0 + 2.1), sin(e * 21.0 + 4.2))); + + let boost = 1.0 + 2.2 * compress * compress; + let hot = vec3(1.0) - exp(-img * boost * 1.5); + img = mix(img, hot, smoothstep(0.0, 0.3, compress)); + img = mix(img, PHOSPHOR * 1.4, smoothstep(0.09, 0.005, b.scale.y)); + col = img * mix(1.0, edge, crt); + } + + let glowR = (6.0 + 10.0 * compress) * dpr; + let halo = exp(-max(d, 0.0) / glowR) * compress * compress * 0.75; + let core = (1.0 - smoothstep(0.0, 1.4 * dpr, d)) * smoothstep(0.6, 1.0, compress); + col = col + PHOSPHOR * (halo + core * 1.3) * lit; + + let rr = length(p) / dpr; + col = col + PHOSPHOR * b.spot * (1.7 * exp(-rr * rr / 14.0) + 0.45 * exp(-rr / 12.0)); + + return vec4(min(col, vec3(1.0)), 1.0); +} +`; diff --git a/example/src/components/crt/tubeSound.ts b/example/src/components/crt/tubeSound.ts new file mode 100644 index 0000000..0e2f151 --- /dev/null +++ b/example/src/components/crt/tubeSound.ts @@ -0,0 +1,96 @@ +import { AudioContext } from 'react-native-audio-api'; + +let ctx: AudioContext | null = null; + +function context() { + ctx = ctx ?? new AudioContext(); + return ctx; +} + +function envelope(ac: AudioContext, at: number, peak: number, length: number) { + const env = ac.createGain(); + env.gain.setValueAtTime(0, at); + env.gain.linearRampToValueAtTime(peak, at + 0.006); + env.gain.exponentialRampToValueAtTime(0.0005, at + length); + env.connect(ac.destination); + return env; +} + +function thump(ac: AudioContext, at: number, from: number, to: number) { + const osc = ac.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(from, at); + osc.frequency.exponentialRampToValueAtTime(to, at + 0.3); + osc.connect(envelope(ac, at, 0.5, 0.35)); + osc.start(at); + osc.stop(at + 0.4); +} + +function crackle(ac: AudioContext, at: number, length: number, peak: number) { + const buffer = ac.createBuffer( + 1, + Math.ceil(ac.sampleRate * length), + ac.sampleRate + ); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) { + data[i] = Math.random() < 0.04 ? Math.random() * 2 - 1 : 0; + } + const src = ac.createBufferSource(); + src.buffer = buffer; + const hp = ac.createBiquadFilter(); + hp.type = 'highpass'; + hp.frequency.value = 2500; + src.connect(hp); + hp.connect(envelope(ac, at, peak, length)); + src.start(at); +} + +function whine(ac: AudioContext, at: number, length: number) { + const osc = ac.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(15734, at); + osc.frequency.linearRampToValueAtTime(15400, at + length); + osc.connect(envelope(ac, at, 0.025, length)); + osc.start(at); + osc.stop(at + length + 0.05); +} + +function hum(ac: AudioContext, at: number, length: number) { + const osc = ac.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.value = 60; + const lp = ac.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 220; + osc.connect(lp); + lp.connect(envelope(ac, at, 0.22, length)); + osc.start(at); + osc.stop(at + length + 0.05); +} + +/** The tube switching off: a relay thunk, static and the flyback whine dying. */ +export function playPowerOff() { + try { + const ac = context(); + const t0 = ac.currentTime + 0.01; + thump(ac, t0, 90, 38); + crackle(ac, t0, 0.18, 0.35); + whine(ac, t0, 0.7); + } catch { + // Sound is optional; the picture still collapses. + } +} + +/** The tube warming up: a degauss thump and a fading 60 Hz buzz. */ +export function playPowerOn() { + try { + const ac = context(); + const t0 = ac.currentTime + 0.01; + thump(ac, t0 + 0.18, 55, 32); + hum(ac, t0 + 0.18, 0.9); + crackle(ac, t0, 0.3, 0.25); + } catch { + // Sound is optional; the picture still powers on. + } +} diff --git a/example/src/components/ferrofluid/Ferrofluid.tsx b/example/src/components/ferrofluid/Ferrofluid.tsx new file mode 100644 index 0000000..7ec7c44 --- /dev/null +++ b/example/src/components/ferrofluid/Ferrofluid.tsx @@ -0,0 +1,272 @@ +import type { ImageSourcePropType, ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +type Props = ViewProps & { + /** The surface the fluid sits on, shot from straight above. */ + surface: ImageSourcePropType; + /** + * `[scaleX, scaleY]` mapping screen uv onto the photo so it covers the + * view without stretching (the excess is cropped evenly). + */ + coverScale: [number, number]; + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A puddle of ferrofluid on a photographed surface, seen from above. + * + * The fluid is a height field: a thin lens with a rounded meniscus, plus the + * Rosensweig spike field (a hex lattice of cones) where the magnet's field is + * strong. The spikes rise with the field, crowd closer as it grows and lean + * toward the magnet. It is shaded as a black mirror: almost no diffuse, a + * strong Fresnel term, a softbox overhead and the surrounding surface + * reflected in the flanks of every spike. + */ +export default function Ferrofluid({ + surface, + coverScale, + paramsSynchronizable, + ...viewProps +}: Props) { + return ( + + ); +} + +const FERRO_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; + +const PUDDLE_R = 0.15; +const LENS_H = 0.016; +const SPIKE_H = 0.085; + +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 photo(c: vec2) -> vec3 { + let suv = vec2(c.x / u.resolution.z + 0.5, 0.5 - c.y); + let iuv = (suv - 0.5) * u.params0.xy + 0.5; + return textureSampleLevel(tex, samp, iuv, 0.0).rgb; +} + +fn modv(a: vec2, b: vec2) -> vec2 { + return a - b * floor(a / b); +} + +// Offset from p to the nearest hex lattice point, lattice spacing 1. +fn hexOffset(p: vec2) -> vec2 { + let r = vec2(1.0, 1.7320508); + let h = r * 0.5; + let a = modv(p, r) - h; + let b = modv(p - h, r) - h; + return select(b, a, dot(a, a) < dot(b, b)); +} + +struct Fluid { + h: f32, + sd: f32, +}; + +fn puddleSd(p: vec2, center: vec2, dir: vec2, s: f32, phase: f32) -> f32 { + let d = p - center; + let along = dot(d, dir); + let across = dot(d, vec2(-dir.y, dir.x)); + let stretch = 1.0 + select(0.12, 0.55, along > 0.0) * s; + let squeeze = 1.0 - 0.12 * s; + let ang = atan2(d.y, d.x); + let wob = 1.0 + 0.025 * sin(ang * 3.0 + phase * 1.7) + 0.018 * sin(ang * 5.0 - phase * 2.3); + let q = vec2(along / stretch, across / squeeze); + return length(q) - PUDDLE_R * (1.0 - 0.1 * s) * wob; +} + +fn spikeFocus(center: vec2, magnet: vec2) -> vec2 { + let toMag = magnet - center; + let len = length(toMag); + return center + toMag * min(1.0, 0.08 / max(len, 1e-4)); +} + +fn spikeField(p: vec2, focus: vec2, s: f32, sd: f32, phase: f32) -> f32 { + let spacing = 0.046 - 0.01 * s; + let cell = hexOffset(p / spacing); + let id = round((p / spacing - cell) * 2.0); + // Each spike stands a little off the lattice point, and is a concave cone + // that meets the surface with zero slope, so neighbours never leave a seam. + let jit = vec2(hash21(id), hash21(id + 7.13)) - 0.5; + let local = cell - jit * 0.16; + let tip = p - local * spacing; + // Tallest right over the magnet, dying away across the puddle so the far + // side stays a smooth pool; nothing grows in the thin fluid at the rim. + let spread = 0.0025 + 0.004 * s; + let fall = exp(-dot(tip - focus, tip - focus) / spread); + let rim = smoothstep(0.0, 0.05, -sd); + let wobble = 1.0 + 0.07 * sin(phase * 5.0 + hash21(id + 3.7) * 6.2832); + let size = 0.85 + 0.3 * hash21(id + 1.9); + let env = s * rim * fall * wobble * size; + let r = sqrt(dot(local, local) + 0.005) / 0.42; + let t = clamp(1.0 - r, 0.0, 1.0); + return SPIKE_H * env * t * t; +} + +fn fluidAt(p: vec2) -> Fluid { + let center = u.live.xy; + let s = u.live.z; + let phase = u.live.w; + let magnet = u.liveData[0].xy; + let toMag = magnet - center; + let dist = length(toMag); + let dir = select(vec2(0.0, 1.0), toMag / max(dist, 1e-4), dist > 1e-3); + let lean = dir * min(dist, 0.25) * s; + let sd = puddleSd(p, center, dir, min(dist * 4.0, 1.0) * s, phase); + // A rounded rim plus a broad, shallow crown, so reflections curve. + let fromCenter = p - center; + let crown = clamp(1.0 - dot(fromCenter, fromCenter) / (PUDDLE_R * PUDDLE_R), 0.0, 1.0); + let lens = LENS_H * (0.6 * sqrt(clamp(-sd / 0.03, 0.0, 1.0)) + 0.4 * crown); + // Shear the lattice toward the magnet by the spike's own height, so each + // cone leans over instead of the whole grid sliding. + let focus = spikeFocus(center, magnet); + let h0 = spikeField(p, focus, s, sd, phase); + let q = p - lean * (h0 / SPIKE_H) * 0.3; + let h1 = spikeField(q, focus, s, sd, phase); + // The fluid heaps up over the magnet, so the spikes stand on a mound. + let mound = 0.02 * s * exp(-dot(p - focus, p - focus) / (0.01 + 0.012 * s)) * smoothstep(0.0, 0.05, -sd); + var f: Fluid; + f.h = lens + mound + h1; + f.sd = sd; + return f; +} + +fn env(r: vec3, c: vec2) -> vec3 { + // A dim room: a softbox overhead, darker ceiling, and the counter itself + // for rays that bounce downward off steep flanks. + // The ceiling is close: where a ray starts shifts what it sees. + let sky = r.xy / max(r.z, 0.05) + c * 3.0; + let boxUv = (sky - vec2(-0.22, 0.95)) / vec2(0.3, 0.2); + let box = smoothstep(1.0, 0.82, abs(boxUv.x)) * smoothstep(1.0, 0.8, abs(boxUv.y)) * (0.35 + 0.65 * smoothstep(-1.0, 0.9, boxUv.y)) + * (1.0 - 0.85 * smoothstep(0.035, 0.0, abs(boxUv.x)) - 0.85 * smoothstep(0.05, 0.0, abs(boxUv.y))); + let up = smoothstep(-0.05, 0.25, r.z); + let behind = smoothstep(0.9, 0.6, abs(sky.x)) * smoothstep(0.5, 0.3, abs(sky.y + 1.1)); + let ceiling = vec3(0.06, 0.06, 0.065) + vec3(1.0, 0.98, 0.95) * (box * 8.0 + behind * 1.1); + let counter = photo(c + r.xy * 0.05) * 0.38; + // Tall windows to either side: every cone flank facing one catches a + // long vertical streak. + let sill = smoothstep(-0.1, 0.05, r.z) * smoothstep(0.75, 0.45, r.z); + let windows = smoothstep(0.55, 0.72, -r.x) * 2.6 + smoothstep(0.62, 0.8, r.x) * 1.1; + return mix(counter, ceiling, up) + vec3(0.97, 0.98, 1.0) * windows * sill; +} + +fn glints(n: vec3, v: vec3) -> f32 { + // Two low windows, the softbox and a lamp behind the viewer; each lands a + // streak on the flank of every spike that faces it. + let l0 = normalize(vec3(-0.66, 0.66, 0.34)); + let l1 = normalize(vec3(0.85, 0.3, 0.4)); + let l2 = normalize(vec3(-0.12, -0.3, 1.0)); + let s0 = pow(clamp(dot(n, normalize(l0 + v)), 0.0, 1.0), 320.0); + let s1 = pow(clamp(dot(n, normalize(l1 + v)), 0.0, 1.0), 60.0); + let s2 = pow(clamp(dot(n, normalize(l2 + v)), 0.0, 1.0), 160.0); + let l3 = normalize(vec3(0.12, -0.85, 0.5)); + let s3 = pow(clamp(dot(n, normalize(l3 + v)), 0.0, 1.0), 380.0); + return min(s0 * 4.0 + s1 * 0.8 + s2 * 1.6 + s3 * 3.5, 4.0); +} + +// The camera looks down at the counter from the bottom of the screen at an +// angle, so a point raised by z shows up OBLIQUE * z higher on screen. March +// the view ray down through the height field to the first surface it meets. +const OBLIQUE = 0.9; +// Steps scale with the spikes: a flat puddle needs only a few. +const MARCH_STEPS_MIN = 8; +const MARCH_STEPS_MAX = 40; + +fn march(c: vec2, hMax: f32, s: f32) -> vec3 { + let steps = MARCH_STEPS_MIN + i32(f32(MARCH_STEPS_MAX - MARCH_STEPS_MIN) * clamp(s * 1.5, 0.0, 1.0)); + var zPrev = hMax; + for (var i = 1; i <= steps; i = i + 1) { + let z = hMax * (1.0 - f32(i) / f32(steps)); + let g = c - vec2(0.0, OBLIQUE * z); + if (fluidAt(g).h >= z) { + var lo = z; + var hi = zPrev; + for (var j = 0; j < 6; j = j + 1) { + let mid = 0.5 * (lo + hi); + let gm = c - vec2(0.0, OBLIQUE * mid); + if (fluidAt(gm).h >= mid) { + lo = mid; + } else { + hi = mid; + } + } + return vec3(c - vec2(0.0, OBLIQUE * lo), 1.0); + } + zPrev = z; + } + return vec3(c, 0.0); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let c = (uv - 0.5) * vec2(u.resolution.z, 1.0); + let e = 1.0 / u.resolution.y; + let s = max(u.live.z, 0.0); + + let hMax = LENS_H + (SPIKE_H * 1.35 + 0.025) * s + 0.002; + let near = fluidAt(c).sd < 0.01 || fluidAt(c - vec2(0.0, OBLIQUE * hMax)).sd < 0.01; + var hit = vec3(c, 0.0); + if (near) { + hit = march(c, hMax, s); + } + + // Surface: the photo, with a soft contact shadow hugging the puddle and + // the spikes casting a little toward the lower right. + var base = photo(c); + let f0 = fluidAt(c); + let shadowSd = fluidAt(c + vec2(-0.012, 0.016)).sd; + let contact = exp(-max(f0.sd, 0.0) / 0.01); + let dropShadow = exp(-max(shadowSd, 0.0) / 0.03) * 0.25 * clamp(s, 0.0, 1.0); + base = base * (1.0 - 0.45 * contact - dropShadow); + + var col = base; + if (hit.z > 0.5) { + let g = hit.xy; + let h0 = fluidAt(g).h; + let hx = fluidAt(g + vec2(e, 0.0)).h; + let hy = fluidAt(g + vec2(0.0, e)).h; + let n = normalize(vec3(-(hx - h0) / e, -(hy - h0) / e, 1.0)); + let v = normalize(vec3(g.x * 0.25, -0.67, 0.74)); + let r = reflect(-v, n); + let ndv = clamp(dot(n, v), 0.0, 1.0); + let k = 1.0 - ndv; + let fres = 0.09 + 0.91 * k * k * k; + let refl = env(r, g); + let glint = glints(n, v); + col = vec3(0.010, 0.010, 0.013) + refl * fres + vec3(0.95, 0.96, 1.0) * glint * (0.25 + fres); + // Soften the silhouette against the counter where the lens is thin. + let edge = smoothstep(0.0, 0.0025, -fluidAt(g).sd); + col = mix(base, col, edge); + } + col = col / (1.0 + col * 0.15); + + let vig = 1.0 - 0.25 * dot(uv - 0.5, uv - 0.5); + col = col * vig + (hash21(uv * u.resolution.xy) - 0.5) / 255.0; + return vec4(col, 1.0); +}`; diff --git a/example/src/components/flashlight/TorchIsland.tsx b/example/src/components/flashlight/TorchIsland.tsx new file mode 100644 index 0000000..ad976a5 --- /dev/null +++ b/example/src/components/flashlight/TorchIsland.tsx @@ -0,0 +1,292 @@ +import { useEffect } from 'react'; +import { Image, Pressable, StyleSheet } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +const GLYPH = require('../../../assets/flashlight/torch-glyph.png'); + +/** The hardware Dynamic Island cut-out, measured on the iPhone 18 Pro Max. */ +export const ISLAND = { width: 95, height: 37, top: 14 }; +/** The expanded torch control, scaled from the iOS 18 screenshots. */ +export const PANEL = { width: 220, height: 318, top: 8, radius: 48 }; +const COMPACT_WIDTH = ISLAND.width + 74; +const APEX_Y = 226; +const GLYPH_SIZE = { width: 26, height: 46 }; + +export type IslandMode = 'idle' | 'compact' | 'expanded'; + +const SPRING = { damping: 17, stiffness: 190, mass: 0.9 }; + +function layoutFor(mode: IslandMode) { + if (mode === 'expanded') { + return { ...PANEL }; + } + return { + width: mode === 'compact' ? COMPACT_WIDTH : ISLAND.width, + height: ISLAND.height, + top: ISLAND.top, + radius: ISLAND.height / 2, + }; +} + +type TorchIslandProps = { + mode: IslandMode; + isOn: boolean; + /** `u.live = (beam 0..1, intensity 0..1, power 0..1, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; + onExpand: () => void; + onToggle: () => void; + /** Drag deltas in pt since the last call. */ + onAdjust: (dx: number, dy: number) => void; +}; + +/** + * The iOS 18 flashlight Dynamic Island: a plain island while the torch is + * off, a compact pill with the torch glyph once it is on, and the expanded + * control (a top-down torch whose beam, solid arc and sun mark follow the + * beam width and intensity). Drag across it to shape the beam, tap it to + * switch the light. + */ +export default function TorchIsland({ + mode, + isOn, + paramsSynchronizable, + onExpand, + onToggle, + onAdjust, +}: TorchIslandProps) { + const start = layoutFor(mode); + const width = useSharedValue(start.width); + const height = useSharedValue(start.height); + const top = useSharedValue(start.top); + const radius = useSharedValue(start.radius); + const panelContent = useSharedValue(mode === 'expanded' ? 1 : 0); + const compactContent = useSharedValue(mode === 'compact' ? 1 : 0); + + useEffect(() => { + const next = layoutFor(mode); + width.value = withSpring(next.width, SPRING); + height.value = withSpring(next.height, SPRING); + top.value = withSpring(next.top, SPRING); + radius.value = withSpring(next.radius, SPRING); + panelContent.value = + mode === 'expanded' + ? withDelay(90, withTiming(1, { duration: 220 })) + : withTiming(0, { duration: 90 }); + compactContent.value = + mode === 'compact' + ? withDelay(160, withTiming(1, { duration: 180 })) + : withTiming(0, { duration: 80 }); + }, [mode, width, height, top, radius, panelContent, compactContent]); + + const shellStyle = useAnimatedStyle(() => ({ + width: width.value, + height: height.value, + top: top.value, + borderRadius: radius.value, + marginLeft: -width.value / 2, + })); + const panelStyle = useAnimatedStyle(() => ({ + opacity: panelContent.value, + transform: [{ scale: 0.85 + 0.15 * panelContent.value }], + })); + const compactStyle = useAnimatedStyle(() => ({ + opacity: compactContent.value, + })); + + const tap = Gesture.Tap() + .runOnJS(true) + .onEnd(() => { + if (mode === 'expanded') { + onToggle(); + } else if (mode === 'compact') { + onExpand(); + } + }); + const pan = Gesture.Pan() + .runOnJS(true) + .minDistance(3) + .onChange((e) => { + if (mode === 'expanded') onAdjust(e.changeX, e.changeY); + }); + const gesture = Gesture.Race(pan, tap); + + return ( + <> + {mode === 'compact' && ( + + )} + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + // Touches on the status bar strip can be claimed by the system, so the + // compact pill also answers taps just below the hardware island. + compactReach: { + position: 'absolute', + top: ISLAND.top + ISLAND.height - 6, + left: '50%', + marginLeft: -COMPACT_WIDTH / 2, + width: COMPACT_WIDTH, + height: 34, + zIndex: 49, + }, + shell: { + position: 'absolute', + left: '50%', + backgroundColor: '#000', + overflow: 'hidden', + zIndex: 50, + }, + panel: { + position: 'absolute', + top: 0, + left: '50%', + marginLeft: -PANEL.width / 2, + width: PANEL.width, + height: PANEL.height, + }, + glyph: { + position: 'absolute', + left: (PANEL.width - GLYPH_SIZE.width) / 2, + top: APEX_Y + 2, + width: GLYPH_SIZE.width, + height: GLYPH_SIZE.height, + }, + glyphOn: { + tintColor: '#e6e4f7', + }, + glyphOff: { + tintColor: '#5d5d66', + }, + compactGlyphWrap: { + position: 'absolute', + left: 15, + top: 0, + bottom: 0, + justifyContent: 'center', + }, + compactGlyph: { + width: 11, + height: 20, + tintColor: '#e6e4f7', + }, +}); + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +const MAX_HALF: f32 = 0.74; +const MIN_HALF: f32 = 0.05; +const DOT_GAP: f32 = 5.2; + +fn spectrum(x: f32) -> vec3 { + return 0.5 + 0.5 * cos(6.2831 * (vec3(x) + vec3(0.0, 0.33, 0.67))); +} + +// Point on the arc of radius r at angle a from straight up, panel pt y-down. +fn onArc(apex: vec2, r: f32, a: f32) -> vec2 { + return apex + vec2(sin(a), -cos(a)) * r; +} + +fn sunMark(q: vec2, c: vec2) -> f32 { + let v = q - c; + let d = length(v); + let disc = 1.0 - smoothstep(2.3, 3.0, d); + let a = atan2(v.y, v.x); + let spoke = smoothstep(0.75, 0.95, cos(a * 8.0)); + let ray = spoke * smoothstep(3.9, 4.4, d) * (1.0 - smoothstep(5.6, 6.2, d)); + return max(disc, ray); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let size = u.params0.xy; + let q = vec2(uv.x, 1.0 - uv.y) * size; + let apex = vec2(size.x * 0.5, u.params0.z); + + let beam = clamp(u.live.x, 0.0, 1.0); + let level = clamp(u.live.y, 0.0, 1.0); + let power = clamp(u.live.z, 0.0, 1.0); + + let v = vec2(q.x - apex.x, apex.y - q.y); + let dist = length(v); + let ang = atan2(v.x, v.y); + let a = abs(ang); + let halfA = mix(MIN_HALF, MAX_HALF, beam); + let reach = mix(72.0, 128.0, level) * mix(1.08, 0.94, beam); + let gain = (0.3 + 0.7 * level) * mix(1.5, 1.0, beam); + + let cone = smoothstep(halfA + 0.05, halfA * 0.6, a) * step(0.0, v.y); + let along = pow(smoothstep(reach * 1.04, reach * 0.05, dist), 0.8); + let core = exp(-dist * dist / 180.0); + let body = cone * along * 0.85 + core * 0.95; + let edge = exp(-pow((a - halfA * 0.86) / (0.03 + 0.12 * halfA), 2.0)) + * along * step(0.0, v.y) * 0.22; + let tint = spectrum(a / max(halfA, 0.01) * 0.55 + dist / 150.0); + + var rgb = vec3(0.96, 0.96, 1.0) * body * gain + tint * edge * gain; + var alpha = clamp((body + edge * 0.5) * gain, 0.0, 1.0); + rgb = rgb * power; + alpha = alpha * power; + + let ring = 1.0 - smoothstep(0.55, 1.35, abs(dist - reach)); + let solid = ring * (1.0 - smoothstep(halfA, halfA + 0.02, a)) * step(0.0, v.y); + let s = ang * reach; + let sc = (floor(s / DOT_GAP) + 0.5) * DOT_GAP; + let dotC = onArc(apex, reach, sc / reach); + let dotted = (1.0 - smoothstep(0.55, 1.15, length(q - dotC))) + * step(halfA + 0.03, a) * step(a, MAX_HALF + 0.06); + let sun = sunMark(q, onArc(apex, reach, max(halfA, 0.1) + 0.17)); + let marks = solid * 0.85 + dotted * 0.4 + sun * 0.75; + let markAlpha = marks * mix(0.35, 1.0, power); + + rgb = rgb + vec3(1.0) * markAlpha * (1.0 - alpha); + alpha = alpha + markAlpha * (1.0 - alpha); + return vec4(min(rgb, vec3(1.0)), clamp(alpha, 0.0, 1.0)); +} +`; diff --git a/example/src/components/flashlight/TorchScene.tsx b/example/src/components/flashlight/TorchScene.tsx new file mode 100644 index 0000000..0500415 --- /dev/null +++ b/example/src/components/flashlight/TorchScene.tsx @@ -0,0 +1,110 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +type TorchSceneProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'params' +> & { + /** `u.live = (spotX pt, spotY pt, beam 0 narrow..1 wide, light 0..1)`. */ + paramsSynchronizable: ParamsSynchronizable; + /** Width / height of the photo, for cover-fit sampling. */ + photoAspect: number; +}; + +/** + * A dark photo lit by a phone torch: moonlight-level ambient everywhere, and + * a warm-white spot whose size and brightness follow the beam (a narrow beam + * packs the same light into a smaller, hotter disc). The spot has a hot core, + * a flat flood with a soft rim, faint reflector rings and a hint of colour + * fringing at its edge. + */ +export default function TorchScene({ + paramsSynchronizable, + photoAspect, + ...rest +}: TorchSceneProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const WARM: vec3 = vec3(1.0, 0.93, 0.8); +const AMBIENT: vec3 = vec3(0.002, 0.0026, 0.0045); + +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 coverUv(uv: vec2) -> vec2 { + let screenAspect = u.resolution.z; + let photoAspect = u.params0.x; + var t = vec2(uv.x, 1.0 - uv.y); + if (screenAspect < photoAspect) { + t.x = 0.5 + (t.x - 0.5) * screenAspect / photoAspect; + } else { + t.y = 0.5 + (t.y - 0.5) * photoAspect / screenAspect; + } + return t; +} + +// Radial light profile, r in spot radii. +fn profile(r: f32) -> f32 { + let core = exp(-r * r * 5.0) * 0.9; + let flood = (1.0 - smoothstep(0.1, 1.08, r)) * 0.5; + let rings = 0.035 * exp(-pow((r - 0.9) / 0.045, 2.0)) + + 0.02 * exp(-pow((r - 0.62) / 0.05, 2.0)); + let spill = 0.02 * exp(-r * 1.6); + return core + flood + rings + spill; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let pt = vec2(uv.x, 1.0 - uv.y) * u.resolution.xy / u.resolution.w; + let photo = textureSampleLevel(tex, samp, coverUv(uv), 0.0).rgb; + let raw = pow(photo, vec3(2.2)); + let albedo = raw / (vec3(1.0) + raw) * 1.7; + + let beam = clamp(u.live.z, 0.0, 1.0); + let light = clamp(u.live.w, 0.0, 1.0); + let radius = mix(58.0, 250.0, beam); + let d = (pt - u.live.xy) / radius; + let r = length(d * vec2(1.0, 1.08)); + let gain = light * mix(2.4, 0.95, beam); + let lit = vec3( + profile(r * 0.985), + profile(r), + profile(r * 1.02) + ) * gain * WARM; + + var col = albedo * (AMBIENT + lit * 1.05); + col = vec3(1.0) - exp(-col * 1.25); + col = pow(col, vec3(1.0 / 2.2)); + col = col + (hash21(uv * u.resolution.xy + fract(u.time.x) * 91.0) - 0.5) / 255.0; + return vec4(col, 1.0); +} +`; diff --git a/example/src/components/lavalamp/LavaLamp.tsx b/example/src/components/lavalamp/LavaLamp.tsx new file mode 100644 index 0000000..b64544e --- /dev/null +++ b/example/src/components/lavalamp/LavaLamp.tsx @@ -0,0 +1,196 @@ +import type { ImageSourcePropType, ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +type Props = ViewProps & { + /** The lamp photo; the shader knows where its glass is. */ + photo: ImageSourcePropType; + /** `[scaleX, scaleY]` mapping screen uv onto the photo, cover-fit. */ + coverScale: [number, number]; + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A photographed lava lamp with live wax. + * + * The cap and the base are the photo, the room around it dimmed to evening + * and warmed by the lamp's own glow. The glass is drawn: a tapered column of + * amber liquid lit by the bulb from below, the simulated wax blobs fused with + * a smooth minimum (thin edges glow orange, thick cores go deep red), a pool + * of wax on the floor, and the vertical reflections of a curved glass wall. + */ +export default function LavaLamp({ + photo, + coverScale, + paramsSynchronizable, + ...viewProps +}: Props) { + return ( + + ); +} + +const LAVA_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; + +// Where the glass is in the photo, pixels. +const IMG = vec2(1280.0, 1795.0); +const GLASS_TOP = 394.0; +const GLASS_FLOOR = 1056.0; +const GLASS_H = 662.0; +const BLOBS = 7; +// The base's top lip, an ellipse seen from slightly above; the glass carries +// on down behind it. +const RIM_X = 647.0; +const RIM_TOP = 1040.0; +const RIM_A = 136.0; +const RIM_B = 45.0; + +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 glassHalfWidth(y: f32) -> f32 { + let t = clamp(1.0 - y / GLASS_H, 0.0, 1.0); + let side = 86.0 + 46.0 * (1.0 - (1.0 - t) * (1.0 - t)); + // The bottle rounds into its bottom below this height, behind the base. + let k = max(14.0 - y, 0.0); + return side - 60.0 + sqrt(max(3600.0 - k * k, 0.0)); +} + +// Photo-pixel y of the base's front lip at photo x. +fn rimY(x: f32) -> f32 { + let q = clamp((x - RIM_X) / RIM_A, -1.0, 1.0); + return RIM_TOP + RIM_B * sqrt(1.0 - q * q); +} + +fn glassCenterX(py: f32) -> f32 { + return 632.0 + (py - GLASS_TOP) * (15.0 / GLASS_H); +} + +fn sq(x: f32) -> f32 { + return x * x; +} + +fn smin(a: f32, b: f32, k: f32) -> f32 { + let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} + +// Signed distance to the wax, photo pixels: blobs and the floor pool, fused +// with a smooth minimum so they only neck together when they nearly touch. +// The pool is a dome: wax does not wet glass, so it rounds down to the walls. +fn waxSd(p: vec2, across: f32, phase: f32) -> f32 { + let heave = 1.0 + 0.06 * sin(p.x * 0.045 + phase * 2.1) + 0.04 * sin(p.x * 0.09 - phase * 3.3); + let dome = max(1.0 - across * across, 0.0); + var d = p.y - (-18.0 + 48.0 * dome * heave); + for (var i = 0; i < BLOBS; i = i + 1) { + let b = u.liveData[i]; + let q = p - b.xy; + let stretched = vec2(q.x * (1.0 + b.w * 0.3), q.y / (1.0 + b.w)); + d = smin(d, length(stretched) - b.z, 26.0); + } + return d; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let suv = vec2(uv.x, 1.0 - uv.y); + let iuv = (suv - 0.5) * u.params0.xy + 0.5; + let px = iuv * IMG; + let pxPerDevice = u.params0.x * IMG.x / u.resolution.x; + let phase = u.live.x; + + let y = GLASS_FLOOR - px.y; + let x = px.x - glassCenterX(px.y); + let hw = glassHalfWidth(y); + let across = x / hw; + let aa = 1.5 * pxPerDevice; + let lip = rimY(px.x); + let aboveRim = smoothstep(-aa, aa, lip - px.y); + let inGlass = smoothstep(hw + aa, hw - aa, abs(x)) + * aboveRim * smoothstep(GLASS_H + aa, GLASS_H - aa, y); + // Inside the base's mouth but outside the rounded bottom of the bottle. + let inMouth = smoothstep(RIM_A + aa, RIM_A - aa, abs(px.x - RIM_X)) + * aboveRim * smoothstep(GLASS_FLOOR - 40.0, GLASS_FLOOR - 20.0, px.y); + + // How much bulb light reaches this height, and how far off-axis. + let h = clamp(y / GLASS_H, 0.0, 1.0); + let heat = exp(-max(y + 30.0, 0.0) / 45.0); + let bulb = (1.2 * exp(-h * 2.6) + 0.48 + 0.35 * heat) * (1.0 - 0.25 * min(across * across, 1.0)); + + // The room: the photo at dusk, warmed by the lamp. + let photo = textureSampleLevel(tex, samp, iuv, 0.0).rgb; + let lampMid = vec2(glassCenterX(GLASS_FLOOR - 240.0), GLASS_FLOOR - 240.0); + let fromLamp = (px - lampMid) / vec2(420.0, 620.0); + let spill = exp(-dot(fromLamp, fromLamp) * 1.6); + var room = photo * vec3(0.24, 0.2, 0.17); + room = room + photo * vec3(1.0, 0.55, 0.22) * spill * 0.75; + room = room + vec3(0.32, 0.12, 0.03) * spill * 0.35; + // The base's lip, lit from inside by the bulb. + let lipX = smoothstep(RIM_A, RIM_A * 0.6, abs(px.x - RIM_X)); + let below = px.y - lip; + room = room + vec3(1.0, 0.5, 0.14) * lipX * exp(-abs(below) / 3.0) * step(0.0, below) * 0.45; + room = room + vec3(0.6, 0.22, 0.05) * lipX * exp(-max(below, 0.0) / 22.0) * step(0.0, below) * 0.18; + let mouth = vec3(0.3, 0.1, 0.02) * (0.5 + 0.5 * heat); + room = mix(room, mouth, inMouth); + + // The glass. The curved wall squeezes what is near its edges. + let bend = across * (0.84 + 0.16 * across * across); + let p = vec2(bend * hw, y); + let sd = waxSd(p, across, phase); + let wax = smoothstep(aa, -aa, sd); + let up = waxSd(p + vec2(0.0, 6.0), across, phase); + let down = waxSd(p - vec2(0.0, 6.0), across, phase); + // How much this bit of wax faces the bulb (its surface normal points down). + let facesBulb = clamp((up - down) / 12.0, 0.0, 1.0); + + // Translucent liquid: the bulb's light, tinted, strongest up the axis. + let axis = exp(-across * across * 2.2); + let liquid = vec3(1.0, 0.66, 0.2) * (0.16 + bulb * (0.32 + 0.22 * axis)); + + // Translucent wax: light scatters through it, so thin edges and necks glow + // bright orange while the thick core deepens to red; the side facing the + // bulb burns hottest. + let depth = clamp(-sd / 38.0, 0.0, 1.0); + let edgeGlow = exp(min(sd, 0.0) / 9.0); + let core = vec3(0.92, 0.16, 0.03); + let thin = vec3(1.0, 0.42, 0.05); + var waxCol = mix(thin, core, depth * (2.0 - depth) * 0.85) * (0.45 + bulb * 0.7); + waxCol = waxCol + vec3(1.0, 0.55, 0.1) * edgeGlow * bulb * 0.35; + waxCol = waxCol + vec3(1.0, 0.78, 0.32) * facesBulb * smoothstep(-22.0, 0.0, sd) * bulb * 0.75; + var glass = mix(liquid, waxCol, wax); + // Light leaking out of the wax tints the liquid around it. + glass = glass + vec3(0.9, 0.3, 0.05) * smoothstep(34.0, 0.0, sd) * (1.0 - wax) * bulb * 0.22; + // Wall: darker, richer toward the rim, with two tall window reflections. + let rim = across * across; + glass = glass * (1.0 - 0.45 * rim * rim * rim); + let streakL = exp(-sq((across + 0.66) / 0.045)) * 0.38 + exp(-sq((across + 0.5) / 0.02)) * 0.12; + let streakR = exp(-sq((across - 0.58) / 0.1)) * 0.12; + glass = glass + vec3(1.0, 0.95, 0.88) * (streakL + streakR) * (0.6 + 0.4 * h); + + var col = mix(room, glass, inGlass); + col = col / (1.0 + col * 0.25); + col = col + (hash21(uv * u.resolution.xy) - 0.5) / 255.0; + return vec4(col, 1.0); +}`; diff --git a/example/src/components/namedrop/ContactPoster.tsx b/example/src/components/namedrop/ContactPoster.tsx new file mode 100644 index 0000000..250ac07 --- /dev/null +++ b/example/src/components/namedrop/ContactPoster.tsx @@ -0,0 +1,228 @@ +import { forwardRef, type ComponentRef } from 'react'; +import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; + +/** + * Photo: "Smiling senior woman with curly hair portrait" by Shixart1985, + * CC BY 2.0, via Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Smiling_senior_woman_with_curly_hair_portrait.jpg), + * cropped to the face. + */ +const AVATAR = require('../../../assets/namedrop/avatar.jpg'); + +export type PosterHandle = ComponentRef; + +export type ExchangeChoice = 'share' | 'receive' | null; + +type ContactPosterProps = { + width: number; + height: number; + choice: ExchangeChoice; + onChoose: (choice: 'share' | 'receive') => void; + onAvatarLoad?: () => void; +}; + +const GRADIENT_STEPS = 24; +const TOP = [198, 192, 211]; +const BOTTOM = [160, 151, 172]; + +function gradientColor(i: number) { + const k = i / (GRADIENT_STEPS - 1); + const [r, g, b] = TOP.map((c, j) => Math.round(c + (BOTTOM[j]! - c) * k)); + return `rgb(${r},${g},${b})`; +} + +const GRADIENT = Array.from({ length: GRADIENT_STEPS }, (_, i) => + gradientColor(i) +); + +/** The "Share Your Contact" screen NameDrop shows full screen. */ +const ContactPoster = forwardRef( + function ContactPoster( + { width, height, choice, onChoose, onAvatarLoad }, + ref + ) { + const avatar = Math.round(width * 0.59); + return ( + + + {GRADIENT.map((color) => ( + + ))} + + + Share Your Contact + + + + + + Maren + + maren@holloway.studio + + i + + + + + {choice === null ? ( + + onChoose('receive')} + /> + onChoose('share')} /> + + ) : ( + + {choice === 'share' ? 'Contacts exchanged' : 'Contact received'} + + )} + + + ); + } +); + +export default ContactPoster; + +function PillButton({ + label, + onPress, +}: { + label: string; + onPress: () => void; +}) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + {label} + + ); +} + +const styles = StyleSheet.create({ + band: { + flex: 1, + }, + title: { + position: 'absolute', + left: 0, + right: 0, + textAlign: 'center', + color: '#fff', + fontSize: 19, + fontWeight: '600', + letterSpacing: -0.2, + }, + avatarRing: { + position: 'absolute', + overflow: 'hidden', + backgroundColor: '#f2e6f0', + }, + avatar: { + width: '100%', + height: '100%', + }, + identity: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + name: { + color: '#fff', + fontSize: 64, + lineHeight: 72, + fontWeight: '800', + letterSpacing: -0.8, + }, + emailRow: { + marginTop: 14, + flexDirection: 'row', + alignItems: 'center', + gap: 7, + }, + email: { + color: '#fff', + fontSize: 18, + fontWeight: '500', + }, + info: { + width: 15, + height: 15, + borderRadius: 7.5, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + infoGlyph: { + color: '#a79fb5', + fontSize: 11, + lineHeight: 13, + fontWeight: '800', + }, + actions: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + buttons: { + flexDirection: 'row', + gap: 12, + paddingHorizontal: 50, + alignSelf: 'stretch', + }, + button: { + flex: 1, + height: 52, + borderRadius: 26, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + pressed: { + backgroundColor: '#e6e3ec', + }, + buttonText: { + color: '#000', + fontSize: 17, + fontWeight: '500', + }, + status: { + height: 52, + lineHeight: 52, + color: '#fff', + fontSize: 17, + fontWeight: '600', + }, +}); diff --git a/example/src/components/namedrop/NameDropWave.tsx b/example/src/components/namedrop/NameDropWave.tsx new file mode 100644 index 0000000..5b95f7d --- /dev/null +++ b/example/src/components/namedrop/NameDropWave.tsx @@ -0,0 +1,169 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** + * The home screen photo next to two pre-blurred copies of itself, so the + * shader can blend any blur strength from three samples. + */ +export const HOME_ATLAS = require('../../../assets/namedrop/home-atlas.jpg'); +/** The Dynamic Island painted into the home screen photo, in uv (y-down). */ +export const ISLAND_UV = { x: 0.5, y: 0.0376, w: 0.312, h: 0.0408 }; + +type NameDropWaveProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'params' +> & { + /** `u.live.x` = seconds since the phones touched. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The home screen as NameDrop bends it: a coloured band of light climbs from + * the bottom edge and pools under the Dynamic Island, blurring what it + * crosses, then a bright liquid wave bursts out of the island and the home + * screen sinks into a dark blur behind the incoming poster. + */ +export default function NameDropWave({ + paramsSynchronizable, + ...rest +}: NameDropWaveProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +fn cell(k: f32, uv: vec2) -> vec3 { + let g = 0.002; + let c = clamp(uv, vec2(g), vec2(1.0 - g)); + return textureSampleLevel(tex, samp, vec2((k + c.x) / 3.0, c.y), 0.0).rgb; +} + +// level 0 is sharp, 1 a light blur, 2 a heavy one. +fn blurred(uv: vec2, level: f32) -> vec3 { + let l = clamp(level, 0.0, 2.0); + let lo = min(floor(l), 1.0); + return mix(cell(lo, uv), cell(lo + 1.0, uv), l - lo); +} + +fn soft(uv: vec2, level: f32, px: vec2) -> vec3 { + let r = px * 7.0 * clamp(level, 0.0, 1.0); + var c = blurred(uv, level) * 2.0; + c += blurred(uv + vec2(r.x, 0.0), level); + c += blurred(uv - vec2(r.x, 0.0), level); + c += blurred(uv + vec2(0.0, r.y), level); + c += blurred(uv - vec2(0.0, r.y), level); + return c / 6.0; +} + +fn glowColor(s: f32) -> vec3 { + let spread = select(0.62, 0.95, s > 0.0); + let envelope = exp(-s * s / (2.0 * spread * spread)); + let tone = mix(vec3(0.42, 0.64, 0.82), vec3(0.92, 0.64, 0.52), smoothstep(-0.9, 1.1, s)); + return tone * envelope; +} + +fn dither(p: vec2) -> f32 { + return (fract(52.9829 * fract(dot(p, vec2(0.06711, 0.00584)))) - 0.5) / 255.0; +} + +fn screen(a: vec3, b: vec3) -> vec3 { + return vec3(1.0) - (vec3(1.0) - a) * (vec3(1.0) - b); +} + +fn pill(p: vec2, size: vec2) -> f32 { + let r = size.y * 0.5; + let q = abs(p) - size * 0.5 + vec2(r); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let res = u.resolution.xy / u.resolution.w; + let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + let pt = uv * res; + let px = 1.0 / res; + let t = u.live.x; + + let island = u.params0.xy * res; + let islandSize = u.params0.zw * res; + let v = pt - island; + let d = length(v * vec2(0.78, 1.0)); + let dir = v / max(length(v), 0.001); + + let rise = res.y * exp(-3.2 * t); + let dx = pt.x - island.x; + let wobble = 12.0 * sin(pt.x * 0.011 + t * 1.3); + let width = 340.0 + 0.6 * rise; + let s = (pt.y - rise - dx * dx * 0.0006 - wobble) / width; + let settle = smoothstep(360.0, 60.0, rise); + let hold = 1.0 - smoothstep(1.2, 1.32, t); + let begin = smoothstep(0.0, 0.1, t); + let centred = mix(0.35, 1.0, exp(-dx * dx / 36000.0)); + let glow = glowColor(s) * mix(0.11, 0.3, settle) * centred * hold * begin; + + let topHaze = smoothstep(0.0, 0.34, t) * (1.0 - smoothstep(0.36, 0.62, t)) * exp(-pt.y / 95.0); + + let passed = smoothstep(-0.3, 0.1, s) * (1.0 - smoothstep(0.35, 0.8, s)); + let veil = passed * mix(0.4, 1.0, settle) * hold * begin; + let lens = exp(-dot(v, v) / 26000.0) * veil; + var sp = pt - v * lens * 0.18; + + let k = (t - 1.33) / 0.46; + let wave = step(0.0, k) * (1.0 - smoothstep(0.9, 1.25, k)); + let radius = mix(30.0, res.y * 1.3, pow(clamp(k, 0.0, 1.0), 1.2)); + let depth = (radius - d) / 210.0; + let inside = smoothstep(-0.28, 0.16, depth) * wave; + let wash = inside * exp(-max(depth, 0.0) / 2.0); + let rimX = (d - radius) / 95.0; + let rim = exp(-rimX * rimX) * wave; + sp -= dir * rimX * rim * 48.0; + sp -= dir * wash * 26.0; + + let back = smoothstep(1.5, 1.72, t); + let level = veil * 1.7 + rim * 0.5 + wash * 1.5 + back * 2.0; + + var col = soft(sp / res, level, px); + let luma = dot(col, vec3(0.3, 0.59, 0.11)); + col = mix(col, vec3(luma * 0.92, luma * 0.97, luma * 1.08), veil * 0.12); + col *= 1.0 - 0.3 * hold * begin * (1.0 - smoothstep(200.0, 620.0, pt.y)); + col = screen(col, min(glow, vec3(0.95))); + col = screen(col, vec3(0.59, 0.71, 0.94) * topHaze * 0.16); + let coreDistance = length(v * vec2(0.7, 1.0)); + let core = clamp(1.25 * exp(-coreDistance / 125.0), 0.0, 1.0) * smoothstep(0.05, 0.7, t) * hold; + col = screen(col, vec3(0.86, 0.93, 1.0) * core); + + let shade = exp(-pow((d - radius - 56.0) / 62.0, 2.0)) * wave * (1.0 - clamp(k * 2.0, 0.0, 1.0)); + col *= 1.0 - shade * 0.55; + + let waveTint = mix(vec3(0.70, 0.93, 1.0), mix(vec3(0.93, 0.95, 1.0), vec3(0.27, 0.50, 0.86), smoothstep(0.5, 2.0, depth)), smoothstep(0.0, 0.2, depth)); + col = mix(col, waveTint, inside * exp(-max(depth, 0.0) / 3.0) * 0.94); + col = mix(col, vec3(0.08, 0.16, 0.30), back * 0.5); + + let isl = smoothstep(0.0, 1.2, pill(pt - island, islandSize)); + let finished = col * isl + vec3(dither(pt * u.resolution.w)); + return vec4(clamp(finished, vec3(0.0), vec3(1.0)), 1.0); +} +`; diff --git a/example/src/components/namedrop/PosterReveal.tsx b/example/src/components/namedrop/PosterReveal.tsx new file mode 100644 index 0000000..a1c132b --- /dev/null +++ b/example/src/components/namedrop/PosterReveal.tsx @@ -0,0 +1,124 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; +import { ISLAND_UV } from './NameDropWave'; + +type PosterRevealProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'params' +> & { + /** `u.live.x` = seconds since the phones touched. */ + paramsSynchronizable: ParamsSynchronizable; + /** A full-screen snapshot of the contact poster. */ + snapshotUri: string; +}; + +/** + * The contact poster arriving: a blurred card swells out of the Dynamic + * Island on one critically damped spring, coming into focus as it grows to + * fill the display. + */ +export default function PosterReveal({ + paramsSynchronizable, + snapshotUri, + ...rest +}: PosterRevealProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const TAPS: i32 = 20; +const START: f32 = 1.57; +const STIFFNESS: f32 = 9.0; +const SPRING_END: f32 = 0.9939; + +fn ease(x: f32) -> f32 { + let c = clamp(x, 0.0, 1.0); + return 1.0 - (1.0 - c) * (1.0 - c) * (1.0 - c); +} + +fn rounded(p: vec2, halfSize: vec2, r: f32) -> f32 { + let q = abs(p) - halfSize + vec2(r); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +fn poster(uv: vec2) -> vec3 { + let c = clamp(uv, vec2(0.001), vec2(0.999)); + return textureSampleLevel(tex, samp, c, 0.0).rgb; +} + +fn defocus(uv: vec2, radius: vec2, spin: f32) -> vec3 { + if (radius.x < 0.0005) { + return poster(uv); + } + var c = vec3(0.0); + for (var i = 0; i < TAPS; i++) { + let f = f32(i) + 0.5; + let r = sqrt(f / f32(TAPS)); + let a = f * 2.39996 + spin; + c += poster(uv + vec2(cos(a), sin(a)) * r * radius); + } + return c / f32(TAPS); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let res = u.resolution.xy / u.resolution.w; + let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + let pt = uv * res; + let t = u.live.x; + + let island = u.params0.xy * res; + let islandSize = u.params0.zw * res; + + let x = max(t - START, 0.0); + let spring = 1.0 - (1.0 + STIFFNESS * x) * exp(-STIFFNESS * x); + let grow = clamp(spring / SPRING_END, 0.0, 1.0); + let size = res * mix(0.16, 1.0, grow); + let top = mix(island.y - islandSize.y * 0.5, 0.0, grow); + let cardMin = vec2((res.x - size.x) * 0.5, top); + let cardMax = cardMin + size; + let ext = (cardMax - cardMin) * 0.5; + let center = (cardMin + cardMax) * 0.5; + let radius = mix(56.0, 0.0, smoothstep(0.96, 1.0, grow)); + let sd = rounded(pt - center, ext, min(radius, min(ext.x, ext.y))); + let edge = 0.8 + 24.0 * (1.0 - smoothstep(0.08, 0.26, x)); + let fadeIn = smoothstep(0.0, 0.12, x) * step(START, t); + let coverage = (1.0 - smoothstep(-edge, edge, sd)) * fadeIn; + + if (coverage <= 0.0) { + return vec4(0.0); + } + + let local = (pt - cardMin) / max(cardMax - cardMin, vec2(1.0)); + let focus = 1.0 - smoothstep(0.05, 0.36, x); + let blurPt = 30.0 * focus; + let spin = fract(52.9829 * fract(dot(pt * u.resolution.w, vec2(0.06711, 0.00584)))) * 6.2832; + var col = defocus(local, vec2(blurPt) / res, spin); + return vec4(col * coverage, coverage); +} +`; diff --git a/example/src/components/polaroid/PolaroidCamera.tsx b/example/src/components/polaroid/PolaroidCamera.tsx new file mode 100644 index 0000000..c59a64a --- /dev/null +++ b/example/src/components/polaroid/PolaroidCamera.tsx @@ -0,0 +1,290 @@ +import { useImperativeHandle, useRef, useState, type Ref } from 'react'; +import { + Image, + Linking, + Pressable, + StyleSheet, + Text, + View, + type ImageSourcePropType, +} from 'react-native'; +import type { CameraView } from 'expo-camera'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +export type PolaroidCameraHandle = { + /** Null when no real camera is available or the capture failed. */ + takePhoto: () => Promise; + blink: () => void; +}; + +type ExpoCamera = typeof import('expo-camera'); + +type LiveCapture = Pick; + +/** Throws on a binary built before expo-camera was added. */ +function loadExpoCamera(): ExpoCamera | null { + try { + return require('expo-camera'); + } catch { + return null; + } +} + +const expoCamera = loadExpoCamera(); + +/** The classic stripe, top to bottom. */ +const STRIPE = ['#e2402c', '#f08a24', '#f6c73a', '#4aa564', '#2f7fc1']; +const WINDOW_SIZE = 164; +export const CAMERA_BODY_HEIGHT = 216; + +function CameraPrompt({ + canAsk, + onAsk, +}: { + canAsk: boolean; + onAsk: () => void; +}) { + return ( + + + {canAsk ? 'Use camera' : 'Open Settings'} + + + ); +} + +/** The live feed and the permission prompt, over the fallback photo. */ +function LiveViewfinder({ + ref, + expo, +}: { + ref: Ref; + expo: ExpoCamera; +}) { + const [permission, requestPermission] = expo.useCameraPermissions(); + const [ready, setReady] = useState(false); + const camera = useRef(null); + const allowed = permission?.granted === true; + + useImperativeHandle( + ref, + () => ({ + takePhoto: async () => { + if (!ready) { + return null; + } + const photo = await camera.current?.takePictureAsync({ + quality: 0.8, + shutterSound: false, + }); + return photo?.uri ?? null; + }, + }), + [ready] + ); + + return ( + <> + {allowed ? ( + setReady(true)} + /> + ) : null} + {permission && !allowed ? ( + + ) : null} + + ); +} + +/** + * The back of the camera: print slot along the top edge, a square viewfinder + * and the shutter. The window shows the live camera once allowed. Without + * permission or a working session (the simulator never gets one, and would + * return a generated placeholder image) it shows the bundled photo the next + * shot will print. + */ +export default function PolaroidCamera({ + ref, + fallbackPhoto, + bottomInset, + slotWidth, + onShutter, +}: { + ref: Ref; + fallbackPhoto: ImageSourcePropType; + bottomInset: number; + slotWidth: number; + onShutter: () => void; +}) { + const live = useRef(null); + const shade = useSharedValue(0); + + useImperativeHandle( + ref, + () => ({ + takePhoto: async () => (await live.current?.takePhoto()) ?? null, + blink: () => { + shade.value = withSequence( + withTiming(1, { duration: 40 }), + withTiming(0, { duration: 220 }) + ); + }, + }), + [shade] + ); + + const shadeStyle = useAnimatedStyle(() => ({ opacity: shade.value })); + + return ( + + + + {STRIPE.map((color) => ( + + ))} + + + + + + {expoCamera ? : null} + + + + + [ + styles.shutterRing, + pressed && styles.shutterPressed, + ]} + > + + + + ); +} + +const styles = StyleSheet.create({ + body: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + paddingTop: 26, + backgroundColor: '#191817', + borderTopLeftRadius: 26, + borderTopRightRadius: 26, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(255,255,255,0.22)', + shadowColor: '#000', + shadowOpacity: 0.6, + shadowRadius: 18, + shadowOffset: { width: 0, height: -6 }, + }, + slot: { + position: 'absolute', + top: 8, + height: 5, + borderRadius: 2.5, + backgroundColor: '#050505', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(255,255,255,0.14)', + }, + stripe: { + position: 'absolute', + left: 34, + top: 0, + bottom: 0, + width: 30, + flexDirection: 'row', + }, + band: { + flex: 1, + }, + bezel: { + padding: 6, + borderRadius: 14, + backgroundColor: '#0a0a0a', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(255,255,255,0.16)', + }, + window: { + width: WINDOW_SIZE, + height: WINDOW_SIZE, + borderRadius: 9, + overflow: 'hidden', + backgroundColor: '#000', + }, + shade: { + ...StyleSheet.absoluteFill, + backgroundColor: '#000', + }, + prompt: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingVertical: 11, + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.62)', + }, + promptText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + shutterRing: { + position: 'absolute', + right: 26, + top: 72, + width: 74, + height: 74, + borderRadius: 37, + backgroundColor: '#0d0d0d', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.18)', + }, + shutterPressed: { + transform: [{ scale: 0.94 }], + }, + shutter: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: '#e2402c', + borderWidth: 3, + borderColor: '#b52d1d', + }, +}); diff --git a/example/src/components/polaroid/PolaroidPrint.tsx b/example/src/components/polaroid/PolaroidPrint.tsx new file mode 100644 index 0000000..d74a420 --- /dev/null +++ b/example/src/components/polaroid/PolaroidPrint.tsx @@ -0,0 +1,159 @@ +import type { ImageSourcePropType } from 'react-native'; +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** An i-Type print: 88 x 107 mm, a 79 mm square image, 22 mm chin. */ +export const PRINT_ASPECT = 88 / 107; + +type PolaroidPrintProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'texture' +> & { + photo: ImageSourcePropType; + /** `u.live = (development 0..1, sway, seed, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A Polaroid print developing. + * + * The frame is paper: off-white, fibrous, with the image window pressed + * slightly into it. Inside the window the photo comes up out of the milky + * blue-grey opacifier the way the chemistry does it: shadows first, the dye + * layers in order (blue, then green, red last, so the early image is cold + * and cyan), contrast building as it goes. The reagent is spread from the + * chin, so the bottom of the image is a little ahead, and the spread is never + * perfectly even. The finished print is soft, warm and vignetted. + */ +export default function PolaroidPrint({ + photo, + paramsSynchronizable, + ...rest +}: PolaroidPrintProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const PRINT = vec2(88.0, 107.0); +const IMG_MIN = vec2(4.5, 6.0); +const IMG_SIZE = 79.0; + +fn hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash(i); + let b = hash(i + vec2(1.0, 0.0)); + let c = hash(i + vec2(0.0, 1.0)); + let d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var s = 0.0; + var amp = 0.5; + for (var i = 0; i < 3; i++) { + s += amp * vnoise(p); + p = p * 2.03 + vec2(17.0, 9.0); + amp *= 0.5; + } + return s; +} + +fn paper(mm: vec2) -> vec3 { + let fiber = vnoise(mm * vec2(14.0, 3.5)) * 0.5 + vnoise(mm * vec2(3.0, 11.0)) * 0.5; + let cloud = fbm(mm * 0.35); + var col = vec3(0.955, 0.948, 0.925) * (0.975 + 0.03 * cloud + 0.018 * fiber); + let edge = min(min(mm.x, PRINT.x - mm.x), min(mm.y, PRINT.y - mm.y)); + col *= 0.93 + 0.07 * smoothstep(0.0, 1.6, edge); + return col; +} + +fn develop(q: vec2, dev: f32, seed: f32) -> vec3 { + let photo = textureSampleLevel(tex, samp, q, 0.0).rgb; + let lum = dot(photo, vec3(0.299, 0.587, 0.114)); + + let mottle = fbm(q * 4.0 + vec2(seed * 13.0, seed * 7.0)) - 0.5; + let fromPod = q.y * 0.09; + let p = pow(dev, 1.5) * 1.2 - 0.06 + fromPod + mottle * 0.12 + (0.45 - lum) * 0.22; + + let pb = smoothstep(0.0, 0.55, p); + let pg = smoothstep(0.1, 0.72, p); + let pr = smoothstep(0.26, 0.98, p); + let contrast = mix(0.4, 1.0, smoothstep(0.15, 1.0, p)); + + var graded = photo * 0.9 + 0.05; + graded = vec3(lum) + (graded - vec3(lum)) * 0.88; + graded *= vec3(1.05, 1.0, 0.9); + let vd = length((q - 0.5) * vec2(1.0, 1.05)); + graded *= 1.0 - 0.38 * smoothstep(0.25, 0.78, vd); + let mid = vec3(0.42); + let target_col = mid + (graded - mid) * contrast; + + let base = vec3(0.44, 0.5, 0.5) + vec3(mottle * 0.04); + var col = vec3( + mix(base.r, target_col.r, pr), + mix(base.g, target_col.g, pg), + mix(base.b, target_col.b, pb) + ); + let grain = hash(floor(q * 900.0) + vec2(seed)) - 0.5; + col += grain * 0.025; + return clamp(col, vec3(0.0), vec3(1.0)); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let mm = vec2(uv.x, 1.0 - uv.y) * PRINT; + let dev = clamp(u.live.x, 0.0, 1.0); + let sway = u.live.y; + let seed = u.live.z; + + let rel = mm - IMG_MIN; + let inImg = rel.x >= 0.0 && rel.y >= 0.0 && rel.x <= IMG_SIZE && rel.y <= IMG_SIZE; + let dEdge = min(min(rel.x, IMG_SIZE - rel.x), min(rel.y, IMG_SIZE - rel.y)); + + var col = paper(mm); + let lip = smoothstep(0.0, 0.5, -dEdge) * (1.0 - smoothstep(0.5, 1.4, -dEdge)); + col *= 1.0 - 0.035 * lip; + + if (inImg) { + let q = rel / IMG_SIZE; + var img = develop(q, dev, seed); + img *= 0.8 + 0.2 * smoothstep(0.0, 0.9, dEdge); + let sheenPos = q.x * 0.7 + q.y * 0.3 - 0.35 + sway * 0.6; + let sheen = exp(-pow((sheenPos - 0.3) * 4.5, 2.0)); + img += vec3(0.05) * sheen; + col = img; + } + return vec4(col, 1.0); +} +`; diff --git a/example/src/components/polaroid/SquareCropper.tsx b/example/src/components/polaroid/SquareCropper.tsx new file mode 100644 index 0000000..f86c554 --- /dev/null +++ b/example/src/components/polaroid/SquareCropper.tsx @@ -0,0 +1,97 @@ +import { + useImperativeHandle, + useRef, + useState, + type ComponentRef, + type Ref, + type RefObject, +} from 'react'; +import { Image, StyleSheet, View } from 'react-native'; +import { captureRef } from 'react-native-view-shot'; + +export type SquareCropperHandle = { + crop: (uri: string) => Promise; +}; + +type Job = { + uri: string; + resolve: (uri: string) => void; + reject: (reason: unknown) => void; +}; + +/** Points; view-shot captures at screen scale, so about 720 px on a phone. */ +const SIZE = 240; + +async function captureSquare( + view: RefObject | null> +) { + const raw = await captureRef(view, { + format: 'jpg', + quality: 0.9, + result: 'tmpfile', + }); + return raw.startsWith('file://') ? raw : `file://${raw}`; +} + +/** + * Turns a full-size photo into a small centred square, the crop of an instant + * print and of the viewfinder. Eight full-size photos as GPU textures would + * cost hundreds of MB. Mount it behind something opaque: the image has to be + * on screen for view-shot to see it. + */ +export default function SquareCropper({ + ref, +}: { + ref: Ref; +}) { + const [job, setJob] = useState(null); + const view = useRef>(null); + + useImperativeHandle( + ref, + () => ({ + crop: (uri) => + new Promise((resolve, reject) => setJob({ uri, resolve, reject })), + }), + [] + ); + + if (!job) { + return null; + } + + const finish = () => { + requestAnimationFrame(() => { + captureSquare(view) + .then(job.resolve, job.reject) + .finally(() => setJob(null)); + }); + }; + + const fail = () => { + job.reject(new Error('photo failed to load')); + setJob(null); + }; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + square: { + position: 'absolute', + left: 0, + top: 0, + width: SIZE, + height: SIZE, + }, +}); diff --git a/example/src/components/polaroid/cameraSamples.ts b/example/src/components/polaroid/cameraSamples.ts new file mode 100644 index 0000000..b29151b --- /dev/null +++ b/example/src/components/polaroid/cameraSamples.ts @@ -0,0 +1,69 @@ +const CLICK_SECONDS = 0.06; +const WHIRR_SECONDS = 0.6; +const CLICK_PEAK = 0.16; +const WHIRR_PEAK = 0.05; +const GEAR_TEETH_PER_SECOND = 120; + +function noise(length: number) { + return Float32Array.from({ length }, () => Math.random() * 2 - 1); +} + +function lowpass(input: Float32Array, cutoff: number, sampleRate: number) { + const k = 1 - Math.exp((-2 * Math.PI * cutoff) / sampleRate); + const output = new Float32Array(input.length); + let state = 0; + input.forEach((sample, i) => { + state += k * (sample - state); + output[i] = state; + }); + return output; +} + +function highpass(input: Float32Array, cutoff: number, sampleRate: number) { + const low = lowpass(input, cutoff, sampleRate); + return input.map((sample, i) => sample - low[i]!); +} + +function bandNoise(length: number, lo: number, hi: number, rate: number) { + return highpass(lowpass(noise(length), hi, rate), lo, rate); +} + +function normalize(samples: Float32Array, peak: number) { + const max = samples.reduce((m, s) => Math.max(m, Math.abs(s)), 1e-6); + return samples.map((s) => (s / max) * peak); +} + +/** Two soft ticks of the blades opening and closing. */ +export function shutterClick(sampleRate: number) { + const length = Math.floor(sampleRate * CLICK_SECONDS); + const open = bandNoise(length, 2500, 9000, sampleRate); + const close = bandNoise(length, 900, 3500, sampleRate); + const closeAt = 0.022; + const mixed = open.map((sample, i) => { + const t = i / sampleRate; + const opening = sample * Math.exp(-t / 0.0025); + const closing = + t < closeAt ? 0 : close[i]! * 0.7 * Math.exp(-(t - closeAt) / 0.006); + const tail = 1 - t / CLICK_SECONDS; + return (opening + closing) * tail; + }); + return normalize(mixed, CLICK_PEAK); +} + +/** Airy gear rattle: uneven noise bursts with no pitch to drone or slide. */ +export function filmWhirr(sampleRate: number) { + const length = Math.floor(sampleRate * WHIRR_SECONDS); + const air = bandNoise(length, 1400, 5200, sampleRate); + const teeth = Float32Array.from( + { length: Math.ceil(WHIRR_SECONDS * GEAR_TEETH_PER_SECOND) + 1 }, + () => 0.55 + Math.random() * 0.45 + ); + const shaped = air.map((sample, i) => { + const t = i / sampleRate; + const tooth = teeth[Math.floor(t * GEAR_TEETH_PER_SECOND)]!; + const attack = Math.min(1, t / 0.02); + const fade = Math.max(0, Math.min(1, (WHIRR_SECONDS - t) / 0.5)); + return sample * tooth * attack * Math.sin((fade * Math.PI) / 2) ** 2; + }); + return normalize(shaped, WHIRR_PEAK); +} diff --git a/example/src/components/polaroid/cameraSound.ts b/example/src/components/polaroid/cameraSound.ts new file mode 100644 index 0000000..77a8e30 --- /dev/null +++ b/example/src/components/polaroid/cameraSound.ts @@ -0,0 +1,67 @@ +import { + AudioContext, + type AudioBuffer, + type AudioBufferSourceNode, + type GainNode, +} from 'react-native-audio-api'; +import { filmWhirr, shutterClick } from './cameraSamples'; + +type Sounds = { ac: AudioContext; click: AudioBuffer; whirr: AudioBuffer }; +type Voice = { source: AudioBufferSourceNode; level: GainNode }; + +const RELEASE_SECONDS = 0.04; + +let sounds: Sounds | null = null; +let voice: Voice | null = null; + +function toBuffer(ac: AudioContext, samples: Float32Array) { + const buffer = ac.createBuffer(1, samples.length, ac.sampleRate); + buffer.copyToChannel(samples, 0); + return buffer; +} + +function loadSounds(): Sounds { + if (!sounds) { + const ac = new AudioContext(); + sounds = { + ac, + click: toBuffer(ac, shutterClick(ac.sampleRate)), + whirr: toBuffer(ac, filmWhirr(ac.sampleRate)), + }; + } + return sounds; +} + +export function stopCameraSound() { + if (!voice || !sounds) { + return; + } + const now = sounds.ac.currentTime; + voice.level.gain.setTargetAtTime(0, now, RELEASE_SECONDS / 4); + voice.source.stop(now + RELEASE_SECONDS); + voice = null; +} + +function play(pick: (loaded: Sounds) => AudioBuffer) { + try { + stopCameraSound(); + const loaded = loadSounds(); + const source = loaded.ac.createBufferSource(); + source.buffer = pick(loaded); + const level = loaded.ac.createGain(); + source.connect(level); + level.connect(loaded.ac.destination); + source.start(); + voice = { source, level }; + } catch { + voice = null; + } +} + +export function playShutterClick() { + play(({ click }) => click); +} + +export function playFilmWhirr() { + play(({ whirr }) => whirr); +} diff --git a/example/src/components/scratchoff/Flakes.tsx b/example/src/components/scratchoff/Flakes.tsx new file mode 100644 index 0000000..447c4c8 --- /dev/null +++ b/example/src/components/scratchoff/Flakes.tsx @@ -0,0 +1,61 @@ +import { StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedStyle, + type SharedValue, +} from 'react-native-reanimated'; + +/** Floats per flake in the shared state: x, y, angle (deg), size, life 0..1. */ +export const FLAKE_STRIDE = 5; +export const FLAKE_COUNT = 48; + +function Flake({ + index, + state, +}: { + index: number; + state: SharedValue; +}) { + const style = useAnimatedStyle(() => { + const o = index * FLAKE_STRIDE; + const s = state.value; + const life = s[o + 4] ?? 0; + const size = s[o + 3] ?? 0; + const angle = s[o + 2] ?? 0; + const facing = Math.abs(Math.cos((angle * Math.PI) / 90)); + return { + opacity: life > 0 ? Math.min(1, life * 3) : 0, + width: size, + height: size * 0.7, + backgroundColor: `rgb(${150 + 90 * facing}, ${152 + 90 * facing}, ${158 + 90 * facing})`, + transform: [ + { translateX: s[o] ?? 0 }, + { translateY: s[o + 1] ?? 0 }, + { rotate: `${angle}deg` }, + ], + }; + }); + return ; +} + +/** + * Crumbs of latex flicked off by the finger: small silver chips that tumble + * (their shade flips as they turn) and fall away under gravity. + */ +export default function Flakes({ state }: { state: SharedValue }) { + return ( + + {Array.from({ length: FLAKE_COUNT }, (_, i) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + flake: { + position: 'absolute', + left: 0, + top: 0, + borderRadius: 1, + }, +}); diff --git a/example/src/components/scratchoff/ScratchCoating.tsx b/example/src/components/scratchoff/ScratchCoating.tsx new file mode 100644 index 0000000..ae17d5f --- /dev/null +++ b/example/src/components/scratchoff/ScratchCoating.tsx @@ -0,0 +1,317 @@ +import { useMemo } from 'react'; +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** The play area, in points. Three prize boxes in a row, a bonus strip. */ +export const PLAY_W = 340; +export const PLAY_H = 182; +export const AREAS = [ + { x: 0, y: 0, w: 104, h: 104 }, + { x: 118, y: 0, w: 104, h: 104 }, + { x: 236, y: 0, w: 104, h: 104 }, + { x: 0, y: 118, w: 340, h: 64 }, +] as const; +export const AREA_RADIUS = 10; + +/** Coverage grid over the play area: one float per cell in `u.liveData`. */ +export const GRID_X = 24; +export const GRID_Y = 15; +/** `u.liveData[TILT_SLOT].xy` carries the tilt, after the grid. */ +export const TILT_SLOT = (GRID_X * GRID_Y) / 4; +export const CHANNEL_LENGTH = 4 + (TILT_SLOT + 1) * 4; + +export type PrizeSymbol = 0 | 1 | 2 | 3; + +type ScratchCoatingProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'params' | 'transparent' +> & { + /** + * `u.live` = reveal sweep 0..1 for each area; `u.liveData` = the coverage + * grid (row-major, 0..1), then the tilt (-1..1) at `TILT_SLOT`. + */ + paramsSynchronizable: ParamsSynchronizable; + /** Symbol under each area: star, clover, diamond, heart. */ + symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol, PrizeSymbol]; +}; + +/** + * The silver latex over the prize areas, and the holographic symbols under it. + * + * Where the coverage grid says the finger has been, the latex is gone. The + * grid is coarse, so the edge is cut by two octaves of noise: scratched latex + * tears along its own grain into flaky, irregular edges and leaves specks + * behind. The remaining coat is metallic: fine grain, a printed lattice, a + * broad sheen and glitter that both move with tilt, a darker curled rim at + * the tear, and a shadow on the card just inside it. Under it each area has + * a hot-stamped holographic symbol: silver foil with fine diffraction grooves + * that throw saturated spectral bands racing across it with tilt, edged by a + * printed dark outline. An area's reveal sweep clears what is left in one + * diagonal pass with a line of sparkle. + */ +export default function ScratchCoating({ + paramsSynchronizable, + symbols, + ...rest +}: ScratchCoatingProps) { + const params = useMemo(() => [...symbols], [symbols]); + return ( + + ); +} + +function rect(i: number): string { + const a = AREAS[i]!; + return `vec4(${a.x}.0, ${a.y}.0, ${a.w}.0, ${a.h}.0)`; +} + +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; + +const PLAY = vec2(${PLAY_W}.0, ${PLAY_H}.0); +const GRID = vec2(${GRID_X}, ${GRID_Y}); +const RADIUS = ${AREA_RADIUS}.0; + +fn hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash(i); + let b = hash(i + vec2(1.0, 0.0)); + let c = hash(i + vec2(0.0, 1.0)); + let d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var s = 0.0; + var amp = 0.5; + for (var i = 0; i < 3; i++) { + s += amp * vnoise(p); + p = p * 2.1 + vec2(3.1, 7.7); + amp *= 0.5; + } + return s; +} + +fn cell(ix: i32, iy: i32) -> f32 { + let x = clamp(ix, 0, GRID.x - 1); + let y = clamp(iy, 0, GRID.y - 1); + let i = y * GRID.x + x; + return u.liveData[i / 4][i % 4]; +} + +fn coverage(pt: vec2) -> f32 { + let g = pt / PLAY * vec2(GRID) - 0.5; + let i = vec2(floor(g)); + let f = fract(g); + let a = cell(i.x, i.y); + let b = cell(i.x + 1, i.y); + let c = cell(i.x, i.y + 1); + let d = cell(i.x + 1, i.y + 1); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +fn sdRoundRect(p: vec2, r: vec4, rad: f32) -> f32 { + let c = r.xy + r.zw * 0.5; + let q = abs(p - c) - r.zw * 0.5 + rad; + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - rad; +} + +fn sdStar(p0: vec2) -> f32 { + let k1 = vec2(0.809016994, -0.587785252); + let k2 = vec2(-k1.x, k1.y); + var p = vec2(abs(p0.x), p0.y); + p -= 2.0 * max(dot(k1, p), 0.0) * k1; + p -= 2.0 * max(dot(k2, p), 0.0) * k2; + p = vec2(abs(p.x), p.y - 1.0); + let ba = 0.45 * vec2(-k1.y, k1.x) - vec2(0.0, 1.0); + let h = clamp(dot(p, ba) / dot(ba, ba), 0.0, 1.0); + return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y); +} + +fn sdClover(p: vec2) -> f32 { + let r = 0.36; + var d = length(p - vec2(0.0, 0.4)) - r; + d = min(d, length(p - vec2(0.4, 0.0)) - r); + d = min(d, length(p - vec2(-0.4, 0.0)) - r); + d = min(d, length(p - vec2(0.0, -0.4)) - r); + let stem = p - vec2(0.05, -0.55); + let h = clamp(dot(stem, vec2(0.32, -0.95)), 0.0, 0.5); + d = min(d, length(stem - vec2(0.32, -0.95) * h) - 0.07); + return d; +} + +fn sdDiamond(p: vec2) -> f32 { + let q = abs(p); + return (q.x * 0.95 + q.y * 0.68 - 0.75) * 0.82; +} + +fn dot2(v: vec2) -> f32 { + return dot(v, v); +} + +fn sdHeart(p0: vec2) -> f32 { + let p = vec2(abs(p0.x), p0.y * 1.05 + 0.62) / 1.25; + if (p.y + p.x > 1.0) { + return (sqrt(dot2(p - vec2(0.25, 0.75))) - 0.353553) * 1.25; + } + let d = sqrt(min(dot2(p - vec2(0.0, 1.0)), dot2(p - 0.5 * max(p.x + p.y, 0.0)))); + return d * sign(p.x - p.y) * 1.25; +} + +fn symbolSdf(kind: f32, q: vec2) -> f32 { + if (kind < 0.5) { + return sdStar(q * 1.05); + } + if (kind < 1.5) { + return sdClover(q); + } + if (kind < 2.5) { + return sdDiamond(q); + } + return sdHeart(q); +} + +fn areaRect(i: i32) -> vec4 { + switch (i) { + case 0: { return ${rect(0)}; } + case 1: { return ${rect(1)}; } + case 2: { return ${rect(2)}; } + default: { return ${rect(3)}; } + } +} + +fn symbolKind(i: i32) -> f32 { + return u.params0[i]; +} + +fn holoSymbol(pt: vec2, r: vec4, kind: f32, tilt: vec2) -> vec4 { + var c = r.xy + vec2(r.z * 0.5, r.w * 0.42); + var size = r.w * 0.27; + if (r.z > r.w * 2.0) { + c = r.xy + vec2(r.w * 0.62, r.w * 0.5); + size = r.w * 0.3; + } + let q = vec2(pt.x - c.x, c.y - pt.y) / size; + if (dot(q, q) > 2.6) { + return vec4(0.0); + } + let d = symbolSdf(kind, q); + let e = 0.02; + let grad = vec2( + symbolSdf(kind, q + vec2(e, 0.0)) - d, + symbolSdf(kind, q + vec2(0.0, e)) - d + ) / e; + let px = 1.2 / size; + let inside = 1.0 - smoothstep(-px, px, d); + let outline = 1.0 - smoothstep(px * 0.9, px * 2.0, abs(d + px * 0.5)); + + let bevel = 1.0 - smoothstep(0.0, 0.16, -d); + let n = normalize(vec3(-grad * bevel * 1.6, 1.0)); + + let grooveDir = normalize(vec2(0.8, 0.6)); + let across = dot(q, vec2(-grooveDir.y, grooveDir.x)); + let grooves = 0.5 + 0.5 * sin(across * size * 3.2); + let order = dot(q, grooveDir) * 0.9 + length(q) * 0.35 + n.x * 0.5 - n.y * 0.3 + tilt.x * 2.4 + tilt.y * 1.5; + let hue = fract(order); + let spectral = clamp(abs(fract(hue + vec3(0.0, 0.6667, 0.3333)) * 6.0 - 3.0) - 1.0, vec3(0.0), vec3(1.0)); + let band = smoothstep(0.15, 0.7, 0.5 + 0.5 * cos(6.2832 * order * 0.8)); + + let silver = vec3(0.74, 0.76, 0.8) * (0.8 + 0.25 * n.z) * (0.93 + 0.1 * grooves); + var col = mix(silver, mix(spectral, vec3(1.0), 0.22) * (0.8 + 0.3 * grooves), band * 0.9); + let light = normalize(vec3(-0.4 + tilt.x * 0.6, 0.5 - tilt.y * 0.6, 0.8)); + let spec = min(pow(max(dot(n, light), 0.0), 30.0), 1.0); + col += vec3(spec * 0.45); + let ink = vec3(0.06, 0.09, 0.08); + let alpha = max(inside, outline); + let rgb = mix(ink, col, inside * (1.0 - outline * 0.8)); + return vec4(rgb * alpha, alpha); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let pt = vec2(uv.x, 1.0 - uv.y) * PLAY; + let tilt = u.liveData[${TILT_SLOT}].xy; + + var area = -1; + var r = vec4(0.0); + for (var i = 0; i < 4; i++) { + let ri = areaRect(i); + if (sdRoundRect(pt, ri, RADIUS) < 0.0) { + area = i; + r = ri; + } + } + if (area < 0) { + return vec4(0.0); + } + let aa = 1.0 - smoothstep(-0.8, 0.4, sdRoundRect(pt, r, RADIUS)); + + let n1 = fbm(pt * 0.16); + let n2 = vnoise(pt * 0.55); + var e = coverage(pt) + (n1 - 0.5) * 0.62 + (n2 - 0.5) * 0.2; + + let reveal = u.live[area]; + let local = (pt - r.xy) / r.zw; + let diag = local.x * 0.72 + local.y * 0.28 + (n1 - 0.5) * 0.16; + let front = reveal * 1.45 - 0.2; + e = max(e, smoothstep(front + 0.04, front - 0.04, diag) * step(0.001, reveal)); + let speck = step(0.9, n2) * step(e, 0.95) * (1.0 - reveal); + let coat = max(1.0 - smoothstep(0.48, 0.52, e), speck * 0.9); + + var under = holoSymbol(pt, r, symbolKind(area), tilt); + let shadow = (1.0 - smoothstep(0.52, 0.64, e)) * (1.0 - coat) * 0.3; + under = vec4(under.rgb * (1.0 - shadow), under.a + (1.0 - under.a) * shadow); + + let grain = hash(floor(pt * 2.5)); + var silver = 0.71 + 0.07 * n1 + 0.05 * (grain - 0.5); + let lp = abs(fract((pt.x + pt.y) / 9.0) - 0.5); + let lq = abs(fract((pt.x - pt.y) / 9.0) - 0.5); + silver -= 0.035 * (1.0 - smoothstep(0.0, 0.06, min(lp, lq))); + let pn = pt / PLAY.x; + let s = pn.x * 0.8 + pn.y * 0.6 - 0.55 + tilt.x * 0.75 - tilt.y * 0.5; + let sheen = exp(-s * s * 14.0) * 0.16 + exp(-s * s * 120.0) * 0.07; + let gcell = floor(pt * 0.8); + let gh = hash(gcell + vec2(9.0, 2.0)); + let glitter = step(0.95, gh) * min(pow(max(cos(gh * 41.0 + tilt.x * 9.0 + tilt.y * 7.0), 0.0), 14.0), 1.0) * 0.3; + var coatCol = vec3(silver) * vec3(0.98, 0.99, 1.03) + vec3(sheen + glitter); + let rim = smoothstep(0.36, 0.48, e) * (1.0 - speck); + coatCol *= 1.0 - 0.22 * rim; + + let sweepGlow = exp(-pow((diag - front) * 18.0, 2.0)) * step(0.001, reveal) * step(reveal, 0.999); + let sparkle = step(0.82, hash(floor(pt * 0.5) + vec2(floor(reveal * 20.0)))) * sweepGlow; + let glowAmt = clamp(sweepGlow * 0.35 + sparkle * 0.9, 0.0, 1.0); + + var rgb = mix(under.rgb, coatCol, coat); + var alpha = mix(under.a, 1.0, coat); + rgb += vec3(1.0, 0.97, 0.88) * glowAmt; + alpha = max(alpha, glowAmt); + return vec4(min(rgb, vec3(alpha)) * aa, alpha * aa); +} +`; diff --git a/example/src/components/scratchoff/scratchSound.ts b/example/src/components/scratchoff/scratchSound.ts new file mode 100644 index 0000000..5832f14 --- /dev/null +++ b/example/src/components/scratchoff/scratchSound.ts @@ -0,0 +1,57 @@ +import { + AudioContext, + type AudioBufferSourceNode, + type BiquadFilterNode, + type GainNode, +} from 'react-native-audio-api'; + +type Voice = { + ctx: AudioContext; + source: AudioBufferSourceNode; + tone: BiquadFilterNode; + level: GainNode; +}; + +let voice: Voice | null = null; + +function createVoice(): Voice { + const ctx = new AudioContext(); + const length = ctx.sampleRate; + const buffer = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buffer.getChannelData(0); + let grit = 0; + for (let i = 0; i < length; i++) { + grit = Math.random() < 0.02 ? Math.random() : grit * 0.96; + data[i] = (Math.random() * 2 - 1) * (0.35 + 0.65 * grit); + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + const tone = ctx.createBiquadFilter(); + tone.type = 'bandpass'; + tone.frequency.value = 2600; + tone.Q.value = 0.7; + const level = ctx.createGain(); + level.gain.value = 0; + source.connect(tone); + tone.connect(level); + level.connect(ctx.destination); + source.start(); + return { ctx, source, tone, level }; +} + +/** + * The rasp of a coin on latex: looped crackly noise through a bandpass whose + * loudness and brightness follow how fast the finger moves (pt/s). + */ +export function setScratchSpeed(speed: number) { + try { + voice = voice ?? createVoice(); + const amount = Math.min(1, speed / 900); + const now = voice.ctx.currentTime; + voice.level.gain.setTargetAtTime(amount * 0.5, now, 0.03); + voice.tone.frequency.setTargetAtTime(1800 + amount * 2600, now, 0.05); + } catch { + // No audio: scratching still works, just silently. + } +} diff --git a/example/src/components/wallet/CardArt.tsx b/example/src/components/wallet/CardArt.tsx new file mode 100644 index 0000000..b34a043 --- /dev/null +++ b/example/src/components/wallet/CardArt.tsx @@ -0,0 +1,258 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { CARD_RADIUS } from './CardSurface'; + +/** EMV chip: a gold pad with its contact segments. */ +function Chip() { + return ( + + + + + + + ); +} + +/** The contactless mark: four nested arcs opening to the right. */ +function Contactless() { + return ( + + {[8, 14, 20, 26].map((size) => ( + + ))} + + ); +} + +/** Two overlapping discs with the orange lens where they meet. */ +function Network() { + return ( + + + + + + + ); +} + +/** Printed and engraved details laid over the metal surface. */ +export function CardArt() { + return ( + + Neobank + Titanium + + + + + •••• 1834 + + + ); +} + +export type StackCardSpec = { + name: string; + color: string; + ink: string; + mark: string; +}; + +/** A card waiting in the stack: only its top edge and name show. */ +export function StackCard({ + spec, + top, + width, + height, +}: { + spec: StackCardSpec; + top: number; + width: number; + height: number; +}) { + return ( + + + {spec.name} + {spec.mark} + + ); +} + +const LENS = 34 - 13; + +const styles = StyleSheet.create({ + bank: { + position: 'absolute', + left: 22, + top: 20, + color: 'rgba(235, 237, 240, 0.86)', + fontSize: 20, + fontWeight: '600', + letterSpacing: 0.2, + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 0, height: -0.6 }, + textShadowRadius: 0.4, + }, + tier: { + position: 'absolute', + right: 22, + top: 24, + color: 'rgba(220, 224, 230, 0.55)', + fontSize: 13, + fontWeight: '500', + letterSpacing: 1.6, + }, + chipWrap: { + position: 'absolute', + left: 22, + top: 92, + flexDirection: 'row', + alignItems: 'center', + }, + chip: { + width: 46, + height: 35, + borderRadius: 7, + backgroundColor: '#c8b27c', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#8a7444', + overflow: 'hidden', + }, + chipLineH: { + position: 'absolute', + left: 0, + right: 0, + top: 17, + height: 1, + backgroundColor: 'rgba(110, 88, 40, 0.7)', + }, + chipLineV: { + position: 'absolute', + top: 0, + bottom: 0, + width: 1, + backgroundColor: 'rgba(110, 88, 40, 0.7)', + }, + chipLineLeft: { + left: 15, + }, + chipLineRight: { + right: 15, + }, + chipCore: { + position: 'absolute', + left: 15, + top: 10, + width: 16, + height: 15, + borderRadius: 3, + borderWidth: 1, + borderColor: 'rgba(110, 88, 40, 0.7)', + backgroundColor: '#d4bf8c', + }, + waves: { + width: 20, + height: 30, + marginLeft: 16, + overflow: 'hidden', + }, + wave: { + position: 'absolute', + left: 0, + top: 15, + borderWidth: 2, + borderColor: 'transparent', + borderRightColor: 'rgba(225, 228, 232, 0.7)', + }, + digits: { + position: 'absolute', + left: 22, + bottom: 22, + color: 'rgba(235, 237, 240, 0.85)', + fontSize: 16, + fontWeight: '500', + letterSpacing: 1.2, + fontVariant: ['tabular-nums'], + }, + network: { + position: 'absolute', + right: 20, + bottom: 18, + width: 34 * 2 - 13, + height: 34, + }, + disc: { + position: 'absolute', + top: 0, + width: 34, + height: 34, + borderRadius: 17, + overflow: 'hidden', + }, + discRed: { + left: 0, + backgroundColor: '#eb001b', + }, + discYellow: { + left: LENS, + backgroundColor: '#f79e1b', + }, + lens: { + left: -LENS, + backgroundColor: '#ff5f00', + }, + stackCard: { + position: 'absolute', + left: 0, + borderRadius: CARD_RADIUS, + overflow: 'hidden', + shadowColor: '#000', + shadowOpacity: 0.6, + shadowRadius: 10, + shadowOffset: { width: 0, height: -4 }, + }, + stackSheen: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 1, + backgroundColor: 'rgba(255, 255, 255, 0.35)', + }, + stackName: { + position: 'absolute', + left: 18, + top: 12, + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + }, + stackMark: { + position: 'absolute', + right: 18, + top: 11, + fontSize: 16, + fontWeight: '800', + fontStyle: 'italic', + }, +}); diff --git a/example/src/components/wallet/CardSurface.tsx b/example/src/components/wallet/CardSurface.tsx new file mode 100644 index 0000000..4565b80 --- /dev/null +++ b/example/src/components/wallet/CardSurface.tsx @@ -0,0 +1,134 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +export const CARD_RADIUS = 14; + +type CardSurfaceProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' +> & { + /** `u.live = (tiltX -1..1, tiltY -1..1, glint 0..1, lift 0..1)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The face of a brushed black titanium card. + * + * Near-black metal brushed along its length, so the light on it is a crisp + * anisotropic streak across the grain that slides with tilt, broken into + * lines by the brushing. Only at steep angles does a faint thin-film tint + * creep into the streak. A thin bright bevel runs round the edge. `glint` + * runs a sharp band of light diagonally across once, the way the card catches + * the light when Wallet lifts it. + */ +export default function CardSurface({ + paramsSynchronizable, + ...rest +}: CardSurfaceProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +const CORNER_RADIUS = ${CARD_RADIUS}.0; + +fn hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash(i); + let b = hash(i + vec2(1.0, 0.0)); + let c = hash(i + vec2(0.0, 1.0)); + let d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var s = 0.0; + var amp = 0.5; + for (var i = 0; i < 4; i++) { + s += amp * vnoise(p); + p = p * 2.02 + vec2(5.3, 1.7); + amp *= 0.5; + } + return s; +} + +fn brushing(uv: vec2, px: vec2) -> f32 { + let row = uv.y * px.y; + let fine = hash(vec2(floor(row), 1.0)); + let streaks = vnoise(vec2(uv.x * 3.0, row * 0.35)) * 0.6 + vnoise(vec2(uv.x * 9.0, row * 0.9)) * 0.4; + return fine * 0.45 + streaks * 0.55; +} + +fn cornerDistance(uv: vec2, size: vec2, radius: f32) -> f32 { + let q = abs((uv - 0.5) * size) - (size * 0.5 - vec2(radius)); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let aspect = u.resolution.z; + let p = (uv - 0.5) * vec2(aspect, 1.0); + let tilt = clamp(u.live.xy, vec2(-1.0), vec2(1.0)); + let glint = u.live.z; + let lift = u.live.w; + + let brush = brushing(uv, u.resolution.xy); + let cloud = fbm(p * 2.2 + vec2(4.0, 1.0)); + var col = vec3(0.055, 0.058, 0.064) * (0.9 + 0.25 * cloud); + col *= 0.86 + 0.28 * brush; + col = mix(col, col * 1.35, smoothstep(-0.5, 0.5, p.y) * 0.4); + + let sx = 0.35 - tilt.x * 0.95; + let sd = p.x - sx + p.y * 0.32 + tilt.y * 0.2; + let core = exp(-sd * sd * 140.0); + let wide = exp(-sd * sd * 9.0); + let lines = 0.55 + 0.9 * brush; + var spec = (core * 0.42 + wide * 0.1) * lines; + + let graze = smoothstep(0.35, 1.0, length(tilt)); + let film = 0.5 + 0.5 * cos(6.2832 * (vec3(0.0, 0.33, 0.67) + sd * 1.6 + tilt.x * 0.5 + 0.55)); + let tint = mix(vec3(1.0), film, 0.06 + 0.4 * graze); + col += tint * spec * vec3(0.95, 0.97, 1.0); + col += film * wide * graze * 0.05; + + let g = mix(-1.5, 1.5, glint); + let gd = (p.x * 0.8 + p.y * 0.6) - g; + let sweep = exp(-gd * gd * 260.0) * 0.5 + exp(-gd * gd * 16.0) * 0.08; + col += vec3(sweep * sin(3.14159 * clamp(glint, 0.0, 1.0))) * lines; + + let edge = cornerDistance(uv, u.resolution.xy, CORNER_RADIUS * u.resolution.w); + let coverage = clamp(0.5 - edge, 0.0, 1.0); + let rim = 1.0 - smoothstep(0.0, 2.5 * u.resolution.w, -edge); + col += vec3(0.22) * rim * (0.35 + 0.65 * uv.y + spec); + col *= 1.0 + 0.05 * lift; + return vec4(clamp(col, vec3(0.0), vec3(1.0)) * coverage, coverage); +} +`; diff --git a/example/src/components/wallet/PayGlyph.tsx b/example/src/components/wallet/PayGlyph.tsx new file mode 100644 index 0000000..88bc284 --- /dev/null +++ b/example/src/components/wallet/PayGlyph.tsx @@ -0,0 +1,169 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +type PayGlyphProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' +> & { + /** + * `u.live = (face, ring, check, reader)` and + * `u.liveData[0] = (done, spin phase, nod, 0)`, every amount 0..1. + */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The status glyph of the Apple Pay sheet, drawn as distance fields so each + * stage can morph into the next: the Face ID face (its corner brackets close + * into a spinning ring while it scans, then a check mark draws), the + * iPhone-in-a-ring reader glyph with a soft pulse for Hold Near Reader, and + * the blue disc with its check for Done. + */ +export default function PayGlyph({ + paramsSynchronizable, + ...rest +}: PayGlyphProps) { + 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; + +const STROKE = 0.05; +const BLUE = vec3(0.04, 0.52, 1.0); + +fn segment(p: vec2, a: vec2, b: vec2) -> f32 { + let pa = p - a; + let ba = b - a; + let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); +} + +fn arc(p: vec2, c: vec2, r: f32, a0: f32, a1: f32) -> f32 { + let d = p - c; + let ang = atan2(d.y, d.x); + if (ang >= a0 && ang <= a1) { + return abs(length(d) - r); + } + let e0 = c + r * vec2(cos(a0), sin(a0)); + let e1 = c + r * vec2(cos(a1), sin(a1)); + return min(length(p - e0), length(p - e1)); +} + +fn brackets(c: vec2) -> f32 { + let q = abs(c); + let corner = arc(q, vec2(0.56), 0.24, 0.0, 1.5708); + let side = min( + segment(q, vec2(0.8, 0.56), vec2(0.8, 0.34)), + segment(q, vec2(0.56, 0.8), vec2(0.34, 0.8)) + ); + return min(corner, side); +} + +fn face(c: vec2, nod: f32) -> f32 { + let o = vec2(nod * 0.06, nod * 0.03); + let eyes = min( + segment(c, vec2(-0.27, 0.24) + o, vec2(-0.27, 0.1) + o), + segment(c, vec2(0.27, 0.24) + o, vec2(0.27, 0.1) + o) + ); + let nose = min( + segment(c, vec2(0.02, 0.24) + o, vec2(0.02, -0.08) + o), + segment(c, vec2(0.02, -0.08) + o, vec2(-0.07, -0.08) + o) + ); + let mouth = arc(c - o, vec2(0.0, 0.06), 0.36, -2.25, -0.89); + return min(eyes, min(nose, mouth)); +} + +fn check(c: vec2, amount: f32, scale: f32) -> f32 { + let a = vec2(-0.42, 0.0) * scale; + let b = vec2(-0.12, -0.3) * scale; + let e = vec2(0.46, 0.32) * scale; + let l1 = length(b - a); + let l2 = length(e - b); + let drawn = amount * (l1 + l2); + var d = 1e3; + if (drawn > 0.0) { + d = segment(c, a, a + (b - a) * min(drawn / l1, 1.0)); + } + if (drawn > l1) { + d = min(d, segment(c, b, b + (e - b) * min((drawn - l1) / l2, 1.0))); + } + return d; +} + +fn ink(d: f32, w: f32, px: f32) -> f32 { + return 1.0 - smoothstep(w - px, w + px, d); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let c = ndc * vec2(u.resolution.z, 1.0) * 1.05; + let px = 2.4 / u.resolution.y; + let faceAmt = u.live.x; + let ring = u.live.y; + let checkAmt = u.live.z; + let reader = u.live.w; + let done = u.liveData[0].x; + let spin = u.liveData[0].y; + let nod = u.liveData[0].z; + + var white = 0.0; + var blue = 0.0; + + let r = length(c); + let ang = atan2(c.y, c.x); + let dashes = abs(fract((ang + spin) / 6.2832 * 16.0) - 0.5) * 2.0; + let ringD = max(abs(r - 0.78), (dashes - 0.55) * 0.12); + let shapeD = mix(brackets(c), ringD, ring); + let frameVis = max(faceAmt, checkAmt) * (1.0 - checkAmt * ring); + white = max(white, ink(shapeD, STROKE, px) * frameVis); + white = max(white, ink(face(c, nod), STROKE, px) * faceAmt * (1.0 - ring)); + white = max(white, ink(check(c, checkAmt, 0.9), STROKE * 1.2, px) * step(0.001, checkAmt)); + + let pulse = fract(u.time.x * 0.7); + let phoneQ = abs(c - vec2(0.0, -0.02)) - vec2(0.25, 0.43) + 0.11; + let phoneD = length(max(phoneQ, vec2(0.0))) + min(max(phoneQ.x, phoneQ.y), 0.0) - 0.11; + let phoneFill = 1.0 - smoothstep(-px, px, phoneD); + let island = segment(c, vec2(-0.07, 0.3), vec2(0.07, 0.3)); + var readerInk = ink(abs(phoneD), STROKE * 0.75, px); + readerInk = max(readerInk, ink(island, STROKE * 0.6, px)); + readerInk = max(readerInk, ink(abs(r - 0.86), STROKE * 0.9, px)); + blue = max(blue, readerInk * reader); + blue = max(blue, phoneFill * 0.22 * reader); + let ringR = 0.86 + pulse * 0.16; + let ripple = ink(abs(r - ringR), STROKE * 0.6, px) * (1.0 - pulse) * 0.6 * reader; + blue = max(blue, ripple); + + let disc = (1.0 - smoothstep(0.82 * done - px, 0.82 * done + px, r)) * step(0.001, done); + let tick = ink(check(c, clamp(done * 1.6 - 0.4, 0.0, 1.0), 0.75), STROKE * 1.25, px); + + var col = mix(BLUE, vec3(0.92), step(0.001, white)); + var alpha = white; + col = mix(col, BLUE, blue); + alpha = max(alpha, blue); + col = mix(col, BLUE, disc); + alpha = max(alpha, disc); + col = mix(col, vec3(1.0), tick * disc); + return vec4(col * alpha, alpha); +} +`; diff --git a/example/src/components/wallet/chime.ts b/example/src/components/wallet/chime.ts new file mode 100644 index 0000000..fe23cfc --- /dev/null +++ b/example/src/components/wallet/chime.ts @@ -0,0 +1,29 @@ +import { AudioContext } from 'react-native-audio-api'; + +let ctx: AudioContext | null = null; + +function tone(ac: AudioContext, freq: number, at: number, length: number) { + const osc = ac.createOscillator(); + osc.type = 'sine'; + osc.frequency.value = freq; + const env = ac.createGain(); + env.gain.setValueAtTime(0, at); + env.gain.linearRampToValueAtTime(0.18, at + 0.008); + env.gain.exponentialRampToValueAtTime(0.001, at + length); + osc.connect(env); + env.connect(ac.destination); + osc.start(at); + osc.stop(at + length + 0.02); +} + +/** A soft two-note success chime, in the spirit of the Apple Pay ding. */ +export function playChime() { + try { + ctx = ctx ?? new AudioContext(); + const t0 = ctx.currentTime + 0.01; + tone(ctx, 1318.5, t0, 0.5); + tone(ctx, 1975.5, t0 + 0.09, 0.7); + } catch { + // Silent fallback: the haptic and check mark still land. + } +} diff --git a/example/src/hooks/useBubbleWrap.ts b/example/src/hooks/useBubbleWrap.ts new file mode 100644 index 0000000..be1f862 --- /dev/null +++ b/example/src/hooks/useBubbleWrap.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useParamsSynchronizable } from 'react-native-effects'; + +/** Target bubble pitch, points. */ +const TARGET_SPACING = 46; +/** `u.liveData` holds 384 floats, one per bubble. */ +const MAX_BUBBLES = 384; +/** How long a bubble takes to crumple flat (s). */ +const POP_DURATION = 0.14; + +export type BubbleGrid = { + spacing: number; + rowHeight: number; + /** Bubbles per row, including the half bubble at the right edge. */ + stride: number; + rows: number; +}; + +function gridFor(width: number, height: number, cols: number): BubbleGrid { + const spacing = width / cols; + const rowHeight = spacing * 0.8660254; + const rows = Math.ceil(height / rowHeight) + 1; + return { spacing, rowHeight, stride: cols + 1, rows }; +} + +function layoutGrid(width: number, height: number): BubbleGrid { + let cols = Math.max(4, Math.round(width / TARGET_SPACING)); + let grid = gridFor(width, height, cols); + while (grid.stride * grid.rows > MAX_BUBBLES && cols > 4) { + cols -= 1; + grid = gridFor(width, height, cols); + } + return grid; +} + +/** + * Bubble centre for (row, col), points. Even rows start half a bubble in, + * odd rows sit on the left edge, so both edges show cut bubbles like a sheet + * cut off the roll. + */ +function bubbleCenter(grid: BubbleGrid, row: number, col: number) { + const shift = row % 2 === 0 ? 0.5 : 0; + return { x: (col + shift) * grid.spacing, y: (row + 0.5) * grid.rowHeight }; +} + +function nearestBubble(grid: BubbleGrid, x: number, y: number): number { + const r0 = Math.floor(y / grid.rowHeight - 0.5); + let best = -1; + let bestD = Infinity; + for (let row = r0; row <= r0 + 1; row++) { + if (row < 0 || row >= grid.rows) { + continue; + } + const shift = row % 2 === 0 ? 0.5 : 0; + const col = Math.round(x / grid.spacing - shift); + if (col < 0 || col >= grid.stride) { + continue; + } + const c = bubbleCenter(grid, row, col); + const d = Math.hypot(x - c.x, y - c.y); + if (d < bestD) { + bestD = d; + best = row * grid.stride + col; + } + } + return bestD < grid.spacing * 0.58 ? best : -1; +} + +/** + * The state of a sheet of bubble wrap. + * + * Each bubble owns one float of the live channel (`u.liveData`, flattened): + * 0 is intact, 1 is popped flat, and in between it is crumpling. A press on + * a bubble pops it; dragging pops every bubble the finger rolls over, for + * every finger on the glass. + */ +export function useBubbleWrap( + width: number, + height: number, + onPop: (index: number) => void +) { + const grid = useMemo(() => layoutGrid(width, height), [width, height]); + const count = grid.stride * grid.rows; + + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(4 + MAX_BUBBLES).fill(0)); + + const stateRef = useRef(new Float64Array(MAX_BUBBLES)); + const animatingRef = useRef(new Set()); + const onPopRef = useRef(onPop); + onPopRef.current = onPop; + + const flush = useCallback(() => { + setParamsSynchronizable(0, 0, 0, 0, ...stateRef.current); + }, [setParamsSynchronizable]); + + useEffect(() => { + let raf = 0; + let lastTs = 0; + const step = (now: number) => { + const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); + lastTs = now; + const animating = animatingRef.current; + if (animating.size > 0) { + const state = stateRef.current; + for (const i of animating) { + state[i] = Math.min(1, (state[i] ?? 0) + dt / POP_DURATION); + if (state[i] === 1) { + animating.delete(i); + } + } + flush(); + } + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [flush]); + + const pressAt = useCallback( + (x: number, y: number) => { + const i = nearestBubble(grid, x, y); + if (i < 0 || i >= count || (stateRef.current[i] ?? 0) > 0) { + return; + } + stateRef.current[i] = 0.001; + animatingRef.current.add(i); + onPopRef.current(i); + }, + [grid, count] + ); + + // Touches jump between move events (the first one by the whole touch + // slop), so press every bubble along the segment, not just its end. + const lastTouchRef = useRef(new Map()); + const dragTo = useCallback( + (id: string, x: number, y: number) => { + const last = lastTouchRef.current.get(id) ?? { x, y }; + const steps = Math.ceil( + Math.hypot(x - last.x, y - last.y) / (grid.spacing * 0.3) + ); + for (let k = 1; k <= steps; k++) { + const t = k / steps; + pressAt(last.x + (x - last.x) * t, last.y + (y - last.y) * t); + } + pressAt(x, y); + lastTouchRef.current.set(id, { x, y }); + }, + [grid, pressAt] + ); + + const releaseTouches = useCallback(() => { + lastTouchRef.current.clear(); + }, []); + + const reset = useCallback(() => { + stateRef.current.fill(0); + animatingRef.current.clear(); + flush(); + }, [flush]); + + const poppedCount = useCallback(() => { + let n = 0; + for (let i = 0; i < count; i++) { + if ((stateRef.current[i] ?? 0) > 0) { + n++; + } + } + return n; + }, [count]); + + // `__bubblePop(x, y)` presses the sheet at screen fraction (x, y), y down. + useEffect(() => { + if (!__DEV__) { + return; + } + const g = globalThis as { __bubblePop?: (x: number, y: number) => void }; + g.__bubblePop = (x, y) => pressAt(x * width, y * height); + return () => { + delete g.__bubblePop; + }; + }, [pressAt, width, height]); + + return { + paramsSynchronizable, + grid, + count, + pressAt, + dragTo, + releaseTouches, + reset, + poppedCount, + }; +} diff --git a/example/src/hooks/useFerrofluidField.ts b/example/src/hooks/useFerrofluidField.ts new file mode 100644 index 0000000..e79c363 --- /dev/null +++ b/example/src/hooks/useFerrofluidField.ts @@ -0,0 +1,283 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Magnetometer } from 'expo-sensors'; +import { useParamsSynchronizable } from 'react-native-effects'; + +/** Where the puddle rests, centred coords (x scaled by aspect, y-up). */ +const REST = { x: 0, y: -0.02 }; +/** + * Field change (uT) ignored: Earth's ~50 uT swings by up to twice that in + * the device frame just from turning the phone. + */ +const FIELD_DEADZONE = 80; +/** Field change that saturates the spikes (uT); a neodymium magnet nearby. */ +const FIELD_FULL = 2000; +/** Knee of the log curve: smaller = more sensitive to a weak magnet. */ +const FIELD_KNEE = 120; +/** How far the magnet's direction pulls the gather point, centred units. */ +const FIELD_REACH = 0.32; +/** Samples averaged into the Earth-field baseline. */ +const CALIBRATION_SAMPLES = 20; +const SENSOR_INTERVAL_MS = 20; +/** Spikes snap up and ring as they settle: a light, underdamped spring. */ +const RISE_STIFFNESS = 110; +const RISE_DAMPING = 7.5; +/** The puddle itself is heavier: it creeps toward the magnet. */ +const FLOW_STIFFNESS = 26; +const FLOW_DAMPING = 7.5; +/** Farthest the puddle centre slides off its rest point. */ +const MAX_GATHER = 0.07; +/** A finger held down ramps up like a magnet being brought in (s). */ +const TOUCH_RAMP = 0.35; +const TOUCH_STRENGTH = 0.9; + +type Vec = { x: number; y: number }; + +type FerroSim = { + aspect: number; + /** Magnet position the field implies, centred coords. */ + magnet: Vec; + strengthTarget: number; + strength: number; + strengthVel: number; + center: Vec; + centerVel: Vec; + phase: number; + touch: { at: Vec; held: number } | null; + sensor: { magnet: Vec; strength: number }; + override: { magnet: Vec; strength: number } | null; + baseline: { x: number; y: number; z: number } | null; + calibration: { x: number; y: number; z: number; n: number }; +}; + +export type FerroSource = 'sensor' | 'touch' | 'none'; + +function fieldToStrength(deltaUT: number): number { + const d = Math.max(0, deltaUT - FIELD_DEADZONE); + return Math.min( + 1, + Math.log1p(d / FIELD_KNEE) / Math.log1p(FIELD_FULL / FIELD_KNEE) + ); +} + +function clampLength(v: Vec, max: number): Vec { + const len = Math.hypot(v.x, v.y); + if (len <= max || len === 0) { + return v; + } + return { x: (v.x / len) * max, y: (v.y / len) * max }; +} + +/** + * Turns a real magnet near the phone into a magnet under a puddle of + * ferrofluid. + * + * The magnetometer's raw field (uT) minus an Earth-field baseline gives the + * magnet's field at the sensor. Its size maps through a log curve to spike + * strength. Its in-plane direction says where the magnet is: under a dipole + * the in-plane field points away from the axis for one pole and toward it for + * the other, and the out-of-screen component tells the two apart, so the + * fluid gathers toward the magnet whichever face is held to the phone. + * Without a sensor (simulator) a held finger acts as the magnet. + * + * Writes `u.live = (centerX, centerY, strength, phase)` and + * `u.liveData[0] = (magnetX, magnetY, strengthVelocity, 0)`, centred coords. + */ +export function useFerrofluidField(width: number, height: number) { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([REST.x, REST.y, 0, 0, REST.x, REST.y, 0, 0]); + const [sensorAvailable, setSensorAvailable] = useState(null); + + const simRef = useRef(null); + if (simRef.current === null) { + simRef.current = { + aspect: width / height, + magnet: { ...REST }, + strengthTarget: 0, + strength: 0, + strengthVel: 0, + center: { ...REST }, + centerVel: { x: 0, y: 0 }, + phase: 0, + touch: null, + sensor: { magnet: { ...REST }, strength: 0 }, + override: null, + baseline: null, + calibration: { x: 0, y: 0, z: 0, n: 0 }, + }; + } + simRef.current.aspect = width / height; + + const calibrate = useCallback(() => { + const s = simRef.current as FerroSim; + s.baseline = null; + s.calibration = { x: 0, y: 0, z: 0, n: 0 }; + s.sensor = { magnet: { ...REST }, strength: 0 }; + }, []); + + useEffect(() => { + let sub: { remove: () => void } | null = null; + let cancelled = false; + + const onReading = ({ x, y, z }: { x: number; y: number; z: number }) => { + const s = simRef.current as FerroSim; + if (s.baseline === null) { + const c = s.calibration; + c.x += x; + c.y += y; + c.z += z; + c.n += 1; + if (c.n >= CALIBRATION_SAMPLES) { + s.baseline = { x: c.x / c.n, y: c.y / c.n, z: c.z / c.n }; + } + return; + } + const dx = x - s.baseline.x; + const dy = y - s.baseline.y; + const dz = z - s.baseline.z; + const mag = Math.hypot(dx, dy, dz); + const strength = fieldToStrength(mag); + const pole = dz >= 0 ? -1 : 1; + const inPlane = + mag > 1e-3 ? { x: dx / mag, y: dy / mag } : { x: 0, y: 0 }; + s.sensor = { + strength, + magnet: { + x: REST.x + pole * inPlane.x * FIELD_REACH, + y: REST.y + pole * inPlane.y * FIELD_REACH, + }, + }; + }; + + const start = async () => { + try { + const ok = await Magnetometer.isAvailableAsync(); + if (cancelled) { + return; + } + setSensorAvailable(ok); + if (!ok) { + return; + } + Magnetometer.setUpdateInterval(SENSOR_INTERVAL_MS); + sub = Magnetometer.addListener(onReading); + } catch { + if (!cancelled) { + setSensorAvailable(false); + } + } + }; + start(); + + // The simulator has no magnetometer. `__ferroMagnet(x, y, strength)` holds + // a magnet at screen fraction (x, y), y down, strength 0..1, until + // `__ferroMagnet(null)` hands control back to the sensor and touch. + if (__DEV__) { + const g = globalThis as { + __ferroMagnet?: ( + x: number | null, + y?: number, + strength?: number + ) => void; + }; + g.__ferroMagnet = (x, y = 0.5, strength = 1) => { + const s = simRef.current as FerroSim; + if (x === null) { + s.override = null; + return; + } + s.override = { + magnet: { x: (x - 0.5) * s.aspect, y: 0.5 - y }, + strength: Math.max(0, Math.min(1, strength)), + }; + }; + } + + return () => { + cancelled = true; + if (sub) { + sub.remove(); + } + if (__DEV__) { + delete (globalThis as { __ferroMagnet?: unknown }).__ferroMagnet; + } + }; + }, []); + + useEffect(() => { + let raf = 0; + let lastTs = 0; + + const step = (now: number) => { + const s = simRef.current as FerroSim; + const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); + lastTs = now; + + if (s.touch) { + s.touch.held = Math.min(1, s.touch.held + dt / TOUCH_RAMP); + } + if (s.override) { + s.magnet = s.override.magnet; + s.strengthTarget = s.override.strength; + } else if (s.touch) { + s.magnet = s.touch.at; + s.strengthTarget = TOUCH_STRENGTH * s.touch.held; + } else { + s.magnet = s.sensor.magnet; + s.strengthTarget = s.sensor.strength; + } + + s.strengthVel += (s.strengthTarget - s.strength) * RISE_STIFFNESS * dt; + s.strengthVel -= s.strengthVel * RISE_DAMPING * dt; + s.strength = Math.max(0, s.strength + s.strengthVel * dt); + + const pull = clampLength( + { x: s.magnet.x - REST.x, y: s.magnet.y - REST.y }, + MAX_GATHER + ); + const gather = Math.min(1, s.strengthTarget * 1.2); + const goal = { x: REST.x + pull.x * gather, y: REST.y + pull.y * gather }; + s.centerVel.x += (goal.x - s.center.x) * FLOW_STIFFNESS * dt; + s.centerVel.y += (goal.y - s.center.y) * FLOW_STIFFNESS * dt; + s.centerVel.x -= s.centerVel.x * FLOW_DAMPING * dt; + s.centerVel.y -= s.centerVel.y * FLOW_DAMPING * dt; + s.center.x += s.centerVel.x * dt; + s.center.y += s.centerVel.y * dt; + + s.phase += dt * (0.6 + Math.abs(s.strengthVel) * 0.8); + + setParamsSynchronizable( + s.center.x, + s.center.y, + s.strength, + s.phase, + s.magnet.x, + s.magnet.y, + s.strengthVel, + 0 + ); + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [setParamsSynchronizable]); + + /** Finger as a magnet: screen points, y down. Pass null on release. */ + const setTouch = useCallback( + (point: { x: number; y: number } | null) => { + const s = simRef.current as FerroSim; + if (point === null) { + s.touch = null; + return; + } + const at = { + x: (point.x / width - 0.5) * s.aspect, + y: 0.5 - point.y / height, + }; + s.touch = { at, held: s.touch ? s.touch.held : 0 }; + }, + [width, height] + ); + + return { paramsSynchronizable, setTouch, calibrate, sensorAvailable }; +} diff --git a/example/src/hooks/useHoldTilt.ts b/example/src/hooks/useHoldTilt.ts new file mode 100644 index 0000000..5838b4c --- /dev/null +++ b/example/src/hooks/useHoldTilt.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; +import { DeviceMotion } from 'expo-sensors'; + +/** Radians of tilt away from the resting hold that reach the full -1..1. */ +const FULL_TILT_RAD = 0.45; +/** The resting hold drifts with the hand; follow it slowly (s). */ +const BASELINE_TAU = 2.5; +const SMOOTH_TAU = 0.08; + +type TiltState = { + x: number; + y: number; + baseBeta: number | null; + baseGamma: number; + rawX: number; + rawY: number; + override: { x: number; y: number } | null; +}; + +type DebugHook = (x: number | null, y?: number) => void; + +/** + * Tilt of the phone relative to how it is being held, as `x` (roll) and `y` + * (pitch) in -1..1, smoothed. The baseline follows the hand slowly, so the + * card rests flat however the phone is held and only the motion reads. + * In dev builds `globalThis[debugName](x, y)` overrides the sensor; `null` + * hands control back. + */ +export function useHoldTilt(debugName: string): React.RefObject { + const state = useRef({ + x: 0, + y: 0, + baseBeta: null, + baseGamma: 0, + rawX: 0, + rawY: 0, + override: null, + }); + + useEffect(() => { + const s = state.current; + let last = Date.now(); + DeviceMotion.setUpdateInterval(16); + const sub = DeviceMotion.addListener(({ rotation }) => { + if (!rotation) { + return; + } + const now = Date.now(); + const dt = Math.min(0.1, (now - last) / 1000); + last = now; + if (s.baseBeta === null) { + s.baseBeta = rotation.beta; + s.baseGamma = rotation.gamma; + } + const k = Math.min(1, dt / BASELINE_TAU); + s.baseBeta += (rotation.beta - s.baseBeta) * k; + s.baseGamma += (rotation.gamma - s.baseGamma) * k; + s.rawX = clampUnit((rotation.gamma - s.baseGamma) / FULL_TILT_RAD); + s.rawY = clampUnit((rotation.beta - s.baseBeta) / FULL_TILT_RAD); + }); + + if (__DEV__) { + const hook: DebugHook = (x, y = 0) => { + s.override = x === null ? null : { x, y }; + }; + (globalThis as Record)[debugName] = hook; + } + + let frame = 0; + let prev = Date.now(); + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - prev) / 1000); + prev = now; + const tx = s.override ? s.override.x : s.rawX; + const ty = s.override ? s.override.y : s.rawY; + const k = Math.min(1, dt / SMOOTH_TAU); + s.x += (tx - s.x) * k; + s.y += (ty - s.y) * k; + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + + return () => { + cancelAnimationFrame(frame); + sub.remove(); + if (__DEV__) { + delete (globalThis as Record)[debugName]; + } + }; + }, [debugName]); + + return state; +} + +function clampUnit(v: number): number { + return Math.max(-1, Math.min(1, v)); +} diff --git a/example/src/hooks/useLavaLamp.ts b/example/src/hooks/useLavaLamp.ts new file mode 100644 index 0000000..797c89e --- /dev/null +++ b/example/src/hooks/useLavaLamp.ts @@ -0,0 +1,238 @@ +import { useEffect, useRef } from 'react'; +import { Accelerometer } from 'expo-sensors'; +import { useParamsSynchronizable } from 'react-native-effects'; + +/** + * The vessel, in the units the shader uses: photo pixels, Y up from the + * glass floor. `halfWidth` must match the shader's `glassHalfWidth`. + */ +export const GLASS_HEIGHT = 662; +function halfWidth(y: number): number { + const t = Math.min(1, Math.max(0, 1 - y / GLASS_HEIGHT)); + const side = 86 + 46 * (1 - (1 - t) * (1 - t)); + const k = Math.max(14 - y, 0); + return side - 60 + Math.sqrt(Math.max(3600 - k * k, 0)); +} + +/** Wax on the bulb warms quickly; out in the liquid it cools slowly (s). */ +const HEAT_TAU = 6; +const COOL_TAU = 45; +/** + * Wax in the pool clings to it until it is this hot, so it leaves the + * bottom hot enough to make it all the way up. + */ +const RELEASE_TEMP = 0.94; +/** Upward acceleration per unit of temperature above neutral (px/s^2). */ +const BUOYANCY = 85; +/** Paraffin in water is syrupy: velocity relaxes at this rate (1/s). */ +const DRAG = 1.6; +/** The heater warms only the bottom of the glass (px). */ +const HEATER_ZONE = 45; +const WALL_STIFFNESS = 30; +const TILT_RATE = 3; + +type Blob = { + x: number; + y: number; + vx: number; + vy: number; + r: number; + temp: number; + /** Held in the pool until it is hot enough to break free. */ + stuck: boolean; +}; + +type LavaSim = { + blobs: Blob[]; + /** Up, in the vessel's frame (unit vector, y up). */ + up: { x: number; y: number }; + upTarget: { x: number; y: number }; + tiltOverride: number | null; + phase: number; +}; + +const SEED_BLOBS: Array> = [ + { x: -15, y: 560, r: 58, temp: 0.5, vy: -3, stuck: false }, + { x: 20, y: 330, r: 52, temp: 0.8, vy: 14, stuck: false }, + { x: -30, y: 200, r: 40, temp: 0.45, vy: -8, stuck: false }, + { x: 45, y: 30, r: 56, temp: 0.75, vy: 0, stuck: true }, + { x: -50, y: 30, r: 48, temp: 0.5, vy: 0, stuck: true }, + { x: 25, y: 630, r: 36, temp: 0.49, vy: 0, stuck: false }, + { x: 50, y: 430, r: 32, temp: 0.47, vy: -6, stuck: false }, +]; + +/** Temperature the wax drifts toward at height y: hot on the bulb. */ +function ambientTemp(y: number, onBulb: boolean): number { + return onBulb ? 1.1 : 0.42 - 0.25 * (y / GLASS_HEIGHT); +} + +function stepBlob(b: Blob, s: LavaSim, dt: number) { + const onBulb = b.y < HEATER_ZONE + b.r * 0.2; + const tau = onBulb ? HEAT_TAU : COOL_TAU; + b.temp += (ambientTemp(b.y, onBulb) - b.temp) * (dt / tau); + if (onBulb && b.temp < 0.5) { + b.stuck = true; + } else if (b.stuck && b.temp > RELEASE_TEMP) { + b.stuck = false; + } + const buoyant = b.stuck ? Math.min(0, b.temp - 0.5) : b.temp - 0.5; + const lift = buoyant * BUOYANCY; + b.vx += s.up.x * lift * dt; + b.vy += s.up.y * lift * dt; + + const hw = halfWidth(b.y) - b.r * 0.75; + if (b.x > hw) { + b.vx -= (b.x - hw) * WALL_STIFFNESS * dt; + } else if (b.x < -hw) { + b.vx -= (b.x + hw) * WALL_STIFFNESS * dt; + } + const floor = b.r * 0.35; + const ceiling = GLASS_HEIGHT - b.r * 0.55; + if (b.y < floor) { + b.vy += (floor - b.y) * WALL_STIFFNESS * dt; + } else if (b.y > ceiling) { + b.vy -= (b.y - ceiling) * WALL_STIFFNESS * dt; + } + + const damp = Math.exp(-DRAG * dt); + b.vx *= damp; + b.vy *= damp; + b.x += b.vx * dt; + b.y += b.vy * dt; +} + +/** Blobs that overlap too far push apart gently, so they bump and pass. */ +function separate(blobs: Blob[], dt: number) { + for (let i = 0; i < blobs.length; i++) { + for (let j = i + 1; j < blobs.length; j++) { + const a = blobs[i] as Blob; + const b = blobs[j] as Blob; + const dx = b.x - a.x; + const dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1; + const minD = (a.r + b.r) * 0.7; + if (d < minD) { + const push = ((minD - d) / d) * 3 * dt; + a.vx -= dx * push; + a.vy -= dy * push; + b.vx += dx * push; + b.vy += dy * push; + } + } + } +} + +/** + * A lava lamp's wax, simulated. + * + * A handful of wax blobs, each with a temperature. The bulb heats the wax + * pooled on the bottom of the glass, which clings there until it is hot and + * then lets go. Warm wax is lighter than the liquid and rises, cools slowly + * up near the cap, turns heavy and sinks back to the pool to warm up again. Everything happens in syrup, so it takes tens of + * seconds. Buoyancy acts along real gravity from the accelerometer, so tilting + * the phone sends the wax drifting toward whichever side is now up. + * + * Writes `u.live = (phase, 0, 0, 0)` and one blob per `u.liveData[i]` as + * `(x, y, radius, stretch)` in photo pixels, y up from the glass floor. + */ +export function useLavaLamp() { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(4 + SEED_BLOBS.length * 4).fill(0)); + + const simRef = useRef(null); + if (simRef.current === null) { + simRef.current = { + blobs: SEED_BLOBS.map((b) => ({ ...b, vx: 0 })), + up: { x: 0, y: 1 }, + upTarget: { x: 0, y: 1 }, + tiltOverride: null, + phase: 0, + }; + } + + useEffect(() => { + let sub: { remove: () => void } | null = null; + let cancelled = false; + + const start = async () => { + try { + const ok = await Accelerometer.isAvailableAsync(); + if (!ok || cancelled) { + return; + } + Accelerometer.setUpdateInterval(50); + sub = Accelerometer.addListener(({ x, y }) => { + const s = simRef.current as LavaSim; + const len = Math.hypot(x, y); + if (s.tiltOverride !== null || len < 0.3) { + return; + } + s.upTarget = { x: -x / len, y: -y / len }; + }); + } catch { + // No accelerometer (simulator): the lamp stands upright. + } + }; + start(); + + // `__lavaTilt(rad)` tilts the phone by that angle (positive = right side + // down) until `__lavaTilt(null)` hands control back to the sensor. + if (__DEV__) { + const g = globalThis as { __lavaTilt?: (rad: number | null) => void }; + g.__lavaTilt = (rad) => { + const s = simRef.current as LavaSim; + s.tiltOverride = rad; + if (rad !== null) { + s.upTarget = { x: Math.sin(rad), y: Math.cos(rad) }; + } + }; + } + + return () => { + cancelled = true; + if (sub) { + sub.remove(); + } + if (__DEV__) { + delete (globalThis as { __lavaTilt?: unknown }).__lavaTilt; + } + }; + }, []); + + useEffect(() => { + let raf = 0; + let lastTs = 0; + + const step = (now: number) => { + const s = simRef.current as LavaSim; + const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); + lastTs = now; + + const k = Math.min(1, TILT_RATE * dt); + s.up.x += (s.upTarget.x - s.up.x) * k; + s.up.y += (s.upTarget.y - s.up.y) * k; + const len = Math.hypot(s.up.x, s.up.y) || 1; + s.up.x /= len; + s.up.y /= len; + + for (const b of s.blobs) { + stepBlob(b, s, dt); + } + separate(s.blobs, dt); + s.phase += dt * 0.25; + + const out: number[] = [s.phase, 0, 0, 0]; + for (const b of s.blobs) { + const speed = Math.hypot(b.vx, b.vy); + out.push(b.x, b.y, b.r, Math.min(0.6, speed / 40)); + } + setParamsSynchronizable(...out); + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [setParamsSynchronizable]); + + return { paramsSynchronizable, blobCount: SEED_BLOBS.length }; +} diff --git a/example/src/hooks/usePolaroidShake.ts b/example/src/hooks/usePolaroidShake.ts new file mode 100644 index 0000000..a816fff --- /dev/null +++ b/example/src/hooks/usePolaroidShake.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; +import { Accelerometer } from 'expo-sensors'; +import { useSharedValue, type SharedValue } from 'react-native-reanimated'; + +/** Acceleration beyond gravity (g) that counts as a full shake. */ +const FULL_SHAKE_G = 1.2; +const SHAKE_ATTACK = 0.08; +const SHAKE_RELEASE = 0.9; +/** The print hangs from the fingers: a soft, ringing spring (deg). */ +const SWAY_STIFFNESS = 90; +const SWAY_DAMPING = 5; +const SWAY_KICK = 30; +/** Synthetic shake frequency while the debug override is set (Hz). */ +const OVERRIDE_HZ = 4; + +type ShakeSim = { + level: number; + lateral: number; + lateralPrev: number; + sway: number; + swayVel: number; + override: number | null; + overridePhase: number; +}; + +type DebugGlobal = { __polaroidShake?: (level: number | null) => void }; + +/** + * Shaking the phone like a Polaroid picture. + * + * Reads the accelerometer: anything beyond gravity is shake, run through an + * envelope into `level` (0..1), which the screen uses to speed development a + * little. Sideways jerks kick a soft spring that the print sways on (`sway`, + * degrees). `__polaroidShake(level)` overrides the sensor with a synthetic + * 4 Hz shake in dev builds; `null` hands control back. + */ +export function usePolaroidShake(): { + level: React.RefObject; + sway: SharedValue; +} { + const level = useRef(0); + const sway = useSharedValue(0); + const sim = useRef({ + level: 0, + lateral: 0, + lateralPrev: 0, + sway: 0, + swayVel: 0, + override: null, + overridePhase: 0, + }); + + useEffect(() => { + const s = sim.current; + let raw = 0; + Accelerometer.setUpdateInterval(16); + const sub = Accelerometer.addListener(({ x, y, z }) => { + raw = Math.abs(Math.sqrt(x * x + y * y + z * z) - 1); + s.lateral = x; + }); + + if (__DEV__) { + (globalThis as DebugGlobal).__polaroidShake = (v) => { + s.override = v; + }; + } + + let last = Date.now(); + let frame = 0; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + + let shake = Math.min(1, raw / FULL_SHAKE_G); + if (s.override !== null) { + s.overridePhase += dt * OVERRIDE_HZ * Math.PI * 2; + shake = s.override; + s.lateral = s.override * Math.sin(s.overridePhase); + } + const tau = shake > s.level ? SHAKE_ATTACK : SHAKE_RELEASE; + s.level += (shake - s.level) * Math.min(1, dt / tau); + + const jerk = (s.lateral - s.lateralPrev) / Math.max(dt, 1e-3); + s.lateralPrev = s.lateral; + const kick = Math.max(-40, Math.min(40, jerk)) * SWAY_KICK * 0.01; + s.swayVel += + (-SWAY_STIFFNESS * s.sway - SWAY_DAMPING * s.swayVel + kick * 60) * dt; + s.sway += s.swayVel * dt; + s.sway = Math.max(-9, Math.min(9, s.sway)); + + level.current = s.level; + sway.value = s.sway; + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + + return () => { + cancelAnimationFrame(frame); + sub.remove(); + if (__DEV__) { + delete (globalThis as DebugGlobal).__polaroidShake; + } + }; + }, [sway]); + + return { level, sway }; +} diff --git a/example/src/hooks/usePopSound.ts b/example/src/hooks/usePopSound.ts new file mode 100644 index 0000000..e6a343c --- /dev/null +++ b/example/src/hooks/usePopSound.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { AudioContext, type AudioBuffer } from 'react-native-audio-api'; + +const NOISE_SECONDS = 0.12; + +type Synth = { ctx: AudioContext; noise: AudioBuffer }; + +function createSynth(): Synth { + const ctx = new AudioContext(); + const length = Math.floor(ctx.sampleRate * NOISE_SECONDS); + const noise = ctx.createBuffer(1, length, ctx.sampleRate); + const data = new Float32Array(length); + for (let i = 0; i < length; i++) { + data[i] = Math.random() * 2 - 1; + } + noise.copyToChannel(data, 0); + return { ctx, noise }; +} + +function between(lo: number, hi: number): number { + return lo + Math.random() * (hi - lo); +} + +/** + * A synthesized bubble-wrap pop: a few milliseconds of band-passed noise for + * the snap of the film tearing, over a short falling sine for the thump of + * the air letting go. Pitch, brightness and level are drawn fresh for every + * pop so a run of them never sounds like one sample on repeat. + */ +export function usePopSound() { + const synthRef = useRef(null); + + useEffect(() => { + return () => { + synthRef.current?.ctx.close(); + synthRef.current = null; + }; + }, []); + + return useCallback((size = 1) => { + if (synthRef.current === null) { + synthRef.current = createSynth(); + } + const { ctx, noise } = synthRef.current; + const t = ctx.currentTime + 0.005; + const level = between(0.75, 1) * size; + + const snap = ctx.createBufferSource(); + snap.buffer = noise; + snap.playbackRate.value = between(0.8, 1.3); + const band = ctx.createBiquadFilter(); + band.type = 'bandpass'; + band.frequency.value = between(1600, 3400); + band.Q.value = 0.9; + const snapGain = ctx.createGain(); + snapGain.gain.setValueAtTime(level * 1.4, t); + snapGain.gain.exponentialRampToValueAtTime(0.001, t + between(0.035, 0.06)); + snap.connect(band); + band.connect(snapGain); + snapGain.connect(ctx.destination); + snap.start(t); + snap.stop(t + 0.1); + + const thump = ctx.createOscillator(); + thump.type = 'sine'; + const f0 = between(120, 190); + thump.frequency.setValueAtTime(f0, t); + thump.frequency.exponentialRampToValueAtTime(f0 * 0.4, t + 0.08); + const thumpGain = ctx.createGain(); + thumpGain.gain.setValueAtTime(level * 0.55, t); + thumpGain.gain.exponentialRampToValueAtTime(0.001, t + 0.09); + thump.connect(thumpGain); + thumpGain.connect(ctx.destination); + thump.start(t); + thump.stop(t + 0.12); + }, []); +} diff --git a/example/src/hooks/useScratchCard.ts b/example/src/hooks/useScratchCard.ts new file mode 100644 index 0000000..1930717 --- /dev/null +++ b/example/src/hooks/useScratchCard.ts @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Vibration } from 'react-native'; +import { useSharedValue } from 'react-native-reanimated'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { + AREAS, + AREA_RADIUS, + CHANNEL_LENGTH, + GRID_X, + GRID_Y, + PLAY_H, + PLAY_W, + TILT_SLOT, +} from '../components/scratchoff/ScratchCoating'; +import { FLAKE_COUNT, FLAKE_STRIDE } from '../components/scratchoff/Flakes'; +import { setScratchSpeed } from '../components/scratchoff/scratchSound'; +import { useHoldTilt } from './useHoldTilt'; + +/** Finger contact radius (pt) and how much one pass removes. */ +const BRUSH_R = 17; +const BRUSH_STRENGTH = 0.7; +/** Fraction of an area's cells scratched through before it reveals itself. */ +const AUTO_REVEAL_AT = 0.6; +const REVEAL_SECONDS = 0.7; +const GRAVITY = 1500; +const FLAKE_LIFE = 1.1; + +const CELL_W = PLAY_W / GRID_X; +const CELL_H = PLAY_H / GRID_Y; + +type Flake = { + x: number; + y: number; + vx: number; + vy: number; + angle: number; + spin: number; + size: number; + life: number; +}; + +function insideArea(x: number, y: number, i: number): boolean { + const a = AREAS[i]!; + const r = AREA_RADIUS * 0.5; + return x > a.x + r && x < a.x + a.w - r && y > a.y + r && y < a.y + a.h - r; +} + +function cellsOf(i: number): number[] { + const cells: number[] = []; + for (let gy = 0; gy < GRID_Y; gy++) { + for (let gx = 0; gx < GRID_X; gx++) { + if (insideArea((gx + 0.5) * CELL_W, (gy + 0.5) * CELL_H, i)) { + cells.push(gy * GRID_X + gx); + } + } + } + return cells; +} + +const AREA_CELLS = AREAS.map((_, i) => cellsOf(i)); + +/** + * State of a scratch card: the coverage grid the shader cuts the latex + * with, each area's auto-reveal sweep, the tilt, and the falling flakes. + * + * `stroke(x, y, first)` takes the finger in play-area points. Each segment is + * stamped into the grid with a soft round brush, flakes are flicked off along + * it, and the scratch noise follows its speed. Once 60% of an area's cells are + * through, the area sweeps itself clean with a haptic tick. The channel is + * rewritten every frame by one loop: `u.live` = reveal per area, + * `u.liveData` = grid, then tilt. Tilt has a dev override, + * `__scratchTilt(x, y)` / `__scratchTilt(null)`. + */ +export function useScratchCard() { + const tilt = useHoldTilt('__scratchTilt'); + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(CHANNEL_LENGTH).fill(0)); + const flakeState = useSharedValue( + new Array(FLAKE_COUNT * FLAKE_STRIDE).fill(0) + ); + const grid = useRef(new Float64Array(GRID_X * GRID_Y)); + const reveal = useRef([0, 0, 0, 0]); + const revealing = useRef([false, false, false, false]); + const flakes = useRef([]); + const last = useRef({ x: 0, y: 0, t: 0 }); + const [revealed, setRevealed] = useState(0); + + const stamp = useCallback((x: number, y: number) => { + const g = grid.current; + const x0 = Math.max(0, Math.floor((x - BRUSH_R) / CELL_W)); + const x1 = Math.min(GRID_X - 1, Math.floor((x + BRUSH_R) / CELL_W)); + const y0 = Math.max(0, Math.floor((y - BRUSH_R) / CELL_H)); + const y1 = Math.min(GRID_Y - 1, Math.floor((y + BRUSH_R) / CELL_H)); + for (let gy = y0; gy <= y1; gy++) { + for (let gx = x0; gx <= x1; gx++) { + const dx = (gx + 0.5) * CELL_W - x; + const dy = (gy + 0.5) * CELL_H - y; + const fall = 1 - Math.hypot(dx, dy) / BRUSH_R; + if (fall > 0) { + const i = gy * GRID_X + gx; + g[i] = Math.min(1, g[i]! + fall * BRUSH_STRENGTH); + } + } + } + }, []); + + const flick = useCallback((x: number, y: number, dx: number, dy: number) => { + const onCoat = AREAS.some((_, i) => insideArea(x, y, i)); + if (!onCoat || Math.random() > 0.55) { + return; + } + const list = flakes.current; + if (list.length >= FLAKE_COUNT) { + list.shift(); + } + list.push({ + x, + y, + vx: dx * 0.25 + (Math.random() - 0.5) * 140, + vy: dy * 0.25 - 60 - Math.random() * 120, + angle: Math.random() * 360, + spin: (Math.random() - 0.5) * 1400, + size: 1.5 + Math.random() * 3.5, + life: FLAKE_LIFE * (0.6 + Math.random() * 0.4), + }); + }, []); + + const checkAreas = useCallback(() => { + const g = grid.current; + AREA_CELLS.forEach((cells, i) => { + if (revealing.current[i]) { + return; + } + const through = cells.filter((c) => g[c]! > 0.5).length; + if (through >= cells.length * AUTO_REVEAL_AT) { + revealing.current[i] = true; + Vibration.vibrate(20); + if (i < 3) { + setRevealed((n) => n + 1); + } + } + }); + }, []); + + const stroke = useCallback( + (x: number, y: number, first: boolean) => { + const now = Date.now(); + const prev = last.current; + last.current = { x, y, t: now }; + if (first) { + stamp(x, y); + return; + } + const dx = x - prev.x; + const dy = y - prev.y; + const dist = Math.hypot(dx, dy); + const steps = Math.max(1, Math.ceil(dist / 4)); + for (let s = 1; s <= steps; s++) { + stamp(prev.x + (dx * s) / steps, prev.y + (dy * s) / steps); + } + const dt = Math.max(0.008, (now - prev.t) / 1000); + const speed = dist / dt; + setScratchSpeed(speed); + const vx = dx / dt; + const vy = dy / dt; + for (let k = 0; k < Math.min(4, steps); k++) { + flick(x - (dx * k) / steps, y - (dy * k) / steps, vx, vy); + } + checkAreas(); + }, + [checkAreas, flick, stamp] + ); + + const lift = useCallback(() => { + setScratchSpeed(0); + }, []); + + const reset = useCallback(() => { + grid.current.fill(0); + reveal.current = [0, 0, 0, 0]; + revealing.current = [false, false, false, false]; + flakes.current = []; + setRevealed(0); + }, []); + + useEffect(() => { + const channel = new Array(CHANNEL_LENGTH).fill(0); + const flakeOut = new Array(FLAKE_COUNT * FLAKE_STRIDE).fill(0); + let prev = Date.now(); + let frame = 0; + let hadFlakes = false; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - prev) / 1000); + prev = now; + + for (let i = 0; i < 4; i++) { + if (revealing.current[i]) { + reveal.current[i] = Math.min( + 1, + reveal.current[i]! + dt / REVEAL_SECONDS + ); + } + channel[i] = reveal.current[i]; + } + const g = grid.current; + for (let i = 0; i < g.length; i++) { + channel[4 + i] = g[i]; + } + channel[4 + TILT_SLOT * 4] = tilt.current.x; + channel[5 + TILT_SLOT * 4] = tilt.current.y; + setParamsSynchronizable(...channel); + + const list = flakes.current; + for (const f of list) { + f.vy += GRAVITY * dt; + f.x += f.vx * dt; + f.y += f.vy * dt; + f.angle += f.spin * dt; + f.life -= dt; + } + flakes.current = list.filter((f) => f.life > 0); + if (flakes.current.length === 0 && !hadFlakes) { + frame = requestAnimationFrame(step); + return; + } + hadFlakes = flakes.current.length > 0; + flakeOut.fill(0); + flakes.current.forEach((f, i) => { + const o = i * FLAKE_STRIDE; + flakeOut[o] = f.x; + flakeOut[o + 1] = f.y; + flakeOut[o + 2] = f.angle; + flakeOut[o + 3] = f.size; + flakeOut[o + 4] = f.life / FLAKE_LIFE; + }); + flakeState.value = flakeOut.slice(); + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [flakeState, setParamsSynchronizable, tilt]); + + return { + paramsSynchronizable, + flakeState, + stroke, + lift, + reset, + revealed, + }; +} diff --git a/example/src/hooks/useTorchAim.ts b/example/src/hooks/useTorchAim.ts new file mode 100644 index 0000000..ff1322b --- /dev/null +++ b/example/src/hooks/useTorchAim.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef } from 'react'; +import { DeviceMotion } from 'expo-sensors'; + +/** Radians of tilt that swing the spot from the centre to the screen edge. */ +const REACH_RAD = 0.42; + +type Aim = { x: number; y: number }; + +function clampUnit(v: number) { + return Math.max(-1, Math.min(1, v)); +} + +/** + * Where the phone points, as `{ x, y }` in -1..1 (0 = the pose the phone had + * when the screen opened). Read the returned ref every frame. + * + * `__torchAim(x, y)` holds the aim (the simulator has no motion sensor); + * `__torchAim(null)` hands it back to the sensor. + */ +export function useTorchAim() { + const aim = useRef({ x: 0, y: 0 }); + const override = useRef(null); + + useEffect(() => { + let neutral: { beta: number; gamma: number } | null = null; + let sub: { remove: () => void } | null = null; + let cancelled = false; + + const listen = async () => { + const available = await DeviceMotion.isAvailableAsync().catch( + () => false + ); + if (!available || cancelled) return; + DeviceMotion.setUpdateInterval(16); + sub = DeviceMotion.addListener(({ rotation }) => { + if (!rotation || override.current) return; + neutral ??= { beta: rotation.beta, gamma: rotation.gamma }; + aim.current = { + x: clampUnit((rotation.gamma - neutral.gamma) / REACH_RAD), + y: clampUnit(-(rotation.beta - neutral.beta) / REACH_RAD), + }; + }); + }; + listen(); + + if (__DEV__) { + const g = globalThis as { + __torchAim?: (x: number | null, y?: number) => void; + }; + g.__torchAim = (x, y = 0) => { + if (x === null) { + override.current = null; + return; + } + override.current = { x: clampUnit(x), y: clampUnit(y) }; + aim.current = override.current; + }; + } + + return () => { + cancelled = true; + sub?.remove(); + if (__DEV__) { + delete (globalThis as { __torchAim?: unknown }).__torchAim; + } + }; + }, []); + + return aim; +} diff --git a/example/src/screens/BubbleWrapScreen.tsx b/example/src/screens/BubbleWrapScreen.tsx new file mode 100644 index 0000000..8f3c247 --- /dev/null +++ b/example/src/screens/BubbleWrapScreen.tsx @@ -0,0 +1,119 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + type GestureResponderEvent, + Platform, + Pressable, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + Vibration, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import BubbleWrap from '../components/bubblewrap/BubbleWrap'; +import { useBubbleWrap } from '../hooks/useBubbleWrap'; +import { usePopSound } from '../hooks/usePopSound'; + +/** + * Photo: "Oberfläche Wohnzimmertisch" by Martin Lorenz, CC BY-SA 3.0, via + * Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Oberfl%C3%A4che_Wohnzimmertisch.JPG), + * rotated to portrait. + */ +const WALNUT = require('../../assets/bubblewrap/walnut.jpg'); +const IMG_W = 853; +const IMG_H = 1280; + +/** + * A sheet of bubble wrap on a walnut table. + * + * Press a bubble and it pops: a snap and a thump synthesized on the spot, + * never the same twice, and the dome crumples flat. Drag a finger (or + * several) across the sheet to pop everything it rolls over. + */ +export default function BubbleWrapScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const [popped, setPopped] = useState(0); + const playPop = usePopSound(); + + const onPop = useCallback(() => { + playPop(); + if (Platform.OS === 'android') { + Vibration.vibrate(12); + } + setPopped((n) => n + 1); + }, [playPop]); + + const { paramsSynchronizable, grid, dragTo, releaseTouches, reset } = + useBubbleWrap(width, height, onPop); + + const coverScale = useMemo<[number, number]>(() => { + const screenAspect = width / height; + const imgAspect = IMG_W / IMG_H; + return screenAspect < imgAspect + ? [screenAspect / imgAspect, 1] + : [1, imgAspect / screenAspect]; + }, [width, height]); + + const onTouches = (e: GestureResponderEvent) => { + for (const t of e.nativeEvent.touches) { + dragTo(String(t.identifier), t.pageX, t.pageY); + } + }; + + const onNewSheet = () => { + reset(); + setPopped(0); + }; + + return ( + + + + + {popped >= 12 ? ( + + New sheet + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#3a2614', + }, + newSheet: { + position: 'absolute', + alignSelf: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + backgroundColor: 'rgba(20, 12, 6, 0.55)', + }, + newSheetText: { + color: 'rgba(255, 244, 230, 0.85)', + fontSize: 14, + fontWeight: '600', + }, +}); diff --git a/example/src/screens/CrackedScreenScreen.tsx b/example/src/screens/CrackedScreenScreen.tsx new file mode 100644 index 0000000..67aa796 --- /dev/null +++ b/example/src/screens/CrackedScreenScreen.tsx @@ -0,0 +1,78 @@ +import { type ComponentRef, useMemo, useRef } from 'react'; +import { StatusBar, StyleSheet, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import CrackedGlass from '../components/cracks/CrackedGlass'; +import LockScreen from '../components/cracks/LockScreen'; +import { useCrackedGlass } from '../components/cracks/useCrackedGlass'; +import { useSnapshotTexture } from '../components/cracks/useSnapshotTexture'; + +/** + * Wallpaper: "Seaside swing at dusk" by Georg Nietsch (Unsplash), CC0, via + * Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Seaside_swing_at_dusk_(Unsplash).jpg). + */ +const WALLPAPER = require('../../assets/cracks/swing.jpg'); +const NOTIFICATION = { + title: 'Mom', + body: 'Did you drop your phone again? Your screen looked weird on FaceTime', +}; + +/** + * Tap anywhere and the cover glass breaks where you hit it. Long press to put + * a fresh screen on. + */ +export default function CrackedScreenScreen() { + const lockRef = useRef>(null); + const { uri, schedule } = useSnapshotTexture(lockRef); + const { paramsSynchronizable, hit, reset } = useCrackedGlass(); + + const gesture = useMemo(() => { + const tap = Gesture.Tap() + .runOnJS(true) + .maxDuration(400) + .onEnd((e, success) => { + if (success) { + hit(e.x, e.y); + } + }); + const hold = Gesture.LongPress() + .runOnJS(true) + .minDuration(650) + .onStart(reset); + return Gesture.Exclusive(hold, tap); + }, [hit, reset]); + + return ( + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + fill: { + ...StyleSheet.absoluteFill, + }, +}); diff --git a/example/src/screens/CrtOffScreen.tsx b/example/src/screens/CrtOffScreen.tsx new file mode 100644 index 0000000..2231054 --- /dev/null +++ b/example/src/screens/CrtOffScreen.tsx @@ -0,0 +1,128 @@ +import { + type ComponentRef, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Pressable, StatusBar, StyleSheet, View } from 'react-native'; +import { useParamsSynchronizable } from 'react-native-effects'; +import CrtTube from '../components/crt/CrtTube'; +import { playPowerOff, playPowerOn } from '../components/crt/tubeSound'; +import { useSnapshotTexture } from '../components/cracks/useSnapshotTexture'; +import HomeScreen from './HomeScreen'; + +/** Lets the tube's first texture upload finish before the splash plays. */ +const POWER_ON_DELAY_MS = 900; +/** Seconds after a toggle during which the tube is still animating. */ +const SETTLE_S = 2.2; + +/** + * The app itself switching off like an old CRT television. Its real home + * list is snapshotted with react-native-view-shot and drawn by CrtTube, which + * powers it on like a splash when the screen opens. Tap and the picture + * collapses into a white-hot line, then a dot that fades on its phosphor; tap + * again to power it back on with a degauss wobble. + */ +export default function CrtOffScreen() { + const contentRef = useRef>(null); + const { uri: snapshot, schedule } = useSnapshotTexture(contentRef); + const [isOff, setIsOff] = useState(true); + const toggledAt = useRef(0); + const frame = useRef(null); + const splashTimer = useRef | null>(null); + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([1, SETTLE_S, 0, 0]); + + useEffect(() => { + return () => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + }; + }, []); + + const run = useCallback( + (off: boolean) => { + const tick = () => { + const elapsed = (performance.now() - toggledAt.current) / 1000; + setParamsSynchronizable(off ? 1 : 0, Math.min(elapsed, SETTLE_S), 0, 0); + frame.current = elapsed < SETTLE_S ? requestAnimationFrame(tick) : null; + }; + if (frame.current !== null) cancelAnimationFrame(frame.current); + toggledAt.current = performance.now(); + tick(); + }, + [setParamsSynchronizable] + ); + + useEffect(() => { + if (!snapshot) return; + const id = setTimeout(() => { + setIsOff(false); + run(false); + }, POWER_ON_DELAY_MS); + splashTimer.current = id; + return () => clearTimeout(id); + }, [snapshot, run]); + + const toggle = useCallback(() => { + if (splashTimer.current !== null) clearTimeout(splashTimer.current); + const next = !isOff; + setIsOff(next); + run(next); + if (next) playPowerOff(); + else playPowerOn(); + }, [isOff, run]); + + // `__crtFreeze(off, seconds)` holds the tube at one moment of a power + // transition for inspection; the next tap resumes normal playback. + useEffect(() => { + if (!__DEV__) return; + const g = globalThis as { + __crtFreeze?: (off: boolean, seconds: number) => void; + }; + g.__crtFreeze = (off, seconds) => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + setIsOff(off); + setParamsSynchronizable(off ? 1 : 0, seconds, 0, 0); + }; + return () => { + delete g.__crtFreeze; + }; + }, [setParamsSynchronizable]); + + return ( + + + + + + + {snapshot && ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, +}); diff --git a/example/src/screens/DuoCrackgateScreen.tsx b/example/src/screens/DuoCrackgateScreen.tsx new file mode 100644 index 0000000..b64b59a --- /dev/null +++ b/example/src/screens/DuoCrackgateScreen.tsx @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import HingeFailure from '../components/cracks/HingeFailure'; +import LockScreen from '../components/cracks/LockScreen'; +import { useHingeFailure } from '../components/cracks/useHingeFailure'; + +/** + * Wallpaper: "Palm silhouettes" by Prakash G (User:Prakashsrivaths), CC BY-SA + * 4.0, via Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Palm_silhouettes.jpg). + */ +const WALLPAPER = require('../../assets/cracks/palms.jpg'); +const CLOCK_ORANGE = '#F79A2E'; +const RESET_HOLD_MS = 600; +/** A beat after the panel opens, so the failure plays on a lit screen. */ +const FOLD_SETTLE_MS = 700; + +/** + * The iPhone Duo "crackgate" inner display failure. The lock screen looks + * fine for a moment, then the hinge gives out on its own. Folding or + * unfolding the phone (a window size change) replays it, a tap pushes the + * damage one stage further or replays a dead panel, and a long press puts a + * fresh panel in. On phones without a fold the hinge is the centre line. + */ +export default function DuoCrackgateScreen() { + const { width, height } = useWindowDimensions(); + const { paramsSynchronizable, play, advance, reset } = useHingeFailure(); + + useEffect(play, [play]); + + const lastSize = useRef({ width, height }); + useEffect(() => { + const prev = lastSize.current; + if (prev.width === width && prev.height === height) { + return; + } + lastSize.current = { width, height }; + reset(); + const id = setTimeout(play, FOLD_SETTLE_MS); + return () => clearTimeout(id); + }, [width, height, play, reset]); + + const gesture = useMemo(() => { + const hold = Gesture.LongPress() + .runOnJS(true) + .minDuration(RESET_HOLD_MS) + .onStart(reset); + const tap = Gesture.Tap() + .runOnJS(true) + .onEnd((_event, success) => success && advance()); + return Gesture.Exclusive(hold, tap); + }, [advance, reset]); + + return ( + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + fill: { + ...StyleSheet.absoluteFill, + }, +}); diff --git a/example/src/screens/FerrofluidScreen.tsx b/example/src/screens/FerrofluidScreen.tsx new file mode 100644 index 0000000..58ce06a --- /dev/null +++ b/example/src/screens/FerrofluidScreen.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { + type GestureResponderEvent, + Pressable, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Ferrofluid from '../components/ferrofluid/Ferrofluid'; +import { useFerrofluidField } from '../hooks/useFerrofluidField'; + +/** + * Photo: "Marble Kymi Euboea Greece" by Jebulon, CC0, via Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Marble_Kymi_Euboea_Greece.jpg), + * rotated to portrait. + */ +const MARBLE = require('../../assets/ferrofluid/marble.jpg'); +const IMG_W = 853; +const IMG_H = 1280; + +/** + * Ferrofluid that answers a real magnet. + * + * A puddle of black ferrofluid on a marble counter. Bring a magnet close to + * the phone and the magnetometer picks it up: the fluid creeps toward it and + * stands up into a field of sharp spikes that grow with the field. Without a + * magnetometer, touch and hold: the finger is the magnet. "Recalibrate" takes + * a fresh Earth-field baseline (do it with the magnet away). + */ +export default function FerrofluidScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { paramsSynchronizable, setTouch, calibrate, sensorAvailable } = + useFerrofluidField(width, height); + + const coverScale = useMemo<[number, number]>(() => { + const screenAspect = width / height; + const imgAspect = IMG_W / IMG_H; + return screenAspect < imgAspect + ? [screenAspect / imgAspect, 1] + : [1, imgAspect / screenAspect]; + }, [width, height]); + + const onTouch = (e: GestureResponderEvent) => { + setTouch({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); + }; + const onRelease = () => setTouch(null); + + const hint = + sensorAvailable === false + ? 'Touch and hold to bring in a magnet' + : 'Hold a magnet near your phone'; + + return ( + + + + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onTouch} + onResponderMove={onTouch} + onResponderRelease={onRelease} + onResponderTerminate={onRelease} + /> + + {hint} + {sensorAvailable ? ( + + Recalibrate + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#d9d6d1', + }, + footer: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + gap: 10, + }, + hint: { + color: 'rgba(30, 30, 34, 0.62)', + fontSize: 14, + fontWeight: '600', + letterSpacing: 0.2, + }, + calibrate: { + color: 'rgba(30, 30, 34, 0.45)', + fontSize: 13, + fontWeight: '600', + }, +}); diff --git a/example/src/screens/FlashlightScreen.tsx b/example/src/screens/FlashlightScreen.tsx new file mode 100644 index 0000000..1a4ea7c --- /dev/null +++ b/example/src/screens/FlashlightScreen.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Image, + Pressable, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useParamsSynchronizable } from 'react-native-effects'; +import TorchScene from '../components/flashlight/TorchScene'; +import TorchIsland, { + type IslandMode, +} from '../components/flashlight/TorchIsland'; +import { useTorchAim } from '../hooks/useTorchAim'; + +/** + * Photo: "Grungy corridor of abandoned school" by Shixart1985, CC BY 2.0, via + * Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Grungy_corridor_of_abandoned_school.jpg). + */ +const CORRIDOR = require('../../assets/flashlight/corridor.jpg'); +const CORRIDOR_ASPECT = 1068 / 1600; +const TORCH_OFF = require('../../assets/flashlight/flashlight-off.png'); +const CAMERA = require('../../assets/flashlight/camera.png'); + +/** pt of drag for a full sweep of beam width / intensity. */ +const DRAG_RANGE = { beam: 170, level: 210 }; + +function clamp01(v: number) { + return Math.max(0, Math.min(1, v)); +} + +function approach(current: number, target: number, rate: number, dt: number) { + return current + (target - current) * (1 - Math.exp(-rate * dt)); +} + +/** + * The iOS 18 torch in a dark corridor. The lock-screen button switches it on + * and the Dynamic Island grows into the flashlight control: drag up/down on + * it for intensity and left/right for beam width; the spot on the photo + * follows, and tilting the phone sweeps it across the corridor. + */ +export default function FlashlightScreen() { + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); + const aim = useTorchAim(); + const [isOn, setIsOn] = useState(false); + const [mode, setMode] = useState('idle'); + const target = useRef({ beam: 0.55, level: 0.75, power: 0 }); + const state = useRef({ beam: 0.55, level: 0.75, power: 0, x: 0, y: 0 }); + + const scene = useParamsSynchronizable([width / 2, height * 0.45, 0.55, 0]); + const island = useParamsSynchronizable([0.55, 0.75, 0, 0]); + const setScene = scene.setParamsSynchronizable; + const setIsland = island.setParamsSynchronizable; + + useEffect(() => { + let frame = 0; + let last = performance.now(); + const tick = () => { + const now = performance.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const s = state.current; + const t = target.current; + s.beam = approach(s.beam, t.beam, 14, dt); + s.level = approach(s.level, t.level, 14, dt); + s.power = approach(s.power, t.power, 28, dt); + s.x = approach(s.x, aim.current.x, 7, dt); + s.y = approach(s.y, aim.current.y, 7, dt); + const spotX = width * (0.5 + 0.42 * s.x); + const spotY = height * (0.47 - 0.36 * s.y); + setScene(spotX, spotY, s.beam, s.power * (0.25 + 0.75 * s.level)); + setIsland(s.beam, s.level, s.power, 0); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [aim, width, height, setScene, setIsland]); + + const setTorch = useCallback((on: boolean) => { + target.current.power = on ? 1 : 0; + setIsOn(on); + }, []); + + const onLockButton = useCallback(() => { + const next = !isOn; + setTorch(next); + setMode(next ? 'expanded' : 'idle'); + }, [isOn, setTorch]); + + const onIslandToggle = useCallback(() => setTorch(!isOn), [isOn, setTorch]); + + const onAdjust = useCallback( + (dx: number, dy: number) => { + const t = target.current; + t.beam = clamp01(t.beam + dx / DRAG_RANGE.beam); + t.level = clamp01(t.level - dy / DRAG_RANGE.level); + if (!isOn) setTorch(true); + }, + [isOn, setTorch] + ); + + const collapse = useCallback(() => { + if (mode === 'expanded') setMode(isOn ? 'compact' : 'idle'); + }, [mode, isOn]); + + return ( + + + + setMode('expanded')} + onToggle={onIslandToggle} + onAdjust={onAdjust} + /> + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + quickRow: { + position: 'absolute', + left: 46, + right: 46, + flexDirection: 'row', + justifyContent: 'space-between', + }, + quickButton: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(60,60,72,0.45)', + alignItems: 'center', + justifyContent: 'center', + }, + quickButtonOn: { + backgroundColor: 'rgba(235,236,245,0.92)', + }, + torchGlyph: { + width: 12, + height: 22, + }, + torchGlyphOn: { + tintColor: '#5b5bd6', + }, + torchGlyphOff: { + tintColor: '#fff', + }, + cameraGlyph: { + width: 24, + height: 18, + tintColor: '#fff', + }, + homeBar: { + position: 'absolute', + alignSelf: 'center', + bottom: 8, + width: 140, + height: 5, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.85)', + }, +}); diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 02672e3..c1ebc3d 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -11,6 +11,94 @@ import { useNavigation } from '@react-navigation/native'; import type { ExampleCategory, HomeScreenNavigationProp } from '../types'; const EXAMPLE_CATEGORIES: ExampleCategory[] = [ + { + id: 'cracked-screen', + title: 'Cracked Screen', + description: 'Tap hard and the glass breaks where you hit it', + screen: 'CrackedScreenExample', + color: '#9fb3c8', + image: require('../../assets/components/cracked-screen.png'), + }, + { + id: 'duo-crackgate', + title: 'Crackgate', + description: 'The iPhone Duo hinge crack, white pixels and all', + screen: 'DuoCrackgateExample', + color: '#e8e8e8', + image: require('../../assets/components/duo-crackgate.png'), + }, + { + id: 'polaroid', + title: 'Polaroid', + description: 'Snap a photo and shake it while it develops', + screen: 'PolaroidExample', + color: '#d9cfb8', + image: require('../../assets/components/polaroid.png'), + }, + { + id: 'scratch-off', + title: 'Scratch-off', + description: 'Scratch the foil to reveal your prize', + screen: 'ScratchOffExample', + color: '#c0c0c0', + image: require('../../assets/components/scratch-off.png'), + }, + { + id: 'bubble-wrap', + title: 'Bubble Wrap', + description: 'Pop every last bubble', + screen: 'BubbleWrapExample', + color: '#a8d8ea', + image: require('../../assets/components/bubble-wrap.png'), + }, + { + id: 'lava-lamp', + title: 'Lava Lamp', + description: 'Warm wax rises, tilt to stir it', + screen: 'LavaLampExample', + color: '#ff5e3a', + image: require('../../assets/components/lava-lamp.png'), + }, + { + id: 'ferrofluid', + title: 'Ferrofluid', + description: 'Hold a real magnet near your phone', + screen: 'FerrofluidExample', + color: '#1c1c1e', + image: require('../../assets/components/ferrofluid.png'), + }, + { + id: 'crt-off', + title: 'CRT Off', + description: 'Close the app like an old TV', + screen: 'CrtOffExample', + color: '#39ff88', + image: require('../../assets/components/crt-off.png'), + }, + { + id: 'flashlight', + title: 'Flashlight', + description: 'The Dynamic Island torch, swipe to shape the beam', + screen: 'FlashlightExample', + color: '#fff6d5', + image: require('../../assets/components/flashlight.png'), + }, + { + id: 'wallet', + title: 'Wallet', + description: 'Double-click to pay, the card lifts and glints', + screen: 'WalletPayExample', + color: '#111111', + image: require('../../assets/components/wallet.png'), + }, + { + id: 'namedrop', + title: 'NameDrop', + description: 'Bring two iPhones together and swap contacts', + screen: 'NameDropExample', + color: '#7fd3ff', + image: require('../../assets/components/namedrop.png'), + }, { id: 'imessage', title: 'iMessage Effects', diff --git a/example/src/screens/LavaLampScreen.tsx b/example/src/screens/LavaLampScreen.tsx new file mode 100644 index 0000000..668e8f6 --- /dev/null +++ b/example/src/screens/LavaLampScreen.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native'; +import LavaLamp from '../components/lavalamp/LavaLamp'; +import { useLavaLamp } from '../hooks/useLavaLamp'; + +/** + * Photo: "Lava lamp (yellow)" by DMGualtieri, CC BY-SA 4.0, via Wikimedia + * Commons (https://commons.wikimedia.org/wiki/File:Lava_lamp_(yellow).jpg). + * Its glass is redrawn by the shader; the cap, base and room are the photo. + */ +const LAMP = require('../../assets/lavalamp/lava-lamp.jpg'); +const IMG_W = 1280; +const IMG_H = 1795; + +/** + * A lava lamp on a table in the evening. The wax heats at the bottom, rises, + * cools and sinks at a lava lamp's own pace. Tilt the phone and the wax + * drifts along real gravity. + */ +export default function LavaLampScreen() { + const { width, height } = useWindowDimensions(); + const { paramsSynchronizable } = useLavaLamp(); + + const coverScale = useMemo<[number, number]>(() => { + const screenAspect = width / height; + const imgAspect = IMG_W / IMG_H; + return screenAspect < imgAspect + ? [screenAspect / imgAspect, 1] + : [1, imgAspect / screenAspect]; + }, [width, height]); + + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#120c08', + }, +}); diff --git a/example/src/screens/NameDropScreen.tsx b/example/src/screens/NameDropScreen.tsx new file mode 100644 index 0000000..29a993e --- /dev/null +++ b/example/src/screens/NameDropScreen.tsx @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Image, + Pressable, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; +import { captureRef } from 'react-native-view-shot'; +import { useParamsSynchronizable } from 'react-native-effects'; +import NameDropWave, { HOME_ATLAS } from '../components/namedrop/NameDropWave'; +import PosterReveal from '../components/namedrop/PosterReveal'; +import ContactPoster, { + type ExchangeChoice, + type PosterHandle, +} from '../components/namedrop/ContactPoster'; + +/** Downward drag (pt) that counts as bringing the other phone close. */ +const TRIGGER_DRAG = 70; +/** Seconds from contact until the poster is fully on screen. */ +const REVEAL_SECONDS = 2.42; +const RESET_AFTER_CHOICE_MS = 1600; + +type Phase = 'home' | 'exchanging' | 'poster'; + +type NameDropDevHooks = { + __nameDrop?: () => void; + __nameDropWaveAt?: (seconds: number) => void; + __nameDropSpeed?: (factor: number | null) => void; +}; + +let playbackSpeed = 1; + +async function snapshotPoster(view: PosterHandle) { + const raw = await captureRef(view, { + format: 'png', + quality: 1, + result: 'tmpfile', + }); + return raw.startsWith('file://') ? raw : `file://${raw}`; +} + +/** + * NameDrop: drag down from the top of the home screen (or tap it) to bring + * the other iPhone close. A coloured glow climbs the screen and pools under + * the Dynamic Island, a liquid wave of light bursts out of it, and the other + * person's "Share Your Contact" poster swells into focus. Tap it to go back. + */ +export default function NameDropScreen() { + const { width, height } = useWindowDimensions(); + const [phase, setPhase] = useState('home'); + const [choice, setChoice] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const { paramsSynchronizable } = useParamsSynchronizable([0, 0, 0, 0]); + const posterRef = useRef(null); + const resetTimer = useRef | null>(null); + const isPlaying = useRef(false); + const clock = useSharedValue(0); + const cover = useSharedValue(1); + + useAnimatedReaction( + () => clock.value, + (seconds) => { + paramsSynchronizable.setBlocking(new Float64Array([seconds, 0, 0, 0])); + } + ); + + const stopClock = useCallback(() => { + cancelAnimation(clock); + isPlaying.current = false; + if (resetTimer.current !== null) clearTimeout(resetTimer.current); + resetTimer.current = null; + }, [clock]); + + useEffect(() => stopClock, [stopClock]); + + const showPoster = useCallback(() => { + isPlaying.current = false; + setPhase('poster'); + }, []); + + const play = useCallback(() => { + stopClock(); + isPlaying.current = true; + setChoice(null); + setPhase('exchanging'); + cover.value = 1; + clock.value = 0; + clock.value = withTiming( + REVEAL_SECONDS, + { + duration: (REVEAL_SECONDS * 1000) / playbackSpeed, + easing: Easing.linear, + }, + (finished) => { + if (!finished) return; + cover.value = 0; + scheduleOnRN(showPoster); + } + ); + }, [stopClock, cover, clock, showPoster]); + + const goHome = useCallback(() => { + stopClock(); + clock.value = 0; + setPhase('home'); + cover.value = withTiming(1, { duration: 320 }); + }, [stopClock, cover, clock]); + + const choose = useCallback( + (picked: 'share' | 'receive') => { + setChoice(picked); + resetTimer.current = setTimeout(goHome, RESET_AFTER_CHOICE_MS); + }, + [goHome] + ); + + const captureSnapshot = useCallback(() => { + if (!posterRef.current) return; + snapshotPoster(posterRef.current) + .then(setSnapshot) + .catch((e) => console.warn('[NameDrop] poster snapshot failed:', e)); + }, []); + + // `__nameDrop()` plays the exchange, `__nameDropWaveAt(seconds)` freezes + // it at one moment and `__nameDropSpeed(factor | null)` slows it down. + useEffect(() => { + if (!__DEV__) return; + const g = globalThis as NameDropDevHooks; + g.__nameDrop = play; + g.__nameDropWaveAt = (seconds) => { + stopClock(); + clock.value = seconds; + const done = seconds >= REVEAL_SECONDS; + setPhase(done ? 'poster' : 'exchanging'); + cover.value = done ? 0 : 1; + }; + g.__nameDropSpeed = (factor) => { + playbackSpeed = factor ?? 1; + }; + return () => { + delete g.__nameDrop; + delete g.__nameDropWaveAt; + delete g.__nameDropSpeed; + }; + }, [play, stopClock, cover, clock]); + + const approach = Gesture.Pan() + .runOnJS(true) + .enabled(phase === 'home') + .activeOffsetY(12) + .onUpdate((e) => { + if (e.translationY > TRIGGER_DRAG && !isPlaying.current) play(); + }); + const tap = Gesture.Tap() + .runOnJS(true) + .enabled(phase === 'home') + .onEnd(play); + const homeGesture = Gesture.Exclusive(approach, tap); + + const coverStyle = useAnimatedStyle(() => ({ opacity: cover.value })); + + return ( + + + + + + + + + + + {snapshot !== null && ( + + )} + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + clip: { + overflow: 'hidden', + }, +}); diff --git a/example/src/screens/PolaroidScreen.tsx b/example/src/screens/PolaroidScreen.tsx new file mode 100644 index 0000000..c3473cc --- /dev/null +++ b/example/src/screens/PolaroidScreen.tsx @@ -0,0 +1,407 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Image, + StatusBar, + StyleSheet, + useWindowDimensions, + View, + type ImageSourcePropType, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, + type SharedValue, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useParamsSynchronizable } from 'react-native-effects'; +import PolaroidPrint, { + PRINT_ASPECT, +} from '../components/polaroid/PolaroidPrint'; +import PolaroidCamera, { + CAMERA_BODY_HEIGHT, + type PolaroidCameraHandle, +} from '../components/polaroid/PolaroidCamera'; +import SquareCropper, { + type SquareCropperHandle, +} from '../components/polaroid/SquareCropper'; +import { + playFilmWhirr, + playShutterClick, + stopCameraSound, +} from '../components/polaroid/cameraSound'; +import { usePolaroidShake } from '../hooks/usePolaroidShake'; + +/** + * Photos, all via Wikimedia Commons: + * table: "Weatherworn top of wooden table" (CC0); + * beach: "Black Rock beach" by Carey Ciuro (CC BY 2.0); + * lake: "Evening on Kathleen Lake" by Jakub Fryš (CC BY-SA 4.0); + * mountains: "Mountain lake, Mylgval mountain cirque before sunset, + * Caucasus Mountains" by Vyacheslav Argenberg (CC BY 4.0). + */ +const TABLE = require('../../assets/polaroid/table.jpg'); +const PHOTOS = [ + require('../../assets/polaroid/photo-beach.jpg'), + require('../../assets/polaroid/photo-lake.jpg'), + require('../../assets/polaroid/photo-mountains.jpg'), +]; + +/** Real prints are readable in ~2 min and done in ~15; this is the demo cut. */ +const DEVELOP_SECONDS = 20; +/** A hard shake develops this much faster (it does nothing in reality). */ +const SHAKE_BOOST = 1.6; +const EJECT_MS = 1500; +const MAX_PRINTS = 8; +/** + * Mounting a ShaderView and uploading its texture touch the shared GPU device + * off the render thread, and Dawn segfaults if another view submits a frame at + * that moment. Prints on the table stop rendering around each new one. + */ +const GPU_PAUSE_LEAD_MS = 150; +const GPU_PAUSE_MS = 700; + +type PrintSpec = { + id: number; + photo: ImageSourcePropType; + seed: number; + angle: number; + dx: number; + dy: number; + z: number; +}; + +type DragBounds = { + minX: number; + maxX: number; + minY: number; + maxY: number; +}; + +type DebugGlobal = { __polaroidPhoto?: (uri: string) => void }; + +function makePrint( + id: number, + z: number, + photo: ImageSourcePropType +): PrintSpec { + return { + id, + z, + photo, + seed: Math.random(), + angle: (Math.random() - 0.5) * 16, + dx: (Math.random() - 0.5) * 56, + dy: (Math.random() - 0.5) * 44, + }; +} + +function clamp(value: number, min: number, max: number) { + 'worklet'; + return Math.min(max, Math.max(min, value)); +} + +/** + * One print on the table. It ejects from the camera, develops on its own + * clock and can be dragged around; the print on top hangs from the fingers + * and sways when the phone is shaken. + */ +function Print({ + spec, + width, + height, + travel, + bounds, + shakeLevel, + sway, + isTop, + paused, + onLift, +}: { + spec: PrintSpec; + width: number; + height: number; + travel: number; + bounds: DragBounds; + shakeLevel: React.RefObject; + sway: SharedValue; + isTop: boolean; + paused: boolean; + onLift: (id: number) => void; +}) { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([0, 0, spec.seed, 0]); + const [developed, setDeveloped] = useState(false); + const rise = useSharedValue(travel); + const angle = useSharedValue(0); + const topFlag = useSharedValue(isTop ? 1 : 0); + const dragX = useSharedValue(0); + const dragY = useSharedValue(0); + const lift = useSharedValue(0); + const dev = useRef(0); + + useEffect(() => { + topFlag.value = withTiming(isTop ? 1 : 0, { duration: 400 }); + }, [isTop, topFlag]); + + useEffect(() => { + rise.value = withTiming(0, { + duration: EJECT_MS, + easing: Easing.bezier(0.2, 0.0, 0.35, 1), + }); + angle.value = withSpring(spec.angle, { damping: 14, stiffness: 60 }); + }, [rise, angle, spec.angle]); + + useEffect(() => { + let last = Date.now(); + let frame = 0; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const boost = 1 + SHAKE_BOOST * (shakeLevel.current ?? 0); + dev.current = Math.min(1, dev.current + (dt / DEVELOP_SECONDS) * boost); + if (dev.current === 1) { + setParamsSynchronizable(1, 0, spec.seed, 0); + setDeveloped(true); + return; + } + setParamsSynchronizable(dev.current, sway.value / 9, spec.seed, 0); + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [setParamsSynchronizable, shakeLevel, spec.seed, sway]); + + const pan = Gesture.Pan() + .onBegin(() => { + runOnJS(onLift)(spec.id); + lift.value = withTiming(1, { duration: 120 }); + }) + .onChange((e) => { + dragX.value = clamp(dragX.value + e.changeX, bounds.minX, bounds.maxX); + dragY.value = clamp(dragY.value + e.changeY, bounds.minY, bounds.maxY); + }) + .onFinalize(() => { + lift.value = withTiming(0, { duration: 200 }); + }); + + const style = useAnimatedStyle(() => ({ + transform: [ + { translateX: spec.dx + dragX.value }, + { translateY: rise.value + spec.dy + dragY.value }, + { rotate: `${angle.value + sway.value * topFlag.value}deg` }, + { scale: 1 + lift.value * 0.04 }, + ], + })); + + return ( + + + + + + + + ); +} + +/** + * Polaroid instant print. + * + * Press the shutter: it clicks, the camera takes a real photo (or a bundled + * one on the simulator or without permission), the film rolls and a print + * slides out onto the table. It starts as the milky blue-grey of the + * opacifier and develops over about twenty seconds in the order the film does + * it. Shake the phone and the top print swings in your fingers while every wet + * print develops a little faster. Prints pile up, newest on top, and can be dragged + * apart; a finished print is frozen and costs no GPU time. + * `__polaroidPhoto(uri)` prints any image in dev builds, through the same + * square crop a real photo takes. + */ +export default function PolaroidScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { level, sway } = usePolaroidShake(); + const camera = useRef(null); + const cropper = useRef(null); + const nextId = useRef(0); + const nextZ = useRef(0); + const busy = useRef(false); + const left = useRef(false); + const [prints, setPrints] = useState([]); + const [bundledIndex, setBundledIndex] = useState(0); + const [pausedBelowId, setPausedBelowId] = useState(0); + + const printWidth = Math.min(width * 0.66, 300); + const printHeight = printWidth / PRINT_ASPECT; + const bodyTop = height - CAMERA_BODY_HEIGHT - insets.bottom; + const printTop = Math.max( + insets.top + 24, + (bodyTop - printHeight) * 0.5 + insets.top * 0.3 + ); + const printLeft = (width - printWidth) / 2; + const travel = bodyTop - printTop; + const topZ = Math.max(...prints.map((p) => p.z)); + + const bounds: DragBounds = { + minX: -printLeft - printWidth * 0.4, + maxX: printLeft + printWidth * 0.4, + minY: -printTop - printHeight * 0.3, + maxY: bodyTop - printTop - printHeight * 0.45, + }; + + const addPrint = useCallback((photo: ImageSourcePropType) => { + const spec = makePrint(nextId.current++, ++nextZ.current, photo); + setPausedBelowId(spec.id); + setTimeout(() => { + if (left.current) { + return; + } + playFilmWhirr(); + setPrints((ps) => [...ps, spec].slice(-MAX_PRINTS)); + }, GPU_PAUSE_LEAD_MS); + setTimeout(() => setPausedBelowId(0), GPU_PAUSE_LEAD_MS + GPU_PAUSE_MS); + }, []); + + const liftPrint = useCallback((id: number) => { + const z = ++nextZ.current; + setPrints((ps) => ps.map((p) => (p.id === id ? { ...p, z } : p))); + }, []); + + const squarePhoto = useCallback(async () => { + const uri = await camera.current?.takePhoto(); + return uri ? cropper.current?.crop(uri) : null; + }, []); + + const shoot = useCallback(async () => { + if (busy.current) { + return; + } + busy.current = true; + camera.current?.blink(); + playShutterClick(); + const photo = await squarePhoto().catch(() => null); + setTimeout(() => { + busy.current = false; + }, EJECT_MS); + if (photo) { + addPrint({ uri: photo }); + return; + } + addPrint(PHOTOS[bundledIndex % PHOTOS.length]!); + setBundledIndex(bundledIndex + 1); + }, [addPrint, bundledIndex, squarePhoto]); + + useEffect(() => { + left.current = false; + return () => { + left.current = true; + stopCameraSound(); + }; + }, []); + + useEffect(() => { + if (!__DEV__) { + return; + } + const debug = globalThis as DebugGlobal; + debug.__polaroidPhoto = (uri) => { + cropper.current?.crop(uri).then((photo) => addPrint({ uri: photo })); + }; + return () => { + delete debug.__polaroidPhoto; + }; + }, [addPrint]); + + return ( + + + + + + + + {prints.map((p) => ( + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#2a1a10', + }, + shade: { + ...StyleSheet.absoluteFill, + backgroundColor: 'rgba(20, 10, 4, 0.18)', + }, + stage: { + position: 'absolute', + }, + print: { + position: 'absolute', + left: 0, + top: 0, + shadowColor: '#000', + shadowOpacity: 0.45, + shadowRadius: 14, + shadowOffset: { width: 0, height: 8 }, + backgroundColor: '#efeee9', + borderRadius: 3, + }, + paper: { + flex: 1, + borderRadius: 3, + overflow: 'hidden', + }, +}); diff --git a/example/src/screens/ScratchOffScreen.tsx b/example/src/screens/ScratchOffScreen.tsx new file mode 100644 index 0000000..8ae6be9 --- /dev/null +++ b/example/src/screens/ScratchOffScreen.tsx @@ -0,0 +1,294 @@ +import { useCallback, useState } from 'react'; +import { + Pressable, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Flakes from '../components/scratchoff/Flakes'; +import ScratchCoating, { + AREAS, + PLAY_H, + PLAY_W, + type PrizeSymbol, +} from '../components/scratchoff/ScratchCoating'; +import { useScratchCard } from '../hooks/useScratchCard'; + +const PRIZES = ['$5', '$20', '$50', '$500']; + +type Ticket = { + symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol, PrizeSymbol]; + amounts: string[]; + bonus: string; + serial: string; + win: boolean; +}; + +function pick(list: readonly T[]): T { + return list[Math.floor(Math.random() * list.length)]!; +} + +function makeTicket(): Ticket { + const win = Math.random() < 0.6; + const main = pick([0, 1, 2, 3]); + const others = ([0, 1, 2, 3] as PrizeSymbol[]).filter((s) => s !== main); + const row: PrizeSymbol[] = win + ? [main, main, main] + : [main, main, pick(others)].sort(() => Math.random() - 0.5); + const prize = pick(PRIZES); + const serial = Array.from({ length: 3 }, () => + Math.floor(1000 + Math.random() * 9000) + ).join(' '); + return { + symbols: [row[0]!, row[1]!, row[2]!, pick([0, 1, 2, 3])], + amounts: win ? [prize, prize, prize] : row.map(() => pick(PRIZES)), + bonus: pick(['$2', '$10', '$25']), + serial, + win, + }; +} + +/** + * A scratch card. + * + * Drag a finger over the silver to scratch it off: the latex tears away + * along the stroke with flaky edges, crumbs tumble off, and it rasps louder + * the faster you go. Each prize box clears itself once most of it is + * scratched, with a sparkle and a haptic tick. Tilt the phone to move the + * foil's sheen and the holographic symbols' rainbow. + */ +export default function ScratchOffScreen() { + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + const scale = (width * 0.92) / CARD_W; + const { paramsSynchronizable, flakeState, stroke, lift, reset, revealed } = + useScratchCard(); + const [ticket, setTicket] = useState(makeTicket); + + const newCard = useCallback(() => { + reset(); + setTicket(makeTicket()); + }, [reset]); + + const scratch = Gesture.Pan() + .minDistance(0) + .runOnJS(true) + .onBegin((e) => stroke(e.x, e.y, true)) + .onUpdate((e) => stroke(e.x, e.y, false)) + .onFinalize(lift); + + const done = revealed >= 3; + + return ( + + + + + + + + Clover Cash + + Match three symbols, win that prize. + + + + $5 + + + + + + {AREAS.map((a, i) => ( + + + {i === 3 ? ticket.bonus : ticket.amounts[i]} + + + ))} + + + + + + + No. {ticket.serial} + Void if altered + + + + + + {ticket.win ? `You won ${ticket.amounts[0]}` : 'Not this time'} + + + [ + styles.button, + { bottom: insets.bottom + 24 }, + pressed && styles.buttonPressed, + ]} + > + New card + + + ); +} + +const GREEN = '#1d4a37'; +const CARD_W = PLAY_W + 44; +const SERIF = 'Georgia'; + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#14171a', + alignItems: 'center', + justifyContent: 'center', + paddingBottom: 90, + }, + card: { + width: CARD_W, + borderRadius: 18, + backgroundColor: '#f2ead8', + padding: 7, + shadowColor: '#000', + shadowOpacity: 0.5, + shadowRadius: 24, + shadowOffset: { width: 0, height: 14 }, + }, + frame: { + borderRadius: 13, + borderWidth: 1.5, + borderColor: GREEN, + paddingHorizontal: 14, + paddingTop: 18, + paddingBottom: 14, + }, + head: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 18, + }, + title: { + fontFamily: SERIF, + fontSize: 32, + fontWeight: '700', + color: GREEN, + letterSpacing: -0.5, + }, + subtitle: { + fontFamily: SERIF, + fontStyle: 'italic', + fontSize: 14, + color: '#55635a', + marginTop: 4, + }, + price: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: '#b8892b', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#e2c27a', + }, + priceText: { + fontFamily: SERIF, + fontSize: 20, + fontWeight: '700', + color: '#fffaf0', + }, + play: { + width: PLAY_W, + height: PLAY_H, + }, + panel: { + position: 'absolute', + borderRadius: 10, + backgroundColor: '#fbf7ee', + borderWidth: 1, + borderColor: 'rgba(29, 74, 55, 0.25)', + alignItems: 'center', + justifyContent: 'flex-end', + paddingBottom: 8, + }, + bonusPanel: { + alignItems: 'flex-end', + justifyContent: 'center', + paddingBottom: 0, + paddingRight: 26, + }, + amount: { + fontFamily: SERIF, + fontSize: 19, + fontWeight: '700', + color: GREEN, + }, + bonusAmount: { + fontFamily: SERIF, + fontSize: 28, + fontWeight: '700', + color: GREEN, + }, + coating: { + position: 'absolute', + left: 0, + top: 0, + width: PLAY_W, + height: PLAY_H, + }, + foot: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 14, + }, + fine: { + fontSize: 10, + color: '#7b847d', + letterSpacing: 0.4, + }, + result: { + marginTop: 36, + fontSize: 20, + fontWeight: '600', + color: '#f2ead8', + }, + hidden: { + opacity: 0, + }, + button: { + position: 'absolute', + paddingHorizontal: 26, + paddingVertical: 14, + borderRadius: 26, + backgroundColor: 'rgba(255,255,255,0.12)', + }, + buttonPressed: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/example/src/screens/WalletPayScreen.tsx b/example/src/screens/WalletPayScreen.tsx new file mode 100644 index 0000000..cd70436 --- /dev/null +++ b/example/src/screens/WalletPayScreen.tsx @@ -0,0 +1,356 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + Vibration, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { + CardArt, + StackCard, + type StackCardSpec, +} from '../components/wallet/CardArt'; +import CardSurface, { CARD_RADIUS } from '../components/wallet/CardSurface'; +import PayGlyph from '../components/wallet/PayGlyph'; +import { playChime } from '../components/wallet/chime'; +import { useHoldTilt } from '../hooks/useHoldTilt'; + +const CARD_ASPECT = 1.586; +const GLYPH_SIZE = 84; + +/** The flow, in seconds from the double click. */ +const FLOW = { + faceIn: [0.15, 0.35], + nod: [0.35, 0.8], + ringIn: [0.75, 0.95], + checkDraw: [1.2, 1.45], + faceOut: [1.7, 1.85], + readerIn: [1.85, 2.1], + paid: 4.4, + doneIn: [4.4, 4.75], + settle: [4.6, 5.2], + end: 7.2, + doneOut: [7.2, 7.5], +} as const; + +type Stage = 'idle' | 'auth' | 'reader' | 'done'; + +type DebugGlobal = { __walletSeek?: (t: number | null) => void }; + +function ramp(t: number, [a, b]: readonly [number, number]): number { + return Math.max(0, Math.min(1, (t - a) / (b - a))); +} + +function easeOut(x: number): number { + return 1 - (1 - x) * (1 - x) * (1 - x); +} + +function stageAt(t: number): Stage { + if (t < FLOW.readerIn[0]) { + return 'auth'; + } + if (t < FLOW.paid) { + return 'reader'; + } + if (t < FLOW.doneOut[1]) { + return 'done'; + } + return 'idle'; +} + +const STAGE_LABEL: Record = { + idle: '', + auth: 'Pay with Face ID', + reader: 'Hold Near Reader', + done: 'Done', +}; + +/** + * Apple Pay, the double-click-to-pay sheet. + * + * A brushed titanium card waits at the top, the other cards stacked at the + * bottom, and the side-button hint. Double-tap anywhere (standing in for the + * double click): the card lifts and a glint runs over it, Face ID scans and + * checks, Hold Near Reader pulses, then the payment lands with a haptic, a + * chime and the blue Done check, and the card settles back. Tilting the + * phone slides the light across the brushed metal. + * + * Dev builds: `__walletTilt(x, y)` stands in for the motion sensor and + * `__walletSeek(seconds)` freezes the flow at a moment (`null` resumes). + */ +export default function WalletPayScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const tilt = useHoldTilt('__walletTilt'); + const card = useParamsSynchronizable([0, 0, 0, 0]); + const glyph = useParamsSynchronizable([0, 0, 0, 0, 0, 0, 0, 0]); + const startedAt = useRef(null); + const frozenAt = useRef(null); + const [stage, setStage] = useState('idle'); + const lift = useSharedValue(0); + const nudge = useSharedValue(0); + + const cardWidth = width - 32; + const cardHeight = cardWidth / CARD_ASPECT; + const cardTop = insets.top + 68; + + useEffect(() => { + nudge.value = withRepeat( + withSequence( + withTiming(-6, { duration: 420, easing: Easing.out(Easing.quad) }), + withTiming(0, { duration: 420, easing: Easing.in(Easing.quad) }), + withTiming(0, { duration: 700 }) + ), + -1 + ); + }, [nudge]); + + const setCard = card.setParamsSynchronizable; + const setGlyph = glyph.setParamsSynchronizable; + + useEffect(() => { + let frame = 0; + let spin = 0; + let last = Date.now(); + let shown: Stage = 'idle'; + let paid = false; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + spin += dt * 6; + const t0 = startedAt.current; + const t = frozenAt.current ?? (t0 === null ? -1 : (now - t0) / 1000); + const running = t >= 0 && t < FLOW.doneOut[1]; + + const glint = running ? ramp(t, [0.05, 0.95]) : 0; + const up = running + ? easeOut(ramp(t, [0, 0.35])) * (1 - ramp(t, FLOW.settle)) + : 0; + lift.value = up; + setCard(tilt.current.x, tilt.current.y, glint, up); + + if (running) { + const faceAmt = ramp(t, FLOW.faceIn) * (1 - ramp(t, FLOW.faceOut)); + const ring = ramp(t, FLOW.ringIn); + const check = + ramp(t, FLOW.checkDraw) - ramp(t, [FLOW.faceOut[0], 1.82]); + const reader = + ramp(t, FLOW.readerIn) * (1 - ramp(t, [FLOW.paid, FLOW.paid + 0.2])); + const done = ramp(t, FLOW.doneIn) * (1 - ramp(t, FLOW.doneOut)); + const nod = + Math.sin((t - FLOW.nod[0]) * 10) * + ramp(t, [FLOW.nod[0], FLOW.nod[0] + 0.1]) * + (1 - ramp(t, [FLOW.nod[1] - 0.1, FLOW.nod[1]])); + setGlyph(faceAmt, ring, Math.max(0, check), reader, done, spin, nod, 0); + if (!paid && t >= FLOW.paid) { + paid = true; + Vibration.vibrate(40); + playChime(); + } + } else { + setGlyph(0, 0, 0, 0, 0, spin, 0, 0); + startedAt.current = null; + } + + const next = running ? stageAt(t) : 'idle'; + if (next !== shown) { + shown = next; + if (next === 'auth') { + paid = false; + } + setStage(next); + } + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [lift, setCard, setGlyph, tilt]); + + useEffect(() => { + if (!__DEV__) { + return; + } + (globalThis as DebugGlobal).__walletSeek = (t) => { + frozenAt.current = t; + }; + return () => { + delete (globalThis as DebugGlobal).__walletSeek; + }; + }, []); + + const pay = useCallback(() => { + if (startedAt.current === null) { + startedAt.current = Date.now(); + } + }, []); + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .maxDelay(350) + .runOnJS(true) + .onEnd(pay); + + const cardStyle = useAnimatedStyle(() => ({ + transform: [ + { translateY: -6 * lift.value }, + { scale: 1 + 0.035 * lift.value }, + ], + shadowOpacity: 0.35 + 0.35 * lift.value, + shadowRadius: 18 + 18 * lift.value, + })); + + const hintStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: nudge.value }], + })); + + const label = STAGE_LABEL[stage]; + const sideTop = cardTop + cardHeight + 28; + const sideHeight = height * 0.075; + + return ( + + + + + + {STACK.map((spec, i) => ( + + ))} + + + + + + + + + + {stage === 'idle' && ( + + + {'Double Click\nto Pay'} + + + + )} + + + + {label} + + + + ); +} + +const STACK: StackCardSpec[] = [ + { name: 'coral', color: '#f0604a', ink: '#fff6f2', mark: 'VISA' }, + { name: 'Northwind Bank', color: '#1d3f8f', ink: '#e8eeff', mark: 'VISA' }, + { name: 'Savings', color: '#e4e4e8', ink: '#2b2b30', mark: '●●' }, +]; + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + stack: { + position: 'absolute', + left: 16, + top: 0, + bottom: 0, + }, + card: { + position: 'absolute', + left: 16, + borderRadius: CARD_RADIUS, + backgroundColor: '#0e0f11', + shadowColor: '#000', + shadowOffset: { width: 0, height: 12 }, + }, + cardClip: { + flex: 1, + borderRadius: CARD_RADIUS, + overflow: 'hidden', + }, + side: { + position: 'absolute', + right: 0, + justifyContent: 'center', + }, + sideRow: { + flexDirection: 'row', + alignItems: 'center', + }, + sideText: { + color: '#fff', + fontSize: 17, + fontWeight: '600', + textAlign: 'right', + marginRight: 14, + lineHeight: 21, + }, + sidePill: { + width: 6, + borderTopLeftRadius: 3, + borderBottomLeftRadius: 3, + backgroundColor: '#fff', + }, + status: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + glyph: { + width: GLYPH_SIZE, + height: GLYPH_SIZE, + }, + label: { + marginTop: 16, + color: 'rgba(235, 235, 245, 0.62)', + fontSize: 19, + fontWeight: '400', + letterSpacing: -0.2, + minHeight: 26, + }, +}); diff --git a/example/src/types.ts b/example/src/types.ts index 982b45b..b5b35c2 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -44,6 +44,17 @@ export type RootStackParamList = { HourglassExample: undefined; TelegramSpoilerExample: undefined; IMessageExample: undefined; + CrackedScreenExample: undefined; + DuoCrackgateExample: undefined; + PolaroidExample: undefined; + ScratchOffExample: undefined; + BubbleWrapExample: undefined; + LavaLampExample: undefined; + FerrofluidExample: undefined; + CrtOffExample: undefined; + FlashlightExample: undefined; + WalletPayExample: undefined; + NameDropExample: undefined; }; export type HomeScreenNavigationProp = diff --git a/yarn.lock b/yarn.lock index 292b038..4218774 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3703,6 +3703,13 @@ __metadata: languageName: node linkType: hard +"@types/emscripten@npm:^1.41.6": + version: 1.41.6 + resolution: "@types/emscripten@npm:1.41.6" + checksum: 10c0/b51a730b6cb2848ac5e3f0536707bf656b6887be21f20e96b01e74a59ed52319dd0942f49b1072b1ca6d38df19e2d633f540df54d6ba1f7216be14af7373a41f + languageName: node + linkType: hard + "@types/estree@npm:^1.0.6": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" @@ -4604,6 +4611,15 @@ __metadata: languageName: node linkType: hard +"barcode-detector@npm:^3.0.0": + version: 3.2.3 + resolution: "barcode-detector@npm:3.2.3" + dependencies: + zxing-wasm: "npm:3.1.6" + checksum: 10c0/6e1b711726c3a4916b6f11508cc13bfcb580591f39e57906c6dcca11c14a607f74c8469e26acfe53f8fa61766ababbf9f87fae8e123b0822fd104b206621df33 + languageName: node + linkType: hard + "base64-arraybuffer@npm:^1.0.2": version: 1.0.2 resolution: "base64-arraybuffer@npm:1.0.2" @@ -6410,6 +6426,23 @@ __metadata: languageName: node linkType: hard +"expo-camera@npm:~56.0.8": + version: 56.0.8 + resolution: "expo-camera@npm:56.0.8" + dependencies: + barcode-detector: "npm:^3.0.0" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + react-native-web: "*" + peerDependenciesMeta: + react-native-web: + optional: true + checksum: 10c0/87c17bf383909fad0025a712af16d58ce81d4a3901ccb5aef8cee1e20224a074c16f1af1463cfd598ee096cbe9dd09aaa97a9fa5d2ac25acf01be4a0afaa21e5 + languageName: node + linkType: hard + "expo-constants@npm:~56.0.17": version: 56.0.17 resolution: "expo-constants@npm:56.0.17" @@ -10760,6 +10793,7 @@ __metadata: babel-preset-expo: "npm:^56.0.14" expo: "npm:~56.0.9" expo-build-properties: "npm:~56.0.17" + expo-camera: "npm:~56.0.8" expo-sensors: "npm:~56.0.5" react: "npm:19.2.3" react-native: "npm:0.85.3" @@ -12073,6 +12107,13 @@ __metadata: languageName: node linkType: hard +"tagged-tag@npm:^1.0.0": + version: 1.0.0 + resolution: "tagged-tag@npm:1.0.0" + checksum: 10c0/91d25c9ffb86a91f20522cefb2cbec9b64caa1febe27ad0df52f08993ff60888022d771e868e6416cf2e72dab68449d2139e8709ba009b74c6c7ecd4000048d1 + languageName: node + linkType: hard + "tar@npm:^7.5.4": version: 7.5.9 resolution: "tar@npm:7.5.9" @@ -12266,6 +12307,15 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^5.10.0": + version: 5.10.0 + resolution: "type-fest@npm:5.10.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10c0/313c5d0e37a3f1cc82e942f3228f897623c8b54a63a8a95532d0c2c21ab200793caa70bd8904a83fd713eb28d87ba32468b09b345ef6efa25cecb345b467226f + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.3": version: 1.0.3 resolution: "typed-array-buffer@npm:1.0.3" @@ -12982,3 +13032,15 @@ __metadata: checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c languageName: node linkType: hard + +"zxing-wasm@npm:3.1.6": + version: 3.1.6 + resolution: "zxing-wasm@npm:3.1.6" + dependencies: + "@types/emscripten": "npm:^1.41.6" + type-fest: "npm:^5.10.0" + peerDependencies: + "@types/emscripten": ">=1.39.6" + checksum: 10c0/5df631ec1ca2ef53c6c22ffbc0cb4a27f28cf40d120eb64733bfa7c3070e844f46ad1c9c629f345e110c128db6a46afd3663c26f402d360b997f000ca7925387 + languageName: node + linkType: hard