├── .gitattributes ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── Particles.xcscheme ├── Assets ├── Emitter.gif ├── ForEach.png ├── Glow.png ├── Hue.gif ├── Lattice.gif └── particles.gif ├── CONTRIBUTING.md ├── Examples └── ParticlesExample │ ├── ParticlesExample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── ParticlesExample.xcscheme │ ├── ParticlesExample │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── chopper.imageset │ │ │ ├── Contents.json │ │ │ └── black-chopper-motorcycle.png │ ├── ContentView.swift │ ├── ParticlesExample.entitlements │ ├── ParticlesExampleApp.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ └── View │ │ ├── CometView.swift │ │ ├── FireView.swift │ │ ├── FireworksView.swift │ │ ├── GhostRiderView.swift │ │ ├── MagicView.swift │ │ ├── RainView.swift │ │ ├── SmokeView.swift │ │ ├── SnowView.swift │ │ └── StarsView.swift │ └── ParticlesExampleWatch Watch App │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── ParticlesExampleWatchApp.swift │ └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── LICENSE ├── Package.swift ├── README.md └── Sources ├── Particles ├── API │ ├── Entities │ │ ├── Emitter.swift │ │ ├── EmptyEntity.swift │ │ ├── ForEach.swift │ │ ├── Group.swift │ │ ├── Lattice.swift │ │ └── Particle.swift │ ├── Entity.swift │ ├── EntityBuilder.swift │ ├── Extensions │ │ ├── Angle.swift │ │ ├── CGVector.swift │ │ ├── ClosedRange+Random.swift │ │ ├── Color+RGBA.swift │ │ └── View │ │ │ ├── View+Lattice.swift │ │ │ └── View+Particles.swift │ ├── Modifiers │ │ ├── Entity+Behavior.swift │ │ ├── Entity+ColorOverlay.swift │ │ ├── Entity+Delay.swift │ │ ├── Entity+Glow.swift │ │ ├── Entity+Physics.swift │ │ ├── Entity+Render.swift │ │ ├── Entity+Shader.swift │ │ └── Entity+Transition.swift │ ├── ParticleSystem.swift │ ├── Proxy.swift │ ├── Transition.swift │ ├── Transitions │ │ ├── OpacityTransition.swift │ │ ├── ScaleTransition.swift │ │ └── TwinkleTransition.swift │ └── Type Erasures │ │ ├── AnyEntity.swift │ │ └── AnyTransition.swift └── Intermodular │ ├── Extensions │ ├── Color.swift │ ├── ConvertImage.swift │ ├── NSImage.swift │ └── View+BoundlessOverlay.swift │ ├── FlatEntity.swift │ ├── ModifiedEntity.swift │ ├── ParticleSystem+Data.swift │ ├── ShaderEntity.swift │ ├── TouchRecognizer.swift │ └── Transparent.swift └── ParticlesPresets ├── API ├── Preset.swift ├── PresetEntry.swift ├── PresetParameter.swift └── Presets │ ├── Comet.swift │ ├── Fire.swift │ ├── Fireworks.swift │ ├── Magic.swift │ ├── Rain.swift │ ├── Smoke.swift │ ├── Snow.swift │ └── Stars.swift ├── Intermodular └── PresetParameter+.swift └── Resources └── Assets.xcassets ├── Contents.json ├── circle.imageset ├── Contents.json └── circle.png ├── flame.imageset ├── Contents.json └── flame.png ├── snow1.imageset ├── Contents.json └── snow1.png ├── snow2.imageset ├── Contents.json └── snow2.png └── sparkle.imageset ├── Contents.json └── sparkle.png /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Particles.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Assets/Emitter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Assets/Emitter.gif -------------------------------------------------------------------------------- /Assets/ForEach.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Assets/ForEach.png -------------------------------------------------------------------------------- /Assets/Glow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Assets/Glow.png -------------------------------------------------------------------------------- /Assets/Hue.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Assets/Hue.gif -------------------------------------------------------------------------------- /Assets/Lattice.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Assets/Lattice.gif -------------------------------------------------------------------------------- /Assets/particles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Assets/particles.gif -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thank you for considering contributing to our Particle Package! Your participation is essential in making this package vibrant and dynamic. Below are the guidelines to help you get started on your journey with us. 4 | 5 | ### Issue Management 6 | 7 | - Issues reported will be actively responded to by the maintainers. 8 | - Please provide thorough descriptions and steps to reproduce the reported issue. 9 | - Respectful and constructive communication is expected from all contributors for effective issue resolution. 10 | 11 | **Preset Guidelines** 12 | 13 | - Users can contribute new particle presets via Pull Requests. 14 | - Presets align with the package's standards and enhance the overall user experience. 15 | - New presets will be reviewed by our team and considered for inclusion in the package. 16 | 17 | ### Community Guidelines 18 | 19 | - Abide by the GitHub community guidelines. 20 | - Ensure respectful and considerate interactions with other contributors and users. 21 | 22 | ### Pre-Release 23 | 24 | - The package is in a Pre-Release state and is actively undergoing development. 25 | - Features and APIs may undergo modifications, so contributors should be prepared for updates and improvements. 26 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample.xcodeproj/xcshareddata/xcschemes/ParticlesExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/Assets.xcassets/chopper.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "black-chopper-motorcycle.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/Assets.xcassets/chopper.imageset/black-chopper-motorcycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Examples/ParticlesExample/ParticlesExample/Assets.xcassets/chopper.imageset/black-chopper-motorcycle.png -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ParticlesExample 4 | // 5 | // Created by Ben Myers on 6/26/23. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct ContentView: View { 13 | @State var purchased = false 14 | var body: some View { 15 | NavigationSplitView { 16 | List { 17 | Text("Presets").font(.headline).foregroundStyle(.secondary) 18 | NavigationLink("Comet", destination: CometView.init) 19 | // NavigationLink("Ghost Rider", destination: GhostRiderView.init) 20 | NavigationLink("Fire", destination: FireView.init) 21 | NavigationLink("Snow", destination: SnowView.init) 22 | NavigationLink("Smoke", destination: SmokeView.init) 23 | NavigationLink("Magic", destination: MagicView.init) 24 | NavigationLink("Rain", destination: RainView.init) 25 | NavigationLink("Stars", destination: StarsView.init) 26 | NavigationLink("Fireworks", destination: FireworksView.init) 27 | NavigationLink("Lattice", destination: thumbnailView) 28 | } 29 | } detail: { 30 | thumbnailView 31 | } 32 | .preferredColorScheme(.dark) 33 | } 34 | 35 | var thumbnailView: some View { 36 | ParticleSystem { 37 | Lattice(spacing: 4) { 38 | Text("Particles") 39 | .fontWeight(.black) 40 | .font(.system(size: 90)) 41 | .foregroundStyle(Color.red) 42 | } 43 | .delay(with: { c in 44 | return Double(c.proxy.position.x) * 0.005 + Double.random(in: 0.0 ... 0.5) 45 | }) 46 | .transition(.opacity, on: .birth, duration: 3.0) 47 | .hueRotation(with: { c in 48 | return .degrees(c.proxy.position.x + 60 * (c.timeAlive + c.proxy.seed.0)) 49 | }) 50 | .glow(radius: 4) 51 | .scale(1.5) 52 | .lifetime(99) 53 | .zIndex(1) 54 | .fixVelocity { c in 55 | .init(dx: 0.1 * cos(6 * (c.timeAlive + c.proxy.seed.0)), dy: 0.1 * sin(6 * (c.timeAlive + c.proxy.seed.1))) 56 | } 57 | Emitter(every: 0.01) { 58 | Particle { 59 | Circle() 60 | .frame(width: 5, height: 5) 61 | .foregroundStyle(.red) 62 | } 63 | .hueRotation(angleIn: .zero ... .degrees(360)) 64 | .glow(radius: 5) 65 | .transition(.opacity, on: .birth, duration: 1.0) 66 | .initialOffset(y: -150.0) 67 | .transition(.twinkle, on: .death, duration: 4.0) 68 | .zIndex(with: { c in 69 | if c.proxy.position.x > c.system.size.width * 0.5 { 70 | return 2 71 | } else { 72 | return 0 73 | } 74 | }) 75 | .fixVelocity { c in 76 | let t: Double = c.timeAlive * ((2.0 + 0.5 * c.proxy.seed.0) /*+ c.proxy.seed.1 * 2 * .pi*/) 77 | return CGVector(dx: 15 * cos(t + 0.1 * c.proxy.seed.2), dy: 10 * sin(t - 0.6)) 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | struct ContentView_Previews: PreviewProvider { 85 | static var previews: some View { 86 | ContentView() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/ParticlesExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/ParticlesExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParticlesExampleApp.swift 3 | // ParticlesExample 4 | // 5 | // Created by Ben Myers on 6/26/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ParticlesExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/CometView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CometView.swift 3 | // ParticlesExample 4 | // 5 | // Created by Demirhan Mehmet Atabey on 06.04.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct CometView: View { 13 | 14 | @State var size: CGFloat = 20.0 15 | @State var intensity: Double = 20 16 | 17 | var body: some View { 18 | ParticleSystem { 19 | Group { 20 | Emitter(every: 14.0) { 21 | Particle { 22 | RadialGradient( 23 | colors: [Color.pink, Color.red], 24 | center: .center, 25 | startRadius: 0.0, 26 | endRadius: 10 27 | ) 28 | .clipShape(Circle()) 29 | } 30 | .hueRotation(angleIn: .degrees(0) ... .degrees(360)) 31 | .lifetime(12) 32 | .glow(Color.red.opacity(0.5), radius: 40.0) 33 | .initialVelocity(x: 2, y: -2) 34 | .initialPosition { c in 35 | let pairs = [(-600, 500), (Int(c.system.size.width) - 600, Int(c.system.size.height) + 500)] 36 | let randomPair = pairs.randomElement()! 37 | return CGPoint(x: randomPair.0, y: randomPair.1) 38 | } 39 | } 40 | Stars() 41 | Preset.Comet() 42 | } 43 | } 44 | } 45 | struct Stars: Entity, PresetEntry { 46 | public var body: some Entity { 47 | Emitter(every: 0.01) { 48 | Star() 49 | } 50 | } 51 | public struct Star: Entity { 52 | public var body: some Entity { 53 | Particle { 54 | Circle() 55 | .frame(width: 14.0, height: 14.0) 56 | } 57 | .initialPosition { c in 58 | let x = Int.random(in: 0 ... Int(c.system.size.width)) 59 | let y = Int.random(in: 0 ... Int(c.system.size.height)) 60 | return CGPoint(x: x, y: y) 61 | } 62 | .opacity { c in 63 | return 1.0 * (c.timeAlive) 64 | } 65 | .lifetime(in: 3.0 +/- 1.0) 66 | .scale(factorIn: 0.1 ... 0.6) 67 | .blendMode(.plusLighter) 68 | .initialVelocity(xIn: 0.2 ... 0.8, yIn: -0.5 ... 0.25) 69 | .fixAcceleration(x: 0.3, y: -0.3) 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/FireView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FireView.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 22.03.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct FireView: View { 13 | 14 | @State var color: Color = .red 15 | @State var flameSize: Double = 15.0 16 | @State var spawnRadius: CGFloat = 8.0 17 | 18 | var body: some View { 19 | ZStack(alignment: .top) { 20 | ParticleSystem { 21 | Preset.Fire(color: color, flameSize: flameSize, spawnRadius: .init(width: spawnRadius, height: spawnRadius)) 22 | } 23 | .statePersistent("fire", refreshesViews: true) 24 | #if !os(watchOS) 25 | HStack { 26 | ColorPicker("Color", selection: $color) 27 | Slider(value: $flameSize, in: 5.0 ... 40.0, label: { Text("Flame Size (\(String(format: "%.1f", flameSize)))") }) 28 | } 29 | .padding() 30 | #endif 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/FireworksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FireworksView.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 31.03.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct FireworksView: View { 13 | 14 | @State var color: Color = .pink 15 | 16 | var body: some View { 17 | ZStack(alignment: .topLeading) { 18 | ParticleSystem { 19 | Preset.Fireworks(color: color) 20 | } 21 | #if !os(watchOS) 22 | HStack { 23 | ColorPicker("Color", selection: $color) 24 | } 25 | .padding() 26 | #endif 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/GhostRiderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GhostRiderView.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 22.03.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct GhostRiderView: View { 13 | 14 | @State var motorcyclePosition: CGFloat = 0 15 | 16 | var body: some View { 17 | ZStack { 18 | HStack { 19 | Image("chopper") 20 | .resizable() 21 | .scaledToFit() 22 | .frame(width: 800, height: 150) 23 | .frame(height: 900) 24 | .overlay { 25 | Text("💀") 26 | .font(.system(size: 30)) 27 | .particleSystem(atop: true, offset: CGPoint(x: 0, y: -15)) { 28 | Preset.Fire() 29 | } 30 | .offset(x: -15, y: -15) 31 | } 32 | .particleSystem(offset: CGPoint(x: -60, y: 0)) { 33 | Preset.Smoke( 34 | dirty: true, 35 | spawnPoint: .topLeading, 36 | startRadius: 6, 37 | endRadius: 25 38 | ) 39 | } 40 | Spacer() 41 | } 42 | .offset(x: motorcyclePosition) 43 | .onAppear { 44 | withAnimation(.easeInOut(duration: 3)) { 45 | motorcyclePosition = 300 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/MagicView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MagicView.swift 3 | // ParticlesExample 4 | // 5 | // Created by Ben Myers on 3/22/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct MagicView: View { 13 | 14 | @State var color: Color = .pink 15 | 16 | var body: some View { 17 | ZStack(alignment: .top) { 18 | ParticleSystem { 19 | Preset.Magic(color: color) 20 | } 21 | .statePersistent("magic") 22 | #if !os(watchOS) 23 | HStack { 24 | ColorPicker("Color", selection: $color) 25 | } 26 | .padding() 27 | #endif 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/RainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RainView.swift 3 | // ParticlesExample 4 | // 5 | // Created by Ben Myers on 3/22/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct RainView: View { 13 | 14 | @State var color: Color = .red 15 | @State var intensity: Double = 20 16 | @State var wind: CGFloat = 0.5 17 | 18 | var body: some View { 19 | ZStack(alignment: .top) { 20 | ParticleSystem { 21 | Preset.Rain(lifetime: 5.0, intensity: Int(intensity), wind: wind) 22 | } 23 | .statePersistent("rain") 24 | HStack { 25 | Slider(value: $intensity, in: 1 ... 100, label: { Text("Intensity (\(Int(intensity)))") }) 26 | Slider(value: $wind, in: -2.0 ... 2.0, label: { Text("Wind (\(String(format: "%.1f", wind)))") }) 27 | } 28 | .padding() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/SmokeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SmokeView.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 22.03.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct SmokeView: View { 13 | 14 | @State var dirty = false 15 | 16 | var body: some View { 17 | ZStack(alignment: .top) { 18 | ParticleSystem { 19 | Preset.Smoke(dirty: dirty) 20 | } 21 | .statePersistent("smoke") 22 | // HStack { 23 | // Toggle("Dirty Smoke", isOn: $dirty) 24 | // } 25 | // .padding() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/SnowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnowView.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 22.03.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct SnowView: View { 13 | 14 | var body: some View { 15 | ParticleSystem { 16 | Preset.Snow() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExample/View/StarsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StarsView.swift 3 | // ParticlesExample 4 | // 5 | // Created by Ben Myers on 3/22/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import ParticlesPresets 11 | 12 | struct StarsView: View { 13 | 14 | @State var size: CGFloat = 20.0 15 | @State var intensity: Double = 20 16 | 17 | var body: some View { 18 | ZStack(alignment: .top) { 19 | ParticleSystem { 20 | Preset.Stars(size: size, lifetime: 5.0, intensity: Int(intensity), twinkle: true) 21 | } 22 | .statePersistent("stars", refreshesViews: true) 23 | HStack { 24 | Slider(value: $size, in: 1 ... 50, label: { Text("Size (\(String(format: "%.1f", size)))") }) 25 | Slider(value: $intensity, in: 1 ... 100, label: { Text("Intensity (\(Int(intensity)))") }) 26 | } 27 | .padding() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExampleWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExampleWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "watchos", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExampleWatch Watch App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExampleWatch Watch App/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ParticlesExampleWatch Watch App 4 | // 5 | // Created by Ben Myers on 4/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var body: some View { 12 | NavigationSplitView { 13 | List { 14 | Text("Presets").font(.headline).foregroundStyle(.secondary) 15 | NavigationLink("Comet", destination: CometView.init) 16 | NavigationLink("Ghost Rider", destination: GhostRiderView.init) 17 | NavigationLink("Fire", destination: FireView.init) 18 | NavigationLink("Snow", destination: SnowView.init) 19 | NavigationLink("Smoke", destination: SmokeView.init) 20 | NavigationLink("Magic", destination: MagicView.init) 21 | NavigationLink("Rain", destination: RainView.init) 22 | NavigationLink("Stars", destination: StarsView.init) 23 | NavigationLink("Fireworks", destination: FireworksView.init) 24 | } 25 | } detail: { 26 | Text("Particles") 27 | } 28 | .preferredColorScheme(.dark) 29 | } 30 | } 31 | 32 | #Preview { 33 | ContentView() 34 | } 35 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExampleWatch Watch App/ParticlesExampleWatchApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParticlesExampleWatchApp.swift 3 | // ParticlesExampleWatch Watch App 4 | // 5 | // Created by Ben Myers on 4/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ParticlesExampleWatch_Watch_AppApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/ParticlesExample/ParticlesExampleWatch Watch App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Benjamin Myers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Particles", 6 | platforms: [.iOS(.v15), .macOS(.v12), .watchOS(.v8)], 7 | products: [ 8 | .library(name: "ParticlesPresets", targets: ["ParticlesPresets"]), 9 | .library(name: "Particles", targets: ["Particles"]) 10 | ], 11 | dependencies: [], 12 | targets: [ 13 | .target( 14 | name: "Particles" 15 | ), 16 | .target( 17 | name: "ParticlesPresets", 18 | dependencies: ["Particles"], 19 | resources: [.process("Resources")] 20 | ), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | > [!NOTE] 6 | > *Mar 30, 2025*: This repo has gotten 100 stars! I will continue to move this project to Release. 7 | > Please submit feature requests in **Issues**! 8 | 9 | > [!IMPORTANT] 10 | > Particles v2.0 is in **Pre-Release** and it is in active development. If you plan on including Particles in your project, please read this [additional information](#pre-release). 11 | 12 | ## Native, declarative, and fast. 13 | 14 | Create particle systems in a flash using a simple but powerful syntax. 15 | 16 |

17 | 18 |

19 | 20 | ```swift 21 | import Particles 22 | 23 | var body: some View { 24 | ParticleSystem { 25 | Emitter(every: 0.05) { 26 | Particle { 27 | Text("✨") 28 | } 29 | .initialPosition(.center) 30 | .initialVelocity(xIn: -1.0 ... 1.0, yIn: -1.0 ... 1.0) 31 | .hueRotation(angleIn: .degrees(0) ... .degrees(360)) 32 | .glow(.white) 33 | } 34 | } 35 | } 36 | ``` 37 | 38 | Easily integrate Particles into your SwiftUI views. 39 | 40 | ```swift 41 | VStack { 42 | Text(purchased ? "Thank you!" : "") 43 | .emits(every: 0.1, if: purchased, offset: CGPoint(x: 0, y: -20)) { 44 | Particle { Text("❤️") } 45 | .fixAcceleration(y: 0.05) 46 | .initialVelocity(xIn: -2.0 ... 2.0, yIn: -2.0 ... -1.5) 47 | .transition(.scale) 48 | } 49 | Button("Purchase") { 50 | purchased = true 51 | } 52 | } 53 | ``` 54 | 55 | And jump in with configurable presets. 56 | 57 | ```swift 58 | import ParticlesPresets 59 | 60 | ParticleSystem { 61 | Preset.Fire() 62 | Preset.Snow(intensity: 5) 63 | Preset.Rain() 64 | } 65 | ``` 66 | 67 | ## Contents 68 | 69 | 1. [Quickstart](#quickstart) - install the repository 70 | 2. [Entities](#entities) - such as [`Particle`](#particle), [`Emitter`](#emitter), and [`ForEach`](#foreach) 71 | 3. [Defining Entities](#defining-entities) - create custom `Entity` structs to use in particle systems 72 | 4. [Modifiers](#modifiers) - change the behavior of particles (see [list](#list-of-entity-modifiers)) 73 | 5. [State Persistence](#state-persistence) - persist `ParticleSystem` simulation through state updates 74 | 6. [Presets](#presets) - browse a curated library of preset entities 75 | 7. [Pre-Release](#pre-release) - this package is in pre-release, additional information here ⚠️ 76 | 8. [Performance](#performance) - debugging, frame rate tips, and benchmarks 77 | 78 | ## Quickstart 79 | 80 | To get started, first add Particles as a Swift Package Dependency in your Xcode project: 81 | 82 | ``` 83 | https://github.com/benlmyers/swiftui-particles 84 | ``` 85 | 86 | - To begin with pre-made particles, like Fire or Rain, add and import the `ParticlesPresets` library: 87 | 88 | ```swift 89 | import ParticlesPresets 90 | ``` 91 | 92 | - Or, to build your own particle systems, add and import the `Particles` library: 93 | 94 | ```swift 95 | import Particles 96 | ``` 97 | 98 | Create a `ParticleSystem` within your `View`. Then, add some entities or presets! 99 | 100 | ```swift 101 | struct MyView: View { 102 | var body: some View { 103 | ParticleSystem { 104 | // Add entities here! 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | ### Supported Versions 111 | 112 | - iOS 15.0+ 113 | - macOS 12.0+ 114 | - watchOS 8.0+ 115 | 116 | ## Entities 117 | 118 | Particles has several entities that bring life to your SwiftUI views. Some entities are built using views, and others using other entities. 119 | 120 | ### Particle 121 | 122 | A `Particle` is the building block of the particle system. You can define one using a view: 123 | 124 | ```swift 125 | Particle { 126 | Circle().foregroundStyle(.red).frame(width: 10.0, height: 10.0) 127 | } 128 | ``` 129 | 130 | ### Emitter 131 | 132 | An `Emitter` fires new entities on a regular interval. 133 |

134 | 135 |

136 | 137 | ```swift 138 | Emitter(every: 0.02) { // Fires every 0.02 seconds 139 | Particle { 140 | Text("😀") 141 | } 142 | .initialAcceleration(xIn: -1.0 ... 1.0, yIn: -1.0 ... 1.0) 143 | .initialTorque(.degrees(1.0)) 144 | } 145 | ``` 146 | 147 | ### Group 148 | 149 | A `Group` holds multiple entities. Like SwiftUI, modifiers applied to a Group will be applied to all entities inside the Group. 150 | 151 |

152 | 153 |

154 | 155 | ```swift 156 | ParticleSystem { 157 | Group { 158 | Particle { Text("🔥") } 159 | Particle { Text("🧨") } 160 | } 161 | .glow(.red) // Both particles will have a red glow 162 | } 163 | ``` 164 | 165 | While the name clashes with SwiftUI's, in most cases you needn't worry. The `ParticleSystem` initializer tells the compiler to expect an `Entity`-conforming rather than a `View`-conforming `Group`. 166 | 167 | ### ForEach 168 | 169 | Like `Group`, `ForEach` holds multiple entities iterated over a collection of elements. 170 | 171 | ```swift 172 | ParticleSystem { 173 | ForEach([1, 2, 3, 4]) { i in 174 | Particle { Text("\(i)") } 175 | .initialVelocity(xIn: -1.0 ... 1.0) // Modifiers can also be applied outside of ForEach 176 | } 177 | } 178 | ``` 179 | 180 | Above, four view is registered; one for each particle. You can improve the performance of `ForEach` by merging views, or in rarer cases, entity declarations: 181 | 182 | #### Merging Views 183 | 184 |

185 | 186 |

187 | 188 | ```swift 189 | ForEach(myLargeCollection, merges: .views) { item in 190 | Particle { 191 | Text("⭐️") 192 | } 193 | .initialPosition(xIn: 0 ... 100, yIn: 0 ... 100) 194 | } 195 | ``` 196 | 197 | Here, only the first view is registered, and the rest of the entities receive the same view. To learn more about `merges: .views`, see [Performance](#performance). 198 | 199 | ### Lattice 200 | 201 | A `Lattice` creates a grid of particles that covers and samples the colors of a `View`. You can customize the behavior of each particle in the `Lattice` by applying modifiers. 202 | 203 |

204 | 205 |

206 | 207 | ```swift 208 | ParticleSystem { 209 | Lattice { 210 | Image(systemName: "star.fill") 211 | .resizable() 212 | .frame(width: 100.0, height: 100.0) 213 | .foregroundStyle(Color.red) 214 | } 215 | .scale(1.5) 216 | .initialVelocity(xIn: -1.0 ... 1.0, yIn: -1.0 ... 1.0) 217 | } 218 | ``` 219 | 220 | > [!TIP] 221 | > You can choose to have the lattice spawn particles along a view's edge by passing `Lattice(hugging:)`. 222 | 223 | ## Defining Entities 224 | 225 | You can define a custom entity by conforming a `struct` to `Entity` and providing a value for `var body: some Entity`. 226 | 227 | ```swift 228 | struct MyEmojiParticle: Entity { 229 | var emoji: String 230 | var body: some Entity { 231 | Particle { 232 | Text(emoji) 233 | } 234 | } 235 | } 236 | 237 | struct MyView: View { 238 | var body: some View { 239 | ParticleSystem { 240 | MyEmojiParticle(emoji: "😀") 241 | } 242 | } 243 | } 244 | ``` 245 | 246 | ## Modifiers 247 | 248 | Particles has several modifiers you can apply to entities to change their behavior. 249 | 250 | ```swift 251 | ParticleSystem { 252 | Particle { 253 | Image(systemName: "leaf.fill") 254 | } 255 | .lifetime(3) // particle lasts 3 seconds 256 | .colorOverlay(.orange) // particle is orange 257 | .blur(in: 0.0 ... 3.0) // particle has random blur effect 258 | } 259 | ``` 260 | 261 | Some modifiers, like `.initialPosition(x:y:)`, affect the initial behavior of an entity; while others, like `.fixPosition(with:)` affect the behavior on each frame. 262 | 263 | Like SwiftUI modifiers, *most*\* entity modifiers are applied *outside first*, *inside last*. For instance, since `.initialPosition(...)` *sets* a particle's position, applying this modifier **above** `.initialOffset(...)` will cause the offset to not be applied. `.initialOffset(...)`, which *changes* the position, must be written *inside*. 264 | 265 | \* *Some rendering operations, like `.colorOverlay(...)` or `.hueRotation(...)`, follow a static ordering despite modifier ordering.* 266 | 267 | ### List of Entity Modifiers 268 | 269 | For more information on modifier parameters, see symbol documentation. 270 | 271 | - Lifetime 272 | - `.lifetime(...)` 273 | - Position and Offset 274 | - `.initialPosition(...)` 275 | - `.initialOffset(...)` 276 | - `.fixPosition(...)` 277 | - `.zIndex(...)` 278 | - Velocity and Acceleration 279 | - `.initialVelocity(...)` 280 | - `.fixVelocity(...)` 281 | - `.initialAcceleration(...)` 282 | - `.fixAcceleration(...)` 283 | - `.drag(...)` 284 | - Rotation and Torque 285 | - `.initialRotation(...)` 286 | - `.fixRotation(...)` 287 | - `.initialTorque(...)` 288 | - `.fixTorque(...)` 289 | - `.rotation3D(x:y:z:)` 290 | - Effects 291 | - `.opacity(...)` 292 | - `.blendMode(_:)` 293 | - `.colorOverlay(...)` 294 | - `.hueRotation(...)` 295 | - `.blur(...)` 296 | - `.scale(...)` 297 | - `.glow(...)` 298 | - `.shader(...)` 299 | - Transitions and Timing 300 | - `.transition(_:on:duration:)` 301 | - `.delay(...)` 302 | - Custom Behavior 303 | - `.onAppear(perform:)` 304 | - `.onUpdate(perform:)` 305 | 306 | ### Other Modifiers 307 | 308 | - `ParticleSystem.debug()` - enables *[Debug Mode](#debug-mode)* for the particle system, showing performance metrics 309 | - `ParticleSystem.statePersistent(_:refreshesViews:)` - enables *[State Persistence](#state-persistence)* for the particle system 310 | - `ParticleSystem.checksTouches(_:)` - option to disable touch updates for performance 311 | - `Emitter.emitSingle(choosing:)` - instructs `Emitter` to emit one particle at a time 312 | - `Emitter.emitAll()` - instructs `Emitter` to emit all passed particles at once 313 | - `Emitter.maxSpawn(count:)` - stops emitting entities after `count` are emitted 314 | - `Lattice.customView(view:)` - customizes the view of `Lattice` particles 315 | 316 | When importing `Particles`, you also have access to some useful view modifiers. 317 | 318 | - `View.particleSystem(atop:offset:entities:)` - creates a particle system centered at the modified view 319 | - `View.emits(every:if:atop:simultaneously:entities:)` - emits specific entities on an interval from the center of the modified view 320 | - `View.dissolve(if:)` - dissolves the view (using `Lattice`) if `condition` is true 321 | - `View.burst(if:)` - bursts the view if `condition` is true 322 | 323 | All modifiers are documented with parameter information. 324 | 325 | > [!WARNING] 326 | > Particles is in **Pre-Release**. The API for the four view modifiers listed above may be changed before release. 327 | 328 | ### Custom Behavior Closures 329 | 330 | Some modifier parameters take in a closure of the form `(Proxy.Context) -> `. [`Proxy.Context`](Sources/Particles/API/Proxy.swift#L68-L89) provides information about the current proxy and the system it lives in. 331 | 332 | Below is a list of properties of `Proxy.Context`. All symbols are documented. 333 | 334 | - `proxy: Proxy` 335 | - `.position: CGPoint` 336 | - `.velocity: CGVector` 337 | - `.acceleration: CGVector` 338 | - `.drag: Double` 339 | - `.rotation: Angle` 340 | - `.torque: Angle` 341 | - `.rotation3d: SIMD3` 342 | - `.zIndex: Int` 343 | - `.lifetime: Double` 344 | - `.seed: (Double, Double, Double, Double)` 345 | - `.opacity: Double` 346 | - `.hueRotation: Angle` 347 | - `.blur: CGFloat` 348 | - `.scale: CGSize` 349 | - `.blendMode: GraphicsContext.BlendMode` 350 | - `system: ParticleSystem.Data` 351 | - `.debug: Bool` 352 | - `.size: CGSize` 353 | - `.currentFrame: UInt` 354 | - `.lastFrameUpdate` 355 | - `.touches` (iOS) 356 | - `.time: TimeInterval` 357 | - `.proxiesAlive` 358 | - `.proxiesSpawned` 359 | - `.averageFrameRate` 360 | 361 | ## State Persistence 362 | 363 | `ParticleSystem` has the ability to persist its simulation through `View` state refreshes. To enable this functionality, provide a string tag to the `ParticleSystem`: 364 | 365 | ```swift 366 | struct MyView: View { 367 | @State var foo: Bool = false 368 | var body: some View { 369 | VStack { 370 | Button("Foo") { foo.toggle() } 371 | ParticleSystem { 372 | Emitter { 373 | if foo { 374 | Particle(view: { Text("😀") }).initialVelocity(withMagnitude: 1.0) 375 | } else { 376 | Particle(view: { Image(systemName: "star") }).initialVelocity(withMagnitude: 1.0) 377 | } 378 | } 379 | } 380 | .statePersistent("myEmitter") 381 | } 382 | } 383 | } 384 | 385 | ``` 386 | 387 | State refreshing works on all levels of the particle system, even in views inside `Particle { ... }`. You can also use `if`/`else` within `ParticleSystem`, `Emitter`, `Group`, and any other entity built with `EntityBuilder`. 388 | 389 | ## Presets 390 | 391 | A curated list of presets are available. These can be configured using parameters. Several additional presets will be added before the packages reaches a Release state. 392 | 393 | - [`Fire`](Sources/ParticlesPresets/API/Presets/Fire.swift) 394 | - [`Snow`](Sources/ParticlesPresets/API/Presets/Snow.swift) 395 | - [`Magic`](Sources/ParticlesPresets/API/Presets/Magic.swift) 396 | - [`Rain`](Sources/ParticlesPresets/API/Presets/Rain.swift) 397 | - [`Smoke`](Sources/ParticlesPresets/API/Presets/Smoke.swift) 398 | - [`Stars`](Sources/ParticlesPresets/API/Presets/Stars.swift) 399 | 400 | ParticlesPresets is accepting preset submissions in the form of pull requests. [Contributing guidelines](CONTRIBUTING.md) 401 | 402 | > [!WARNING] 403 | > ParticlesPresets is in **Pre-Release**. The appearance of currently available presets are subject to change, as are their parameters. Avoid including presets in production code. [More information](#pre-release) 404 | 405 | ### Example Project 406 | 407 | This package contains an example project where you can preview and tweak the library of available presets. To run it, open the example's [Xcode project file](Examples/ParticlesExample/ParticlesExample.xcodeproj) located in **Examples/ParticlesExample**. 408 | 409 | ## Pre-Release 410 | 411 | This package is in a pre-release state, which means certain parts of it are subject to change. The following is a roadmap to Release (and conseqeuently, a list of planned API changes): 412 | 413 | - Streamlined demos for presets 414 | - Several new preset entries 415 | - View modifier improvements, like `.burst(if:)` or `.emits(...)` 416 | 417 | Until full release, including Particles in production code is not recommended. 418 | 419 | ## Performance 420 | 421 | Particles can support the use of thousands of entities when compiled in **Release** scheme. As more modifiers and entities are added, `ParticleSystem`'s frame rate will lower. 422 | 423 | ### Debug Mode 424 | 425 | Use `ParticleSystem.debug()` to enable Debug Mode. 426 | 427 | Screenshot 2024-03-30 at 11 46 32 PM 428 | 429 | You can debug a `ParticleSystem` to view performance statistics, including *view size*, *frame rate*, *proxy count*, *entity count*, *registered view count*, *proxy update time*, and *rendering time*. 430 | 431 | `ParticleSystem` targets 60 FPS, meaning each frame must update in `1.0 / 60.0 = 0.01666..` seconds, or 16.66ms. Each frame update is roughly equal to the max of *proxy update time* and *rendering time*. If this exceeds 16ms, frames will begin to drop. 432 | 433 | ### Improve Frame Rate 434 | 435 | - Particles runs faster in **Release** compile scheme as compared to **Debug**. 436 | - If you are using many entity modifiers, consider combining behavior into a single closure using `.onAppear(...)` or `.onUpdate(...)`. 437 | - Modifiers with custom closures containing expensive operations will increase proxy update time. 438 | - An increased amount of effect modifiers, like `.glow(...)` or `.blur(...)`, will increase rendering time. 439 | - Use `ForEach(merges: .views)` if the view passed to `Particle` is the same across ForEach's data mapping. 440 | - Use `ForEach(merges: .entities)` if mapped entities only variate in their initial properties. `merges: .entities` tells `ForEach` (aka `Group`) to endow each created `Proxy` with properties determined by the mapped `Entity` **only upon birth**. After the proxy is born with its initial properties, like position, rotation, or hue rotation, it's entity rules are merged to the **first data mapping's** upon update. 441 | - By default, `ParticleSystem` has the `checksTouches` property set to true. Set `.checksTouches(false)` to improve performance. 442 | 443 | ### Benchmarks 444 | 445 | Performance Benchmarks will be available when this packages reaches a Release state. 446 | -------------------------------------------------------------------------------- /Sources/Particles/API/Entities/Emitter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Emitter.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An ``Entity`` that emits other entities. 11 | /// To create an emitter, pass an optional spawn interval and the entities to spawn using `@EntityBuilder`: 12 | /// ``` 13 | /// ParticleSystem { 14 | /// // Emits ✨ and 🌟 every 1.5 seconds 15 | /// Emitter(every: 1.5) { 16 | /// Particle { Text("✨") } 17 | /// Particle { Text("🌟") } 18 | /// } 19 | /// } 20 | /// ``` 21 | public struct Emitter: Entity, _Emitter where Children: Entity { 22 | 23 | // MARK: - Properties 24 | 25 | public var body: Children 26 | 27 | internal var emitInterval: TimeInterval 28 | internal var emitChooser: ((Proxy.Context) -> Int)? = { c in Int(c.system?.proxiesSpawned ?? 0) } 29 | 30 | // MARK: - Initalizers 31 | 32 | /// Creates an emitter that emits passed entities on an interval. 33 | /// If a group of entities is passed in `emits`, you can use ``emitAll()`` or ``emitSingle(choosing:)`` to change the entities spawned in the interval. 34 | /// - Parameter interval: The interval to emit entities. 35 | /// - Parameter emits: A closure returning the entity/entities to spawn on the interval. 36 | public init(every interval: TimeInterval = 1.0, @EntityBuilder emits: () -> Children) { 37 | self.emitInterval = interval 38 | self.body = emits() 39 | } 40 | 41 | // MARK: - Methods 42 | 43 | /// Modifies the ``Emitter`` to emit only one entity at a time. 44 | /// - Parameter choice: A closure that decides the index of the entity to spawn when the ``Emitter`` can spawn a new entity. By default, it cycles through passed entities. 45 | /// - Returns: The modified emitter 46 | public func emitSingle(choosing choice: @escaping (Proxy.Context) -> Int = { c in Int(c.system?.proxiesSpawned ?? 0)}) -> Emitter { 47 | var copy = self 48 | copy.emitChooser = choice 49 | return copy 50 | } 51 | 52 | /// Modifies the ``Emitter`` to emit all the passed entities at once. 53 | public func emitAll() -> Emitter { 54 | var copy = self 55 | copy.emitChooser = nil 56 | return copy 57 | } 58 | 59 | /// Modifies the ``Emitter`` to spawn a specified number of entities. 60 | /// Under the hood, this modifier is equivalent to `.lifetime(count * emitInterval)`. 61 | /// - Parameter count: The number of entities particles that can be spawned before the emitter is to be destroyed. 62 | /// - Returns: The modified entity. 63 | public func maxSpawn(count: Int) -> some Entity { 64 | self.lifetime(Double(count) * emitInterval) 65 | } 66 | } 67 | 68 | internal protocol _Emitter { 69 | var emitInterval: TimeInterval { get set } 70 | var emitChooser: ((Proxy.Context) -> Int)? { get set } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Particles/API/Entities/EmptyEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyEntity.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A type-erased, empty ``Entity`` used for result building. 11 | public struct EmptyEntity: Entity { 12 | public typealias Body = Never 13 | public var body: Never { fatalError() } 14 | } 15 | 16 | extension Never: Entity {} 17 | -------------------------------------------------------------------------------- /Sources/Particles/API/Entities/ForEach.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForEach.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An entity that creates several entities iterated over data elements. 11 | /// Creating multiple entities iterated over data elements is simple with ``ForEach``; it is similar to how views are defined within SwiftUI: 12 | /// ``` 13 | /// ForEach([Color.red, .orange, .yellow]) { color in 14 | /// Particle { 15 | /// Text("Hi").foregroundColor(color) 16 | /// } 17 | /// } 18 | /// ``` 19 | /// Modifiers placed outside a ``ForEach`` behave like ``Group``; they are applied to the inner entities: 20 | /// ``` 21 | /// ForEach(...) { x in ... } 22 | /// .initialPosition(.center) 23 | /// .initialVelocity(xIn: -1.0 ... 1.0, yIn: -1.0 ... 1.0) 24 | /// ``` 25 | /// In the example above, the entities spawned inside the `ForEach` all spawn in the center of the screen with a random velocity. 26 | public struct ForEach: Entity, Transparent where Data: RandomAccessCollection { 27 | 28 | // MARK: - Properties 29 | 30 | public var body: Particles.Group 31 | 32 | internal var data: Data 33 | internal var mapping: (Data.Element) -> any Entity 34 | internal var merges: Group.Merges? 35 | 36 | // MARK: - Initalizers 37 | 38 | /// - Parameter data: The data to iterate over. 39 | /// - Parameter merges: The merge rule to use when grouping entities. For more information, see ``Group/Merges``. 40 | /// - Parameter mapping: The mapping of data to entities. 41 | public init( 42 | _ data: Data, 43 | merges: Group.Merges? = nil, 44 | @EntityBuilder mapping: @escaping (Data.Element) -> E 45 | ) where E: Entity { 46 | self.data = data 47 | self.mapping = mapping 48 | self.merges = merges 49 | self.body = Group(values: data.map({ .init(body: mapping($0)) }), merges: merges) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Particles/API/Entities/Group.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Group.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A group of entities. 11 | /// Applying a modifier to a ``Group`` will affect all its children: 12 | /// ``` 13 | /// // Both particles are twice as large 14 | /// Group { 15 | /// Particle { Text("☁️") } 16 | /// Particle { Text("☀️") } 17 | /// } 18 | /// .scale(2.0) 19 | /// ``` 20 | public struct Group: Entity { 21 | 22 | // MARK: - Properties 23 | 24 | public var body: EmptyEntity { .init() } 25 | 26 | internal var values: [AnyEntity] 27 | 28 | internal private(set) var merges: Merges? 29 | 30 | // MARK: - Initalizers 31 | 32 | /// Creates a group of entities. 33 | /// Modifiers applied to the group will be applied to each of the entities passed in the initializer. 34 | /// - Parameter entities: The entities to include in the group. 35 | public init(@EntityBuilder entities: () -> E) where E: Entity { 36 | if let e = entities() as? Group { 37 | self = e 38 | } else { 39 | self.values = [.init(body: entities())] 40 | } 41 | } 42 | 43 | internal init(values: [AnyEntity], merges: Merges? = nil) { 44 | self.values = values 45 | self.merges = merges 46 | } 47 | 48 | // MARK: - Subtypes 49 | 50 | /// Controls what types of data are merged when a group is instantiated. 51 | /// When data is *merged*, the first of that data element is copied on the proxy level. 52 | /// 53 | /// For instance, consider this ``ForEach`` example: 54 | /// ``` 55 | /// ParticleSystem { 56 | /// ForEach([5, 10, 15], merges: nil) { x in 57 | /// Particle { 58 | /// Circle() 59 | /// .foregroundColor(x % 2 == 0 ? .red : .yellow) // A 60 | /// .frame(width: x, height: x) // B 61 | /// } 62 | /// } 63 | /// .initialPosition(.center) 64 | /// .initialOffset(xIn: -50.0 ... 50.0) 65 | /// .initialVelocity(y: 0.1 * x) // C 66 | /// } 67 | /// } 68 | /// ``` 69 | /// Here, three circle particles are created. We can see that `A` alternates the color, `B` sets a size, and `C` endows a different y-speed for each circle. 70 | /// Since `merges: nil` is passed, we expect default behavior. Indeed, we see 3 particles of varying sizes and speeds with alternating colors. 71 | /// 72 | /// However, this comes at a cost: If ``ParticleSystem/debug()`` is enabled, you can see that 3 views and 3 entities are registered. 73 | /// 74 | /// It only makes sense to use `merges: .entities` 75 | /// 76 | /// - Use `merges: .views` to only register the **first view** encountered. 77 | /// - Likewise, use `merges: .entities` to only register the **first entity** behavior encountered. Then, the system will iterate over the data array and create different initial ``Proxy`` and ``Proxy`` "clones". 78 | /// - Note that ``entities`` automatically also merges ``views``. 79 | /// 80 | /// Use of ``Merges`` is recommended, as it always speeds up rendering time. 81 | public enum Merges { 82 | case views 83 | case entities 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Particles/API/Entities/Lattice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Lattice.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/21/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | import CoreGraphics 11 | 12 | /// A lattice entity group that 'imitates' a view by creating a grid of colored particles. 13 | /// 14 | /// You can customize the behavior of each particle to create neat effects on your views: 15 | /// ``` 16 | /// Lattice(spacing: 3) { 17 | /// Text("Hello, World!").font(.title).fontWeight(.bold) 18 | /// } withBehavior: { p in 19 | /// p 20 | /// .initialVelocity(xIn: -0.05 ... 0.05, yIn: -0.05 ... 0.05) 21 | /// .lifetime(4) 22 | /// .initialAcceleration(y: 0.0002) 23 | /// } customView: { 24 | /// Circle().frame(width: 3.0, height: 3.0) 25 | /// } 26 | /// ``` 27 | @available(watchOS, unavailable) 28 | public struct Lattice: Entity, Transparent { 29 | 30 | // MARK: - Properties 31 | 32 | private var mode: Mode = .cover 33 | private var customView: AnyView = AnyView(Circle().frame(width: 2.0, height: 2.0)) 34 | private var spawns: [(CGPoint, Color)] 35 | private var viewSize: CGSize 36 | private var anchor: UnitPoint 37 | 38 | // MARK: - Initalizers 39 | 40 | /// Creates a new Lattice particle group, which creates a grid of colored particles atop the opaque pixels of a view. 41 | /// - Parameter edges: The edges to spawn particles on. Pass `[]` (default) to cover the view in particles. 42 | /// - Parameter spacing: Distance between each particle in the lattice. 43 | /// - Parameter anchor: Whether to spawn the lattice of particles relative to the view. 44 | /// - Parameter view: The view that is used as a source layer to choose where to spawn various colored particles. 45 | public init( 46 | hugging edges: [Edge] = [], 47 | spacing: CGFloat = 3.0, 48 | anchor: UnitPoint = .center, 49 | @ViewBuilder view: () -> Base 50 | ) where Base: View { 51 | if edges.isEmpty { 52 | self.init(mode: .cover, spacing: spacing, anchor: anchor, view: view) 53 | } else { 54 | self.init(mode: .hug(edges), spacing: spacing, anchor: anchor, view: view) 55 | } 56 | } 57 | 58 | /// Creates a new Lattice particle group, which creates a grid of colored particles atop the opaque pixels of a view. 59 | /// - Parameter edge: The edge to spawn particles on. 60 | /// - Parameter spacing: Distance between each particle in the lattice. 61 | /// - Parameter anchor: Whether to spawn the lattice of particles relative to the view. 62 | /// - Parameter view: The view that is used as a source layer to choose where to spawn various colored particles. 63 | public init( 64 | hugging edge: Edge, 65 | spacing: CGFloat = 3.0, 66 | anchor: UnitPoint = .center, 67 | @ViewBuilder view: () -> Base 68 | ) where Base: View { 69 | self.init(hugging: [edge], spacing: spacing, anchor: anchor, view: view) 70 | } 71 | 72 | @available(watchOS, unavailable) 73 | private init( 74 | mode: Mode = .cover, 75 | spacing: CGFloat = 3.0, 76 | anchor: UnitPoint = .center, 77 | @ViewBuilder view: () -> Base 78 | ) where Base: View { 79 | 80 | #if os(iOS) 81 | let s = 0.66 82 | #else 83 | let s = 1.0 84 | #endif 85 | 86 | guard let viewImage = view().scaleEffect(s).background(Color.black).asImage()?.cgImage, let imgData = viewImage.dataProvider?.data else { 87 | fatalError("Particles could not convert view to image correctly. (Burst)") 88 | } 89 | 90 | viewSize = .init(width: viewImage.width / 2, height: viewImage.height / 2) 91 | 92 | var pixelColorCache: [String: Color] = [:] 93 | func getPixelColorAt(x: Int, y: Int, useCache: Bool = false) -> Color? { 94 | if useCache, let color = pixelColorCache["\(x)_\(y)"] { return color } 95 | let data: UnsafePointer = CFDataGetBytePtr(imgData) 96 | let bpr: Int = viewImage.bytesPerRow 97 | let pixelInfo: Int = (bpr * y*2) + 4 * x*2 98 | let r = CGFloat(data[pixelInfo]) / CGFloat(255.0) 99 | let g = CGFloat(data[pixelInfo + 1]) / CGFloat(255.0) 100 | let b = CGFloat(data[pixelInfo + 2]) / CGFloat(255.0) 101 | let a = CGFloat(data[pixelInfo + 3]) / CGFloat(255.0) 102 | let color = Color(red: Double(r), green: Double(g), blue: Double(b), opacity: Double(a)) 103 | if a == 0 || r + g + b < 0.1 { return nil } 104 | if useCache { pixelColorCache["\(x)_\(y)"] = color } 105 | return color 106 | } 107 | 108 | self.spawns = [] 109 | self.anchor = anchor 110 | self.customView = AnyView(Circle().frame(width: 2, height: 2)) 111 | 112 | var newSpawns: [(CGPoint, Color)] = [] 113 | 114 | switch mode { 115 | case .cover: 116 | for x in stride(from: 0.0, to: CGFloat(viewImage.width) / 2.0, by: spacing) { 117 | for y in stride(from: 0.0, to: CGFloat(viewImage.height) / 2.0, by: spacing) { 118 | if let color = getPixelColorAt(x: Int(x), y: Int(y)) { 119 | newSpawns.append((CGPoint(x: x, y: y), color)) 120 | } 121 | } 122 | } 123 | case .hug(let array): 124 | let flat: [(Edge, CGPoint)] = array.flatMap({ edge in edge.points(size: viewSize, spacing: spacing).map({ point in (edge, point) }) }) 125 | for (edge, point) in flat { 126 | var proxy: CGPoint = point 127 | while proxy.x >= 0 && proxy.x <= viewSize.width && proxy.y >= 0 && proxy.y <= viewSize.height { 128 | if let color = getPixelColorAt(x: Int(proxy.x), y: Int(proxy.y), useCache: true) { 129 | newSpawns.append((CGPoint(x: proxy.x, y: proxy.y), color)) 130 | break 131 | } else { 132 | switch edge { 133 | case .top: 134 | proxy.y += spacing 135 | case .leading: 136 | proxy.x += spacing 137 | case .bottom: 138 | proxy.y -= spacing 139 | case .trailing: 140 | proxy.x -= spacing 141 | } 142 | } 143 | } 144 | } 145 | } 146 | 147 | self.spawns = newSpawns 148 | } 149 | 150 | // MARK: - Body Entity 151 | 152 | public var body: some Entity { 153 | ForEach(spawns, merges: .views) { spawn in 154 | Particle(anyView: customView) 155 | .initialOffset(x: spawn.0.x - viewSize.width * anchor.x, y: spawn.0.y - viewSize.height * anchor.y) 156 | .colorOverlay(spawn.1) 157 | } 158 | } 159 | 160 | // MARK: - Modifiers 161 | 162 | public func customView(@ViewBuilder view: () -> V) -> Lattice where V: View { 163 | var copy = self 164 | copy.customView = .init(view()) 165 | return copy 166 | } 167 | 168 | public func hugs(_ edges: Edge...) -> Lattice { 169 | var copy = self 170 | copy.mode = .hug(edges) 171 | return copy 172 | } 173 | 174 | public func hugs() -> Lattice { 175 | var copy = self 176 | copy.mode = .hug(.all) 177 | return copy 178 | } 179 | 180 | // MARK: - Subtypes 181 | 182 | /// How ``Lattice`` choose to generate particles. 183 | private enum Mode { 184 | /// Cover the entire view with particles. 185 | case cover 186 | /// Hug the outside edges of the view with particles. 187 | case hug([Edge]) 188 | } 189 | 190 | /// A edge against which Lattice choose to generate particles. 191 | public enum Edge { 192 | /// The top edge of a view. 193 | case top 194 | /// The leading edge of a view. 195 | case leading 196 | /// The bottom edge of a view. 197 | case bottom 198 | /// The trailing edge of a view. 199 | case trailing 200 | 201 | fileprivate func points(size: CGSize, spacing: CGFloat) -> [CGPoint] { 202 | switch self { 203 | case .top: 204 | return stride(from: 0, to: size.width, by: spacing).map({ .init(x: $0, y: 0.0)}) 205 | case .leading: 206 | return stride(from: 0, to: size.height, by: spacing).map({ .init(x: 0.0, y: $0)}) 207 | case .bottom: 208 | return stride(from: 0, to: size.width, by: spacing).map({ .init(x: $0, y: size.height)}) 209 | case .trailing: 210 | return stride(from: 0, to: size.height, by: spacing).map({ .init(x: size.width, y: $0)}) 211 | } 212 | } 213 | } 214 | } 215 | 216 | @available(watchOS, unavailable) 217 | public extension Array where Element == Lattice.Edge { 218 | 219 | /// Hugs every edge. 220 | static var all : Self { 221 | [.top, .leading, .bottom, .trailing] 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /Sources/Particles/API/Entities/Particle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Particle.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A basic ``Entity`` that renders a provided `View`. 11 | /// To create a particle inside of a ``ParticleSystem``, use Particles' declarative syntax: 12 | /// ``` 13 | /// ParticleSystem { 14 | /// Particle { 15 | /// Text("Any view can be a particle") 16 | /// } 17 | /// .initialPosition(.center) 18 | /// } 19 | /// ``` 20 | public struct Particle: Entity { 21 | 22 | // MARK: - Properties 23 | 24 | public var body = EmptyEntity() 25 | 26 | internal var view: AnyView 27 | 28 | // MARK: - Initalizers 29 | 30 | /// Create a particle with the appearance of a passed view. 31 | /// - Parameter view: The view to create the particle with. 32 | public init(@ViewBuilder view: () -> V) where V: View { 33 | self.view = .init(view()) 34 | } 35 | 36 | internal init(anyView: AnyView) { 37 | self.view = anyView 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Particles/API/Entity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | // MARK: - Protocol Definition 12 | 13 | /// A protocol used to define the behavior of objects within a ``ParticleSystem``. 14 | /// Like views in SwiftUI, new entity types can be defined using ``Entity/body`` conformance: 15 | /// ``` 16 | /// struct MyCustomParticle: Entity { 17 | /// var body: some Entity { 18 | /// Particle { Text("☀️") } 19 | /// .hueRotation(.degrees(45.0)) 20 | /// .scale { _ in .random(in: 1.0 ... 3.0) } 21 | /// } 22 | /// } 23 | /// ``` 24 | public protocol Entity { 25 | 26 | associatedtype Body: Entity 27 | 28 | /// The inner content of the entity. 29 | /// Inner content often holds additional modifiers to apply to ``Proxy`` or ``Proxy`` instances upon spawn. 30 | var body: Self.Body { get } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Particles/API/EntityBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntityBuilder.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A result builder used to build entities. 11 | /// If multiple entities are passed, a ``Group`` is returned. 12 | @resultBuilder 13 | public struct EntityBuilder { 14 | 15 | public static func buildExpression(_ content: E) -> E where E: Entity { 16 | content 17 | } 18 | 19 | public static func buildBlock(_ content: E) -> E where E: Entity { 20 | content 21 | } 22 | 23 | public static func buildBlock(_ c1: E1, _ c2: E2) -> Group where E1: Entity, E2: Entity { 24 | Group(values: [.init(body: c1), .init(body: c2)]) 25 | } 26 | 27 | public static func buildBlock(_ c1: E1, _ c2: E2, _ c3: E3) -> Group where E1: Entity, E2: Entity, E3: Entity { 28 | Group(values: [.init(body: c1), .init(body: c2), .init(body: c3)]) 29 | } 30 | 31 | public static func buildBlock( 32 | _ c1: E1, _ c2: E2, _ c3: E3, _ c4: E4 33 | ) -> some Entity where E1: Entity, E2: Entity, E3: Entity, E4: Entity { 34 | Group(values: [.init(body: c1), .init(body: c2), .init(body: c3), .init(body: c4)]) 35 | } 36 | 37 | public static func buildIf(_ content: T?) -> some Entity where T: Entity { 38 | Group { 39 | if let content { 40 | content 41 | } else { 42 | EmptyEntity() 43 | } 44 | } 45 | } 46 | 47 | public static func buildEither(first: T) -> Group where T: Entity { 48 | return .init(values: [.init(body: first), .init(body: EmptyEntity())]) 49 | } 50 | 51 | public static func buildEither(second: F) -> Group where F: Entity { 52 | return .init(values: [.init(body: EmptyEntity()), .init(body: second)]) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Particles/API/Extensions/Angle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Angle.swift 3 | // 4 | // 5 | // Created by Ben Myers on 10/3/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Angle { 11 | 12 | /// Upwards. 13 | static let up: Angle = Angle(degrees: 270.0) 14 | /// Downwards. 15 | static let down: Angle = Angle(degrees: 90.0) 16 | /// Leftwards. 17 | static let left: Angle = Angle(degrees: 180.0) 18 | /// Rightwards. 19 | static let right: Angle = Angle.zero 20 | 21 | /// Whether the angle is zero. 22 | var isZero: Bool { 23 | return degrees == 0.0 24 | } 25 | 26 | static func += (left: inout Angle, right: Angle) { 27 | left = Angle(degrees: left.degrees + right.degrees) 28 | } 29 | 30 | static func -= (left: inout Angle, right: Angle) { 31 | left = Angle(degrees: left.degrees - right.degrees) 32 | } 33 | 34 | /// Returns a random angle. 35 | /// - Returns: A randomly generated angle. 36 | static func random() -> Angle { 37 | return Angle(degrees: .random(in: 0.0 ... 360.0)) 38 | } 39 | 40 | /// Returns a random angle within the specified range of degrees. 41 | /// - Parameter range: The range of degrees within which the angle should be generated. 42 | /// - Returns: A randomly generated angle within the specified range. 43 | static func random(degreesIn range: ClosedRange) -> Angle { 44 | return Angle(degrees: .random(in: range)) 45 | } 46 | } 47 | 48 | /// Calculates the cosine of an angle. 49 | /// 50 | /// - Parameter angle: The angle in radians. 51 | /// - Returns: The cosine of the angle. 52 | public func cos(_ angle: Angle) -> Double { 53 | return cos(angle.radians) 54 | } 55 | 56 | /// Calculates the sine of an angle. 57 | /// 58 | /// - Parameter angle: The angle in radians. 59 | /// - Returns: The sine of the angle. 60 | public func sin(_ angle: Angle) -> Double { 61 | return sin(angle.radians) 62 | } 63 | 64 | -------------------------------------------------------------------------------- /Sources/Particles/API/Extensions/CGVector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGVector.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | public extension CGVector { 12 | 13 | init(angle: Angle, magnitude: CGFloat) { 14 | self.init(dx: cos(angle) * magnitude, dy: sin(angle) * magnitude) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Particles/API/Extensions/ClosedRange+Random.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosedRange.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/14/24. 6 | // 7 | 8 | import Foundation 9 | import CoreGraphics 10 | 11 | infix operator +/- : RangeFormationPrecedence 12 | 13 | public extension Double { 14 | 15 | /// Gets a random value. 16 | /// - Parameter range: The range of possible values. 17 | /// - Parameter seed: An integer seed use to generate a number chosen from the specified range. 18 | static func random(in range: ClosedRange, seed: Int) -> Double { 19 | srand48(seed) 20 | return range.lowerBound + (range.upperBound - range.lowerBound) * drand48() 21 | } 22 | 23 | /// Gets a random value. 24 | /// - Parameter range: The range of possible values. 25 | /// - Parameter seed: A double seed use to generate a number chosen from the specified range. Deterministic modulo `1.0`. 26 | static func random(in range: ClosedRange, seed: Double) -> Double { 27 | let seed: Double = seed.truncatingRemainder(dividingBy: 1.0) 28 | return random(in: range, seed: Int(seed * Double(Int.max))) 29 | } 30 | 31 | static func +/- (lhs: Double, rhs: Double) -> ClosedRange { 32 | return lhs - rhs ... lhs + rhs 33 | } 34 | } 35 | 36 | public extension Int { 37 | 38 | /// Gets a random value. 39 | /// - Parameter range: The range of possible values. 40 | /// - Parameter seed: An integer seed use to generate a number chosen from the specified range. 41 | static func random(in range: ClosedRange, seed: Int) -> Int { 42 | srand48(seed) 43 | return Int(Double(range.lowerBound) + Double(range.upperBound - range.lowerBound) * drand48()) 44 | } 45 | 46 | /// Gets a random value. 47 | /// - Parameter range: The range of possible values. 48 | /// - Parameter seed: A double seed use to generate a number chosen from the specified range. Deterministic modulo `1.0`. 49 | static func random(in range: ClosedRange, seed: Double) -> Int { 50 | random(in: range, seed: Int(seed * Double(Int.max))) 51 | } 52 | 53 | static func +/- (lhs: Int, rhs: Int) -> ClosedRange { 54 | return lhs - rhs ... lhs + rhs 55 | } 56 | } 57 | 58 | public extension CGFloat { 59 | 60 | /// Gets a random value. 61 | /// - Parameter range: The range of possible values. 62 | /// - Parameter seed: An integer seed use to generate a number chosen from the specified range. 63 | static func random(in range: ClosedRange, seed: Int) -> CGFloat { 64 | srand48(seed) 65 | return CGFloat(CGFloat(range.lowerBound) + CGFloat(range.upperBound - range.lowerBound) * drand48()) 66 | } 67 | 68 | /// Gets a random value. 69 | /// - Parameter range: The range of possible values. 70 | /// - Parameter seed: A double seed use to generate a number chosen from the specified range. Deterministic modulo `1.0`. 71 | static func random(in range: ClosedRange, seed: Double) -> CGFloat { 72 | random(in: range, seed: Int(seed * Double(Int.max))) 73 | } 74 | 75 | static func +/- (lhs: CGFloat, rhs: CGFloat) -> ClosedRange { 76 | return lhs - rhs ... lhs + rhs 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/Particles/API/Extensions/Color+RGBA.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/27/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if canImport(UIKit) 11 | import UIKit 12 | #elseif canImport(AppKit) 13 | import AppKit 14 | #endif 15 | 16 | public extension Color { 17 | 18 | /// The red component of this `Color`, `0.0`-`1.0`. (*via* `import Particles`) 19 | var red: CGFloat { 20 | components.red 21 | } 22 | 23 | /// The green component of this `Color`, `0.0`-`1.0`. (*via* `import Particles`) 24 | var green: CGFloat { 25 | components.green 26 | } 27 | 28 | /// The blue component of this `Color`, `0.0`-`1.0`. (*via* `import Particles`) 29 | var blue: CGFloat { 30 | components.blue 31 | } 32 | 33 | /// The alpha component of this `Color`, `0.0`-`1.0`. (*via* `import Particles`) 34 | var alpha: CGFloat { 35 | components.opacity 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Particles/API/Extensions/View/View+Lattice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Lattice.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/22/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(watchOS, unavailable) 11 | public extension View { 12 | 13 | /// Dissolves the view into several tiny particles when `condition` is set to `true`. 14 | /// - parameter condition: The condition to check against. If `true`, the view will dissolve into particles. 15 | func dissolve(if condition: Bool) -> some View { 16 | self.opacity(condition ? 0 : 1.0).boundlessOverlay(atop: true) { 17 | ZStack { 18 | if condition { 19 | ParticleSystem { 20 | Lattice(view: { self }) 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | /// Bursts the view into several tiny particles when `condition` is set to `true`. 28 | /// - parameter condition: The condition to check against. If `true`, the view will dissolve into particles. 29 | func burst(if condition: Bool) -> some View { 30 | self 31 | .opacity(condition ? 0.0 : 1.0) 32 | .boundlessOverlay(atop: true) { 33 | ZStack { 34 | ParticleSystem { 35 | Lattice(view: { self }) 36 | .fixVelocity { c in 37 | if condition { 38 | return CGVector(angle: Angle.degrees(Double.random(in: 0.0 ... 360.0, seed: c.proxy.seed.0)), magnitude: .random(in: 0.2 ... 0.5)) 39 | } 40 | return .zero 41 | } 42 | .initialPosition(.center).initialVelocity(xIn: -0.01 ... 0.01, yIn: -0.01 ... 0.01) 43 | .transition(.twinkle) 44 | .lifetime(3) 45 | } 46 | } 47 | .opacity(condition ? 1 : 0) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Particles/API/Extensions/View/View+Particles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Particles.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/22/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension View { 11 | 12 | /// Applies a particle system to this view with its center at the center of the view. 13 | /// - Parameter atop: Whether particles are laid atop the view. Pass `false` to lay particles under the view. 14 | /// - Parameter entities: The entities to spawn in the particle system. 15 | func particleSystem( 16 | atop: Bool = true, 17 | offset: CGPoint = .zero, 18 | @EntityBuilder entities: () -> E 19 | ) -> some View where E: Entity { 20 | self.boundlessOverlay( 21 | atop: atop, 22 | offset: offset) 23 | { 24 | ParticleSystem(entity: entities) 25 | } 26 | } 27 | 28 | /// Applies a particle emitter to this view. 29 | /// - Parameter interval: How often to emit the passed entities. 30 | /// - Parameter condition: Used to conditionally emit entities. 31 | /// - Parameter atop: Whether particles are laid atop the view. Pass `false` to lay particles under the view. 32 | /// - Parameter offset: The offset size of the emitter. 33 | /// - Parameter simultaneously: Whether to spawn passed entities simultaneously. If not, they are spawned sequentially in a cycle. 34 | /// - Parameter entities: The entities to spawn. 35 | func emits( 36 | every interval: TimeInterval = 1.0, 37 | if condition: Bool = true, 38 | atop: Bool = true, 39 | offset: CGPoint = .zero, 40 | simultaneously: Bool = false, 41 | @EntityBuilder entities: () -> E 42 | ) -> some View where E: Entity { 43 | if simultaneously { 44 | return self.boundlessOverlay(atop: atop, offset: offset) { 45 | ParticleSystem { 46 | if condition { 47 | Emitter(every: interval, emits: entities) 48 | .emitAll() 49 | .initialPosition(.center) 50 | } 51 | } 52 | } 53 | } else { 54 | return self.boundlessOverlay(atop: atop, offset: offset) { 55 | ParticleSystem { 56 | if condition { 57 | Emitter(every: interval, emits: entities) 58 | .emitSingle() 59 | .initialPosition(.center) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+Behavior.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+Behavior.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/22/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Entity { 11 | 12 | /// Adds entity behavior upon birth. 13 | /// - Parameter closure: A closure describing what happens to the entity's physics proxy. The closure parameter can be directly modified to change an entity's properties. 14 | /// - Returns: The modified entity. 15 | func onAppear(perform closure: @escaping (inout Proxy) -> Void) -> some Entity { 16 | onAppear { p, _ in closure(&p) } 17 | } 18 | 19 | /// Adds entity behavior upon birth. 20 | /// - Parameter closure: A closure describing what happens to the entity's physics and rendering proxies. The first two closure parameters can be directly modified to change an entity's properties. 21 | /// - Returns: The modified entity. 22 | func onAppear(perform closure: @escaping (inout Proxy, ParticleSystem.Data) -> Void) -> some Entity { 23 | return ModifiedEntity(entity: self, onBirth: { c in 24 | var proxy = c.proxy 25 | closure(&proxy, c.system) 26 | return proxy 27 | }, onUpdate: nil) 28 | } 29 | 30 | /// Adds entity behavior upon birth. 31 | /// - Parameter closure: A closure describing what happens to the entity's physics proxy. The closure parameter can be directly modified to change an entity's properties. 32 | /// - Returns: The modified entity. 33 | func onUpdate(perform closure: @escaping (inout Proxy) -> Void) -> some Entity { 34 | onUpdate { p, _ in closure(&p) } 35 | } 36 | 37 | /// Adds entity behavior upon birth. 38 | /// - Parameter closure: A closure describing what happens to the entity's physics and rendering proxies. The first two closure parameters can be directly modified to change an entity's properties. 39 | /// - Returns: The modified entity. 40 | func onUpdate(perform closure: @escaping (inout Proxy, ParticleSystem.Data) -> Void) -> some Entity { 41 | return ModifiedEntity(entity: self, onUpdate: { c in 42 | var proxy = c.proxy 43 | closure(&proxy, c.system) 44 | return proxy 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+ColorOverlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+ColorOverlay.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/16/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | public extension Entity { 12 | 13 | /// Applies a color overlay to this entity. 14 | /// - Parameter color: The color to overlay on to the entity. 15 | func colorOverlay(_ color: Color) -> some Entity { 16 | var m = ModifiedEntity(entity: self) 17 | m.preferences.append(.custom({ c in .colorOverlay(color: color) })) 18 | return m 19 | } 20 | 21 | /// Applies a color overlay to this entity randomly. 22 | /// - Parameter fromColor: Sets the color overlay of the entity to a color chosen randomly between `fromColor` and `toColor`. 23 | /// - Parameter toColor: Sets the color overlay of the entity to a color chosen randomly between `fromColor` and `toColor`. 24 | /// - Returns: The modified entity. 25 | func colorOverlay(from fromColor: Color, to toColor: Color) -> some Entity { 26 | var m = ModifiedEntity(entity: self) 27 | m.preferences.append(.custom({ c in .colorOverlay(color: Color.lerp(a: fromColor, b: toColor, t: .random(in: 0.0 ... 1.0))) })) 28 | return m 29 | } 30 | 31 | /// Applies a color overlay to this entity using the provided closure on update. 32 | /// - Parameter withColor: A closure that produces the color overlay to use on update. 33 | /// - Returns: The modified entity. 34 | func colorOverlay( 35 | with withColor: @escaping (Proxy.Context) -> Color = { _ in .white } 36 | ) -> some Entity { 37 | var m = ModifiedEntity(entity: self) 38 | m.preferences.append(.custom({ c in .colorOverlay(color: withColor(c)) })) 39 | return m 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+Delay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+Delay.swift 3 | // 4 | // 5 | // Created by Ben Myers on 4/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Entity { 11 | 12 | /// Applies a delay to this entity. This causes it to wait for a specified amount of time until birth. 13 | /// - Parameter duration: The duration, in seconds, of the delay. 14 | /// - Returns: The modified entity. 15 | func delay(_ duration: TimeInterval) -> some Entity { 16 | var m = ModifiedEntity(entity: self) 17 | m.preferences.append(.custom({ c in .delay(duration: duration) })) 18 | return m 19 | } 20 | 21 | /// Applies a delay to this entity randomly. This causes it to wait for a specified amount of time until birth. 22 | /// - Parameter range: The range of durations, in seconds, of the possible delay value. 23 | /// - Returns: The modified entity. 24 | func delay(in range: ClosedRange) -> some Entity { 25 | var m = ModifiedEntity(entity: self) 26 | m.preferences.append(.custom({ c in .delay(duration: .random(in: range)) })) 27 | return m 28 | } 29 | 30 | /// Applies a delay to this entity using the provided closure before birth. This causes it to wait for a specified amount of time. 31 | /// - Parameter withDelay: A closure that produces a value to use as the delay interval for this proxy. 32 | /// - Returns: The modified entity. 33 | func delay(with withDelay: @escaping (Proxy.Context) -> TimeInterval) -> some Entity { 34 | var m = ModifiedEntity(entity: self) 35 | m.preferences.append(.custom({ c in .delay(duration: withDelay(c)) })) 36 | return m 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+Glow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+Glow.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | public extension Entity { 12 | 13 | /// Applies a glow to this entity. 14 | /// - Parameter color: The color the entity glows. Pass `nil` to glow the same color of the entity. 15 | /// - Parameter radius: The radius of the glow effect. Default `15.0`. 16 | func glow(_ color: Color? = nil, radius: CGFloat = 15.0) -> some Entity { 17 | var m = ModifiedEntity(entity: self) 18 | m.preferences.append(.custom({ c in .glow(color: color, radius: radius) })) 19 | return m 20 | } 21 | 22 | /// Applies a glow to this entity randomly. 23 | /// - Parameter fromColor: Sets the glow of the entity to a color randomly between `fromColor` and `toColor`. 24 | /// - Parameter toColor: Sets the glow of the entity to a color randomly between `fromColor` and `toColor`. 25 | /// - Parameter radiusIn: A range to randomly choose a radius for the glow effect. 26 | func glow(from fromColor: Color, to toColor: Color, radiusIn: ClosedRange) -> some Entity { 27 | var m = ModifiedEntity(entity: self) 28 | m.preferences.append( 29 | .custom( 30 | { c in 31 | .glow( 32 | color: Color.lerp(a: fromColor, b: toColor, t: .random(in: 0.0 ... 1.0)), 33 | radius: .random(in: radiusIn) 34 | ) 35 | } 36 | ) 37 | ) 38 | return m 39 | } 40 | 41 | /// Applies a glow to this entity using the provided closure on update. 42 | /// - Parameter withColor: A closure that produces a glow color to use on update. 43 | /// - Returns: A closure that produces a radius to use on update. 44 | func glow( 45 | withColor: @escaping (Proxy.Context) -> Color? = { _ in nil }, 46 | withRadius: @escaping (Proxy.Context) -> CGFloat = { _ in 15.0 } 47 | ) -> some Entity { 48 | var m = ModifiedEntity(entity: self) 49 | m.preferences.append(.custom({ c in .glow(color: withColor(c), radius: withRadius(c)) })) 50 | return m 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+Physics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+Physics.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | public extension Entity { 12 | 13 | /// Sets the initial position of the entity. 14 | /// - Parameters: 15 | /// - x: The x-position, in pixels, to set the entity upon creation. Set to `nil` for no behavior. 16 | /// - y: The y-position, in pixels, to set the entity upon creation. Set to `nil` for no behavior. 17 | /// - Returns: The modified entity. 18 | func initialPosition(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 19 | ModifiedEntity(entity: self, onBirth: { context in 20 | var p = context.proxy 21 | if let x = x { 22 | p.position.x = x 23 | } 24 | if let y = y { 25 | p.position.y = y 26 | } 27 | return p 28 | }) 29 | } 30 | 31 | /// Sets the initial position of the entity using the provided closure. 32 | /// - Parameters: 33 | /// - withPoint: A closure returning the 2D point, in pixels, to set the entity upon creation. 34 | /// - y: A closure returning the y-position, in pixels, to set the entity upon creation. 35 | /// - Returns: The modified entity. 36 | func initialPosition(with withPoint: @escaping (Proxy.Context) -> CGPoint) -> some Entity { 37 | ModifiedEntity(entity: self, onBirth: { context in 38 | var p = context.proxy 39 | let point = withPoint(context) 40 | p.position.x = point.x 41 | p.position.y = point.y 42 | return p 43 | }) 44 | } 45 | 46 | /// Sets the initial position of the entity randomly. 47 | /// - Parameters: 48 | /// - xIn: The x-range, in pixels, to set the entity's x-position to randomly in upon creation. 49 | /// - yIn: The y-position, in pixels, to set the entity upon creation. Set to `nil` for no behavior. 50 | /// - Returns: The modified entity. 51 | func initialPosition(xIn: ClosedRange, yIn: ClosedRange) -> some Entity { 52 | initialPosition { _ in 53 | return CGPoint(x: CGFloat.random(in: xIn), y: CGFloat.random(in: yIn)) 54 | } 55 | } 56 | 57 | /// Sets the initial position of the entity relative to the size of its parent ``ParticleSystem``. 58 | /// - Parameters: 59 | /// - point: The relative location to position the entity at birth. 60 | /// - Returns: The modified entity. 61 | func initialPosition(_ point: UnitPoint) -> some Entity { 62 | ModifiedEntity(entity: self, onBirth: { context in 63 | var p = context.proxy 64 | if let w = context.system?.size.width { 65 | p.position.x = w * point.x 66 | } 67 | if let h = context.system?.size.height { 68 | p.position.y = h * point.y 69 | } 70 | return p 71 | }) 72 | } 73 | 74 | /// Offsets the initial position of the entity by the specified amounts. 75 | /// - Parameters: 76 | /// - x: The x-offset, in pixels, to apply to the initial position. Set to `nil` for no behavior. 77 | /// - y: The y-offset, in pixels, to apply to the initial position. Set to `nil` for no behavior. 78 | /// - Returns: The modified entity. 79 | func initialOffset(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 80 | ModifiedEntity(entity: self, onBirth: { context in 81 | var p = context.proxy 82 | if let x = x { 83 | p.position.x += x 84 | } 85 | if let y = y { 86 | p.position.y += y 87 | } 88 | return p 89 | }) 90 | } 91 | 92 | /// Offsets the initial position of the entity by the amounts returned by the provided closures. 93 | /// - Parameters: 94 | /// - withX: A closure returning the x-offset, in pixels, to apply to the initial position. 95 | /// - withY: A closure returning the y-offset, in pixels, to apply to the initial position. 96 | /// - Returns: The modified entity. 97 | func initialOffset( 98 | withX: @escaping (Proxy.Context) -> CGFloat? = { _ in nil }, 99 | withY: @escaping (Proxy.Context) -> CGFloat? = { _ in nil } 100 | ) -> some Entity { 101 | ModifiedEntity(entity: self, onBirth: { context in 102 | var p = context.proxy 103 | if let x = withX(context) { 104 | p.position.x += x 105 | } 106 | if let y = withY(context) { 107 | p.position.y += y 108 | } 109 | return p 110 | }) 111 | } 112 | 113 | /// Offsets the initial position of the entity by the specified ranges. 114 | /// - Parameters: 115 | /// - xIn: The x-range, in pixels, to offset the initial x-position by. Set to `nil` for no behavior. 116 | /// - yIn: The y-range, in pixels, to offset the initial y-position by. Set to `nil` for no behavior. 117 | /// - Returns: The modified entity. 118 | func initialOffset(xIn: ClosedRange = .zero ... .zero, yIn: ClosedRange = .zero ... .zero) -> some Entity { 119 | initialOffset { _ in 120 | return .random(in: xIn) 121 | } withY: { _ in 122 | return .random(in: yIn) 123 | } 124 | } 125 | 126 | /// Sets the constant position of the entity. 127 | /// - Parameters: 128 | /// - x: The x-position, in pixels, to set the entity's position to. Set to `nil` for no behavior. 129 | /// - y: The y-position, in pixels, to set the entity's position to. Set to `nil` for no behavior. 130 | /// - Returns: The modified entity. 131 | func fixPosition(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 132 | ModifiedEntity(entity: self, onUpdate: { context in 133 | var p = context.proxy 134 | if let x = x { 135 | p.position.x = x 136 | } 137 | if let y = y { 138 | p.position.y = y 139 | } 140 | return p 141 | }) 142 | } 143 | 144 | /// Sets the position of the entity relative to the size of its parent ``ParticleSystem``. 145 | /// - Parameters: 146 | /// - point: The relative location to position the entity on update. 147 | /// - Returns: The modified entity. 148 | func fixPosition(_ point: UnitPoint) -> some Entity { 149 | ModifiedEntity(entity: self, onUpdate: { context in 150 | var p = context.proxy 151 | guard let size = context.system?.size else { 152 | return p 153 | } 154 | p.position.x = size.width * point.x 155 | p.position.y = size.height * point.y 156 | return p 157 | }) 158 | } 159 | 160 | /// Sets the scale of the entity. 161 | /// - Parameters: 162 | /// - scale: The scale of the entity on update. 163 | /// - Returns: The modified entity. 164 | func fixScale(_ withScale: @escaping (Proxy.Context) -> CGFloat) -> some Entity { 165 | ModifiedEntity(entity: self, onUpdate: { context in 166 | var p = context.proxy 167 | let scale = withScale(context) 168 | p.scale.width *= scale 169 | p.scale.height *= scale 170 | return p 171 | }) 172 | } 173 | 174 | 175 | /// Sets the constant position of the entity using the values returned by the provided closures. 176 | /// ⚠️ **Warning:** Be sure to specify a return type of `CGPoint` or `UnitPoint` explicitly. 177 | /// - Parameters: 178 | /// - withPoint: A closure returning the 2D point, in pixels, to set the entity's position to. 179 | /// - Returns: The modified entity. 180 | func fixPosition(with withPoint: @escaping (Proxy.Context) -> CGPoint) -> some Entity { 181 | ModifiedEntity(entity: self, onUpdate: { context in 182 | var p = context.proxy 183 | let point = withPoint(context) 184 | p.position.x = point.x 185 | p.position.y = point.y 186 | return p 187 | }) 188 | } 189 | 190 | /// Sets the initial velocity of the entity. 191 | /// - Parameters: 192 | /// - x: The x-velocity, in pixels per frame, to set the entity upon creation. Set to `nil` for no behavior. 193 | /// - y: The y-velocity, in pixels per frame, to set the entity upon creation. Set to `nil` for no behavior. 194 | /// - Returns: The modified entity. 195 | func initialVelocity(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 196 | ModifiedEntity(entity: self, onBirth: { context in 197 | var p = context.proxy 198 | if let x = x { 199 | p.velocity.dx = x 200 | } 201 | if let y = y { 202 | p.velocity.dy = y 203 | } 204 | return p 205 | }) 206 | } 207 | 208 | /// Sets the initial velocity of the entity using the values returned by the provided closures. 209 | /// - Parameters: 210 | /// - withVelocity: A closure returning the 2D velocity, in pixels per frame, to set the entity upon creation. 211 | /// - Returns: The modified entity. 212 | func initialVelocity(with withVelocity: @escaping (Proxy.Context) -> CGVector) -> some Entity { 213 | ModifiedEntity(entity: self, onBirth: { context in 214 | var p = context.proxy 215 | let v = withVelocity(context) 216 | p.velocity.dx = v.dx 217 | p.velocity.dy = v.dy 218 | return p 219 | }) 220 | } 221 | 222 | /// Sets the initial velocity of the entity randomly within the specified ranges. 223 | /// - Parameters: 224 | /// - xIn: The x-range, in pixels per frame, to set the initial x-velocity randomly within. 225 | /// - yIn: The y-range, in pixels per frame, to set the initial y-velocity randomly within. 226 | /// - Returns: The modified entity. 227 | func initialVelocity(xIn: ClosedRange, yIn: ClosedRange) -> some Entity { 228 | initialVelocity { _ in 229 | .init(dx: .random(in: xIn), dy: .random(in: yIn)) 230 | } 231 | } 232 | 233 | /// Sets the initial velocity of the entity in a random direction with a specified magnitude. 234 | /// - Parameters: 235 | /// - magnitude: The magnitude of the initial random velocity. 236 | /// - Returns: The modified entity. 237 | func initialVelocity(withMagnitude magnitude: Double) -> some Entity { 238 | initialVelocity { _ in 239 | .init(angle: .random(), magnitude: magnitude) 240 | } 241 | } 242 | 243 | /// Sets the constant velocity of the entity. 244 | /// - Parameters: 245 | /// - x: The x-velocity, in pixels per frame, to set the entity's velocity to. Set to `nil` for no behavior. 246 | /// - y: The y-velocity, in pixels per frame, to set the entity's velocity to. Set to `nil` for no behavior. 247 | /// - Returns: The modified entity. 248 | func fixVelocity(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 249 | ModifiedEntity(entity: self, onUpdate: { context in 250 | var p = context.proxy 251 | if let x = x { 252 | p.velocity.dx = x 253 | } 254 | if let y = y { 255 | p.velocity.dy = y 256 | } 257 | return p 258 | }) 259 | } 260 | 261 | /// Sets the constant velocity of the entity using the values returned by the provided closures. 262 | /// - Parameters: 263 | /// - withVelocity: A closure returning the 2D- velocity, in pixels per frame, to set the entity's velocity to. 264 | /// - Returns: The modified entity. 265 | func fixVelocity(with withVelocity: @escaping (Proxy.Context) -> CGVector) -> some Entity { 266 | ModifiedEntity(entity: self, onUpdate: { context in 267 | var p = context.proxy 268 | let v = withVelocity(context) 269 | p.velocity.dx = v.dx 270 | p.velocity.dy = v.dy 271 | return p 272 | }) 273 | } 274 | 275 | /// Sets the initial acceleration of the entity. 276 | /// - Parameters: 277 | /// - x: The x-acceleration, in pixels per second squared, to set the entity upon creation. Set to `nil` for no behavior. 278 | /// - y: The y-acceleration, in pixels per second squared, to set the entity upon creation. Set to `nil` for no behavior. 279 | /// - Returns: The modified entity. 280 | func initialAcceleration(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 281 | ModifiedEntity(entity: self, onBirth: { context in 282 | var p = context.proxy 283 | if let x = x { 284 | p.acceleration.dx = x 285 | } 286 | if let y = y { 287 | p.acceleration.dy = y 288 | } 289 | return p 290 | }) 291 | } 292 | 293 | /// Sets the initial acceleration of the entity using the values returned by the provided closures. 294 | /// - Parameters: 295 | /// - withAcceleration: A closure returning the acceleration, in pixels per second squared, to set the entity upon creation. 296 | /// - Returns: The modified entity. 297 | func initialAcceleration(with withAcceleration: @escaping (Proxy.Context) -> CGVector) -> some Entity { 298 | ModifiedEntity(entity: self, onBirth: { context in 299 | var p = context.proxy 300 | let a = withAcceleration(context) 301 | p.acceleration.dx = a.dx 302 | p.acceleration.dy = a.dy 303 | return p 304 | }) 305 | } 306 | 307 | /// Sets the initial acceleration of the entity randomly within the specified ranges. 308 | /// - Parameters: 309 | /// - xIn: The x-range, in pixels per second squared, to set the initial x-acceleration randomly within. 310 | /// - yIn: The y-range, in pixels per second squared, to set the initial y-acceleration randomly within. 311 | /// - Returns: The modified entity. 312 | func initialAcceleration(xIn: ClosedRange, yIn: ClosedRange) -> some Entity { 313 | initialAcceleration { _ in 314 | .init(dx: .random(in: xIn), dy: .random(in: yIn)) 315 | } 316 | } 317 | 318 | /// Sets the constant acceleration of the entity. 319 | /// - Parameters: 320 | /// - x: The x-acceleration, in pixels per second squared, to set the entity's acceleration to. Set to `nil` for no behavior. 321 | /// - y: The y-acceleration, in pixels per second squared, to set the entity's acceleration to. Set to `nil` for no behavior. 322 | /// - Returns: The modified entity. 323 | func fixAcceleration(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 324 | ModifiedEntity(entity: self, onUpdate: { context in 325 | var p = context.proxy 326 | if let x = x { 327 | p.acceleration.dx = x 328 | } 329 | if let y = y { 330 | p.acceleration.dy = y 331 | } 332 | return p 333 | }) 334 | } 335 | 336 | /// Sets the constant acceleration of the entity using the values returned by the provided closures. 337 | /// - Parameters: 338 | /// - withAcceleration: A closure returning `CGVector` representing the value to set the entity's acceleration to. 339 | /// - Returns: The modified entity. 340 | func fixAcceleration(with withAcceleration: @escaping (Proxy.Context) -> CGVector) -> some Entity { 341 | ModifiedEntity(entity: self, onUpdate: { context in 342 | var p = context.proxy 343 | let a = withAcceleration(context) 344 | p.acceleration.dx = a.dx 345 | p.acceleration.dy = a.dy 346 | return p 347 | }) 348 | } 349 | 350 | /// Sets the initial rotation of the entity. 351 | /// - Parameter angle: The initial rotation angle of the entity. 352 | /// - Returns: The modified entity. 353 | func initialRotation(_ angle: Angle) -> some Entity { 354 | ModifiedEntity(entity: self, onBirth: { context in 355 | var p = context.proxy 356 | p.rotation = angle 357 | return p 358 | }) 359 | } 360 | 361 | /// Sets the initial rotation of the entity using the value returned by the provided closure. 362 | /// - Parameter withAngle: A closure returning the initial rotation angle of the entity. 363 | /// - Returns: The modified entity. 364 | func initialRotation(with withAngle: @escaping (Proxy.Context) -> Angle) -> some Entity { 365 | ModifiedEntity(entity: self, onBirth: { context in 366 | var p = context.proxy 367 | p.rotation = withAngle(context) 368 | return p 369 | }) 370 | } 371 | 372 | /// Sets the initial rotation angle of the entity randomly within the specified range. 373 | /// - Parameter angleIn: The range of angles to set the initial rotation angle randomly within. 374 | /// - Returns: The modified entity. 375 | func initialRotation(angleIn: ClosedRange) -> some Entity { 376 | initialRotation { _ in 377 | .random(degreesIn: min(angleIn.lowerBound.degrees, angleIn.upperBound.degrees) ... max(angleIn.upperBound.degrees, angleIn.lowerBound.degrees)) 378 | } 379 | } 380 | 381 | /// Sets the constant rotation of the entity. 382 | /// - Parameter angle: The constant rotation angle of the entity. 383 | /// - Returns: The modified entity. 384 | func fixRotation(_ angle: Angle) -> some Entity { 385 | ModifiedEntity(entity: self, onUpdate: { context in 386 | var p = context.proxy 387 | p.rotation = angle 388 | return p 389 | }) 390 | } 391 | 392 | /// Sets the constant rotation of the entity using the value returned by the provided closure. 393 | /// - Parameter withAngle: A closure returning the constant rotation angle of the entity. 394 | /// - Returns: The modified entity. 395 | func fixRotation(with withAngle: @escaping (Proxy.Context) -> Angle) -> some Entity { 396 | ModifiedEntity(entity: self, onUpdate: { context in 397 | var p = context.proxy 398 | p.rotation = withAngle(context) 399 | return p 400 | }) 401 | } 402 | 403 | /// Sets the initial torque of the entity. 404 | /// - Parameter angle: The initial torque angle of the entity. 405 | /// - Returns: The modified entity. 406 | func initialTorque(_ angle: Angle) -> some Entity { 407 | ModifiedEntity(entity: self, onBirth: { context in 408 | var p = context.proxy 409 | p.torque = angle 410 | return p 411 | }) 412 | } 413 | 414 | /// Sets the initial torque of the entity using the value returned by the provided closure. 415 | /// - Parameter withAngle: A closure returning the initial torque angle of the entity. 416 | /// - Returns: The modified entity. 417 | func initialTorque(with withAngle: @escaping (Proxy.Context) -> Angle) -> some Entity { 418 | ModifiedEntity(entity: self, onBirth: { context in 419 | var p = context.proxy 420 | p.torque = withAngle(context) 421 | return p 422 | }) 423 | } 424 | 425 | /// Sets the initial torque angle of the entity randomly within the specified range. 426 | /// - Parameter angleIn: The range of angles to set the initial torque angle randomly within. 427 | /// - Returns: The modified entity. 428 | func initialTorque(angleIn: ClosedRange) -> some Entity { 429 | initialTorque { _ in 430 | .random(degreesIn: min(angleIn.lowerBound.degrees, angleIn.upperBound.degrees) ... max(angleIn.upperBound.degrees, angleIn.lowerBound.degrees)) 431 | } 432 | } 433 | 434 | /// Sets the constant torque of the entity. 435 | /// - Parameter angle: The constant torque angle of the entity. 436 | /// - Returns: The modified entity. 437 | func fixTorque(_ angle: Angle) -> some Entity { 438 | ModifiedEntity(entity: self, onUpdate: { context in 439 | var p = context.proxy 440 | p.torque = angle 441 | return p 442 | }) 443 | } 444 | 445 | /// Sets the constant torque of the entity using the value returned by the provided closure. 446 | /// - Parameter withAngle: A closure returning the constant torque angle of the entity. 447 | /// - Returns: The modified entity. 448 | func fixTorque(with withAngle: @escaping (Proxy.Context) -> Angle) -> some Entity { 449 | ModifiedEntity(entity: self, onUpdate: { context in 450 | var p = context.proxy 451 | p.torque = withAngle(context) 452 | return p 453 | }) 454 | } 455 | 456 | /// Sets the lifetime of the entity. 457 | /// - Parameter value: The lifetime of the entity, in seconds. 458 | /// - Returns: The modified entity. 459 | func lifetime(_ value: Double) -> some Entity { 460 | ModifiedEntity(entity: self, onBirth: { context in 461 | var p = context.proxy 462 | p.lifetime = value 463 | return p 464 | }) 465 | } 466 | 467 | /// Sets the lifetime of the entity using the value returned by the provided closure. 468 | /// - Parameter withValue: A closure returning the lifetime of the entity, in seconds. 469 | /// - Returns: The modified entity. 470 | func lifetime(with withValue: @escaping (Proxy.Context) -> Double) -> some Entity { 471 | ModifiedEntity(entity: self, onBirth: { context in 472 | var p = context.proxy 473 | p.lifetime = withValue(context) 474 | return p 475 | }) 476 | } 477 | 478 | /// Sets the lifetime of the entity randomly within the specified range. 479 | /// - Parameter in: The range of lifetimes, in seconds, to set the lifetime of the entity randomly within. 480 | /// - Returns: The modified entity. 481 | func lifetime(in range: ClosedRange) -> some Entity { 482 | lifetime { _ in 483 | .random(in: range) 484 | } 485 | } 486 | 487 | /// Sets the initial drag of the entity. It is advised to use small values less than `0.1`. 488 | /// - Parameter value: The initial drag of the entity, from `0.0` (no drag) to `1.0` (immediate stop). 489 | /// - Returns: The modified entity. 490 | func drag(_ value: Double) -> some Entity { 491 | ModifiedEntity(entity: self, onBirth: { context in 492 | var p = context.proxy 493 | p.drag = value 494 | return p 495 | }) 496 | } 497 | 498 | /// Sets the constant drag of the entity using the values returned by the provided closure. 499 | /// - Parameter withDrag: A closure returning a `Double` from `0.0` to `1.0` representing the value to set the entity's drag to. 500 | /// - Returns: The modified entity. 501 | func drag(with withDrag: @escaping (Proxy.Context) -> Double) -> some Entity { 502 | ModifiedEntity(entity: self, onUpdate: { context in 503 | var p = context.proxy 504 | p.drag = withDrag(context) 505 | return p 506 | }) 507 | } 508 | 509 | /// Sets the initial drag of the entity randomly within the specified range. 510 | /// - Parameter range: The range of possible drag values to use, from `0.0` to `1.0`. 511 | /// - Returns: The modified entity. 512 | func drag(in range: ClosedRange) -> some Entity { 513 | ModifiedEntity(entity: self, onBirth: { context in 514 | var p = context.proxy 515 | p.drag = .random(in: range) 516 | return p 517 | }) 518 | } 519 | 520 | /// Sets the initial z-index of the entity. It is advised to use small values less than `0.1`. 521 | /// - Parameter value: The initial z-index of the entity. 522 | /// - Returns: The modified entity. 523 | func zIndex(_ value: Int) -> some Entity { 524 | ModifiedEntity(entity: self, onBirth: { context in 525 | var p = context.proxy 526 | p.zIndex = value 527 | return p 528 | }) 529 | } 530 | 531 | /// Sets the constant z-index of the entity using the values returned by the provided closure. 532 | /// - Parameter withZIndex: A closure returning an `Int` representing the value to set the entity's z-index to. 533 | /// - Returns: The modified entity. 534 | func zIndex(with withZIndex: @escaping (Proxy.Context) -> Int) -> some Entity { 535 | ModifiedEntity(entity: self, onUpdate: { context in 536 | var p = context.proxy 537 | p.zIndex = withZIndex(context) 538 | return p 539 | }) 540 | } 541 | 542 | /// Sets the initial z-index of the entity randomly within the specified range. 543 | /// - Parameter range: The range of possible z-index values to use, from `0` to `Int.max`. 544 | /// - Returns: The modified entity. 545 | func zIndex(in range: ClosedRange) -> some Entity { 546 | ModifiedEntity(entity: self, onBirth: { context in 547 | var p = context.proxy 548 | p.zIndex = .random(in: range) 549 | return p 550 | }) 551 | } 552 | 553 | } 554 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+Render.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+Render.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | public extension Entity { 12 | 13 | /// Adjusts the opacity of the entity. 14 | /// - Parameter value: The opacity value to multiply the current opacity by. 15 | /// - Returns: The modified entity. 16 | func opacity(_ value: Double) -> some Entity { 17 | ModifiedEntity(entity: self, onBirth: { context in 18 | var p = context.proxy 19 | p.opacity *= value 20 | return p 21 | }) 22 | } 23 | 24 | /// Adjusts the opacity of the entity using the value returned by the provided closure. 25 | /// - Parameter withValue: A closure returning the opacity value to multiply the current opacity by. 26 | /// - Returns: The modified entity. 27 | func opacity(with withValue: @escaping (Proxy.Context) -> Double) -> some Entity { 28 | ModifiedEntity(entity: self, onUpdate: { context in 29 | var p = context.proxy 30 | p.opacity = withValue(context) 31 | return p 32 | }) 33 | } 34 | 35 | /// Adjusts the opacity of the entity randomly. 36 | /// - Parameter value: The range to randomly choose an opacity value to multiply the current opacity by. 37 | /// - Returns: The modified entity. 38 | func opacity(in range: ClosedRange) -> some Entity { 39 | ModifiedEntity(entity: self, onBirth: { context in 40 | var p = context.proxy 41 | p.opacity = .random(in: range) 42 | return p 43 | }) 44 | } 45 | 46 | /// Applies a hue rotation to the entity. 47 | /// - Parameter angle: The angle of rotation in hue space. 48 | /// - Returns: The modified entity. 49 | func hueRotation(_ angle: Angle) -> some Entity { 50 | ModifiedEntity(entity: self, onBirth: { context in 51 | var p = context.proxy 52 | p.hueRotation = angle 53 | return p 54 | }) 55 | } 56 | 57 | /// Applies a hue rotation to the entity using the value returned by the provided closure. 58 | /// - Parameter withAngle: A closure returning the angle of rotation in hue space. 59 | /// - Returns: The modified entity. 60 | func hueRotation(with withAngle: @escaping (Proxy.Context) -> Angle) -> some Entity { 61 | ModifiedEntity(entity: self, onUpdate: { context in 62 | var p = context.proxy 63 | let hr = withAngle(context) 64 | p.hueRotation = hr 65 | return p 66 | }) 67 | } 68 | 69 | /// Applies a hue rotation to the entity. 70 | /// - Parameter angle: The angle of rotation in hue space. 71 | /// - Returns: The modified entity. 72 | func hueRotation(angleIn: ClosedRange) -> some Entity { 73 | ModifiedEntity(entity: self, onBirth: { context in 74 | var p = context.proxy 75 | let hr = Angle.random(degreesIn: min(angleIn.lowerBound.degrees, angleIn.upperBound.degrees) ... max(angleIn.upperBound.degrees, angleIn.lowerBound.degrees)) 76 | p.hueRotation = hr 77 | return p 78 | }) 79 | } 80 | 81 | /// Applies a blur effect to the entity. 82 | /// - Parameter size: The size of the blur radius in pixels. 83 | /// - Returns: The modified entity. 84 | func blur(_ size: CGFloat) -> some Entity { 85 | ModifiedEntity(entity: self, onBirth: { context in 86 | var p = context.proxy 87 | p.blur = size 88 | return p 89 | }) 90 | } 91 | 92 | /// Applies a blur effect randomly to the entity. 93 | /// - Parameter size: The range of the size of the blur radius in pixels. 94 | /// - Returns: The modified entity. 95 | func blur(in range: ClosedRange) -> some Entity { 96 | ModifiedEntity(entity: self, onBirth: { context in 97 | var p = context.proxy 98 | p.blur = .random(in: range) 99 | return p 100 | }) 101 | } 102 | 103 | /// Applies a blending mode to the entity. 104 | /// - Parameter mode: The blending mode to use. 105 | /// - Returns: The modified entity. 106 | func blendMode(_ mode: GraphicsContext.BlendMode) -> some Entity { 107 | ModifiedEntity(entity: self, onBirth: { context in 108 | var p = context.proxy 109 | p.blendMode = mode 110 | return p 111 | }) 112 | } 113 | 114 | /// Scales the entity in the x and y directions by the specified sizes. 115 | /// - Parameters: 116 | /// - x: The scaling factor to apply to the x dimension. Set to `nil` for no behavior. 117 | /// - y: The scaling factor to apply to the y dimension. Set to `nil` for no behavior. 118 | /// - Returns: The modified entity. 119 | func scale(x: CGFloat? = nil, y: CGFloat? = nil) -> some Entity { 120 | ModifiedEntity(entity: self, onBirth: { context in 121 | var p = context.proxy 122 | if let x = x { 123 | p.scale.width *= x 124 | } 125 | if let y = y { 126 | p.scale.height *= y 127 | } 128 | return p 129 | }) 130 | } 131 | 132 | /// Scales the entity by the specified size in both the x and y directions. 133 | /// - Parameter size: The scaling factor to apply to both the x and y dimensions. 134 | /// - Returns: The modified entity. 135 | func scale(_ factor: CGFloat) -> some Entity { 136 | self.scale(x: factor, y: factor) 137 | } 138 | 139 | /// Scales the entity by the size returned by the provided closure in both the x and y directions. 140 | /// - Parameter withSize: A closure returning the scaling factor to apply to both the x and y dimensions. 141 | /// - Returns: The modified entity. 142 | func scale(with withFactor: @escaping (Proxy.Context) -> CGFloat) -> some Entity { 143 | ModifiedEntity(entity: self, onUpdate: { context in 144 | var p = context.proxy 145 | let s = withFactor(context) 146 | p.scale.width = s 147 | p.scale.height = s 148 | return p 149 | }) 150 | } 151 | 152 | /// Scales the entity by the specified size in both the x and y directions. 153 | /// - Parameter range: A range to randomly scale the entity by. 154 | /// - Returns: The modified entity. 155 | func scale(factorIn range: ClosedRange) -> some Entity { 156 | ModifiedEntity(entity: self, onBirth: { context in 157 | var p = context.proxy 158 | let s: CGFloat = CGFloat.random(in: range) 159 | p.scale.width *= s 160 | p.scale.height *= s 161 | return p 162 | }) 163 | } 164 | 165 | /// Scales the entity in the x and y directions by the sizes returned by the provided closures. 166 | /// - Parameter withSize: A closure returning the scaling factors to apply to the x and y dimensions. 167 | /// - Returns: The modified entity. 168 | func scale(with withSize: @escaping (Proxy.Context) -> CGSize) -> some Entity { 169 | ModifiedEntity(entity: self, onUpdate: { context in 170 | var p = context.proxy 171 | let s = withSize(context) 172 | p.scale.width = s.width 173 | p.scale.height = s.height 174 | return p 175 | }) 176 | } 177 | 178 | /// Applies a 3D rotation effect to the entity. 179 | /// - Parameter x: The x-rotation. 180 | /// - Parameter y: The y-rotation. 181 | /// - Parameter z: The z-rotation. 182 | func rotation3D(x: Angle, y: Angle, z: Angle) -> some Entity { 183 | ModifiedEntity(entity: self, onBirth: { context in 184 | var p = context.proxy 185 | p.rotation3d.x = x.radians 186 | p.rotation3d.y = y.radians 187 | p.rotation3d.z = z.radians 188 | return p 189 | }) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+Shader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+Shader.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/20/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(macOS 14.0, iOS 17.0, *) 11 | @available(watchOS, unavailable) 12 | public extension Entity { 13 | 14 | /// Applies a shader to this entity. 15 | /// - Parameter shader: The shader to apply to this entity. 16 | func shader(_ shader: Shader) -> some Entity { 17 | return ShaderEntity(entity: self) { _ in 18 | return shader 19 | } 20 | } 21 | 22 | /// Applies a shader to this entity. 23 | /// - Parameter withShader: The shader to apply to this entity using the contextual callback. 24 | func shader(with withShader: @escaping (Proxy.Context) -> Shader) -> some Entity { 25 | return ShaderEntity(entity: self, shader: withShader) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Particles/API/Modifiers/Entity+Transition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity+Transition.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Entity { 11 | 12 | /// Applies a transition to this entity. 13 | /// Transitions are applied near an entity's birth and/or death, and you customize how long their duration is. 14 | /// - Parameter transition: The transition to apply. 15 | /// - Parameter bounds: The bounds to apply the transition to. 16 | /// - Parameter duration: The duration, in seconds, of the transition. 17 | func transition(_ transition: AnyTransition, on bounds: TransitionBounds = .birthAndDeath, duration: TimeInterval = 0.5) -> some Entity { 18 | var m = ModifiedEntity(entity: self) 19 | m.preferences.append(.custom({ c in .transition(transition: transition, bounds: bounds, duration: duration) })) 20 | return m 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Particles/API/ParticleSystem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParticleSystem.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// A view used to display particles. 12 | /// A ``ParticleSystem`` can be created within views. The behavior of ``Entity`` objects, or "particles", is controlled by what is passed declaratively, like in SwiftUI: 13 | /// ``` 14 | /// var body: some View { 15 | /// ParticleSystem { 16 | /// Emitter { 17 | /// Particle { 18 | /// Circle().foregroundColor(.red).frame(width: 20.0, height: 20.0) 19 | /// } 20 | /// } 21 | /// .initialPosition(.center) 22 | /// .initialVelocity(y: 0.5) 23 | /// } 24 | /// } 25 | /// ``` 26 | public struct ParticleSystem: View { 27 | 28 | internal typealias EntityID = UInt 29 | internal typealias ProxyID = UInt 30 | internal typealias GroupID = UInt 31 | 32 | // MARK: - Static Properties 33 | 34 | internal static var data: [String: ParticleSystem.Data] = [:] 35 | 36 | // MARK: - Stored Properties 37 | 38 | internal var _data: Self.Data? 39 | private var _id: String? 40 | private var _checksTouches: Bool = true 41 | 42 | internal var data: Self.Data { 43 | if let _data { 44 | return _data 45 | } else if let _id { 46 | if let d = Self.data[_id] { 47 | return d 48 | } else { 49 | Self.data[_id] = .init() 50 | return Self.data[_id]! 51 | } 52 | } else { 53 | fatalError() 54 | } 55 | } 56 | 57 | // MARK: - Computed Properties 58 | 59 | public var body: some View { 60 | ZStack { 61 | GeometryReader { proxy in 62 | TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: false)) { [self] t in 63 | Canvas(opaque: true, colorMode: .linear, rendersAsynchronously: false, renderer: renderer) { 64 | Text("❌").tag("NOT_FOUND") 65 | SwiftUI.ForEach(Array(data.viewPairs()), id: \.1) { (pair: (AnyView, EntityID)) in 66 | pair.0.tag(pair.1) 67 | } 68 | } 69 | .border(data.debug ? Color.red.opacity(0.5) : Color.clear) 70 | .overlay { 71 | HStack { 72 | if data.debug { 73 | VStack { 74 | debugView 75 | Spacer() 76 | } 77 | Spacer() 78 | } 79 | } 80 | } 81 | } 82 | } 83 | #if os(iOS) 84 | if _checksTouches { 85 | TouchRecognizer { touch, optLocation in 86 | data.touches[touch] = optLocation 87 | print(data.touches.count) 88 | } 89 | } 90 | #endif 91 | } 92 | .onDisappear { 93 | if let _id { 94 | ParticleSystem.data.removeValue(forKey: _id) 95 | } 96 | } 97 | } 98 | 99 | private var debugView: some View { 100 | VStack(alignment: .leading, spacing: 2.0) { 101 | Text(data.performanceSummary()) 102 | .lineLimit(99) 103 | .fixedSize(horizontal: false, vertical: false) 104 | .multilineTextAlignment(.leading) 105 | } 106 | .font(.caption2) 107 | .opacity(0.5) 108 | } 109 | 110 | // MARK: - Initalizers 111 | 112 | /// Creates a particle system with the passed entity/entities. 113 | /// Any entities passed will have one copy created at the start of the system's simulation. 114 | /// - Parameter entity: The entity or entities to create when the system begins. 115 | public init(@EntityBuilder entity: () -> E) where E: Entity { 116 | let e: E = entity() 117 | self._data = .init() 118 | self._data?.initialEntity = e 119 | } 120 | 121 | // MARK: - Methods 122 | 123 | /// Enables debug mode for this particle system. 124 | /// When enabled, a border is shown around the system's edges, and statistics are displayed. 125 | /// - Returns: A modified `ParticleSystem` 126 | public func debug() -> ParticleSystem { 127 | self.data.debug = true 128 | return self 129 | } 130 | 131 | /// Marks this particle system as **state persistent**. 132 | /// State persistent particle systems to not reset their simulations when the view is re-rendered. 133 | /// - Parameter id: A `String` ID to use for the particle system. A unique ID will allow the system to persist across state updates. 134 | /// - Parameter refreshesViews: Whether view refreshes at the top level should reset and re-render all particle views. Default `false`. 135 | public func statePersistent(_ id: String, refreshesViews: Bool = false) -> ParticleSystem { 136 | var copy = self 137 | copy._id = id 138 | if !Self.data.contains(where: { $0.key == id }) { 139 | Self.data[id] = .init() 140 | } else if refreshesViews { 141 | Self.data[id]?.refreshViews = true 142 | } 143 | Self.data[id]?.initialEntity = copy._data?.initialEntity 144 | copy._data = nil 145 | return copy 146 | } 147 | 148 | /// Sets whether this particle system checks and updates ``ParticleSystem/Data/touches``. 149 | /// - Parameter flag: Whether to update `touches`. 150 | public func checksTouches(_ flag: Bool = true) -> ParticleSystem { 151 | var copy = self 152 | copy._checksTouches = flag 153 | return copy 154 | } 155 | 156 | private func renderer(_ context: inout GraphicsContext, size: CGSize) { 157 | context.stroke(.init(roundedRect: .init(x: 0.0, y: 0.0, width: size.width, height: size.height), cornerSize: .zero), with: .color(.white.opacity(0.001))) 158 | self.data.update(context: context, size: size) 159 | } 160 | } 161 | 162 | -------------------------------------------------------------------------------- /Sources/Particles/API/Proxy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Proxy.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// A proxy representing a single spawned entity's physics data within a ``ParticleSystem``. 12 | public struct Proxy { 13 | 14 | // MARK: - Properties 15 | 16 | private var _x: CGFloat 17 | private var _y: CGFloat 18 | private var _z: UInt 19 | private var _inception: UInt 20 | private var _rotation: Double 21 | private var _torque: Double 22 | private var _randomSeed : SIMD4 23 | 24 | private var _velX: CGFloat 25 | private var _velY: CGFloat 26 | private var _accX: CGFloat 27 | private var _accY: CGFloat 28 | private var _lifetime: Double 29 | private var _drag: Double 30 | 31 | private var _opacity: Double 32 | private var _hueRotation: Double 33 | private var _blur: CGFloat 34 | private var _scaleX: CGFloat 35 | private var _scaleY: CGFloat 36 | private var _blendMode: Int32 37 | private var _rotation3d: SIMD3 38 | 39 | // MARK: - Initalizers 40 | 41 | internal init(currentFrame: UInt) { 42 | _x = .zero 43 | _y = .zero 44 | _z = .zero 45 | _velX = .zero 46 | _velY = .zero 47 | _accX = .zero 48 | _accY = .zero 49 | _rotation = .zero 50 | _torque = .zero 51 | _inception = UInt(currentFrame) 52 | _lifetime = 5.0 53 | _randomSeed = .random(in: 0.0 ... 1.0) 54 | _opacity = 1.0 55 | _hueRotation = .zero 56 | _blur = .zero 57 | _scaleX = 1 58 | _scaleY = 1 59 | _blendMode = GraphicsContext.BlendMode.normal.rawValue 60 | _rotation3d = .zero 61 | _drag = .zero 62 | } 63 | 64 | // MARK: - Subtypes 65 | 66 | /// Context used to assist in updating the **physical properties** of a spawned entity. 67 | /// Every ``Context`` model carries properties that may be helpful in the creation of unique particle systems. 68 | public struct Context { 69 | 70 | // MARK: - Stored Properties 71 | 72 | public internal(set) var proxy: Proxy 73 | 74 | public private(set) weak var system: ParticleSystem.Data! 75 | 76 | // MARK: - Computed Properties 77 | 78 | public var timeAlive: TimeInterval { 79 | return (Double(system.currentFrame) - Double(proxy.inception)) / Double(system.averageFrameRate) 80 | } 81 | 82 | // MARK: - Initalizers 83 | 84 | internal init(proxy: Proxy, system: ParticleSystem.Data) { 85 | self.proxy = proxy 86 | self.system = system 87 | } 88 | } 89 | } 90 | 91 | public extension Proxy { 92 | 93 | /// The position of the entity, in pixels. 94 | var position: CGPoint { get { 95 | CGPoint(x: _x, y: _y) 96 | } set { 97 | _x = newValue.x 98 | _y = newValue.y 99 | }} 100 | 101 | /// The z-index of the entity, used for layering. 102 | var zIndex: Int { get { 103 | Int(_z) 104 | } set { 105 | if newValue > 0 { 106 | _z = UInt(newValue) 107 | } else { 108 | _z = 0 109 | } 110 | }} 111 | 112 | /// The velocity of the entity, in pixels **per frame**. 113 | var velocity: CGVector { get { 114 | CGVector(dx: _velX, dy: _velY) 115 | } set { 116 | _velX = .init(newValue.dx) 117 | _velY = .init(newValue.dy) 118 | }} 119 | 120 | /// The acceleration of the entity, in pixels **per frame per frame**. 121 | var acceleration: CGVector { get { 122 | CGVector(dx: CGFloat(_accX), dy: CGFloat(_accY)) 123 | } set { 124 | _accX = .init(newValue.dx) 125 | _accY = .init(newValue.dy) 126 | }} 127 | 128 | /// The rotation angle of the entity. 129 | var rotation: Angle { get { 130 | .degrees(_rotation) 131 | } set { 132 | _rotation = newValue.degrees.truncatingRemainder(dividingBy: 360.0) 133 | }} 134 | 135 | /// The rotational torque angle of the entity **per frame**. 136 | var torque: Angle { get { 137 | .degrees(_torque) 138 | } set { 139 | _torque = newValue.degrees.truncatingRemainder(dividingBy: 360.0) 140 | }} 141 | 142 | /// The frame number upon which the entity was created. 143 | internal(set) var inception: Int { get { 144 | Int(_inception) 145 | } set { 146 | _inception = UInt(newValue) 147 | }} 148 | 149 | /// The lifetime, in seconds, of the entity. 150 | var lifetime: Double { get { 151 | Double(_lifetime) 152 | } set { 153 | _lifetime = .init(newValue) 154 | }} 155 | 156 | /// Four random seeds that can be used to customize the behavior of spawned particles. 157 | /// Each of the integer values contains a value 0.0 - 1.0. 158 | var seed: (Double, Double, Double, Double) { 159 | (_randomSeed.x, _randomSeed.y, _randomSeed.z, _randomSeed.w) 160 | } 161 | 162 | /// The opacity of the particle, 0.0 to 1.0. 163 | var opacity: Double { get { 164 | _opacity 165 | } set { 166 | _opacity = newValue 167 | }} 168 | 169 | /// The hue rotation angle of the particle. 170 | var hueRotation: Angle { get { 171 | .degrees(_hueRotation) 172 | } set { 173 | _hueRotation = newValue.degrees 174 | }} 175 | 176 | /// The blur of the particle. 177 | var blur: CGFloat { get { 178 | _blur 179 | } set { 180 | _blur = newValue 181 | }} 182 | 183 | /// The x- and y-scale of the particle. Default `1.0 x 1.0`. 184 | var scale: CGSize { get { 185 | CGSize(width: CGFloat(_scaleX), height: CGFloat(_scaleY)) 186 | } set { 187 | _scaleX = .init(newValue.width) 188 | _scaleY = .init(newValue.height) 189 | }} 190 | 191 | /// The blending mode of the particle. 192 | var blendMode: GraphicsContext.BlendMode { get { 193 | .init(rawValue: _blendMode) 194 | } set { 195 | _blendMode = newValue.rawValue 196 | }} 197 | 198 | /// The 3D rotation of the particle. 199 | var rotation3d: SIMD3 { get { 200 | _rotation3d 201 | } set { 202 | _rotation3d = newValue 203 | }} 204 | 205 | /// The drag of the particle, from `0.0` to `1.0`. 206 | /// Drag reduces the acceleration of a particle. 207 | var drag: Double { get { 208 | _drag 209 | } set { 210 | _drag = max(min(newValue, 1), 0) 211 | }} 212 | } 213 | -------------------------------------------------------------------------------- /Sources/Particles/API/Transition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transition.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/14/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// A particle render transition. 12 | /// A few presets are provided. See ``AnyTransition``. 13 | public protocol Transition { 14 | 15 | /// A required method for ``Transition``s that modifies a `GraphicsContext` during the transition's designated timeframe. 16 | /// For implementation examples, see ``OpacityTransition`` or ``ScaleTransition``. 17 | /// - Parameter progress: Upon birth, progress goes `1.0 -> 0.0`. Upon death, progress goes `0.0 -> 1.0`. 18 | /// - Parameter physics: Physics context provided for use in the transition. 19 | /// - Parameter context: The `GraphicsContext` that is to be modified. 20 | func modifyRender(progress: Double, physics: Proxy.Context, context: inout GraphicsContext) 21 | } 22 | 23 | /// Bounds for a transition. 24 | public enum TransitionBounds { 25 | /// Perform the transition after the particle is spawned. 26 | case birth 27 | /// Perform the transition before the particle is to be removed. 28 | case death 29 | /// Perform at both ``birth`` and ``death``. 30 | case birthAndDeath 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Particles/API/Transitions/OpacityTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpacityTransition.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/14/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// A transition modifying the opacity of an entity. 12 | public struct OpacityTransition: Transition { 13 | public func modifyRender(progress: Double, physics: Proxy.Context, context: inout GraphicsContext) { 14 | context.opacity = 1 - progress 15 | } 16 | } 17 | 18 | public extension Particles.AnyTransition { 19 | static var opacity: AnyTransition { 20 | return .init(OpacityTransition()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Particles/API/Transitions/ScaleTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScaleTransition.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/14/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// A transition modifying the scale of an entity. 12 | public struct ScaleTransition: Transition { 13 | public func modifyRender(progress: Double, physics: Proxy.Context, context: inout GraphicsContext) { 14 | context.scaleBy(x: 1 - progress, y: 1 - progress) 15 | } 16 | } 17 | 18 | public extension Particles.AnyTransition { 19 | static var scale: AnyTransition { 20 | return .init(ScaleTransition()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Particles/API/Transitions/TwinkleTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwinkleTransition.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/21/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// A transition modifying the opacity of an entity, making a twinkle effect. 12 | public struct TwinkleTransition: Transition { 13 | public func modifyRender(progress: Double, physics: Proxy.Context, context: inout GraphicsContext) { 14 | let r = Double.random(in: 0.0 ... 1.0, seed: physics.proxy.seed.0) 15 | context.opacity = max(min((1 - progress) + 0.5 * sqrt(1 - progress) * sin(40 * progress + 40 * r), 1), 0) 16 | } 17 | } 18 | 19 | public extension Particles.AnyTransition { 20 | static var twinkle: AnyTransition { 21 | return .init(TwinkleTransition()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Particles/API/Type Erasures/AnyEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyEntity.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A tyoe-erased ``Entity``. 11 | public struct AnyEntity { 12 | 13 | public var body: Any 14 | public typealias Body = Any 15 | 16 | init(body: T) where T: Entity { 17 | self.body = body 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Particles/API/Type Erasures/AnyTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyTransition.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/14/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// A type-erased ``Transition`` struct. 12 | public struct AnyTransition { 13 | 14 | internal private(set) var modifyRender: (Double, Proxy.Context, inout GraphicsContext) -> Void 15 | 16 | public init(_ transition: T) where T: Particles.Transition { 17 | self.modifyRender = transition.modifyRender 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/Extensions/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color.swift 3 | // 4 | // 5 | // Created by Ben Myers on 4/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if canImport(UIKit) 11 | import UIKit 12 | #elseif canImport(AppKit) 13 | import AppKit 14 | #endif 15 | 16 | internal extension Color { 17 | 18 | static func lerp(a: Color, b: Color, t: CGFloat) -> Color { 19 | return Color( 20 | .sRGB, 21 | red: lerp(a.components.red, b.components.red, t), 22 | green: lerp(a.components.green, b.components.green, t), 23 | blue: lerp(a.components.blue, b.components.blue, t) 24 | ) 25 | } 26 | 27 | private static func lerp(_ a: Double, _ b: Double, _ t: CGFloat) -> Double { 28 | return a + (b - a) * Double(t) 29 | } 30 | 31 | var components: (red: CGFloat, green: CGFloat, blue: CGFloat, opacity: CGFloat) { 32 | #if canImport(UIKit) 33 | typealias NativeColor = UIColor 34 | #elseif canImport(AppKit) 35 | typealias NativeColor = NSColor 36 | #endif 37 | var r: CGFloat = 0 38 | var g: CGFloat = 0 39 | var b: CGFloat = 0 40 | var o: CGFloat = 0 41 | NativeColor(self).getRed(&r, green: &g, blue: &b, alpha: &o) 42 | return (r, g, b, o) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/Extensions/ConvertImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConvertImage.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 23.01.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) 11 | import UIKit 12 | #elseif os(macOS) 13 | import AppKit 14 | #endif 15 | 16 | #if os(iOS) 17 | @available(watchOS, unavailable) 18 | extension UIView { 19 | func asImage() -> UIImage { 20 | let renderer = UIGraphicsImageRenderer(bounds: bounds) 21 | return renderer.image { rendererContext in 22 | layer.render(in: rendererContext.cgContext) 23 | } 24 | } 25 | } 26 | #endif 27 | 28 | @available(watchOS, unavailable) 29 | public extension View { 30 | 31 | #if os(iOS) 32 | func asImage() -> UIImage? { 33 | let controller = UIHostingController(rootView: self) 34 | controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1) 35 | let size = controller.sizeThatFits(in: UIScreen.main.bounds.size) 36 | controller.view.bounds = CGRect(origin: .zero, size: size) 37 | controller.view.sizeToFit() 38 | keyWindow?.addSubview(controller.view) 39 | let image = controller.view.asImage() 40 | controller.view.removeFromSuperview() 41 | return image 42 | } 43 | 44 | private var keyWindow: UIWindow? { 45 | let allScenes = UIApplication.shared.connectedScenes 46 | for scene in allScenes { 47 | guard let windowScene = scene as? UIWindowScene else { continue } 48 | for window in windowScene.windows where window.isKeyWindow { 49 | return window 50 | } 51 | } 52 | return nil 53 | } 54 | #elseif os(macOS) 55 | func asImage() -> NSImage? { 56 | let controller = NSHostingController(rootView: self) 57 | let targetSize = controller.view.intrinsicContentSize 58 | let contentRect = NSRect(origin: .zero, size: targetSize) 59 | 60 | let window = NSWindow( 61 | contentRect: contentRect, 62 | styleMask: [.borderless], 63 | backing: .buffered, 64 | defer: false 65 | ) 66 | window.contentView = controller.view 67 | 68 | guard 69 | let bitmapRep = controller.view.bitmapImageRepForCachingDisplay(in: contentRect) 70 | else { return nil } 71 | 72 | controller.view.cacheDisplay(in: contentRect, to: bitmapRep) 73 | let image = NSImage(size: bitmapRep.size) 74 | image.addRepresentation(bitmapRep) 75 | return image 76 | } 77 | #else 78 | func asImage() -> UIImage? { 79 | return nil 80 | } 81 | #endif 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/Extensions/NSImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 23.01.2024. 6 | // 7 | 8 | #if os(macOS) 9 | import AppKit 10 | 11 | internal extension NSImage { 12 | var cgImage: CGImage? { 13 | return cgImage(forProposedRect: nil, 14 | context: nil, 15 | hints: nil) 16 | } 17 | } 18 | #endif 19 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/Extensions/View+BoundlessOverlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+BoundlessOverlay.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/22/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal extension View { 11 | 12 | /// Applies a boundless overlay to the view. 13 | /// - Parameter atop: Whether the overlay view goes on top or under the source view. 14 | /// - Parameter overlay: The view to overlay. This can extend the bounds of the base view without affecting the size of its frame. 15 | func boundlessOverlay( 16 | atop: Bool = true, 17 | offset: CGPoint = .zero, 18 | minSize: CGSize = .init(width: 800.0, height: 800.0), 19 | @ViewBuilder overlay: () -> V 20 | ) -> some View where V: View { 21 | BoundlessOverlayWrapper( 22 | atop: atop, 23 | offset: offset, 24 | minSize: minSize, 25 | content: { self }, 26 | overlay: overlay 27 | ) 28 | } 29 | } 30 | 31 | fileprivate struct BoundlessOverlayWrapper: View where Content: View, Overlay: View { 32 | 33 | @State private var contentSize: CGSize? 34 | 35 | var content: Content 36 | var overlay: Overlay 37 | var offset: CGPoint 38 | var minSize: CGSize 39 | var atop: Bool 40 | 41 | var body: some View { 42 | ZStack { 43 | if atop { 44 | c 45 | o 46 | } else { 47 | o 48 | c 49 | } 50 | } 51 | .frame(width: contentSize?.width, height: contentSize?.height) 52 | } 53 | 54 | var c: some View { 55 | content 56 | .background { 57 | GeometryReader { proxy in Color.clear.onAppear { contentSize = proxy.size } } 58 | } 59 | } 60 | 61 | var o: some View { 62 | overlay 63 | .frame(minWidth: minSize.width, minHeight: minSize.height) 64 | .offset(x: offset.x, y: offset.y) 65 | } 66 | 67 | init( 68 | atop: Bool = true, 69 | offset: CGPoint = .zero, 70 | minSize: CGSize = .init(width: 300.0, height: 300.0), 71 | @ViewBuilder content: () -> Content, 72 | @ViewBuilder overlay: () -> Overlay 73 | ) { 74 | self.content = content() 75 | self.overlay = overlay() 76 | self.minSize = minSize 77 | self.offset = offset 78 | self.atop = atop 79 | } 80 | } 81 | 82 | #if os(iOS) 83 | var screenSize: CGSize { 84 | return UIScreen.main.bounds.size 85 | } 86 | #elseif os(macOS) 87 | var screenSize: CGSize { 88 | guard let window = NSApplication.shared.windows.first else { 89 | return CGSize(width: 0, height: 0) 90 | } 91 | return window.frame.size 92 | } 93 | #endif 94 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/FlatEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlatEntity.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/23/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | internal struct FlatEntity { 12 | 13 | internal var preferences: [Preference] 14 | internal var root: (any Entity)? 15 | 16 | init?(single e: Any, centered: Bool = true) { 17 | guard var body: any Entity = e as? any Entity else { return nil } 18 | guard !(e is EmptyEntity) else { return nil } 19 | self.preferences = [] 20 | if centered { 21 | self.preferences = [.onBirth({ c in 22 | var p = c.proxy 23 | let s = c.system.size 24 | p.position = .init(x: 0.5 * s.width, y: 0.5 * s.height) 25 | return p 26 | })] 27 | } 28 | while true { 29 | if let group = body as? Group { 30 | self.root = group 31 | break 32 | } else if let m = body as? any _ModifiedEntity { 33 | self.preferences.append(contentsOf: m.preferences) 34 | body = body.body 35 | continue 36 | } else if body is Particle || body is _Emitter { 37 | self.root = body 38 | break 39 | } else { 40 | body = body.body 41 | continue 42 | } 43 | } 44 | } 45 | 46 | static func make(_ entity: Any, merges: Group.Merges? = nil, centered: Bool = true) -> [(result: FlatEntity, merges: Group.Merges?)] { 47 | if let grouped = entity as? any Transparent { 48 | return FlatEntity.make(grouped.body) 49 | } 50 | guard let single = FlatEntity.init(single: entity, centered: centered) else { 51 | return [] 52 | } 53 | if let group = single.root as? Group { 54 | let flats: [(FlatEntity, Group.Merges?)] = group.values.flatMap({ (e: AnyEntity) in 55 | var children: [(FlatEntity, Group.Merges?)] = FlatEntity.make(e.body, merges: group.merges) 56 | for i in 0 ..< children.count { 57 | children[i].0.preferences.insert(contentsOf: single.preferences, at: 0) 58 | } 59 | return children 60 | }) 61 | return flats 62 | } else { 63 | return [(single, merges)] 64 | } 65 | } 66 | 67 | enum Preference { 68 | case onBirth((Proxy.Context) -> Proxy) 69 | case onUpdate((Proxy.Context) -> Proxy) 70 | case custom((Proxy.Context) -> Custom) 71 | 72 | enum Custom { 73 | case glow(color: Color?, radius: CGFloat) 74 | case colorOverlay(color: Color) 75 | case transition(transition: AnyTransition, bounds: TransitionBounds, duration: TimeInterval) 76 | case delay(duration: TimeInterval) 77 | } 78 | } 79 | 80 | func onBirth(_ context: Proxy.Context) -> Proxy { 81 | var proxy: Proxy = context.proxy 82 | for p in preferences { 83 | if case .onBirth(let c) = p { 84 | let context = Proxy.Context(proxy: proxy, system: context.system) 85 | proxy = c(context) 86 | } 87 | } 88 | return proxy 89 | } 90 | 91 | func onUpdate(_ context: Proxy.Context) -> Proxy { 92 | var proxy: Proxy = context.proxy 93 | for p in preferences { 94 | if case .onUpdate(let c) = p { 95 | let context = Proxy.Context(proxy: proxy, system: context.system) 96 | proxy = c(context) 97 | } 98 | } 99 | return proxy 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/ModifiedEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModifiedEntity.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal protocol _ModifiedEntity { 11 | var preferences: [FlatEntity.Preference] { get set } 12 | } 13 | 14 | internal struct ModifiedEntity: Entity, _ModifiedEntity where E: Entity { 15 | 16 | var body: E 17 | 18 | internal var preferences: [FlatEntity.Preference] = [] 19 | 20 | init( 21 | entity: E, 22 | onBirth: ((Proxy.Context) -> Proxy)? = nil, 23 | onUpdate: ((Proxy.Context) -> Proxy)? = nil 24 | ) { 25 | self.body = entity 26 | if let onBirth { 27 | preferences.insert(.onBirth(onBirth), at: 0) 28 | } 29 | if let onUpdate { 30 | preferences.insert(.onUpdate(onUpdate), at: 0) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/ParticleSystem+Data.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParticleSystem+Data.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Dispatch 10 | import Foundation 11 | import CoreGraphics 12 | 13 | public extension ParticleSystem { 14 | 15 | /// The primary data-holder of the `ParticleSystem` simulation. 16 | /// Contains most logic for updating the system simulation. 17 | class Data { 18 | 19 | // MARK: - Stored Properties 20 | 21 | /// Whether this ``ParticleSystem`` is in debug mode. 22 | public internal(set) var debug: Bool = false 23 | /// The size of the ``ParticleSystem``, in pixels. 24 | public internal(set) var size: CGSize = .zero 25 | /// The current frame of the ``ParticleSystem``. 26 | public private(set) var currentFrame: UInt = .zero 27 | /// The date of the last frame update in the ``ParticleSystem``. 28 | public private(set) var lastFrameUpdate: Date = .distantPast 29 | 30 | #if os(iOS) 31 | /// The taps registered inside the view. 32 | public internal(set) var touches: [UITouch: CGPoint] = [:] 33 | #endif 34 | 35 | internal var initialEntity: (any Entity)? 36 | internal var nextEntityRegistry: EntityID = .zero 37 | internal var refreshViews: Bool = false 38 | internal private(set) var fps: Double = .zero 39 | internal private(set) var nextProxyRegistry: ProxyID = .zero 40 | 41 | private var inception: Date = Date() 42 | private var last60: Date = .distantPast 43 | private var updateTime: TimeInterval = .zero 44 | private var performRenderTime: TimeInterval = .zero 45 | 46 | // ID of entity -> Flattened entity 47 | private var entities: [EntityID: FlatEntity] = [:] 48 | // ID of entity -> View to render 49 | private var views: [EntityID: MaybeView] = .init() 50 | // ID of proxy -> Physics data 51 | private var proxies: [ProxyID: Proxy] = [:] 52 | // ID of proxy -> ID of entity containing data behaviors 53 | private var proxyEntities: [ProxyID: EntityID] = [:] 54 | // ID of emitter proxy -> Frame such proxy last emitted a child 55 | private var lastEmitted: [ProxyID: UInt] = [:] 56 | // ID of emitter entity -> Entity IDs to create children with 57 | private var emitEntities: [EntityID: [EntityID]] = [:] 58 | 59 | // MARK: - Computed Properties 60 | 61 | /// The amount of time, in seconds, that has elapsed since the ``ParticleSystem`` was created. 62 | public var time: TimeInterval { 63 | return Date().timeIntervalSince(inception) 64 | } 65 | 66 | /// The total number of proxies alive. 67 | public var proxiesAlive: Int { 68 | return proxies.count 69 | } 70 | 71 | /// The total number of proxies spawned in the simulation. 72 | public var proxiesSpawned: Int { 73 | return Int(nextProxyRegistry) 74 | } 75 | 76 | /// The average frame rate, in frames per second, of the system simulation. 77 | public var averageFrameRate: Double { 78 | return Double(currentFrame) / Date().timeIntervalSince(inception) 79 | } 80 | 81 | // MARK: - Initalizers 82 | 83 | init() {} 84 | 85 | // MARK: - Methods 86 | 87 | internal func update(context: GraphicsContext, size: CGSize) { 88 | self.size = size 89 | if let initialEntity = self.initialEntity, self.currentFrame > 1 { 90 | if self.nextEntityRegistry > .zero { 91 | self.nextEntityRegistry = .zero 92 | self.create(entity: initialEntity, spawn: false) 93 | } else { 94 | self.create(entity: initialEntity, spawn: true) 95 | } 96 | self.initialEntity = nil 97 | } 98 | let group = DispatchGroup() 99 | let queue = DispatchQueue(label: "com.benmyers.particles.update", qos: .userInteractive, attributes: .concurrent) 100 | var updateResult: ([ProxyID: Proxy], [ProxyID])? 101 | group.enter() 102 | queue.async(group: group) { 103 | 104 | updateResult = self.updateProxies() 105 | group.leave() 106 | } 107 | self.performRenders(context) 108 | group.wait() 109 | if let (newProxies, expiredProxies) = updateResult { 110 | for (proxyID, newProxy) in newProxies { 111 | proxies[proxyID] = newProxy 112 | } 113 | for proxyID in expiredProxies { 114 | proxies.removeValue(forKey: proxyID) 115 | proxies.removeValue(forKey: proxyID) 116 | proxyEntities.removeValue(forKey: proxyID) 117 | } 118 | } 119 | advanceFrame() 120 | emitChildren() 121 | } 122 | 123 | internal func viewPairs() -> [(AnyView, EntityID)] { 124 | var result: [(AnyView, EntityID)] = [] 125 | for (id, maybe) in views { 126 | if case MaybeView.some(let view) = maybe { 127 | result.append((view, id)) 128 | } 129 | } 130 | return result 131 | } 132 | 133 | internal func performanceSummary(advanced: Bool = true) -> String { 134 | var arr: [String] = [] 135 | arr.append("\(Int(size.width)) x \(Int(size.height)) \t\(String(format: "%.1f", fps)) FPS") 136 | arr.append("Proxies: \(proxies.count)\tEntities: \(entities.count) \tViews: \(views.filter({ $0.value.isSome }).count)") 137 | arr.append("Update: \(String(format: "%.1f", updateTime * 1000))ms \tRender: \(String(format: "%.1f", performRenderTime * 1000))ms") 138 | if advanced { 139 | arr.append("PE=\(proxyEntities.count) \tLE=\(lastEmitted.count) \tEE=\(emitEntities.count)") 140 | } 141 | return arr.joined(separator: "\n") 142 | } 143 | 144 | private func emitChildren() { 145 | guard currentFrame > 1 else { return } 146 | let proxyIDs = proxies.keys 147 | for proxyID in proxyIDs { 148 | guard let proxy: Proxy = proxies[proxyID] else { continue } 149 | guard let entityID: EntityID = proxyEntities[proxyID] else { continue } 150 | guard let entity: FlatEntity = entities[entityID] else { continue } 151 | guard let emitter = (entity.root as? (any _Emitter)) else { continue } 152 | guard let protoEntities: [EntityID] = emitEntities[entityID] else { continue } 153 | if let emitted: UInt = lastEmitted[proxyID] { 154 | let emitAt: UInt = emitted + UInt(emitter.emitInterval * 60.0) 155 | guard currentFrame >= emitAt else { continue } 156 | } 157 | var finalEntities: [EntityID] = protoEntities 158 | if let chooser = emitter.emitChooser, !protoEntities.isEmpty { 159 | let context = Proxy.Context(proxy: proxy, system: self) 160 | let i = chooser(context) % protoEntities.count 161 | finalEntities = [protoEntities[i]] 162 | } 163 | for protoEntity in finalEntities { 164 | guard let _: ProxyID = self.createProxy(protoEntity, inherit: proxy) else { continue } 165 | self.lastEmitted[proxyID] = currentFrame 166 | } 167 | } 168 | } 169 | 170 | private func updateProxies() -> (new: [ProxyID: Proxy], expired: [ProxyID]) { 171 | let flag = Date() 172 | let group = DispatchGroup() 173 | let queue = DispatchQueue(label: "com.benmyers.particles.update.proxy", qos: .userInteractive, attributes: .concurrent) 174 | var newProxies: [ProxyID: Proxy] = [:] 175 | var expiredProxies: [ProxyID] = [] 176 | let lock = NSLock() 177 | for (proxyID, entityID) in proxyEntities { 178 | group.enter() 179 | queue.async(group: group) { [weak self] in 180 | guard let self else { 181 | group.leave() 182 | return 183 | } 184 | guard let proxy: Proxy = proxies[proxyID] else { 185 | group.leave() 186 | return 187 | } 188 | guard let entity: FlatEntity = entities[entityID] else { 189 | group.leave() 190 | return 191 | } 192 | var deathFrame: Int = .max 193 | if proxy.lifetime < .infinity { 194 | deathFrame = Int(Double(proxy.inception) + proxy.lifetime * 60.0) 195 | } 196 | if Int(currentFrame) >= deathFrame { 197 | lock.lock() 198 | expiredProxies.append(proxyID) 199 | lock.unlock() 200 | group.leave() 201 | return 202 | } 203 | let context = Proxy.Context(proxy: proxy, system: self) 204 | var new: Proxy = entity.onUpdate(context) 205 | new.velocity.dx += new.acceleration.dx 206 | new.velocity.dy += new.acceleration.dy 207 | new.position.x += new.velocity.dx 208 | new.position.y += new.velocity.dy 209 | new.rotation.degrees += new.torque.degrees 210 | new.velocity.dx *= (1 - new.drag) 211 | new.velocity.dy *= (1 - new.drag) 212 | lock.lock() 213 | newProxies[proxyID] = new 214 | lock.unlock() 215 | group.leave() 216 | } 217 | } 218 | group.wait() 219 | self.updateTime = Date().timeIntervalSince(flag) 220 | return (newProxies, expiredProxies) 221 | } 222 | 223 | private func performRenders(_ context: GraphicsContext) { 224 | let flag = Date() 225 | var zIndices: [(ProxyID, Int)] = [] 226 | for proxyID in proxies.keys { 227 | guard let proxy: Proxy = proxies[proxyID] else { continue } 228 | zIndices.append((proxyID, proxy.zIndex)) 229 | } 230 | zIndices.sort(by: { $0.1 < $1.1 }) 231 | for (proxyID, _) in zIndices { 232 | // Initial checks 233 | guard 234 | let proxy: Proxy = proxies[proxyID], 235 | let entityID: EntityID = proxyEntities[proxyID], 236 | let entity: FlatEntity = entities[entityID] 237 | else { 238 | continue 239 | } 240 | // Resolve the view 241 | var resolvedEntityID: EntityID = entityID 242 | if let maybe = views[entityID] { 243 | switch maybe { 244 | case .merged(let mergedID): 245 | resolvedEntityID = mergedID 246 | if views[resolvedEntityID] == nil, let view: AnyView = (entity.root as? Particle)?.view { 247 | views[resolvedEntityID] = .some(view) 248 | } 249 | case .some(_): 250 | if refreshViews { 251 | guard let view: AnyView = (entity.root as? Particle)?.view else { break } 252 | views[entityID] = .some(view) 253 | } 254 | break 255 | } 256 | } else { 257 | guard let view: AnyView = (entity.root as? Particle)?.view else { 258 | continue 259 | } 260 | views[entityID] = .some(view) 261 | } 262 | guard let resolved: GraphicsContext.ResolvedSymbol = context.resolveSymbol(id: resolvedEntityID) else { 263 | continue 264 | } 265 | // Ensure position in system bounds 266 | guard 267 | proxy.position.x > -resolved.size.width, 268 | proxy.position.x < self.size.width + resolved.size.width, 269 | proxy.position.y > -resolved.size.height, 270 | proxy.position.y < self.size.height + resolved.size.height, 271 | self.currentFrame > proxy.inception 272 | else { 273 | continue 274 | } 275 | var cc: GraphicsContext = context 276 | // Render the proxy 277 | cc.opacity = proxy.opacity 278 | cc.blendMode = proxy.blendMode 279 | cc.drawLayer { cc in 280 | cc.translateBy(x: proxy.position.x, y: proxy.position.y) 281 | cc.rotate(by: proxy.rotation) 282 | #if !os(watchOS) 283 | if proxy.rotation3d != .zero { 284 | var transform = CATransform3DIdentity 285 | transform = CATransform3DRotate(transform, proxy.rotation3d.x, 1, 0, 0) 286 | transform = CATransform3DRotate(transform, proxy.rotation3d.y, 0, 1, 0) 287 | transform = CATransform3DRotate(transform, proxy.rotation3d.z, 0, 0, 1) 288 | cc.addFilter(.projectionTransform(ProjectionTransform(transform))) 289 | } 290 | #endif 291 | cc.scaleBy(x: proxy.scale.width, y: proxy.scale.height) 292 | cc.addFilter(.hueRotation(proxy.hueRotation)) 293 | cc.addFilter(.blur(radius: proxy.blur)) 294 | var blurOverlayRadius: CGFloat = .zero 295 | for preference in entity.preferences { 296 | if case .custom(let custom) = preference { 297 | let context = Proxy.Context(proxy: proxy, system: self) 298 | let custom = custom(context) 299 | if case .glow(let color, let radius) = custom { 300 | if let color { 301 | cc.addFilter(.shadow(color: color, radius: radius, x: 0.0, y: 0.0, blendMode: .plusLighter, options: .shadowAbove)) 302 | } else { 303 | blurOverlayRadius = radius 304 | } 305 | } 306 | else if case .colorOverlay(let overlay) = custom { 307 | var m: ColorMatrix = ColorMatrix() 308 | m.r1 = 0 309 | m.g2 = 0 310 | m.b3 = 0 311 | m.a4 = 1 312 | m.r5 = 1 313 | m.g5 = 1 314 | m.b5 = 1 315 | cc.addFilter(.colorMultiply(overlay)) 316 | cc.addFilter(.colorMatrix(m)) 317 | cc.addFilter(.colorMultiply(overlay)) 318 | } else if case .transition(let transition, let bounds, let duration) = custom { 319 | let c = Proxy.Context(proxy: proxy, system: self) 320 | if bounds == .birth || bounds == .birthAndDeath { 321 | guard c.timeAlive < duration else { continue } 322 | } 323 | if bounds == .death || bounds == .birthAndDeath { 324 | guard c.timeAlive > proxy.lifetime - duration else { continue } 325 | } 326 | transition.modifyRender( 327 | getTransitionProgress(bounds: bounds, duration: duration, context: c), 328 | c, 329 | &cc 330 | ) 331 | } 332 | } 333 | } 334 | cc.draw(resolved, at: .zero) 335 | cc.drawLayer { ccx in 336 | if blurOverlayRadius > .zero { 337 | ccx.blendMode = .plusLighter 338 | ccx.addFilter(.blur(radius: blurOverlayRadius)) 339 | ccx.draw(resolved, at: .zero) 340 | } 341 | } 342 | } 343 | } 344 | self.performRenderTime = Date().timeIntervalSince(flag) 345 | refreshViews = false 346 | } 347 | 348 | private func advanceFrame() { 349 | if self.currentFrame > .max - 1000 { 350 | self.currentFrame = 2 351 | for k in self.lastEmitted.keys { 352 | self.lastEmitted[k] = 0 353 | } 354 | } else { 355 | self.currentFrame += 1 356 | } 357 | self.lastFrameUpdate = Date() 358 | if self.currentFrame % 15 == 0 { 359 | let fps: Double = 15.0 / Date().timeIntervalSince(self.last60) 360 | self.fps = fps 361 | self.last60 = Date() 362 | } 363 | } 364 | 365 | @discardableResult 366 | private func create(entity: E, spawn: Bool = true, centered: Bool = true) -> [(EntityID, ProxyID?)] where E: Entity { 367 | guard !(entity is EmptyEntity) else { return [] } 368 | var result: [(EntityID, ProxyID?)] = [] 369 | let make = FlatEntity.make(entity, centered: centered) 370 | var firstID: EntityID? 371 | for (flat, merges) in make { 372 | var proxyID: ProxyID? 373 | let entityID: EntityID = self.register(entity: flat) 374 | if spawn { 375 | proxyID = self.createProxy(entityID) 376 | } 377 | if let root = flat.root, root is (any _Emitter) { 378 | self.emitEntities[entityID] = self.create(entity: root.body, spawn: false, centered: false).map({ $0.0 }) 379 | } 380 | if let merges: Group.Merges, let firstID: EntityID { 381 | switch merges { 382 | case .views: 383 | self.views[entityID] = .merged(firstID) 384 | case .entities: 385 | unregister(entityID: entityID) 386 | if let proxyID { 387 | proxyEntities[proxyID] = firstID 388 | } 389 | } 390 | } 391 | if firstID == nil { 392 | firstID = entityID 393 | } 394 | result.append((entityID, proxyID)) 395 | } 396 | return result 397 | } 398 | 399 | @discardableResult 400 | private func createProxy(_ id: EntityID, inherit: Proxy? = nil) -> ProxyID? { 401 | guard let entity: FlatEntity = self.entities[id] else { return nil } 402 | var proxy = Proxy(currentFrame: currentFrame) 403 | if let inherit { 404 | proxy.position = inherit.position 405 | proxy.rotation = inherit.rotation 406 | proxy.velocity = inherit.velocity 407 | } 408 | if entity.root is (any _Emitter) { 409 | proxy.lifetime = .infinity 410 | } 411 | let context = Proxy.Context(proxy: proxy, system: self) 412 | var new = entity.onBirth(context) 413 | for p in entity.preferences { 414 | let context = Proxy.Context(proxy: new, system: self) 415 | if case .custom(let custom) = p, case .delay(let duration) = custom(context) { 416 | new.inception = new.inception + Int(60.0 * duration) 417 | } 418 | } 419 | self.proxies[nextProxyRegistry] = new 420 | self.proxyEntities[nextProxyRegistry] = id 421 | let proxyID = nextProxyRegistry 422 | nextProxyRegistry += 1 423 | return proxyID 424 | } 425 | 426 | private func register(entity: FlatEntity) -> EntityID { 427 | self.entities[nextEntityRegistry] = entity 428 | let id = nextEntityRegistry 429 | nextEntityRegistry += 1 430 | return id 431 | } 432 | 433 | private func unregister(entityID: EntityID) { 434 | self.entities.removeValue(forKey: entityID) 435 | } 436 | 437 | private func getTransitionProgress(bounds: TransitionBounds, duration: TimeInterval, context: Proxy.Context) -> Double { 438 | switch bounds { 439 | case .birth: 440 | return 1 - min(max(context.timeAlive / duration, 0.0), 1.0) 441 | case .death: 442 | return min(max((context.timeAlive - context.proxy.lifetime + duration) / duration, 0.0), 1.0) 443 | case .birthAndDeath: 444 | if context.timeAlive < context.proxy.lifetime / 2.0 { 445 | return getTransitionProgress(bounds: .birth, duration: duration, context: context) 446 | } else { 447 | return getTransitionProgress(bounds: .death, duration: duration, context: context) 448 | } 449 | } 450 | } 451 | 452 | // MARK: - Subtypes 453 | 454 | private enum MaybeView { 455 | case some(AnyView) 456 | case merged(EntityID) 457 | var isSome: Bool { 458 | switch self { 459 | case .some(_): 460 | return true 461 | case .merged(_): 462 | return false 463 | } 464 | } 465 | } 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/ShaderEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShaderEntity.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/20/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(macOS 14.0, iOS 17.0, *) 11 | @available(watchOS, unavailable) 12 | internal struct ShaderEntity: Entity where E: Entity { 13 | 14 | internal private(set) var shader: (Proxy.Context) -> Shader 15 | 16 | var body: E 17 | 18 | init( 19 | entity: E, 20 | shader: @escaping (Proxy.Context) -> Shader 21 | ) { 22 | self.body = entity 23 | self.shader = shader 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/TouchRecognizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchRecognizer.swift 3 | // 4 | // 5 | // Created by Ben Myers on 4/10/24. 6 | // 7 | 8 | #if os(iOS) 9 | 10 | import UIKit 11 | import Foundation 12 | import SwiftUI 13 | 14 | private class NFingerGestureRecognizer: UIGestureRecognizer { 15 | 16 | var tappedCallback: (UITouch, CGPoint?) -> Void 17 | 18 | var touchViews = [UITouch:CGPoint]() 19 | 20 | init(target: Any?, tappedCallback: @escaping (UITouch, CGPoint?) -> ()) { 21 | self.tappedCallback = tappedCallback 22 | super.init(target: target, action: nil) 23 | } 24 | 25 | override func touchesBegan(_ touches: Set, with event: UIEvent) { 26 | for touch in touches { 27 | let location = touch.location(in: touch.view) 28 | // print("Start: (\(location.x)/\(location.y))") 29 | touchViews[touch] = location 30 | tappedCallback(touch, location) 31 | } 32 | } 33 | 34 | override func touchesMoved(_ touches: Set, with event: UIEvent) { 35 | for touch in touches { 36 | let newLocation = touch.location(in: touch.view) 37 | // let oldLocation = touchViews[touch]! 38 | // print("Move: (\(oldLocation.x)/\(oldLocation.y)) -> (\(newLocation.x)/\(newLocation.y))") 39 | touchViews[touch] = newLocation 40 | tappedCallback(touch, newLocation) 41 | } 42 | } 43 | 44 | override func touchesEnded(_ touches: Set, with event: UIEvent) { 45 | for touch in touches { 46 | // let oldLocation = touchViews[touch]! 47 | // print("End: (\(oldLocation.x)/\(oldLocation.y))") 48 | touchViews.removeValue(forKey: touch) 49 | tappedCallback(touch, nil) 50 | } 51 | } 52 | 53 | } 54 | 55 | internal struct TouchRecognizer: UIViewRepresentable { 56 | 57 | var tappedCallback: (UITouch, CGPoint?) -> Void 58 | 59 | func makeUIView(context: UIViewRepresentableContext) -> TouchRecognizer.UIViewType { 60 | let v = UIView(frame: .zero) 61 | let gesture = NFingerGestureRecognizer(target: context.coordinator, tappedCallback: tappedCallback) 62 | v.addGestureRecognizer(gesture) 63 | return v 64 | } 65 | 66 | func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { 67 | // empty 68 | } 69 | 70 | } 71 | 72 | #endif 73 | -------------------------------------------------------------------------------- /Sources/Particles/Intermodular/Transparent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transparent.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal protocol Transparent: Entity {} 11 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Preset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preset.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/21/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The base namespace for all particles presets. 11 | /// 12 | /// To define new presets, see ``Preset/Entry``. 13 | /// 14 | /// - seealso: ``ParticlesPresets/Preset/Fire`` 15 | public struct Preset { 16 | 17 | /// Every preset available, as an array of entities. 18 | public static var allDefaults: [(String, any PresetEntry)] { 19 | [ 20 | ("Fire", Fire()), 21 | ("Magic", Magic()), 22 | ("Rain", Rain()), 23 | ("Smoke", Smoke()), 24 | ("Snow", Snow()), 25 | ("Stars", Stars()) 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/PresetEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresetEntry.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/21/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | /// A preset entry entity. 13 | /// 14 | /// Preset entities have an auto-computed property, `parameters`, which hold every `@PresetParameter` property. 15 | /// 16 | /// - seealso: ``PresetEntry/view`` 17 | public protocol PresetEntry: Entity { 18 | 19 | /// The parameters of this entry. 20 | var parameters: [any _PresetParameter] { get } 21 | } 22 | 23 | public extension PresetEntry { 24 | 25 | // MARK: - Properties 26 | 27 | /// Converts the preset into a `View` that can be used in SwiftUI. 28 | var view: some View { 29 | ParticleSystem(entity: { self }) 30 | .statePersistent("_ParticlesPresetEntry\(String(describing: type(of: self)))") 31 | } 32 | 33 | /// Converts the presets into a demo view where parameters can be tweaked. 34 | var demo: some View { 35 | DemoView(entry: self) 36 | } 37 | 38 | // MARK: - Conformance 39 | 40 | var parameters: [any _PresetParameter] { 41 | var properties: [any _PresetParameter] = [] 42 | let mirror = Mirror(reflecting: self) 43 | for case let (label?, value) in mirror.children { 44 | if var property = value as? any _PresetParameter { 45 | let cr = (value as? CustomReflectable).debugDescription 46 | property.setMirrorMetadata(label, cr) 47 | properties.append(property) 48 | } 49 | } 50 | return properties 51 | } 52 | } 53 | 54 | fileprivate struct DemoView: View where Preset: PresetEntry { 55 | 56 | @State var entry: Preset 57 | @State var refresh: Bool = false 58 | 59 | var body: some View { 60 | ZStack(alignment: .topLeading) { 61 | entry.view 62 | VStack(alignment: .leading) { 63 | ForEach(entry.parameters, id: \.name) { parameter in 64 | AnyView(parameter.view) 65 | .onAppear { 66 | // parameter.o = { v in refresh.toggle() } 67 | } 68 | } 69 | } 70 | .padding() 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/PresetParameter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresetParameter.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/29/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | @propertyWrapper 12 | public struct PresetParameter: _PresetParameter { 13 | 14 | // MARK: - Properties 15 | 16 | public var wrappedValue: V 17 | 18 | /// The recommended value range of this parameter. 19 | public var range: (min: V, max: V)? 20 | 21 | /// The name of this parameter. Auto-generated. 22 | public var name: String = "" 23 | /// The documenation for this parameter. Auto-generated. 24 | public var documentation: String? 25 | 26 | // public var onUpdate: (V) -> Void = { _ in } 27 | 28 | // MARK: - Methods 29 | 30 | public mutating func setMirrorMetadata(_ name: String, _ documentation: String?) { 31 | self.name = name.dropFirst().capitalized 32 | self.documentation = documentation 33 | } 34 | 35 | // MARK: - Initalizers 36 | 37 | /// Defines a parameter with a default value. 38 | /// - parameter wrappedValue: The default value to apply. 39 | public init(wrappedValue: V) where V: _PresetParameterSingleValue { 40 | self.wrappedValue = wrappedValue 41 | } 42 | 43 | /// Defines a parameter with a default value in a recommended range. 44 | /// - parameter wrappedValue: The default value to apply. 45 | /// - parameter range: The recommended range. 46 | public init(wrappedValue: V, in range: ClosedRange) where V == Float { 47 | self.wrappedValue = wrappedValue 48 | self.range = (range.lowerBound, range.upperBound) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Comet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comet.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 6.04.2024. 6 | // 7 | 8 | 9 | import SwiftUI 10 | import Particles 11 | import Foundation 12 | 13 | public extension Preset { 14 | 15 | struct Comet: Entity, PresetEntry { 16 | 17 | var color: Color 18 | var spawnPoint: UnitPoint 19 | var spawnRadius: CGSize 20 | var flameSize: CGFloat = 25.0 21 | var flameLifetime: TimeInterval = 1 22 | 23 | public var body: some Entity { 24 | Emitter(every: 0.01) { 25 | Particle { 26 | RadialGradient( 27 | colors: [color, Color.clear], 28 | center: .center, 29 | startRadius: 0.0, 30 | endRadius: flameSize * 0.8 31 | ) 32 | .clipShape(Circle()) 33 | } 34 | .initialOffset(xIn: -spawnRadius.width/2 ... spawnRadius.width/2, yIn: -spawnRadius.height/2 ... spawnRadius.height/2) 35 | .initialPosition(.center) 36 | .hueRotation(angleIn: .degrees(0.0) ... .degrees(20.0)) 37 | .initialOffset(xIn: -spawnRadius.width/2 ... spawnRadius.width/2, yIn: -spawnRadius.height/2 ... spawnRadius.height/2) 38 | .initialVelocity(xIn: 0.2 ... 0.8, yIn: -0.5 ... 0.25) 39 | .fixAcceleration(x: 0.4, y: -0.4) 40 | .lifetime(in: 2 +/- 0.5) 41 | .glow(color.opacity(0.9), radius: 18.0) 42 | .blendMode(.plusLighter) 43 | .transition(.scale, on: .death, duration: 0.5) 44 | // .transition(.twinkle, on: .birth, duration: 0.3) 45 | .fixScale { c in 46 | return 1.0 - (c.timeAlive * 0.1) 47 | } 48 | } 49 | } 50 | 51 | public init( 52 | color: Color = Color.blue, 53 | spawnPoint: UnitPoint = .center, 54 | flameSize: CGFloat = 50.0, 55 | spawnRadius: CGSize = .init(width: 8.0, height: 8.0) 56 | ) { 57 | self.color = color 58 | self.spawnPoint = spawnPoint 59 | self.flameSize = flameSize 60 | self.spawnRadius = spawnRadius 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Fire.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fire.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/21/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | public extension Preset { 13 | 14 | struct Fire: Entity, PresetEntry { 15 | 16 | var color: Color 17 | var spawnPoint: UnitPoint 18 | var spawnRadius: CGSize 19 | var flameSize: CGFloat = 10.0 20 | var flameLifetime: TimeInterval = 1 21 | 22 | public var body: some Entity { 23 | Emitter(every: 0.01) { 24 | Particle { 25 | RadialGradient( 26 | colors: [color, .clear], 27 | center: .center, 28 | startRadius: 0.0, 29 | endRadius: flameSize * 0.8 30 | ) 31 | .clipShape(Circle()) 32 | } 33 | .initialOffset(xIn: -spawnRadius.width/2 ... spawnRadius.width/2, yIn: -spawnRadius.height/2 ... spawnRadius.height/2) 34 | .initialPosition(.center) 35 | .hueRotation(angleIn: .degrees(0.0) ... .degrees(50.0)) 36 | .initialOffset(xIn: -spawnRadius.width/2 ... spawnRadius.width/2, yIn: -spawnRadius.height/2 ... spawnRadius.height/2) 37 | .initialVelocity(xIn: -0.4 ... 0.4, yIn: -1 ... 0.5) 38 | .fixAcceleration(y: -0.05) 39 | .lifetime(in: 1 +/- 0.2) 40 | .glow(color.opacity(0.5), radius: 18.0) 41 | .blendMode(.plusLighter) 42 | .transition(.scale, on: .death, duration: 0.5) 43 | .transition(.opacity, on: .birth, duration: 0.3) 44 | } 45 | } 46 | 47 | public init( 48 | color: Color = .red, 49 | spawnPoint: UnitPoint = .center, 50 | flameSize: CGFloat = 10.0, 51 | spawnRadius: CGSize = .init(width: 20.0, height: 4.0) 52 | ) { 53 | self.color = color 54 | self.spawnPoint = spawnPoint 55 | self.flameSize = flameSize 56 | self.spawnRadius = spawnRadius 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Fireworks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fireworks.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 31.03.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | public extension Preset { 13 | 14 | struct Fireworks: Entity, PresetEntry { 15 | 16 | private var parameters: Parameters 17 | 18 | public init (shootFor shootDuration: TimeInterval = 1.0, color: Color = .blue, spread: Double = 1.0) { 19 | self.parameters = .init(shootDuration: shootDuration, color: color, spread: spread) 20 | } 21 | 22 | public var body: some Entity { 23 | Group { 24 | ForEach(0 ... 500, merges: .views) { i in 25 | Particle { 26 | RadialGradient(colors: [parameters.color, .clear], center: .center, startRadius: 0.0, endRadius: 4.0) 27 | .clipShape(Circle()) 28 | .frame(width: 4.0, height: 4.0) 29 | } 30 | .initialPosition(.center) 31 | .lifetime(4) 32 | .transition(.twinkle, on: .death, duration: 3.0) 33 | .blendMode(.plusLighter) 34 | .glow(radius: 6.0) 35 | .onUpdate { p, c in 36 | if c.time < parameters.shootDuration { 37 | p.velocity = .zero 38 | p.opacity = .zero 39 | } else { 40 | p.opacity = 1.0 41 | if p.velocity == .zero { 42 | p.velocity = .init(angle: .degrees(Double(i) * 7), magnitude: parameters.spread * Double.random(in: 0.1 ... 3.0)) 43 | } else { 44 | p.velocity.dx *= 0.98 45 | p.velocity.dy *= 0.98 46 | } 47 | } 48 | } 49 | } 50 | Emitter(every: 0.04) { 51 | Particle { 52 | RadialGradient(colors: [Color.yellow, .clear], center: .center, startRadius: 0.0, endRadius: 4.0) 53 | .clipShape(Circle()) 54 | .frame(width: 4.0, height: 4.0) 55 | } 56 | .lifetime(2.0) 57 | .transition(.scale, on: .death, duration: 1.0) 58 | .initialVelocity(xIn: -0.3 ... 0.3, yIn: -0.3 ... 0.3) 59 | .initialAcceleration(y: 0.003) 60 | .blur(in: 0.0 ... 5.0) 61 | .opacity(in: 0.2 ... 0.5) 62 | .blendMode(.plusLighter) 63 | } 64 | .maxSpawn(count: 30) 65 | .onUpdate { p, c in 66 | if p.position.y < c.size.height * 0.5 { 67 | p.position.y = c.size.height 68 | } 69 | } 70 | .lifetime(10) 71 | .initialPosition(.bottom) 72 | .initialVelocity { c in 73 | .init(dx: 0.0, dy: -0.01 * c.system.size.height / parameters.shootDuration) 74 | } 75 | .fixAcceleration(y: 0.05) 76 | } 77 | } 78 | 79 | internal struct Parameters { 80 | var shootDuration: TimeInterval 81 | var color: Color 82 | var spread: Double = 1.0 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Magic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Magic.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/22/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | public extension Preset { 13 | 14 | struct Magic: Entity, PresetEntry { 15 | 16 | var color: Color 17 | var spawnPoint: UnitPoint 18 | 19 | public var body: some Entity { 20 | Emitter(every: 0.03) { 21 | Particle { 22 | RadialGradient( 23 | colors: [color, .clear], 24 | center: .center, 25 | startRadius: 0.0, 26 | endRadius: 10.0 27 | ) 28 | .clipShape(Circle()) 29 | .frame(width: 15.0, height: 15.0) 30 | } 31 | .initialPosition(.center) 32 | .initialVelocity { c in 33 | .init(angle: .random(), magnitude: .random(in: 0.3 ... 0.5)) 34 | } 35 | .fixVelocity{ c in 36 | return .init(dx: c.proxy.velocity.dx + c.timeAlive * 0.02 * sin(5 * (c.proxy.seed.0 - 0.5) * c.timeAlive), dy: c.proxy.velocity.dy - c.timeAlive * 0.02 * cos(5 * (c.proxy.seed.1 - 0.5) * c.timeAlive)) 37 | } 38 | .blendMode(.plusLighter) 39 | .hueRotation(angleIn: .degrees(-10.0) ... .degrees(10.0)) 40 | .transition(.twinkle, on: .death, duration: 2.0) 41 | .transition(.opacity, on: .birth, duration: 0.5) 42 | .lifetime(3) 43 | .glow(color, radius: 4) 44 | } 45 | } 46 | 47 | public init( 48 | color: Color = .purple, 49 | spawnPoint: UnitPoint = .center 50 | ) { 51 | self.color = color 52 | self.spawnPoint = spawnPoint 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Rain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Rain.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/20/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | public extension Preset { 13 | 14 | struct Rain: Entity, PresetEntry { 15 | 16 | private var parameters: Parameters 17 | 18 | public init(lifetime: TimeInterval = 1.0, intensity: Int = 20, wind: CGFloat = 0.5) { 19 | self.parameters = .init(intensity: intensity, rainLifetime: lifetime, windVelocity: wind) 20 | } 21 | 22 | public var body: some Entity { 23 | Emitter(every: 1.0 / Double(parameters.intensity)) { 24 | Drop(parameters: parameters) 25 | } 26 | .emitAll() 27 | .initialPosition(.top) 28 | } 29 | 30 | public struct Drop: Entity { 31 | 32 | internal var parameters: Rain.Parameters 33 | 34 | public var body: some Entity { 35 | Particle { 36 | Rectangle().frame(width: 3.0, height: 12.0) 37 | .foregroundColor(.blue) 38 | } 39 | .initialOffset(withX: { c in 40 | let w = c.system.size.width * 0.5 41 | return .random(in: -w ... w) 42 | }) 43 | .initialPosition(.top) 44 | .initialVelocity(xIn: parameters.windVelocity +/- 1, yIn: 13 ... 15) 45 | .initialAcceleration(y: 0.1) 46 | .opacity(in: 0.5 ... 1.0) 47 | .transition(.opacity) 48 | .lifetime(parameters.rainLifetime) 49 | .initialRotation(.degrees(-5.0 * parameters.windVelocity)) 50 | .hueRotation(angleIn: .degrees(-10.0) ... .degrees(10.0)) 51 | .blendMode(.plusLighter) 52 | .scale { c in 53 | let s = CGFloat.random(in: 0.3 ... 1.0, seed: c.proxy.seed.0) 54 | return CGSize(width: s /** cos(0.1 * c.system.time + c.proxy.seed.1)*/, height: s) 55 | } 56 | } 57 | } 58 | 59 | internal struct Parameters { 60 | var intensity: Int 61 | var rainLifetime: TimeInterval 62 | var windVelocity: CGFloat 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Smoke.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Smoke.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 22.03.2024. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | public extension Preset { 13 | 14 | struct Smoke: Entity, PresetEntry { 15 | 16 | var color: Color 17 | var startRadius: CGFloat = 8.0 18 | var endRadius: CGFloat = 30.0 19 | var spawnPoint: UnitPoint 20 | var spawnRadius: CGSize 21 | var dirty: Bool = false 22 | 23 | private var velocityX: ClosedRange { 24 | switch spawnPoint { 25 | case .bottomLeading, .leading, .topLeading: 26 | return -6.0 ... -3.0 27 | case .topTrailing, .bottomTrailing, .trailing: 28 | return 3.0 ... 6.0 29 | default: 30 | return -0.5 ... 0.5 31 | } 32 | } 33 | 34 | private var velocityY: ClosedRange { 35 | switch spawnPoint { 36 | case .bottomLeading, .bottomTrailing, .bottom: 37 | return 1.0 ... 3.0 38 | case .leading, .trailing: 39 | return 0.0 ... 0.0 40 | default: 41 | return -3.0 ... 1.0 42 | } 43 | } 44 | 45 | private var velocityAccelerationY: CGFloat { 46 | switch spawnPoint { 47 | case .bottom, .bottomLeading, .bottomTrailing: 48 | return 0.02 49 | case .leading, .trailing: 50 | return 0 51 | default: 52 | return -0.02 53 | } 54 | } 55 | 56 | public var body: some Entity { 57 | Emitter(every: 0.01) { 58 | Particle { 59 | RadialGradient( 60 | colors: [color, .clear], 61 | center: .center, 62 | startRadius: startRadius, 63 | endRadius: endRadius 64 | ) 65 | .clipShape(Circle()) 66 | } 67 | .initialPosition(.center) 68 | .initialOffset(xIn: -spawnRadius.width ... spawnRadius.width/2, yIn: -spawnRadius.height/2 ... spawnRadius.height/2) 69 | .initialVelocity(xIn: velocityX, yIn: velocityY) 70 | .fixAcceleration(y: velocityAccelerationY) 71 | .lifetime(in: 3 +/- 0.2) 72 | .blendMode(dirty ? .hardLight : .normal) 73 | .transition(.scale, on: .death, duration: 0.5) 74 | .transition(.opacity, on: .birth) 75 | .opacity(0.3) 76 | } 77 | } 78 | 79 | public init( 80 | color: Color = Color(red: 128/255, green: 128/255, blue: 128/255, opacity: 1), 81 | dirty: Bool = false, 82 | spawnPoint: UnitPoint = .center, 83 | startRadius: CGFloat = 12.0, 84 | endRadius: CGFloat = 35.0, 85 | spawnRadius: CGSize = .init(width: 40.0, height: 8.0) 86 | ) { 87 | self.dirty = dirty 88 | self.color = color 89 | self.spawnPoint = spawnPoint 90 | self.startRadius = startRadius 91 | self.endRadius = endRadius 92 | self.spawnRadius = spawnRadius 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Snow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Snow.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/21/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | public extension Preset { 13 | 14 | struct Snow: Entity, PresetEntry { 15 | 16 | private var parameters: Parameters 17 | 18 | public init(size: CGFloat = 30.0, lifetime: TimeInterval = 5.0, intensity: Int = 20) { 19 | self.parameters = .init(intensity: intensity, snowSize: size, snowLifetime: lifetime) 20 | } 21 | 22 | public var body: some Entity { 23 | Emitter(every: 1.0 / Double(parameters.intensity)) { 24 | Flake(parameters: parameters) 25 | Drift(parameters: parameters) 26 | } 27 | .emitAll() 28 | .initialPosition(.top) 29 | } 30 | 31 | public struct Flake: Entity { 32 | 33 | internal var parameters: Snow.Parameters 34 | 35 | public var body: some Entity { 36 | Particle { 37 | Image("snow1", bundle: .module) 38 | .resizable() 39 | .aspectRatio(contentMode: .fill) 40 | .frame(width: parameters.snowSize, height: parameters.snowSize) 41 | } 42 | .initialOffset(withX: { c in 43 | let w = c.system.size.width * 0.5 44 | return .random(in: -w ... w) 45 | }) 46 | .initialPosition(.top) 47 | .initialVelocity(xIn: -1.0 ... 1.0, yIn: 0.2 ... 0.8) 48 | .fixAcceleration{ c in 49 | return CGVectorMake(0.005 * sin(c.proxy.seed.2 + c.system.time * 1.8), 0.01) 50 | } 51 | .opacity(in: 0.2 ... 0.8) 52 | .transition(.opacity, duration: 1.0) 53 | .colorOverlay(.init(red: 0.7, green: 0.9, blue: 0.9)) 54 | .initialTorque(angleIn: .degrees(-2.0) ... .degrees(2.0)) 55 | .hueRotation(angleIn: .degrees(-30.0) ... .degrees(30.0)) 56 | .blendMode(.plusLighter) 57 | .blur(in: 0.0 ... 2.0) 58 | .scale { c in 59 | let s = CGFloat.random(in: 0.3 ... 1.0, seed: c.proxy.seed.0) 60 | return CGSize(width: s /** cos(0.1 * c.system.time + c.proxy.seed.1)*/, height: s) 61 | } 62 | } 63 | } 64 | 65 | public struct Drift: Entity { 66 | 67 | internal var parameters: Snow.Parameters 68 | 69 | public var body: some Entity { 70 | Particle { 71 | RadialGradient(colors: [.white, .clear], center: .center, startRadius: 0.0, endRadius: 5.0) 72 | .frame(width: 10.0, height: 10.0) 73 | .clipShape(Circle()) 74 | } 75 | .initialOffset(withX: { c in 76 | let w = c.system.size.width * 0.5 77 | return .random(in: -w ... w) 78 | }) 79 | .initialPosition(.top) 80 | .initialVelocity(xIn: -4 ... 0.4, yIn: 1.0 ... 3.0) 81 | .fixAcceleration{ c in 82 | return CGVectorMake(0.04 * sin(c.proxy.seed.2 + c.system.time * 1.8), 0.007 + 0.008 * cos(c.system.time * 1.8)) 83 | } 84 | .opacity(in: 0.2 ... 0.8) 85 | .scale(factorIn: 0.2 ... 1.4) 86 | .transition(.scale) 87 | .lifetime(3) 88 | } 89 | } 90 | 91 | internal struct Parameters { 92 | var intensity: Int = 50 93 | var snowSize: CGFloat 94 | var snowLifetime: TimeInterval 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/API/Presets/Stars.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stars.swift 3 | // 4 | // 5 | // Created by Ben Myers on 1/21/24. 6 | // 7 | 8 | import SwiftUI 9 | import Particles 10 | import Foundation 11 | 12 | public extension Preset { 13 | 14 | struct Stars: Entity, PresetEntry { 15 | 16 | private var parameters: Parameters 17 | 18 | public init(size: CGFloat = 30.0, lifetime: TimeInterval = 5.0, intensity: Int = 20, twinkle: Bool = true) { 19 | self.parameters = .init(intensity: intensity, starSize: size, starLifetime: lifetime, twinkle: twinkle) 20 | } 21 | 22 | public var body: some Entity { 23 | Emitter(every: 1.0 / Double(parameters.intensity)) { 24 | Star(parameters: parameters) 25 | } 26 | .emitAll() 27 | } 28 | 29 | public struct Star: Entity { 30 | 31 | internal var parameters: Stars.Parameters 32 | 33 | public var body: some Entity { 34 | Particle { 35 | Image("sparkle", bundle: .module) 36 | .resizable() 37 | .aspectRatio(contentMode: .fill) 38 | .frame(width: parameters.starSize, height: parameters.starSize) 39 | } 40 | .initialPosition { c in 41 | let x = Int.random(in: 0 ... Int(c.system.size.width)) 42 | let y = Int.random(in: 0 ... Int(c.system.size.height)) 43 | return CGPoint(x: x, y: y) 44 | } 45 | .transition(.opacity, duration: 3.0) 46 | .opacity { c in 47 | return 0.5 + sin(c.timeAlive) 48 | } 49 | .scale(factorIn: 0.5 ... 1.0) 50 | .blendMode(.plusLighter) 51 | } 52 | } 53 | 54 | internal struct Parameters { 55 | var intensity: Int = 3 56 | var starSize: CGFloat 57 | var starLifetime: TimeInterval 58 | var twinkle: Bool = false 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Intermodular/PresetParameter+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresetParameter+.swift 3 | // 4 | // 5 | // Created by Ben Myers on 3/29/24. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | public protocol _PresetParameter { 12 | associatedtype V 13 | var wrappedValue: V { get set } 14 | var name: String { get set } 15 | var documentation: String? { get set } 16 | // var onUpdate: (V) -> Void { get set } 17 | mutating func setMirrorMetadata(_ name: String, _ documentation: String?) 18 | } 19 | 20 | internal extension _PresetParameter { 21 | @ViewBuilder 22 | var view: some View { 23 | if let single = wrappedValue as? _PresetParameterSingleValue { 24 | single.view(self) 25 | } 26 | } 27 | } 28 | 29 | public protocol _PresetParameterSingleValue { 30 | func view(_ v: any _PresetParameter) -> AnyView 31 | } 32 | public protocol _PresetParameterRangedValue: Comparable {} 33 | 34 | //extension Color: _PresetParameterSingleValue { 35 | // public func view(_ v: any _PresetParameter) -> AnyView { 36 | // .init(_ColorView(parameter: v)) 37 | // } 38 | //} 39 | 40 | //fileprivate struct _ColorView: View { 41 | // @State var color: Color 42 | // var parameter: any _PresetParameter 43 | // var body: some View { 44 | // ColorPicker(parameter.name, selection: $color) 45 | // .preference(key: _ContainerPreferenceKey.self, value: color) 46 | // } 47 | // init(parameter: any _PresetParameter) { 48 | // self.parameter = parameter 49 | // self._color = State(wrappedValue: Color.white) 50 | // if let colorParameter = parameter as? PresetParameter { 51 | // self.color = colorParameter.wrappedValue 52 | // } 53 | // } 54 | //} 55 | 56 | public struct _ContainerPreferenceKey: PreferenceKey { 57 | static public var defaultValue: V? { nil } 58 | static public func reduce(value: inout V?, nextValue: () -> V?) { 59 | if let nextValue = nextValue() { 60 | value = nextValue 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/circle.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "circle.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/circle.imageset/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Sources/ParticlesPresets/Resources/Assets.xcassets/circle.imageset/circle.png -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/flame.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "flame.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/flame.imageset/flame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Sources/ParticlesPresets/Resources/Assets.xcassets/flame.imageset/flame.png -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/snow1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "snow1.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/snow1.imageset/snow1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Sources/ParticlesPresets/Resources/Assets.xcassets/snow1.imageset/snow1.png -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/snow2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "snow2.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/snow2.imageset/snow2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Sources/ParticlesPresets/Resources/Assets.xcassets/snow2.imageset/snow2.png -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/sparkle.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "sparkle.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ParticlesPresets/Resources/Assets.xcassets/sparkle.imageset/sparkle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlmyers/swiftui-particles/baa0b6a664521689a8141e68579083d0122aa254/Sources/ParticlesPresets/Resources/Assets.xcassets/sparkle.imageset/sparkle.png --------------------------------------------------------------------------------