diff --git a/example/assets/components/glitter.png b/example/assets/components/glitter.png new file mode 100644 index 0000000..2798860 Binary files /dev/null and b/example/assets/components/glitter.png differ diff --git a/example/src/App.tsx b/example/src/App.tsx index 05129f6..69e9f67 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -17,6 +17,7 @@ import CampfireStaticScreen from './screens/Campfire/CampfireStaticScreen'; import CalicoSwirlStaticScreen from './screens/CalicoSwirl/CalicoSwirlStaticScreen'; import DesertStaticScreen from './screens/Desert/DesertStaticScreen'; import HoloStaticScreen from './screens/Holo/HoloStaticScreen'; +import GlitterStaticScreen from './screens/Glitter/GlitterStaticScreen'; import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -77,6 +78,7 @@ export default function App() { /> + ); diff --git a/example/src/screens/Glitter/GlitterStaticScreen.tsx b/example/src/screens/Glitter/GlitterStaticScreen.tsx new file mode 100644 index 0000000..f6caa4c --- /dev/null +++ b/example/src/screens/Glitter/GlitterStaticScreen.tsx @@ -0,0 +1,226 @@ +import { + View, + Text, + StyleSheet, + StatusBar, + Image, + ScrollView, + Dimensions, + type ImageSourcePropType, +} from 'react-native'; +import { Glitter } from 'react-native-backgrounds'; +import { Header } from '../../components/Header'; + +const { width } = Dimensions.get('window'); +const CARD_WIDTH = width - 80; + +type GlitterConfig = { + id: string; + name: string; + scale: number; + speed: number; + intensity: number; + density: number; + colorA: string; + colorB: string; + image?: ImageSourcePropType; +}; + +const GLITTER_PRESETS: GlitterConfig[] = [ + { + id: 'default', + name: 'Classic Sparkle', + scale: 60, + speed: 1, + intensity: 2.0, + density: 0.1, + colorA: '#ffffff', + colorB: '#ffffff', + image: require('../../../assets/charizard.png'), + }, + { + id: 'golden', + name: 'Golden Shimmer', + scale: 100, + speed: 2, + intensity: 4.0, + density: 0.1, + colorA: '#ffd700', + colorB: '#ffed4e', + }, + { + id: 'rainbow', + name: 'Rainbow Sparkle', + scale: 60, + speed: 1, + intensity: 6.0, + density: 0.05, + colorA: '#ff00ff', + colorB: '#00ffff', + }, + { + id: 'subtle', + name: 'Subtle Dust', + scale: 80, + speed: 1, + intensity: 2.5, + density: 0.3, + colorA: '#ffffff', + colorB: '#cccccc', + }, + { + id: 'ice', + name: 'Ice Crystals', + scale: 70, + speed: 1, + intensity: 5.5, + density: 0.2, + colorA: '#00ffff', + colorB: '#b0e0e6', + }, +]; + +export default function GlitterStaticScreen() { + return ( + + + +
+ + + {GLITTER_PRESETS.map((preset) => ( + + + {preset.image && ( + + )} + + + + {preset.name} + + + Scale: + {preset.scale} + + + Speed: + {preset.speed} + + + Intensity: + {preset.intensity} + + + Density: + {preset.density} + + + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 20, + paddingBottom: 40, + }, + card: { + width: CARD_WIDTH, + alignSelf: 'center', + backgroundColor: '#111', + borderRadius: 20, + padding: 16, + marginBottom: 24, + borderWidth: 1, + borderColor: '#252525', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.4, + shadowRadius: 12, + elevation: 6, + }, + gradientContainer: { + width: '100%', + maxWidth: 300, + height: 400, + borderRadius: 14, + overflow: 'hidden', + marginBottom: 16, + position: 'relative', + alignSelf: 'center', + }, + backgroundImage: { + position: 'absolute', + width: '100%', + height: '100%', + }, + gradient: { + position: 'absolute', + width: '100%', + height: '100%', + }, + cardInfo: { + paddingHorizontal: 4, + }, + cardTitle: { + fontSize: 20, + fontWeight: '700', + color: '#fff', + marginBottom: 12, + letterSpacing: -0.3, + }, + detailsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginBottom: 8, + }, + detailItem: { + flex: 1, + minWidth: '45%', + }, + detailLabel: { + fontSize: 12, + color: '#666', + marginBottom: 4, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + detailValue: { + fontSize: 14, + color: '#aaa', + fontWeight: '500', + }, +}); diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 30b172a..6147548 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -83,6 +83,14 @@ const EXAMPLE_CATEGORIES: ExampleCategory[] = [ color: '#ff00ff', image: require('../../assets/components/holo.png'), }, + { + id: 'glitter', + title: 'Glitter', + description: 'Animated sparkle effect with scrolling noise', + screen: 'GlitterStatic', + color: '#ffffff', + image: require('../../assets/components/glitter.png'), + }, ]; export default function HomeScreen() { diff --git a/example/src/types.ts b/example/src/types.ts index 86d683b..747669a 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -16,6 +16,7 @@ export type RootStackParamList = { CalicoSwirlStatic: undefined; DesertStatic: undefined; HoloStatic: undefined; + GlitterStatic: undefined; }; export type HomeScreenNavigationProp = diff --git a/src/components/Glitter/index.tsx b/src/components/Glitter/index.tsx new file mode 100644 index 0000000..9b8aff9 --- /dev/null +++ b/src/components/Glitter/index.tsx @@ -0,0 +1,280 @@ +import { StyleSheet, type ViewProps } from 'react-native'; +import { Canvas } from 'react-native-wgpu'; +import { TRIANGLE_VERTEX_SHADER } from '../../shaders/TRIANGLE_VERTEX_SHADER'; +import { useWGPUSetup } from '../../hooks/useWGPUSetup'; +import { useCallback, useEffect } from 'react'; +import { runOnUI, useDerivedValue } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import { GLITTER_SHADER } from './shader'; +import { useClock } from '../../hooks/useClock'; +import doTheTrick from '../../utils/doTheTrick'; +import { colorToVec4, type ColorInput } from '../../utils/colors'; + +type CanvasProps = ViewProps & { + transparent?: boolean; +}; + +type Props = CanvasProps & { + /** + * Scale of the sparkles (affects size/frequency). + * @default 50.0 + */ + scale?: number | SharedValue; + /** + * Animation speed multiplier. + * @default 0.005 + */ + speed?: number | SharedValue; + /** + * Brightness/intensity of the sparkles. + * @default 5.0 + */ + intensity?: number | SharedValue; + /** + * How sparse the glitter is (0-1, higher = more sparse). + * @default 0.0 + */ + density?: number | SharedValue; + /** + * First color for tinting sparkles. + * @default '#ffffff' + */ + colorA?: ColorInput | SharedValue; + /** + * Second color for tinting sparkles. + * @default '#ffffff' + */ + colorB?: ColorInput | SharedValue; +}; + +export default function Glitter({ + scale = 50.0, + speed = 1, + intensity = 2.0, + density = 0.1, + colorA = '#ffffff', + colorB = '#ffffff', + style, + ...canvasProps +}: Props) { + const { sharedContext, canvasRef } = useWGPUSetup(); + const clock = useClock(); + + const animatedScale = useDerivedValue(() => + typeof scale === 'number' ? scale : scale.get() + ); + const animatedSpeed = useDerivedValue(() => + typeof speed === 'number' ? speed : speed.get() + ); + const animatedIntensity = useDerivedValue(() => + typeof intensity === 'number' ? intensity : intensity.get() + ); + const animatedDensity = useDerivedValue(() => + typeof density === 'number' ? density : density.get() + ); + const animatedColorA = useDerivedValue(() => + typeof colorA === 'number' || typeof colorA === 'string' + ? colorA + : colorA.get() + ); + const animatedColorB = useDerivedValue(() => + typeof colorB === 'number' || typeof colorB === 'string' + ? colorB + : colorB.get() + ); + + const drawGlitter = useCallback(() => { + 'worklet'; + const { device, context, presentationFormat } = sharedContext.get(); + if (!device || !context || !presentationFormat) { + return; + } + + const uniformBuffer = device.createBuffer({ + size: 80, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + const width = context.canvas.width ?? 1; + const height = context.canvas.height ?? 1; + const aspect = width / height; + const time = clock.get() / 1000; + + const colorARGBA = colorToVec4(animatedColorA.get()); + const colorBRGBA = colorToVec4(animatedColorB.get()); + + const uniformData = new Float32Array([ + width, + height, + aspect, + 0.0, + + time, + 0.0, + 0.0, + 0.0, + + animatedScale.get(), + animatedSpeed.get(), + animatedIntensity.get(), + animatedDensity.get(), + + colorARGBA.r, + colorARGBA.g, + colorARGBA.b, + colorARGBA.a, + + colorBRGBA.r, + colorBRGBA.g, + colorBRGBA.b, + colorBRGBA.a, + ]); + + device.queue.writeBuffer(uniformBuffer, 0, uniformData); + + const pipeline = device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: device.createShaderModule({ code: TRIANGLE_VERTEX_SHADER }), + entryPoint: 'main', + }, + fragment: { + module: device.createShaderModule({ code: GLITTER_SHADER }), + entryPoint: 'main', + targets: [ + { + format: presentationFormat, + blend: { + color: { + srcFactor: 'one', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + alpha: { + srcFactor: 'one', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + }, + writeMask: GPUColorWrite.ALL, + }, + ], + }, + primitive: { + topology: 'triangle-list', + }, + }); + + const bindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { + binding: 0, + resource: { + buffer: uniformBuffer, + }, + }, + ], + }); + + const commandEncoder = device.createCommandEncoder(); + + const textureView = context.getCurrentTexture().createView(); + const renderPassDescriptor: GPURenderPassDescriptor = { + colorAttachments: [ + { + view: textureView, + clearValue: [0, 0, 0, 0], + loadOp: 'clear', + storeOp: 'store', + }, + ], + }; + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(3); + passEncoder.end(); + + device.queue.submit([commandEncoder.finish()]); + context.present(); + }, [ + clock, + sharedContext, + animatedScale, + animatedSpeed, + animatedIntensity, + animatedDensity, + animatedColorA, + animatedColorB, + ]); + + useEffect(() => { + doTheTrick(drawGlitter); + + function listenToAnimatedValues() { + clock.addListener(0, () => { + drawGlitter(); + }); + animatedScale.addListener(0, () => { + drawGlitter(); + }); + animatedSpeed.addListener(0, () => { + drawGlitter(); + }); + animatedIntensity.addListener(0, () => { + drawGlitter(); + }); + animatedDensity.addListener(0, () => { + drawGlitter(); + }); + animatedColorA.addListener(0, () => { + drawGlitter(); + }); + animatedColorB.addListener(0, () => { + drawGlitter(); + }); + } + + function stopListeningToAnimatedValues() { + clock.removeListener(0); + animatedScale.removeListener(0); + animatedSpeed.removeListener(0); + animatedIntensity.removeListener(0); + animatedDensity.removeListener(0); + animatedColorA.removeListener(0); + animatedColorB.removeListener(0); + } + + runOnUI(listenToAnimatedValues)(); + return runOnUI(stopListeningToAnimatedValues); + }, [ + clock, + drawGlitter, + sharedContext, + animatedScale, + animatedSpeed, + animatedIntensity, + animatedDensity, + animatedColorA, + animatedColorB, + ]); + + return ( + + ); +} + +Glitter.displayName = 'Glitter'; + +const styles = StyleSheet.create({ + webgpu: { + flex: 1, + }, +}); diff --git a/src/components/Glitter/shader.ts b/src/components/Glitter/shader.ts new file mode 100644 index 0000000..1166082 --- /dev/null +++ b/src/components/Glitter/shader.ts @@ -0,0 +1,66 @@ +export const GLITTER_SHADER = /* wgsl */ ` +struct GlitterParams { + resolution: vec4, + time_vec: vec4, + controls: vec4, // (scale, speed, intensity, density) + colorA: vec4, + colorB: vec4, +} + +@group(0) @binding(0) var params: GlitterParams; + +fn hash(p: vec2) -> f32 { + let h = sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123; + return fract(h); +} + +fn noise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + + 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)); + + let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +fn pow12(x: f32) -> f32 { + let x2 = x * x; + let x4 = x2 * x2; + let x8 = x4 * x4; + return x8 * x4; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let time = params.time_vec.x; + let res = params.resolution.xy; + let uv = (ndc * 0.5 + 0.5) * res / res.x; + + let scale = params.controls.x; + let speed = params.controls.y; + let intensity = params.controls.z; + let density = clamp(params.controls.w, 0.0, 0.99); + + let offset = time * speed; + let wrapped = offset - floor(offset / 4096.0) * 4096.0; + let scroll = vec2(wrapped); + + let n1 = noise(uv * scale * 1.07 + scroll); + let n2 = noise(uv * scale * 0.93 - scroll); + + let spark = clamp(n1 * 0.5 + n2 * 0.5, 0.0, 1.0); + let sharpened = pow12(spark); + let gated = max(0.0, sharpened - density) / max(1e-5, 1.0 - density); + + let tint = mix(params.colorA.rgb, params.colorB.rgb, spark); + let rgb = tint * intensity * gated; + let alpha = clamp(intensity * gated, 0.0, 1.0); + + return vec4(rgb, alpha); +} +`; diff --git a/src/index.tsx b/src/index.tsx index 6ac7240..fea92b2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import Campfire from './components/Campfire'; import CalicoSwirl from './components/CalicoSwirl'; import Desert from './components/Desert'; import Holo from './components/Holo'; +import Glitter from './components/Glitter'; export { CircularGradient, @@ -18,4 +19,5 @@ export { CalicoSwirl, Desert, Holo, + Glitter, };