nandi/vflutter_ffipublic Fork 0
0ed90f39e1f4dc334e577e5b38316674a53fc88d
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.

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