Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c793c73
feat: Add Sprite.contour and ImageExtension.contour to trace image ou…
adario Oct 4, 2026
62df8aa
chore: Add Hertel/Mehlhorn in the cspell usernames.
adario Oct 5, 2026
7f70d9d
Merge branch 'main' into feat/image-contours
adario Oct 5, 2026
acff355
Merge branch 'main' into feat/image-contours
adario Oct 5, 2026
28c44ba
feat: Move convexPieces from the examples to flame/geometry
adario Oct 5, 2026
30c0c3f
fix: Keep the contour corners where the alpha matches the threshold
adario Oct 5, 2026
bc7afdd
fix: Split the polygons that touch themselves in convexPieces
adario Oct 5, 2026
4502c5b
perf: Trace image contours from an inside mask and the outline edges
adario Oct 5, 2026
43cc1a4
feat: Add CollidableSpritesExample with hitboxes traced from sprites
adario Oct 5, 2026
2997359
fix: Copy the vertices of the convex pieces and keep the contours wit…
spydon Oct 5, 2026
07d4065
Merge branch 'main' into feat/image-contours
adario Oct 6, 2026
46da114
fix: Keep the pieces of convexPieces strictly convex
adario Oct 6, 2026
9c7e52b
fix: Find collinear contour points regardless of the contour size
adario Oct 6, 2026
a2e81e4
Merge branch 'main' into feat/image-contours
adario Oct 6, 2026
dcebd80
feat: Add FPS display in SpriteBodyExample and CollidableSpritesExample.
adario Oct 6, 2026
fe9e17d
fix: Draw the pieces of SpriteBodyExample on top of the sprite
adario Oct 6, 2026
29b8e75
feat: Alternate flames and pizzas in SpriteBodyExample
adario Oct 6, 2026
b6c9744
Merge branch 'main' into feat/image-contours
adario Oct 6, 2026
74d938f
Merge branch 'main' into feat/image-contours
adario Oct 6, 2026
14cc338
refactor: Use proper named position parameter in CollidableSpriteComp…
adario Oct 6, 2026
82d3895
feat: Add initial spin and zap sprite in SpriteBodyExample.
adario Oct 6, 2026
dfca0eb
Merge branch 'main' into feat/image-contours
adario Oct 8, 2026
dc783bb
refactor: Hoist polygonsOf out of PathComponent into Path.toPolygons.
adario Oct 10, 2026
7e3fa6e
Merge branch 'main' into feat/image-contours
adario Oct 11, 2026
794f1de
Merge branch 'main' into feat/image-contours
adario Oct 11, 2026
c3546f8
Merge branch 'main' into feat/image-contours
adario Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/.cspell/people_usernames.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +18,7 @@ Lousberg # kaylousberg.itch.io
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
Expand Down
40 changes: 40 additions & 0 deletions doc/flame/collision_detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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),
Expand Down
57 changes: 55 additions & 2 deletions doc/flame/components/shape_components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -164,6 +166,57 @@ 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 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
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:

- `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 cost grows about with the square of the number of vertices, so split the polygons when loading
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`:

```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 pieces = [
for (final polygon in path.toPolygons())
...convexPieces(polygon),
];
}
```


## RectangleComponent

A `RectangleComponent` is created very similarly to how a `PositionComponent` is created, since it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,183 @@
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';
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() extends Forge2DExampleGame {
class SpriteBodyExample({bool showPieces = false})
extends Forge2DExampleGame
with HasGameRef<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 bodies, which alternate between flames and
pizzas.
''';

this
: super(
gravity: Vector2(0, 10.0),
world: SpriteBodyWorld(),
world: SpriteBodyWorld(showPieces: showPieces),
);
}

class SpriteBodyWorld()
/// 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<List<Vector2>> pieces});

class SpriteBodyWorld({bool showPieces = false})
extends Forge2DWorld
with TapCallbacks, HasGameRef<Forge2DGame> {
/// 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, '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.
bool get showPieces => _showPieces;
set showPieces(bool value) {
_showPieces = value;
for (final body in children.whereType<ContourBody>()) {
body.renderBody = value;
}
}

@override
Future<void> onLoad() async {
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);
}

@override
void onTapDown(TapDownEvent info) {
super.onTapDown(info);
final position = info.localPosition;
add(Pizza(position, size: Vector2(10, 15)));
final shape = _shapes[_taps++ % _shapes.length];
add(
ContourBody(
info.localPosition,
sprite: shape.sprite,
pieces: shape.pieces,
size: shape.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, which are drawn on top of
/// the [sprite] when [renderBody] is true.
class ContourBody(
final Vector2 initialPosition, {
required final Sprite sprite,
required final List<List<Vector2>> pieces,
required final Vector2 size,
}) 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
/// 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 polygons of a
/// [PathComponent] follows it closely. The parts of the sprite that are
/// apart give separate polygons, and so separate pieces.
static Future<List<List<Vector2>>> piecesOf(
Sprite sprite,
Vector2 size,
double pixels,
) async {
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
// pieces are relative to its center.
final center = pixelSize / 2;
return [
for (final polygon in outline.toPolygons())
...convexPieces(
[for (final vertex in polygon) (vertex - center) / pixels],
minDistance: 4 * linearSlop,
minWidth: 2 * linearSlop,
),
];
}

@override
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
Body createBody() {
final shapeDef = ShapeDef(
userData: this, // To be able to determine object in collision
material: SurfaceMaterial(restitution: 0.4, friction: 0.5),
);

final angle = (initialPosition.x + initialPosition.y) / 2 * pi;
final bodyDef = BodyDef(
position: initialPosition,
rotation: Rot.fromAngle(angle),
angularVelocity: angle / 8,
type: BodyType.dynamic,
);
final body = world.createBody(bodyDef);
for (final piece in pieces) {
body.createShape(Polygon(piece), shapeDef);
}
return body;
}
}

Expand Down
Loading
Loading