diff --git a/example/assets/components/blur.png b/example/assets/components/blur.png new file mode 100644 index 0000000..95121f1 Binary files /dev/null and b/example/assets/components/blur.png differ diff --git a/example/assets/spongebob.png b/example/assets/spongebob.png new file mode 100644 index 0000000..9204d5e Binary files /dev/null and b/example/assets/spongebob.png differ diff --git a/example/src/App.tsx b/example/src/App.tsx index 69e9f67..4eec379 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -18,6 +18,7 @@ import CalicoSwirlStaticScreen from './screens/CalicoSwirl/CalicoSwirlStaticScre import DesertStaticScreen from './screens/Desert/DesertStaticScreen'; import HoloStaticScreen from './screens/Holo/HoloStaticScreen'; import GlitterStaticScreen from './screens/Glitter/GlitterStaticScreen'; +import BlurStaticScreen from './screens/Blur/BlurStaticScreen'; import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -79,6 +80,7 @@ export default function App() { + ); diff --git a/example/src/screens/Blur/BlurStaticScreen.tsx b/example/src/screens/Blur/BlurStaticScreen.tsx new file mode 100644 index 0000000..2a23475 --- /dev/null +++ b/example/src/screens/Blur/BlurStaticScreen.tsx @@ -0,0 +1,166 @@ +import { + View, + Text, + StyleSheet, + StatusBar, + ScrollView, + Dimensions, +} from 'react-native'; +import { Blur } from 'react-native-backgrounds'; +import { Header } from '../../components/Header'; + +const { width } = Dimensions.get('window'); +const CARD_WIDTH = width - 80; + +type BlurConfig = { + id: string; + name: string; + filterSize: number; + description: string; +}; + +const BLUR_PRESETS: BlurConfig[] = [ + { + id: 'subtle', + name: 'Subtle Blur', + filterSize: 5, + description: 'Light blur effect', + }, + { + id: 'medium', + name: 'Medium Blur', + filterSize: 20, + description: 'Balanced blur effect', + }, + { + id: 'strong', + name: 'Strong Blur', + filterSize: 40, + description: 'Heavy blur effect', + }, + { + id: 'extreme', + name: 'Extreme Blur', + filterSize: 100, + description: 'Maximum blur effect', + }, +]; + +export default function BlurStaticScreen() { + return ( + + + +
+ + + {BLUR_PRESETS.map((preset) => ( + + + + + + {preset.name} + {preset.description} + + + Filter Size: + {preset.filterSize}px + + + + + ))} + + + ); +} + +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, + }, + blurContainer: { + width: '100%', + height: 220, + borderRadius: 14, + overflow: 'hidden', + marginBottom: 16, + }, + blur: { + width: '100%', + height: '100%', + }, + cardInfo: { + paddingHorizontal: 4, + }, + cardTitle: { + fontSize: 20, + fontWeight: '700', + color: '#fff', + marginBottom: 8, + letterSpacing: -0.3, + }, + description: { + fontSize: 14, + color: '#aaa', + marginBottom: 12, + lineHeight: 20, + }, + detailsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + 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/Blur/BlurWithTextScreen.tsx b/example/src/screens/Blur/BlurWithTextScreen.tsx new file mode 100644 index 0000000..44c958a --- /dev/null +++ b/example/src/screens/Blur/BlurWithTextScreen.tsx @@ -0,0 +1,222 @@ +import { View, Text, StyleSheet, StatusBar } from 'react-native'; +import { Blur } from 'react-native-backgrounds'; +import { Header } from '../../components/Header'; + +export default function BlurWithTextScreen() { + return ( + + + +
+ + + {/* Card with blurred background */} + + + + + + {/* Text overlay on top of blur */} + + Frosted Glass Effect + iOS-style blur with overlay + + + This demonstrates how to create a frosted glass effect by + combining a blurred background image with text content overlaid on + top. + + + + + 40px + Blur Radius + + + + GPU + Accelerated + + + + 60fps + Performance + + + + + + {/* Another example with different styling */} + + + + + + + + ✨ PREMIUM + + Light Blur + Subtle background effect + + With a lighter blur (20px), more of the background image is + visible while still creating visual depth and hierarchy. + + + + + {/* Heavy blur example */} + + + + + + + 🌊 + Heavy Blur + Strong background softening + + Heavy blur (100px) creates an almost abstract background, perfect + for keeping focus on the text content. + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + content: { + flex: 1, + padding: 20, + gap: 20, + }, + card: { + height: 250, + borderRadius: 20, + overflow: 'hidden', + position: 'relative', + borderWidth: 1, + borderColor: '#333', + }, + blurBackground: { + position: 'absolute', + width: '100%', + height: '100%', + }, + blur: { + width: '100%', + height: '100%', + }, + textOverlay: { + flex: 1, + padding: 24, + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.3)', // Semi-transparent overlay + }, + title: { + fontSize: 28, + fontWeight: '800', + color: '#fff', + marginBottom: 8, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 10, + }, + subtitle: { + fontSize: 16, + color: '#ddd', + marginBottom: 16, + fontWeight: '500', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 5, + }, + description: { + fontSize: 14, + color: '#ccc', + lineHeight: 20, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 5, + }, + divider: { + height: 1, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + marginVertical: 16, + }, + statsContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + marginTop: 16, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 12, + padding: 16, + }, + statItem: { + alignItems: 'center', + }, + statValue: { + fontSize: 24, + fontWeight: '800', + color: '#fff', + marginBottom: 4, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 5, + }, + statLabel: { + fontSize: 12, + color: '#bbb', + textTransform: 'uppercase', + letterSpacing: 0.5, + fontWeight: '600', + }, + statDivider: { + width: 1, + height: 40, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + badge: { + alignSelf: 'flex-start', + backgroundColor: 'rgba(255, 215, 0, 0.9)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + marginBottom: 16, + }, + badgeText: { + fontSize: 12, + fontWeight: '800', + color: '#000', + letterSpacing: 1, + }, + emojiLarge: { + fontSize: 48, + marginBottom: 12, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 10, + }, +}); diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 6147548..951c2b2 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -91,6 +91,14 @@ const EXAMPLE_CATEGORIES: ExampleCategory[] = [ color: '#ffffff', image: require('../../assets/components/glitter.png'), }, + { + id: 'blur', + title: 'Blur', + description: 'GPU-accelerated compute shader blur', + screen: 'BlurStatic', + color: '#6b7280', + image: require('../../assets/components/blur.png'), + }, ]; export default function HomeScreen() { diff --git a/example/src/types.ts b/example/src/types.ts index 747669a..bdb1067 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -17,6 +17,7 @@ export type RootStackParamList = { DesertStatic: undefined; HoloStatic: undefined; GlitterStatic: undefined; + BlurStatic: undefined; }; export type HomeScreenNavigationProp = diff --git a/src/components/Blur/index.tsx b/src/components/Blur/index.tsx new file mode 100644 index 0000000..4d21396 --- /dev/null +++ b/src/components/Blur/index.tsx @@ -0,0 +1,256 @@ +import { + StyleSheet, + type ViewProps, + type ImageSourcePropType, + Image, +} from 'react-native'; +import { Canvas } from 'react-native-wgpu'; +import { useWGPUSetup } from '../../hooks/useWGPUSetup'; +import { useCallback, useEffect, useState } from 'react'; +import { runOnUI, useDerivedValue } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import { BLUR_SHADER } from './shader'; +import doTheTrick from '../../utils/doTheTrick'; +import { TRIANGLE_VERTEX_SHADER } from '../../shaders/TRIANGLE_VERTEX_SHADER'; + +type CanvasProps = ViewProps & { + transparent?: boolean; +}; + +type Props = CanvasProps & { + /** + * The image source to blur. + */ + source: ImageSourcePropType; + /** + * Blur filter size + * @default 15 + */ + filterSize?: number | SharedValue; +}; + +export default function Blur({ + source, + filterSize = 15, + style, + ...canvasProps +}: Props) { + const { sharedContext, canvasRef } = useWGPUSetup(); + const [imageBitmap, setImageBitmap] = useState(null); + + const animatedFilterSize = useDerivedValue(() => { + const size = typeof filterSize === 'number' ? filterSize : filterSize.get(); + return size; + }); + + useEffect(() => { + const loadImage = async () => { + try { + const resolved = Image.resolveAssetSource(source); + if (!resolved) { + throw new Error('Failed to resolve image source'); + } + + const url = resolved.uri; + const response = await fetch(url); + const blob = await response.blob(); + const bitmap = await createImageBitmap(blob); + + setImageBitmap(bitmap); + } catch (error) { + console.error('Error loading image:', error); + } + }; + + loadImage(); + }, [source]); + + const drawBlur = useCallback(() => { + 'worklet'; + const { device, context, presentationFormat } = sharedContext.get(); + if (!device || !context || !presentationFormat || !imageBitmap) { + return; + } + + const filterSizeValue = animatedFilterSize.get(); + + // Create source texture from image + const srcTexture = device.createTexture({ + size: [imageBitmap.width, imageBitmap.height, 1], + format: 'rgba8unorm', + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + + device.queue.copyExternalImageToTexture( + { source: imageBitmap }, + { texture: srcTexture }, + [imageBitmap.width, imageBitmap.height] + ); + + // Create intermediate texture for two-pass blur + const intermediateTexture = device.createTexture({ + size: [imageBitmap.width, imageBitmap.height], + format: 'rgba8unorm', + usage: + GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT, + }); + + // Create sampler + const sampler = device.createSampler({ + magFilter: 'linear', + minFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', + }); + + // Create uniform buffers for both passes + const horizontalParams = device.createBuffer({ + size: 8, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer( + horizontalParams, + 0, + new Float32Array([filterSizeValue, 0.0]) // direction = 0 (horizontal) + ); + + const verticalParams = device.createBuffer({ + size: 8, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer( + verticalParams, + 0, + new Float32Array([filterSizeValue, 1.0]) // direction = 1 (vertical) + ); + + const fragmentModule = device.createShaderModule({ code: BLUR_SHADER }); + const vertexModule = device.createShaderModule({ + code: TRIANGLE_VERTEX_SHADER, + }); + + const horizontalPipeline = device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: vertexModule, + entryPoint: 'main', + }, + fragment: { + module: fragmentModule, + entryPoint: 'fragmentMain', + targets: [{ format: 'rgba8unorm' }], + }, + primitive: { + topology: 'triangle-list', + }, + }); + + const verticalPipeline = device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: vertexModule, + entryPoint: 'main', + }, + fragment: { + module: fragmentModule, + entryPoint: 'fragmentMain', + targets: [{ format: presentationFormat }], + }, + primitive: { + topology: 'triangle-list', + }, + }); + + const commandEncoder = device.createCommandEncoder(); + + const horizontalPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: intermediateTexture.createView(), + clearValue: [0, 0, 0, 1], + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + horizontalPass.setPipeline(horizontalPipeline); + horizontalPass.setBindGroup( + 0, + device.createBindGroup({ + layout: horizontalPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: srcTexture.createView() }, + { binding: 1, resource: sampler }, + { binding: 2, resource: { buffer: horizontalParams } }, + ], + }) + ); + horizontalPass.draw(3); + horizontalPass.end(); + + // Second pass: Vertical blur directly to screen + const verticalPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: context.getCurrentTexture().createView(), + clearValue: [0, 0, 0, 1], + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + verticalPass.setPipeline(verticalPipeline); + verticalPass.setBindGroup( + 0, + device.createBindGroup({ + layout: verticalPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: intermediateTexture.createView() }, + { binding: 1, resource: sampler }, + { binding: 2, resource: { buffer: verticalParams } }, + ], + }) + ); + verticalPass.draw(3); + verticalPass.end(); + + device.queue.submit([commandEncoder.finish()]); + context.present(); + }, [sharedContext, imageBitmap, animatedFilterSize]); + + useEffect(() => { + if (!imageBitmap) { + return; + } + + doTheTrick(drawBlur); + + function listenToAnimatedValues() { + animatedFilterSize.addListener(0, () => { + drawBlur(); + }); + } + + function stopListeningToAnimatedValues() { + animatedFilterSize.removeListener(0); + } + + runOnUI(listenToAnimatedValues)(); + return runOnUI(stopListeningToAnimatedValues); + }, [imageBitmap, drawBlur, sharedContext, animatedFilterSize]); + + return ( + + ); +} + +Blur.displayName = 'Blur'; + +const styles = StyleSheet.create({ + webgpu: { + flex: 1, + }, +}); diff --git a/src/components/Blur/shader.ts b/src/components/Blur/shader.ts new file mode 100644 index 0000000..1ff60f6 --- /dev/null +++ b/src/components/Blur/shader.ts @@ -0,0 +1,30 @@ +export const BLUR_SHADER = /* wgsl */ ` +@group(0) @binding(0) var inputTexture: texture_2d; +@group(0) @binding(1) var texSampler: sampler; + +struct BlurParams { + filterSize: f32, + direction: f32, // 0 = horizontal, 1 = vertical +} +@group(0) @binding(2) var params: BlurParams; + +@fragment +fn fragmentMain(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = (ndc + 1.0) / 2.0; + let texSize = vec2(textureDimensions(inputTexture)); + + var color = vec4(0.0); + let radius = i32(params.filterSize / 2.0); + + for (var i = -radius; i <= radius; i++) { + let offset = mix( + vec2(f32(i) / texSize.x, 0.0), // horizontal + vec2(0.0, f32(i) / texSize.y), // vertical + params.direction + ); + color += textureSample(inputTexture, texSampler, uv + offset); + } + + return color / params.filterSize; +} +`; diff --git a/src/index.tsx b/src/index.tsx index fea92b2..f4e9879 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import CalicoSwirl from './components/CalicoSwirl'; import Desert from './components/Desert'; import Holo from './components/Holo'; import Glitter from './components/Glitter'; +import Blur from './components/Blur'; export { CircularGradient, @@ -20,4 +21,5 @@ export { Desert, Holo, Glitter, + Blur, };