A metallic particle effect demo built with SpriteKit and SwiftUI. Features dual color themes, touch interaction, and physics-based field simulation.
![]() |
![]() |
![]() |
This project was inspired by a metallic particle effect video posted by @alexwidua on X back in 2023.
Original: https://x.com/alexwidua/status/1702356241186476225
The implementation differs from the original, but aims to recreate a similar visual effect. Thanks to Alex Widua for the creative inspiration.
To recreate the effect, a few challenges needed solving:
- Metallic appearance — particles need depth and specular highlights
- Square coverage — particles must fill a square region uniformly
- Smooth color transitions — no hard cuts when switching themes
- Touch interaction — particles should repel from finger contact
A single SKEmitterNode with particlePositionRange produces particles that cluster at the center and thin out at edges.
The fix: divide the square into a grid, place an emitter in each cell. Every sub-region gets its own particle source, resulting in uniform coverage.
Single emitter: Grid emitters (4×4):
· · · · · ·
· * · → · · · ·
· · · · · ·
(center-heavy) · · · ·
(uniform)
Metallic surfaces have depth and specular reflection. This is simulated with three particle layers:
| Layer | Grid | Purpose |
|---|---|---|
| Base | 4×4 | Dark background, large particles |
| Mid | 5×5 | Main body, medium brightness |
| Highlight | 6×6 | Specular reflection, high brightness |
Each layer uses the same hue with different saturation/brightness. Stacked together with additive blending, they create the metallic look.
Real metal highlights aren't uniform — they concentrate around a focal point. An exponential falloff controls particle density:
let distance = sqrt(dx*dx + dy*dy) / maxDistance
let density = pow(1.0 - distance, gradientPower)
if density < 0.1 { continue } // skip sparse regionsgradientPower controls falloff steepness:
| Value | Behavior | Density at 0.5 | Density at 0.8 |
|---|---|---|---|
| 2.0 | Soft gradient | 25% | 4% |
| 3.0 | Moderate focus | 12.5% | 0.8% |
| 5.0 | Tight focus | 3% | 0.03% |
| 8.0 | Extreme focus | 0.4% | ≈0% |
Higher values produce tighter, more concentrated highlights.
Directly changing particleColor on emitters produces a jarring effect — existing particles keep the old color while new ones appear in the new color.
The workaround: trigger a turbulence burst to scatter particles, wait ~0.6s (peak chaos), then update the color. Old particles die off naturally while new particles emerge in the new color. No emitter rebuilding needed.
Tap color → Trigger turbulence → Wait 0.6s → Update color
↓
Old particles fade out
↓
New particles take over
SpriteKit's SKFieldNode applies forces to particles.
Finger tracking: A radialGravityField with negative strength becomes a repulsion field. Position follows the touch point.
let field = SKFieldNode.radialGravityField()
field.strength = -2.5 // negative = repel
field.position = touchLocationRipple trails: As the finger moves, small repulsion fields spawn along the path. Each expands and fades, creating ripple effects.
Finger path: ○ → ○ → ○ → ○ → ●(current)
↓ ↓ ↓ ↓
ripples expand and fade
If all particles respond to gravity, enabling it makes the entire square fall apart.
Solution: use fieldBitMask to make some particles ignore certain fields.
let respondsToGravity = random() > gravityVariation * 0.5
emitter.fieldBitMask = respondsToGravity ? 0xFFFFFFFF : 0xFFFFFFFBWith gravityVariation = 0.5, roughly half the particles fall while the rest stay put, creating a layered effect.
Three grids = 4×4 + 5×5 + 6×6 = 77 emitters, generating thousands of particles per second.
Optimizations:
- Gradient pruning: Cells with density < 0.1 don't create emitters
- Small textures: 4×4 pixel radial gradient
- Additive blending:
.addblend mode is GPU-friendly - Lifecycle balance: Tuned
particleLifetimeandparticleBirthRatefor stable particle count
┌─────────────────────────────────────────────────────┐
│ SwiftUI Layer │
│ ┌─────────────────────────────────────────────┐ │
│ │ ParticleHomeView │ │
│ │ - Top toolbar (reset / language) │ │
│ │ - Category buttons (Size/Layer/Field/Touch)│ │
│ │ - Settings panel (Liquid Glass UI) │ │
│ │ - Color picker │ │
│ └─────────────────────────────────────────────┘ │
│ ↓ SpriteView │
├─────────────────────────────────────────────────────┤
│ SpriteKit Layer │
│ ┌─────────────────────────────────────────────┐ │
│ │ ParticleHomeScene (SKScene) │ │
│ │ - SKEmitterNode × N (grid emitters) │ │
│ │ - SKFieldNode (gravity/turbulence/touch) │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
| Parameter | Description | Default | Range |
|---|---|---|---|
| Square Size | Particle region side length | 200 | 50-400 |
| Scale | Base particle size | 0.8 | 0.1-2.0 |
| Scale Range | Size randomization | 0.18 | 0-0.5 |
| Lifetime | Particle lifespan (seconds) | 2.5 | 0.5-10 |
| Lifetime Range | Lifespan randomization | 2.0 | 0-10 |
| Fade Speed | Alpha decay rate (negative) | -0.3 | -2~0 |
| Speed | Initial velocity | 3 | 0-50 |
| Speed Range | Velocity randomization | 3 | 0-30 |
| Highlight Gradient | Density falloff exponent | 3.0 | 0-8 |
| Highlight Target X/Y | Highlight focal point | 1.0/1.0 | -1~1 |
| Parameter | Description | Default |
|---|---|---|
| Base Alpha | Base layer opacity | 0.8 |
| Base Birth Rate | Base layer particles/sec | 3000 |
| Mid Alpha | Mid layer opacity | 0.7 |
| Mid Birth Rate | Mid layer particles/sec | 2400 |
| Highlight Alpha | Highlight layer opacity | 0.85 |
| Highlight Birth Rate | Highlight layer particles/sec | 8000 |
Higher birth rates = denser particles = higher GPU load.
| Parameter | Description | Default |
|---|---|---|
| Enabled | Toggle gravity field | Off |
| Sensor | Use gyroscope for direction | Off |
| Strength | Gravity magnitude | 0.2 |
| Direction X | Horizontal component (-1 left, 1 right) | 0 |
| Direction Y | Vertical component (-1 down, 1 up) | -1 |
| Variation | Fraction of particles affected | 1.0 |
| Parameter | Description | Default |
|---|---|---|
| Enabled | Toggle turbulence field | Off |
| Strength | Turbulence intensity | 5.0 |
| Smoothness | Noise smoothness | 0.5 |
| Animation Speed | Field change rate | 1.5 |
| Position X/Y | Field center | 0/0 |
| Radius | Effect radius (0 = infinite) | 0 |
| Parameter | Description | Default |
|---|---|---|
| Repel Radius | Touch influence radius | 10 |
| Touch Strength | Repulsion force (negative = repel) | -2.5 |
| Trail Strength | Ripple force along path | -1.5 |
| Ripple Spread | Max ripple radius multiplier | 5.0 |
| Recovery Speed | Ripple fade duration (seconds) | 0.15 |
| Trail Density | Ripple spawn interval (pixels) | 10 |
| Trail Taper | Ripple strength decay ratio | 0.30 |
| Expand Speed | Time to reach max radius (ratio) | 0.50 |
Supports English and Simplified Chinese via Apple's standard localization:
SpriteTestDemo/
├── en.lproj/
│ └── Localizable.strings
├── zh-Hans.lproj/
│ └── Localizable.strings
Language switches at runtime without restart.
TitaniumFlow/
├── TitaniumFlowApp.swift # Entry point
├── ContentView.swift # Root view
├── ParticleHomeScene.swift # Core implementation
│ ├── ParticleDefaults # Default constants
│ ├── LocalizationManager # Language switching
│ ├── ParticleColorTheme # Color themes
│ ├── ParticleHomeScene # SpriteKit scene
│ ├── ParticleSettings # Settings model
│ └── ParticleHomeView # Main SwiftUI view
├── en.lproj/ # English strings
├── zh-Hans.lproj/ # Chinese strings
└── Assets.xcassets/ # Assets
- iOS 26+
- SwiftUI
- SpriteKit
- CoreMotion
MIT


