nandi/vflutter_ffipublic Fork 0
4506a586d7400b00d732dcb97167be75ab41cab1
Commits
Clone
git clone https://git.rickub.com/nandi/vflutter_ffi.git
git clone ssh://git@rickub.com/nandi/vflutter_ffi.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Render in parallel on web, via a pool of Web Workers 4506a58 · on 4506a586d7400b00d732dcb97167be75ab41cab1 · nandithebull · 7h ago
main.dart · 587 lines · 18.9 KBDart Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
// A Mandelbrot explorer whose pixels are computed in Nim.
//
// What this is actually demonstrating:
//   * bulk data across the FFI boundary — a full RGBA frame per render,
//     into a buffer Dart allocates and Nim fills in place (nothing to nf_free)
//   * band parallelism — the image is split into horizontal strips rendered
//     on separate isolates, which is safe because the library is -gc none
//   * the same algorithm written twice, in Nim and in Dart, so the cost of the
//     bridge is a number on screen rather than a claim
import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:nimflutter_ffi/nimflutter_ffi.dart' as v;

import 'mandelbrot_dart.dart';

void main() => runApp(const ExampleApp());

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'nimflutter_ffi',
        debugShowCheckedModeBanner: false,
        theme: ThemeData.dark(useMaterial3: true).copyWith(
          scaffoldBackgroundColor: const Color(0xFF12141A),
        ),
        home: const ExplorerPage(),
      );
}

class ExplorerPage extends StatefulWidget {
  const ExplorerPage({super.key});

  @override
  State<ExplorerPage> createState() => _ExplorerPageState();
}

/// Identifies the interactive canvas, so tests can drive gestures on it
/// without depending on whether a frame has finished rendering yet.
const Key canvasKey = Key('fractal-canvas');

class _ExplorerPageState extends State<ExplorerPage> {
  static const int _renderWidth = 720;
  static const int _renderHeight = 540;

  v.FractalView _view = const v.FractalView(
    width: _renderWidth,
    height: _renderHeight,
    maxIter: 400,
  );

  ui.Image? _image;
  bool _rendering = false;
  bool _renderQueued = false;
  int _lastMs = 0;
  int _tiles = 16;
  _Benchmark? _benchmark;
  String? _bootError;

  /// Bands run on isolates natively and sequentially on web, so the word the
  /// UI uses for them has to follow the backend.
  String get _unit =>
      v.supportsIsolateParallelism ? v.parallelUnitName : 'band';

  @override
  void initState() {
    super.initState();
    _boot();
  }

  /// Brings the bridge up and draws the first frame.
  Future<void> _boot() => _render();

  /// Waits for the bridge, returning false if it could not be brought up.
  ///
  /// On native this resolves immediately. On web it is where the wasm module
  /// is fetched and instantiated, which cannot be synchronous.
  ///
  /// Every path that asks for a frame goes through here, not just startup:
  /// the first layout pass calls _fitToCanvas -> _render before initState's
  /// future has resolved, so gating only the boot path would still let the
  /// very first render race the module load. initialize() is idempotent and
  /// hands back the in-flight future, so the extra calls just wait.
  Future<bool> _ensureReady() async {
    if (v.isInitialized) return true;
    try {
      await v.initialize();
      return true;
    } catch (e) {
      if (mounted) setState(() => _bootError = '$e');
      return false;
    }
  }

  @override
  void dispose() {
    _image?.dispose();
    super.dispose();
  }

  /// Renders the current view, coalescing requests that arrive mid-frame.
  ///
  /// A wheel gesture emits far more events than we can render. Dropping them
  /// outright would leave the view mutated but never drawn, so instead the
  /// latest request is remembered and serviced once the in-flight frame lands.
  Future<void> _render() async {
    if (!await _ensureReady()) return;
    if (_rendering) {
      _renderQueued = true;
      return;
    }
    setState(() => _rendering = true);

    do {
      _renderQueued = false;
      final view = _view;

      final watch = Stopwatch()..start();
      final pixels = _tiles > 1
          ? await v.renderParallel(view, tiles: _tiles)
          : v.render(view);
      watch.stop();

      final image = await _decode(pixels, view.width, view.height);
      if (!mounted) {
        image.dispose();
        return;
      }
      setState(() {
        _image?.dispose();
        _image = image;
        _lastMs = watch.elapsedMilliseconds;
      });
    } while (_renderQueued);

    if (!mounted) return;
    setState(() => _rendering = false);
  }

  static Future<ui.Image> _decode(Uint8List rgba, int w, int h) {
    final completer = Completer<ui.Image>();
    ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888,
        completer.complete);
    return completer.future;
  }

  /// Drags the view by a pixel delta, so the image follows the cursor.
  void _panBy(Offset delta, Size widgetSize) {
    if (widgetSize.isEmpty || delta == Offset.zero) return;
    setState(() {
      _view = _view.pannedBy(
        delta.dx / widgetSize.width,
        delta.dy / widgetSize.height,
      );
    });
    _render();
  }

  /// Zooms about a point, keeping that point under the cursor.
  void _zoomAt(Offset local, Size widgetSize, double factor) {
    if (widgetSize.isEmpty) return;
    setState(() {
      _view = _view.zoomedAt(
        local.dx / widgetSize.width,
        local.dy / widgetSize.height,
        factor,
      );
    });
    _render();
  }

  /// Restores the starting framing.
  ///
  /// Only the pan/zoom is reset. Width and height belong to the canvas (see
  /// _fitToCanvas) and maxIter belongs to its slider — clobbering those made
  /// "Reset view" silently undo settings the button does not own, and briefly
  /// render at the wrong aspect ratio before the canvas corrected it.
  void _reset() {
    const home = v.FractalView(width: 1, height: 1);
    setState(() {
      _view = _view.copyWith(
        centerX: home.centerX,
        centerY: home.centerY,
        scale: home.scale,
      );
    });
    _render();
  }

  /// Renders the identical frame in Nim and in Dart on this isolate, so the
  /// comparison is like-for-like rather than Nim-plus-parallelism vs Dart.
  Future<void> _runBenchmark() async {
    if (!await _ensureReady()) return;
    setState(() {
      _rendering = true;
      _benchmark = null;
    });

    final swV = Stopwatch()..start();
    v.render(_view);
    swV.stop();

    final swP = Stopwatch()..start();
    await v.renderParallel(_view, tiles: _tiles);
    swP.stop();

    final swD = Stopwatch()..start();
    renderDart(_view);
    swD.stop();

    if (!mounted) return;
    setState(() {
      _rendering = false;
      _benchmark = _Benchmark(
        unit: _unit,
        parallel: v.supportsIsolateParallelism,
        vSerialMs: swV.elapsedMilliseconds,
        vParallelMs: swP.elapsedMilliseconds,
        dartMs: swD.elapsedMilliseconds,
        tiles: _tiles,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final wide = constraints.maxWidth > 900;
            final canvas = _buildCanvas();
            final controls = _buildControls();
            return wide
                ? Row(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      Expanded(child: canvas),
                      SizedBox(
                        width: 340,
                        child: SingleChildScrollView(child: controls),
                      ),
                    ],
                  )
                : ListView(
                    children: [
                      AspectRatio(
                        aspectRatio: _renderWidth / _renderHeight,
                        child: canvas,
                      ),
                      controls,
                    ],
                  );
          },
        ),
      ),
    );
  }

  /// Keeps the rendered buffer at the canvas's aspect ratio.
  ///
  /// Without this the image would be letterboxed or cropped, and the
  /// click-to-zoom maths — which maps widget coordinates straight into the
  /// complex plane — would point at the wrong place.
  void _fitToCanvas(Size size) {
    if (size.isEmpty) return;
    final height = (_renderWidth * size.height / size.width).round();
    if ((height - _view.height).abs() <= 2) return;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;
      setState(() => _view = _view.copyWith(height: height));
      _render();
    });
  }

  Widget _buildCanvas() {
    return LayoutBuilder(
      builder: (context, constraints) {
        final size = Size(constraints.maxWidth, constraints.maxHeight);
        _fitToCanvas(size);
        return Listener(
          key: canvasKey,
          // Wheel zoom, anchored at the cursor like the tap zoom. One notch is
          // a small step so a scroll feels continuous rather than octave-wise;
          // the render loop coalesces whatever it cannot keep up with.
          onPointerSignal: (event) {
            if (event is! PointerScrollEvent) return;
            final dy = event.scrollDelta.dy;
            if (dy == 0) return;
            _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble());
          },
          child: GestureDetector(
            // Zoom on tap up rather than tap down. The gesture arena already
            // keeps onTapDown from firing once a pan claims the pointer, so
            // this is not what makes drag-to-pan work — it just means the
            // zoom commits on a completed tap.
            onTapUp: (d) => _zoomAt(d.localPosition, size, 0.5),
            onSecondaryTapUp: (d) => _zoomAt(d.localPosition, size, 2.0),
            onPanUpdate: (d) => _panBy(d.delta, size),
            child: Stack(
            fit: StackFit.expand,
            children: [
              if (_bootError != null)
                // Most likely on web: the wasm glue did not load, so there is
                // no Nim to call. Say so rather than spinning forever.
                Center(
                  child: Padding(
                    padding: const EdgeInsets.all(24),
                    child: Text(
                      'Could not start the Nim bridge.\n\n$_bootError',
                      textAlign: TextAlign.center,
                      style: Theme.of(context).textTheme.bodySmall,
                    ),
                  ),
                )
              else if (_image != null)
                RawImage(image: _image, fit: BoxFit.fill)
              else
                const Center(child: CircularProgressIndicator()),
              Positioned(
                left: 12,
                bottom: 12,
                child: _Chip(
                  'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x  ·  '
                  '$_lastMs ms  ·  '
                  '${_tiles > 1 ? "$_tiles ${_unit}s" : "1 $_unit"}',
                ),
              ),
              if (_rendering)
                const Positioned(
                  right: 12,
                  top: 12,
                  child: SizedBox(
                    width: 18,
                    height: 18,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }

  /// Returns a non-scrolling column.
  ///
  /// The wide layout wraps this in its own scroll view; the narrow layout
  /// embeds it in the page's ListView. Making this a ListView itself would
  /// nest two scrollables and give the inner one unbounded height.
  Widget _buildControls() {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
        Text('Mandelbrot in Nim', style: theme.textTheme.titleLarge),
        const SizedBox(height: 4),
        Text(
          'Scroll or tap to zoom in, right-click to zoom out, drag to pan. '
          'Every pixel is computed by nf_mandelbrot in src/nimflutter.v.',
          style: theme.textTheme.bodySmall,
        ),
        const SizedBox(height: 20),
        _Labelled(
          label: 'iterations',
          value: '${_view.maxIter}',
          child: Slider(
            value: _view.maxIter.toDouble(),
            min: 50,
            max: 2000,
            divisions: 39,
            onChanged: (n) =>
                setState(() => _view = _view.copyWith(maxIter: n.round())),
            onChangeEnd: (_) => _render(),
          ),
        ),
        _Labelled(
          label: '${_unit}s',
          value: '$_tiles',
          child: Slider(
            value: _tiles.toDouble(),
            min: 1,
            // Deliberately well past the core count. Bands cost wildly
            // different amounts — rows through the set's interior run the full
            // iteration cap, rows in open space escape immediately — so the
            // frame waits on its slowest band. Over-decomposing lets a free
            // core pick up the next small band instead of idling. Measured on
            // an 8-thread machine: 8 tiles 44 ms, 24 tiles 29 ms.
            max: 32,
            divisions: 31,
            onChanged: (n) => setState(() => _tiles = n.round()),
            onChangeEnd: (_) => _render(),
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            Expanded(
              child: FilledButton.tonal(
                onPressed: _rendering ? null : _reset,
                child: const Text('Reset view'),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: FilledButton(
                onPressed: _rendering ? null : _runBenchmark,
                child: const Text('Benchmark'),
              ),
            ),
          ],
        ),
        const SizedBox(height: 20),
        if (_benchmark != null) _BenchmarkCard(result: _benchmark!),
        const SizedBox(height: 12),
        Card(
          color: theme.colorScheme.surfaceContainerHighest,
          child: Padding(
            padding: const EdgeInsets.all(14),
            child: Text(
              v.backendName == 'dart:ffi'
                  ? 'The Nim kernel fills a buffer Dart allocates, so no Nim '
                      'memory crosses the boundary and there is nothing to '
                      'free. Bands are independent, which is what makes the '
                      'isolate slider safe: ARC has no stop-the-world phase '
                      'to serialise them.'
                  : 'The same Nim kernel, compiled to WebAssembly and reached '
                      'over dart:js_interop instead of dart:ffi — it fills a '
                      'buffer in the wasm heap, so nothing needs freeing on '
                      'the Dart side. Each Web Worker holds its own instance '
                      'of the module, so nothing is shared and no '
                      'SharedArrayBuffer or COOP/COEP headers are needed. The '
                      'frame is identical to the native build, byte for byte.',
              style: theme.textTheme.bodySmall,
            ),
          ),
        ),
        ],
      ),
    );
  }
}

class _Benchmark {
  const _Benchmark({
    required this.vSerialMs,
    required this.vParallelMs,
    required this.dartMs,
    required this.tiles,
    required this.unit,
    required this.parallel,
  });

  final int vSerialMs;
  final int vParallelMs;
  final int dartMs;
  final int tiles;

  /// 'isolate' natively, 'band' on web.
  final String unit;

  /// Whether the split actually ran concurrently.
  final bool parallel;
}

class _BenchmarkCard extends StatelessWidget {
  const _BenchmarkCard({required this.result});

  final _Benchmark result;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final ratio = result.dartMs / result.vSerialMs;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('same frame, three ways',
                style: theme.textTheme.titleSmall),
            const SizedBox(height: 10),
            _Row(label: 'Nim, 1 ${result.unit}', value: '${result.vSerialMs} ms'),
            _Row(
                label: 'Nim, ${result.tiles} ${result.unit}s',
                value: '${result.vParallelMs} ms'),
            _Row(label: 'Dart, 1 ${result.unit}', value: '${result.dartMs} ms'),
            const Divider(height: 20),
            Text(
              result.parallel
                  ? 'Nim is ${ratio.toStringAsFixed(2)}x Dart on one isolate. '
                      'For a scalar FP loop the two are close — the real win '
                      'here is the parallel split, which Dart could also do. '
                      'The bridge is what this demonstrates, not a speed claim.'
                  : 'Nim is ${ratio.toStringAsFixed(2)}x Dart on one thread. '
                      'The wasm module is single-threaded, so the '
                      '${result.tiles}-band row above is the same work done '
                      'in sequence, not in parallel — it is here to show the '
                      'split produces identical pixels either way.',
              style: theme.textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }
}

class _Row extends StatelessWidget {
  const _Row({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.symmetric(vertical: 2),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(label, style: Theme.of(context).textTheme.bodyMedium),
            Text(value,
                style: Theme.of(context)
                    .textTheme
                    .bodyMedium
                    ?.copyWith(fontFeatures: const [ui.FontFeature.tabularFigures()])),
          ],
        ),
      );
}

class _Labelled extends StatelessWidget {
  const _Labelled({
    required this.label,
    required this.value,
    required this.child,
  });

  final String label;
  final String value;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(label, style: theme.textTheme.labelLarge),
            Text(value, style: theme.textTheme.labelLarge),
          ],
        ),
        child,
      ],
    );
  }
}

class _Chip extends StatelessWidget {
  const _Chip(this.text);

  final String text;

  @override
  Widget build(BuildContext context) => Container(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
        decoration: BoxDecoration(
          color: Colors.black.withValues(alpha: 0.55),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(text,
            style: const TextStyle(color: Colors.white, fontSize: 12)),
      );
}