Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions benchmark/feature_layer_benchmark_test.dart
Original file line number Diff line number Diff line change
@@ -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<LatLng> _randomWalk(
math.Random rng,
LatLng start,
int count, [
double stepDeg = 0.0004,
]) {
final points = <LatLng>[];
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<Widget> 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<double> _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<Object>(
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<Object>(
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<Object>(
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');
});
}
22 changes: 14 additions & 8 deletions lib/src/layer/polyline_layer/polyline_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,11 @@ class _PolylineLayerState<R extends Object> extends State<PolylineLayer<R>>
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.
Expand All @@ -235,6 +231,16 @@ class _PolylineLayerState<R extends Object> extends State<PolylineLayer<R>>
// 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;
Expand Down
7 changes: 6 additions & 1 deletion lib/src/layer/polyline_layer/projected_polyline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ class _ProjectedPolyline<R extends Object> with HitDetectableElement<R> {
final Polyline<R> polyline;
final List<Offset> 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,
});
Expand Down
Loading