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');
});
}
31 changes: 20 additions & 11 deletions lib/src/layer/polygon_layer/polygon_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -228,19 +228,28 @@ class _PolygonLayerState<R extends Object> extends State<PolygonLayer<R>>
(i) {
final culledPolygon = culled[i];

final points = culledPolygon.holePoints.isEmpty
? culledPolygon.points
: culledPolygon.points
.followedBy(culledPolygon.holePoints.expand((e) => e));
// Flatten the points and hole points into the coordinate list
// directly, avoiding quadratic `elementAt` calls on a lazy
// concatenated iterable.
var totalLength = culledPolygon.points.length;
for (final hole in culledPolygon.holePoints) {
totalLength += hole.length;
}
final coords = Float64List(totalLength * 2);
var ii = 0;
for (final point in culledPolygon.points) {
coords[ii++] = point.dx;
coords[ii++] = point.dy;
}
for (final hole in culledPolygon.holePoints) {
for (final point in hole) {
coords[ii++] = point.dx;
coords[ii++] = point.dy;
}
}

return Earcut.triangulateRaw(
List.generate(
points.length * 2,
(ii) => ii.isEven
? points.elementAt(ii ~/ 2).dx
: points.elementAt(ii ~/ 2).dy,
growable: false,
),
coords,
holeIndices: culledPolygon.holePoints.isEmpty
? null
: _generateHolesIndices(culledPolygon)
Expand Down
29 changes: 23 additions & 6 deletions lib/src/misc/offsets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,26 @@ class OffsetHelper {
final crs = camera.crs;
final zoomScale = crs.scale(camera.zoom);

final realPoints = holePoints == null || holePoints.isEmpty
? points
: points.followedBy(holePoints.expand((e) => e));
// Flatten the points and hole points into a single list, so that the
// loops below can use constant-time indexing. Using a lazy concatenated
// iterable here makes `elementAt` walk from the start on every call,
// which is quadratic in the total number of points.
final List<Offset> realPoints;
if (holePoints == null || holePoints.isEmpty) {
realPoints = points;
} else {
var totalLength = points.length;
for (final hole in holePoints) {
totalLength += hole.length;
}
realPoints = List<Offset>.filled(totalLength, Offset.zero)
..setRange(0, points.length, points);
var start = points.length;
for (final hole in holePoints) {
realPoints.setRange(start, start + hole.length, hole);
start += hole.length;
}
}

final ox = -_origin.dx;
final oy = -_origin.dy;
Expand All @@ -92,7 +109,7 @@ class OffsetHelper {
-worldWidth,
];
final halfScreenWidth = camera.size.width / 2;
final p = realPoints.elementAt(0);
final p = realPoints[0];
late double result;
late double bestX;
for (int i = 0; i < addedWidths.length; i++) {
Expand All @@ -119,7 +136,7 @@ class OffsetHelper {
if (crs case final CrsWithStaticTransformation crs) {
final v = List<Offset>.filled(len, Offset.zero, growable: true);
for (int i = 0; i < len; ++i) {
final p = realPoints.elementAt(i);
final p = realPoints[i];
final (x, y) = crs.transform(p.dx + addedWorldWidth, p.dy, zoomScale);
v[i] = Offset(x + ox + shift, y + oy);
}
Expand All @@ -128,7 +145,7 @@ class OffsetHelper {

final v = List<Offset>.filled(len, Offset.zero, growable: true);
for (int i = 0; i < len; ++i) {
final p = realPoints.elementAt(i);
final p = realPoints[i];
final (x, y) = crs.transform(p.dx + addedWorldWidth, p.dy, zoomScale);
v[i] = Offset(x + ox + shift, y + oy);
}
Expand Down
Loading