Skip to content

Instantly share code, notes, and snippets.

@callmephil
Created May 10, 2025 03:28
Show Gist options
  • Save callmephil/caaee27784b9aecc331379948988ebef to your computer and use it in GitHub Desktop.
Save callmephil/caaee27784b9aecc331379948988ebef to your computer and use it in GitHub Desktop.
3D Particle Cloud (Gemini)
import 'dart:math';
import 'package:flutter/material.dart';
// Main function to run the Flutter application
void main() {
runApp(const ParticleApp());
}
// Root widget of the application
class ParticleApp extends StatelessWidget {
const ParticleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '3D Particle Cloud Visualizer',
theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.dark,
sliderTheme: SliderThemeData(
activeTrackColor: Colors.cyanAccent.withOpacity(0.7),
inactiveTrackColor: Colors.grey.withOpacity(0.5),
thumbColor: Colors.cyanAccent,
overlayColor: Colors.cyanAccent.withAlpha(0x29),
valueIndicatorColor: Colors.cyanAccent,
activeTickMarkColor: Colors.transparent,
inactiveTickMarkColor: Colors.transparent,
),
),
home: const ParticleVisualizerScreen(),
debugShowCheckedModeBanner: false,
);
}
}
// Represents a single particle in the 3D cloud
class Particle {
Offset positionXY;
double positionZ;
Offset velocityXY;
double velocityZ;
Color color;
double radius;
double lifespan;
double initialLifespan;
Particle({
required this.positionXY,
required this.positionZ,
required this.velocityXY,
required this.velocityZ,
required this.color,
required this.radius,
required this.lifespan,
}) : initialLifespan = lifespan;
void update(
Size bounds,
Offset? attractorPointXY,
double attractorPointZ,
bool repel,
double focalDepth,
) {
positionXY += velocityXY;
positionZ += velocityZ;
velocityXY *= 0.98;
velocityZ *= 0.98;
if (attractorPointXY != null) {
double dx = attractorPointXY.dx - positionXY.dx;
double dy = attractorPointXY.dy - positionXY.dy;
double dz = attractorPointZ - positionZ;
double distanceSq = dx * dx + dy * dy + dz * dz;
double distance = sqrt(distanceSq);
if (distance > 1.0) {
double forceFactor = 1.0 / (distanceSq + 10.0);
double forceStrength = repel ? -75.0 : 50.0;
velocityXY += Offset(
dx * forceFactor * forceStrength,
dy * forceFactor * forceStrength,
);
velocityZ += dz * forceFactor * forceStrength;
}
}
double worldWidth = bounds.width * 1.5;
double worldHeight = bounds.height * 1.5;
if (positionXY.dx < -worldWidth / 2)
positionXY = Offset(worldWidth / 2, positionXY.dy);
if (positionXY.dx > worldWidth / 2)
positionXY = Offset(-worldWidth / 2, positionXY.dy);
if (positionXY.dy < -worldHeight / 2)
positionXY = Offset(positionXY.dx, worldHeight / 2);
if (positionXY.dy > worldHeight / 2)
positionXY = Offset(positionXY.dx, -worldHeight / 2);
double zBoundary = focalDepth * 2;
if (positionZ > zBoundary || positionZ < -zBoundary * 0.5) {
velocityZ *= -0.8;
positionZ = positionZ.clamp(-zBoundary * 0.5, zBoundary);
}
}
bool isAlive() => lifespan > 0;
}
class ParticleVisualizerScreen extends StatefulWidget {
const ParticleVisualizerScreen({super.key});
@override
State<ParticleVisualizerScreen> createState() =>
_ParticleVisualizerScreenState();
}
class _ParticleVisualizerScreenState extends State<ParticleVisualizerScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
List<Particle> _particles = [];
final Random _random = Random();
Size _screenSize = Size.zero;
Color _baseColor = Colors.cyan;
final List<Color> _colorPalette = [
Colors.cyan,
Colors.pinkAccent,
Colors.lightGreenAccent,
Colors.orangeAccent,
Colors.purpleAccent,
Colors.tealAccent,
Colors.redAccent,
];
int _currentColorIndex = 0;
Offset? _interactionPointXY;
double _interactionPointZ = 0;
bool _repelMode = true;
bool _attractorActive = true;
double focalLength = 300;
double focalDepth = 150;
double _animationTime = 0.0;
// NEW: State variables for sliders
double _particleCount = 150.0;
double _focalPointSizeFactor = 1.0;
double _numberOfStaticRings = 3.0;
@override
void initState() {
super.initState();
_controller =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 16),
)
..addListener(() {
_animationTime += 0.05;
if (_animationTime > 1000 * pi) _animationTime = 0;
_updateParticles();
if (mounted) setState(() {});
})
..repeat();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final newScreenSize = MediaQuery.of(context).size;
if (_screenSize != newScreenSize && newScreenSize != Size.zero) {
_screenSize = newScreenSize;
focalLength = _screenSize.width / 1.5;
focalDepth = _screenSize.width / 3;
if (_attractorActive) {
_interactionPointXY = _screenSize.center(Offset.zero);
_interactionPointZ = focalDepth / 2;
}
if (_particles.isEmpty) {
_initializeParticles(_particleCount.toInt());
}
}
}
void _initializeParticles(int count) {
if (_screenSize == Size.zero || !mounted) return;
_particles.clear();
for (int i = 0; i < count; i++) {
_particles.add(
_createParticle(Offset.zero, 0, burst: false, initialSpread: true),
);
}
}
Particle _createParticle(
Offset originXY,
double originZ, {
bool burst = false,
bool initialSpread = false,
}) {
if (_screenSize == Size.zero && !mounted) {
return Particle(
positionXY: Offset.zero,
positionZ: 0,
velocityXY: Offset.zero,
velocityZ: 0,
color: _baseColor,
radius: 2,
lifespan: 1.0,
);
}
final double angleXY = _random.nextDouble() * 2 * pi;
final double angleZ = _random.nextDouble() * 2 * pi;
double speedFactor = burst ? 6.0 : 2.5;
if (initialSpread) speedFactor = 0;
final double vz =
cos(angleZ) *
(_random.nextDouble() * speedFactor + (initialSpread ? 0.05 : 0.2));
final double xySpeed =
sin(angleZ) *
(_random.nextDouble() * speedFactor + (initialSpread ? 0.05 : 0.5));
final Offset vxy = Offset(cos(angleXY) * xySpeed, sin(angleXY) * xySpeed);
final double radius = _random.nextDouble() * 2.0 + 1.0;
final double lifespan = 1.0;
final HSLColor hslBase = HSLColor.fromColor(_baseColor);
final double hueShift = (_random.nextDouble() - 0.5) * 45;
final Color particleColor =
hslBase.withHue((hslBase.hue + hueShift) % 360.0).toColor();
Offset pxy = originXY;
double pz = originZ;
if (initialSpread) {
double r = _random.nextDouble() * focalLength * 0.8;
double phi = _random.nextDouble() * 2 * pi;
double theta = acos(2 * _random.nextDouble() - 1);
pxy = Offset(r * sin(theta) * cos(phi), r * sin(theta) * sin(phi));
pz = r * cos(theta);
}
return Particle(
positionXY: pxy,
positionZ: pz,
velocityXY: vxy,
velocityZ: vz,
color: particleColor,
radius: radius,
lifespan: lifespan,
);
}
void _updateParticles() {
if (!mounted || _screenSize == Size.zero) return;
List<Particle> nextGeneration = [];
for (var p in _particles) {
Offset? attractorWorldXY;
if (_attractorActive && _interactionPointXY != null) {
attractorWorldXY =
_interactionPointXY! - _screenSize.center(Offset.zero);
}
p.update(
_screenSize,
attractorWorldXY,
_interactionPointZ,
_repelMode,
focalDepth,
);
nextGeneration.add(p);
}
_particles = nextGeneration;
_particles.sort((a, b) => b.positionZ.compareTo(a.positionZ));
// Only add new particles if attractor is off and count is below target
// And current particle count is less than desired from slider
if (!_attractorActive &&
_particles.length < _particleCount.toInt() &&
_random.nextDouble() < 0.1) {
if (_particles.length < _particleCount.toInt() * 0.8) {
// Add more aggressively if far below target
for (int i = 0; i < 5; i++) {
_particles.add(
_createParticle(
Offset.zero,
_random.nextDouble() * focalDepth - focalDepth / 2,
burst: false,
initialSpread: true,
),
);
}
} else {
_particles.add(
_createParticle(
Offset.zero,
_random.nextDouble() * focalDepth - focalDepth / 2,
burst: false,
initialSpread: true,
),
);
}
}
}
void _handleTap(Offset tapPosition) {
if (!mounted) return;
_interactionPointXY = tapPosition;
Offset worldTapXY = tapPosition - _screenSize.center(Offset.zero);
if (!_attractorActive) {
for (int i = 0; i < 40; i++) {
_particles.add(
_createParticle(worldTapXY, _interactionPointZ, burst: true),
);
}
}
if (mounted) setState(() {});
}
void _handlePan(Offset panPosition) {
if (!mounted) return;
_interactionPointXY = panPosition;
Offset worldPanXY = panPosition - _screenSize.center(Offset.zero);
if (!_attractorActive) {
if (_random.nextDouble() < 0.4) {
_particles.add(
_createParticle(worldPanXY, _interactionPointZ, burst: false),
);
}
}
if (mounted) setState(() {});
}
void _changeBaseColor() {
if (!mounted) return;
setState(() {
_currentColorIndex = (_currentColorIndex + 1) % _colorPalette.length;
_baseColor = _colorPalette[_currentColorIndex];
});
}
void _toggleAttractorRepulsor() {
if (!mounted) return;
setState(() {
if (!_attractorActive) {
_attractorActive = true;
_repelMode = true;
_interactionPointXY ??= _screenSize.center(Offset.zero);
_interactionPointZ = focalDepth / 2;
} else if (_repelMode) {
_repelMode = false;
} else {
_attractorActive = false;
}
});
}
String get _attractorButtonText {
if (!_attractorActive) return "Activate Repel";
if (_repelMode) return "Switch to Attract";
return "Deactivate";
}
IconData get _attractorButtonIcon {
if (!_attractorActive) return Icons.arrow_circle_up_outlined;
if (_repelMode) return Icons.arrow_circle_down_outlined;
return Icons.power_settings_new_outlined;
}
void _resetParticles() {
if (!mounted) return;
setState(() {
_initializeParticles(_particleCount.toInt());
if (_attractorActive) {
_interactionPointXY = _screenSize.center(Offset.zero);
_interactionPointZ = focalDepth / 2;
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildSlider({
required String label,
required double value,
required double min,
required double max,
required int divisions,
required ValueChanged<double> onChanged,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 4.0),
child: Text(
'$label: ${value.toStringAsFixed(label == "Focal Size" ? 1 : 0)}',
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
Slider(
value: value,
min: min,
max: max,
divisions: divisions,
label: value.toStringAsFixed(label == "Focal Size" ? 1 : 0),
onChanged: onChanged,
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('3D Particle Cloud'),
elevation: 0,
backgroundColor: Colors.black.withOpacity(0.3),
),
backgroundColor: const Color(0xFF000010),
body: LayoutBuilder(
builder: (context, constraints) {
final newSize = Size(constraints.maxWidth, constraints.maxHeight);
if (_screenSize != newSize && newSize != Size.zero) {
_screenSize = newSize;
focalLength = _screenSize.width / 1.5;
focalDepth = _screenSize.width / 3;
if (_attractorActive || _particles.isEmpty) {
_interactionPointXY = _screenSize.center(Offset.zero);
_interactionPointZ = focalDepth / 2;
}
if (_particles.isEmpty)
_initializeParticles(_particleCount.toInt());
}
return GestureDetector(
onTapDown: (details) => _handleTap(details.localPosition),
onPanUpdate: (details) => _handlePan(details.localPosition),
child: CustomPaint(
painter: ParticlePainter(
particles: _particles,
screenSize: _screenSize,
focalLength: focalLength,
interactionPointXY: _interactionPointXY,
interactionPointZ: _interactionPointZ,
attractorActive: _attractorActive,
repelMode: _repelMode,
animationTime: _animationTime,
focalPointSizeFactor: _focalPointSizeFactor, // NEW
numberOfStaticRings: _numberOfStaticRings.toInt(), // NEW
),
child: Container(),
),
);
},
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: Padding(
padding: const EdgeInsets.all(8.0), // Reduced padding
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Wrap(
alignment: WrapAlignment.center,
spacing: 8.0,
runSpacing: 8.0,
children: <Widget>[
FloatingActionButton.extended(
onPressed: _changeBaseColor,
tooltip: 'Change Color',
icon: const Icon(Icons.color_lens_outlined),
label: Text(
_colorPalette[(_currentColorIndex + 1) %
_colorPalette.length]
.value
.toRadixString(16)
.padLeft(8, '0')
.substring(2)
.toUpperCase(),
),
heroTag: "fab_color_3d",
backgroundColor: _baseColor,
foregroundColor:
_baseColor.computeLuminance() > 0.5
? Colors.black
: Colors.white,
),
FloatingActionButton.extended(
onPressed: _toggleAttractorRepulsor,
tooltip: _attractorButtonText,
icon: Icon(_attractorButtonIcon),
label: Text(_attractorButtonText),
heroTag: "fab_attractor_3d",
),
FloatingActionButton(
onPressed: _resetParticles,
tooltip: 'Reset Particles',
child: const Icon(Icons.refresh),
heroTag: "fab_reset_3d",
),
],
),
SizedBox(height: 10),
Container(
padding: EdgeInsets.symmetric(vertical: 0), // Reduced padding
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
_buildSlider(
label: 'Particles',
value: _particleCount,
min: 20,
max: 2000,
divisions: (2000 - 20) ~/ 10, // e.g. 48 divisions
onChanged: (double value) {
setState(() {
_particleCount = value;
_initializeParticles(_particleCount.toInt());
});
},
),
_buildSlider(
label: 'Focal Size',
value: _focalPointSizeFactor,
min: 0.5,
max: 2.5,
divisions: ((2.5 - 0.5) / 0.1).round(), // 20 divisions
onChanged: (double value) {
setState(() {
_focalPointSizeFactor = value;
});
},
),
_buildSlider(
label: 'Rings',
value: _numberOfStaticRings,
min: 0,
max: 6,
divisions: 6,
onChanged: (double value) {
setState(() {
_numberOfStaticRings = value;
});
},
),
],
),
),
],
),
),
);
}
}
class ParticlePainter extends CustomPainter {
final List<Particle> particles;
final Size screenSize;
final double focalLength;
final Offset? interactionPointXY;
final double interactionPointZ;
final bool attractorActive;
final bool repelMode;
final double animationTime;
final double focalPointSizeFactor; // NEW
final int numberOfStaticRings; // NEW
ParticlePainter({
required this.particles,
required this.screenSize,
required this.focalLength,
this.interactionPointXY,
required this.interactionPointZ,
required this.attractorActive,
required this.repelMode,
required this.animationTime,
required this.focalPointSizeFactor, // NEW
required this.numberOfStaticRings, // NEW
});
@override
void paint(Canvas canvas, Size size) {
if (screenSize == Size.zero || focalLength <= 0) return;
final paint = Paint()..style = PaintingStyle.fill;
final Offset screenCenter = size.center(Offset.zero);
for (var p in particles) {
double pScale = focalLength / (focalLength + p.positionZ + 0.001);
double projectedX = p.positionXY.dx * pScale + screenCenter.dx;
double projectedY = p.positionXY.dy * pScale + screenCenter.dy;
double projectedRadius = p.radius * pScale;
if (projectedRadius < 0.2 || pScale < 0) continue;
paint.color = p.color.withOpacity(
p.color.opacity * pScale.clamp(0.1, 1.0),
);
canvas.drawCircle(
Offset(projectedX, projectedY),
max(0.5, projectedRadius),
paint,
);
}
if (attractorActive && interactionPointXY != null) {
Offset attractorWorldXY = interactionPointXY! - screenCenter;
double focalPointScale =
focalLength /
(focalLength +
interactionPointZ +
0.001); // Renamed to avoid confusion with pScale
double projectedX =
attractorWorldXY.dx * focalPointScale + screenCenter.dx;
double projectedY =
attractorWorldXY.dy * focalPointScale + screenCenter.dy;
if (focalPointScale > 0) {
final baseAttractorColor =
repelMode ? Colors.redAccent : Colors.greenAccent;
// Core
final double coreBaseSize = 8.0;
final double coreSize = max(
1.0,
(coreBaseSize * focalPointSizeFactor) * focalPointScale,
);
final attractorCorePaint =
Paint()
..color = baseAttractorColor.withOpacity(
focalPointScale.clamp(0.7, 1.0),
)
..style = PaintingStyle.fill;
canvas.drawCircle(
Offset(projectedX, projectedY),
coreSize,
attractorCorePaint,
);
// Rings
double ringBaseSize = 12.0 * focalPointSizeFactor * focalPointScale;
double ringSpacing = 8.0 * focalPointSizeFactor * focalPointScale;
double ringStrokeBase = 1.5 * focalPointSizeFactor * focalPointScale;
for (int i = 0; i < numberOfStaticRings; i++) {
double currentRingSize = ringBaseSize + (i * ringSpacing);
double currentStrokeWidth = max(
0.5,
ringStrokeBase * (1 - i / (numberOfStaticRings + 1) * 0.7),
); // Avoid division by zero if numberOfStaticRings is 0
double currentOpacity =
0.4 *
(1 - i / (numberOfStaticRings + 2)) *
focalPointScale.clamp(0.2, 1.0);
final ringPaint =
Paint()
..color = baseAttractorColor.withOpacity(currentOpacity)
..style = PaintingStyle.stroke
..strokeWidth = currentStrokeWidth;
if (currentRingSize > 0 && currentStrokeWidth > 0) {
// Ensure drawable
canvas.drawCircle(
Offset(projectedX, projectedY),
currentRingSize,
ringPaint,
);
}
}
// Wavy Animated Glow - Always visible and waving
double baseGlowRadiusValue =
(numberOfStaticRings > 0
? (ringBaseSize + (numberOfStaticRings * ringSpacing))
: ringBaseSize * 0.8) +
(ringSpacing * 0.5);
if (numberOfStaticRings == 0)
baseGlowRadiusValue =
coreBaseSize * 1.5 * focalPointSizeFactor * focalPointScale;
double waveAmplitudeBase = 4.0 * focalPointSizeFactor;
double waveAmplitudeModulation = 2.0 * focalPointSizeFactor;
double waveAmplitude =
(waveAmplitudeBase +
waveAmplitudeModulation * sin(animationTime * 0.7)) *
focalPointScale;
int numWaves =
5 + (sin(animationTime * 0.3) * 2).toInt(); // 3 to 7 waves
double waveSpeed = 1.2;
int numSegments = 72;
final glowPath = Path();
for (int i = 0; i <= numSegments; i++) {
double angle = (i / numSegments) * 2 * pi;
double radius =
baseGlowRadiusValue +
waveAmplitude * sin(numWaves * angle + waveSpeed * animationTime);
if (radius < 0) radius = 0; // Prevent negative radius
double pointX = projectedX + radius * cos(angle);
double pointY = projectedY + radius * sin(angle);
if (i == 0) {
glowPath.moveTo(pointX, pointY);
} else {
glowPath.lineTo(pointX, pointY);
}
}
glowPath.close();
double glowOpacityBase = 0.12;
double glowOpacityModulation = 0.08;
double glowFinalOpacity =
(glowOpacityBase +
glowOpacityModulation * sin(animationTime * 1.5)) *
focalPointScale.clamp(0.1, 1.0);
double glowStrokeBase = 2.5 * focalPointSizeFactor;
double glowStrokeModulation = 1.5 * focalPointSizeFactor;
double glowFinalStrokeWidth = max(
0.5,
(glowStrokeBase + glowStrokeModulation * sin(animationTime)) *
focalPointScale,
);
final glowPaint =
Paint()
..color = baseAttractorColor.withOpacity(
glowFinalOpacity.clamp(0.0, 1.0),
)
..style = PaintingStyle.stroke
..strokeWidth = glowFinalStrokeWidth;
if (baseGlowRadiusValue > 0 || waveAmplitude > 0) {
// Ensure path is meaningful
canvas.drawPath(glowPath, glowPaint);
}
}
}
}
@override
bool shouldRepaint(covariant ParticlePainter oldDelegate) {
return true; // Always repaint for animation and state changes via sliders
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment