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/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index 6da20ffc5..a705802b6 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -208,15 +208,11 @@ class _PolylineLayerState extends State> continue; } + final projectedBounds = projectedPolyline.boundingBox; + /// Returns true if the points stretch on different versions of the world. - bool stretchesBeyondTheLimits() { - for (final point in projectedPolyline.points) { - if (point.dx > xEast || point.dx < xWest) { - return true; - } - } - return false; - } + bool stretchesBeyondTheLimits() => + projectedBounds.right > xEast || projectedBounds.left < xWest; // TODO: think about how to cull polylines that go beyond -180/180. // As the notions of projected west/east as min/max are not reliable. @@ -235,6 +231,16 @@ class _PolylineLayerState extends State> // when none of the line is visible. Here, focusing on longitudes. if (!isOverlappingLongitude()) continue; + // Fast path: when the whole polyline is visible there is nothing to + // cull, so skip the per-segment scan (and its sublist allocations). + if (projBounds.left <= projectedBounds.left && + projBounds.top <= projectedBounds.top && + projBounds.right >= projectedBounds.right && + projBounds.bottom >= projectedBounds.bottom) { + yield projectedPolyline; + continue; + } + // pointer that indicates the start of the visible polyline segment int start = -1; bool containsSegment = false; diff --git a/lib/src/layer/polyline_layer/projected_polyline.dart b/lib/src/layer/polyline_layer/projected_polyline.dart index 49707db41..bc57c2f8e 100644 --- a/lib/src/layer/polyline_layer/projected_polyline.dart +++ b/lib/src/layer/polyline_layer/projected_polyline.dart @@ -5,10 +5,15 @@ class _ProjectedPolyline with HitDetectableElement { final Polyline polyline; final List points; + /// Bounding box of [points], in projected space (cached) + /// + /// Computed lazily: culled fragments never use it. + late final Rect boundingBox = RectExtension.containing(points); + @override R? get hitValue => polyline.hitValue; - const _ProjectedPolyline._({ + _ProjectedPolyline._({ required this.polyline, required this.points, });