Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active March 29, 2026 12:41
Show Gist options
  • Select an option

  • Save PlugFox/0a0ee8ba7dc3679b86f2d8b4aab01569 to your computer and use it in GitHub Desktop.

Select an option

Save PlugFox/0a0ee8ba7dc3679b86f2d8b4aab01569 to your computer and use it in GitHub Desktop.
Sunflower V2 — 100K animated dots on a single layer with spring physics
/*
* 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