Last active
March 29, 2026 12:41
-
-
Save PlugFox/0a0ee8ba7dc3679b86f2d8b4aab01569 to your computer and use it in GitHub Desktop.
Sunflower V2 — 100K animated dots on a single layer with spring physics
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Sunflower V2 — 100K animated dots on a single layer with spring physics. | |
| * https://gist.github.com/PlugFox/0a0ee8ba7dc3679b86f2d8b4aab01569 | |
| * https://dartpad.dev?id=0a0ee8ba7dc3679b86f2d8b4aab01569 | |
| * Mike Matiunin <plugfox@gmail.com>, 26 March 2026 | |
| */ | |
| //ignore_for_file: curly_braces_in_flow_control_structures | |
| import 'dart:async'; | |
| import 'dart:math' as math; | |
| import 'dart:typed_data'; | |
| import 'dart:ui' as ui show Gradient, PointMode; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart' show PipelineOwner; | |
| import 'package:flutter/scheduler.dart'; | |
| void main() => runZonedGuarded<void>(() { | |
| final chunks = ValueNotifier<int>(4); | |
| final inward = ValueNotifier<bool>(true); | |
| runApp(SunflowerApp(chunks: chunks, inward: inward)); | |
| }, (e, stackTrace) => print('Top level exception: $e')); | |
| class SunflowerApp extends StatelessWidget { | |
| const SunflowerApp({required this.chunks, required this.inward, super.key}); | |
| final ValueNotifier<int> chunks; | |
| final ValueNotifier<bool> inward; | |
| @override | |
| Widget build(BuildContext context) => MaterialApp( | |
| title: 'Sunflower', | |
| debugShowCheckedModeBanner: false, | |
| theme: ThemeData.dark(useMaterial3: true), | |
| home: const SunflowerScreen(), | |
| builder: | |
| (context, child) => MediaQuery.withNoTextScaling( | |
| child: child ?? const SizedBox.shrink(), | |
| ), | |
| ); | |
| } | |
| class SunflowerScreen extends StatelessWidget { | |
| const SunflowerScreen({super.key}); | |
| @override | |
| Widget build(BuildContext context) => const Scaffold( | |
| body: SafeArea( | |
| child: Padding( | |
| padding: EdgeInsets.all(16.0), | |
| // Main content is unconstrained square, | |
| // centered in the available space. | |
| child: SunflowerForm(), | |
| ), | |
| ), | |
| ); | |
| } | |
| class SunflowerForm extends StatelessWidget { | |
| const SunflowerForm({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| final app = context.findAncestorWidgetOfExactType<SunflowerApp>(); | |
| if (app == null) return const SizedBox.shrink(); | |
| final textTheme = TextTheme.of(context); | |
| return Column( | |
| mainAxisSize: MainAxisSize.max, | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| crossAxisAlignment: CrossAxisAlignment.stretch, | |
| spacing: 8.0, | |
| children: <Widget>[ | |
| // --- Sunflower (tap to toggle inward/outward) --- | |
| Expanded( | |
| child: GestureDetector( | |
| behavior: HitTestBehavior.opaque, | |
| onTap: () => app.inward.value = !app.inward.value, | |
| child: Center( | |
| child: SunflowerWidget( | |
| animationSpeed: 0.7, | |
| chunks: app.chunks, | |
| inward: app.inward, | |
| ), | |
| ), | |
| ), | |
| ), | |
| // --- Active chunks slider --- | |
| SizedBox( | |
| width: 480, | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.stretch, | |
| children: <Widget>[ | |
| Padding( | |
| padding: const EdgeInsets.symmetric(horizontal: 24.0), | |
| child: Text( | |
| 'Active chunks'.toUpperCase(), | |
| style: textTheme.labelLarge, | |
| maxLines: 1, | |
| overflow: TextOverflow.ellipsis, | |
| ), | |
| ), | |
| SizedBox( | |
| height: 48, | |
| child: ValueListenableBuilder<int>( | |
| valueListenable: app.chunks, | |
| builder: | |
| (context, value, _) => Slider( | |
| value: value.toDouble(), | |
| min: 1, | |
| max: 100, | |
| divisions: 99, | |
| label: | |
| '${value << _SeedChunk.kBits} dots ' | |
| '($value chunks)', | |
| onChanged: (v) => app.chunks.value = v.toInt(), | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // _SeedChunk — 1024 dots, owns all Float32List buffers. | |
| // --------------------------------------------------------------------------- | |
| class _SeedChunk { | |
| _SeedChunk(this.offset, math.Random rng) | |
| : drawBuffer = Float32List(kSize << 1), | |
| _velocities = Float32List(kSize << 1), | |
| _targets = Float32List(kSize << 1), | |
| _speeds = Float32List(kSize) { | |
| for (var i = 0; i < kSize; i++) { | |
| // Per-dot speed multiplier in [0.2 .. 1.8] via fast int path. | |
| _speeds[i] = 0.2 + (rng.nextInt(0xFFFF) & 0xFFFF) * (1.6 / 0xFFFF); | |
| } | |
| } | |
| static const kBits = 10; | |
| static const kSize = 1 << kBits; // 1024 | |
| /// Global seed index of the first dot. | |
| final int offset; | |
| /// Current x,y pairs — fed straight to [Canvas.drawRawPoints]. | |
| final Float32List drawBuffer; | |
| final Float32List _velocities; | |
| final Float32List _targets; | |
| final Float32List _speeds; // random per-dot multiplier, set once | |
| /// Damped-spring step. Returns max |v|² (for settled detection). | |
| /// | |
| /// Inner loop: 4 mul + 1 sub + 2 add per axis per dot. | |
| double tick(double dt, double stiffnessDt, double dampFactor) { | |
| final buf = drawBuffer; | |
| final vel = _velocities; | |
| final tgt = _targets; | |
| final spd = _speeds; | |
| double maxVSq = 0.0; | |
| for (var i = 0; i < kSize; i++) { | |
| final i2 = i << 1; | |
| final sDt = spd[i] * stiffnessDt; | |
| // X — symplectic Euler (velocity first, then position) | |
| var vx = vel[i2] * dampFactor + (tgt[i2] - buf[i2]) * sDt; | |
| buf[i2] += vx * dt; | |
| vel[i2] = vx; | |
| // Y | |
| final iy = i2 | 1; | |
| var vy = vel[iy] * dampFactor + (tgt[iy] - buf[iy]) * sDt; | |
| buf[iy] += vy * dt; | |
| vel[iy] = vy; | |
| final vSq = vx * vx + vy * vy; | |
| if (vSq > maxVSq) maxVSq = vSq; | |
| } | |
| return maxVSq; | |
| } | |
| /// Recompute target positions (spiral or circle). | |
| void computeTargets({ | |
| required bool inward, | |
| required int totalDots, | |
| required double spiralScale, | |
| required double circleRadius, | |
| }) { | |
| const tau = math.pi * 2; | |
| final phi = (math.sqrt(5) + 1) / 2; | |
| final tgt = _targets; | |
| final maxR = math.sqrt(totalDots); | |
| final sFactor = maxR > 0 ? spiralScale / (maxR * 40) : 0.0; | |
| final invN1 = totalDots > 1 ? 1.0 / (totalDots - 1) : 0.0; | |
| for (var i = 0; i < kSize; i++) { | |
| final gi = offset + i; | |
| final i2 = i << 1; | |
| if (inward) { | |
| final theta = gi * tau / phi; | |
| final r = math.sqrt(gi) * sFactor; | |
| tgt[i2] = r * math.cos(theta); | |
| tgt[i2 | 1] = -r * math.sin(theta); | |
| } else { | |
| final angle = tau * gi * invN1; | |
| tgt[i2] = math.cos(angle) * circleRadius; | |
| tgt[i2 | 1] = math.sin(angle) * circleRadius; | |
| } | |
| } | |
| } | |
| /// Snap to targets instantly, zero all velocities. | |
| void snapToTargets() { | |
| drawBuffer.setAll(0, _targets); | |
| _velocities.fillRange(0, _velocities.length, 0); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Widget | |
| // --------------------------------------------------------------------------- | |
| class SunflowerWidget extends LeafRenderObjectWidget { | |
| const SunflowerWidget({ | |
| required this.chunks, | |
| required this.inward, | |
| this.animationSpeed = 1.0, | |
| this.edgePadding = 0.15, | |
| super.key, | |
| }); | |
| final ValueNotifier<int> chunks; | |
| final ValueNotifier<bool> inward; | |
| /// Multiplier for spring stiffness. 1.0 = default (~0.8 s settle), | |
| /// 0.5 = ~1.4× slower, 2.0 = ~1.4× faster. | |
| final double animationSpeed; | |
| /// Fraction of the radius reserved as padding so overshooting dots | |
| /// don't clip at the mask edge. 0.0 = no padding, 0.2 = 20 % inset. | |
| final double edgePadding; | |
| @override | |
| RenderObject createRenderObject(BuildContext context) => | |
| _SunflowerRenderObject( | |
| chunks: chunks, | |
| inward: inward, | |
| animationSpeed: animationSpeed, | |
| edgePadding: edgePadding, | |
| ); | |
| @override | |
| void updateRenderObject(BuildContext context, RenderObject renderObject) { | |
| if (renderObject case _SunflowerRenderObject ro) | |
| ro.update( | |
| chunks: chunks, | |
| inward: inward, | |
| animationSpeed: animationSpeed, | |
| edgePadding: edgePadding, | |
| ); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // RenderObject — chunk pool, ticker, spring physics, async budget. | |
| // --------------------------------------------------------------------------- | |
| class _SunflowerRenderObject extends RenderBox { | |
| _SunflowerRenderObject({ | |
| required ValueNotifier<int> chunks, | |
| required ValueNotifier<bool> inward, | |
| required double animationSpeed, | |
| required double edgePadding, | |
| }) : _chunksN = chunks, | |
| _inwardN = inward, | |
| _animationSpeed = animationSpeed, | |
| _edgePadding = edgePadding; | |
| // -- spring tuning (underdamped → ~30 % overshoot, settles in ~0.8 s) ----- | |
| static const _kBaseStiffness = 200.0; | |
| static const _kDamping = 12.0; | |
| static const _kSettled = 0.05; // velocity² threshold | |
| static const _kBudgetUs = 8000; // 8 ms frame budget | |
| // -- widget params -------------------------------------------------------- | |
| ValueNotifier<int> _chunksN; | |
| ValueNotifier<bool> _inwardN; | |
| double _animationSpeed; | |
| double _edgePadding; | |
| // -- chunk pool ----------------------------------------------------------- | |
| final List<_SeedChunk> _pool = []; | |
| int _active = 0; | |
| final math.Random _rng = math.Random(42); | |
| // -- animation ------------------------------------------------------------ | |
| Ticker? _ticker; | |
| Duration _prev = Duration.zero; | |
| bool _busy = false; | |
| bool _restart = false; | |
| final Stopwatch _sw = Stopwatch(); | |
| // -- hint text ------------------------------------------------------------ | |
| bool _hintVisible = true; // shown until first tap | |
| double _hintOpacity = 1.0; | |
| // -- paint cache ---------------------------------------------------------- | |
| List<Paint> _chunkPaints = const []; // flat colour, no shader | |
| Paint? _radialMask; // single dstIn radial alpha gradient | |
| Rect _bounds = Rect.zero; | |
| int _paintedActive = -1; | |
| Size _paintedSize = Size.zero; | |
| // -- layout --------------------------------------------------------------- | |
| Size _sz = Size.zero; | |
| bool _first = true; | |
| int get _totalDots => _active << _SeedChunk.kBits; | |
| @override | |
| bool get isRepaintBoundary => true; | |
| @override | |
| bool get sizedByParent => true; | |
| // ========================================================================= | |
| // Lifecycle | |
| // ========================================================================= | |
| @override | |
| void attach(PipelineOwner owner) { | |
| super.attach(owner); | |
| PaintingBinding.instance.systemFonts.addListener(_onSystemFontsChange); | |
| _chunksN.addListener(_onChunks); | |
| _inwardN.addListener(_onDirection); | |
| _syncPool(); | |
| } | |
| @override | |
| void detach() { | |
| _ticker?.dispose(); | |
| _ticker = null; | |
| _chunksN.removeListener(_onChunks); | |
| _inwardN.removeListener(_onDirection); | |
| PaintingBinding.instance.systemFonts.removeListener(_onSystemFontsChange); | |
| super.detach(); | |
| } | |
| void _onSystemFontsChange() { | |
| if (attached) markNeedsPaint(); | |
| } | |
| void update({ | |
| required ValueNotifier<int> chunks, | |
| required ValueNotifier<bool> inward, | |
| required double animationSpeed, | |
| required double edgePadding, | |
| }) { | |
| _animationSpeed = animationSpeed; | |
| _edgePadding = edgePadding; | |
| if (!identical(_chunksN, chunks)) { | |
| _chunksN.removeListener(_onChunks); | |
| _chunksN = chunks..addListener(_onChunks); | |
| } | |
| if (!identical(_inwardN, inward)) { | |
| _inwardN.removeListener(_onDirection); | |
| _inwardN = inward..addListener(_onDirection); | |
| } | |
| } | |
| // ========================================================================= | |
| // Notifier callbacks | |
| // ========================================================================= | |
| void _onChunks() { | |
| if (_busy) { | |
| _restart = true; | |
| return; | |
| } | |
| _syncPool(); | |
| _recomputeTargets(); | |
| _kick(); | |
| } | |
| void _onDirection() { | |
| _hintVisible = false; // hide after first interaction | |
| if (_busy) { | |
| _restart = true; | |
| return; | |
| } | |
| _recomputeTargets(); | |
| // Velocities survive → old impulse decays / reverses naturally. | |
| _kick(); | |
| } | |
| // ========================================================================= | |
| // Pool | |
| // ========================================================================= | |
| void _syncPool() { | |
| final want = _chunksN.value; | |
| while (_pool.length < want) { | |
| _pool.add(_SeedChunk(_pool.length << _SeedChunk.kBits, _rng)); | |
| } | |
| _active = want; | |
| } | |
| // ========================================================================= | |
| // Targets | |
| // ========================================================================= | |
| void _recomputeTargets() { | |
| if (_sz.isEmpty) return; | |
| final total = _totalDots; | |
| final half = _sz.shortestSide / 2; | |
| final outerLimit = 0.9 - _edgePadding; // e.g. 0.9 - 0.15 = 0.75 | |
| final spiral = 0.5 * outerLimit * half; | |
| final circle = outerLimit * half; | |
| final inward = _inwardN.value; | |
| for (var i = 0; i < _active; i++) { | |
| _pool[i].computeTargets( | |
| inward: inward, | |
| totalDots: total, | |
| spiralScale: spiral, | |
| circleRadius: circle, | |
| ); | |
| } | |
| } | |
| void _snapAll() { | |
| for (var i = 0; i < _active; i++) _pool[i].snapToTargets(); | |
| markNeedsPaint(); | |
| } | |
| // ========================================================================= | |
| // Ticker / physics | |
| // ========================================================================= | |
| void _kick() { | |
| final t = _ticker ??= Ticker(_onTick, debugLabel: 'Sunflower'); | |
| if (!t.isActive) { | |
| _prev = Duration.zero; | |
| t.start(); | |
| } | |
| } | |
| void _onTick(Duration elapsed) { | |
| if (_busy || !attached) return; | |
| final dtUs = (elapsed - _prev).inMicroseconds; | |
| _prev = elapsed; | |
| final dt = (dtUs * 0.000001).clamp(0.0, 0.033); | |
| if (dt <= 0) return; | |
| _physics(dt); | |
| } | |
| Future<void> _physics(double dt) async { | |
| _busy = true; | |
| _sw | |
| ..reset() | |
| ..start(); | |
| // Pre-compute outside the per-chunk / per-dot loops. | |
| final stiffnessDt = _kBaseStiffness * _animationSpeed * dt; | |
| final dampFactor = 1.0 - _kDamping * dt; | |
| double peak = 0.0; | |
| for (var ci = 0; ci < _active; ci++) { | |
| if (_restart) break; | |
| final v = _pool[ci].tick(dt, stiffnessDt, dampFactor); | |
| if (v > peak) peak = v; | |
| // Yield to event loop after 8 ms so pending paints can flush. | |
| if (_sw.elapsedMicroseconds >= _kBudgetUs) { | |
| markNeedsPaint(); | |
| await Future<void>.delayed(Duration.zero); | |
| if (!attached) { | |
| _busy = false; | |
| return; | |
| } | |
| _sw | |
| ..reset() | |
| ..start(); | |
| } | |
| } | |
| markNeedsPaint(); | |
| if (peak < _kSettled && !_restart) _ticker?.stop(); | |
| _busy = false; | |
| if (_restart) { | |
| _restart = false; | |
| _syncPool(); | |
| _recomputeTargets(); | |
| _kick(); | |
| } | |
| } | |
| // ========================================================================= | |
| // Layout | |
| // ========================================================================= | |
| @override | |
| Size computeDryLayout(BoxConstraints constraints) => | |
| Size.square(constraints.biggest.shortestSide); | |
| @override | |
| void performResize() { | |
| final s = size = computeDryLayout(constraints); | |
| if (s != _sz) { | |
| _sz = s; | |
| _recomputeTargets(); | |
| if (_first) { | |
| _first = false; | |
| _snapAll(); | |
| } else { | |
| _kick(); | |
| } | |
| } | |
| } | |
| @override | |
| void performLayout() { | |
| // No children, so nothing to layout. | |
| } | |
| // ========================================================================= | |
| // Paint | |
| // ========================================================================= | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| if (_active == 0) return; | |
| final canvas = context.canvas; | |
| canvas.save(); | |
| canvas.translate(offset.dx + _sz.width / 2, offset.dy + _sz.height / 2); | |
| _ensureChunkPaints(); | |
| // Off-screen layer — one radial mask composites everything in a single pass. | |
| canvas.saveLayer(_bounds, _kLayerPaint); | |
| for (var ci = 0; ci < _active; ci++) | |
| canvas.drawRawPoints( | |
| ui.PointMode.points, | |
| _pool[ci].drawBuffer, | |
| _chunkPaints[ci], | |
| ); | |
| // Radial alpha mask: center → transparent, edge → opaque. | |
| if (_radialMask case final mask?) canvas.drawRect(_bounds, mask); | |
| canvas.restore(); // saveLayer | |
| // Debug: red border when physics is still computing (throttled frame). | |
| if (_busy) | |
| canvas.drawRect( | |
| Rect.fromCenter( | |
| center: Offset.zero, | |
| width: _sz.width - 2, | |
| height: _sz.height - 2, | |
| ), | |
| _throttlePaint, | |
| ); | |
| _paintHint(canvas); | |
| canvas.restore(); // translate | |
| } | |
| /// "Tap to animate" hint — fades out after first interaction. | |
| void _paintHint(Canvas canvas) { | |
| if (_hintOpacity <= 0.0) return; | |
| if (!_hintVisible) _hintOpacity = (_hintOpacity - 0.02).clamp(0.0, 1.0); | |
| final tp = TextPainter( | |
| text: TextSpan( | |
| text: 'Tap to animate', | |
| style: TextStyle( | |
| color: Colors.white.withValues(alpha: _hintOpacity), | |
| fontSize: 24, | |
| fontWeight: FontWeight.w300, | |
| letterSpacing: 3, | |
| overflow: TextOverflow.clip, | |
| ), | |
| ), | |
| textDirection: TextDirection.ltr, | |
| maxLines: 3, | |
| textAlign: TextAlign.center, | |
| )..layout(maxWidth: _sz.width * 0.8); | |
| tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2)); | |
| } | |
| /// Rebuild flat-colour Paints + single radial mask when active/size changes. | |
| void _ensureChunkPaints() { | |
| if (_paintedActive == _active && _paintedSize == _sz) return; | |
| _paintedActive = _active; | |
| _paintedSize = _sz; | |
| final total = _totalDots; | |
| final sw = ((20.0 / math.sqrt(total)).clamp(1.0, 16.0)) * 2; | |
| const primaries = Colors.primaries; | |
| _bounds = Rect.fromLTWH( | |
| -_sz.width / 2, | |
| -_sz.height / 2, | |
| _sz.width, | |
| _sz.height, | |
| ); | |
| // Flat-colour Paints — no shader. | |
| _chunkPaints = List<Paint>.generate(_active, (ci) { | |
| return Paint() | |
| ..color = primaries[ci % primaries.length] | |
| ..strokeCap = StrokeCap.round | |
| ..blendMode = BlendMode.src | |
| ..isAntiAlias = false | |
| ..strokeWidth = sw; | |
| }, growable: false); | |
| // Single radial alpha mask (dstIn). | |
| final radius = _sz.shortestSide * 0.45; | |
| _radialMask = | |
| Paint() | |
| ..blendMode = BlendMode.dstIn | |
| ..isAntiAlias = false | |
| ..shader = ui.Gradient.radial( | |
| Offset.zero, | |
| radius, | |
| [const Color(0x00FFFFFF), const Color(0xFFFFFFFF)], | |
| [0.0, 1.0], | |
| TileMode.clamp, | |
| ); | |
| } | |
| static final _kLayerPaint = Paint(); | |
| static final _throttlePaint = | |
| Paint() | |
| ..color = const Color(0xFFFF0000) | |
| ..style = PaintingStyle.stroke | |
| ..blendMode = BlendMode.srcOver | |
| ..strokeWidth = 6.0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment