From 1d0b82f31c3f7304d2ca407c76cdb55302c2595f Mon Sep 17 00:00:00 2001 From: Mathis Pruvot Date: Thu, 28 May 2026 11:49:12 +0000 Subject: [PATCH] feat: add SkSL GPU shaders for wallpaper animations Ken Burns (zoom+pan), Color Shift (HSL cycling), Particles (snow/rain/bokeh overlay), Vignette Pulse, Glitch (RGB split), and Depth Parallax (gyroscope-driven 2.5D via depth map). --- src/shaders/color-shift.ts | 82 +++++++++++++++++++++++++++ src/shaders/depth-parallax.ts | 37 ++++++++++++ src/shaders/glitch.ts | 74 ++++++++++++++++++++++++ src/shaders/index.ts | 26 +++++++++ src/shaders/ken-burns.ts | 33 +++++++++++ src/shaders/particles.ts | 102 ++++++++++++++++++++++++++++++++++ src/shaders/vignette-pulse.ts | 34 ++++++++++++ 7 files changed, 388 insertions(+) create mode 100644 src/shaders/color-shift.ts create mode 100644 src/shaders/depth-parallax.ts create mode 100644 src/shaders/glitch.ts create mode 100644 src/shaders/index.ts create mode 100644 src/shaders/ken-burns.ts create mode 100644 src/shaders/particles.ts create mode 100644 src/shaders/vignette-pulse.ts diff --git a/src/shaders/color-shift.ts b/src/shaders/color-shift.ts new file mode 100644 index 0000000..be48f5f --- /dev/null +++ b/src/shaders/color-shift.ts @@ -0,0 +1,82 @@ +import { Skia } from "@shopify/react-native-skia"; + +// Color Shift: cyclic hue/saturation breathing effect +// Uniforms: time, intensity, speed +export const colorShiftShader = Skia.RuntimeEffect.Make(` + uniform shader image; + uniform float2 resolution; + uniform float time; + uniform float intensity; + uniform float speed; + + // RGB to HSL conversion + float3 rgb2hsl(float3 c) { + float mx = max(c.r, max(c.g, c.b)); + float mn = min(c.r, min(c.g, c.b)); + float l = (mx + mn) * 0.5; + float s = 0.0; + float h = 0.0; + + if (mx != mn) { + float d = mx - mn; + s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn); + + if (mx == c.r) { + h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); + } else if (mx == c.g) { + h = (c.b - c.r) / d + 2.0; + } else { + h = (c.r - c.g) / d + 4.0; + } + h /= 6.0; + } + + return float3(h, s, l); + } + + float hue2rgb(float p, float q, float t) { + float tt = t; + if (tt < 0.0) tt += 1.0; + if (tt > 1.0) tt -= 1.0; + if (tt < 1.0/6.0) return p + (q - p) * 6.0 * tt; + if (tt < 1.0/2.0) return q; + if (tt < 2.0/3.0) return p + (q - p) * (2.0/3.0 - tt) * 6.0; + return p; + } + + float3 hsl2rgb(float3 hsl) { + float h = hsl.x; + float s = hsl.y; + float l = hsl.z; + + if (s == 0.0) return float3(l); + + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + + return float3( + hue2rgb(p, q, h + 1.0/3.0), + hue2rgb(p, q, h), + hue2rgb(p, q, h - 1.0/3.0) + ); + } + + half4 main(float2 coord) { + half4 color = image.eval(coord); + float3 hsl = rgb2hsl(float3(color.rgb)); + + float t = time * speed * 0.15; + + // Shift hue cyclically + hsl.x = fract(hsl.x + intensity * 0.1 * sin(t)); + + // Pulse saturation + hsl.y = clamp(hsl.y + intensity * 0.15 * sin(t * 1.3), 0.0, 1.0); + + // Subtle luminance breathing + hsl.z = clamp(hsl.z + intensity * 0.03 * sin(t * 0.8), 0.0, 1.0); + + float3 rgb = hsl2rgb(hsl); + return half4(half3(rgb), color.a); + } +`)!; diff --git a/src/shaders/depth-parallax.ts b/src/shaders/depth-parallax.ts new file mode 100644 index 0000000..872bb60 --- /dev/null +++ b/src/shaders/depth-parallax.ts @@ -0,0 +1,37 @@ +import { Skia } from "@shopify/react-native-skia"; + +// Depth Parallax 2.5D: uses a depth map to create parallax displacement +// Requires ML-generated depth map (MiDaS) — V1.5 feature +// Uniforms: time, intensity, speed, gyroX, gyroY +export const depthParallaxShader = Skia.RuntimeEffect.Make(` + uniform shader image; + uniform shader depthMap; + uniform float2 resolution; + uniform float time; + uniform float intensity; + uniform float speed; + uniform float gyroX; + uniform float gyroY; + + half4 main(float2 coord) { + float2 uv = coord / resolution; + + // Sample depth (grayscale, 0=far, 1=near) + float depth = depthMap.eval(coord).r; + + // Combine gyroscope input with time-based oscillation + float t = time * speed * 0.2; + float2 offset = float2( + gyroX * 0.8 + sin(t) * 0.3, + gyroY * 0.8 + cos(t * 0.7) * 0.3 + ); + + // Displacement proportional to depth + // Near objects (depth=1) move more, far objects (depth=0) stay still + float2 displacement = offset * depth * intensity * 0.04; + + float2 sampleUV = clamp(uv + displacement, float2(0.0), float2(1.0)); + + return image.eval(sampleUV * resolution); + } +`)!; diff --git a/src/shaders/glitch.ts b/src/shaders/glitch.ts new file mode 100644 index 0000000..b7d1d0a --- /dev/null +++ b/src/shaders/glitch.ts @@ -0,0 +1,74 @@ +import { Skia } from "@shopify/react-native-skia"; + +// Glitch: periodic RGB channel split with noise displacement +// Uniforms: time, intensity, speed +export const glitchShader = Skia.RuntimeEffect.Make(` + uniform shader image; + uniform float2 resolution; + uniform float time; + uniform float intensity; + uniform float speed; + + float hash(float n) { + return fract(sin(n) * 43758.5453123); + } + + float noise(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + float a = hash(i.x + i.y * 57.0); + float b = hash(i.x + 1.0 + i.y * 57.0); + float c = hash(i.x + (i.y + 1.0) * 57.0); + float d = hash(i.x + 1.0 + (i.y + 1.0) * 57.0); + float2 u = f * f * (3.0 - 2.0 * f); + return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; + } + + half4 main(float2 coord) { + float2 uv = coord / resolution; + float t = time * speed * 0.5; + + // Glitch trigger: periodic bursts + float glitchTrigger = step(0.92, sin(t * 2.0) * sin(t * 7.3)); + float glitchAmount = intensity * glitchTrigger; + + // Horizontal displacement based on noise + float blockY = floor(uv.y * 20.0) / 20.0; + float noiseVal = noise(float2(blockY * 50.0, t * 10.0)); + float displacement = (noiseVal - 0.5) * glitchAmount * 0.08; + + // Scanline flicker + float scanline = sin(uv.y * resolution.y * 1.5 + t * 20.0) * 0.02 * glitchAmount; + + // Sample RGB channels with offset + float shift = intensity * 0.006 + glitchAmount * 0.015; + float2 uvR = float2(uv.x + shift + displacement, uv.y + scanline); + float2 uvG = float2(uv.x + displacement, uv.y + scanline); + float2 uvB = float2(uv.x - shift + displacement, uv.y + scanline); + + // Clamp UVs + uvR = clamp(uvR, float2(0.0), float2(1.0)); + uvG = clamp(uvG, float2(0.0), float2(1.0)); + uvB = clamp(uvB, float2(0.0), float2(1.0)); + + float r = image.eval(uvR * resolution).r; + float g = image.eval(uvG * resolution).g; + float b = image.eval(uvB * resolution).b; + + float3 result = float3(r, g, b); + + // Subtle persistent RGB split (even without glitch burst) + float persistentShift = intensity * 0.003; + float2 uvR2 = float2(uv.x + persistentShift, uv.y) * resolution; + float2 uvB2 = float2(uv.x - persistentShift, uv.y) * resolution; + float3 persistent = float3( + image.eval(uvR2).r, + image.eval(coord).g, + image.eval(uvB2).b + ); + + result = mix(persistent, result, glitchTrigger); + + return half4(half3(result), 1.0); + } +`)!; diff --git a/src/shaders/index.ts b/src/shaders/index.ts new file mode 100644 index 0000000..549bc5a --- /dev/null +++ b/src/shaders/index.ts @@ -0,0 +1,26 @@ +import type { SkRuntimeEffect } from "@shopify/react-native-skia"; +import { kenBurnsShader } from "./ken-burns"; +import { colorShiftShader } from "./color-shift"; +import { particlesShader } from "./particles"; +import { vignettePulseShader } from "./vignette-pulse"; +import { glitchShader } from "./glitch"; +import { depthParallaxShader } from "./depth-parallax"; +import type { AnimationType } from "@/types/wallpaper"; + +export const shaders: Record = { + "ken-burns": kenBurnsShader, + "color-shift": colorShiftShader, + particles: particlesShader, + "vignette-pulse": vignettePulseShader, + glitch: glitchShader, + "depth-parallax": depthParallaxShader, +}; + +export { + kenBurnsShader, + colorShiftShader, + particlesShader, + vignettePulseShader, + glitchShader, + depthParallaxShader, +}; diff --git a/src/shaders/ken-burns.ts b/src/shaders/ken-burns.ts new file mode 100644 index 0000000..d707e3d --- /dev/null +++ b/src/shaders/ken-burns.ts @@ -0,0 +1,33 @@ +import { Skia } from "@shopify/react-native-skia"; + +// Ken Burns: slow zoom + pan across the image +// Uniforms: time, intensity (zoom amplitude), speed, direction (angle in radians) +export const kenBurnsShader = Skia.RuntimeEffect.Make(` + uniform shader image; + uniform float2 resolution; + uniform float time; + uniform float intensity; + uniform float speed; + uniform float direction; + + half4 main(float2 coord) { + float t = time * speed * 0.1; + + // Compute zoom: oscillates between 1.0 and 1.0 + intensity * 0.15 + float zoom = 1.0 + intensity * 0.15 * (0.5 + 0.5 * sin(t)); + + // Compute pan offset based on direction + float panX = cos(direction) * intensity * 0.05 * sin(t * 0.7); + float panY = sin(direction) * intensity * 0.05 * cos(t * 0.7); + + // Transform coordinates: center, scale, offset, uncenter + float2 center = resolution * 0.5; + float2 uv = (coord - center) / zoom + center; + uv += float2(panX, panY) * resolution; + + // Clamp to image bounds + uv = clamp(uv, float2(0.0), resolution); + + return image.eval(uv); + } +`)!; diff --git a/src/shaders/particles.ts b/src/shaders/particles.ts new file mode 100644 index 0000000..4d26523 --- /dev/null +++ b/src/shaders/particles.ts @@ -0,0 +1,102 @@ +import { Skia } from "@shopify/react-native-skia"; + +// Particle overlay: snow, rain, bokeh rendered as shader +// Uniforms: time, intensity, speed, particleType (0=snow, 1=rain, 2=bokeh) +export const particlesShader = Skia.RuntimeEffect.Make(` + uniform shader image; + uniform float2 resolution; + uniform float time; + uniform float intensity; + uniform float speed; + uniform float particleType; + + // Hash function for pseudo-random values + float hash(float2 p) { + float3 p3 = fract(float3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + // Snow particle layer + float snow(float2 uv, float layer) { + float t = time * speed * 0.4 + layer * 12.345; + float2 grid = floor(uv * (8.0 + layer * 6.0)); + float2 frac_uv = fract(uv * (8.0 + layer * 6.0)); + + float rnd = hash(grid); + float2 center = float2( + 0.5 + 0.3 * sin(t + rnd * 6.28), + fract(rnd + t * (0.1 + rnd * 0.08)) + ); + + float d = length(frac_uv - center); + float size = 0.04 + rnd * 0.06; + float alpha = smoothstep(size, size * 0.3, d); + + return alpha * (0.4 + 0.6 * rnd); + } + + // Rain streak + float rain(float2 uv, float layer) { + float t = time * speed * 1.2 + layer * 7.89; + float2 grid = floor(uv * float2(20.0, 4.0 + layer * 2.0)); + float2 frac_uv = fract(uv * float2(20.0, 4.0 + layer * 2.0)); + + float rnd = hash(grid); + float2 center = float2(0.5, fract(rnd - t * (0.3 + rnd * 0.2))); + + // Elongated shape for rain drops + float dx = abs(frac_uv.x - center.x); + float dy = abs(frac_uv.y - center.y); + float d = dx * 8.0 + dy * 1.5; + + float alpha = smoothstep(0.5, 0.1, d) * step(0.5, rnd); + return alpha * 0.5; + } + + // Bokeh circle + float bokeh(float2 uv, float layer) { + float t = time * speed * 0.2 + layer * 5.67; + float2 grid = floor(uv * (3.0 + layer * 2.0)); + float2 frac_uv = fract(uv * (3.0 + layer * 2.0)); + + float rnd = hash(grid); + float2 center = float2( + 0.5 + 0.2 * sin(t * 0.5 + rnd * 6.28), + 0.5 + 0.2 * cos(t * 0.3 + rnd * 6.28) + ); + + float d = length(frac_uv - center); + float size = 0.1 + rnd * 0.15; + + // Ring shape for bokeh + float ring = smoothstep(size, size - 0.02, d) - smoothstep(size - 0.03, size - 0.06, d); + float fill = smoothstep(size, size * 0.2, d) * 0.3; + + return (ring + fill) * (0.3 + 0.7 * rnd) * step(0.3, rnd); + } + + half4 main(float2 coord) { + half4 color = image.eval(coord); + float2 uv = coord / resolution; + + float particles = 0.0; + + // Stack 3 layers for depth + for (float i = 0.0; i < 3.0; i += 1.0) { + if (particleType < 0.5) { + particles += snow(uv, i); + } else if (particleType < 1.5) { + particles += rain(uv, i); + } else { + particles += bokeh(uv, i); + } + } + + particles *= intensity; + + // Additive blending + float3 result = float3(color.rgb) + float3(particles); + return half4(half3(clamp(result, float3(0.0), float3(1.0))), color.a); + } +`)!; diff --git a/src/shaders/vignette-pulse.ts b/src/shaders/vignette-pulse.ts new file mode 100644 index 0000000..5c5a310 --- /dev/null +++ b/src/shaders/vignette-pulse.ts @@ -0,0 +1,34 @@ +import { Skia } from "@shopify/react-native-skia"; + +// Vignette Pulse: dynamic vignette with oscillating intensity +// Uniforms: time, intensity, speed +export const vignettePulseShader = Skia.RuntimeEffect.Make(` + uniform shader image; + uniform float2 resolution; + uniform float time; + uniform float intensity; + uniform float speed; + + half4 main(float2 coord) { + half4 color = image.eval(coord); + float2 uv = coord / resolution; + + // Distance from center + float2 center = uv - 0.5; + float dist = length(center); + + // Oscillating vignette + float t = time * speed * 0.3; + float vignetteStrength = intensity * (0.6 + 0.4 * sin(t)); + + // Smooth radial gradient with asymmetric falloff + float vignette = 1.0 - smoothstep(0.2, 0.85, dist * (1.0 + vignetteStrength)); + + // Subtle color tinting at the edges + float tint = (1.0 - vignette) * 0.15 * intensity; + float3 tinted = float3(color.rgb) * vignette; + tinted.b += tint * 0.3; // Slight blue tint in shadows + + return half4(half3(clamp(tinted, float3(0.0), float3(1.0))), color.a); + } +`)!;