├── .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 |
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
--------------------------------------------------------------------------------