diff --git a/benchmark/feature_layer_benchmark_test.dart b/benchmark/feature_layer_benchmark_test.dart new file mode 100644 index 000000000..a7f9ab2b8 --- /dev/null +++ b/benchmark/feature_layer_benchmark_test.dart @@ -0,0 +1,277 @@ +// Layer-level CPU benchmarks, used to validate performance work. +// +// Run with: +// flutter test benchmark/feature_layer_benchmark_test.dart --plain-name=benchmark -r expanded +// +// Numbers are JIT/debug-mode and only meaningful *relative* to each other +// (before/after a change on the same machine). Each scenario warms up, then +// times repeated pumps while the camera pans, and reports the best repetition +// (min) to reduce GC/scheduling noise. +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/misc/offsets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +const _center = LatLng(-37.8136, 144.9631); + +List _randomWalk( + math.Random rng, + LatLng start, + int count, [ + double stepDeg = 0.0004, +]) { + final points = []; + var lat = start.latitude; + var lng = start.longitude; + for (var i = 0; i < count; i++) { + lat += (rng.nextDouble() - 0.5) * stepDeg; + lng += (rng.nextDouble() - 0.5) * stepDeg; + points.add(LatLng(lat, lng)); + } + return points; +} + +LatLng _randomNear(math.Random rng, LatLng base, double spreadDeg) => LatLng( + base.latitude + (rng.nextDouble() - 0.5) * spreadDeg, + base.longitude + (rng.nextDouble() - 0.5) * spreadDeg, + ); + +Widget _app(MapController controller, double zoom, List layers) => + MaterialApp( + home: FlutterMap( + mapController: controller, + options: MapOptions(initialCenter: _center, initialZoom: zoom), + children: layers, + ), + ); + +/// Pans the camera back and forth and reports the best-rep average frame +/// build+paint time, in microseconds. +Future _benchPans( + WidgetTester tester, + MapController controller, + double zoom, { + int reps = 3, + int framesPerRep = 40, +}) async { + // Warm-up (fills projection/simplification caches, JIT). + for (var i = 0; i < 10; i++) { + controller.move( + LatLng(_center.latitude, _center.longitude + 0.00001 * (i + 1)), + zoom, + ); + await tester.pump(); + } + + var best = double.infinity; + for (var rep = 0; rep < reps; rep++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < framesPerRep; i++) { + // Small alternating pan, never repeating the previous camera. + controller.move( + LatLng( + _center.latitude + 0.0001 * (i % 7), + _center.longitude + 0.0001 * (i % 11) + 0.000001 * i, + ), + zoom, + ); + await tester.pump(); + } + sw.stop(); + final perFrame = sw.elapsedMicroseconds / framesPerRep; + if (perFrame < best) best = perFrame; + } + return best; +} + +void main() { + testWidgets('benchmark: polylines pan (all visible)', (tester) async { + final rng = math.Random(42); + final polylines = [ + for (var i = 0; i < 600; i++) + Polyline( + points: _randomWalk(rng, _randomNear(rng, _center, 0.02), 60), + strokeWidth: 2, + color: Colors.blue, + ), + ]; + final controller = MapController(); + // Zoom 13: ~0.05° viewport, the 0.02° spread keeps everything visible. + await tester.pumpWidget( + _app(controller, 13, [PolylineLayer(polylines: polylines)]), + ); + final us = await _benchPans(tester, controller, 13); + debugPrint('RESULT polylines_pan_all_visible: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: polylines pan (mostly culled)', (tester) async { + final rng = math.Random(42); + final polylines = [ + for (var i = 0; i < 600; i++) + Polyline( + points: _randomWalk(rng, _randomNear(rng, _center, 0.5), 60), + strokeWidth: 2, + color: Colors.blue, + ), + ]; + final controller = MapController(); + // Zoom 16: viewport much smaller than the 0.5° spread. + await tester.pumpWidget( + _app(controller, 16, [PolylineLayer(polylines: polylines)]), + ); + final us = await _benchPans(tester, controller, 16); + debugPrint('RESULT polylines_pan_mostly_culled: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: polygons with holes pan', (tester) async { + final rng = math.Random(7); + final polygons = [ + for (var i = 0; i < 200; i++) + () { + final base = _randomNear(rng, _center, 0.02); + return Polygon( + points: _randomWalk(rng, base, 40), + holePointsList: [ + for (var h = 0; h < 3; h++) + _randomWalk(rng, _randomNear(rng, base, 0.001), 20, 0.0001), + ], + color: Colors.green.withValues(alpha: 0.5), + ); + }(), + ]; + final controller = MapController(); + await tester.pumpWidget( + _app(controller, 13, [PolygonLayer(polygons: polygons)]), + ); + final us = await _benchPans(tester, controller, 13); + debugPrint('RESULT polygons_holes_pan: ${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: markers pan', (tester) async { + final rng = math.Random(3); + final markers = [ + for (var i = 0; i < 3000; i++) + Marker( + point: _randomNear(rng, _center, 0.05), + width: 20, + height: 20, + child: const SizedBox.shrink(), + ), + ]; + final controller = MapController(); + await tester.pumpWidget( + _app(controller, 14, [MarkerLayer(markers: markers)]), + ); + final us = await _benchPans(tester, controller, 14); + debugPrint('RESULT markers_pan: ${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: markers pan (mostly culled)', (tester) async { + final rng = math.Random(3); + final markers = [ + for (var i = 0; i < 10000; i++) + Marker( + point: _randomNear(rng, _center, 1), + width: 20, + height: 20, + child: const SizedBox.shrink(), + ), + ]; + final controller = MapController(); + // Zoom 16: viewport much smaller than the 1° spread, so per-frame cost is + // dominated by the per-marker projection + cull check. + await tester.pumpWidget( + _app(controller, 16, [MarkerLayer(markers: markers)]), + ); + final us = await _benchPans(tester, controller, 16); + debugPrint('RESULT markers_pan_mostly_culled: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + test('benchmark: marker projection kernel', () { + final rng = math.Random(5); + final camera = MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: 14, + rotation: 0, + nonRotatedSize: const Size(800, 600), + ); + const crs = Epsg3857(); + final points = [ + for (var i = 0; i < 10000; i++) _randomNear(rng, _center, 1), + ]; + final projected = [for (final p in points) crs.projection.project(p)]; + const frames = 100; + + // Old per-frame path: full LatLng -> screen projection (trigonometry). + var sink = 0.0; + var sw = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + for (final p in points) { + sink += camera.projectAtZoom(p).dx; + } + } + sw.stop(); + final oldNs = sw.elapsedMicroseconds * 1000 / (frames * points.length); + + // New per-frame path: linear transform of the cached projection. + final zoomScale = crs.scale(camera.zoom); + sw = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + for (final p in projected) { + final (x, _) = crs.transform(p.dx, p.dy, zoomScale); + sink += x; + } + } + sw.stop(); + final newNs = sw.elapsedMicroseconds * 1000 / (frames * points.length); + + debugPrint('RESULT marker_projection_kernel: sink=${sink.isFinite} ' + 'full=${oldNs.toStringAsFixed(1)} ns/marker ' + 'cached=${newNs.toStringAsFixed(1)} ns/marker'); + }); + + test('benchmark: getOffsetsXY holed polygon (direct)', () { + final rng = math.Random(11); + final camera = MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: 14, + rotation: 0, + nonRotatedSize: const Size(800, 600), + ); + const projection = SphericalMercator(); + final points = projection.projectList(_randomWalk(rng, _center, 500)); + final holePoints = [ + for (var h = 0; h < 10; h++) + projection.projectList( + _randomWalk(rng, _randomNear(rng, _center, 0.005), 200, 0.0001), + ), + ]; + + final helper = OffsetHelper(camera: camera); + // Warm-up. + for (var i = 0; i < 20; i++) { + helper.getOffsetsXY(points: points, holePoints: holePoints); + } + var best = double.infinity; + for (var rep = 0; rep < 5; rep++) { + const n = 200; + final sw = Stopwatch()..start(); + for (var i = 0; i < n; i++) { + helper.getOffsetsXY(points: points, holePoints: holePoints); + } + sw.stop(); + final per = sw.elapsedMicroseconds / n; + if (per < best) best = per; + } + debugPrint('RESULT get_offsets_xy_holed: ' + '${best.toStringAsFixed(1)} us/call'); + }); +} diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index fe4693d46..6b8ffa64b 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -6,7 +6,7 @@ part 'marker.dart'; /// A [Marker] layer for [FlutterMap]. @immutable -class MarkerLayer extends StatelessWidget { +class MarkerLayer extends StatefulWidget { /// The list of [Marker]s. final List markers; @@ -38,30 +38,92 @@ class MarkerLayer extends StatelessWidget { this.rotate = false, }); + @override + State createState() => _MarkerLayerState(); +} + +class _MarkerLayerState extends State { + /// Projected (zoom-independent) coordinates of every [Marker.point], in the + /// same order as the markers list + /// + /// Projecting a point is relatively expensive (it involves trigonometry), + /// but only depends on the CRS - not on the camera position or zoom. Caching + /// it means each camera movement only costs the cheap linear + /// projected -> screen transformation per marker, instead of a full + /// re-projection. + List? _projectedPoints; + Crs? _projectionCrs; + + @override + void didUpdateWidget(MarkerLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + // Matches the invalidation convention of the polyline/polygon layers: any + // new widget instance re-projects, so in-place mutations of the markers + // list keep working as they did when projection was performed per-frame. + _projectedPoints = null; + } + + List _projectPoints(Crs crs) { + final projection = crs.projection; + return List.generate( + widget.markers.length, + (i) { + final point = widget.markers[i].point; + // Same finiteness guard as `Crs.latLngToXY`, which was previously on + // this path. cf. https://github.com/fleaflet/flutter_map/issues/2178 + if (!(point.latitude.isFinite && point.longitude.isFinite)) { + throw Exception('LatLng is not finite: $point'); + } + return projection.project(point); + }, + growable: false, + ); + } + @override Widget build(BuildContext context) { final map = MapCamera.of(context); + final crs = map.crs; + + if (_projectedPoints == null || _projectionCrs != crs) { + _projectionCrs = crs; + _projectedPoints = _projectPoints(crs); + } + final projectedPoints = _projectedPoints!; + final worldWidth = map.getWorldWidthAtZoom(); + final zoomScale = crs.scale(map.zoom); + final pixelBounds = map.pixelBounds; + final pixelOrigin = map.pixelOrigin; + final markers = widget.markers; return MobileLayerTransformer( child: Stack( - children: (List markers) sync* { - for (final m in markers) { + children: () sync* { + for (var i = 0; i < markers.length; i++) { + final m = markers[i]; + // Resolve real alignment // TODO: maybe just using Size, Offset, and Rect? - final left = 0.5 * m.width * ((m.alignment ?? alignment).x + 1); - final top = 0.5 * m.height * ((m.alignment ?? alignment).y + 1); + final left = + 0.5 * m.width * ((m.alignment ?? widget.alignment).x + 1); + final top = + 0.5 * m.height * ((m.alignment ?? widget.alignment).y + 1); final right = m.width - left; final bottom = m.height - top; - // Perform projection - final pxPoint = map.projectAtZoom(m.point); + // Scale the cached projection to the current zoom + final projected = projectedPoints[i]; + final (px, py) = + crs.transform(projected.dx, projected.dy, zoomScale); + final pxPoint = Offset(px, py); Positioned? getPositioned(double worldShift) { final shiftedX = pxPoint.dx + worldShift; // Cull if out of bounds - if (!map.pixelBounds.overlaps( + if (!pixelBounds.overlaps( Rect.fromPoints( Offset(shiftedX + left, pxPoint.dy - bottom), Offset(shiftedX - right, pxPoint.dy + top), @@ -73,7 +135,7 @@ class MarkerLayer extends StatelessWidget { // Shift original coordinate along worlds, then move into relative // to origin space final shiftedLocalPoint = - Offset(shiftedX, pxPoint.dy) - map.pixelOrigin; + Offset(shiftedX, pxPoint.dy) - pixelOrigin; return Positioned( key: m.key, @@ -81,10 +143,10 @@ class MarkerLayer extends StatelessWidget { height: m.height, left: shiftedLocalPoint.dx - right, top: shiftedLocalPoint.dy - bottom, - child: (m.rotate ?? rotate) + child: (m.rotate ?? widget.rotate) ? Transform.rotate( angle: -map.rotationRad, - alignment: (m.alignment ?? alignment) * -1, + alignment: (m.alignment ?? widget.alignment) * -1, child: m.child, ) : m.child, @@ -117,7 +179,7 @@ class MarkerLayer extends StatelessWidget { yield additional; } } - }(markers) + }() .toList(), ), );