From cb4276ee2408fa21430cb0e66bf16b369cedcc54 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 28 Aug 2026 16:47:14 +0200 Subject: [PATCH 1/3] perf: Cache the rotation and skip redundant work in Transform2D Cache the sine and cosine of the angle so that matrix recalculation after position, scale or offset changes no longer recomputes them, keep the assigned matrix instead of recomputing it after the transformMatrix setter, batch the scale and position notifications in that setter, and avoid repeated reads in the point conversion methods. Adds a Transform2D micro-benchmark. Claude-Session: https://claude.ai/code/session_01H6f6E3vr3DM6iAWskqXpQT --- packages/flame/benchmark/README.md | 3 + packages/flame/benchmark/main.dart | 2 + .../benchmark/transform2d_benchmark.dart | 204 ++++++++++++++++++ packages/flame/lib/src/game/transform2d.dart | 136 +++++++----- 4 files changed, 296 insertions(+), 49 deletions(-) create mode 100644 packages/flame/benchmark/transform2d_benchmark.dart diff --git a/packages/flame/benchmark/README.md b/packages/flame/benchmark/README.md index 16c4521fa2b..b0e5a23bd6d 100644 --- a/packages/flame/benchmark/README.md +++ b/packages/flame/benchmark/README.md @@ -54,6 +54,9 @@ the benchmark results are printed above it. (`componentsAtPoint`) with and without the hit-test cache. - `collision_detection_benchmark.dart`: the collision detection system with flat and nested hitbox hierarchies. +- `transform2d_benchmark.dart`: the `Transform2D` hot paths: matrix + recalculation after position and angle changes, point conversion, matrix + assignment, and copying transforms. ## Writing benchmarks diff --git a/packages/flame/benchmark/main.dart b/packages/flame/benchmark/main.dart index 723946a20f9..37e062cc7e3 100644 --- a/packages/flame/benchmark/main.dart +++ b/packages/flame/benchmark/main.dart @@ -4,6 +4,7 @@ import 'component_churn_benchmark.dart' as component_churn; import 'components_at_point_benchmark.dart' as components_at_point; import 'priority_change_benchmark.dart' as priority_change; import 'render_components_benchmark.dart' as render_components; +import 'transform2d_benchmark.dart' as transform2d; import 'type_query_benchmark.dart' as type_query; import 'update_components_benchmark.dart' as update_components; @@ -16,4 +17,5 @@ Future main() async { await render_components.main(); await components_at_point.main(); await collision_detection.main(); + await transform2d.main(); } diff --git a/packages/flame/benchmark/transform2d_benchmark.dart b/packages/flame/benchmark/transform2d_benchmark.dart new file mode 100644 index 00000000000..03856b16314 --- /dev/null +++ b/packages/flame/benchmark/transform2d_benchmark.dart @@ -0,0 +1,204 @@ +import 'dart:math'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flame/game.dart'; + +const _amountTransforms = 1000; +const _amountPoints = 1000; + +/// The per-frame pattern of a moving component: the position changes every +/// tick and the matrix is requested again when the component is rendered. +class PositionUpdateBenchmark extends BenchmarkBase { + final Random random; + + late final List _transforms; + late final List _positions; + + PositionUpdateBenchmark(this.random) : super('Transform2D position update'); + + static void main() { + PositionUpdateBenchmark(Random(69420)).report(); + } + + @override + void setup() { + _transforms = _generateTransforms(random); + _positions = _generateVectors(random, _amountTransforms); + } + + @override + void run() { + for (var i = 0; i < _amountTransforms; i++) { + final transform = _transforms[i]; + final position = _positions[i]; + transform.position.setValues(position.x, position.y); + transform.transformMatrix; + } + } +} + +/// A rotating component: the angle changes every tick and the matrix is +/// requested again when the component is rendered. +class AngleUpdateBenchmark extends BenchmarkBase { + final Random random; + + late final List _transforms; + late final List _angles; + + AngleUpdateBenchmark(this.random) : super('Transform2D angle update'); + + static void main() { + AngleUpdateBenchmark(Random(69420)).report(); + } + + @override + void setup() { + _transforms = _generateTransforms(random); + _angles = List.generate( + _amountTransforms, + (_) => random.nextDouble() * 2 * pi, + ); + } + + @override + void run() { + for (var i = 0; i < _amountTransforms; i++) { + final transform = _transforms[i]; + transform.angle = _angles[i]; + transform.transformMatrix; + } + } +} + +/// Converting points back and forth through an unchanged transform, as done +/// by hit testing and the camera. +class PointConversionBenchmark extends BenchmarkBase { + final Random random; + + late final Transform2D _transform; + late final List _points; + final Vector2 _output = Vector2.zero(); + + PointConversionBenchmark(this.random) + : super('Transform2D localToGlobal/globalToLocal'); + + static void main() { + PointConversionBenchmark(Random(69420)).report(); + } + + @override + void setup() { + _transform = _generateTransforms(random).first; + _points = _generateVectors(random, _amountPoints); + } + + @override + void run() { + for (var i = 0; i < _amountPoints; i++) { + final point = _points[i]; + _transform.localToGlobal(point, output: _output); + _transform.globalToLocal(point, output: _output); + } + } +} + +/// Assigning a full matrix to a transform and reading it back. +class MatrixAssignmentBenchmark extends BenchmarkBase { + final Random random; + + late final List _transforms; + late final List _matrices; + + MatrixAssignmentBenchmark(this.random) + : super('Transform2D transformMatrix setter'); + + static void main() { + MatrixAssignmentBenchmark(Random(69420)).report(); + } + + @override + void setup() { + _transforms = _generateTransforms(random); + _matrices = _generateTransforms(random) + .map((transform) => transform.transformMatrix.clone()) + .toList(growable: false); + } + + @override + void run() { + for (var i = 0; i < _amountTransforms; i++) { + final transform = _transforms[i]; + transform.transformMatrix = _matrices[i]; + transform.transformMatrix; + } + } +} + +/// Copying one transform into another and reading the resulting matrix. +class SetFromBenchmark extends BenchmarkBase { + final Random random; + + late final List _transforms; + late final List _sources; + + SetFromBenchmark(this.random) : super('Transform2D setFrom'); + + static void main() { + SetFromBenchmark(Random(69420)).report(); + } + + @override + void setup() { + _transforms = _generateTransforms(random); + _sources = _generateTransforms(random); + for (final source in _sources) { + source.transformMatrix; + } + } + + @override + void run() { + for (var i = 0; i < _amountTransforms; i++) { + final transform = _transforms[i]; + transform.setFrom(_sources[i]); + transform.transformMatrix; + } + } +} + +List _generateTransforms(Random random) { + return List.generate( + _amountTransforms, + (_) => Transform2D() + ..position.setValues( + random.nextDouble() * 1000, + random.nextDouble() * 1000, + ) + ..angle = random.nextDouble() * 2 * pi + ..scale.setValues( + random.nextDouble() * 3 + 0.5, + random.nextDouble() * 3 + 0.5, + ) + ..offset.setValues( + random.nextDouble() * -100, + random.nextDouble() * -100, + ), + growable: false, + ); +} + +List _generateVectors(Random random, int amount) { + return List.generate( + amount, + (_) => Vector2(random.nextDouble() * 1000, random.nextDouble() * 1000), + growable: false, + ); +} + +Future main() async { + PositionUpdateBenchmark.main(); + AngleUpdateBenchmark.main(); + PointConversionBenchmark.main(); + MatrixAssignmentBenchmark.main(); + SetFromBenchmark.main(); +} diff --git a/packages/flame/lib/src/game/transform2d.dart b/packages/flame/lib/src/game/transform2d.dart index b880cf17628..3dd4c3c2168 100644 --- a/packages/flame/lib/src/game/transform2d.dart +++ b/packages/flame/lib/src/game/transform2d.dart @@ -31,8 +31,11 @@ import 'package:vector_math/vector_math.dart'; class Transform2D extends ChangeNotifier { final Matrix4 _transformMatrix; bool _recalculate; + bool _recalculateRotation; bool _isBatchUpdating = false; double _angle; + double _cosAngle; + double _sinAngle; final NotifyingVector2 _position; final NotifyingVector2 _scale; final NotifyingVector2 _offset; @@ -40,7 +43,10 @@ class Transform2D extends ChangeNotifier { Transform2D() : _transformMatrix = Matrix4.identity(), _recalculate = true, + _recalculateRotation = false, _angle = 0, + _cosAngle = 1, + _sinAngle = 0, _position = NotifyingVector2.zero(), _scale = NotifyingVector2.all(1), _offset = NotifyingVector2.zero() { @@ -49,11 +55,7 @@ class Transform2D extends ChangeNotifier { _offset.addListener(_markAsModified); } - factory Transform2D.copy(Transform2D other) => Transform2D() - ..angle = other.angle - ..position = other.position - ..scale = other.scale - ..offset = other.offset; + factory Transform2D.copy(Transform2D other) => Transform2D()..setFrom(other); /// Clone of this. Transform2D clone() => Transform2D.copy(this); @@ -61,11 +63,15 @@ class Transform2D extends ChangeNotifier { /// Set this to the values of the [other] [Transform2D]. void setFrom(Transform2D other) { _isBatchUpdating = true; - angle = other.angle; - position = other.position; - scale = other.scale; - offset = other.offset; + _angle = other._angle; + _cosAngle = other._cosAngle; + _sinAngle = other._sinAngle; + _recalculateRotation = other._recalculateRotation; + _position.setFrom(other._position); + _scale.setFrom(other._scale); + _offset.setFrom(other._offset); _isBatchUpdating = false; + _recalculate = true; notifyListeners(); } @@ -77,16 +83,16 @@ class Transform2D extends ChangeNotifier { /// /// The [tolerance] parameter is in absolute units, not relative. bool closeTo(Transform2D other, {double tolerance = 1e-10}) { - final deltaAngle = (angle - other.angle) % geometry.tau; + final deltaAngle = (_angle - other._angle) % geometry.tau; assert(deltaAngle >= 0); return (deltaAngle <= tolerance || deltaAngle >= geometry.tau - tolerance) && - (position.x - other.position.x).abs() <= tolerance && - (position.y - other.position.y).abs() <= tolerance && - (scale.x - other.scale.x).abs() <= tolerance && - (scale.y - other.scale.y).abs() <= tolerance && - (offset.x - other.offset.x).abs() <= tolerance && - (offset.y - other.offset.y).abs() <= tolerance; + (_position.x - other._position.x).abs() <= tolerance && + (_position.y - other._position.y).abs() <= tolerance && + (_scale.x - other._scale.x).abs() <= tolerance && + (_scale.y - other._scale.y).abs() <= tolerance && + (_offset.x - other._offset.x).abs() <= tolerance && + (_offset.y - other._offset.y).abs() <= tolerance; } /// The translation part of the transform. This translation is applied @@ -111,15 +117,13 @@ class Transform2D extends ChangeNotifier { double get angle => _angle; set angle(double a) { _angle = a; + _recalculateRotation = true; _markAsModified(); } /// Similar to [angle], but uses degrees instead of radians. double get angleDegrees => _angle * (360 / geometry.tau); - set angleDegrees(double a) { - _angle = a * (geometry.tau / 360); - _markAsModified(); - } + set angleDegrees(double a) => angle = a * (geometry.tau / 360); /// The scale part of the transform. The default scale factor is (1, 1), /// a scale greater than 1 corresponds to expansion, and less than 1 is @@ -166,15 +170,28 @@ class Transform2D extends ChangeNotifier { // .. rotateZ(_angle) // .. scale(_scale.x, _scale.y, 1) // .. translate(_offset.x, _offset.y); + if (_recalculateRotation) { + _cosAngle = math.cos(_angle); + _sinAngle = math.sin(_angle); + _recalculateRotation = false; + } + final cosA = _cosAngle; + final sinA = _sinAngle; + final scaleX = _scale.x; + final scaleY = _scale.y; + final offsetX = _offset.x; + final offsetY = _offset.y; + final m0 = cosA * scaleX; + final m1 = sinA * scaleX; + final m4 = -sinA * scaleY; + final m5 = cosA * scaleY; final m = _transformMatrix.storage; - final cosA = math.cos(_angle); - final sinA = math.sin(_angle); - m[0] = cosA * _scale.x; - m[1] = sinA * _scale.x; - m[4] = -sinA * _scale.y; - m[5] = cosA * _scale.y; - m[12] = _position.x + m[0] * _offset.x + m[4] * _offset.y; - m[13] = _position.y + m[1] * _offset.x + m[5] * _offset.y; + m[0] = m0; + m[1] = m1; + m[4] = m4; + m[5] = m5; + m[12] = _position.x + m0 * offsetX + m4 * offsetY; + m[13] = _position.y + m1 * offsetX + m5 * offsetY; _recalculate = false; } return _transformMatrix; @@ -194,26 +211,39 @@ class Transform2D extends ChangeNotifier { value.storage[15] == 1, 'The provided matrix is not a valid 2D transformation', ); - _transformMatrix.setFrom(value); - _recalculate = false; + final m = _transformMatrix.storage; + m.setAll(0, value.storage); - final storage = _transformMatrix.storage; - _isBatchUpdating = true; - final determinant = storage[0] * storage[5] - storage[1] * storage[4]; - _scale.x = math.sqrt(storage[0] * storage[0] + storage[1] * storage[1]); - if (_scale.x == 0) { - _angle = math.atan2(-storage[4], storage[5]); - _scale.y = math.sqrt(storage[4] * storage[4] + storage[5] * storage[5]); + final m0 = m[0]; + final m1 = m[1]; + final m4 = m[4]; + final m5 = m[5]; + final double scaleX; + final double scaleY; + final double angle; + final scaleXSquared = m0 * m0 + m1 * m1; + if (scaleXSquared == 0) { + scaleX = 0; + scaleY = math.sqrt(m4 * m4 + m5 * m5); + angle = math.atan2(-m4, m5); } else { - _angle = math.atan2(storage[1], storage[0]); - _scale.y = determinant / _scale.x; + scaleX = math.sqrt(scaleXSquared); + scaleY = (m0 * m5 - m1 * m4) / scaleX; + angle = math.atan2(m1, m0); } + final offsetX = _offset.x; + final offsetY = _offset.y; - _position.x = - storage[12] - (storage[0] * _offset.x + storage[4] * _offset.y); - _position.y = - storage[13] - (storage[1] * _offset.x + storage[5] * _offset.y); + _isBatchUpdating = true; + _angle = angle; + _recalculateRotation = true; + _scale.setValues(scaleX, scaleY); + _position.setValues( + m[12] - (m0 * offsetX + m4 * offsetY), + m[13] - (m1 * offsetX + m5 * offsetY), + ); _isBatchUpdating = false; + _recalculate = false; notifyListeners(); } @@ -224,8 +254,10 @@ class Transform2D extends ChangeNotifier { /// creating a new Vector2 object in this method. Vector2 localToGlobal(Vector2 point, {Vector2? output}) { final m = transformMatrix.storage; - final x = m[0] * point.x + m[4] * point.y + m[12]; - final y = m[1] * point.x + m[5] * point.y + m[13]; + final px = point.x; + final py = point.y; + final x = m[0] * px + m[4] * py + m[12]; + final y = m[1] * px + m[5] * py + m[13]; return (output?..setValues(x, y)) ?? Vector2(x, y); } @@ -243,12 +275,18 @@ class Transform2D extends ChangeNotifier { // `m[0]`, `m[1]`, `m[4]`, `m[5]`, `m[12]`, and `m[13]` are modified. // This greatly simplifies computation of the inverse matrix. final m = transformMatrix.storage; - var det = m[0] * m[5] - m[1] * m[4]; + final m0 = m[0]; + final m1 = m[1]; + final m4 = m[4]; + final m5 = m[5]; + var det = m0 * m5 - m1 * m4; if (det != 0) { det = 1 / det; } - final x = ((point.x - m[12]) * m[5] - (point.y - m[13]) * m[4]) * det; - final y = ((point.y - m[13]) * m[0] - (point.x - m[12]) * m[1]) * det; + final dx = point.x - m[12]; + final dy = point.y - m[13]; + final x = (dx * m5 - dy * m4) * det; + final y = (dy * m0 - dx * m1) * det; return (output?..setValues(x, y)) ?? Vector2(x, y); } @@ -269,7 +307,7 @@ class Transform2D extends ChangeNotifier { /// Whether the transform includes a reflection, i.e. it flips the orientation /// of the coordinate system. - bool get hasReflection => _scale.x.sign * _scale.y.sign == -1; + bool get hasReflection => _scale.x * _scale.y < 0; void _markAsModified() { _recalculate = true; From 2389c689476e6a4dd495b2ce61418382e7fe0a7d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 28 Aug 2026 17:02:18 +0200 Subject: [PATCH 2/3] Compute the translation from the stored float32 rotation cells Keeps the transform matrix bit-identical to the previous implementation, which the snapshot golden depends on. Claude-Session: https://claude.ai/code/session_01H6f6E3vr3DM6iAWskqXpQT --- packages/flame/lib/src/game/transform2d.dart | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/flame/lib/src/game/transform2d.dart b/packages/flame/lib/src/game/transform2d.dart index 3dd4c3c2168..ab8b732891c 100644 --- a/packages/flame/lib/src/game/transform2d.dart +++ b/packages/flame/lib/src/game/transform2d.dart @@ -181,17 +181,13 @@ class Transform2D extends ChangeNotifier { final scaleY = _scale.y; final offsetX = _offset.x; final offsetY = _offset.y; - final m0 = cosA * scaleX; - final m1 = sinA * scaleX; - final m4 = -sinA * scaleY; - final m5 = cosA * scaleY; final m = _transformMatrix.storage; - m[0] = m0; - m[1] = m1; - m[4] = m4; - m[5] = m5; - m[12] = _position.x + m0 * offsetX + m4 * offsetY; - m[13] = _position.y + m1 * offsetX + m5 * offsetY; + m[0] = cosA * scaleX; + m[1] = sinA * scaleX; + m[4] = -sinA * scaleY; + m[5] = cosA * scaleY; + m[12] = _position.x + m[0] * offsetX + m[4] * offsetY; + m[13] = _position.y + m[1] * offsetX + m[5] * offsetY; _recalculate = false; } return _transformMatrix; From 4931a51b5e2f4ad1f451c9e30f377209656ba034 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 28 Aug 2026 17:09:17 +0200 Subject: [PATCH 3/3] Use full names for the matrix locals in Transform2D Claude-Session: https://claude.ai/code/session_01H6f6E3vr3DM6iAWskqXpQT --- packages/flame/lib/src/game/transform2d.dart | 81 ++++++++++---------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/flame/lib/src/game/transform2d.dart b/packages/flame/lib/src/game/transform2d.dart index ab8b732891c..1ca846ed977 100644 --- a/packages/flame/lib/src/game/transform2d.dart +++ b/packages/flame/lib/src/game/transform2d.dart @@ -175,19 +175,19 @@ class Transform2D extends ChangeNotifier { _sinAngle = math.sin(_angle); _recalculateRotation = false; } - final cosA = _cosAngle; - final sinA = _sinAngle; + final cosAngle = _cosAngle; + final sinAngle = _sinAngle; final scaleX = _scale.x; final scaleY = _scale.y; final offsetX = _offset.x; final offsetY = _offset.y; - final m = _transformMatrix.storage; - m[0] = cosA * scaleX; - m[1] = sinA * scaleX; - m[4] = -sinA * scaleY; - m[5] = cosA * scaleY; - m[12] = _position.x + m[0] * offsetX + m[4] * offsetY; - m[13] = _position.y + m[1] * offsetX + m[5] * offsetY; + final storage = _transformMatrix.storage; + storage[0] = cosAngle * scaleX; + storage[1] = sinAngle * scaleX; + storage[4] = -sinAngle * scaleY; + storage[5] = cosAngle * scaleY; + storage[12] = _position.x + storage[0] * offsetX + storage[4] * offsetY; + storage[13] = _position.y + storage[1] * offsetX + storage[5] * offsetY; _recalculate = false; } return _transformMatrix; @@ -207,25 +207,25 @@ class Transform2D extends ChangeNotifier { value.storage[15] == 1, 'The provided matrix is not a valid 2D transformation', ); - final m = _transformMatrix.storage; - m.setAll(0, value.storage); + final storage = _transformMatrix.storage; + storage.setAll(0, value.storage); - final m0 = m[0]; - final m1 = m[1]; - final m4 = m[4]; - final m5 = m[5]; + final xAxisX = storage[0]; + final xAxisY = storage[1]; + final yAxisX = storage[4]; + final yAxisY = storage[5]; final double scaleX; final double scaleY; final double angle; - final scaleXSquared = m0 * m0 + m1 * m1; + final scaleXSquared = xAxisX * xAxisX + xAxisY * xAxisY; if (scaleXSquared == 0) { scaleX = 0; - scaleY = math.sqrt(m4 * m4 + m5 * m5); - angle = math.atan2(-m4, m5); + scaleY = math.sqrt(yAxisX * yAxisX + yAxisY * yAxisY); + angle = math.atan2(-yAxisX, yAxisY); } else { scaleX = math.sqrt(scaleXSquared); - scaleY = (m0 * m5 - m1 * m4) / scaleX; - angle = math.atan2(m1, m0); + scaleY = (xAxisX * yAxisY - xAxisY * yAxisX) / scaleX; + angle = math.atan2(xAxisY, xAxisX); } final offsetX = _offset.x; final offsetY = _offset.y; @@ -235,8 +235,8 @@ class Transform2D extends ChangeNotifier { _recalculateRotation = true; _scale.setValues(scaleX, scaleY); _position.setValues( - m[12] - (m0 * offsetX + m4 * offsetY), - m[13] - (m1 * offsetX + m5 * offsetY), + storage[12] - (xAxisX * offsetX + yAxisX * offsetY), + storage[13] - (xAxisY * offsetX + yAxisY * offsetY), ); _isBatchUpdating = false; _recalculate = false; @@ -249,11 +249,11 @@ class Transform2D extends ChangeNotifier { /// Use [output] to send in a Vector2 object that will be used to avoid /// creating a new Vector2 object in this method. Vector2 localToGlobal(Vector2 point, {Vector2? output}) { - final m = transformMatrix.storage; - final px = point.x; - final py = point.y; - final x = m[0] * px + m[4] * py + m[12]; - final y = m[1] * px + m[5] * py + m[13]; + final storage = transformMatrix.storage; + final pointX = point.x; + final pointY = point.y; + final x = storage[0] * pointX + storage[4] * pointY + storage[12]; + final y = storage[1] * pointX + storage[5] * pointY + storage[13]; return (output?..setValues(x, y)) ?? Vector2(x, y); } @@ -268,21 +268,22 @@ class Transform2D extends ChangeNotifier { /// creating a new Vector2 object in this method. Vector2 globalToLocal(Vector2 point, {Vector2? output}) { // Here we rely on the fact that in the transform matrix only elements - // `m[0]`, `m[1]`, `m[4]`, `m[5]`, `m[12]`, and `m[13]` are modified. + // `storage[0]`, `storage[1]`, `storage[4]`, `storage[5]`, `storage[12]`, + // and `storage[13]` are modified. // This greatly simplifies computation of the inverse matrix. - final m = transformMatrix.storage; - final m0 = m[0]; - final m1 = m[1]; - final m4 = m[4]; - final m5 = m[5]; - var det = m0 * m5 - m1 * m4; - if (det != 0) { - det = 1 / det; + final storage = transformMatrix.storage; + final xAxisX = storage[0]; + final xAxisY = storage[1]; + final yAxisX = storage[4]; + final yAxisY = storage[5]; + var determinant = xAxisX * yAxisY - xAxisY * yAxisX; + if (determinant != 0) { + determinant = 1 / determinant; } - final dx = point.x - m[12]; - final dy = point.y - m[13]; - final x = (dx * m5 - dy * m4) * det; - final y = (dy * m0 - dx * m1) * det; + final deltaX = point.x - storage[12]; + final deltaY = point.y - storage[13]; + final x = (deltaX * yAxisY - deltaY * yAxisX) * determinant; + final y = (deltaY * xAxisX - deltaX * xAxisY) * determinant; return (output?..setValues(x, y)) ?? Vector2(x, y); }