| Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 7h ago | 1 | // A Mandelbrot explorer whose pixels are computed in V. |
| 2 | // |
| 3 | // What this is actually demonstrating: |
| 4 | // * bulk data across the FFI boundary — a full RGBA frame per render, |
| 5 | // into a buffer Dart allocates and V fills in place (nothing to vf_free) |
| 6 | // * band parallelism — the image is split into horizontal strips rendered |
| 7 | // on separate isolates, which is safe because the library is -gc none |
| 8 | // * the same algorithm written twice, in V and in Dart, so the cost of the |
| 9 | // bridge is a number on screen rather than a claim |
| 10 | import 'dart:async'; |
| 11 | import 'dart:math' as math; |
| 12 | import 'dart:typed_data'; |
| 13 | import 'dart:ui' as ui; |
| 14 | |
| 15 | import 'package:flutter/gestures.dart'; |
| 16 | import 'package:flutter/material.dart'; |
| 17 | import 'package:vflutter_ffi/vflutter_ffi.dart' as v; |
| 18 | |
| 19 | import 'mandelbrot_dart.dart'; |
| 20 | |
| 21 | void main() => runApp(const ExampleApp()); |
| 22 | |
| 23 | class ExampleApp extends StatelessWidget { |
| 24 | const ExampleApp({super.key}); |
| 25 | |
| 26 | @override |
| 27 | Widget build(BuildContext context) => MaterialApp( |
| 28 | title: 'vflutter_ffi', |
| 29 | debugShowCheckedModeBanner: false, |
| 30 | theme: ThemeData.dark(useMaterial3: true).copyWith( |
| 31 | scaffoldBackgroundColor: const Color(0xFF12141A), |
| 32 | ), |
| 33 | home: const ExplorerPage(), |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | class ExplorerPage extends StatefulWidget { |
| 38 | const ExplorerPage({super.key}); |
| 39 | |
| 40 | @override |
| 41 | State<ExplorerPage> createState() => _ExplorerPageState(); |
| 42 | } |
| 43 | |
| 44 | class _ExplorerPageState extends State<ExplorerPage> { |
| 45 | static const int _renderWidth = 720; |
| 46 | static const int _renderHeight = 540; |
| 47 | |
| 48 | v.FractalView _view = const v.FractalView( |
| 49 | width: _renderWidth, |
| 50 | height: _renderHeight, |
| 51 | maxIter: 400, |
| 52 | ); |
| 53 | |
| 54 | ui.Image? _image; |
| 55 | bool _rendering = false; |
| 56 | bool _renderQueued = false; |
| 57 | int _lastMs = 0; |
| 58 | int _tiles = 4; |
| 59 | _Benchmark? _benchmark; |
| 60 | |
| 61 | @override |
| 62 | void initState() { |
| 63 | super.initState(); |
| 64 | v.ensureInitialized(); |
| 65 | _render(); |
| 66 | } |
| 67 | |
| 68 | @override |
| 69 | void dispose() { |
| 70 | _image?.dispose(); |
| 71 | super.dispose(); |
| 72 | } |
| 73 | |
| 74 | /// Renders the current view, coalescing requests that arrive mid-frame. |
| 75 | /// |
| 76 | /// A wheel gesture emits far more events than we can render. Dropping them |
| 77 | /// outright would leave the view mutated but never drawn, so instead the |
| 78 | /// latest request is remembered and serviced once the in-flight frame lands. |
| 79 | Future<void> _render() async { |
| 80 | if (_rendering) { |
| 81 | _renderQueued = true; |
| 82 | return; |
| 83 | } |
| 84 | setState(() => _rendering = true); |
| 85 | |
| 86 | do { |
| 87 | _renderQueued = false; |
| 88 | final view = _view; |
| 89 | |
| 90 | final watch = Stopwatch()..start(); |
| 91 | final pixels = _tiles > 1 |
| 92 | ? await v.renderParallel(view, tiles: _tiles) |
| 93 | : v.render(view); |
| 94 | watch.stop(); |
| 95 | |
| 96 | final image = await _decode(pixels, view.width, view.height); |
| 97 | if (!mounted) { |
| 98 | image.dispose(); |
| 99 | return; |
| 100 | } |
| 101 | setState(() { |
| 102 | _image?.dispose(); |
| 103 | _image = image; |
| 104 | _lastMs = watch.elapsedMilliseconds; |
| 105 | }); |
| 106 | } while (_renderQueued); |
| 107 | |
| 108 | if (!mounted) return; |
| 109 | setState(() => _rendering = false); |
| 110 | } |
| 111 | |
| 112 | static Future<ui.Image> _decode(Uint8List rgba, int w, int h) { |
| 113 | final completer = Completer<ui.Image>(); |
| 114 | ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888, |
| 115 | completer.complete); |
| 116 | return completer.future; |
| 117 | } |
| 118 | |
| 119 | /// Zooms about a point, keeping that point under the cursor. |
| 120 | void _zoomAt(Offset local, Size widgetSize, double factor) { |
| 121 | if (widgetSize.isEmpty) return; |
| 122 | setState(() { |
| 123 | _view = _view.zoomedAt( |
| 124 | local.dx / widgetSize.width, |
| 125 | local.dy / widgetSize.height, |
| 126 | factor, |
| 127 | ); |
| 128 | }); |
| 129 | _render(); |
| 130 | } |
| 131 | |
| 132 | /// Restores the starting framing. |
| 133 | /// |
| 134 | /// Only the pan/zoom is reset. Width and height belong to the canvas (see |
| 135 | /// _fitToCanvas) and maxIter belongs to its slider — clobbering those made |
| 136 | /// "Reset view" silently undo settings the button does not own, and briefly |
| 137 | /// render at the wrong aspect ratio before the canvas corrected it. |
| 138 | void _reset() { |
| 139 | const home = v.FractalView(width: 1, height: 1); |
| 140 | setState(() { |
| 141 | _view = _view.copyWith( |
| 142 | centerX: home.centerX, |
| 143 | centerY: home.centerY, |
| 144 | scale: home.scale, |
| 145 | ); |
| 146 | }); |
| 147 | _render(); |
| 148 | } |
| 149 | |
| 150 | /// Renders the identical frame in V and in Dart on this isolate, so the |
| 151 | /// comparison is like-for-like rather than V-plus-parallelism vs Dart. |
| 152 | Future<void> _runBenchmark() async { |
| 153 | setState(() { |
| 154 | _rendering = true; |
| 155 | _benchmark = null; |
| 156 | }); |
| 157 | |
| 158 | final swV = Stopwatch()..start(); |
| 159 | v.render(_view); |
| 160 | swV.stop(); |
| 161 | |
| 162 | final swP = Stopwatch()..start(); |
| 163 | await v.renderParallel(_view, tiles: _tiles); |
| 164 | swP.stop(); |
| 165 | |
| 166 | final swD = Stopwatch()..start(); |
| 167 | renderDart(_view); |
| 168 | swD.stop(); |
| 169 | |
| 170 | if (!mounted) return; |
| 171 | setState(() { |
| 172 | _rendering = false; |
| 173 | _benchmark = _Benchmark( |
| 174 | vSerialMs: swV.elapsedMilliseconds, |
| 175 | vParallelMs: swP.elapsedMilliseconds, |
| 176 | dartMs: swD.elapsedMilliseconds, |
| 177 | tiles: _tiles, |
| 178 | ); |
| 179 | }); |
| 180 | } |
| 181 | |
| 182 | @override |
| 183 | Widget build(BuildContext context) { |
| 184 | return Scaffold( |
| 185 | body: SafeArea( |
| 186 | child: LayoutBuilder( |
| 187 | builder: (context, constraints) { |
| 188 | final wide = constraints.maxWidth > 900; |
| 189 | final canvas = _buildCanvas(); |
| 190 | final controls = _buildControls(); |
| 191 | return wide |
| 192 | ? Row( |
| 193 | crossAxisAlignment: CrossAxisAlignment.stretch, |
| 194 | children: [ |
| 195 | Expanded(child: canvas), |
| 196 | SizedBox( |
| 197 | width: 340, |
| 198 | child: SingleChildScrollView(child: controls), |
| 199 | ), |
| 200 | ], |
| 201 | ) |
| 202 | : ListView( |
| 203 | children: [ |
| 204 | AspectRatio( |
| 205 | aspectRatio: _renderWidth / _renderHeight, |
| 206 | child: canvas, |
| 207 | ), |
| 208 | controls, |
| 209 | ], |
| 210 | ); |
| 211 | }, |
| 212 | ), |
| 213 | ), |
| 214 | ); |
| 215 | } |
| 216 | |
| 217 | /// Keeps the rendered buffer at the canvas's aspect ratio. |
| 218 | /// |
| 219 | /// Without this the image would be letterboxed or cropped, and the |
| 220 | /// click-to-zoom maths — which maps widget coordinates straight into the |
| 221 | /// complex plane — would point at the wrong place. |
| 222 | void _fitToCanvas(Size size) { |
| 223 | if (size.isEmpty) return; |
| 224 | final height = (_renderWidth * size.height / size.width).round(); |
| 225 | if ((height - _view.height).abs() <= 2) return; |
| 226 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 227 | if (!mounted) return; |
| 228 | setState(() => _view = _view.copyWith(height: height)); |
| 229 | _render(); |
| 230 | }); |
| 231 | } |
| 232 | |
| 233 | Widget _buildCanvas() { |
| 234 | return LayoutBuilder( |
| 235 | builder: (context, constraints) { |
| 236 | final size = Size(constraints.maxWidth, constraints.maxHeight); |
| 237 | _fitToCanvas(size); |
| 238 | return Listener( |
| 239 | // Wheel zoom, anchored at the cursor like the tap zoom. One notch is |
| 240 | // a small step so a scroll feels continuous rather than octave-wise; |
| 241 | // the render loop coalesces whatever it cannot keep up with. |
| 242 | onPointerSignal: (event) { |
| 243 | if (event is! PointerScrollEvent) return; |
| 244 | final dy = event.scrollDelta.dy; |
| 245 | if (dy == 0) return; |
| 246 | _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble()); |
| 247 | }, |
| 248 | child: GestureDetector( |
| 249 | onTapDown: (d) => _zoomAt(d.localPosition, size, 0.5), |
| 250 | onSecondaryTapDown: (d) => _zoomAt(d.localPosition, size, 2.0), |
| 251 | child: Stack( |
| 252 | fit: StackFit.expand, |
| 253 | children: [ |
| 254 | if (_image != null) |
| 255 | RawImage(image: _image, fit: BoxFit.fill) |
| 256 | else |
| 257 | const Center(child: CircularProgressIndicator()), |
| 258 | Positioned( |
| 259 | left: 12, |
| 260 | bottom: 12, |
| 261 | child: _Chip( |
| 262 | 'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x · ' |
| 263 | '$_lastMs ms · ' |
| 264 | '${_tiles > 1 ? "$_tiles isolates" : "1 isolate"}', |
| 265 | ), |
| 266 | ), |
| 267 | if (_rendering) |
| 268 | const Positioned( |
| 269 | right: 12, |
| 270 | top: 12, |
| 271 | child: SizedBox( |
| 272 | width: 18, |
| 273 | height: 18, |
| 274 | child: CircularProgressIndicator(strokeWidth: 2), |
| 275 | ), |
| 276 | ), |
| 277 | ], |
| 278 | ), |
| 279 | ), |
| 280 | ); |
| 281 | }, |
| 282 | ); |
| 283 | } |
| 284 | |
| 285 | /// Returns a non-scrolling column. |
| 286 | /// |
| 287 | /// The wide layout wraps this in its own scroll view; the narrow layout |
| 288 | /// embeds it in the page's ListView. Making this a ListView itself would |
| 289 | /// nest two scrollables and give the inner one unbounded height. |
| 290 | Widget _buildControls() { |
| 291 | final theme = Theme.of(context); |
| 292 | return Padding( |
| 293 | padding: const EdgeInsets.all(20), |
| 294 | child: Column( |
| 295 | crossAxisAlignment: CrossAxisAlignment.stretch, |
| 296 | children: [ |
| 297 | Text('Mandelbrot in V', style: theme.textTheme.titleLarge), |
| 298 | const SizedBox(height: 4), |
| 299 | Text( |
| 300 | 'Tap to zoom in, right-click to zoom out. ' |
| 301 | 'Every pixel is computed by vf_mandelbrot in src/vflutter.v.', |
| 302 | style: theme.textTheme.bodySmall, |
| 303 | ), |
| 304 | const SizedBox(height: 20), |
| 305 | _Labelled( |
| 306 | label: 'iterations', |
| 307 | value: '${_view.maxIter}', |
| 308 | child: Slider( |
| 309 | value: _view.maxIter.toDouble(), |
| 310 | min: 50, |
| 311 | max: 2000, |
| 312 | divisions: 39, |
| 313 | onChanged: (n) => |
| 314 | setState(() => _view = _view.copyWith(maxIter: n.round())), |
| 315 | onChangeEnd: (_) => _render(), |
| 316 | ), |
| 317 | ), |
| 318 | _Labelled( |
| 319 | label: 'isolates', |
| 320 | value: '$_tiles', |
| 321 | child: Slider( |
| 322 | value: _tiles.toDouble(), |
| 323 | min: 1, |
| 324 | max: 8, |
| 325 | divisions: 7, |
| 326 | onChanged: (n) => setState(() => _tiles = n.round()), |
| 327 | onChangeEnd: (_) => _render(), |
| 328 | ), |
| 329 | ), |
| 330 | const SizedBox(height: 8), |
| 331 | Row( |
| 332 | children: [ |
| 333 | Expanded( |
| 334 | child: FilledButton.tonal( |
| 335 | onPressed: _rendering ? null : _reset, |
| 336 | child: const Text('Reset view'), |
| 337 | ), |
| 338 | ), |
| 339 | const SizedBox(width: 12), |
| 340 | Expanded( |
| 341 | child: FilledButton( |
| 342 | onPressed: _rendering ? null : _runBenchmark, |
| 343 | child: const Text('Benchmark'), |
| 344 | ), |
| 345 | ), |
| 346 | ], |
| 347 | ), |
| 348 | const SizedBox(height: 20), |
| 349 | if (_benchmark != null) _BenchmarkCard(result: _benchmark!), |
| 350 | const SizedBox(height: 12), |
| 351 | Card( |
| 352 | color: theme.colorScheme.surfaceContainerHighest, |
| 353 | child: Padding( |
| 354 | padding: const EdgeInsets.all(14), |
| 355 | child: Text( |
| 356 | 'The V kernel fills a buffer Dart allocates, so no V memory ' |
| 357 | 'crosses the boundary and there is nothing to free. Bands are ' |
| 358 | 'independent, which is what makes the isolate slider safe: ' |
| 359 | '-gc none means no stop-the-world phase to serialise them.', |
| 360 | style: theme.textTheme.bodySmall, |
| 361 | ), |
| 362 | ), |
| 363 | ), |
| 364 | ], |
| 365 | ), |
| 366 | ); |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | class _Benchmark { |
| 371 | const _Benchmark({ |
| 372 | required this.vSerialMs, |
| 373 | required this.vParallelMs, |
| 374 | required this.dartMs, |
| 375 | required this.tiles, |
| 376 | }); |
| 377 | |
| 378 | final int vSerialMs; |
| 379 | final int vParallelMs; |
| 380 | final int dartMs; |
| 381 | final int tiles; |
| 382 | } |
| 383 | |
| 384 | class _BenchmarkCard extends StatelessWidget { |
| 385 | const _BenchmarkCard({required this.result}); |
| 386 | |
| 387 | final _Benchmark result; |
| 388 | |
| 389 | @override |
| 390 | Widget build(BuildContext context) { |
| 391 | final theme = Theme.of(context); |
| 392 | final ratio = result.dartMs / result.vSerialMs; |
| 393 | return Card( |
| 394 | child: Padding( |
| 395 | padding: const EdgeInsets.all(14), |
| 396 | child: Column( |
| 397 | crossAxisAlignment: CrossAxisAlignment.start, |
| 398 | children: [ |
| 399 | Text('same frame, three ways', |
| 400 | style: theme.textTheme.titleSmall), |
| 401 | const SizedBox(height: 10), |
| 402 | _Row(label: 'V, 1 isolate', value: '${result.vSerialMs} ms'), |
| 403 | _Row( |
| 404 | label: 'V, ${result.tiles} isolates', |
| 405 | value: '${result.vParallelMs} ms'), |
| 406 | _Row(label: 'Dart, 1 isolate', value: '${result.dartMs} ms'), |
| 407 | const Divider(height: 20), |
| 408 | Text( |
| 409 | 'V is ${ratio.toStringAsFixed(2)}x Dart on one isolate. For a ' |
| 410 | 'scalar FP loop the two are close — the real win here is the ' |
| 411 | 'parallel split, which Dart could also do. The bridge is what ' |
| 412 | 'this demonstrates, not a speed claim.', |
| 413 | style: theme.textTheme.bodySmall, |
| 414 | ), |
| 415 | ], |
| 416 | ), |
| 417 | ), |
| 418 | ); |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | class _Row extends StatelessWidget { |
| 423 | const _Row({required this.label, required this.value}); |
| 424 | |
| 425 | final String label; |
| 426 | final String value; |
| 427 | |
| 428 | @override |
| 429 | Widget build(BuildContext context) => Padding( |
| 430 | padding: const EdgeInsets.symmetric(vertical: 2), |
| 431 | child: Row( |
| 432 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 433 | children: [ |
| 434 | Text(label, style: Theme.of(context).textTheme.bodyMedium), |
| 435 | Text(value, |
| 436 | style: Theme.of(context) |
| 437 | .textTheme |
| 438 | .bodyMedium |
| 439 | ?.copyWith(fontFeatures: const [ui.FontFeature.tabularFigures()])), |
| 440 | ], |
| 441 | ), |
| 442 | ); |
| 443 | } |
| 444 | |
| 445 | class _Labelled extends StatelessWidget { |
| 446 | const _Labelled({ |
| 447 | required this.label, |
| 448 | required this.value, |
| 449 | required this.child, |
| 450 | }); |
| 451 | |
| 452 | final String label; |
| 453 | final String value; |
| 454 | final Widget child; |
| 455 | |
| 456 | @override |
| 457 | Widget build(BuildContext context) { |
| 458 | final theme = Theme.of(context); |
| 459 | return Column( |
| 460 | crossAxisAlignment: CrossAxisAlignment.start, |
| 461 | children: [ |
| 462 | Row( |
| 463 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 464 | children: [ |
| 465 | Text(label, style: theme.textTheme.labelLarge), |
| 466 | Text(value, style: theme.textTheme.labelLarge), |
| 467 | ], |
| 468 | ), |
| 469 | child, |
| 470 | ], |
| 471 | ); |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | class _Chip extends StatelessWidget { |
| 476 | const _Chip(this.text); |
| 477 | |
| 478 | final String text; |
| 479 | |
| 480 | @override |
| 481 | Widget build(BuildContext context) => Container( |
| 482 | padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), |
| 483 | decoration: BoxDecoration( |
| 484 | color: Colors.black.withValues(alpha: 0.55), |
| 485 | borderRadius: BorderRadius.circular(8), |
| 486 | ), |
| 487 | child: Text(text, |
| 488 | style: const TextStyle(color: Colors.white, fontSize: 12)), |
| 489 | ); |
| 490 | } |