nandi/vflutter_ffipublic Fork 0
f7c3825dc671305345e20a62ea6ac8facbc903bb
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 · 490 lines · 14.5 KBDart Blame HistoryRaw
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 7h 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
44class _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
370class _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
384class _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
422class _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
445class _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
475class _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}