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
9175198
feat: Add WarpGrid to describe sprite warping
adario Oct 5, 2026
464aeaf
feat: Add mesh builder for WarpGrid
adario Oct 5, 2026
52ff0f1
feat: Support warp grids in SpriteComponent
adario Oct 5, 2026
2e6504d
test: Add golden and eviction tests for warped SpriteComponent
adario Oct 6, 2026
3aa01e0
Merge branch 'main' into feat/sprite-warp-grid
adario Oct 6, 2026
81eb110
fix: Round texel ties consistently in warped sprites
adario Oct 6, 2026
ec3ba1d
docs: Add docs and example for warping SpriteComponent
adario Oct 6, 2026
d01b523
feat: Show the warp grid and add a Grid Size knob to the Sprite War…
adario Oct 6, 2026
9c0d89b
refactor: Move sprite warping into the HasWarpGrid mixin
adario Oct 6, 2026
3ff9bfc
feat: Add an Image knob to the Sprite Warp example
adario Oct 6, 2026
4b09660
Merge branch 'main' into feat/sprite-warp-grid
adario Oct 6, 2026
4dab3de
feat: Add a Reset button knob to the Sprite Warp example
adario Oct 7, 2026
20bc525
docs: Add documentation for WarpEffect.
adario Oct 7, 2026
31111e0
docs: Describe the Sprite Warp example knobs
adario Oct 7, 2026
56a0f19
fix: cspell verification failing due to dictionary errors
adario Oct 7, 2026
f00bf2c
docs: Fully describe the Sprite Warp example
adario Oct 7, 2026
2982ef2
fix: Keep the grid when WarpEffect.to has nothing to change
adario Oct 7, 2026
eb271d9
fix: Throw a StateError when a warped grid is removed
adario Oct 7, 2026
77b0cf6
fix: Throw an ArgumentError for warp grids with too many cells
adario Oct 7, 2026
056df71
fix: Throw an ArgumentError for a wrong number of warp grid positions
adario Oct 7, 2026
5909772
fix: Throw an ArgumentError for warp grids without cells
adario Oct 7, 2026
c9bcaa3
Merge branch 'main' into feat/sprite-warp-grid
adario Oct 8, 2026
4c99f07
Merge branch 'main' into feat/sprite-warp-grid
adario Oct 11, 2026
24fabbb
Merge branch 'main' into feat/sprite-warp-grid
adario Oct 11, 2026
e1ac971
feat: Extend SpriteWarpExample with editable start and target grids.
adario Oct 12, 2026
0999710
fix: Satisfy cspell in a variable name.
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
1 change: 1 addition & 0 deletions .github/.cspell/people_usernames.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
akida # github.com/akida
bdero # github.com/bdero
bluefireteam # github.com/bluefireteam
Catmull # Catmull/Rom
erayzesen # erayzesen.itch.io
erickzanardo # github.com/erickzanardo
feroult # github.com/feroult
Expand Down
2 changes: 2 additions & 0 deletions .github/.cspell/words_dictionary.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# actual english words (or common abbreviations) missing from CSpell
bilinearly
bloodlust
collidable
collidables
Expand Down Expand Up @@ -30,3 +31,4 @@ thumbstick
trackpad
underutilize
untinted
unwarped
80 changes: 80 additions & 0 deletions doc/flame/components/sprite_components.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,86 @@ class MyGame extends FlameGame {
```


### Warping

A `SpriteComponent` with the `HasWarpGrid` mixin can distort (warp) its sprite with a `WarpGrid`,
similarly to SpriteKit's `SKWarpGeometryGrid`. A grid of `columns` x `rows` cells has
`(columns + 1) * (rows + 1)` vertices, and each vertex has a source position and a destination
position: the part of the sprite found at the source position is drawn at the destination
position, and everything in between is interpolated.

```dart
class WarpedSprite extends SpriteComponent with HasWarpGrid {
WarpedSprite({super.sprite, super.size});
}

final grid = WarpGrid.identity(columns: 2, rows: 2);
final positions = grid.destinationPositions;
// Pull the center vertex towards the top-right corner.
positions[grid.vertexIndex(1, 1)] = Vector2(0.7, 0.3);

final component = WarpedSprite(sprite: sprite, size: Vector2.all(128))
..warpGrid = grid.replacingDestinationPositions(positions);
```

The mixin works with any subclass of `SpriteComponent`. Subclasses that change how the sprite is
drawn, like `HasWarpGrid` does, can override `renderSprite`.

Positions are normalized, with the y axis pointing down: source positions are relative to the
sprite's source rectangle, and destination positions are relative to the component's `size`.
Destination positions outside of the `0..1` range are valid, and are drawn outside of the
component's bounds. Vertices are stored in row-major order starting from the top-left corner,
so for a 2x2 grid the indices are:

```text
[0]---[1]---[2]
| | |
[3]---[4]---[5]
| | |
[6]---[7]---[8]
```

Note that SpriteKit uses the opposite conventions: its vertices start from the bottom-left corner
and its y axis points up.

`WarpGrid` is immutable: to change the warp, assign a new grid to `warpGrid`, for example one
created with `replacingDestinationPositions`. Setting `warpGrid` to `null` renders the sprite
undistorted again. To animate the warp, use a [`WarpEffect`](../effects/warp_effects.md).

The grid is interpolated according to `warpInterpolation`:

- `WarpInterpolation.bilinear` (the default) interpolates each cell independently, like SpriteKit
does. Moving a vertex only affects the cells around it, and the edges between cells stay
straight.
- `WarpInterpolation.catmullRom` interpolates the grid with Catmull-Rom splines, so that the
sprite bends smoothly across cells while still passing through every vertex. Moving a vertex
also affects the cells up to two cells away, and strong distortions can slightly overshoot.

A few more things to keep in mind:

- Warping only changes how the sprite is drawn: the component's `size`, hit testing and
collisions are not affected.
- The component's paint keeps working, so effects like `OpacityEffect` and `ColorEffect`, as well
as `tint` and `hue`, apply to warped sprites too. The paint's `shader` is ignored.
- Each cell is drawn as 4x4 sub-cells of two triangles each. The resulting mesh is rebuilt only
when a new grid is assigned or `warpInterpolation` changes, so creating a new grid every frame,
for example to animate the warp, has a cost.
- An image filter in the paint, like a blur, makes the edges of a warped sprite fade out, while
an undistorted sprite repeats its border pixels instead.

The [Sprite Warp example](https://examples.flame-engine.org/#/?path=sprites/sprite-warp) shows
two sprites with editable grids: the start grid on the left and the target grid of a
[`WarpEffect.to`](../effects/warp_effects.md#warpeffectto) on the right. Drag a handle to move
its vertex, or double tap it to reset the vertex. Knobs:

- `Grid Size`: the number of cells per side (3 to 8); changing it resets both grids.
- `Image`, `Interpolation`: the sprite and the `WarpInterpolation` of both sprites.
- `Default Target`: a predefined target grid, such as waves, crushes, or an implosion.
- `Animate`: warps each sprite to the other's grid and back, with two infinite `WarpEffect.to`.
- `Reset Start`, `Reset Target`: reset the start grid to the identity, and the target grid to the
`Default Target`.


## SpriteAnimationComponent

This class is used to represent a Component that has sprites that run in a single cyclic animation.
Expand Down
3 changes: 3 additions & 0 deletions doc/flame/effects/effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ There are multiple pre-built effects provided by Flame, and you can also
- [`OpacityToEffect`](color_effects.md#opacitytoeffect)
- [`OpacityByEffect`](color_effects.md#opacitybyeffect)
- [`ColorEffect`](color_effects.md#coloreffect)
- [`WarpEffect.by`](warp_effects.md#warpeffectby)
- [`WarpEffect.to`](warp_effects.md#warpeffectto)
- [`SequenceEffect`](sequence_effect.md)
- [`CombinedEffect`](combined_effect.md)
- [`RemoveEffect`](remove_effect.md)
Expand Down Expand Up @@ -133,6 +135,7 @@ Scale Effects <scale_effects.md>
Size Effects <size_effects.md>
Anchor Effects <anchor_effects.md>
Color Effects <color_effects.md>
Warp Effects <warp_effects.md>
Sequence Effect <sequence_effect.md>
Combined Effect <combined_effect.md>
Remove Effect <remove_effect.md>
Expand Down
57 changes: 57 additions & 0 deletions doc/flame/effects/warp_effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Warp Effects

Warp effects animate the [`WarpGrid`](../components/sprite_components.md#warping) of a component
over time, similarly to SpriteKit's `SKAction.warp(to:duration:)`. They can be applied to any
component that implements the `WarpGridProvider` interface, such as a `SpriteComponent` with the
`HasWarpGrid` mixin.

Like the other built-in effects, warp effects change the grid incrementally, so several of them
can run on the same component at the same time, and they can be combined with any other code that
moves the grid's vertices by relative amounts.


## `WarpEffect.by`

This effect moves each destination position of the target's grid by the corresponding offset,
relative to its current position. Offsets are normalized like the grid positions, and there must
be one per vertex. For example, the following effect pulls the bottom-right corner of a 1x1 grid
down and to the right by half the size of the component:

```dart
final effect = WarpEffect.by(
[Vector2.zero(), Vector2.zero(), Vector2.zero(), Vector2(0.5, 0.5)],
EffectController(duration: 1),
);
```

Source positions can be moved as well with the optional `sourceOffsets` parameter. The target must
already have a grid when the effect starts.


## `WarpEffect.to`

Changes both the source and destination positions of the target's grid to those of the specified
grid, which must have the same number of columns and rows. If the target has no grid when the
effect starts, it starts from an undistorted grid:

```dart
final grid = WarpGrid.identity(columns: 2, rows: 2);
final positions = grid.destinationPositions;
positions[grid.vertexIndex(1, 1)] = Vector2(0.7, 0.3);

final effect = WarpEffect.to(
grid.replacingDestinationPositions(positions),
EffectController(duration: 1, reverseDuration: 1, infinite: true),
);
```

To animate through several grids, like SpriteKit's `SKAction.animate(withWarps:times:)`, use a
`SequenceEffect` of `WarpEffect.to` effects.

Note that a new grid is assigned at every tick while the effect runs, so the sprite's mesh is
rebuilt every frame.

The `Animate` knob of the
[Sprite Warp example](https://examples.flame-engine.org/#/?path=sprites/sprite-warp) warps two
sprites between their grids with two infinite `WarpEffect.to`. See
[Warping](../components/sprite_components.md#warping) for details.
57 changes: 57 additions & 0 deletions examples/lib/commons/button_knob.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';

extension ButtonKnobBuilder on KnobsBuilder {
/// Adds a button to the knobs panel, and returns how many times it has been
/// pressed, so that a use case can react to presses by comparing the count
/// with the previous one.
int button({required String label, bool enabled = true}) {
return onKnobAdded(ButtonKnob(label: label, enabled: enabled)) ?? 0;
}
}

/// A knob that shows a button instead of a value, since Widgetbook doesn't
/// provide one. Its value is the number of times the button was pressed.
class ButtonKnob({
required super.label,
final bool enabled = true,
}) extends Knob<int?> {
this : super(initialValue: 0);

@override
List<Field> get fields => [_ButtonField(name: label, enabled: enabled)];

@override
int? valueFromQueryGroup(Map<String, String> group) {
return valueOf(label, group);
}
}

class _ButtonField({
required super.name,
required final bool enabled,
}) extends Field<int> {
this
: super(
type: FieldType.intInput,
initialValue: 0,
defaultValue: 0,
codec: FieldCodec(
toParam: (value) => value.toString(),
toValue: (param) => param == null ? null : int.tryParse(param),
),
);

@override
Widget toWidget(BuildContext context, String group, int? value) {
return Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: enabled
? () => updateField(context, group, (value ?? 0) + 1)
: null,
child: Text(name),
),
);
}
}
79 changes: 79 additions & 0 deletions examples/lib/commons/dropdown_knob.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';

extension DropdownKnobBuilder on KnobsBuilder {
/// Adds a dropdown to the knobs panel like `object.dropdown`, which can
/// also be disabled.
T dropdown<T>({
required String label,
required List<T> options,
required LabelBuilder<T> labelBuilder,
T? initialOption,
bool enabled = true,
}) {
final initialValue = initialOption ?? options.first;
return onKnobAdded(
DropdownKnob<T>(
label: label,
initialValue: initialValue,
options: options,
labelBuilder: labelBuilder,
enabled: enabled,
),
) ??
initialValue;
}
}

/// A dropdown knob that can be disabled, since Widgetbook's one can't.
class DropdownKnob<T>({
required super.label,
required T super.initialValue,
required final List<T> options,
required final LabelBuilder<T> labelBuilder,
final bool enabled = true,
}) extends Knob<T?> {
@override
List<Field> get fields => [
_DropdownField<T>(
name: label,
values: options,
initialValue: initialValue,
labelBuilder: labelBuilder,
enabled: enabled,
),
];

@override
T? valueFromQueryGroup(Map<String, String> group) {
return valueOf(label, group);
}
}

class _DropdownField<T>({
required super.name,
required super.values,
required super.initialValue,
required super.labelBuilder,
required final bool enabled,
}) extends ObjectDropdownField<T> {
@override
Widget toWidget(BuildContext context, String group, T? value) {
return DropdownMenu<T>(
enabled: enabled,
expandedInsets: EdgeInsets.zero,
trailingIcon: const Icon(Icons.keyboard_arrow_down_rounded),
selectedTrailingIcon: const Icon(Icons.keyboard_arrow_up_rounded),
initialSelection: value,
onSelected: (value) {
if (value != null) {
updateField(context, group, value);
}
},
dropdownMenuEntries: [
for (final value in values)
DropdownMenuEntry(value: value, label: labelBuilder(value)),
],
);
}
}
Loading
Loading