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).
This commit is contained in:
Mathis Pruvot
2026-05-28 11:49:12 +00:00
parent 3b177b064e
commit 1d0b82f31c
7 changed files with 388 additions and 0 deletions

View File

@@ -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);
}
`)!;

View File

@@ -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);
}
`)!;

74
src/shaders/glitch.ts Normal file
View File

@@ -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);
}
`)!;

26
src/shaders/index.ts Normal file
View File

@@ -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<AnimationType, SkRuntimeEffect | null> = {
"ken-burns": kenBurnsShader,
"color-shift": colorShiftShader,
particles: particlesShader,
"vignette-pulse": vignettePulseShader,
glitch: glitchShader,
"depth-parallax": depthParallaxShader,
};
export {
kenBurnsShader,
colorShiftShader,
particlesShader,
vignettePulseShader,
glitchShader,
depthParallaxShader,
};

33
src/shaders/ken-burns.ts Normal file
View File

@@ -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);
}
`)!;

102
src/shaders/particles.ts Normal file
View File

@@ -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);
}
`)!;

View File

@@ -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);
}
`)!;