From 9c6cd3681fcf0937b0e17248f0f5adc9f1a9735a Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Sun, 26 Oct 2025 21:15:13 +0100 Subject: [PATCH] feat: add Glitter component with animated sparkle effects and integrate into navigation --- example/assets/components/glitter.png | Bin 0 -> 8517 bytes example/src/App.tsx | 2 + .../screens/Glitter/GlitterStaticScreen.tsx | 226 ++++++++++++++ example/src/screens/HomeScreen.tsx | 8 + example/src/types.ts | 1 + src/components/Glitter/index.tsx | 280 ++++++++++++++++++ src/components/Glitter/shader.ts | 66 +++++ src/index.tsx | 2 + 8 files changed, 585 insertions(+) create mode 100644 example/assets/components/glitter.png create mode 100644 example/src/screens/Glitter/GlitterStaticScreen.tsx create mode 100644 src/components/Glitter/index.tsx create mode 100644 src/components/Glitter/shader.ts diff --git a/example/assets/components/glitter.png b/example/assets/components/glitter.png new file mode 100644 index 0000000000000000000000000000000000000000..27988606253ca02daff5180bf1f7c744eac37f22 GIT binary patch literal 8517 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 @@ -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, };