From c793c73fd3c9d443efee55094d1bbe54ba55fe4b Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sun, 4 Oct 2026 22:38:31 +0200 Subject: [PATCH 01/21] feat: Add Sprite.contour and ImageExtension.contour to trace image outlines Trace the outlines of the non-transparent pixels of an image as the closed contours of a Path, to build hitboxes that follow the outline of a sprite, like a PathHitbox or a PolygonHitbox.fromPath. The outlines are found with marching squares on the alpha of the pixel centers, interpolated at the alphaThreshold, so that they follow the anti-aliased edges more closely than a pixel. Only the outer outlines are kept, and each separate part of the image gives a separate contour. - ImageExtension.contour traces a region of an image, scaled to a size. - ImageExtension.contourFromPixels does the same from raw RGBA pixels. - Sprite.contour traces the region of the sprite. The SpriteBodyExample of flame_forge2d now drops flames whose bodies collide as the convex pieces of the outline of the sprite, which the new Show pieces knob draws. --- doc/flame/collision_detection.md | 40 +++ .../flame_forge2d/flame_forge2d.dart | 28 +- .../flame_forge2d/sprite_body_example.dart | 140 ++++++++- .../flame_forge2d/utils/convex_pieces.dart | 272 ++++++++++++++++++ packages/flame/lib/src/extensions/image.dart | 77 +++++ .../lib/src/geometry/alpha_contours.dart | 180 ++++++++++++ packages/flame/lib/src/sprite.dart | 18 ++ .../test/extensions/image_contour_test.dart | 232 +++++++++++++++ 8 files changed, 979 insertions(+), 8 deletions(-) create mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart create mode 100644 packages/flame/lib/src/geometry/alpha_contours.dart create mode 100644 packages/flame/test/extensions/image_contour_test.dart diff --git a/doc/flame/collision_detection.md b/doc/flame/collision_detection.md index 89017b64a20..67cc871ee2d 100644 --- a/doc/flame/collision_detection.md +++ b/doc/flame/collision_detection.md @@ -330,6 +330,46 @@ costs accordingly. Keep the `filter` on unless the inner contours matter, and us `sampling` that still follows the outline closely enough. +### Hitboxes from the outline of a sprite + +When the outline of a sprite is not available as a `Path`, it can be traced from the sprite itself +with `Sprite.contour`, which gives a closed contour for each of the separate parts of the sprite +that are not transparent, leaving out the holes in them. The same is available for a region of an +`Image` with `ImageExtension.contour`, and for raw pixels with `ImageExtension.contourFromPixels`. + +A pixel belongs to the outlined parts when its alpha is at least the `alphaThreshold`, which is +`0.5` by default, and the contours pass between the pixels where the alpha reaches the threshold, +so that they follow the anti-aliased edges of the sprite more closely than a pixel. Use a low +threshold, like `1 / 255`, to include every visible pixel. + +The contours are relative to the top left corner of the sprite, and they are scaled to the given +`size`, so that they line up with the sprite rendered by a `SpriteComponent` of that size. Since a +`PathHitbox` moves its path to its own origin, place it at the top left corner of the contours: + +```dart +class Spaceship extends SpriteComponent with CollisionCallbacks { + Spaceship(Sprite sprite) : super(sprite: sprite, size: Vector2(64, 48)); + + @override + Future onLoad() async { + await super.onLoad(); + final outline = await sprite!.contour(size: size); + add( + PathHitbox( + path: outline, + position: outline.getBounds().topLeft.toVector2(), + sampling: 2, + ), + ); + } +} +``` + +A `PolygonHitbox.fromPath(outline, sampling: 2)` places itself and is enough when the sprite has a +single part. Tracing the outline reads back the pixels of the image, so do it while loading, and +only once for all the components that share the same sprite. + + ### RectangleHitbox The `RectangleHitbox` has the same constructors as the [](components/shape_components.md#rectanglecomponent), diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart index e51224e1e29..7c6cfc50668 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart @@ -20,6 +20,7 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/sprite_body_exam import 'package:examples/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/widget_example.dart'; import 'package:flame/game.dart'; +import 'package:flutter/widgets.dart'; import 'package:widgetbook/widgetbook.dart'; String link(String example) => @@ -55,7 +56,9 @@ WidgetbookComponent forge2DStories() { ), ExampleUseCase( name: 'Sprite Bodies', - builder: (_) => GameWidget(game: SpriteBodyExample()), + builder: (context) => _SpriteBodyStory( + showPieces: context.knobs.boolean(label: 'Show pieces'), + ), codeLink: link('sprite_body_example.dart'), info: SpriteBodyExample.description, ), @@ -154,3 +157,26 @@ WidgetbookComponent jointsStories() { ], ); } + +/// Hosts a single [SpriteBodyExample] and applies the [showPieces] knob to it, +/// so that changing the knob doesn't restart the game. +class const _SpriteBodyStory({required final bool showPieces}) + extends StatefulWidget { + @override + State<_SpriteBodyStory> createState() => _SpriteBodyStoryState(); +} + +class _SpriteBodyStoryState() extends State<_SpriteBodyStory> { + late final _game = SpriteBodyExample(showPieces: widget.showPieces); + + @override + void didUpdateWidget(_SpriteBodyStory oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.showPieces != oldWidget.showPieces) { + (_game.world as SpriteBodyWorld).showPieces = widget.showPieces; + } + } + + @override + Widget build(BuildContext context) => GameWidget(game: _game); +} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 04e9ecd708f..f03696e2571 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -1,38 +1,164 @@ import 'dart:math'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; +import 'package:flame/extensions.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class SpriteBodyExample() extends Forge2DExampleGame { +class SpriteBodyExample({bool showPieces = false}) extends Forge2DExampleGame { static const String description = ''' - In this example we show how to add a sprite on top of a `BodyComponent`. - Tap the screen to add more pizzas. + In this example we show how to add a sprite on top of a `BodyComponent` + whose shape follows the outline of the sprite. + + The outline is traced from the pixels of the image that are not + transparent, with `Sprite.contour`, and the body collides as its convex + pieces, which the Show pieces knob draws. + + Tap the screen to add more flames. '''; this : super( gravity: Vector2(0, 10.0), - world: SpriteBodyWorld(), + world: SpriteBodyWorld(showPieces: showPieces), ); } -class SpriteBodyWorld() +class SpriteBodyWorld({bool showPieces = false}) extends Forge2DWorld with TapCallbacks, HasGameRef { + this { + _showPieces = showPieces; + } + + /// The width of the flames, in meters. + static const flameWidth = 8.0; + + late final Sprite _sprite; + + /// The size of the flames, in meters, with the aspect ratio of the + /// [_sprite]. + late final Vector2 _size; + + /// The convex pieces of the outline of the [_sprite], which all the + /// flames share, since tracing it reads back the pixels of the image. + late final List> _pieces; + + bool _showPieces = false; + + /// Whether the convex pieces of the flames are drawn, which applies to the + /// flames already added too. + bool get showPieces => _showPieces; + set showPieces(bool value) { + _showPieces = value; + for (final flame in children.whereType()) { + flame.renderBody = value; + } + } + @override Future onLoad() async { await super.onLoad(); addAll(createBoundaries(gameRef)); + _sprite = await gameRef.loadSprite('assets/images/flame.png'); + _size = _sprite.srcSize..scale(flameWidth / _sprite.srcSize.x); + _pieces = await FlameBody.piecesOf( + _sprite, + _size, + gameRef.metersToPixels, + ); } @override void onTapDown(TapDownEvent info) { super.onTapDown(info); - final position = info.localPosition; - add(Pizza(position, size: Vector2(10, 15))); + add( + FlameBody( + info.localPosition, + sprite: _sprite, + pieces: _pieces, + size: _size, + )..renderBody = showPieces, + ); + } +} + +/// A body that is drawn by a [sprite] of the given [size], in meters, and +/// collides as the convex [pieces] of its outline. +class FlameBody( + final Vector2 initialPosition, { + required final Sprite sprite, + required final List> pieces, + required final Vector2 size, +}) extends BodyComponent { + this : super(renderBody: false); + + /// The linear slop of Box2D in meters, as `Tolerances.linearSlop` with the + /// default length units, which is not used here as it needs the native + /// library: the points of a polygon closer than 4 times it are welded, and + /// the ones closer than twice it to an edge are dropped, see + /// `b2ComputeHull`. + static const linearSlop = 0.005; + + /// The convex pieces of the outline of the [sprite], drawn with the given + /// [size] in meters, relative to its center. + /// + /// The outline is traced in pixels rather than in meters, with [pixels] per + /// meter, so that the default sampling of the [PathComponent] that makes + /// its polygons follows it closely. The parts of the sprite that are apart + /// give separate polygons, and so separate pieces. + static Future>> piecesOf( + Sprite sprite, + Vector2 size, + double pixels, + ) async { + final pixelSize = size * pixels; + final outline = await sprite.contour(size: pixelSize); + // The component moves the outline to its origin, so it is placed where + // the center of the outline is, relative to the center of the sprite. + final center = outline.getBounds().center.toVector2(); + final component = PathComponent( + path: outline, + position: (center - pixelSize / 2) / pixels, + anchor: Anchor.center, + scale: Vector2.all(1 / pixels), + ); + return [ + for (final polygon in component.polygons) + ...convexPieces( + [for (final vertex in polygon) component.positionOf(vertex)], + minDistance: 4 * linearSlop, + minWidth: 2 * linearSlop, + ), + ]; + } + + @override + Future onLoad() async { + await super.onLoad(); + add(SpriteComponent(sprite: sprite, size: size, anchor: Anchor.center)); + } + + @override + Body createBody() { + final shapeDef = ShapeDef( + userData: this, // To be able to determine object in collision + material: SurfaceMaterial(restitution: 0.4, friction: 0.5), + ); + + final bodyDef = BodyDef( + position: initialPosition, + rotation: Rot.fromAngle((initialPosition.x + initialPosition.y) / 2 * pi), + type: BodyType.dynamic, + ); + final body = world.createBody(bodyDef); + for (final piece in pieces) { + body.createShape(Polygon(piece), shapeDef); + } + return body; } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart new file mode 100644 index 00000000000..198316085a6 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart @@ -0,0 +1,272 @@ +import 'dart:math'; + +import 'package:flame/extensions.dart'; + +/// Splits a simple [polygon] into convex polygons of at most [maxVertices] +/// vertices each, like the ones that physics engines such as Box2D need. +/// +/// The polygon is split into triangles by ear clipping first, which are then +/// merged with their neighbors as long as they stay convex and within +/// [maxVertices] (the Hertel-Mehlhorn algorithm). +/// +/// Physics engines reject the polygons that are too small for their +/// tolerances, so the [polygon] is cleaned up first: of the consecutive +/// vertices closer than [minDistance], only the first one is kept, and the +/// vertices closer than half of it to the line through their neighbors are +/// left out. Then, the pieces narrower than [minWidth] are left out, as well +/// as the ones with fewer than three vertices that are [minDistance] apart. +/// For Box2D, [minDistance] is 4 times its linear slop and [minWidth] twice +/// it, see `b2ComputeHull`. +/// +/// The pieces reuse the vertices of the [polygon], in the same direction. +List> convexPieces( + List polygon, { + int maxVertices = 8, + double minDistance = 0, + double minWidth = 0, +}) { + assert(maxVertices >= 3, 'A piece needs at least three vertices'); + final vertices = _clean(polygon, minDistance); + final n = vertices.length; + if (n < 3) { + return const []; + } + // The pieces are worked out on indices of vertices going counterclockwise, + // that is with a positive area, and turned back into vertices at the end. + final isClockwise = _doubleArea(vertices) < 0; + final indices = List.generate(n, (i) => isClockwise ? n - 1 - i : i); + final pieces = _triangulate(vertices, indices); + // Corners that are reflex by less than half of the minimum width are + // accepted as convex, since the engine straightens them anyway. + _merge(vertices, pieces, maxVertices, minWidth / 2); + return [ + for (final piece in pieces) + if (_isWideEnough(vertices, piece, minDistance, minWidth)) + [for (final i in isClockwise ? piece.reversed : piece) vertices[i]], + ]; +} + +/// The vertices of the [polygon] without the ones closer than [minDistance] +/// to the previous one, nor the ones closer than half of it to the line +/// through their neighbors. +List _clean(List polygon, double minDistance) { + final vertices = []; + for (final vertex in polygon) { + if (vertices.isEmpty || !_isWelded(vertex, vertices.last, minDistance)) { + vertices.add(vertex); + } + } + while (vertices.length > 1 && + _isWelded(vertices.last, vertices.first, minDistance)) { + vertices.removeLast(); + } + var hasRemoved = true; + while (hasRemoved && vertices.length > 3) { + hasRemoved = false; + for (var i = 0; i < vertices.length; i++) { + final m = vertices.length; + final a = vertices[(i - 1 + m) % m]; + final b = vertices[i]; + final c = vertices[(i + 1) % m]; + final chord = a.distanceTo(c); + // A vertex whose neighbors coincide is the tip of a spike. + final distance = chord == 0 + ? a.distanceTo(b) + : _cross(a, b, c).abs() / chord; + if (distance < minDistance / 2) { + vertices.removeAt(i); + hasRemoved = true; + break; + } + } + } + return vertices; +} + +/// Whether the vertices [a] and [b] are the same, or closer than +/// [minDistance]. +bool _isWelded(Vector2 a, Vector2 b, double minDistance) { + final distance = a.distanceTo(b); + return distance == 0 || distance < minDistance; +} + +/// Twice the signed area of the [polygon], positive if it is counterclockwise. +double _doubleArea(List polygon) { + var area = 0.0; + for (var i = 0; i < polygon.length; i++) { + final a = polygon[i]; + final b = polygon[(i + 1) % polygon.length]; + area += a.x * b.y - b.x * a.y; + } + return area; +} + +/// Twice the signed area of the triangle [a], [b], [c], positive if it is +/// counterclockwise, that is if [b] is a convex corner. +double _cross(Vector2 a, Vector2 b, Vector2 c) { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +/// Splits the counterclockwise polygon given by the [indices] of [vertices] +/// into triangles, by clipping its ears. +List> _triangulate(List vertices, List indices) { + final remaining = List.of(indices); + final triangles = >[]; + while (remaining.length >= 3) { + final ear = _findEar(vertices, remaining); + if (ear == -1) { + // Only a polygon that crosses itself has no ears; what is left of it is + // left out. + break; + } + final m = remaining.length; + final triangle = [ + remaining[(ear - 1 + m) % m], + remaining[ear], + remaining[(ear + 1) % m], + ]; + remaining.removeAt(ear); + final [a, b, c] = [for (final i in triangle) vertices[i]]; + // Clipping a collinear vertex leaves no triangle. + if (_cross(a, b, c) > 0) { + triangles.add(triangle); + } + } + return triangles; +} + +/// The position in [remaining] of a vertex that can be clipped, or -1 if +/// there is none. +/// +/// A vertex can be clipped if its corner is not reflex and no other vertex +/// lies within the triangle that it makes with its neighbors. Collinear +/// vertices can be clipped too, as they only make a degenerate triangle. +int _findEar(List vertices, List remaining) { + final m = remaining.length; + for (var i = 0; i < m; i++) { + final a = vertices[remaining[(i - 1 + m) % m]]; + final b = vertices[remaining[i]]; + final c = vertices[remaining[(i + 1) % m]]; + if (_cross(a, b, c) < 0) { + continue; + } + var isEar = true; + for (final j in remaining) { + final p = vertices[j]; + if (p == a || p == b || p == c) { + continue; + } + if (_cross(a, b, p) >= 0 && + _cross(b, c, p) >= 0 && + _cross(c, a, p) >= 0) { + isEar = false; + break; + } + } + if (isEar) { + return i; + } + } + return -1; +} + +/// Merges the [pieces] that share an edge, as long as the result is convex +/// within the [tolerance] and has at most [maxVertices] vertices. +void _merge( + List vertices, + List> pieces, + int maxVertices, + double tolerance, +) { + var hasMerged = true; + while (hasMerged) { + hasMerged = false; + for (var i = 0; i < pieces.length; i++) { + for (var j = i + 1; j < pieces.length; j++) { + final merged = _union(pieces[i], pieces[j]); + if (merged != null && + merged.length <= maxVertices && + _isConvex(vertices, merged, tolerance)) { + pieces[i] = merged; + pieces.removeAt(j); + hasMerged = true; + // The grown piece may now be merged with the ones already checked. + j = i; + } + } + } + } +} + +/// The union of the counterclockwise pieces [a] and [b] if they share an +/// edge, or null otherwise. +List? _union(List a, List b) { + for (var k = 0; k < a.length; k++) { + final from = a[k]; + final to = a[(k + 1) % a.length]; + // The shared edge goes the other way around in [b]. + final l = b.indexOf(to); + if (l == -1 || b[(l + 1) % b.length] != from) { + continue; + } + return [ + // All of [a], starting after the shared edge and ending before it. + for (var i = 1; i <= a.length; i++) a[(k + i) % a.length], + // The vertices of [b] that are not on the shared edge. + for (var i = 2; i < b.length; i++) b[(l + i) % b.length], + ]; + } + return null; +} + +/// Whether the counterclockwise [piece] has no reflex corners, except for the +/// ones closer than the [tolerance] to the line through their neighbors. +bool _isConvex(List vertices, List piece, double tolerance) { + final m = piece.length; + for (var i = 0; i < m; i++) { + final a = vertices[piece[(i - 1 + m) % m]]; + final b = vertices[piece[i]]; + final c = vertices[piece[(i + 1) % m]]; + if (_cross(a, b, c) < -tolerance * a.distanceTo(c)) { + return false; + } + } + return true; +} + +/// Whether the convex [piece] keeps at least three vertices when the ones +/// closer than [minDistance] to a previous one are left out, and is at least +/// [minWidth] wide. +/// +/// The width of a convex polygon is the smallest distance between one of its +/// edges and the vertex farthest from it. +bool _isWideEnough( + List vertices, + List piece, + double minDistance, + double minWidth, +) { + final points = []; + for (final i in piece) { + final vertex = vertices[i]; + if (!points.any((point) => _isWelded(vertex, point, minDistance))) { + points.add(vertex); + } + } + if (points.length < 3) { + return false; + } + for (var i = 0; i < points.length; i++) { + final a = points[i]; + final b = points[(i + 1) % points.length]; + final length = a.distanceTo(b); + var farthest = 0.0; + for (final point in points) { + farthest = max(farthest, _cross(a, b, point).abs() / length); + } + if (farthest < minWidth) { + return false; + } + } + return true; +} diff --git a/packages/flame/lib/src/extensions/image.dart b/packages/flame/lib/src/extensions/image.dart index 89fca5461a2..0d4a4fab713 100644 --- a/packages/flame/lib/src/extensions/image.dart +++ b/packages/flame/lib/src/extensions/image.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'dart:typed_data'; import 'dart:ui'; +import 'package:flame/collisions.dart'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; +import 'package:flame/src/geometry/alpha_contours.dart'; export 'dart:ui' show Image; @@ -76,6 +78,81 @@ extension ImageExtension on Image { return await fromPixels(newPixelData, width, height); } + /// Returns the outlines of the parts of this image that are not transparent, + /// as the closed contours of a [Path]. + /// + /// Only the pixels in the [region] are considered, the whole image by + /// default, and the contours are relative to its top left corner. They are + /// scaled from the size of the [region] to the given [size], when there is + /// one. + /// + /// A pixel is part of the outlined area when its alpha is at least the + /// [alphaThreshold], which is between zero, excluded, and one. The outlines + /// pass between the pixels, at the point where the alpha would reach the + /// [alphaThreshold] if it changed linearly between their centers, so that the + /// anti-aliased edges of the image are followed more closely than a pixel. + /// Only the outer outlines are kept, not the ones of the holes in the + /// areas, and the separate areas give separate contours. + /// + /// The contours are meant to make hitboxes that follow the outline of the + /// image, like a [PathHitbox], or a [PolygonHitbox] from one of them, which + /// simplify them according to their sampling. + /// + /// Keep in mind that this reads back the pixels of the image, so it is an + /// expensive operation that should be done when loading, not in the game + /// loop. + Future contour({ + Rect? region, + Vector2? size, + double alphaThreshold = 0.5, + }) async { + return contourFromPixels( + await pixelsInUint8(), + width, + height, + region: region, + size: size, + alphaThreshold: alphaThreshold, + ); + } + + /// Returns the outlines of the parts of a raw list of [pixels] that are not + /// transparent, as the closed contours of a [Path]. + /// + /// The [pixels] are in the RGBA format, as in [fromPixels]. See [contour] + /// for the [region], [size] and [alphaThreshold] parameters. + static Path contourFromPixels( + Uint8List pixels, + int width, + int height, { + Rect? region, + Vector2? size, + double alphaThreshold = 0.5, + }) { + assert(pixels.length == width * height * 4); + assert( + alphaThreshold > 0 && alphaThreshold <= 1, + 'The alpha threshold has to be in (0, 1]: $alphaThreshold', + ); + final left = (region?.left.round() ?? 0).clamp(0, width); + final top = (region?.top.round() ?? 0).clamp(0, height); + final right = (region?.right.round() ?? width).clamp(left, width); + final bottom = (region?.bottom.round() ?? height).clamp(top, height); + final columns = right - left; + final rows = bottom - top; + return traceAlphaContours( + pixels, + width: width, + left: left, + top: top, + columns: columns, + rows: rows, + threshold: alphaThreshold, + scaleX: size == null || columns == 0 ? 1 : size.x / columns, + scaleY: size == null || rows == 0 ? 1 : size.y / rows, + ); + } + /// Returns the bounding [Rect] of the image. Rect getBoundingRect() => Vector2.zero() & size; diff --git a/packages/flame/lib/src/geometry/alpha_contours.dart b/packages/flame/lib/src/geometry/alpha_contours.dart new file mode 100644 index 00000000000..58a5d83b74e --- /dev/null +++ b/packages/flame/lib/src/geometry/alpha_contours.dart @@ -0,0 +1,180 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:meta/meta.dart'; + +/// Traces the outlines of the areas of a region of [pixels] whose alpha is at +/// least the [threshold], and returns them as the closed contours of a [Path]. +/// +/// The [pixels] are in the RGBA format, row by row, and the image is [width] +/// pixels wide. The region starts at [left] and [top] and it is [columns] +/// pixels wide and [rows] pixels high. The contours are in the coordinates of +/// the region, where each pixel is a square of side one, multiplied by the +/// [scaleX] and the [scaleY]. +/// +/// The outlines are found with marching squares on the alpha of the pixel +/// centers, so they cross between two pixels where the alpha would reach the +/// [threshold] if it changed linearly between them. Only the outer outlines +/// are kept, not the ones of the holes, and each of them goes clockwise in +/// the screen coordinate system. +@internal +Path traceAlphaContours( + Uint8List pixels, { + required int width, + required int left, + required int top, + required int columns, + required int rows, + required double threshold, + double scaleX = 1.0, + double scaleY = 1.0, +}) { + final path = Path(); + if (columns <= 0 || rows <= 0) { + return path; + } + // The alpha of the region with a transparent border around it, so that + // every outline is closed. + final gridWidth = columns + 2; + final gridHeight = rows + 2; + final alpha = Float64List(gridWidth * gridHeight); + for (var y = 0; y < rows; y++) { + final pixel = (top + y) * width + left; + final sample = (y + 1) * gridWidth + 1; + for (var x = 0; x < columns; x++) { + alpha[sample + x] = pixels[(pixel + x) * 4 + 3] / 255; + } + } + + // An edge joins a sample with the one at its right (2 * sample) or with the + // one below it (2 * sample + 1). Each crossed edge starts one segment of an + // outline, which ends at the edge stored here, and it ends another one. + final next = Int32List(gridWidth * gridHeight * 2) + ..fillRange( + 0, + gridWidth * gridHeight * 2, + -1, + ); + // The corners and the edges of a cell in clockwise order, where the edge at + // an index goes from the corner at that index to the following one. + final corners = Int32List(4); + final edges = Int32List(4); + final inside = List.filled(4, false); + final crossed = Int32List(4); + for (var y = 0; y < gridHeight - 1; y++) { + for (var x = 0; x < gridWidth - 1; x++) { + final topLeft = y * gridWidth + x; + corners[0] = topLeft; + corners[1] = topLeft + 1; + corners[2] = topLeft + gridWidth + 1; + corners[3] = topLeft + gridWidth; + var insideCount = 0; + for (var i = 0; i < 4; i++) { + inside[i] = alpha[corners[i]] >= threshold; + if (inside[i]) { + insideCount++; + } + } + if (insideCount == 0 || insideCount == 4) { + continue; + } + edges[0] = 2 * corners[0]; + edges[1] = 2 * corners[1] + 1; + edges[2] = 2 * corners[3]; + edges[3] = 2 * corners[0] + 1; + var count = 0; + for (var i = 0; i < 4; i++) { + if (inside[i] != inside[(i + 1) % 4]) { + crossed[count++] = i; + } + } + // A segment goes from where the clockwise walk around the cell leaves + // the inside to where it enters it again, so that the inside is on its + // right. When the inside corners are opposite, the alpha at the center + // decides whether they are joined, by cutting off the outside corners, + // or apart: the segments then end at the next or at the previous + // crossing, which are the same one in the other cases. + final joined = + (alpha[corners[0]] + + alpha[corners[1]] + + alpha[corners[2]] + + alpha[corners[3]]) / + 4 >= + threshold; + for (var i = 0; i < count; i++) { + final edge = crossed[i]; + if (inside[edge]) { + final end = joined + ? crossed[(i + 1) % count] + : crossed[(i + count - 1) % count]; + next[edges[edge]] = edges[end]; + } + } + } + } + + Offset crossing(int edge) { + final sample = edge >> 1; + final other = edge.isEven ? sample + 1 : sample + gridWidth; + final t = (threshold - alpha[sample]) / (alpha[other] - alpha[sample]); + // The sample at (1, 1) is the center of the first pixel of the region. + final x = sample % gridWidth - 0.5 + (edge.isEven ? t : 0); + final y = sample ~/ gridWidth - 0.5 + (edge.isEven ? 0 : t); + return Offset(x * scaleX, y * scaleY); + } + + for (var start = 0; start < next.length; start++) { + if (next[start] < 0) { + continue; + } + final points = []; + var edge = start; + do { + points.add(crossing(edge)); + final following = next[edge]; + next[edge] = -1; + edge = following; + } while (edge != start && edge >= 0); + final outline = _withoutCollinear(points); + // The outer outlines have the inside on their right, so they go clockwise + // on the screen, while the outlines of the holes go counterclockwise. + if (outline.length >= 3 && _signedArea(outline) > 0) { + path.addPolygon(outline, true); + } + } + return path; +} + +/// Removes the points of the closed outline that are in the middle of a +/// straight stretch, like the ones along the sides of the pixels. +List _withoutCollinear(List points) { + final count = points.length; + return [ + for (var i = 0; i < count; i++) + if (!_isCollinear( + points[(i + count - 1) % count], + points[i], + points[(i + 1) % count], + )) + points[i], + ]; +} + +bool _isCollinear(Offset previous, Offset point, Offset next) { + final cross = + (point.dx - previous.dx) * (next.dy - point.dy) - + (point.dy - previous.dy) * (next.dx - point.dx); + return cross.abs() <= 1e-9; +} + +/// Twice the area of the polygon, positive when it goes clockwise on the +/// screen. +double _signedArea(List points) { + var area = 0.0; + for (var i = 0; i < points.length; i++) { + final a = points[i]; + final b = points[(i + 1) % points.length]; + area += a.dx * b.dy - b.dx * a.dy; + } + return area; +} diff --git a/packages/flame/lib/src/sprite.dart b/packages/flame/lib/src/sprite.dart index da994efcb47..759f94826b8 100644 --- a/packages/flame/lib/src/sprite.dart +++ b/packages/flame/lib/src/sprite.dart @@ -99,6 +99,24 @@ class Sprite( ); } + /// Returns the outlines of the parts of this sprite that are not + /// transparent, as the closed contours of a [Path]. + /// + /// The contours are relative to the top left corner of the sprite, and they + /// are scaled from the [srcSize] to the given [size], when there is one, so + /// that they line up with the sprite when it is rendered with that size. + /// + /// See [ImageExtension.contour] for the [alphaThreshold] and for the + /// details of the contours. Keep in mind that this reads back the pixels of + /// the [image], so it should be done when loading, not in the game loop. + Future contour({Vector2? size, double alphaThreshold = 0.5}) { + return image.contour( + region: src, + size: size, + alphaThreshold: alphaThreshold, + ); + } + /// Same as [render], but takes both the position and the size as a single /// [Rect]. /// diff --git a/packages/flame/test/extensions/image_contour_test.dart b/packages/flame/test/extensions/image_contour_test.dart new file mode 100644 index 00000000000..ab4171ecfab --- /dev/null +++ b/packages/flame/test/extensions/image_contour_test.dart @@ -0,0 +1,232 @@ +import 'dart:typed_data'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The pixels of the [rows], where each character is a pixel: '#' is opaque, +/// '+' has half alpha and anything else is transparent. +Uint8List _pixels(List rows) { + final width = rows.first.length; + final pixels = Uint8List(width * rows.length * 4); + for (var y = 0; y < rows.length; y++) { + for (var x = 0; x < width; x++) { + final alpha = switch (rows[y][x]) { + '#' => 255, + '+' => 128, + _ => 0, + }; + pixels[(y * width + x) * 4 + 3] = alpha; + } + } + return pixels; +} + +Path _contour( + List rows, { + Rect? region, + Vector2? size, + double alphaThreshold = 0.5, +}) { + return ImageExtension.contourFromPixels( + _pixels(rows), + rows.first.length, + rows.length, + region: region, + size: size, + alphaThreshold: alphaThreshold, + ); +} + +Future _image(List rows) { + return ImageExtension.fromPixels( + _pixels(rows), + rows.first.length, + rows.length, + ); +} + +/// The vertices of each contour of the [path], without the samples along its +/// edges. +List> _vertices(Path path) => path.walkContours(1, 0.001); + +void main() { + group('ImageExtension.contourFromPixels', () { + test('is empty for a transparent image', () { + final path = _contour(['...', '...']); + expect(path.contours, isEmpty); + }); + + test('is empty for an empty region', () { + final path = _contour(['##', '##'], region: Rect.zero); + expect(path.contours, isEmpty); + }); + + test('cuts the corners of a single pixel', () { + final path = _contour(['...', '.#.', '...']); + final vertices = _vertices(path); + expect(vertices, hasLength(1)); + expect( + vertices.single, + unorderedEquals(const [ + Offset(1.5, 1), + Offset(2, 1.5), + Offset(1.5, 2), + Offset(1, 1.5), + ]), + ); + }); + + test('follows the sides of the pixels with straight edges', () { + final path = _contour(['....', '.##.', '.##.', '....']); + final vertices = _vertices(path); + expect(vertices.single, hasLength(8)); + expect(path.getBounds(), const Rect.fromLTRB(1, 1, 3, 3)); + }); + + test('reaches the borders of a fully opaque image', () { + final path = _contour(['###', '###']); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(0, 0, 3, 2)); + }); + + test('goes clockwise on the screen', () { + final vertices = _vertices(_contour(['##', '##'])).single; + var area = 0.0; + for (var i = 0; i < vertices.length; i++) { + final a = vertices[i]; + final b = vertices[(i + 1) % vertices.length]; + area += a.dx * b.dy - b.dx * a.dy; + } + expect(area, greaterThan(0)); + }); + + test('follows concave outlines', () { + final path = _contour([ + '#..', + '#..', + '###', + ]); + expect(path.contours, hasLength(1)); + expect(path.contains(const Offset(0.5, 0.5)), isTrue); + expect(path.contains(const Offset(2.5, 2.5)), isTrue); + expect(path.contains(const Offset(2, 1)), isFalse); + }); + + test('leaves out the holes', () { + final path = _contour([ + '###', + '#.#', + '###', + ]); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(0, 0, 3, 3)); + expect(path.contains(const Offset(1.5, 1.5)), isTrue); + }); + + test('gives a contour for each separate area', () { + final path = _contour([ + '##..#', + '##...', + '....#', + ]); + expect(path.contours, hasLength(3)); + }); + + test('joins diagonal pixels when the center reaches the threshold', () { + expect(_contour(['#.', '.#']).contours, hasLength(1)); + expect( + _contour(['#.', '.#'], alphaThreshold: 0.6).contours, + hasLength(2), + ); + }); + + test('places the edges where the alpha reaches the threshold', () { + final half = _contour(['#+']).getBounds(); + expect(half.right, closeTo(1.5 + 0.004, 0.001)); + final low = _contour(['#+'], alphaThreshold: 0.25).getBounds(); + expect(low.right, closeTo(1.5 + 1 - 0.25 / (128 / 255), 0.001)); + final high = _contour(['#+'], alphaThreshold: 0.75).getBounds(); + expect(high.right, closeTo(0.5 + 0.25 / (127 / 255), 0.001)); + }); + + test('counts only the pixels in the region, relative to it', () { + final path = _contour([ + '#....', + '...#.', + '.....', + ], region: const Rect.fromLTWH(2, 0, 3, 3)); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(1, 1, 2, 2)); + }); + + test('scales the region to the size', () { + final path = _contour( + ['....', '..#.'], + region: const Rect.fromLTWH(2, 0, 2, 2), + size: Vector2(20, 10), + ); + expect(path.getBounds(), const Rect.fromLTRB(0, 5, 10, 10)); + }); + }); + + group('ImageExtension.contour', () { + test('reads the pixels of the image', () async { + final rows = ['....', '.##.', '..#.']; + final image = await _image(rows); + final path = await image.contour(size: Vector2(8, 6)); + expect( + _vertices(path), + _vertices(_contour(rows, size: Vector2(8, 6))), + ); + }); + }); + + group('Sprite.contour', () { + test('follows the region of the sprite', () async { + final image = await _image(['##..', '##.#']); + final sprite = Sprite( + image, + srcPosition: Vector2(2, 0), + srcSize: Vector2(2, 2), + ); + final path = await sprite.contour(size: Vector2(4, 4)); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(2, 2, 4, 4)); + }); + }); + + group('hitboxes from the contour', () { + test('a PolygonHitbox follows a concave outline', () { + final path = _contour([ + '....', + '.#..', + '.#..', + '.###', + ]); + final hitbox = PolygonHitbox.fromPath(path, sampling: 0.5); + expect(hitbox.position, Vector2(1, 1)); + expect(hitbox.size, Vector2(3, 3)); + expect(hitbox.containsLocalPoint(Vector2(0.5, 0.5)), isTrue); + expect(hitbox.containsLocalPoint(Vector2(2.5, 2.5)), isTrue); + expect(hitbox.containsLocalPoint(Vector2(2, 1)), isFalse); + }); + + test('a PathHitbox covers every area', () { + final path = _contour([ + '#..', + '...', + '..#', + ]); + final hitbox = PathHitbox( + path: path, + position: path.getBounds().topLeft.toVector2(), + ); + expect(hitbox.polygons, hasLength(2)); + expect(hitbox.containsPoint(Vector2(0.5, 0.5)), isTrue); + expect(hitbox.containsPoint(Vector2(2.5, 2.5)), isTrue); + expect(hitbox.containsPoint(Vector2(1.5, 1.5)), isFalse); + }); + }); +} From 62df8aaec5f74272fc09528c55f2627c132c982a Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 5 Oct 2026 14:11:55 +0200 Subject: [PATCH 02/21] chore: Add Hertel/Mehlhorn in the cspell usernames. --- .github/.cspell/people_usernames.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index f17b6d1723c..a75fed40031 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -7,6 +7,7 @@ erickzanardo # github.com/erickzanardo feroult # github.com/feroult fröber # github.com/Brixto gnarhard # github.com/gnarhard +Hertel # Co-author of triangulation algorithm Hoodead # github.com/kornellapu kenney # kenney.nl Klingsbo # github.com/spydon @@ -16,6 +17,7 @@ Lapu # github.com/kornellapu luan # github.com/luanpotter luanpotter # github.com/luanpotter Lukas # github.com/spydon +Mehlhorn # Co-author of triangulation algorithm pgainullin # github.com/pgainullin Schnurber # github.com/schnurber spydon # github.com/spydon From 28c44ba3b42c6ef507842ed05f5a117c68dc623b Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 5 Oct 2026 15:53:33 +0200 Subject: [PATCH 03/21] feat: Move convexPieces from the examples to flame/geometry convexPieces splits a simple polygon, convex or concave, into convex pieces of at most maxVertices vertices, like the shapes that physics engines such as Box2D accept, by ear clipping it and merging the triangles back as long as they stay convex (the Hertel-Mehlhorn algorithm). The minDistance and minWidth parameters weld close vertices and leave out the pieces that are too narrow for the tolerances of an engine. It is now exported by package:flame/geometry.dart, and documented in the "Convex pieces" section of the shape components docs. The SpriteBodyExample of flame_forge2d uses it from there. --- doc/flame/components/shape_components.md | 32 ++++ .../flame_forge2d/sprite_body_example.dart | 2 +- packages/flame/lib/geometry.dart | 1 + .../lib/src/geometry}/convex_pieces.dart | 0 .../test/geometry/convex_pieces_test.dart | 154 ++++++++++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) rename {examples/lib/stories/bridge_libraries/flame_forge2d/utils => packages/flame/lib/src/geometry}/convex_pieces.dart (100%) create mode 100644 packages/flame/test/geometry/convex_pieces_test.dart diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index dd7698e8d21..338cf873fda 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -164,6 +164,38 @@ The `PathHitbox` is the hitbox counterpart of the `PathComponent`, see [](../collision_detection.md#pathhitbox). +### Convex pieces + +Some consumers of polygons only accept convex ones with a limited number of vertices, like the +shapes of physics engines such as Box2D, which take at most 8 vertices. The `convexPieces` function +splits a simple polygon, convex or concave, into such pieces: it first splits the polygon into +triangles by ear clipping, and then merges neighboring triangles as long as they stay convex and +within `maxVertices`, which defaults to `8` (the Hertel-Mehlhorn algorithm). The pieces reuse the +vertices of the polygon and go in the same direction. + +Engines also reject the polygons that are too small for their tolerances, so two more arguments +clean up the result: + +- `minDistance`: Of the consecutive vertices closer than this, only the first one is kept, and the + vertices closer than half of it to the line through their neighbors are left out. +- `minWidth`: The pieces narrower than this are left out. + +Both default to `0`, which keeps every vertex and every piece. For Box2D, `minDistance` is 4 times +its linear slop and `minWidth` twice it. + +The polygons of a `PathComponent` are a convenient input, since they follow each contour of a path +with straight edges: + +```dart +void main() { + final component = PathComponent(path: path); + final pieces = [ + for (final polygon in component.polygons) ...convexPieces(polygon), + ]; +} +``` + + ## RectangleComponent A `RectangleComponent` is created very similarly to how a `PositionComponent` is created, since it diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index f03696e2571..49d7341f2ad 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -1,11 +1,11 @@ import 'dart:math'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; class SpriteBodyExample({bool showPieces = false}) extends Forge2DExampleGame { diff --git a/packages/flame/lib/geometry.dart b/packages/flame/lib/geometry.dart index 7d60a1b2048..c5e81759b1a 100644 --- a/packages/flame/lib/geometry.dart +++ b/packages/flame/lib/geometry.dart @@ -1,5 +1,6 @@ export 'src/geometry/circle_component.dart'; export 'src/geometry/constants.dart'; +export 'src/geometry/convex_pieces.dart'; export 'src/geometry/line.dart'; export 'src/geometry/line_segment.dart'; export 'src/geometry/path_component.dart'; diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart similarity index 100% rename from examples/lib/stories/bridge_libraries/flame_forge2d/utils/convex_pieces.dart rename to packages/flame/lib/src/geometry/convex_pieces.dart diff --git a/packages/flame/test/geometry/convex_pieces_test.dart b/packages/flame/test/geometry/convex_pieces_test.dart new file mode 100644 index 00000000000..07645c3fbf4 --- /dev/null +++ b/packages/flame/test/geometry/convex_pieces_test.dart @@ -0,0 +1,154 @@ +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('convexPieces', () { + test('keeps a convex polygon whole', () { + final square = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(0, 1), + ]; + final pieces = convexPieces(square); + expect(pieces, hasLength(1)); + expect(pieces.first, hasLength(4)); + }); + + test('splits a concave polygon into convex pieces of the same area', () { + final l = [ + Vector2(0, 0), + Vector2(2, 0), + Vector2(2, 1), + Vector2(1, 1), + Vector2(1, 2), + Vector2(0, 2), + ]; + final pieces = convexPieces(l); + expect(pieces.length, greaterThan(1)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(3, 1e-9)); + }); + + test('keeps the direction of the polygon', () { + final clockwise = [ + Vector2(0, 0), + Vector2(0, 2), + Vector2(1, 2), + Vector2(1, 1), + Vector2(2, 1), + Vector2(2, 0), + ]; + final pieces = convexPieces(clockwise); + expect(pieces.every((piece) => _signedArea(piece) < 0), isTrue); + }); + + test('limits the number of vertices of the pieces', () { + final circle = [ + for (var i = 0; i < 64; i++) + Vector2(math.cos(i * math.pi / 32), math.sin(i * math.pi / 32)), + ]; + final pieces = convexPieces(circle, maxVertices: 5); + expect(pieces.every((piece) => piece.length <= 5), isTrue); + expect(_totalArea(pieces), closeTo(_signedArea(circle), 1e-9)); + }); + + test('welds the vertices closer than minDistance', () { + // A triangle with a corner split in two vertices 0.01 apart, which + // would make a degenerate piece. + final polygon = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 0.01), + Vector2(0.5, 1), + ]; + final pieces = convexPieces(polygon, minDistance: 0.02); + expect(pieces, hasLength(1)); + expect(pieces.first, hasLength(3)); + }); + + test('leaves out the pieces narrower than minWidth', () { + final sliver = [Vector2(0, 0), Vector2(1, 0), Vector2(0.5, 0.005)]; + expect(convexPieces(sliver, minWidth: 0.01), isEmpty); + expect(convexPieces(sliver), hasLength(1)); + }); + + test('gives no pieces for fewer than three vertices', () { + expect(convexPieces([]), isEmpty); + expect(convexPieces([Vector2(0, 0), Vector2(1, 0)]), isEmpty); + }); + + testRandom('covers random concave polygons with convex pieces', (r) { + // A star with points at random distances from its center, which is + // concave and simple. + final count = 3 + r.nextInt(30); + final star = [ + for (var i = 0; i < 2 * count; i++) + Vector2(math.cos(i * math.pi / count), math.sin(i * math.pi / count)) + ..scale(i.isEven ? 1 + r.nextDouble() * 9 : 0.2 + r.nextDouble()), + ]; + final maxVertices = 3 + r.nextInt(6); + final pieces = convexPieces(star, maxVertices: maxVertices); + expect(pieces.every(_isConvex), isTrue); + expect( + pieces.every( + (piece) => piece.length >= 3 && piece.length <= maxVertices, + ), + isTrue, + ); + expect(pieces.every((piece) => _signedArea(piece) > 0), isTrue); + expect(_totalArea(pieces), closeTo(_signedArea(star), 1e-6)); + }, repeatCount: 100); + + test('covers the polygons of a PathComponent', () { + final path = Path() + ..moveTo(0, 0) + ..lineTo(40, 0) + ..lineTo(40, 40) + ..lineTo(30, 40) + ..lineTo(30, 10) + ..lineTo(10, 10) + ..lineTo(10, 40) + ..lineTo(0, 40) + ..close(); + final component = PathComponent(path: path); + final polygon = component.polygons.single.toList(); + final pieces = convexPieces(polygon); + expect(pieces.length, greaterThan(1)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(40 * 40 - 20 * 30, 1e-6)); + }); + }); +} + +double _signedArea(List polygon) { + var area = 0.0; + for (var i = 0; i < polygon.length; i++) { + final a = polygon[i]; + final b = polygon[(i + 1) % polygon.length]; + area += a.x * b.y - b.x * a.y; + } + return area / 2; +} + +double _totalArea(List> pieces) { + return pieces.fold(0, (sum, piece) => sum + _signedArea(piece).abs()); +} + +bool _isConvex(List piece) { + final sign = _signedArea(piece).sign; + for (var i = 0; i < piece.length; i++) { + final a = piece[i]; + final b = piece[(i + 1) % piece.length]; + final c = piece[(i + 2) % piece.length]; + if ((b - a).cross(c - b) * sign < -1e-12) { + return false; + } + } + return true; +} From 30c0c3fef4e52f2e20b81ab5d9de61114e63e5ce Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 5 Oct 2026 16:04:52 +0200 Subject: [PATCH 04/21] fix: Keep the contour corners where the alpha matches the threshold When the alpha of a pixel is exactly the alphaThreshold, the outline passes through the center of the pixel, where its edges are crossed at the same point. The repeated points looked collinear with their neighbors, so both were removed along with the corner, and a contour could be dropped altogether: an opaque square traced with an alphaThreshold of 1 gave no contour. The repeated points are now merged first. - contourFromPixels asserts that the region is within the image, instead of clamping it, which moved and stretched the contours. - ImageExtension.contour no longer imports the collisions library only for its documentation. - Document that the regions are rounded to whole pixels, that the pixels of the whole image are read back, and how to trace the sprites of a sprite sheet reading them once. - Document the cost of convexPieces, and complete the snippet of its docs. --- doc/flame/components/shape_components.md | 19 ++++++++++-- packages/flame/lib/src/extensions/image.dart | 30 ++++++++++++------- .../lib/src/geometry/alpha_contours.dart | 23 ++++++++++++-- .../flame/lib/src/geometry/convex_pieces.dart | 6 ++++ packages/flame/lib/src/sprite.dart | 8 ++++- .../test/extensions/image_contour_test.dart | 25 ++++++++++++++++ 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index 338cf873fda..0acfa39c459 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -183,11 +183,26 @@ clean up the result: Both default to `0`, which keeps every vertex and every piece. For Box2D, `minDistance` is 4 times its linear slop and `minWidth` twice it. -The polygons of a `PathComponent` are a convenient input, since they follow each contour of a path -with straight edges: +The cost grows about with the cube of the number of vertices, so split the polygons when loading +and not in every tick. The polygons of a `PathComponent` are a convenient input, since they follow +each contour of a path with straight edges and are already simplified according to their +`sampling`: ```dart void main() { + // A U shape, which is concave. + final path = Path() + ..addPolygon(const [ + Offset(0, 0), + Offset(40, 0), + Offset(40, 40), + Offset(30, 40), + Offset(30, 10), + Offset(10, 10), + Offset(10, 40), + Offset(0, 40), + ], true); + final component = PathComponent(path: path); final pieces = [ for (final polygon in component.polygons) ...convexPieces(polygon), diff --git a/packages/flame/lib/src/extensions/image.dart b/packages/flame/lib/src/extensions/image.dart index 0d4a4fab713..815d3e9c99a 100644 --- a/packages/flame/lib/src/extensions/image.dart +++ b/packages/flame/lib/src/extensions/image.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:typed_data'; import 'dart:ui'; -import 'package:flame/collisions.dart'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; import 'package:flame/src/geometry/alpha_contours.dart'; @@ -84,7 +83,8 @@ extension ImageExtension on Image { /// Only the pixels in the [region] are considered, the whole image by /// default, and the contours are relative to its top left corner. They are /// scaled from the size of the [region] to the given [size], when there is - /// one. + /// one. The [region] has to be within the image, and its sides are rounded + /// to whole pixels. /// /// A pixel is part of the outlined area when its alpha is at least the /// [alphaThreshold], which is between zero, excluded, and one. The outlines @@ -95,12 +95,15 @@ extension ImageExtension on Image { /// areas, and the separate areas give separate contours. /// /// The contours are meant to make hitboxes that follow the outline of the - /// image, like a [PathHitbox], or a [PolygonHitbox] from one of them, which + /// image, like a `PathHitbox`, or a `PolygonHitbox` from one of them, which /// simplify them according to their sampling. /// - /// Keep in mind that this reads back the pixels of the image, so it is an - /// expensive operation that should be done when loading, not in the game - /// loop. + /// Keep in mind that this reads back the pixels of the whole image, even + /// when the [region] is only a part of it, so it is an expensive operation + /// that should be done when loading, not in the game loop. To trace several + /// regions of the same image, like the sprites of a sprite sheet, read the + /// pixels once with [pixelsInUint8] and pass them to [contourFromPixels] for + /// each region. Future contour({ Rect? region, Vector2? size, @@ -134,10 +137,17 @@ extension ImageExtension on Image { alphaThreshold > 0 && alphaThreshold <= 1, 'The alpha threshold has to be in (0, 1]: $alphaThreshold', ); - final left = (region?.left.round() ?? 0).clamp(0, width); - final top = (region?.top.round() ?? 0).clamp(0, height); - final right = (region?.right.round() ?? width).clamp(left, width); - final bottom = (region?.bottom.round() ?? height).clamp(top, height); + final left = region?.left.round() ?? 0; + final top = region?.top.round() ?? 0; + final right = region?.right.round() ?? width; + final bottom = region?.bottom.round() ?? height; + // A region that is clamped to the image would move and stretch the + // contours, relative to the region that was asked for. + assert( + left >= 0 && top >= 0 && right <= width && bottom <= height, + 'The region $region is not within the image of $width x $height', + ); + assert(right >= left && bottom >= top, 'The region $region is inverted'); final columns = right - left; final rows = bottom - top; return traceAlphaContours( diff --git a/packages/flame/lib/src/geometry/alpha_contours.dart b/packages/flame/lib/src/geometry/alpha_contours.dart index 58a5d83b74e..31506c98537 100644 --- a/packages/flame/lib/src/geometry/alpha_contours.dart +++ b/packages/flame/lib/src/geometry/alpha_contours.dart @@ -135,7 +135,7 @@ Path traceAlphaContours( next[edge] = -1; edge = following; } while (edge != start && edge >= 0); - final outline = _withoutCollinear(points); + final outline = _withoutCollinear(_withoutDuplicates(points)); // The outer outlines have the inside on their right, so they go clockwise // on the screen, while the outlines of the holes go counterclockwise. if (outline.length >= 3 && _signedArea(outline) > 0) { @@ -145,8 +145,27 @@ Path traceAlphaContours( return path; } +/// Removes the points of the closed outline that are the same as the previous +/// one, which happens where the alpha of a pixel is exactly the threshold: +/// the outline then passes through the center of the pixel, where all the +/// edges of the pixel are crossed. +List _withoutDuplicates(List points) { + final result = []; + for (final point in points) { + if (result.isEmpty || result.last != point) { + result.add(point); + } + } + while (result.length > 1 && result.last == result.first) { + result.removeLast(); + } + return result; +} + /// Removes the points of the closed outline that are in the middle of a -/// straight stretch, like the ones along the sides of the pixels. +/// straight stretch, like the ones along the sides of the pixels. The outline +/// must not have consecutive duplicate points, since both of them would be +/// removed. List _withoutCollinear(List points) { final count = points.length; return [ diff --git a/packages/flame/lib/src/geometry/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart index 198316085a6..4159bd34f26 100644 --- a/packages/flame/lib/src/geometry/convex_pieces.dart +++ b/packages/flame/lib/src/geometry/convex_pieces.dart @@ -19,6 +19,12 @@ import 'package:flame/extensions.dart'; /// it, see `b2ComputeHull`. /// /// The pieces reuse the vertices of the [polygon], in the same direction. +/// +/// The cost grows about with the cube of the number of vertices, so it is +/// meant to be done when loading, not in the game loop. The polygons of a +/// `PathComponent` are already simplified according to their sampling, while +/// a [polygon] made by hand should not have a vertex for each pixel or each +/// tiny step along a curve. List> convexPieces( List polygon, { int maxVertices = 8, diff --git a/packages/flame/lib/src/sprite.dart b/packages/flame/lib/src/sprite.dart index 759f94826b8..248fbd78c96 100644 --- a/packages/flame/lib/src/sprite.dart +++ b/packages/flame/lib/src/sprite.dart @@ -105,10 +105,16 @@ class Sprite( /// The contours are relative to the top left corner of the sprite, and they /// are scaled from the [srcSize] to the given [size], when there is one, so /// that they line up with the sprite when it is rendered with that size. + /// The sides of the [src] are rounded to whole pixels, so when they are not + /// whole the contours can be off by up to half a pixel of the [image]. /// /// See [ImageExtension.contour] for the [alphaThreshold] and for the /// details of the contours. Keep in mind that this reads back the pixels of - /// the [image], so it should be done when loading, not in the game loop. + /// the whole [image], not only the ones of the sprite, so it should be done + /// when loading, not in the game loop. To trace several sprites of the same + /// sprite sheet, read its pixels once with [ImageExtension.pixelsInUint8] + /// and pass them to [ImageExtension.contourFromPixels] with the [src] of + /// each sprite. Future contour({Vector2? size, double alphaThreshold = 0.5}) { return image.contour( region: src, diff --git a/packages/flame/test/extensions/image_contour_test.dart b/packages/flame/test/extensions/image_contour_test.dart index ab4171ecfab..c1a47db00f8 100644 --- a/packages/flame/test/extensions/image_contour_test.dart +++ b/packages/flame/test/extensions/image_contour_test.dart @@ -151,6 +151,31 @@ void main() { expect(high.right, closeTo(0.5 + 0.25 / (127 / 255), 0.001)); }); + test('keeps the corners where the alpha is exactly the threshold', () { + // The outline passes through the centers of the opaque pixels, where + // all the edges of the pixels are crossed at the same point. + final path = _contour([ + '.....', + '.###.', + '.###.', + '.###.', + '.....', + ], alphaThreshold: 1); + expect(_vertices(path).single, hasLength(4)); + expect(path.getBounds(), const Rect.fromLTRB(1.5, 1.5, 3.5, 3.5)); + }); + + test('asserts that the region is within the image', () { + expect( + () => _contour(['##', '##'], region: const Rect.fromLTWH(-1, 0, 2, 2)), + throwsAssertionError, + ); + expect( + () => _contour(['##', '##'], region: const Rect.fromLTWH(1, 1, 2, 1)), + throwsAssertionError, + ); + }); + test('counts only the pixels in the region, relative to it', () { final path = _contour([ '#....', From bc7afdd2f9c4dad07a5d4c20925ff64948534ae8 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 5 Oct 2026 16:17:42 +0200 Subject: [PATCH 05/21] fix: Split the polygons that touch themselves in convexPieces Ear clipping can get stuck on a polygon that touches itself at a vertex, like the outline of two shapes that meet at a corner, and what was left of the polygon was then silently left out. Such outlines come, for example, from Sprite.contour when the alpha of some pixels is exactly the alphaThreshold. The polygon is now split where two of its vertices that are not consecutive are welded, and each of the resulting loops is split into pieces on its own. A loop that goes the other way is a hole that touches the outline, which is filled by the other loops. The docs now say so, and that the parts of a polygon that crosses itself may be left out. --- doc/flame/components/shape_components.md | 4 ++ .../flame/lib/src/geometry/convex_pieces.dart | 45 +++++++++++++++++-- .../test/geometry/convex_pieces_test.dart | 35 +++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index 0acfa39c459..212c2a1d05f 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -173,6 +173,10 @@ triangles by ear clipping, and then merges neighboring triangles as long as they within `maxVertices`, which defaults to `8` (the Hertel-Mehlhorn algorithm). The pieces reuse the vertices of the polygon and go in the same direction. +A polygon that touches itself at a vertex, like the outline of two shapes that meet at a corner, is +split there first, and a hole that touches the outline at a vertex is filled. The parts of a +polygon that crosses itself may be left out. + Engines also reject the polygons that are too small for their tolerances, so two more arguments clean up the result: diff --git a/packages/flame/lib/src/geometry/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart index 4159bd34f26..f09184090fb 100644 --- a/packages/flame/lib/src/geometry/convex_pieces.dart +++ b/packages/flame/lib/src/geometry/convex_pieces.dart @@ -20,6 +20,11 @@ import 'package:flame/extensions.dart'; /// /// The pieces reuse the vertices of the [polygon], in the same direction. /// +/// The [polygon] may touch itself at its vertices, like the outline of two +/// shapes that meet at a corner, which is split there into simple polygons: +/// a hole that touches the outline at a vertex is filled, though. The parts +/// of a [polygon] that crosses itself may be left out. +/// /// The cost grows about with the cube of the number of vertices, so it is /// meant to be done when loading, not in the game loop. The polygons of a /// `PathComponent` are already simplified according to their sampling, while @@ -41,7 +46,13 @@ List> convexPieces( // that is with a positive area, and turned back into vertices at the end. final isClockwise = _doubleArea(vertices) < 0; final indices = List.generate(n, (i) => isClockwise ? n - 1 - i : i); - final pieces = _triangulate(vertices, indices); + final pieces = [ + for (final loop in _splitAtTouches(vertices, indices, minDistance)) + // The loops that go the other way are holes that touch the outline, + // which the other loops already cover. + if (_doubleArea([for (final i in loop) vertices[i]]) > 0) + ..._triangulate(vertices, loop), + ]; // Corners that are reflex by less than half of the minimum width are // accepted as convex, since the engine straightens them anyway. _merge(vertices, pieces, maxVertices, minWidth / 2); @@ -52,6 +63,34 @@ List> convexPieces( ]; } +/// Splits the polygon given by the [loop] of indices of [vertices] where it +/// touches itself, that is where two of its vertices that are not +/// consecutive are welded, into loops that do not touch themselves. +/// +/// Ear clipping can get stuck on a polygon that touches itself, which would +/// leave the rest of it out. +List> _splitAtTouches( + List vertices, + List loop, + double minDistance, +) { + for (var a = 0; a < loop.length; a++) { + for (var b = a + 2; b < loop.length; b++) { + if (_isWelded(vertices[loop[a]], vertices[loop[b]], minDistance)) { + return [ + ..._splitAtTouches(vertices, loop.sublist(a, b), minDistance), + ..._splitAtTouches( + vertices, + [...loop.sublist(b), ...loop.sublist(0, a)], + minDistance, + ), + ]; + } + } + } + return [loop]; +} + /// The vertices of the [polygon] without the ones closer than [minDistance] /// to the previous one, nor the ones closer than half of it to the line /// through their neighbors. @@ -121,8 +160,8 @@ List> _triangulate(List vertices, List indices) { while (remaining.length >= 3) { final ear = _findEar(vertices, remaining); if (ear == -1) { - // Only a polygon that crosses itself has no ears; what is left of it is - // left out. + // A polygon that crosses itself may have no ears left, while a simple + // one always has; what is left of it is left out. break; } final m = remaining.length; diff --git a/packages/flame/test/geometry/convex_pieces_test.dart b/packages/flame/test/geometry/convex_pieces_test.dart index 07645c3fbf4..5273a921a0b 100644 --- a/packages/flame/test/geometry/convex_pieces_test.dart +++ b/packages/flame/test/geometry/convex_pieces_test.dart @@ -78,6 +78,41 @@ void main() { expect(convexPieces(sliver), hasLength(1)); }); + test('splits a polygon where it touches itself', () { + // Two squares that meet at the corner (1, 1), which is visited twice. + final touching = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(2, 1), + Vector2(2, 2), + Vector2(1, 2), + Vector2(1, 1), + Vector2(0, 1), + ]; + final pieces = convexPieces(touching); + expect(pieces, hasLength(2)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(2, 1e-9)); + }); + + test('fills a hole that touches the outline at a vertex', () { + // A square, and a triangular hole that goes the other way, which both + // start at (0, 0). + final withHole = [ + Vector2(0, 0), + Vector2(4, 0), + Vector2(4, 4), + Vector2(0, 4), + Vector2(0, 0), + Vector2(1, 2), + Vector2(2, 1), + ]; + final pieces = convexPieces(withHole); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(16, 1e-9)); + }); + test('gives no pieces for fewer than three vertices', () { expect(convexPieces([]), isEmpty); expect(convexPieces([Vector2(0, 0), Vector2(1, 0)]), isEmpty); From 4502c5b5d17eef8d399b598c54ed63722f27aafc Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 5 Oct 2026 16:57:47 +0200 Subject: [PATCH 06/21] perf: Trace image contours from an inside mask and the outline edges The tracing of contourFromPixels scanned every cell of the image through the alpha of its four corners, which were copied into a list of doubles, and linked the outlines in a table with two entries for each pixel. Each pixel is now checked against the alphaThreshold once, into a mask of bytes, and the case of a cell is made of the bits of its corners, sharing two of them with the previous cell. The alpha is only read along the outlines, and the edges of the outlines are kept in a map. The contours are the same, while tracing is 2.5 to 3.5 times as fast, and takes about one byte for each pixel instead of sixteen. - Add image_contour_benchmark.dart, a standalone suite for tracing, convexPieces, and the whole way from the pixels to the convex pieces. - Document that the cost of convexPieces grows about with the square of the number of vertices, as measured, not with the cube. --- doc/flame/components/shape_components.md | 2 +- packages/flame/benchmark/README.md | 5 + .../benchmark/image_contour_benchmark.dart | 120 +++++++++++++++++ .../lib/src/geometry/alpha_contours.dart | 121 ++++++++++-------- .../flame/lib/src/geometry/convex_pieces.dart | 2 +- 5 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 packages/flame/benchmark/image_contour_benchmark.dart diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index 212c2a1d05f..85febb70056 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -187,7 +187,7 @@ clean up the result: Both default to `0`, which keeps every vertex and every piece. For Box2D, `minDistance` is 4 times its linear slop and `minWidth` twice it. -The cost grows about with the cube of the number of vertices, so split the polygons when loading +The cost grows about with the square of the number of vertices, so split the polygons when loading and not in every tick. The polygons of a `PathComponent` are a convenient input, since they follow each contour of a path with straight edges and are already simplified according to their `sampling`: diff --git a/packages/flame/benchmark/README.md b/packages/flame/benchmark/README.md index f2f3454a07e..eeb8aabdf31 100644 --- a/packages/flame/benchmark/README.md +++ b/packages/flame/benchmark/README.md @@ -100,6 +100,11 @@ which takes minutes. To compare a change, run it on both branches. shapes, the effect of the simplification tolerance of `walkContours` next to the samples before they are simplified, and polygon-polygon intersection cost. +- `image_contour_benchmark.dart`: a standalone suite, not part of + `main.dart`, that measures tracing the outlines of images with + `ImageExtension.contourFromPixels`, including a transparent image for the + scan of the pixels alone, splitting stars of growing size with + `convexPieces`, and the whole way from the pixels to the convex pieces. - `ray_intersection_benchmark.dart`: `rayIntersection` on polygon hitboxes that are sampled from a concave and from a convex `Path` contour, with one precomputed ray for each hitbox in every tick. diff --git a/packages/flame/benchmark/image_contour_benchmark.dart b/packages/flame/benchmark/image_contour_benchmark.dart new file mode 100644 index 00000000000..eecbc67079b --- /dev/null +++ b/packages/flame/benchmark/image_contour_benchmark.dart @@ -0,0 +1,120 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; + +/// Measures tracing the outlines of images with +/// [ImageExtension.contourFromPixels] and splitting polygons into +/// [convexPieces], from the pixels to the convex pieces of a body. +/// +/// This is a standalone suite, not part of `main.dart`. Run it with +/// `flutter test benchmark/image_contour_benchmark.dart`. +Future main() async { + for (final size in [128, 512, 2048]) { + TraceBenchmark(size).report(); + } + TraceBenchmark(2048, transparent: true).report(); + for (final vertices in [50, 200, 800, 2000]) { + ConvexPiecesBenchmark(vertices).report(); + } + for (final size in [512, 2048]) { + PipelineBenchmark(size).report(); + } +} + +/// A benchmark whose every sample is a single [run], since a run can take +/// tens of milliseconds, while the harness runs it 10 times per sample by +/// default. +abstract class _SingleRunBenchmark(super.name) extends BenchmarkBase { + @override + void exercise() => run(); +} + +/// Tracing the outlines of a [size] by [size] image, which is a blob with +/// wavy anti-aliased edges, or fully [transparent] to measure the scan of the +/// pixels alone. +class TraceBenchmark(final int size, {final bool transparent = false}) + extends _SingleRunBenchmark { + this + : super( + 'Trace contours of ${transparent ? 'transparent' : 'blob'} ' + '${size}x$size', + ); + + late final Uint8List _pixels; + + @override + void setup() { + _pixels = transparent ? Uint8List(size * size * 4) : _blob(size); + } + + @override + void run() { + ImageExtension.contourFromPixels(_pixels, size, size); + } +} + +/// Splitting a star with [vertices] vertices, which is concave, into convex +/// pieces of at most 8 vertices. +class ConvexPiecesBenchmark(final int vertices) extends _SingleRunBenchmark { + this : super('Convex pieces of a star with $vertices vertices'); + + late final List _star; + + @override + void setup() { + _star = [ + for (var i = 0; i < vertices; i++) + Vector2(cos(2 * pi * i / vertices), sin(2 * pi * i / vertices)) + ..scale(i.isEven ? 10 : 6), + ]; + } + + @override + void run() { + convexPieces(_star); + } +} + +/// The whole way from the pixels of a [size] by [size] blob to the convex +/// pieces of its outline, through the polygons of a [PathComponent] with the +/// default sampling. +class PipelineBenchmark(final int size) extends _SingleRunBenchmark { + this : super('Pixels to convex pieces of blob ${size}x$size'); + + late final Uint8List _pixels; + + @override + void setup() { + _pixels = _blob(size); + } + + @override + void run() { + final outline = ImageExtension.contourFromPixels(_pixels, size, size); + final component = PathComponent(path: outline); + for (final polygon in component.polygons) { + convexPieces(polygon); + } + } +} + +/// A [size] by [size] image of a blob with 7 waves along its edge, which is +/// opaque inside and anti-aliased over a pixel along the edge. +Uint8List _blob(int size) { + final pixels = Uint8List(size * size * 4); + final center = size / 2; + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final dx = x + 0.5 - center; + final dy = y + 0.5 - center; + final radius = center * (0.7 + 0.2 * sin(7 * atan2(dy, dx))); + final inside = radius - sqrt(dx * dx + dy * dy); + pixels[(y * size + x) * 4 + 3] = (inside.clamp(0, 1) * 255).round(); + } + } + return pixels; +} diff --git a/packages/flame/lib/src/geometry/alpha_contours.dart b/packages/flame/lib/src/geometry/alpha_contours.dart index 31506c98537..c823c682f10 100644 --- a/packages/flame/lib/src/geometry/alpha_contours.dart +++ b/packages/flame/lib/src/geometry/alpha_contours.dart @@ -33,58 +33,78 @@ Path traceAlphaContours( if (columns <= 0 || rows <= 0) { return path; } - // The alpha of the region with a transparent border around it, so that - // every outline is closed. + // The samples are the pixels of the region with a transparent border around + // them, so that every outline is closed. final gridWidth = columns + 2; final gridHeight = rows + 2; - final alpha = Float64List(gridWidth * gridHeight); + + /// The alpha of the pixel at the [sample], which is zero on the border. + double alphaAt(int sample) { + final x = sample % gridWidth - 1; + final y = sample ~/ gridWidth - 1; + if (x < 0 || y < 0 || x >= columns || y >= rows) { + return 0; + } + return pixels[((top + y) * width + left + x) * 4 + 3] / 255; + } + + // Whether each sample is inside, which is all that the scan of the cells + // needs, while the alpha itself is only read along the outlines. The + // smallest alpha that is inside is found with the same comparison that the + // alpha of a sample would go through. + var minAlpha = 0; + while (minAlpha < 256 && minAlpha / 255 < threshold) { + minAlpha++; + } + final inside = Uint8List(gridWidth * gridHeight); for (var y = 0; y < rows; y++) { - final pixel = (top + y) * width + left; + final pixel = ((top + y) * width + left) * 4 + 3; final sample = (y + 1) * gridWidth + 1; for (var x = 0; x < columns; x++) { - alpha[sample + x] = pixels[(pixel + x) * 4 + 3] / 255; + if (pixels[pixel + x * 4] >= minAlpha) { + inside[sample + x] = 1; + } } } // An edge joins a sample with the one at its right (2 * sample) or with the // one below it (2 * sample + 1). Each crossed edge starts one segment of an - // outline, which ends at the edge stored here, and it ends another one. - final next = Int32List(gridWidth * gridHeight * 2) - ..fillRange( - 0, - gridWidth * gridHeight * 2, - -1, - ); - // The corners and the edges of a cell in clockwise order, where the edge at - // an index goes from the corner at that index to the following one. - final corners = Int32List(4); + // outline, which ends at the edge stored here, and it ends another one. Only + // the edges along the outlines are stored, in the order they are found in, + // which is also the order of the [starts]. + final next = {}; + final starts = []; + // The edges of a cell in clockwise order, where the edge at an index goes + // from the corner at that index to the following one, and the corners are + // the bits of the cell in the same order. final edges = Int32List(4); - final inside = List.filled(4, false); final crossed = Int32List(4); for (var y = 0; y < gridHeight - 1; y++) { + final row = y * gridWidth; + // The left corners of a cell are the right corners of the previous one. + var topLeftBit = inside[row]; + var bottomLeftBit = inside[row + gridWidth]; for (var x = 0; x < gridWidth - 1; x++) { - final topLeft = y * gridWidth + x; - corners[0] = topLeft; - corners[1] = topLeft + 1; - corners[2] = topLeft + gridWidth + 1; - corners[3] = topLeft + gridWidth; - var insideCount = 0; - for (var i = 0; i < 4; i++) { - inside[i] = alpha[corners[i]] >= threshold; - if (inside[i]) { - insideCount++; - } - } - if (insideCount == 0 || insideCount == 4) { + final topLeft = row + x; + final topRightBit = inside[topLeft + 1]; + final bottomRightBit = inside[topLeft + gridWidth + 1]; + final cell = + topLeftBit | + topRightBit << 1 | + bottomRightBit << 2 | + bottomLeftBit << 3; + topLeftBit = topRightBit; + bottomLeftBit = bottomRightBit; + if (cell == 0 || cell == 15) { continue; } - edges[0] = 2 * corners[0]; - edges[1] = 2 * corners[1] + 1; - edges[2] = 2 * corners[3]; - edges[3] = 2 * corners[0] + 1; + edges[0] = 2 * topLeft; + edges[1] = 2 * (topLeft + 1) + 1; + edges[2] = 2 * (topLeft + gridWidth); + edges[3] = 2 * topLeft + 1; var count = 0; for (var i = 0; i < 4; i++) { - if (inside[i] != inside[(i + 1) % 4]) { + if ((cell >> i & 1) != (cell >> (i + 1) % 4 & 1)) { crossed[count++] = i; } } @@ -95,19 +115,21 @@ Path traceAlphaContours( // or apart: the segments then end at the next or at the previous // crossing, which are the same one in the other cases. final joined = - (alpha[corners[0]] + - alpha[corners[1]] + - alpha[corners[2]] + - alpha[corners[3]]) / - 4 >= - threshold; + count == 4 && + (alphaAt(topLeft) + + alphaAt(topLeft + 1) + + alphaAt(topLeft + gridWidth + 1) + + alphaAt(topLeft + gridWidth)) / + 4 >= + threshold; for (var i = 0; i < count; i++) { final edge = crossed[i]; - if (inside[edge]) { + if (cell >> edge & 1 == 1) { final end = joined ? crossed[(i + 1) % count] : crossed[(i + count - 1) % count]; next[edges[edge]] = edges[end]; + starts.add(edges[edge]); } } } @@ -116,25 +138,24 @@ Path traceAlphaContours( Offset crossing(int edge) { final sample = edge >> 1; final other = edge.isEven ? sample + 1 : sample + gridWidth; - final t = (threshold - alpha[sample]) / (alpha[other] - alpha[sample]); + final alpha = alphaAt(sample); + final t = (threshold - alpha) / (alphaAt(other) - alpha); // The sample at (1, 1) is the center of the first pixel of the region. final x = sample % gridWidth - 0.5 + (edge.isEven ? t : 0); final y = sample ~/ gridWidth - 0.5 + (edge.isEven ? 0 : t); return Offset(x * scaleX, y * scaleY); } - for (var start = 0; start < next.length; start++) { - if (next[start] < 0) { + for (final start in starts) { + if (!next.containsKey(start)) { continue; } final points = []; - var edge = start; + int? edge = start; do { - points.add(crossing(edge)); - final following = next[edge]; - next[edge] = -1; - edge = following; - } while (edge != start && edge >= 0); + points.add(crossing(edge!)); + edge = next.remove(edge); + } while (edge != start && edge != null); final outline = _withoutCollinear(_withoutDuplicates(points)); // The outer outlines have the inside on their right, so they go clockwise // on the screen, while the outlines of the holes go counterclockwise. diff --git a/packages/flame/lib/src/geometry/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart index f09184090fb..647e086074f 100644 --- a/packages/flame/lib/src/geometry/convex_pieces.dart +++ b/packages/flame/lib/src/geometry/convex_pieces.dart @@ -25,7 +25,7 @@ import 'package:flame/extensions.dart'; /// a hole that touches the outline at a vertex is filled, though. The parts /// of a [polygon] that crosses itself may be left out. /// -/// The cost grows about with the cube of the number of vertices, so it is +/// The cost grows about with the square of the number of vertices, so it is /// meant to be done when loading, not in the game loop. The polygons of a /// `PathComponent` are already simplified according to their sampling, while /// a [polygon] made by hand should not have a vertex for each pixel or each From 43cc1a480020d8c910ceac38f4f5a26a860734d6 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 5 Oct 2026 18:16:40 +0200 Subject: [PATCH 07/21] feat: Add CollidableSpritesExample with hitboxes traced from sprites Add a collision detection example, after CollidableAnimationExample, with four sprites of existing example images: flame.png, layers/player.png, zap.png and layers/enemy.png. They move along the same routes as the birds of the animation example while spinning around their centers, and turn back when they collide. The hitbox of each sprite is a PathHitbox made from Sprite.contour, so it follows the outline of the sprite rather than a hand-written polygon. A sprite only turns back when it moves towards what it hit, the walls that the intersection points are on or the center of the other sprite, so that two collisions that start at the same time do not cancel each other out, and a sprite that is caught up by a faster one keeps going instead of being pushed through it. --- .../collidable_sprites_example.dart | 144 ++++++++++++++++++ .../collision_detection.dart | 9 ++ 2 files changed, 153 insertions(+) create mode 100644 examples/lib/stories/collision_detection/collidable_sprites_example.dart diff --git a/examples/lib/stories/collision_detection/collidable_sprites_example.dart b/examples/lib/stories/collision_detection/collidable_sprites_example.dart new file mode 100644 index 00000000000..f8d9f02c6ef --- /dev/null +++ b/examples/lib/stories/collision_detection/collidable_sprites_example.dart @@ -0,0 +1,144 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/game.dart'; +import 'package:flame/geometry.dart'; +import 'package:flame/palette.dart'; + +class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { + static const description = ''' + In this example you can see four spinning sprites which are moving + straight along the same route as the birds of the animation example, until + they hit either another sprite or the wall, which makes them turn back. + The hitboxes of the sprites follow their outlines, which are traced from + the pixels of their images that are not transparent, and are marked with + the white lines. + '''; + + @override + Future onLoad() async { + add(ScreenHitbox()); + const componentWidth = 150.0; + // Top left component + add( + CollidableSpriteComponent( + 'assets/images/flame.png', + Vector2.all(200), + Vector2.all(100), + componentWidth * 2 / 3, + ), + ); + // Bottom right component + add( + CollidableSpriteComponent( + 'assets/images/layers/player.png', + Vector2(-100, -100), + size.clone()..sub(Vector2.all(200)), + componentWidth / 2, + ), + ); + // Bottom left component + add( + CollidableSpriteComponent( + 'assets/images/zap.png', + Vector2(100, -100), + Vector2(100, size.y - 100), + componentWidth, + angle: pi / 4, + ), + ); + // Top right component + add( + CollidableSpriteComponent( + 'assets/images/layers/enemy.png', + Vector2(-300, 300), + Vector2(size.x - 100, 100), + componentWidth / 3, + angle: pi / 4, + ), + ); + } +} + +/// A sprite of the image at [path] that is [spriteWidth] wide, with the +/// aspect ratio of the image, and that moves with the [velocity] while it +/// keeps turning around its center, a full circle every 3 seconds. Its hitbox +/// follows the outline of the sprite, and a collision turns it back. +class CollidableSpriteComponent( + final String path, + final Vector2 velocity, + Vector2 position, + final double spriteWidth, { + double angle = -pi / 4, +}) extends SpriteComponent with CollisionCallbacks, HasGameRef { + this + : super( + position: position, + angle: angle, + anchor: Anchor.center, + ); + + @override + Future onLoad() async { + final sprite = this.sprite = await gameRef.loadSprite(path); + size = sprite.srcSize..scale(spriteWidth / sprite.srcSize.x); + // The outline is relative to the top left corner of the sprite, while the + // hitbox moves it to its own origin, so the hitbox is placed where the + // outline starts. + final outline = await sprite.contour(size: size); + final hitboxPaint = BasicPalette.white.paint() + ..style = PaintingStyle.stroke; + add( + PathHitbox( + path: outline, + position: outline.getBounds().topLeft.toVector2(), + ) + ..paint = hitboxPaint + ..renderShape = true, + ); + add(RotateEffect.by(tau, EffectController(duration: 3, infinite: true))); + } + + @override + void update(double dt) { + super.update(dt); + position += velocity * dt; + } + + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + // Only a sprite that moves towards what it hit turns back, so that two + // collisions that start at the same time do not cancel each other out, + // and a sprite that is caught up by a faster one keeps going. What it hit + // is in the direction of the walls that the intersection points are on, + // or of the center of the other sprite. + final towards = Vector2.zero(); + if (other is ScreenHitbox) { + for (final point in intersectionPoints) { + if (point.x <= 1) { + towards.x = -1; + } else if (point.x >= other.size.x - 1) { + towards.x = 1; + } + if (point.y <= 1) { + towards.y = -1; + } else if (point.y >= other.size.y - 1) { + towards.y = 1; + } + } + } else { + towards.setFrom(other.absoluteCenter - absoluteCenter); + } + if (velocity.dot(towards) > 0) { + velocity.negate(); + } + } +} diff --git a/examples/lib/stories/collision_detection/collision_detection.dart b/examples/lib/stories/collision_detection/collision_detection.dart index 5802a13ca2d..ea42d7c3e9a 100644 --- a/examples/lib/stories/collision_detection/collision_detection.dart +++ b/examples/lib/stories/collision_detection/collision_detection.dart @@ -3,6 +3,7 @@ import 'package:examples/commons/example_use_case.dart'; import 'package:examples/stories/collision_detection/bouncing_ball_example.dart'; import 'package:examples/stories/collision_detection/circles_example.dart'; import 'package:examples/stories/collision_detection/collidable_animation_example.dart'; +import 'package:examples/stories/collision_detection/collidable_sprites_example.dart'; import 'package:examples/stories/collision_detection/multiple_shapes_example.dart'; import 'package:examples/stories/collision_detection/multiple_worlds_example.dart'; import 'package:examples/stories/collision_detection/quadtree_example.dart'; @@ -27,6 +28,14 @@ WidgetbookComponent collisionDetectionStories() { ), info: CollidableAnimationExample.description, ), + ExampleUseCase( + name: 'Collidable SpriteComponent', + builder: (_) => GameWidget(game: CollidableSpritesExample()), + codeLink: baseLink( + 'collision_detection/collidable_sprites_example.dart', + ), + info: CollidableSpritesExample.description, + ), ExampleUseCase( name: 'Circles', builder: (_) => GameWidget(game: CirclesExample()), From 299735995f7ed477e2828118e32777b808df2cca Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 5 Oct 2026 19:52:09 +0200 Subject: [PATCH 08/21] fix: Copy the vertices of the convex pieces and keep the contours within the region Address the review of the image contours: - convexPieces returns copies of the vertices, since a PolygonHitbox changes its vertices in place, which corrupted the neighboring pieces and the source polygon. - The contours stay within the region at every alpha threshold, as the samples of the transparent border sit half a pixel outside of it. - The last vertices of a loop that are welded to its first one are left out explicitly in _splitAtTouches. - A single signedArea helper replaces the shoelace copies in convexPieces, PolygonComponent.isClockwise and the alpha contours, and the orientation is described as clockwise on the screen everywhere. - PathComponent.polygonsOf samples the polygons of a path without making a component, which the forge2d example, the docs and the benchmark use. - SpriteBodyWorld initializes its field in place of a constructor body. --- doc/flame/components/shape_components.md | 10 +-- .../flame_forge2d/sprite_body_example.dart | 29 +++----- .../benchmark/image_contour_benchmark.dart | 7 +- packages/flame/lib/src/extensions/image.dart | 7 +- .../lib/src/geometry/alpha_contours.dart | 33 ++++----- .../flame/lib/src/geometry/convex_pieces.dart | 63 +++++++++-------- .../lib/src/geometry/path_component.dart | 19 ++++-- .../lib/src/geometry/polygon_component.dart | 8 +-- .../flame/lib/src/geometry/signed_area.dart | 16 +++++ .../test/extensions/image_contour_test.dart | 25 ++++--- .../test/geometry/convex_pieces_test.dart | 67 ++++++++++++++----- .../test/geometry/path_component_test.dart | 15 +++++ 12 files changed, 181 insertions(+), 118 deletions(-) create mode 100644 packages/flame/lib/src/geometry/signed_area.dart diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index 85febb70056..89a09581984 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -170,8 +170,8 @@ Some consumers of polygons only accept convex ones with a limited number of vert shapes of physics engines such as Box2D, which take at most 8 vertices. The `convexPieces` function splits a simple polygon, convex or concave, into such pieces: it first splits the polygon into triangles by ear clipping, and then merges neighboring triangles as long as they stay convex and -within `maxVertices`, which defaults to `8` (the Hertel-Mehlhorn algorithm). The pieces reuse the -vertices of the polygon and go in the same direction. +within `maxVertices`, which defaults to `8` (the Hertel-Mehlhorn algorithm). The pieces are made of +copies of the vertices of the polygon and go in the same direction. A polygon that touches itself at a vertex, like the outline of two shapes that meet at a corner, is split there first, and a hole that touches the outline at a vertex is filled. The parts of a @@ -190,7 +190,7 @@ its linear slop and `minWidth` twice it. The cost grows about with the square of the number of vertices, so split the polygons when loading and not in every tick. The polygons of a `PathComponent` are a convenient input, since they follow each contour of a path with straight edges and are already simplified according to their -`sampling`: +`sampling`, and `PathComponent.polygonsOf` gives them without making a component: ```dart void main() { @@ -207,9 +207,9 @@ void main() { Offset(0, 40), ], true); - final component = PathComponent(path: path); final pieces = [ - for (final polygon in component.polygons) ...convexPieces(polygon), + for (final polygon in PathComponent.polygonsOf(path)) + ...convexPieces(polygon), ]; } ``` diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 49d7341f2ad..f00a600c83b 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -4,7 +4,6 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; -import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; @@ -30,10 +29,6 @@ class SpriteBodyExample({bool showPieces = false}) extends Forge2DExampleGame { class SpriteBodyWorld({bool showPieces = false}) extends Forge2DWorld with TapCallbacks, HasGameRef { - this { - _showPieces = showPieces; - } - /// The width of the flames, in meters. static const flameWidth = 8.0; @@ -47,7 +42,7 @@ class SpriteBodyWorld({bool showPieces = false}) /// flames share, since tracing it reads back the pixels of the image. late final List> _pieces; - bool _showPieces = false; + bool _showPieces = showPieces; /// Whether the convex pieces of the flames are drawn, which applies to the /// flames already added too. @@ -107,9 +102,9 @@ class FlameBody( /// [size] in meters, relative to its center. /// /// The outline is traced in pixels rather than in meters, with [pixels] per - /// meter, so that the default sampling of the [PathComponent] that makes - /// its polygons follows it closely. The parts of the sprite that are apart - /// give separate polygons, and so separate pieces. + /// meter, so that the default sampling of the polygons of a + /// [PathComponent] follows it closely. The parts of the sprite that are + /// apart give separate polygons, and so separate pieces. static Future>> piecesOf( Sprite sprite, Vector2 size, @@ -117,19 +112,13 @@ class FlameBody( ) async { final pixelSize = size * pixels; final outline = await sprite.contour(size: pixelSize); - // The component moves the outline to its origin, so it is placed where - // the center of the outline is, relative to the center of the sprite. - final center = outline.getBounds().center.toVector2(); - final component = PathComponent( - path: outline, - position: (center - pixelSize / 2) / pixels, - anchor: Anchor.center, - scale: Vector2.all(1 / pixels), - ); + // The outline is relative to the top left corner of the sprite, while the + // pieces are relative to its center. + final center = pixelSize / 2; return [ - for (final polygon in component.polygons) + for (final polygon in PathComponent.polygonsOf(outline)) ...convexPieces( - [for (final vertex in polygon) component.positionOf(vertex)], + [for (final vertex in polygon) (vertex - center) / pixels], minDistance: 4 * linearSlop, minWidth: 2 * linearSlop, ), diff --git a/packages/flame/benchmark/image_contour_benchmark.dart b/packages/flame/benchmark/image_contour_benchmark.dart index eecbc67079b..1efcdc1e831 100644 --- a/packages/flame/benchmark/image_contour_benchmark.dart +++ b/packages/flame/benchmark/image_contour_benchmark.dart @@ -80,8 +80,8 @@ class ConvexPiecesBenchmark(final int vertices) extends _SingleRunBenchmark { } /// The whole way from the pixels of a [size] by [size] blob to the convex -/// pieces of its outline, through the polygons of a [PathComponent] with the -/// default sampling. +/// pieces of its outline, through the polygons that a [PathComponent] makes +/// of it with the default sampling. class PipelineBenchmark(final int size) extends _SingleRunBenchmark { this : super('Pixels to convex pieces of blob ${size}x$size'); @@ -95,8 +95,7 @@ class PipelineBenchmark(final int size) extends _SingleRunBenchmark { @override void run() { final outline = ImageExtension.contourFromPixels(_pixels, size, size); - final component = PathComponent(path: outline); - for (final polygon in component.polygons) { + for (final polygon in PathComponent.polygonsOf(outline)) { convexPieces(polygon); } } diff --git a/packages/flame/lib/src/extensions/image.dart b/packages/flame/lib/src/extensions/image.dart index 815d3e9c99a..e82ee8e1bb5 100644 --- a/packages/flame/lib/src/extensions/image.dart +++ b/packages/flame/lib/src/extensions/image.dart @@ -90,9 +90,10 @@ extension ImageExtension on Image { /// [alphaThreshold], which is between zero, excluded, and one. The outlines /// pass between the pixels, at the point where the alpha would reach the /// [alphaThreshold] if it changed linearly between their centers, so that the - /// anti-aliased edges of the image are followed more closely than a pixel. - /// Only the outer outlines are kept, not the ones of the holes in the - /// areas, and the separate areas give separate contours. + /// anti-aliased edges of the image are followed more closely than a pixel, + /// and they stay within the [region]. Only the outer outlines are kept, not + /// the ones of the holes in the areas, and the separate areas give separate + /// contours. /// /// The contours are meant to make hitboxes that follow the outline of the /// image, like a `PathHitbox`, or a `PolygonHitbox` from one of them, which diff --git a/packages/flame/lib/src/geometry/alpha_contours.dart b/packages/flame/lib/src/geometry/alpha_contours.dart index c823c682f10..ec446589aab 100644 --- a/packages/flame/lib/src/geometry/alpha_contours.dart +++ b/packages/flame/lib/src/geometry/alpha_contours.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; -import 'dart:ui'; +import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; import 'package:meta/meta.dart'; /// Traces the outlines of the areas of a region of [pixels] whose alpha is at @@ -14,9 +15,9 @@ import 'package:meta/meta.dart'; /// /// The outlines are found with marching squares on the alpha of the pixel /// centers, so they cross between two pixels where the alpha would reach the -/// [threshold] if it changed linearly between them. Only the outer outlines -/// are kept, not the ones of the holes, and each of them goes clockwise in -/// the screen coordinate system. +/// [threshold] if it changed linearly between them, and they stay within the +/// region. Only the outer outlines are kept, not the ones of the holes, and +/// each of them goes clockwise in the screen coordinate system. @internal Path traceAlphaContours( Uint8List pixels, { @@ -140,10 +141,15 @@ Path traceAlphaContours( final other = edge.isEven ? sample + 1 : sample + gridWidth; final alpha = alphaAt(sample); final t = (threshold - alpha) / (alphaAt(other) - alpha); - // The sample at (1, 1) is the center of the first pixel of the region. + // The sample at (1, 1) is the center of the first pixel of the region, + // while the samples of the border are half a pixel outside of it, so the + // crossings on their edges are kept within the region. final x = sample % gridWidth - 0.5 + (edge.isEven ? t : 0); final y = sample ~/ gridWidth - 0.5 + (edge.isEven ? 0 : t); - return Offset(x * scaleX, y * scaleY); + return Offset( + x.clamp(0, columns).toDouble() * scaleX, + y.clamp(0, rows).toDouble() * scaleY, + ); } for (final start in starts) { @@ -159,7 +165,8 @@ Path traceAlphaContours( final outline = _withoutCollinear(_withoutDuplicates(points)); // The outer outlines have the inside on their right, so they go clockwise // on the screen, while the outlines of the holes go counterclockwise. - if (outline.length >= 3 && _signedArea(outline) > 0) { + if (outline.length >= 3 && + signedArea([for (final point in outline) point.toVector2()]) > 0) { path.addPolygon(outline, true); } } @@ -206,15 +213,3 @@ bool _isCollinear(Offset previous, Offset point, Offset next) { (point.dy - previous.dy) * (next.dx - point.dx); return cross.abs() <= 1e-9; } - -/// Twice the area of the polygon, positive when it goes clockwise on the -/// screen. -double _signedArea(List points) { - var area = 0.0; - for (var i = 0; i < points.length; i++) { - final a = points[i]; - final b = points[(i + 1) % points.length]; - area += a.dx * b.dy - b.dx * a.dy; - } - return area; -} diff --git a/packages/flame/lib/src/geometry/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart index 647e086074f..56e6745b753 100644 --- a/packages/flame/lib/src/geometry/convex_pieces.dart +++ b/packages/flame/lib/src/geometry/convex_pieces.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; /// Splits a simple [polygon] into convex polygons of at most [maxVertices] /// vertices each, like the ones that physics engines such as Box2D need. @@ -18,7 +19,9 @@ import 'package:flame/extensions.dart'; /// For Box2D, [minDistance] is 4 times its linear slop and [minWidth] twice /// it, see `b2ComputeHull`. /// -/// The pieces reuse the vertices of the [polygon], in the same direction. +/// The pieces are made of copies of the vertices of the [polygon], going in +/// the same direction, so they can be handed to the consumers that change +/// their vertices in place, like a `PolygonHitbox`. /// /// The [polygon] may touch itself at its vertices, like the outline of two /// shapes that meet at a corner, which is split there into simple polygons: @@ -42,15 +45,16 @@ List> convexPieces( if (n < 3) { return const []; } - // The pieces are worked out on indices of vertices going counterclockwise, - // that is with a positive area, and turned back into vertices at the end. - final isClockwise = _doubleArea(vertices) < 0; - final indices = List.generate(n, (i) => isClockwise ? n - 1 - i : i); + // The pieces are worked out on indices of vertices going clockwise on the + // screen, that is with a positive signed area, and turned back into + // vertices at the end. + final isReversed = signedArea(vertices) < 0; + final indices = List.generate(n, (i) => isReversed ? n - 1 - i : i); final pieces = [ for (final loop in _splitAtTouches(vertices, indices, minDistance)) // The loops that go the other way are holes that touch the outline, // which the other loops already cover. - if (_doubleArea([for (final i in loop) vertices[i]]) > 0) + if (signedArea([for (final i in loop) vertices[i]]) > 0) ..._triangulate(vertices, loop), ]; // Corners that are reflex by less than half of the minimum width are @@ -59,21 +63,33 @@ List> convexPieces( return [ for (final piece in pieces) if (_isWideEnough(vertices, piece, minDistance, minWidth)) - [for (final i in isClockwise ? piece.reversed : piece) vertices[i]], + [ + for (final i in isReversed ? piece.reversed : piece) + vertices[i].clone(), + ], ]; } -/// Splits the polygon given by the [loop] of indices of [vertices] where it +/// Splits the polygon given by the [indices] of [vertices] where it /// touches itself, that is where two of its vertices that are not /// consecutive are welded, into loops that do not touch themselves. /// /// Ear clipping can get stuck on a polygon that touches itself, which would /// leave the rest of it out. +/// +/// The last vertices of a loop that are welded to its first one are left out, +/// like consecutive vertices are, since a loop that was split off ends next to +/// the vertex where the polygon touches itself. List> _splitAtTouches( List vertices, - List loop, + List indices, double minDistance, ) { + var loop = indices; + while (loop.length > 1 && + _isWelded(vertices[loop.last], vertices[loop.first], minDistance)) { + loop = loop.sublist(0, loop.length - 1); + } for (var a = 0; a < loop.length; a++) { for (var b = a + 2; b < loop.length; b++) { if (_isWelded(vertices[loop[a]], vertices[loop[b]], minDistance)) { @@ -135,25 +151,15 @@ bool _isWelded(Vector2 a, Vector2 b, double minDistance) { return distance == 0 || distance < minDistance; } -/// Twice the signed area of the [polygon], positive if it is counterclockwise. -double _doubleArea(List polygon) { - var area = 0.0; - for (var i = 0; i < polygon.length; i++) { - final a = polygon[i]; - final b = polygon[(i + 1) % polygon.length]; - area += a.x * b.y - b.x * a.y; - } - return area; -} - -/// Twice the signed area of the triangle [a], [b], [c], positive if it is -/// counterclockwise, that is if [b] is a convex corner. +/// Twice the signed area of the triangle [a], [b], [c], positive if it goes +/// clockwise on the screen, that is if [b] is a convex corner of a polygon +/// that goes clockwise on the screen. double _cross(Vector2 a, Vector2 b, Vector2 c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); } -/// Splits the counterclockwise polygon given by the [indices] of [vertices] -/// into triangles, by clipping its ears. +/// Splits the polygon given by the [indices] of [vertices], which goes +/// clockwise on the screen, into triangles, by clipping its ears. List> _triangulate(List vertices, List indices) { final remaining = List.of(indices); final triangles = >[]; @@ -243,8 +249,8 @@ void _merge( } } -/// The union of the counterclockwise pieces [a] and [b] if they share an -/// edge, or null otherwise. +/// The union of the pieces [a] and [b], which go clockwise on the screen, if +/// they share an edge, or null otherwise. List? _union(List a, List b) { for (var k = 0; k < a.length; k++) { final from = a[k]; @@ -264,8 +270,9 @@ List? _union(List a, List b) { return null; } -/// Whether the counterclockwise [piece] has no reflex corners, except for the -/// ones closer than the [tolerance] to the line through their neighbors. +/// Whether the [piece], which goes clockwise on the screen, has no reflex +/// corners, except for the ones closer than the [tolerance] to the line +/// through their neighbors. bool _isConvex(List vertices, List piece, double tolerance) { final m = piece.length; for (var i = 0; i < m; i++) { diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 3bb67ec430f..34d06ce24a5 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -51,7 +51,7 @@ class PathComponent({ /// all lie inside of the largest polygon are left out too, since they are /// details of the shape that it already covers, like the eyes of a face. this : super(size: path.getBounds().size.toVector2()) { - _polygons = _polygonsOf( + _polygons = polygonsOf( this.path, sampling: sampling, tolerance: tolerance, @@ -213,13 +213,18 @@ class PathComponent({ return rectIntersections; } - /// Returns the polygon of each closed contour of the [path] with at least - /// three vertices, with the vertices going counterclockwise. - static List> _polygonsOf( + /// Returns the polygons that a [PathComponent] of the [path] would have, in + /// the coordinates of the [path], without making the component. + /// + /// There is one polygon for each closed contour of the [path] with at least + /// three vertices, which go counterclockwise in the screen coordinate + /// system. See the constructor for the [sampling], the [tolerance] and the + /// [filter]. + static List> polygonsOf( Path path, { - required double sampling, - required double? tolerance, - required bool filter, + double sampling = 1.0, + double? tolerance, + bool filter = true, }) { final polygons = >[]; for (final metric in path.computeMetrics()) { diff --git a/packages/flame/lib/src/geometry/polygon_component.dart b/packages/flame/lib/src/geometry/polygon_component.dart index 0a46acfb3e9..fc7b914aa26 100644 --- a/packages/flame/lib/src/geometry/polygon_component.dart +++ b/packages/flame/lib/src/geometry/polygon_component.dart @@ -6,6 +6,7 @@ import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:flame/src/geometry/absolute_transform.dart'; +import 'package:flame/src/geometry/signed_area.dart'; import 'package:meta/meta.dart'; class PolygonComponent( @@ -376,11 +377,6 @@ class PolygonComponent( /// system, where the y axis points down. @internal static bool isClockwise(List vertices) { - var area = 0.0; - for (var i = 0; i < vertices.length; i++) { - final j = (i + 1) % vertices.length; - area += vertices[i].x * vertices[j].y - vertices[j].x * vertices[i].y; - } - return area >= 0; + return signedArea(vertices) >= 0; } } diff --git a/packages/flame/lib/src/geometry/signed_area.dart b/packages/flame/lib/src/geometry/signed_area.dart new file mode 100644 index 00000000000..382aab0ac2e --- /dev/null +++ b/packages/flame/lib/src/geometry/signed_area.dart @@ -0,0 +1,16 @@ +import 'package:flame/extensions.dart'; +import 'package:meta/meta.dart'; + +/// The signed area of the [polygon], which is positive when its vertices go +/// clockwise in the screen coordinate system, where the y axis points down, +/// and negative when they go counterclockwise. +@internal +double signedArea(List polygon) { + var area = 0.0; + for (var i = 0; i < polygon.length; i++) { + final a = polygon[i]; + final b = polygon[(i + 1) % polygon.length]; + area += a.x * b.y - b.x * a.y; + } + return area / 2; +} diff --git a/packages/flame/test/extensions/image_contour_test.dart b/packages/flame/test/extensions/image_contour_test.dart index c1a47db00f8..ef49975fa64 100644 --- a/packages/flame/test/extensions/image_contour_test.dart +++ b/packages/flame/test/extensions/image_contour_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; import 'package:flutter_test/flutter_test.dart'; /// The pixels of the [rows], where each character is a pixel: '#' is opaque, @@ -93,13 +94,10 @@ void main() { test('goes clockwise on the screen', () { final vertices = _vertices(_contour(['##', '##'])).single; - var area = 0.0; - for (var i = 0; i < vertices.length; i++) { - final a = vertices[i]; - final b = vertices[(i + 1) % vertices.length]; - area += a.dx * b.dy - b.dx * a.dy; - } - expect(area, greaterThan(0)); + expect( + signedArea([for (final vertex in vertices) vertex.toVector2()]), + greaterThan(0), + ); }); test('follows concave outlines', () { @@ -145,12 +143,23 @@ void main() { test('places the edges where the alpha reaches the threshold', () { final half = _contour(['#+']).getBounds(); expect(half.right, closeTo(1.5 + 0.004, 0.001)); - final low = _contour(['#+'], alphaThreshold: 0.25).getBounds(); + final low = _contour(['#+.'], alphaThreshold: 0.25).getBounds(); expect(low.right, closeTo(1.5 + 1 - 0.25 / (128 / 255), 0.001)); final high = _contour(['#+'], alphaThreshold: 0.75).getBounds(); expect(high.right, closeTo(0.5 + 0.25 / (127 / 255), 0.001)); }); + test('stays within the region at a low threshold', () { + final low = _contour(['##', '##'], alphaThreshold: 1 / 255); + expect(low.getBounds(), const Rect.fromLTRB(0, 0, 2, 2)); + final region = _contour( + ['....', '.##.', '....'], + region: const Rect.fromLTWH(1, 1, 2, 1), + alphaThreshold: 0.25, + ); + expect(region.getBounds(), const Rect.fromLTRB(0, 0, 2, 1)); + }); + test('keeps the corners where the alpha is exactly the threshold', () { // The outline passes through the centers of the opaque pixels, where // all the edges of the pixels are crossed at the same point. diff --git a/packages/flame/test/geometry/convex_pieces_test.dart b/packages/flame/test/geometry/convex_pieces_test.dart index 5273a921a0b..45ede631e07 100644 --- a/packages/flame/test/geometry/convex_pieces_test.dart +++ b/packages/flame/test/geometry/convex_pieces_test.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; +import 'package:flame/src/geometry/signed_area.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -45,7 +46,27 @@ void main() { Vector2(2, 0), ]; final pieces = convexPieces(clockwise); - expect(pieces.every((piece) => _signedArea(piece) < 0), isTrue); + expect(pieces.every((piece) => signedArea(piece) < 0), isTrue); + }); + + test('copies the vertices of the polygon', () { + final square = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(0, 1), + ]; + final pieces = convexPieces(square); + expect(pieces.single, unorderedEquals(square)); + for (final vertex in pieces.single) { + vertex.setZero(); + } + expect(square, [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(0, 1), + ]); }); test('limits the number of vertices of the pieces', () { @@ -55,7 +76,7 @@ void main() { ]; final pieces = convexPieces(circle, maxVertices: 5); expect(pieces.every((piece) => piece.length <= 5), isTrue); - expect(_totalArea(pieces), closeTo(_signedArea(circle), 1e-9)); + expect(_totalArea(pieces), closeTo(signedArea(circle), 1e-9)); }); test('welds the vertices closer than minDistance', () { @@ -96,6 +117,27 @@ void main() { expect(_totalArea(pieces), closeTo(2, 1e-9)); }); + test('welds the vertices next to where the polygon touches itself', () { + // Two squares that meet near the corner (1, 1), where the second visit + // is within minDistance of the first one, and the last vertex is within + // minDistance of the second visit but not of the first one. + final touching = [ + Vector2(1, 1), + Vector2(2, 1), + Vector2(2, 2), + Vector2(1, 2), + Vector2(1.05, 1), + Vector2(0, 1), + Vector2(0, 0), + Vector2(1, 0), + Vector2(1.1, 0.95), + ]; + final pieces = convexPieces(touching, minDistance: 0.1); + expect(pieces, hasLength(2)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(2.025, 1e-6)); + }); + test('fills a hole that touches the outline at a vertex', () { // A square, and a triangular hole that goes the other way, which both // start at (0, 0). @@ -136,8 +178,8 @@ void main() { ), isTrue, ); - expect(pieces.every((piece) => _signedArea(piece) > 0), isTrue); - expect(_totalArea(pieces), closeTo(_signedArea(star), 1e-6)); + expect(pieces.every((piece) => signedArea(piece) > 0), isTrue); + expect(_totalArea(pieces), closeTo(signedArea(star), 1e-6)); }, repeatCount: 100); test('covers the polygons of a PathComponent', () { @@ -151,8 +193,7 @@ void main() { ..lineTo(10, 40) ..lineTo(0, 40) ..close(); - final component = PathComponent(path: path); - final polygon = component.polygons.single.toList(); + final polygon = PathComponent.polygonsOf(path).single; final pieces = convexPieces(polygon); expect(pieces.length, greaterThan(1)); expect(pieces.every(_isConvex), isTrue); @@ -161,22 +202,12 @@ void main() { }); } -double _signedArea(List polygon) { - var area = 0.0; - for (var i = 0; i < polygon.length; i++) { - final a = polygon[i]; - final b = polygon[(i + 1) % polygon.length]; - area += a.x * b.y - b.x * a.y; - } - return area / 2; -} - double _totalArea(List> pieces) { - return pieces.fold(0, (sum, piece) => sum + _signedArea(piece).abs()); + return pieces.fold(0, (sum, piece) => sum + signedArea(piece).abs()); } bool _isConvex(List piece) { - final sign = _signedArea(piece).sign; + final sign = signedArea(piece).sign; for (var i = 0; i < piece.length; i++) { final a = piece[i]; final b = piece[(i + 1) % piece.length]; diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index f18e728a2bd..4c9b3a0bb01 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -102,6 +102,21 @@ void main() { ); }); + test('polygonsOf gives the polygons in the coordinates of the path', () { + final path = Path() + ..addRect(const Rect.fromLTWH(5, 5, 10, 10)) + ..addRect(const Rect.fromLTWH(7, 7, 2, 2)); + final polygons = PathComponent.polygonsOf(path); + final component = PathComponent(path: path); + + expect(polygons, hasLength(1)); + expect( + polygons.single.map((vertex) => vertex - Vector2.all(5)), + component.polygons.single, + ); + expect(PathComponent.polygonsOf(path, filter: false), hasLength(2)); + }); + test('contains the points inside of any polygon', () { final path = Path() ..addRect(const Rect.fromLTWH(0, 0, 10, 10)) From 46da114331e20aeb30a67239f796cce86299399a Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 15:49:08 +0200 Subject: [PATCH 09/21] fix: Keep the pieces of convexPieces strictly convex convexPieces accepted merged pieces with reflex corners shallower than half of minWidth, assuming the physics engine would straighten them, so it could return concave pieces whenever minWidth was positive. Merge the pieces only while they stay strictly convex, as documented, and test it with a minWidth. --- .../flame/lib/src/geometry/convex_pieces.dart | 22 ++++++------------- .../test/geometry/convex_pieces_test.dart | 15 +++++++++++++ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/flame/lib/src/geometry/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart index 56e6745b753..02ec9c3be43 100644 --- a/packages/flame/lib/src/geometry/convex_pieces.dart +++ b/packages/flame/lib/src/geometry/convex_pieces.dart @@ -57,9 +57,7 @@ List> convexPieces( if (signedArea([for (final i in loop) vertices[i]]) > 0) ..._triangulate(vertices, loop), ]; - // Corners that are reflex by less than half of the minimum width are - // accepted as convex, since the engine straightens them anyway. - _merge(vertices, pieces, maxVertices, minWidth / 2); + _merge(vertices, pieces, maxVertices); return [ for (final piece in pieces) if (_isWideEnough(vertices, piece, minDistance, minWidth)) @@ -222,13 +220,8 @@ int _findEar(List vertices, List remaining) { } /// Merges the [pieces] that share an edge, as long as the result is convex -/// within the [tolerance] and has at most [maxVertices] vertices. -void _merge( - List vertices, - List> pieces, - int maxVertices, - double tolerance, -) { +/// and has at most [maxVertices] vertices. +void _merge(List vertices, List> pieces, int maxVertices) { var hasMerged = true; while (hasMerged) { hasMerged = false; @@ -237,7 +230,7 @@ void _merge( final merged = _union(pieces[i], pieces[j]); if (merged != null && merged.length <= maxVertices && - _isConvex(vertices, merged, tolerance)) { + _isConvex(vertices, merged)) { pieces[i] = merged; pieces.removeAt(j); hasMerged = true; @@ -271,15 +264,14 @@ List? _union(List a, List b) { } /// Whether the [piece], which goes clockwise on the screen, has no reflex -/// corners, except for the ones closer than the [tolerance] to the line -/// through their neighbors. -bool _isConvex(List vertices, List piece, double tolerance) { +/// corners. +bool _isConvex(List vertices, List piece) { final m = piece.length; for (var i = 0; i < m; i++) { final a = vertices[piece[(i - 1 + m) % m]]; final b = vertices[piece[i]]; final c = vertices[piece[(i + 1) % m]]; - if (_cross(a, b, c) < -tolerance * a.distanceTo(c)) { + if (_cross(a, b, c) < 0) { return false; } } diff --git a/packages/flame/test/geometry/convex_pieces_test.dart b/packages/flame/test/geometry/convex_pieces_test.dart index 45ede631e07..f3a7df8f9da 100644 --- a/packages/flame/test/geometry/convex_pieces_test.dart +++ b/packages/flame/test/geometry/convex_pieces_test.dart @@ -99,6 +99,21 @@ void main() { expect(convexPieces(sliver), hasLength(1)); }); + test('keeps the pieces convex with a minWidth', () { + // A square with a shallow dent in its top edge, shallower than minWidth. + final dented = [ + Vector2(0, 0), + Vector2(5, 0.3), + Vector2(10, 0), + Vector2(10, 10), + Vector2(0, 10), + ]; + final pieces = convexPieces(dented, minWidth: 1); + expect(pieces, hasLength(2)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(signedArea(dented).abs(), 1e-9)); + }); + test('splits a polygon where it touches itself', () { // Two squares that meet at the corner (1, 1), which is visited twice. final touching = [ From 9c7e52b820e16129eb0b5d4bcca41ebe213875fa Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 16:03:38 +0200 Subject: [PATCH 10/21] fix: Find collinear contour points regardless of the contour size traceAlphaContours dropped the points whose cross product was below an absolute threshold after scaling the contour to the requested size, so real corners vanished at small sizes, down to an empty path, while collinear points were kept at large ones. Compare the angle between the edges instead, which doesn't depend on the scale, and test the outline at tiny, large and non-uniform sizes. --- .../lib/src/geometry/alpha_contours.dart | 16 ++++++--- .../test/extensions/image_contour_test.dart | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/flame/lib/src/geometry/alpha_contours.dart b/packages/flame/lib/src/geometry/alpha_contours.dart index ec446589aab..f48cb857cb4 100644 --- a/packages/flame/lib/src/geometry/alpha_contours.dart +++ b/packages/flame/lib/src/geometry/alpha_contours.dart @@ -207,9 +207,17 @@ List _withoutCollinear(List points) { ]; } +/// Whether the [point] is on the line through the [previous] and the [next] +/// ones, within an angle of about 1e-9 radians, which doesn't depend on the +/// scale of the outline, unlike its area. bool _isCollinear(Offset previous, Offset point, Offset next) { - final cross = - (point.dx - previous.dx) * (next.dy - point.dy) - - (point.dy - previous.dy) * (next.dx - point.dx); - return cross.abs() <= 1e-9; + final firstDx = point.dx - previous.dx; + final firstDy = point.dy - previous.dy; + final secondDx = next.dx - point.dx; + final secondDy = next.dy - point.dy; + final cross = firstDx * secondDy - firstDy * secondDx; + final squaredLengths = + (firstDx * firstDx + firstDy * firstDy) * + (secondDx * secondDx + secondDy * secondDy); + return cross * cross <= 1e-18 * squaredLengths; } diff --git a/packages/flame/test/extensions/image_contour_test.dart b/packages/flame/test/extensions/image_contour_test.dart index ef49975fa64..b990c8045e8 100644 --- a/packages/flame/test/extensions/image_contour_test.dart +++ b/packages/flame/test/extensions/image_contour_test.dart @@ -203,6 +203,39 @@ void main() { ); expect(path.getBounds(), const Rect.fromLTRB(0, 5, 10, 10)); }); + + test('keeps the same outline at any size', () { + final rows = [ + '......', + '.##+..', + '.####.', + '.+##..', + '......', + ]; + final expected = _contour(rows); + for (final size in [ + Vector2(6e-6, 5e-6), + Vector2(6e-2, 5e-2), + Vector2(6e3, 5e3), + Vector2(6e-6, 5), + Vector2(6e3, 5e-2), + ]) { + final actual = _contour(rows, size: size); + expect(actual.contours, hasLength(1), reason: 'size $size'); + // Points every quarter of a pixel, which tell apart the outlines + // that miss a corner, since the corners cut off half a pixel. They + // are shifted so that none of them is on an edge. + for (var y = 0.2; y < 5; y += 0.25) { + for (var x = 0.1; x < 6; x += 0.25) { + expect( + actual.contains(Offset(x * size.x / 6, y * size.y / 5)), + expected.contains(Offset(x, y)), + reason: '($x, $y) at size $size', + ); + } + } + } + }); }); group('ImageExtension.contour', () { From dcebd8062a44fad673c2882809705261d53cce2a Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 17:01:33 +0200 Subject: [PATCH 11/21] feat: Add FPS display in SpriteBodyExample and CollidableSpritesExample. --- .../bridge_libraries/flame_forge2d/sprite_body_example.dart | 5 ++++- .../collision_detection/collidable_sprites_example.dart | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index f00a600c83b..e1fbebcb826 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -7,7 +7,9 @@ import 'package:flame/events.dart'; import 'package:flame/geometry.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class SpriteBodyExample({bool showPieces = false}) extends Forge2DExampleGame { +class SpriteBodyExample({bool showPieces = false}) + extends Forge2DExampleGame + with HasGameRef { static const String description = ''' In this example we show how to add a sprite on top of a `BodyComponent` whose shape follows the outline of the sprite. @@ -57,6 +59,7 @@ class SpriteBodyWorld({bool showPieces = false}) @override Future onLoad() async { await super.onLoad(); + gameRef.camera.viewport.add(FpsTextComponent(position: Vector2(8, 4))); addAll(createBoundaries(gameRef)); _sprite = await gameRef.loadSprite('assets/images/flame.png'); _size = _sprite.srcSize..scale(flameWidth / _sprite.srcSize.x); diff --git a/examples/lib/stories/collision_detection/collidable_sprites_example.dart b/examples/lib/stories/collision_detection/collidable_sprites_example.dart index f8d9f02c6ef..4a1dc7f2163 100644 --- a/examples/lib/stories/collision_detection/collidable_sprites_example.dart +++ b/examples/lib/stories/collision_detection/collidable_sprites_example.dart @@ -21,6 +21,8 @@ class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { @override Future onLoad() async { + camera.viewport.add(FpsTextComponent(position: Vector2(8, 4))); + add(ScreenHitbox()); const componentWidth = 150.0; // Top left component From fe9e17d6267bc7814dd361730c165fa89fbcb27b Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 17:19:08 +0200 Subject: [PATCH 12/21] fix: Draw the pieces of SpriteBodyExample on top of the sprite The pieces were drawn by the body before its SpriteComponent child, which covered them entirely, and with an opaque white fill that hid where one piece ends and the next begins. Draw the sprite in the body's render before its shapes, and outline the pieces with the GlowingBody style. --- .../flame_forge2d/sprite_body_example.dart | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index e1fbebcb826..e7e56a4da1b 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'dart:ui'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; @@ -85,14 +86,19 @@ class SpriteBodyWorld({bool showPieces = false}) } /// A body that is drawn by a [sprite] of the given [size], in meters, and -/// collides as the convex [pieces] of its outline. +/// collides as the convex [pieces] of its outline, which are drawn on top of +/// the [sprite] when [renderBody] is true. class FlameBody( final Vector2 initialPosition, { required final Sprite sprite, required final List> pieces, required final Vector2 size, -}) extends BodyComponent { - this : super(renderBody: false); +}) extends BodyComponent with GlowingBody { + this + : super( + paint: Paint()..color = ExampleColors.sky, + renderBody: false, + ); /// The linear slop of Box2D in meters, as `Tolerances.linearSlop` with the /// default length units, which is not used here as it needs the native @@ -129,9 +135,14 @@ class FlameBody( } @override - Future onLoad() async { - await super.onLoad(); - add(SpriteComponent(sprite: sprite, size: size, anchor: Anchor.center)); + double get outlineWidth => 0.05; + + // The sprite is drawn here rather than by a child, since the children are + // drawn after the body, and the sprite would hide the pieces. + @override + void render(Canvas canvas) { + sprite.render(canvas, size: size, anchor: Anchor.center); + super.render(canvas); } @override From 29b8e75e0d795b654c911555a448b3b435d1f623 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 17:30:43 +0200 Subject: [PATCH 13/21] feat: Alternate flames and pizzas in SpriteBodyExample Taps now add 8 m flames and 6 m pizzas in turn, both with bodies made of the convex pieces of their traced outlines, so the example shows that Sprite.contour works for different images. FlameBody becomes ContourBody, since it now holds any sprite. --- .../flame_forge2d/sprite_body_example.dart | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index e7e56a4da1b..0883be2bc24 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -19,7 +19,8 @@ class SpriteBodyExample({bool showPieces = false}) transparent, with `Sprite.contour`, and the body collides as its convex pieces, which the Show pieces knob draws. - Tap the screen to add more flames. + Tap the screen to add more bodies, which alternate between flames and + pizzas. '''; this @@ -29,31 +30,33 @@ class SpriteBodyExample({bool showPieces = false}) ); } +/// A sprite, with its size in meters, with the aspect ratio of the sprite, +/// and the convex pieces of its outline, which all the bodies of that sprite +/// share, since tracing it reads back the pixels of the image. +typedef _Shape = ({Sprite sprite, Vector2 size, List> pieces}); + class SpriteBodyWorld({bool showPieces = false}) extends Forge2DWorld with TapCallbacks, HasGameRef { - /// The width of the flames, in meters. - static const flameWidth = 8.0; - - late final Sprite _sprite; + /// The images of the bodies, which the taps go through in turn, with their + /// widths in meters. + static const images = {'flame.png': 8.0, 'pizza.png': 6.0}; - /// The size of the flames, in meters, with the aspect ratio of the - /// [_sprite]. - late final Vector2 _size; + /// The shape of each of the [images]. + late final List<_Shape> _shapes; - /// The convex pieces of the outline of the [_sprite], which all the - /// flames share, since tracing it reads back the pixels of the image. - late final List> _pieces; + /// The number of bodies added by tapping, which picks the next shape. + int _taps = 0; bool _showPieces = showPieces; - /// Whether the convex pieces of the flames are drawn, which applies to the - /// flames already added too. + /// Whether the convex pieces of the bodies are drawn, which applies to the + /// bodies already added too. bool get showPieces => _showPieces; set showPieces(bool value) { _showPieces = value; - for (final flame in children.whereType()) { - flame.renderBody = value; + for (final body in children.whereType()) { + body.renderBody = value; } } @@ -62,24 +65,34 @@ class SpriteBodyWorld({bool showPieces = false}) await super.onLoad(); gameRef.camera.viewport.add(FpsTextComponent(position: Vector2(8, 4))); addAll(createBoundaries(gameRef)); - _sprite = await gameRef.loadSprite('assets/images/flame.png'); - _size = _sprite.srcSize..scale(flameWidth / _sprite.srcSize.x); - _pieces = await FlameBody.piecesOf( - _sprite, - _size, + _shapes = [ + for (final MapEntry(key: image, value: width) in images.entries) + await _loadShape(image, width), + ]; + } + + /// The shape of the [image], scaled to the [width] in meters. + Future<_Shape> _loadShape(String image, double width) async { + final sprite = await gameRef.loadSprite('assets/images/$image'); + final size = sprite.srcSize..scale(width / sprite.srcSize.x); + final pieces = await ContourBody.piecesOf( + sprite, + size, gameRef.metersToPixels, ); + return (sprite: sprite, size: size, pieces: pieces); } @override void onTapDown(TapDownEvent info) { super.onTapDown(info); + final shape = _shapes[_taps++ % _shapes.length]; add( - FlameBody( + ContourBody( info.localPosition, - sprite: _sprite, - pieces: _pieces, - size: _size, + sprite: shape.sprite, + pieces: shape.pieces, + size: shape.size, )..renderBody = showPieces, ); } @@ -88,7 +101,7 @@ class SpriteBodyWorld({bool showPieces = false}) /// A body that is drawn by a [sprite] of the given [size], in meters, and /// collides as the convex [pieces] of its outline, which are drawn on top of /// the [sprite] when [renderBody] is true. -class FlameBody( +class ContourBody( final Vector2 initialPosition, { required final Sprite sprite, required final List> pieces, From 14cc338aabd1f2b2f169d9076c18ecf9d5623233 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 22:30:18 +0200 Subject: [PATCH 14/21] refactor: Use proper named position parameter in CollidableSpriteComponent. --- .../collidable_sprites_example.dart | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/examples/lib/stories/collision_detection/collidable_sprites_example.dart b/examples/lib/stories/collision_detection/collidable_sprites_example.dart index 4a1dc7f2163..d933d9bf234 100644 --- a/examples/lib/stories/collision_detection/collidable_sprites_example.dart +++ b/examples/lib/stories/collision_detection/collidable_sprites_example.dart @@ -30,8 +30,8 @@ class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { CollidableSpriteComponent( 'assets/images/flame.png', Vector2.all(200), - Vector2.all(100), componentWidth * 2 / 3, + position: Vector2.all(100), ), ); // Bottom right component @@ -39,8 +39,8 @@ class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { CollidableSpriteComponent( 'assets/images/layers/player.png', Vector2(-100, -100), - size.clone()..sub(Vector2.all(200)), componentWidth / 2, + position: size.clone()..sub(Vector2.all(200)), ), ); // Bottom left component @@ -48,8 +48,8 @@ class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { CollidableSpriteComponent( 'assets/images/zap.png', Vector2(100, -100), - Vector2(100, size.y - 100), componentWidth, + position: Vector2(100, size.y - 100), angle: pi / 4, ), ); @@ -58,8 +58,8 @@ class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { CollidableSpriteComponent( 'assets/images/layers/enemy.png', Vector2(-300, 300), - Vector2(size.x - 100, 100), componentWidth / 3, + position: Vector2(size.x - 100, 100), angle: pi / 4, ), ); @@ -73,8 +73,8 @@ class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { class CollidableSpriteComponent( final String path, final Vector2 velocity, - Vector2 position, final double spriteWidth, { + required Vector2 position, double angle = -pi / 4, }) extends SpriteComponent with CollisionCallbacks, HasGameRef { this @@ -119,28 +119,32 @@ class CollidableSpriteComponent( super.onCollisionStart(intersectionPoints, other); // Only a sprite that moves towards what it hit turns back, so that two // collisions that start at the same time do not cancel each other out, - // and a sprite that is caught up by a faster one keeps going. What it hit - // is in the direction of the walls that the intersection points are on, - // or of the center of the other sprite. + // and a sprite that is caught up by a faster one keeps going. + if (velocity.dot(_towards(other, intersectionPoints)) > 0) { + velocity.negate(); + } + } + + /// The direction of the [other] component that this one hit at the + /// [intersectionPoints]: that of the walls that the points are on, or of + /// the center of the other sprite. + Vector2 _towards(PositionComponent other, List intersectionPoints) { + if (other is! ScreenHitbox) { + return other.absoluteCenter - absoluteCenter; + } final towards = Vector2.zero(); - if (other is ScreenHitbox) { - for (final point in intersectionPoints) { - if (point.x <= 1) { - towards.x = -1; - } else if (point.x >= other.size.x - 1) { - towards.x = 1; - } - if (point.y <= 1) { - towards.y = -1; - } else if (point.y >= other.size.y - 1) { - towards.y = 1; - } + for (final point in intersectionPoints) { + if (point.x <= 1) { + towards.x = -1; + } else if (point.x >= other.size.x - 1) { + towards.x = 1; + } + if (point.y <= 1) { + towards.y = -1; + } else if (point.y >= other.size.y - 1) { + towards.y = 1; } - } else { - towards.setFrom(other.absoluteCenter - absoluteCenter); - } - if (velocity.dot(towards) > 0) { - velocity.negate(); } + return towards; } } From 82d38959bc544b6bb4532e23bd914442c0f06a42 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 23:04:22 +0200 Subject: [PATCH 15/21] feat: Add initial spin and zap sprite in SpriteBodyExample. --- .../bridge_libraries/flame_forge2d/sprite_body_example.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 0883be2bc24..0c243359af7 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -40,7 +40,7 @@ class SpriteBodyWorld({bool showPieces = false}) with TapCallbacks, HasGameRef { /// The images of the bodies, which the taps go through in turn, with their /// widths in meters. - static const images = {'flame.png': 8.0, 'pizza.png': 6.0}; + static const images = {'flame.png': 8.0, 'pizza.png': 6.0, 'zap.png': 9.0}; /// The shape of each of the [images]. late final List<_Shape> _shapes; @@ -165,9 +165,11 @@ class ContourBody( material: SurfaceMaterial(restitution: 0.4, friction: 0.5), ); + final angle = (initialPosition.x + initialPosition.y) / 2 * pi; final bodyDef = BodyDef( position: initialPosition, - rotation: Rot.fromAngle((initialPosition.x + initialPosition.y) / 2 * pi), + rotation: Rot.fromAngle(angle), + angularVelocity: angle / 8, type: BodyType.dynamic, ); final body = world.createBody(bodyDef); From dc783bb24ec83be9640f90fb7d84c6d24b6b03f0 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 10 Oct 2026 23:52:52 +0200 Subject: [PATCH 16/21] refactor: Hoist polygonsOf out of PathComponent into Path.toPolygons. --- doc/flame/components/shape_components.md | 12 ++-- .../flame_forge2d/sprite_body_example.dart | 3 +- .../benchmark/image_contour_benchmark.dart | 2 +- packages/flame/lib/src/extensions/path.dart | 53 ++++++++++++++++ .../lib/src/geometry/path_component.dart | 61 ++----------------- packages/flame/test/extensions/path_test.dart | 51 ++++++++++++++++ .../test/geometry/convex_pieces_test.dart | 5 +- .../test/geometry/path_component_test.dart | 7 +-- 8 files changed, 125 insertions(+), 69 deletions(-) diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index 89a09581984..e4e3f4a98cc 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -153,9 +153,11 @@ void main() { ``` The `sampling` and `tolerance` arguments control how the contours are followed, see -[](#from-a-path). The vertices of each polygon are available in `polygons`. +[](#from-a-path). The vertices of each polygon are available in `polygons`, and `path.toPolygons()` +gives the same polygons, in the coordinates of the path, without making a component. -Contours with fewer than three vertices, like open lines, are rendered but do not become polygons. +Open contours and degenerate ones, with fewer than three vertices or no area, are rendered but do +not become polygons. By default, the polygons whose vertices all lie inside of the largest polygon are left out as well, since the largest one already covers them; the eyes of a face are an example of this. Pass `filter: false` to keep every polygon, for example when the inner contours should be hit by rays. @@ -188,9 +190,9 @@ Both default to `0`, which keeps every vertex and every piece. For Box2D, `minDi its linear slop and `minWidth` twice it. The cost grows about with the square of the number of vertices, so split the polygons when loading -and not in every tick. The polygons of a `PathComponent` are a convenient input, since they follow +and not in every tick. The polygons from `Path.toPolygons` are a convenient input, since they follow each contour of a path with straight edges and are already simplified according to their -`sampling`, and `PathComponent.polygonsOf` gives them without making a component: +`sampling`: ```dart void main() { @@ -208,7 +210,7 @@ void main() { ], true); final pieces = [ - for (final polygon in PathComponent.polygonsOf(path)) + for (final polygon in path.toPolygons()) ...convexPieces(polygon), ]; } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 0c243359af7..54cd4dcdef6 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -5,6 +5,7 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; +import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; @@ -138,7 +139,7 @@ class ContourBody( // pieces are relative to its center. final center = pixelSize / 2; return [ - for (final polygon in PathComponent.polygonsOf(outline)) + for (final polygon in outline.toPolygons()) ...convexPieces( [for (final vertex in polygon) (vertex - center) / pixels], minDistance: 4 * linearSlop, diff --git a/packages/flame/benchmark/image_contour_benchmark.dart b/packages/flame/benchmark/image_contour_benchmark.dart index 1efcdc1e831..e73358d21ce 100644 --- a/packages/flame/benchmark/image_contour_benchmark.dart +++ b/packages/flame/benchmark/image_contour_benchmark.dart @@ -95,7 +95,7 @@ class PipelineBenchmark(final int size) extends _SingleRunBenchmark { @override void run() { final outline = ImageExtension.contourFromPixels(_pixels, size, size); - for (final polygon in PathComponent.polygonsOf(outline)) { + for (final polygon in outline.toPolygons()) { convexPieces(polygon); } } diff --git a/packages/flame/lib/src/extensions/path.dart b/packages/flame/lib/src/extensions/path.dart index 90b906be5cf..0c329d1abff 100644 --- a/packages/flame/lib/src/extensions/path.dart +++ b/packages/flame/lib/src/extensions/path.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flame/extensions.dart'; import 'package:flame/src/cache/matrix_pool.dart' show pathTransform; +import 'package:flame/src/geometry/signed_area.dart'; export 'dart:ui' show Path; @@ -88,6 +89,58 @@ extension PathExtension on Path { ]; } + /// Returns a polygon for each closed contour of the path, with vertices that + /// go counterclockwise in the screen coordinate system. + /// + /// Unlike [walkContours], open contours and degenerate ones, with fewer than + /// three vertices or no area, are dropped. When [filter] is true, so are the + /// polygons that lie inside of the largest one. + /// + /// See [PathMetricExtension.walkContour] for the [sampling] and the + /// [tolerance] parameters. + List> toPolygons({ + double sampling = 1.0, + double? tolerance, + bool filter = true, + }) { + final polygons = >[]; + for (final metric in computeMetrics()) { + if (!metric.isClosed) { + continue; + } + final vertices = metric.walkContour(sampling, tolerance).vertices; + final area = vertices.length > 2 ? signedArea(vertices) : 0.0; + if (area == 0) { + continue; + } + if (area > 0) { + vertices.reverse(); + } + polygons.add(vertices); + } + if (filter && polygons.length > 1) { + final largest = polygons.reduce( + (a, b) => _boundsArea(a) >= _boundsArea(b) ? a : b, + ); + final largestPath = Path() + ..addPolygon( + largest.map((vertex) => vertex.toOffset()).toList(growable: false), + true, + ); + polygons.removeWhere( + (polygon) => + polygon != largest && + polygon.every((vertex) => largestPath.contains(vertex.toOffset())), + ); + } + return polygons; + } + + static double _boundsArea(List vertices) { + final bounds = RectExtension.getBounds(vertices); + return bounds.width * bounds.height; + } + /// Walk only the contour at [index], without sampling the other contours of /// the path. /// diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 34d06ce24a5..470f081bc30 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -46,13 +46,13 @@ class PathComponent({ /// are left out; by default, the tolerance is half the [sampling]. See /// [PathMetricExtension.walkContour] for the details of both parameters. /// - /// Contours that end up with fewer than three vertices, like open lines, do - /// not become polygons. When [filter] is true, the polygons whose vertices - /// all lie inside of the largest polygon are left out too, since they are - /// details of the shape that it already covers, like the eyes of a face. + /// Open and degenerate contours do not become polygons, see + /// [PathExtension.toPolygons]. When [filter] is true, the polygons whose + /// vertices all lie inside of the largest polygon are left out too, since + /// they are details of the shape that it already covers, like the eyes of a + /// face. this : super(size: path.getBounds().size.toVector2()) { - _polygons = polygonsOf( - this.path, + _polygons = this.path.toPolygons( sampling: sampling, tolerance: tolerance, filter: filter, @@ -212,53 +212,4 @@ class PathComponent({ } return rectIntersections; } - - /// Returns the polygons that a [PathComponent] of the [path] would have, in - /// the coordinates of the [path], without making the component. - /// - /// There is one polygon for each closed contour of the [path] with at least - /// three vertices, which go counterclockwise in the screen coordinate - /// system. See the constructor for the [sampling], the [tolerance] and the - /// [filter]. - static List> polygonsOf( - Path path, { - double sampling = 1.0, - double? tolerance, - bool filter = true, - }) { - final polygons = >[]; - for (final metric in path.computeMetrics()) { - if (!metric.isClosed) { - continue; - } - final contour = metric.walkContour(sampling, tolerance); - if (contour.length > 2) { - final vertices = contour.vertices; - if (PolygonComponent.isClockwise(vertices)) { - vertices.reverse(); - } - polygons.add(vertices); - } - } - if (filter && polygons.length > 1) { - final largest = polygons.reduce((a, b) => _area(a) >= _area(b) ? a : b); - final largestPath = Path() - ..addPolygon( - largest.map((vertex) => vertex.toOffset()).toList(growable: false), - true, - ); - polygons.removeWhere( - (polygon) => - polygon != largest && - polygon.every((vertex) => largestPath.contains(vertex.toOffset())), - ); - } - return polygons; - } - - /// The area of the bounds of the polygon with the given [vertices]. - static double _area(List vertices) { - final bounds = RectExtension.getBounds(vertices); - return bounds.width * bounds.height; - } } diff --git a/packages/flame/test/extensions/path_test.dart b/packages/flame/test/extensions/path_test.dart index 6f263b11ca4..6635dae4962 100644 --- a/packages/flame/test/extensions/path_test.dart +++ b/packages/flame/test/extensions/path_test.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'dart:ui'; import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; import 'package:test/test.dart'; double _area(List polygon) { @@ -227,6 +228,56 @@ void main() { }); }); + group('toPolygons', () { + test('gives a counterclockwise polygon for each closed contour', () { + final path = Path() + ..addRect(const Rect.fromLTWH(0, 0, 10, 10)) + ..addPolygon(const [ + Offset(20, 0), + Offset(20, 10), + Offset(30, 10), + ], true); + final polygons = path.toPolygons(); + expect(polygons, hasLength(2)); + expect(polygons.every((polygon) => signedArea(polygon) < 0), isTrue); + expect( + RectExtension.getBounds(polygons.first), + const Rect.fromLTWH(0, 0, 10, 10), + ); + }); + + test('drops the open and the degenerate contours', () { + final path = Path() + ..moveTo(0, 0) + ..lineTo(10, 0) + ..lineTo(10, 10) + ..moveTo(0, 20) + ..lineTo(10, 20) + ..close() + ..moveTo(0, 30) + ..close() + ..addRect(const Rect.fromLTWH(20, 20, 10, 10)); + expect(path.toPolygons(), hasLength(1)); + expect(Path().toPolygons(), isEmpty); + }); + + test('drops the polygons inside of the largest one unless asked', () { + final path = Path() + ..addRect(const Rect.fromLTWH(5, 5, 10, 10)) + ..addRect(const Rect.fromLTWH(7, 7, 2, 2)); + expect(path.toPolygons(), hasLength(1)); + expect(path.toPolygons(filter: false), hasLength(2)); + }); + + test('follows the contours like walkContours', () { + final path = Path()..addOval(const Rect.fromLTWH(0, 0, 100, 60)); + expect( + path.toPolygons(sampling: 2, tolerance: 0.3).single, + hasLength(path.walkContours(2, 0.3).single.length), + ); + }); + }); + group('contours', () { test('can be read more than once', () { final path = Path() diff --git a/packages/flame/test/geometry/convex_pieces_test.dart b/packages/flame/test/geometry/convex_pieces_test.dart index f3a7df8f9da..f0e733de959 100644 --- a/packages/flame/test/geometry/convex_pieces_test.dart +++ b/packages/flame/test/geometry/convex_pieces_test.dart @@ -1,6 +1,5 @@ import 'dart:math' as math; -import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:flame/src/geometry/signed_area.dart'; @@ -197,7 +196,7 @@ void main() { expect(_totalArea(pieces), closeTo(signedArea(star), 1e-6)); }, repeatCount: 100); - test('covers the polygons of a PathComponent', () { + test('covers the polygons of a path', () { final path = Path() ..moveTo(0, 0) ..lineTo(40, 0) @@ -208,7 +207,7 @@ void main() { ..lineTo(10, 40) ..lineTo(0, 40) ..close(); - final polygon = PathComponent.polygonsOf(path).single; + final polygon = path.toPolygons().single; final pieces = convexPieces(polygon); expect(pieces.length, greaterThan(1)); expect(pieces.every(_isConvex), isTrue); diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index 4c9b3a0bb01..0cfb5c3b52b 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -1,7 +1,7 @@ import 'dart:math'; -import 'dart:ui'; import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; import 'package:flame_test/test_paths.dart'; import 'package:test/test.dart'; @@ -102,11 +102,11 @@ void main() { ); }); - test('polygonsOf gives the polygons in the coordinates of the path', () { + test('the polygons are the ones of the path moved to the origin', () { final path = Path() ..addRect(const Rect.fromLTWH(5, 5, 10, 10)) ..addRect(const Rect.fromLTWH(7, 7, 2, 2)); - final polygons = PathComponent.polygonsOf(path); + final polygons = path.toPolygons(); final component = PathComponent(path: path); expect(polygons, hasLength(1)); @@ -114,7 +114,6 @@ void main() { polygons.single.map((vertex) => vertex - Vector2.all(5)), component.polygons.single, ); - expect(PathComponent.polygonsOf(path, filter: false), hasLength(2)); }); test('contains the points inside of any polygon', () { From a6d0d3b625d3776c75a2836b2e9344503d1ba796 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 13 Oct 2026 23:15:15 +0200 Subject: [PATCH 17/21] refactor: Trace the ContourBody pieces in onLoad. --- .../flame_forge2d/sprite_body_example.dart | 92 +++++++------------ 1 file changed, 35 insertions(+), 57 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 54cd4dcdef6..aaae753a095 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -13,15 +13,14 @@ class SpriteBodyExample({bool showPieces = false}) extends Forge2DExampleGame with HasGameRef { static const String description = ''' - In this example we show how to add a sprite on top of a `BodyComponent` + In this example we show how to add a sprite on top of a BodyComponent whose shape follows the outline of the sprite. The outline is traced from the pixels of the image that are not - transparent, with `Sprite.contour`, and the body collides as its convex + transparent with Sprite.contour, and the body collides as its convex pieces, which the Show pieces knob draws. - Tap the screen to add more bodies, which alternate between flames and - pizzas. + Tap the screen to add more bodies. '''; this @@ -31,28 +30,17 @@ class SpriteBodyExample({bool showPieces = false}) ); } -/// A sprite, with its size in meters, with the aspect ratio of the sprite, -/// and the convex pieces of its outline, which all the bodies of that sprite -/// share, since tracing it reads back the pixels of the image. -typedef _Shape = ({Sprite sprite, Vector2 size, List> pieces}); - class SpriteBodyWorld({bool showPieces = false}) extends Forge2DWorld with TapCallbacks, HasGameRef { - /// The images of the bodies, which the taps go through in turn, with their - /// widths in meters. + /// The images added in turn by taps, with their widths in meters. static const images = {'flame.png': 8.0, 'pizza.png': 6.0, 'zap.png': 9.0}; - /// The shape of each of the [images]. - late final List<_Shape> _shapes; - - /// The number of bodies added by tapping, which picks the next shape. int _taps = 0; bool _showPieces = showPieces; - /// Whether the convex pieces of the bodies are drawn, which applies to the - /// bodies already added too. + /// Whether the convex pieces of all the bodies are drawn. bool get showPieces => _showPieces; set showPieces(bool value) { _showPieces = value; @@ -66,46 +54,33 @@ class SpriteBodyWorld({bool showPieces = false}) await super.onLoad(); gameRef.camera.viewport.add(FpsTextComponent(position: Vector2(8, 4))); addAll(createBoundaries(gameRef)); - _shapes = [ - for (final MapEntry(key: image, value: width) in images.entries) - await _loadShape(image, width), - ]; - } - - /// The shape of the [image], scaled to the [width] in meters. - Future<_Shape> _loadShape(String image, double width) async { - final sprite = await gameRef.loadSprite('assets/images/$image'); - final size = sprite.srcSize..scale(width / sprite.srcSize.x); - final pieces = await ContourBody.piecesOf( - sprite, - size, - gameRef.metersToPixels, - ); - return (sprite: sprite, size: size, pieces: pieces); + await gameRef.images.loadAll([ + for (final image in images.keys) 'assets/images/$image', + ]); } @override void onTapDown(TapDownEvent info) { super.onTapDown(info); - final shape = _shapes[_taps++ % _shapes.length]; + final MapEntry(key: image, value: width) = images.entries.elementAt( + _taps++ % images.length, + ); + final sprite = Sprite(gameRef.images.fromCache('assets/images/$image')); add( ContourBody( info.localPosition, - sprite: shape.sprite, - pieces: shape.pieces, - size: shape.size, + sprite: sprite, + size: sprite.srcSize..scale(width / sprite.srcSize.x), )..renderBody = showPieces, ); } } -/// A body that is drawn by a [sprite] of the given [size], in meters, and -/// collides as the convex [pieces] of its outline, which are drawn on top of -/// the [sprite] when [renderBody] is true. +/// A body drawn by a [sprite] of the given [size] in meters, colliding as the +/// convex [pieces] of its outline, drawn when [renderBody] is true. class ContourBody( final Vector2 initialPosition, { required final Sprite sprite, - required final List> pieces, required final Vector2 size, }) extends BodyComponent with GlowingBody { this @@ -114,25 +89,28 @@ class ContourBody( renderBody: false, ); - /// The linear slop of Box2D in meters, as `Tolerances.linearSlop` with the - /// default length units, which is not used here as it needs the native - /// library: the points of a polygon closer than 4 times it are welded, and - /// the ones closer than twice it to an edge are dropped, see - /// `b2ComputeHull`. + /// Box2D's `linearSlop` in meters (the native `Tolerances` can't be used + /// here): `b2ComputeHull` welds points closer than 4 times it and drops + /// those closer than twice it to an edge. static const linearSlop = 0.005; - /// The convex pieces of the outline of the [sprite], drawn with the given - /// [size] in meters, relative to its center. + /// The convex pieces of the outline of the [sprite]. + late final List> pieces; + + @override + Future onLoad() async { + pieces = await tracePieces(); + // Creates the body from the pieces. + await super.onLoad(); + } + + /// Traces the convex pieces of the outline of the [sprite], relative to its + /// center. /// - /// The outline is traced in pixels rather than in meters, with [pixels] per - /// meter, so that the default sampling of the polygons of a - /// [PathComponent] follows it closely. The parts of the sprite that are - /// apart give separate polygons, and so separate pieces. - static Future>> piecesOf( - Sprite sprite, - Vector2 size, - double pixels, - ) async { + /// The outline is traced in pixels, so that the polygons of the path follow + /// it closely. + Future>> tracePieces() async { + final pixels = gameRef.metersToPixels; final pixelSize = size * pixels; final outline = await sprite.contour(size: pixelSize); // The outline is relative to the top left corner of the sprite, while the From 022854d0da4c24e26af57da70e68f4150bcc435a Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 13 Oct 2026 23:47:13 +0200 Subject: [PATCH 18/21] refactor: Share traced ContourBody pieces via an Image cache. --- .../flame_forge2d/sprite_body_example.dart | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index aaae753a095..5fc266ac48f 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -38,6 +38,9 @@ class SpriteBodyWorld({bool showPieces = false}) int _taps = 0; + /// The pieces traced by the bodies, shared by those of the same image. + final _pieces = >>>{}; + bool _showPieces = showPieces; /// Whether the convex pieces of all the bodies are drawn. @@ -71,17 +74,22 @@ class SpriteBodyWorld({bool showPieces = false}) info.localPosition, sprite: sprite, size: sprite.srcSize..scale(width / sprite.srcSize.x), + cache: _pieces, )..renderBody = showPieces, ); } } -/// A body drawn by a [sprite] of the given [size] in meters, colliding as the -/// convex [pieces] of its outline, drawn when [renderBody] is true. +/// A body drawn by a [sprite] (when [renderBody] is `true`) of the given [size] +/// in meters, colliding as the convex [pieces] of its outline. +/// +/// The pieces are traced only once for each image by the bodies sharing the +/// same [cache], which must all draw the full image whole at the same size. class ContourBody( final Vector2 initialPosition, { required final Sprite sprite, required final Vector2 size, + final Map>>>? cache, }) extends BodyComponent with GlowingBody { this : super( @@ -99,7 +107,8 @@ class ContourBody( @override Future onLoad() async { - pieces = await tracePieces(); + pieces = + await (cache?.putIfAbsent(sprite.image, tracePieces) ?? tracePieces()); // Creates the body from the pieces. await super.onLoad(); } From 69cbd5d9e616074c0765d93f5b6cad2c0fd82e87 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 14 Oct 2026 00:07:21 +0200 Subject: [PATCH 19/21] refactor: Move contourFromPixels to a Uint8List extension method. --- doc/flame/collision_detection.md | 2 +- packages/flame/benchmark/README.md | 2 +- .../benchmark/image_contour_benchmark.dart | 6 +-- packages/flame/lib/extensions.dart | 1 + packages/flame/lib/src/extensions/image.dart | 52 ++----------------- .../flame/lib/src/extensions/uint8_list.dart | 50 ++++++++++++++++++ packages/flame/lib/src/sprite.dart | 4 +- .../test/extensions/image_contour_test.dart | 5 +- 8 files changed, 63 insertions(+), 59 deletions(-) create mode 100644 packages/flame/lib/src/extensions/uint8_list.dart diff --git a/doc/flame/collision_detection.md b/doc/flame/collision_detection.md index 67cc871ee2d..6c6b595461e 100644 --- a/doc/flame/collision_detection.md +++ b/doc/flame/collision_detection.md @@ -335,7 +335,7 @@ costs accordingly. Keep the `filter` on unless the inner contours matter, and us When the outline of a sprite is not available as a `Path`, it can be traced from the sprite itself with `Sprite.contour`, which gives a closed contour for each of the separate parts of the sprite that are not transparent, leaving out the holes in them. The same is available for a region of an -`Image` with `ImageExtension.contour`, and for raw pixels with `ImageExtension.contourFromPixels`. +`Image` with `ImageExtension.contour`, and for raw pixels with `Uint8ListExtension.contour`. A pixel belongs to the outlined parts when its alpha is at least the `alphaThreshold`, which is `0.5` by default, and the contours pass between the pixels where the alpha reaches the threshold, diff --git a/packages/flame/benchmark/README.md b/packages/flame/benchmark/README.md index eeb8aabdf31..846627181eb 100644 --- a/packages/flame/benchmark/README.md +++ b/packages/flame/benchmark/README.md @@ -102,7 +102,7 @@ which takes minutes. To compare a change, run it on both branches. cost. - `image_contour_benchmark.dart`: a standalone suite, not part of `main.dart`, that measures tracing the outlines of images with - `ImageExtension.contourFromPixels`, including a transparent image for the + `Uint8ListExtension.contour`, including a transparent image for the scan of the pixels alone, splitting stars of growing size with `convexPieces`, and the whole way from the pixels to the convex pieces. - `ray_intersection_benchmark.dart`: `rayIntersection` on polygon hitboxes diff --git a/packages/flame/benchmark/image_contour_benchmark.dart b/packages/flame/benchmark/image_contour_benchmark.dart index e73358d21ce..7c66bb6ab51 100644 --- a/packages/flame/benchmark/image_contour_benchmark.dart +++ b/packages/flame/benchmark/image_contour_benchmark.dart @@ -7,7 +7,7 @@ import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; /// Measures tracing the outlines of images with -/// [ImageExtension.contourFromPixels] and splitting polygons into +/// [Uint8ListExtension.contour] and splitting polygons into /// [convexPieces], from the pixels to the convex pieces of a body. /// /// This is a standalone suite, not part of `main.dart`. Run it with @@ -53,7 +53,7 @@ class TraceBenchmark(final int size, {final bool transparent = false}) @override void run() { - ImageExtension.contourFromPixels(_pixels, size, size); + _pixels.contour(size, size); } } @@ -94,7 +94,7 @@ class PipelineBenchmark(final int size) extends _SingleRunBenchmark { @override void run() { - final outline = ImageExtension.contourFromPixels(_pixels, size, size); + final outline = _pixels.contour(size, size); for (final polygon in outline.toPolygons()) { convexPieces(polygon); } diff --git a/packages/flame/lib/extensions.dart b/packages/flame/lib/extensions.dart index 742382073d9..b8b8119635d 100644 --- a/packages/flame/lib/extensions.dart +++ b/packages/flame/lib/extensions.dart @@ -14,4 +14,5 @@ export 'src/extensions/random.dart'; export 'src/extensions/rect.dart'; export 'src/extensions/rectangle.dart'; export 'src/extensions/size.dart'; +export 'src/extensions/uint8_list.dart'; export 'src/extensions/vector2.dart'; diff --git a/packages/flame/lib/src/extensions/image.dart b/packages/flame/lib/src/extensions/image.dart index e82ee8e1bb5..956be39c9ac 100644 --- a/packages/flame/lib/src/extensions/image.dart +++ b/packages/flame/lib/src/extensions/image.dart @@ -4,7 +4,6 @@ import 'dart:ui'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; -import 'package:flame/src/geometry/alpha_contours.dart'; export 'dart:ui' show Image; @@ -103,15 +102,14 @@ extension ImageExtension on Image { /// when the [region] is only a part of it, so it is an expensive operation /// that should be done when loading, not in the game loop. To trace several /// regions of the same image, like the sprites of a sprite sheet, read the - /// pixels once with [pixelsInUint8] and pass them to [contourFromPixels] for - /// each region. + /// pixels once with [pixelsInUint8] and pass them to + /// [Uint8ListExtension.contour] for each region. Future contour({ Rect? region, Vector2? size, double alphaThreshold = 0.5, }) async { - return contourFromPixels( - await pixelsInUint8(), + return (await pixelsInUint8()).contour( width, height, region: region, @@ -120,50 +118,6 @@ extension ImageExtension on Image { ); } - /// Returns the outlines of the parts of a raw list of [pixels] that are not - /// transparent, as the closed contours of a [Path]. - /// - /// The [pixels] are in the RGBA format, as in [fromPixels]. See [contour] - /// for the [region], [size] and [alphaThreshold] parameters. - static Path contourFromPixels( - Uint8List pixels, - int width, - int height, { - Rect? region, - Vector2? size, - double alphaThreshold = 0.5, - }) { - assert(pixels.length == width * height * 4); - assert( - alphaThreshold > 0 && alphaThreshold <= 1, - 'The alpha threshold has to be in (0, 1]: $alphaThreshold', - ); - final left = region?.left.round() ?? 0; - final top = region?.top.round() ?? 0; - final right = region?.right.round() ?? width; - final bottom = region?.bottom.round() ?? height; - // A region that is clamped to the image would move and stretch the - // contours, relative to the region that was asked for. - assert( - left >= 0 && top >= 0 && right <= width && bottom <= height, - 'The region $region is not within the image of $width x $height', - ); - assert(right >= left && bottom >= top, 'The region $region is inverted'); - final columns = right - left; - final rows = bottom - top; - return traceAlphaContours( - pixels, - width: width, - left: left, - top: top, - columns: columns, - rows: rows, - threshold: alphaThreshold, - scaleX: size == null || columns == 0 ? 1 : size.x / columns, - scaleY: size == null || rows == 0 ? 1 : size.y / rows, - ); - } - /// Returns the bounding [Rect] of the image. Rect getBoundingRect() => Vector2.zero() & size; diff --git a/packages/flame/lib/src/extensions/uint8_list.dart b/packages/flame/lib/src/extensions/uint8_list.dart new file mode 100644 index 00000000000..fd3265f4e4e --- /dev/null +++ b/packages/flame/lib/src/extensions/uint8_list.dart @@ -0,0 +1,50 @@ +import 'dart:typed_data'; + +import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/alpha_contours.dart'; + +extension Uint8ListExtension on Uint8List { + /// Returns the outlines of the parts of these pixels that are not + /// transparent, as the closed contours of a [Path]. + /// + /// The pixels are in the RGBA format, [width] by [height], as in + /// [ImageExtension.fromPixels]. See [ImageExtension.contour] for the + /// [region], [size] and [alphaThreshold] parameters. + Path contour( + int width, + int height, { + Rect? region, + Vector2? size, + double alphaThreshold = 0.5, + }) { + assert(length == width * height * 4); + assert( + alphaThreshold > 0 && alphaThreshold <= 1, + 'The alpha threshold has to be in (0, 1]: $alphaThreshold', + ); + final left = region?.left.round() ?? 0; + final top = region?.top.round() ?? 0; + final right = region?.right.round() ?? width; + final bottom = region?.bottom.round() ?? height; + // A region that is clamped to the image would move and stretch the + // contours, relative to the region that was asked for. + assert( + left >= 0 && top >= 0 && right <= width && bottom <= height, + 'The region $region is not within the image of $width x $height', + ); + assert(right >= left && bottom >= top, 'The region $region is inverted'); + final columns = right - left; + final rows = bottom - top; + return traceAlphaContours( + this, + width: width, + left: left, + top: top, + columns: columns, + rows: rows, + threshold: alphaThreshold, + scaleX: size == null || columns == 0 ? 1 : size.x / columns, + scaleY: size == null || rows == 0 ? 1 : size.y / rows, + ); + } +} diff --git a/packages/flame/lib/src/sprite.dart b/packages/flame/lib/src/sprite.dart index 248fbd78c96..69eeb1ecf05 100644 --- a/packages/flame/lib/src/sprite.dart +++ b/packages/flame/lib/src/sprite.dart @@ -113,8 +113,8 @@ class Sprite( /// the whole [image], not only the ones of the sprite, so it should be done /// when loading, not in the game loop. To trace several sprites of the same /// sprite sheet, read its pixels once with [ImageExtension.pixelsInUint8] - /// and pass them to [ImageExtension.contourFromPixels] with the [src] of - /// each sprite. + /// and pass them to [Uint8ListExtension.contour] with the [src] of each + /// sprite. Future contour({Vector2? size, double alphaThreshold = 0.5}) { return image.contour( region: src, diff --git a/packages/flame/test/extensions/image_contour_test.dart b/packages/flame/test/extensions/image_contour_test.dart index b990c8045e8..9c3cbc6e3d3 100644 --- a/packages/flame/test/extensions/image_contour_test.dart +++ b/packages/flame/test/extensions/image_contour_test.dart @@ -30,8 +30,7 @@ Path _contour( Vector2? size, double alphaThreshold = 0.5, }) { - return ImageExtension.contourFromPixels( - _pixels(rows), + return _pixels(rows).contour( rows.first.length, rows.length, region: region, @@ -53,7 +52,7 @@ Future _image(List rows) { List> _vertices(Path path) => path.walkContours(1, 0.001); void main() { - group('ImageExtension.contourFromPixels', () { + group('Uint8ListExtension.contour', () { test('is empty for a transparent image', () { final path = _contour(['...', '...']); expect(path.contours, isEmpty); From c62a4640563a76e7e050499c33fe31722923cc43 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 14 Oct 2026 09:43:08 +0200 Subject: [PATCH 20/21] perf: Track ears and reflex vertices in convexPieces and merge pieces through shared edges --- .../flame/lib/src/geometry/convex_pieces.dart | 187 +++++++++++++----- 1 file changed, 135 insertions(+), 52 deletions(-) diff --git a/packages/flame/lib/src/geometry/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart index 02ec9c3be43..4af77c3b243 100644 --- a/packages/flame/lib/src/geometry/convex_pieces.dart +++ b/packages/flame/lib/src/geometry/convex_pieces.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'dart:typed_data'; import 'package:flame/extensions.dart'; import 'package:flame/src/geometry/signed_area.dart'; @@ -158,88 +159,170 @@ double _cross(Vector2 a, Vector2 b, Vector2 c) { /// Splits the polygon given by the [indices] of [vertices], which goes /// clockwise on the screen, into triangles, by clipping its ears. +/// +/// A vertex is an ear if its corner is not reflex and no other vertex lies +/// within the triangle that it makes with its neighbors. Collinear vertices +/// are ears too, as they only make a degenerate triangle. Only the reflex +/// and collinear vertices can be within an ear of a simple polygon, so only +/// those are checked, and clipping an ear only changes whether its two +/// neighbors are ears. List> _triangulate(List vertices, List indices) { - final remaining = List.of(indices); - final triangles = >[]; - while (remaining.length >= 3) { - final ear = _findEar(vertices, remaining); - if (ear == -1) { - // A polygon that crosses itself may have no ears left, while a simple - // one always has; what is left of it is left out. - break; + final m = indices.length; + final previous = Int32List.fromList([for (var k = 0; k < m; k++) k - 1]) + ..[0] = m - 1; + final next = Int32List.fromList([for (var k = 0; k < m; k++) k + 1]) + ..[m - 1] = 0; + Vector2 at(int k) => vertices[indices[k]]; + double turn(int k) => _cross(at(previous[k]), at(k), at(next[k])); + + final isReflex = List.filled(m, false); + final reflex = []; + for (var k = 0; k < m; k++) { + if (turn(k) <= 0) { + isReflex[k] = true; + reflex.add(k); } - final m = remaining.length; - final triangle = [ - remaining[(ear - 1 + m) % m], - remaining[ear], - remaining[(ear + 1) % m], - ]; - remaining.removeAt(ear); - final [a, b, c] = [for (final i in triangle) vertices[i]]; - // Clipping a collinear vertex leaves no triangle. - if (_cross(a, b, c) > 0) { - triangles.add(triangle); + } + var reflexCount = reflex.length; + void setConvex(int k) { + if (isReflex[k]) { + isReflex[k] = false; + reflexCount--; } } - return triangles; -} -/// The position in [remaining] of a vertex that can be clipped, or -1 if -/// there is none. -/// -/// A vertex can be clipped if its corner is not reflex and no other vertex -/// lies within the triangle that it makes with its neighbors. Collinear -/// vertices can be clipped too, as they only make a degenerate triangle. -int _findEar(List vertices, List remaining) { - final m = remaining.length; - for (var i = 0; i < m; i++) { - final a = vertices[remaining[(i - 1 + m) % m]]; - final b = vertices[remaining[i]]; - final c = vertices[remaining[(i + 1) % m]]; - if (_cross(a, b, c) < 0) { - continue; + bool isEar(int k) { + if (turn(k) < 0) { + return false; } - var isEar = true; - for (final j in remaining) { - final p = vertices[j]; + final a = at(previous[k]); + final b = at(k); + final c = at(next[k]); + for (final r in reflex) { + if (!isReflex[r]) { + continue; + } + final p = at(r); if (p == a || p == b || p == c) { continue; } if (_cross(a, b, p) >= 0 && _cross(b, c, p) >= 0 && _cross(c, a, p) >= 0) { - isEar = false; - break; + return false; + } + } + return true; + } + + final ears = List.generate(m, isEar); + final triangles = >[]; + var remaining = m; + var k = 0; + var stepsWithoutEar = 0; + var isRechecked = false; + while (remaining >= 3) { + if (!ears[k]) { + if (++stepsWithoutEar > remaining) { + // The vertices on the edges of an ear can keep it from being one + // until they are clipped, which only its neighbors are checked for. + // A polygon that crosses itself may have no ears left, while a + // simple one always has; what is left of it is left out. + if (isRechecked) { + break; + } + for (var i = 0, j = k; i < remaining; i++, j = next[j]) { + ears[j] = isEar(j); + } + isRechecked = true; + stepsWithoutEar = 0; } + k = next[k]; + continue; + } + final before = previous[k]; + final after = next[k]; + // Clipping a collinear vertex leaves no triangle. + if (turn(k) > 0) { + triangles.add([indices[before], indices[k], indices[after]]); + } + next[before] = after; + previous[after] = before; + setConvex(k); + remaining--; + // A neighbor can only turn from reflex to convex, never the other way. + if (turn(before) > 0) { + setConvex(before); + } + if (turn(after) > 0) { + setConvex(after); } - if (isEar) { - return i; + // The vertices that are no longer reflex are dropped from the list once + // they make up most of it. + if (reflex.length > 2 * reflexCount + 32) { + reflex.removeWhere((r) => !isReflex[r]); } + ears[before] = isEar(before); + ears[after] = isEar(after); + stepsWithoutEar = 0; + isRechecked = false; + k = after; } - return -1; + return triangles; } /// Merges the [pieces] that share an edge, as long as the result is convex /// and has at most [maxVertices] vertices. +/// +/// The neighbors of a piece are found through the edges that it shares with +/// them, which go the other way around in the neighbor. void _merge(List vertices, List> pieces, int maxVertices) { - var hasMerged = true; - while (hasMerged) { - hasMerged = false; - for (var i = 0; i < pieces.length; i++) { - for (var j = i + 1; j < pieces.length; j++) { - final merged = _union(pieces[i], pieces[j]); + final n = vertices.length; + final owners = {}; + void own(int piece) { + final indices = pieces[piece]; + for (var k = 0; k < indices.length; k++) { + owners[indices[k] * n + indices[(k + 1) % indices.length]] = piece; + } + } + + for (var i = 0; i < pieces.length; i++) { + own(i); + } + final isMerged = List.filled(pieces.length, false); + for (var i = 0; i < pieces.length; i++) { + if (isMerged[i]) { + continue; + } + var hasMerged = true; + while (hasMerged) { + hasMerged = false; + final piece = pieces[i]; + for (var k = 0; k < piece.length; k++) { + final j = owners[piece[(k + 1) % piece.length] * n + piece[k]]; + if (j == null || j == i || isMerged[j]) { + continue; + } + final merged = _union(piece, pieces[j]); if (merged != null && merged.length <= maxVertices && _isConvex(vertices, merged)) { pieces[i] = merged; - pieces.removeAt(j); + isMerged[j] = true; + own(i); hasMerged = true; - // The grown piece may now be merged with the ones already checked. - j = i; + break; } } } } + var kept = 0; + for (var i = 0; i < pieces.length; i++) { + if (!isMerged[i]) { + pieces[kept++] = pieces[i]; + } + } + pieces.length = kept; } /// The union of the pieces [a] and [b], which go clockwise on the screen, if From 9f7848efa3debccb52369e1a8530d615fe7b57f0 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 14 Oct 2026 09:46:25 +0200 Subject: [PATCH 21/21] refactor: Key the contour pieces cache by sprite region and size and drop the world cast in the sprite body story --- .../flame_forge2d/flame_forge2d.dart | 5 ++-- .../flame_forge2d/sprite_body_example.dart | 26 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart index 7c6cfc50668..b2d9f9a5f17 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart @@ -167,13 +167,14 @@ class const _SpriteBodyStory({required final bool showPieces}) } class _SpriteBodyStoryState() extends State<_SpriteBodyStory> { - late final _game = SpriteBodyExample(showPieces: widget.showPieces); + late final _world = SpriteBodyWorld(showPieces: widget.showPieces); + late final _game = SpriteBodyExample(world: _world); @override void didUpdateWidget(_SpriteBodyStory oldWidget) { super.didUpdateWidget(oldWidget); if (widget.showPieces != oldWidget.showPieces) { - (_game.world as SpriteBodyWorld).showPieces = widget.showPieces; + _world.showPieces = widget.showPieces; } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 5fc266ac48f..46761263220 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -9,9 +9,7 @@ import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class SpriteBodyExample({bool showPieces = false}) - extends Forge2DExampleGame - with HasGameRef { +class SpriteBodyExample({SpriteBodyWorld? world}) extends Forge2DExampleGame { static const String description = ''' In this example we show how to add a sprite on top of a BodyComponent whose shape follows the outline of the sprite. @@ -26,10 +24,15 @@ class SpriteBodyExample({bool showPieces = false}) this : super( gravity: Vector2(0, 10.0), - world: SpriteBodyWorld(showPieces: showPieces), + world: world ?? SpriteBodyWorld(), ); } +/// The convex pieces traced for a region of an image at a size, which the +/// bodies drawing that region at that size share. +typedef ContourPiecesCache = + Map<(Image, Rect, Vector2), Future>>>; + class SpriteBodyWorld({bool showPieces = false}) extends Forge2DWorld with TapCallbacks, HasGameRef { @@ -38,8 +41,9 @@ class SpriteBodyWorld({bool showPieces = false}) int _taps = 0; - /// The pieces traced by the bodies, shared by those of the same image. - final _pieces = >>>{}; + /// The pieces traced by the bodies, shared by those of the same sprite and + /// size. + final ContourPiecesCache _pieces = {}; bool _showPieces = showPieces; @@ -83,13 +87,13 @@ class SpriteBodyWorld({bool showPieces = false}) /// A body drawn by a [sprite] (when [renderBody] is `true`) of the given [size] /// in meters, colliding as the convex [pieces] of its outline. /// -/// The pieces are traced only once for each image by the bodies sharing the -/// same [cache], which must all draw the full image whole at the same size. +/// The pieces are traced only once for each region of an image and size by +/// the bodies sharing the same [cache]. class ContourBody( final Vector2 initialPosition, { required final Sprite sprite, required final Vector2 size, - final Map>>>? cache, + final ContourPiecesCache? cache, }) extends BodyComponent with GlowingBody { this : super( @@ -107,8 +111,8 @@ class ContourBody( @override Future onLoad() async { - pieces = - await (cache?.putIfAbsent(sprite.image, tracePieces) ?? tracePieces()); + final key = (sprite.image, sprite.src, size.clone()); + pieces = await (cache?.putIfAbsent(key, tracePieces) ?? tracePieces()); // Creates the body from the pieces. await super.onLoad(); }