diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index bdf170b2099..436a45af319 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -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 diff --git a/.github/.cspell/words_dictionary.txt b/.github/.cspell/words_dictionary.txt index 40745a7e6af..97c485b5000 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -1,4 +1,5 @@ # actual english words (or common abbreviations) missing from CSpell +bilinearly bloodlust collidable collidables @@ -30,3 +31,4 @@ thumbstick trackpad underutilize untinted +unwarped diff --git a/doc/flame/components/sprite_components.md b/doc/flame/components/sprite_components.md index d32a71c01d4..685b31cadf2 100644 --- a/doc/flame/components/sprite_components.md +++ b/doc/flame/components/sprite_components.md @@ -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. diff --git a/doc/flame/effects/effects.md b/doc/flame/effects/effects.md index 459792b9a8d..d288ca6c780 100644 --- a/doc/flame/effects/effects.md +++ b/doc/flame/effects/effects.md @@ -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) @@ -133,6 +135,7 @@ Scale Effects Size Effects Anchor Effects Color Effects +Warp Effects Sequence Effect Combined Effect Remove Effect diff --git a/doc/flame/effects/warp_effects.md b/doc/flame/effects/warp_effects.md new file mode 100644 index 00000000000..0ad053a234b --- /dev/null +++ b/doc/flame/effects/warp_effects.md @@ -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. diff --git a/examples/lib/commons/button_knob.dart b/examples/lib/commons/button_knob.dart new file mode 100644 index 00000000000..d7fec1bc1b3 --- /dev/null +++ b/examples/lib/commons/button_knob.dart @@ -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 { + this : super(initialValue: 0); + + @override + List get fields => [_ButtonField(name: label, enabled: enabled)]; + + @override + int? valueFromQueryGroup(Map group) { + return valueOf(label, group); + } +} + +class _ButtonField({ + required super.name, + required final bool enabled, +}) extends Field { + 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), + ), + ); + } +} diff --git a/examples/lib/commons/dropdown_knob.dart b/examples/lib/commons/dropdown_knob.dart new file mode 100644 index 00000000000..fa45e0e776a --- /dev/null +++ b/examples/lib/commons/dropdown_knob.dart @@ -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({ + required String label, + required List options, + required LabelBuilder labelBuilder, + T? initialOption, + bool enabled = true, + }) { + final initialValue = initialOption ?? options.first; + return onKnobAdded( + DropdownKnob( + 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({ + required super.label, + required T super.initialValue, + required final List options, + required final LabelBuilder labelBuilder, + final bool enabled = true, +}) extends Knob { + @override + List get fields => [ + _DropdownField( + name: label, + values: options, + initialValue: initialValue, + labelBuilder: labelBuilder, + enabled: enabled, + ), + ]; + + @override + T? valueFromQueryGroup(Map group) { + return valueOf(label, group); + } +} + +class _DropdownField({ + required super.name, + required super.values, + required super.initialValue, + required super.labelBuilder, + required final bool enabled, +}) extends ObjectDropdownField { + @override + Widget toWidget(BuildContext context, String group, T? value) { + return DropdownMenu( + 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)), + ], + ); + } +} diff --git a/examples/lib/stories/sprites/sprite_warp_example.dart b/examples/lib/stories/sprites/sprite_warp_example.dart new file mode 100644 index 00000000000..33f63d2185e --- /dev/null +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -0,0 +1,504 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:material_ui/material_ui.dart'; + +/// The images that the [SpriteWarpExample] can warp. +enum SpriteWarpImage(final String path) { + flame('flame.png'), + zap('zap.png'), + pizza('pizza.png'), + player('layers/player.png'), + enemy('layers/enemy.png'), + background('layers/background.png'), + mountain('parallax/mountain-far.png'), +} + +/// Predefined target grids of the [SpriteWarpExample]. +enum DefaultWarpTarget(final String label) { + none('None'), + wave('Wave'), + waveHorizontal('Wave Horizontal'), + waveVertical('Wave Vertical'), + crushNorth('Crush N'), + crushNorthEast('Crush NE'), + crushEast('Crush E'), + crushSouthEast('Crush SE'), + crushSouth('Crush S'), + crushSouthWest('Crush SW'), + crushWest('Crush W'), + crushNorthWest('Crush NW'), + deflate('Deflate'), + inflate('Inflate'), + implode('Implode'), + explode('Explode'); + + /// Returns this target for a grid of [gridSize] x [gridSize] cells. + WarpGrid grid(int gridSize) { + final identity = WarpGrid.identity(columns: gridSize, rows: gridSize); + if (this == none) { + return identity; + } + // Seeded, so that crushes always look the same. + final random = Random(index); + return identity.replacingDestinationPositions([ + for (final position in identity.destinationPositions) + position + _offset(position.x, position.y, random), + ]); + } + + /// The offset of the vertex at ([u], [v]). + Vector2 _offset(double u, double v, Random random) { + return switch (this) { + none => Vector2.zero(), + wave => Vector2(0.1 * sin(2 * pi * v), 0.1 * sin(2 * pi * u)), + waveHorizontal => Vector2(0.1 * sin(2 * pi * v), 0), + waveVertical => Vector2(0, 0.1 * sin(2 * pi * u)), + crushNorth => _crushSide(u, v, Vector2(0.5, 0), random), + crushNorthEast => _crush(u, v, Vector2(1, 0), random), + crushEast => _crushSide(u, v, Vector2(1, 0.5), random), + crushSouthEast => _crush(u, v, Vector2(1, 1), random), + crushSouth => _crushSide(u, v, Vector2(0.5, 1), random), + crushSouthWest => _crush(u, v, Vector2(0, 1), random), + crushWest => _crushSide(u, v, Vector2(0, 0.5), random), + crushNorthWest => _crush(u, v, Vector2(0, 0), random), + deflate => _towardsCenter(u, v, 0.35), + inflate => _towardsCenter(u, v, -0.25), + implode => _toCircle(u, v, 0.3), + explode => _toCircle(u, v, 0.75), + }; + } + + /// Dents [corner] inwards, crumpled by some jitter. + static Vector2 _crush(double u, double v, Vector2 corner, Random random) { + final distance = Vector2(u, v).distanceTo(corner) / sqrt2; + final falloff = pow(max(0, 1 - distance / 0.55), 2).toDouble(); + return _dent(corner, 0.45, 0.15, falloff, random); + } + + /// Dents the [middle] of a side inwards, rounder than [_crush]. + static Vector2 _crushSide(double u, double v, Vector2 middle, Random random) { + final distance = Vector2(u, v).distanceTo(middle) / sqrt2; + final falloff = pow(max(0, 1 - pow(distance / 0.55, 2)), 2).toDouble(); + return _dent(middle, 0.3, 0.06, falloff, random); + } + + /// Moves a vertex inwards from [origin] by [depth] plus random [jitter], + /// scaled by [falloff]. + static Vector2 _dent( + Vector2 origin, + double depth, + double jitter, + double falloff, + Random random, + ) { + final noise = Vector2(random.nextDouble(), random.nextDouble()) + ..sub(Vector2.all(0.5)); + final inwards = (Vector2.all(0.5) - origin)..normalize(); + return (inwards * depth + noise * jitter) * falloff; + } + + /// Moves the vertices towards the center (away if [amount] is negative), + /// the corners the most. + static Vector2 _towardsCenter(double u, double v, double amount) { + final cornerOrientation = sqrt(4 * (u - 0.5).abs() * (v - 0.5).abs()); + return Vector2(0.5 - u, 0.5 - v) * amount * (0.4 + 0.6 * cornerOrientation); + } + + /// Moves the vertices radially, turning the grid's concentric squares into + /// circles, the outer one of [radius]. + static Vector2 _toCircle(double u, double v, double radius) { + final outwards = Vector2(u - 0.5, v - 0.5); + if (outwards.isZero()) { + return Vector2.zero(); + } + final square = max(outwards.x.abs(), outwards.y.abs()) * 2; + return outwards.normalized() * square * radius - outwards; + } +} + +/// Hosts a [SpriteWarpExample], recreated only when [gridSize] changes. A +/// change of [startResets] or [targetResets] resets the matching grid. +class const SpriteWarpStory({ + required final int gridSize, + required final SpriteWarpImage image, + required final WarpInterpolation interpolation, + required final DefaultWarpTarget defaultTarget, + final bool animate = false, + final int startResets = 0, + final int targetResets = 0, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _SpriteWarpGame( + gridSize: gridSize, + image: image, + interpolation: interpolation, + defaultTarget: defaultTarget, + animate: animate, + startResets: startResets, + targetResets: targetResets, + key: ValueKey(gridSize), + ); + } +} + +class const _SpriteWarpGame({ + required final int gridSize, + required final SpriteWarpImage image, + required final WarpInterpolation interpolation, + required final DefaultWarpTarget defaultTarget, + required final bool animate, + required final int startResets, + required final int targetResets, + super.key, +}) extends StatefulWidget { + @override + State<_SpriteWarpGame> createState() => _SpriteWarpGameState(); +} + +class _SpriteWarpGameState() extends State<_SpriteWarpGame> { + late final SpriteWarpExample _game = SpriteWarpExample( + gridSize: widget.gridSize, + image: widget.image, + interpolation: widget.interpolation, + defaultTarget: widget.defaultTarget, + animate: widget.animate, + ); + + @override + void didUpdateWidget(_SpriteWarpGame oldWidget) { + super.didUpdateWidget(oldWidget); + _game + ..setImage(widget.image) + ..interpolation = widget.interpolation + ..defaultTarget = widget.defaultTarget + ..animate = widget.animate; + if (widget.startResets != oldWidget.startResets) { + _game.resetStart(); + } + if (widget.targetResets != oldWidget.targetResets) { + _game.resetTarget(); + } + } + + @override + Widget build(BuildContext context) => GameWidget(game: _game); +} + +class SpriteWarpExample({ + final int gridSize = 4, + SpriteWarpImage image = SpriteWarpImage.flame, + WarpInterpolation interpolation = WarpInterpolation.bilinear, + DefaultWarpTarget defaultTarget = DefaultWarpTarget.none, + bool animate = false, +}) extends FlameGame { + static const String description = ''' + Two sprites with the `HasWarpGrid` mixin show the start grid (left) and + the target grid (right) of a `WarpEffect.to`. + + Drag a handle to move its vertex, or double tap it to reset the vertex. + Use `Default Target` to pick a predefined target grid, and the reset + buttons to reset a grid. + + Turn on `Animate` to warp each sprite to the other's grid and back. + '''; + + SpriteWarpImage _image = image; + WarpInterpolation _interpolation = interpolation; + DefaultWarpTarget _defaultTarget = defaultTarget; + bool _animate = animate; + final List _animations = []; + + /// The grids to restore when the animation stops. + WarpGrid? _startGrid; + WarpGrid? _targetGrid; + + late final _EditableWarpSprite _startSprite = _EditableWarpSprite( + gridSize: gridSize, + label: 'Start', + ); + + late final _EditableWarpSprite _targetSprite = _EditableWarpSprite( + gridSize: gridSize, + label: 'Target', + ); + + @override + Future onLoad() async { + _applyInterpolation(); + _targetSprite.warpGrid = _defaultTarget.grid(gridSize); + world.addAll([_startSprite, _targetSprite]); + await _showImage(); + _updateAnimation(); + } + + /// Shows [image] on both sprites. + void setImage(SpriteWarpImage image) { + if (image == _image) { + return; + } + _image = image; + loaded.then((_) => _showImage()); + } + + Future _showImage() async { + final image = _image; + final sprite = await loadSprite('assets/images/${image.path}'); + if (image != _image) { + // Superseded by a newer image. + return; + } + final scale = min( + size.x * 0.35 / sprite.srcSize.x, + size.y * 0.6 / sprite.srcSize.y, + ); + final spriteSize = sprite.srcSize * scale; + _startSprite + ..sprite = sprite + ..size = spriteSize + ..position = Vector2(-spriteSize.x * 0.75, 0); + _targetSprite + ..sprite = sprite + ..size = spriteSize + ..position = Vector2(spriteSize.x * 0.75, 0); + } + + /// Sets how both grids are interpolated. + set interpolation(WarpInterpolation value) { + _interpolation = value; + _applyInterpolation(); + } + + void _applyInterpolation() { + _startSprite.warpInterpolation = _interpolation; + _targetSprite.warpInterpolation = _interpolation; + } + + /// Replaces the target grid with [value], unless animating. + set defaultTarget(DefaultWarpTarget value) { + if (value == _defaultTarget) { + return; + } + _defaultTarget = value; + resetTarget(); + } + + /// Starts or stops warping both sprites between the two grids. + set animate(bool value) { + if (value == _animate) { + return; + } + _animate = value; + loaded.then((_) => _updateAnimation()); + } + + void _updateAnimation() { + for (final sprite in [_startSprite, _targetSprite]) { + sprite + ..editable = !_animate + ..gridBehind = _animate; + } + if (!_animate) { + if (_animations.isNotEmpty) { + for (final animation in _animations) { + // Paused, so that it doesn't apply again before its removal. + animation + ..pause() + ..removeFromParent(); + } + _animations.clear(); + _startSprite.warpGrid = _startGrid; + _targetSprite.warpGrid = _targetGrid; + } + return; + } + if (_animations.isNotEmpty) { + return; + } + _startGrid = _startSprite.warpGrid; + _targetGrid = _targetSprite.warpGrid; + final toTarget = WarpEffect.to(_targetGrid!, _animationController()); + final toStart = WarpEffect.to(_startGrid!, _animationController()); + _startSprite.add(toTarget); + _targetSprite.add(toStart); + _animations.addAll([toTarget, toStart]); + } + + static EffectController _animationController() { + return EffectController( + duration: 1, + reverseDuration: 1, + infinite: true, + curve: Curves.easeInOut, + ); + } + + /// Removes any distortion from the start grid, unless animating. + void resetStart() { + loaded.then((_) { + if (!_animate) { + _startSprite.warpGrid = WarpGrid.identity( + columns: gridSize, + rows: gridSize, + ); + } + }); + } + + /// Resets the target grid to the default target, unless animating. + void resetTarget() { + loaded.then((_) { + if (!_animate) { + _targetSprite.warpGrid = _defaultTarget.grid(gridSize); + } + }); + } +} + +/// A labeled [SpriteComponent] whose grid has handles on its vertices, which +/// can be dragged or double tapped while [editable]. +class _EditableWarpSprite({ + required int gridSize, + required String label, +}) extends SpriteComponent with HasWarpGrid { + this : super(anchor: Anchor.center) { + warpGrid = WarpGrid.identity(columns: gridSize, rows: gridSize); + final text = TextComponent(text: label, anchor: Anchor.center); + addAll([text, _overlay]); + size.addListener( + () => text.position = Vector2(size.x / 2, size.y * 1.2), + ); + } + + final _WarpGridOverlay _overlay = _WarpGridOverlay(); + + bool editable = true; + + /// Whether the grid is drawn behind the sprite, more transparent. + bool get gridBehind => _gridBehind; + bool _gridBehind = false; + set gridBehind(bool value) { + _gridBehind = value; + _overlay._paint.color = Color(value ? 0x40FFFFFF : 0x80FFFFFF); + } + + @override + void render(Canvas canvas) { + if (gridBehind) { + super.renderChild(canvas, _overlay); + } + super.render(canvas); + } + + @override + void renderChild(Canvas canvas, Component child) { + // When behind, [render] already rendered the overlay. + if (!gridBehind || child != _overlay) { + super.renderChild(canvas, child); + } + } +} + +/// Draws the grid of its parent, and places the handles on its vertices. +class _WarpGridOverlay() extends Component with ParentIsA<_EditableWarpSprite> { + // Updates after the effects on the parent, so that the handles follow them. + this : super(priority: 1); + + /// Shared by the grid lines and the handles. + final Paint _paint = Paint() + ..color = const Color(0x80FFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + final List<_Handle> _handles = []; + WarpGrid? _shownGrid; + final Vector2 _shownSize = Vector2.zero(); + + @override + void onLoad() { + for (var i = 0; i < parent.warpGrid!.vertexCount; i++) { + _handles.add(_Handle(this, i)); + } + addAll(_handles); + } + + @override + void update(double dt) { + final grid = parent.warpGrid!; + final size = parent.size; + if (identical(grid, _shownGrid) && size == _shownSize) { + return; + } + _shownGrid = grid; + _shownSize.setFrom(size); + final positions = grid.destinationPositions; + for (var i = 0; i < _handles.length; i++) { + _handles[i].position = positions[i]..multiply(size); + } + } + + /// Resets the vertex of [handle]. + void _reset(_Handle handle) { + final grid = parent.warpGrid!; + final stride = grid.columns + 1; + _move( + handle, + Vector2( + (handle._index % stride) / grid.columns, + (handle._index ~/ stride) / grid.rows, + )..multiply(parent.size), + ); + } + + void _move(_Handle handle, Vector2 position) { + if (!parent.editable) { + return; + } + handle.position = position; + final size = parent.size; + parent.warpGrid = _shownGrid = parent.warpGrid! + .replacingDestinationPositions([ + for (final handle in _handles) handle.position.clone()..divide(size), + ]); + } + + /// Connects each handle to its right and bottom neighbors. + @override + void render(Canvas canvas) { + final grid = parent.warpGrid!; + final stride = grid.columns + 1; + for (var row = 0; row <= grid.rows; row++) { + for (var column = 0; column <= grid.columns; column++) { + final from = _handles[row * stride + column].position.toOffset(); + if (column < grid.columns) { + final to = _handles[row * stride + column + 1].position; + canvas.drawLine(from, to.toOffset(), _paint); + } + if (row < grid.rows) { + final to = _handles[(row + 1) * stride + column].position; + canvas.drawLine(from, to.toOffset(), _paint); + } + } + } + } +} + +/// Moves the vertex at [_index] when dragged, and resets it when double +/// tapped. +class _Handle(final _WarpGridOverlay _overlay, final int _index) + extends CircleComponent + with DragCallbacks, DoubleTapCallbacks { + this : super(radius: 8, anchor: Anchor.center, paint: _overlay._paint); + + @override + void onDragUpdate(DragUpdateEvent event) => + _overlay._move(this, position + event.localDelta); + + @override + void onDoubleTapDown(DoubleTapDownEvent event) => _overlay._reset(this); +} diff --git a/examples/lib/stories/sprites/sprites.dart b/examples/lib/stories/sprites/sprites.dart index dc9780e93b0..df46667ba5a 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -1,4 +1,6 @@ +import 'package:examples/commons/button_knob.dart'; import 'package:examples/commons/commons.dart'; +import 'package:examples/commons/dropdown_knob.dart'; import 'package:examples/commons/example_use_case.dart'; import 'package:examples/stories/sprites/base64_sprite_example.dart'; import 'package:examples/stories/sprites/basic_sprite_example.dart'; @@ -7,6 +9,8 @@ import 'package:examples/stories/sprites/sprite_batch_example.dart'; import 'package:examples/stories/sprites/sprite_batch_load_example.dart'; import 'package:examples/stories/sprites/sprite_group_example.dart'; import 'package:examples/stories/sprites/sprite_sheet_example.dart'; +import 'package:examples/stories/sprites/sprite_warp_example.dart'; +import 'package:flame/components.dart'; import 'package:flame/game.dart'; import 'package:widgetbook/widgetbook.dart'; @@ -56,6 +60,50 @@ WidgetbookComponent spritesStories() { codeLink: baseLink('sprites/sprite_group_example.dart'), info: SpriteGroupExample.description, ), + ExampleUseCase( + name: 'Sprite Warp', + builder: (context) { + final animate = context.knobs.boolean(label: 'Animate'); + return SpriteWarpStory( + gridSize: context.knobs.int.slider( + label: 'Grid Size', + initialValue: 4, + min: 3, + max: 8, + divisions: 5, + ), + image: context.knobs.object.dropdown( + label: 'Image', + initialOption: SpriteWarpImage.flame, + options: SpriteWarpImage.values, + labelBuilder: (image) => image.name, + ), + interpolation: context.knobs.object.dropdown( + label: 'Interpolation', + initialOption: WarpInterpolation.bilinear, + options: WarpInterpolation.values, + labelBuilder: (interpolation) => interpolation.name, + ), + defaultTarget: context.knobs.dropdown( + label: 'Default Target', + options: DefaultWarpTarget.values, + labelBuilder: (target) => target.label, + enabled: !animate, + ), + animate: animate, + startResets: context.knobs.button( + label: 'Reset Start', + enabled: !animate, + ), + targetResets: context.knobs.button( + label: 'Reset Target', + enabled: !animate, + ), + ); + }, + codeLink: baseLink('sprites/sprite_warp_example.dart'), + info: SpriteWarpExample.description, + ), ], ); } diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 8e314bb52c5..3afd85a169e 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -63,5 +63,7 @@ export 'src/geometry/polygon_component.dart'; export 'src/geometry/rectangle_component.dart'; export 'src/geometry/shape_component.dart'; export 'src/math/block.dart'; +export 'src/sprite_warp/has_warp_grid.dart'; +export 'src/sprite_warp/warp_grid.dart' show WarpGrid, WarpInterpolation; export 'src/text/renderers/text_paint.dart'; export 'src/timer.dart'; diff --git a/packages/flame/lib/effects.dart b/packages/flame/lib/effects.dart index 3b341a01cbf..257664d26de 100644 --- a/packages/flame/lib/effects.dart +++ b/packages/flame/lib/effects.dart @@ -46,7 +46,8 @@ export 'src/effects/provider_interfaces.dart' ReadOnlyScaleProvider, ReadOnlySizeProvider, ScaleProvider, - SizeProvider; + SizeProvider, + WarpGridProvider; export 'src/effects/remove_effect.dart'; export 'src/effects/rotate_around_effect.dart'; export 'src/effects/rotate_effect.dart'; @@ -54,3 +55,4 @@ export 'src/effects/scale_effect.dart'; export 'src/effects/sequence_effect.dart' show SequenceEffect; export 'src/effects/size_effect.dart'; export 'src/effects/transform2d_effect.dart'; +export 'src/effects/warp_effect.dart'; diff --git a/packages/flame/lib/src/components/sprite_component.dart b/packages/flame/lib/src/components/sprite_component.dart index 9b08e74007f..380a0ea52fb 100644 --- a/packages/flame/lib/src/components/sprite_component.dart +++ b/packages/flame/lib/src/components/sprite_component.dart @@ -126,7 +126,20 @@ class SpriteComponent({ @mustCallSuper @override void render(Canvas canvas) { - sprite?.render( + final sprite = _sprite; + if (sprite != null) { + renderSprite(canvas, sprite); + } + } + + /// Renders the [sprite] of this component onto the [canvas], in the local + /// coordinates of the component. + /// + /// This is called by [render] when the component has a sprite, and can be + /// overridden to change how the sprite is drawn, as [HasWarpGrid] does. + @protected + void renderSprite(Canvas canvas, Sprite sprite) { + sprite.render( canvas, size: size, overridePaint: paint, diff --git a/packages/flame/lib/src/effects/provider_interfaces.dart b/packages/flame/lib/src/effects/provider_interfaces.dart index 18d2565981b..8d7c274b748 100644 --- a/packages/flame/lib/src/effects/provider_interfaces.dart +++ b/packages/flame/lib/src/effects/provider_interfaces.dart @@ -97,3 +97,11 @@ abstract class HueProvider() { double get hue; set hue(double value); } + +/// Interface for a component that can be affected by warp effects. +/// +/// See [HasWarpGrid] for an example implementation. +abstract class WarpGridProvider() { + WarpGrid? get warpGrid; + set warpGrid(WarpGrid? value); +} diff --git a/packages/flame/lib/src/effects/warp_effect.dart b/packages/flame/lib/src/effects/warp_effect.dart new file mode 100644 index 00000000000..a4fb089872f --- /dev/null +++ b/packages/flame/lib/src/effects/warp_effect.dart @@ -0,0 +1,176 @@ +import 'dart:typed_data'; + +import 'package:flame/components.dart'; +import 'package:flame/src/effects/controllers/effect_controller.dart'; +import 'package:flame/src/effects/effect.dart'; +import 'package:flame/src/effects/effect_target.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; + +/// Change the [WarpGrid] of a component over time, similarly to SpriteKit's +/// `SKAction.warp(to:duration:)`. +/// +/// This effect applies incremental changes to the positions of the target's +/// warp grid, and requires that any other effect or update logic applied to +/// the same grid also used incremental updates. +/// +/// To animate through several grids, use a `SequenceEffect` of +/// [WarpEffect.to] effects. +class WarpEffect.by( + List offsets, + super.controller, { + List? sourceOffsets, + WarpGridProvider? target, + super.onComplete, + super.key, +}) extends Effect with EffectTarget { + /// This constructor will create an effect that moves each destination + /// position of the target's grid by the corresponding entry of [offsets], + /// and each source position by the corresponding entry of [sourceOffsets], + /// if given. + /// + /// The offsets are normalized like the grid positions, and there must be + /// one per vertex. The target must have a grid when the effect starts. + this { + this.target = target; + } + + /// This constructor will create an effect that changes both the source and + /// destination positions of the target's grid to those of [grid]. + /// + /// The target's grid must have the same number of columns and rows as + /// [grid]. If the target has no grid when the effect starts, it starts from + /// an identity grid. + factory WarpEffect.to( + WarpGrid grid, + EffectController controller, { + WarpGridProvider? target, + void Function()? onComplete, + ComponentKey? key, + }) { + return _WarpToEffect( + grid, + controller, + target: target, + onComplete: onComplete, + key: key, + ); + } + + Float32List? _destinationOffsets = _toRaw(offsets); + Float32List? _sourceOffsets = sourceOffsets == null + ? null + : _toRaw(sourceOffsets); + + @override + void onStart() { + final grid = target.warpGrid; + if (grid == null) { + throw StateError('Can only apply this effect to a target with a grid'); + } + _checkLength(grid, _destinationOffsets); + _checkLength(grid, _sourceOffsets); + } + + @override + void apply(double progress) { + final dProgress = progress - previousProgress; + // Without offsets the grid is left as it is, so that its mesh is not + // rebuilt every frame. + if (dProgress == 0 || + (_sourceOffsets == null && _destinationOffsets == null)) { + return; + } + final grid = target.warpGrid; + if (grid == null) { + throw StateError('The grid was removed while being warped'); + } + target.warpGrid = WarpGrid.raw( + grid.columns, + grid.rows, + _offsetBy(grid.rawSourcePositions, _sourceOffsets, dProgress), + _offsetBy(grid.rawDestinationPositions, _destinationOffsets, dProgress), + ); + } + + static Float32List _offsetBy( + Float32List positions, + Float32List? offsets, + double factor, + ) { + if (offsets == null) { + return positions; + } + assert( + offsets.length == positions.length, + 'The grid changed its number of vertices while being warped', + ); + final result = Float32List(positions.length); + for (var i = 0; i < result.length; i++) { + result[i] = positions[i] + offsets[i] * factor; + } + return result; + } + + static void _checkLength(WarpGrid grid, Float32List? offsets) { + if (offsets != null && offsets.length != 2 * grid.vertexCount) { + throw ArgumentError( + 'Expected ${grid.vertexCount} offsets for a $grid, ' + 'got ${offsets.length ~/ 2}', + ); + } + } + + static Float32List _toRaw(List vectors) { + final result = Float32List(2 * vectors.length); + for (var i = 0; i < vectors.length; i++) { + result[2 * i] = vectors[i].x; + result[2 * i + 1] = vectors[i].y; + } + return result; + } + + /// Returns `a - b`, or `null` when they are equal so that unchanged + /// positions are shared between grids instead of being copied every frame. + static Float32List? _difference(Float32List a, Float32List b) { + final result = Float32List(a.length); + var isZero = true; + for (var i = 0; i < result.length; i++) { + result[i] = a[i] - b[i]; + isZero = isZero && result[i] == 0; + } + return isZero ? null : result; + } +} + +/// Implementation class for [WarpEffect.to] +class _WarpToEffect( + final WarpGrid _grid, + EffectController controller, { + super.target, + super.onComplete, + super.key, +}) extends WarpEffect { + this : super.by(const [], controller); + + @override + void onStart() { + final grid = target.warpGrid ??= WarpGrid.identity( + columns: _grid.columns, + rows: _grid.rows, + ); + if (grid.columns != _grid.columns || grid.rows != _grid.rows) { + throw ArgumentError( + 'Cannot warp a ${grid.columns} x ${grid.rows} grid ' + 'to a ${_grid.columns} x ${_grid.rows} grid', + ); + } + _destinationOffsets = WarpEffect._difference( + _grid.rawDestinationPositions, + grid.rawDestinationPositions, + ); + _sourceOffsets = WarpEffect._difference( + _grid.rawSourcePositions, + grid.rawSourcePositions, + ); + } +} diff --git a/packages/flame/lib/src/sprite_warp/has_warp_grid.dart b/packages/flame/lib/src/sprite_warp/has_warp_grid.dart new file mode 100644 index 00000000000..0a2f821c8ef --- /dev/null +++ b/packages/flame/lib/src/sprite_warp/has_warp_grid.dart @@ -0,0 +1,80 @@ +import 'dart:ui'; + +import 'package:flame/src/components/sprite_component.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; +import 'package:flame/src/sprite_warp/sprite_warp_renderer.dart'; +import 'package:flame/src/sprite_warp/warp_grid.dart'; +import 'package:meta/meta.dart'; + +/// A mixin for [SpriteComponent]s that can warp (distort) their sprite with a +/// [WarpGrid], similarly to SpriteKit's `SKWarpGeometryGrid`. +/// +/// ```dart +/// class WarpedSprite extends SpriteComponent with HasWarpGrid { +/// WarpedSprite({super.sprite, super.size}) { +/// warpGrid = WarpGrid.identity(columns: 2, rows: 2); +/// } +/// } +/// ``` +/// +/// While [warpGrid] is `null` the sprite is rendered as usual. Warping only +/// changes how the sprite is drawn: the component's size, hit testing and +/// collisions are not affected. +mixin HasWarpGrid on SpriteComponent implements WarpGridProvider { + /// The grid used to warp the [sprite], or `null` to render it undistorted. + /// + /// The grid's destination positions are relative to the component's + /// [size]. Since [WarpGrid] is immutable, assign a new grid to change the + /// warp, or animate it with a `WarpEffect`. + @override + WarpGrid? warpGrid; + + /// How [warpGrid] is interpolated between its vertices. + WarpInterpolation warpInterpolation = WarpInterpolation.bilinear; + + SpriteWarpRenderer? _warpRenderer; + + /// The renderer used while [warpGrid] is set. + @visibleForTesting + SpriteWarpRenderer? get warpRenderer => _warpRenderer; + + @override + set sprite(Sprite? value) { + // The warp renderer holds an image shader, which would keep the previous + // image alive after it is released (and possibly evicted). + _disposeWarpRenderer(); + super.sprite = value; + } + + @override + @mustCallSuper + void onRemove() { + _disposeWarpRenderer(); + super.onRemove(); + } + + @override + void renderSprite(Canvas canvas, Sprite sprite) { + final warpGrid = this.warpGrid; + if (warpGrid == null) { + _disposeWarpRenderer(); + super.renderSprite(canvas, sprite); + return; + } + (_warpRenderer ??= SpriteWarpRenderer()).render( + canvas, + sprite: sprite, + grid: warpGrid, + interpolation: warpInterpolation, + width: size.x, + height: size.y, + paint: paint, + bleed: bleed ?? 0, + ); + } + + void _disposeWarpRenderer() { + _warpRenderer?.dispose(); + _warpRenderer = null; + } +} diff --git a/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart b/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart new file mode 100644 index 00000000000..8945099eea2 --- /dev/null +++ b/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart @@ -0,0 +1,192 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/src/sprite.dart'; +import 'package:flame/src/sprite_warp/warp_grid.dart'; +import 'package:flame/src/sprite_warp/warp_mesh.dart'; +import 'package:meta/meta.dart'; + +final Float64List _identityMatrix = Float64List.fromList([ + 1, 0, 0, 0, // + 0, 1, 0, 0, // + 0, 0, 1, 0, // + 0, 0, 0, 1, // +]); + +/// Offset, in texels, added to all the texture coordinates. +/// +/// When a pixel samples the image exactly on the boundary between two texels +/// (e.g. every third pixel when a sprite is scaled by 1.5 with nearest +/// filtering), each triangle of the mesh would round the tie in its own way, +/// because of tiny floating point differences, and lines of the image would +/// show 1-texel steps along the triangles' edges. Nudging the coordinates +/// towards the lower texel makes every triangle round ties the same way as +/// drawImageRect does, while staying well above the float32 precision of the +/// coordinates of large textures and well below any visible effect with +/// linear filtering. +const double _texelOffset = -1 / 1024; + +/// Renders a [Sprite] warped by a [WarpGrid], caching everything that does +/// not change between frames. +/// +/// The mesh is rebuilt only when the grid or the interpolation change, the +/// vertices only when the mesh, the sprite's source rectangle or the +/// destination size change, and the image shader only when the sprite's image +/// changes. +@internal +class SpriteWarpRenderer() { + WarpGrid? _grid; + WarpInterpolation? _interpolation; + WarpMesh? _mesh; + + Rect? _src; + double _width = 0; + double _height = 0; + double _bleed = 0; + Vertices? _vertices; + Rect _bounds = Rect.zero; + + Image? _image; + ImageShader? _shader; + + final Paint _paint = Paint(); + final Paint _layerPaint = Paint(); + + /// The number of times the mesh was built. + @visibleForTesting + int meshBuilds = 0; + + /// The number of times the vertices were built. + @visibleForTesting + int verticesBuilds = 0; + + /// Renders [sprite] warped by [grid] into a rectangle of the given [width] + /// and [height] at the origin, expanded by [bleed] on every side, using the + /// properties of [paint] (except its shader). + void render( + Canvas canvas, { + required Sprite sprite, + required WarpGrid grid, + required WarpInterpolation interpolation, + required double width, + required double height, + required Paint paint, + double bleed = 0, + }) { + _updateMesh(grid, interpolation); + _updateShader(sprite.image); + _updateVertices(sprite.src, width, height, bleed); + + _paint + ..shader = _shader + ..color = paint.color + ..filterQuality = paint.filterQuality + ..isAntiAlias = paint.isAntiAlias + ..imageFilter = paint.imageFilter + ..maskFilter = paint.maskFilter + ..invertColors = paint.invertColors; + + // The blend mode passed to drawVertices only combines vertex colors (which + // are not used) with the shader, but Impeller crashes with some modes + // (e.g. BlendMode.dst), so it is always srcOver. + final colorFilter = paint.colorFilter; + if (colorFilter == null) { + _paint.blendMode = paint.blendMode; + canvas.drawVertices(_vertices!, BlendMode.srcOver, _paint); + } else { + // Impeller ignores the paint's color filter in drawVertices, so it is + // applied through a layer instead, on every backend for consistency. + _paint.blendMode = BlendMode.srcOver; + _layerPaint + ..colorFilter = colorFilter + ..blendMode = paint.blendMode; + final hasFilters = paint.imageFilter != null || paint.maskFilter != null; + canvas.saveLayer(hasFilters ? null : _bounds, _layerPaint); + canvas.drawVertices(_vertices!, BlendMode.srcOver, _paint); + canvas.restore(); + } + } + + /// Releases the cached resources. + /// + /// The image shader is not disposed but only dereferenced, since on the + /// CanvasKit web renderer disposing an image shader also disposes its image, + /// which belongs to the sprite and may be shared. + void dispose() { + _vertices?.dispose(); + _vertices = null; + _shader = null; + _image = null; + _paint.shader = null; + _mesh = null; + _grid = null; + _interpolation = null; + _src = null; + } + + void _updateMesh(WarpGrid grid, WarpInterpolation interpolation) { + if (identical(grid, _grid) && interpolation == _interpolation) { + return; + } + _grid = grid; + _interpolation = interpolation; + _mesh = WarpMesh.fromGrid(grid, interpolation); + meshBuilds++; + _src = null; + } + + void _updateShader(Image image) { + if (identical(image, _image)) { + return; + } + _image = image; + _shader = ImageShader( + image, + TileMode.clamp, + TileMode.clamp, + _identityMatrix, + ); + } + + void _updateVertices(Rect src, double width, double height, double bleed) { + if (src == _src && + width == _width && + height == _height && + bleed == _bleed) { + return; + } + _src = src; + _width = width; + _height = height; + _bleed = bleed; + + final mesh = _mesh!; + final scaleX = width + 2 * bleed; + final scaleY = height + 2 * bleed; + final positions = Float32List(mesh.positions.length); + final textureCoordinates = Float32List(mesh.textureCoordinates.length); + for (var k = 0; k < positions.length; k += 2) { + positions[k] = mesh.positions[k] * scaleX - bleed; + positions[k + 1] = mesh.positions[k + 1] * scaleY - bleed; + textureCoordinates[k] = + src.left + mesh.textureCoordinates[k] * src.width + _texelOffset; + textureCoordinates[k + 1] = + src.top + mesh.textureCoordinates[k + 1] * src.height + _texelOffset; + } + _vertices?.dispose(); + _vertices = Vertices.raw( + VertexMode.triangles, + positions, + textureCoordinates: textureCoordinates, + indices: mesh.indices, + ); + verticesBuilds++; + final bounds = mesh.bounds; + _bounds = Rect.fromLTRB( + bounds.left * scaleX - bleed, + bounds.top * scaleY - bleed, + bounds.right * scaleX - bleed, + bounds.bottom * scaleY - bleed, + ); + } +} diff --git a/packages/flame/lib/src/sprite_warp/warp_grid.dart b/packages/flame/lib/src/sprite_warp/warp_grid.dart new file mode 100644 index 00000000000..6434c075eec --- /dev/null +++ b/packages/flame/lib/src/sprite_warp/warp_grid.dart @@ -0,0 +1,252 @@ +import 'dart:typed_data'; + +import 'package:flame/src/extensions/vector2.dart'; +import 'package:meta/meta.dart'; + +/// Number of subdivision iterations used to build the rendered mesh of a +/// [WarpGrid]: each grid cell is split into `2^n x 2^n` sub-cells. +@internal +const int warpSubdivisionLevels = 2; + +/// Maximum number of vertices of a rendered mesh, bound by 16-bit indices. +const int _maxMeshVertices = 1 << 16; + +/// An immutable grid that describes how to warp (distort) a sprite. +/// +/// The grid has [columns] x [rows] cells, and therefore +/// `(columns + 1) * (rows + 1)` vertices (see [vertexCount]). Each vertex has +/// a source position and a destination position: the content found at the +/// source position of the sprite is drawn at the destination position, and +/// everything in between is interpolated. +/// +/// Vertices are stored in row-major order starting from the top-left corner +/// (see [vertexIndex]), and positions are normalized with the y axis pointing +/// down, following Flame's conventions: +/// * source positions are relative to the sprite's source rectangle, so +/// `(0, 0)` is its top-left corner and `(1, 1)` its bottom-right corner; +/// * destination positions are relative to the size of the component, and +/// values outside of the `0..1` range are valid: they correspond to +/// positions outside of the undistorted bounds. +/// +/// When omitted, both source and destination positions describe a regular +/// grid, so that the sprite is rendered without distortion. +/// +/// Note that SpriteKit's `SKWarpGeometryGrid` uses a bottom-left origin with +/// the y axis pointing up, so its positions must be converted accordingly. +@immutable +class const WarpGrid.raw( + /// The number of cells in the horizontal direction. + final int columns, + + /// The number of cells in the vertical direction. + final int rows, + final Float32List _source, + final Float32List _destination, +) { + /// Creates a grid from interleaved x, y source and destination positions, + /// which are used as is and must not be modified afterwards. + /// + /// Both lists must hold `2 * (columns + 1) * (rows + 1)` values. + @internal + this; + + /// Creates a grid with the given positions, which default to those of an + /// undistorted grid. + /// + /// Throws an [ArgumentError] when [columns] or [rows] are not positive, when + /// the rendered mesh of the grid would have more than 65536 vertices, e.g. + /// with more than 63 x 63 cells, or when there isn't one position per + /// vertex. + factory WarpGrid({ + required int columns, + required int rows, + List? sourcePositions, + List? destinationPositions, + }) { + if (columns <= 0 || rows <= 0) { + throw ArgumentError( + 'Columns and rows must be positive, got $columns x $rows', + ); + } + final meshVertices = + (columns * (1 << warpSubdivisionLevels) + 1) * + (rows * (1 << warpSubdivisionLevels) + 1); + if (meshVertices > _maxMeshVertices) { + throw ArgumentError( + 'Too many columns and rows: $columns x $rows (at most ' + '$_maxMeshVertices mesh vertices, got $meshVertices)', + ); + } + return WarpGrid.raw( + columns, + rows, + _positionsOrIdentity(sourcePositions, columns, rows), + _positionsOrIdentity(destinationPositions, columns, rows), + ); + } + + /// Creates a grid that renders the sprite without distortion. + /// + /// Throws an [ArgumentError] in the same cases as the default constructor. + factory WarpGrid.identity({int columns = 1, int rows = 1}) { + return WarpGrid(columns: columns, rows: rows); + } + + /// The number of vertices, equal to `(columns + 1) * (rows + 1)`. + int get vertexCount => (columns + 1) * (rows + 1); + + /// The index of the vertex at the given [column] and [row], where `(0, 0)` + /// is the top-left vertex and `(columns, rows)` the bottom-right one. + int vertexIndex(int column, int row) { + assert(column >= 0 && column <= columns, 'Invalid column: $column'); + assert(row >= 0 && row <= rows, 'Invalid row: $row'); + return row * (columns + 1) + column; + } + + /// The normalized source position of the vertex at [index]. + Vector2 sourcePosition(int index) => _positionAt(_source, index); + + /// The normalized destination position of the vertex at [index]. + Vector2 destinationPosition(int index) => _positionAt(_destination, index); + + /// A copy of all the source positions, in vertex order. + List get sourcePositions => _toVectors(_source); + + /// A copy of all the destination positions, in vertex order. + List get destinationPositions => _toVectors(_destination); + + /// The source positions as interleaved x, y values. + /// + /// The returned list must not be modified. + @internal + Float32List get rawSourcePositions => _source; + + /// The destination positions as interleaved x, y values. + /// + /// The returned list must not be modified. + @internal + Float32List get rawDestinationPositions => _destination; + + /// Returns a copy of this grid with the given source [positions]. + /// + /// Throws an [ArgumentError] when there isn't one position per vertex. + WarpGrid replacingSourcePositions(List positions) { + return WarpGrid.raw( + columns, + rows, + _fromVectors(positions, vertexCount), + _destination, + ); + } + + /// Returns a copy of this grid with the given destination [positions]. + /// + /// Throws an [ArgumentError] when there isn't one position per vertex. + WarpGrid replacingDestinationPositions(List positions) { + return WarpGrid.raw( + columns, + rows, + _source, + _fromVectors(positions, vertexCount), + ); + } + + @override + bool operator ==(Object other) { + return other is WarpGrid && + columns == other.columns && + rows == other.rows && + _equals(_source, other._source) && + _equals(_destination, other._destination); + } + + @override + int get hashCode => Object.hash( + columns, + rows, + Object.hashAll(_source), + Object.hashAll(_destination), + ); + + @override + String toString() => 'WarpGrid($columns x $rows)'; + + static Float32List _positionsOrIdentity( + List? positions, + int columns, + int rows, + ) { + final vertexCount = (columns + 1) * (rows + 1); + if (positions != null) { + return _fromVectors(positions, vertexCount); + } + final result = Float32List(2 * vertexCount); + var k = 0; + for (var row = 0; row <= rows; row++) { + for (var column = 0; column <= columns; column++) { + result[k++] = column / columns; + result[k++] = row / rows; + } + } + return result; + } + + static Float32List _fromVectors(List positions, int vertexCount) { + if (positions.length != vertexCount) { + throw ArgumentError( + 'Expected $vertexCount positions, got ${positions.length}', + ); + } + final result = Float32List(2 * vertexCount); + for (var i = 0; i < vertexCount; i++) { + result[2 * i] = positions[i].x; + result[2 * i + 1] = positions[i].y; + } + return result; + } + + static List _toVectors(Float32List values) { + return List.generate( + values.length ~/ 2, + (i) => Vector2(values[2 * i], values[2 * i + 1]), + growable: false, + ); + } + + static Vector2 _positionAt(Float32List values, int index) { + assert(index >= 0 && 2 * index < values.length, 'Invalid index: $index'); + return Vector2(values[2 * index], values[2 * index + 1]); + } + + static bool _equals(Float32List a, Float32List b) { + if (identical(a, b)) { + return true; + } + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; + } +} + +/// How a [WarpGrid] is interpolated between its vertices. +enum WarpInterpolation() { + /// Bilinear interpolation within each cell, matching SpriteKit's + /// `SKWarpGeometryGrid` rendering. + /// + /// Moving a vertex only affects the cells that share it, and the edges + /// between cells stay straight. + bilinear, + + /// Catmull-Rom spline interpolation across cells. + /// + /// The warped sprite passes through all the grid vertices and bends + /// smoothly across cell boundaries. Moving a vertex also affects cells up to + /// two cells away, and strong distortions can slightly overshoot. + catmullRom, +} diff --git a/packages/flame/lib/src/sprite_warp/warp_mesh.dart b/packages/flame/lib/src/sprite_warp/warp_mesh.dart new file mode 100644 index 00000000000..5023609234c --- /dev/null +++ b/packages/flame/lib/src/sprite_warp/warp_mesh.dart @@ -0,0 +1,225 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/src/sprite_warp/warp_grid.dart'; +import 'package:meta/meta.dart'; + +/// A triangle mesh built from a [WarpGrid], ready to be turned into +/// `Vertices`. +/// +/// Each grid cell is subdivided into `2^n x 2^n` sub-cells (where `n` is the +/// number of subdivision levels), and every sub-cell is split into two +/// triangles. Positions and texture coordinates of the mesh vertices are +/// interpolated from the grid with the given [WarpInterpolation], and are +/// normalized like the grid's positions. +@internal +class WarpMesh._( + /// Normalized destination positions, as interleaved x, y values. + final Float32List positions, + + /// Normalized source positions, as interleaved x, y values. + final Float32List textureCoordinates, + + /// Indices of the triangles' vertices. + final Uint16List indices, +) { + /// Builds the mesh of [grid] using the given [interpolation]. + factory WarpMesh.fromGrid( + WarpGrid grid, + WarpInterpolation interpolation, { + @visibleForTesting int subdivisionLevels = warpSubdivisionLevels, + }) { + final subdivisions = 1 << subdivisionLevels; + final columns = grid.columns * subdivisions + 1; + final rows = grid.rows * subdivisions + 1; + final weightsX = _AxisWeights(grid.columns, subdivisions, interpolation); + final weightsY = _AxisWeights(grid.rows, subdivisions, interpolation); + final source = _PaddedGrid( + grid.rawSourcePositions, + grid.columns, + grid.rows, + ); + final destination = _PaddedGrid( + grid.rawDestinationPositions, + grid.columns, + grid.rows, + ); + + final positions = Float32List(2 * columns * rows); + final textureCoordinates = Float32List(2 * columns * rows); + var k = 0; + for (var row = 0; row < rows; row++) { + for (var column = 0; column < columns; column++) { + destination.interpolate(weightsX, column, weightsY, row, positions, k); + source.interpolate( + weightsX, + column, + weightsY, + row, + textureCoordinates, + k, + ); + k += 2; + } + } + + final indices = Uint16List(6 * (columns - 1) * (rows - 1)); + var i = 0; + for (var row = 0; row < rows - 1; row++) { + for (var column = 0; column < columns - 1; column++) { + final topLeft = row * columns + column; + final topRight = topLeft + 1; + final bottomLeft = topLeft + columns; + final bottomRight = bottomLeft + 1; + indices[i++] = topLeft; + indices[i++] = topRight; + indices[i++] = bottomLeft; + indices[i++] = topRight; + indices[i++] = bottomRight; + indices[i++] = bottomLeft; + } + } + return WarpMesh._(positions, textureCoordinates, indices); + } + + /// The bounding box of [positions]. + late final Rect bounds = _boundsOf(positions); + + int get vertexCount => positions.length ~/ 2; + + static Rect _boundsOf(Float32List positions) { + var left = double.infinity; + var top = double.infinity; + var right = double.negativeInfinity; + var bottom = double.negativeInfinity; + for (var k = 0; k < positions.length; k += 2) { + left = min(left, positions[k]); + right = max(right, positions[k]); + top = min(top, positions[k + 1]); + bottom = max(bottom, positions[k + 1]); + } + return Rect.fromLTRB(left, top, right, bottom); + } +} + +/// Interpolation weights along one axis of the grid. +/// +/// Every mesh vertex along the axis is interpolated from the 4 nearest grid +/// vertices (in padded coordinates, see [_PaddedGrid]), with weights that +/// only depend on the vertex's position within its cell: they are computed +/// once for each of the `subdivisions + 1` possible positions. +class _AxisWeights( + final int cells, + final int subdivisions, + WarpInterpolation interpolation, +) { + final Float64List _weights = Float64List(4 * (subdivisions + 1)); + + this { + for (var k = 0; k <= subdivisions; k++) { + final t = k / subdivisions; + final w = 4 * k; + switch (interpolation) { + case WarpInterpolation.bilinear: + _weights[w + 1] = 1 - t; + _weights[w + 2] = t; + case WarpInterpolation.catmullRom: + final t2 = t * t; + final t3 = t2 * t; + _weights[w] = 0.5 * (-t + 2 * t2 - t3); + _weights[w + 1] = 0.5 * (2 - 5 * t2 + 3 * t3); + _weights[w + 2] = 0.5 * (t + 4 * t2 - 3 * t3); + _weights[w + 3] = 0.5 * (-t2 + t3); + } + } + } + + /// The cell containing the mesh vertex at [index]; the last vertex belongs + /// to the last cell. + int cellOf(int index) => min(index ~/ subdivisions, cells - 1); + + /// The offset of the weights of the mesh vertex at [index] in its [cell]. + int weightsOffset(int index, int cell) => 4 * (index - cell * subdivisions); + + double operator [](int offset) => _weights[offset]; +} + +/// Grid positions surrounded by one extra row and column on every side, +/// linearly extrapolated from the grid, so that every cell has 4 x 4 +/// neighboring vertices to interpolate from. +/// +/// Linear extrapolation makes Catmull-Rom splines reproduce linear functions, +/// so that an undistorted grid stays undistorted, and makes a single cell +/// interpolate bilinearly. +class _PaddedGrid(Float32List values, int columns, int rows) { + final int _width = columns + 3; + final Float32List _data = Float32List(2 * (columns + 3) * (rows + 3)); + + this { + for (var row = 0; row <= rows; row++) { + for (var column = 0; column <= columns; column++) { + final from = 2 * (row * (columns + 1) + column); + final to = _offset(column + 1, row + 1); + _data[to] = values[from]; + _data[to + 1] = values[from + 1]; + } + } + for (var row = 1; row <= rows + 1; row++) { + _extrapolate(0, row, 1, row, 2, row); + _extrapolate(columns + 2, row, columns + 1, row, columns, row); + } + for (var column = 0; column < _width; column++) { + _extrapolate(column, 0, column, 1, column, 2); + _extrapolate(column, rows + 2, column, rows + 1, column, rows); + } + } + + int _offset(int column, int row) => 2 * (row * _width + column); + + /// Sets the target vertex to `2 * near - far`. + void _extrapolate( + int targetColumn, + int targetRow, + int nearColumn, + int nearRow, + int farColumn, + int farRow, + ) { + final target = _offset(targetColumn, targetRow); + final near = _offset(nearColumn, nearRow); + final far = _offset(farColumn, farRow); + _data[target] = 2 * _data[near] - _data[far]; + _data[target + 1] = 2 * _data[near + 1] - _data[far + 1]; + } + + /// Interpolates the mesh vertex at ([column], [row]) into [out] at [at]. + void interpolate( + _AxisWeights weightsX, + int column, + _AxisWeights weightsY, + int row, + Float32List out, + int at, + ) { + final cellX = weightsX.cellOf(column); + final cellY = weightsY.cellOf(row); + final wx = weightsX.weightsOffset(column, cellX); + final wy = weightsY.weightsOffset(row, cellY); + var x = 0.0; + var y = 0.0; + for (var j = 0; j < 4; j++) { + // Padded row cellY + j is grid row cellY - 1 + j. + var k = _offset(cellX, cellY + j); + final weightY = weightsY[wy + j]; + for (var i = 0; i < 4; i++) { + final weight = weightsX[wx + i] * weightY; + x += _data[k] * weight; + y += _data[k + 1] * weight; + k += 2; + } + } + out[at] = x; + out[at + 1] = y; + } +} diff --git a/packages/flame/test/_goldens/sprite_component_warp_interpolation.png b/packages/flame/test/_goldens/sprite_component_warp_interpolation.png new file mode 100644 index 00000000000..02369126b8e Binary files /dev/null and b/packages/flame/test/_goldens/sprite_component_warp_interpolation.png differ diff --git a/packages/flame/test/_goldens/sprite_component_warp_paint.png b/packages/flame/test/_goldens/sprite_component_warp_paint.png new file mode 100644 index 00000000000..36f91988c74 Binary files /dev/null and b/packages/flame/test/_goldens/sprite_component_warp_paint.png differ diff --git a/packages/flame/test/effects/warp_effect_test.dart b/packages/flame/test/effects/warp_effect_test.dart new file mode 100644 index 00000000000..fdd9ed94967 --- /dev/null +++ b/packages/flame/test/effects/warp_effect_test.dart @@ -0,0 +1,296 @@ +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('WarpEffect', () { + final identity = WarpGrid.identity(); + // Moves the bottom-right corner by (0.5, 0.25). + final offsets = [ + Vector2.zero(), + Vector2.zero(), + Vector2.zero(), + Vector2(0.5, 0.25), + ]; + + testWithFlameGame('relative', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add(WarpEffect.by(offsets, EffectController(duration: 1))); + game.update(0); + expect(component.warpGrid, identity); + expect(component.children.length, 1); + + game.update(0.5); + expect(_destination(component, 3), closeToVector(Vector2(1.25, 1.125))); + expect(_destination(component, 0), closeToVector(Vector2.zero())); + + game.update(0.5); + expect(_destination(component, 3), closeToVector(Vector2(1.5, 1.25))); + game.update(0); + expect(component.children.length, 0); + expect(component.warpGrid!.sourcePositions, identity.sourcePositions); + }); + + testWithFlameGame('relative with source offsets', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add( + WarpEffect.by( + List.generate(4, (_) => Vector2.zero()), + EffectController(duration: 1), + sourceOffsets: offsets.map((o) => -o).toList(), + ), + ); + game.update(0); + game.update(0.5); + expect(_source(component, 3), closeToVector(Vector2(0.75, 0.875))); + expect(_destination(component, 3), closeToVector(Vector2(1, 1))); + }); + + testWithFlameGame('absolute', (game) async { + final target = WarpGrid( + columns: 1, + rows: 1, + sourcePositions: [ + Vector2(0.5, 0), + Vector2(1, 0), + Vector2(0, 1), + Vector2(1, 1), + ], + destinationPositions: [ + Vector2(-1, 0), + Vector2(1, 0), + Vector2(0, 1), + Vector2(2, 3), + ], + ); + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add(WarpEffect.to(target, EffectController(duration: 1))); + game.update(0); + game.update(0.5); + expect(_source(component, 0), closeToVector(Vector2(0.25, 0))); + expect(_destination(component, 0), closeToVector(Vector2(-0.5, 0))); + expect(_destination(component, 3), closeToVector(Vector2(1.5, 2))); + + game.update(0.5); + for (var i = 0; i < target.vertexCount; i++) { + expect(_source(component, i), closeToVector(target.sourcePosition(i))); + expect( + _destination(component, i), + closeToVector(target.destinationPosition(i)), + ); + } + }); + + testWithFlameGame('absolute without initial grid', (game) async { + final target = + WarpGrid.identity( + columns: 2, + rows: 3, + ).replacingDestinationPositions( + List.generate(12, (i) => Vector2(i.toDouble(), 0)), + ); + final component = _WarpedComponent(); + await game.ensureAdd(component); + + component.add(WarpEffect.to(target, EffectController(duration: 1))); + game.update(0); + expect(component.warpGrid, WarpGrid.identity(columns: 2, rows: 3)); + + game.update(1); + expect(component.warpGrid!.columns, 2); + expect(component.warpGrid!.rows, 3); + expect(_destination(component, 5), closeToVector(Vector2(5, 0))); + }); + + testWithFlameGame('unchanged positions are shared', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add( + WarpEffect.to( + identity.replacingDestinationPositions( + [ + for (var i = 0; i < 4; i++) identity.destinationPosition(i), + ]..[3] = Vector2(2, 2), + ), + EffectController(duration: 1), + ), + ); + game.update(0); + game.update(0.5); + expect( + identical( + component.warpGrid!.rawSourcePositions, + identity.rawSourcePositions, + ), + isTrue, + ); + }); + + testWithFlameGame('unchanged grid is kept', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add( + WarpEffect.to(WarpGrid.identity(), EffectController(duration: 1)), + ); + game.update(0); + game.update(0.5); + expect(identical(component.warpGrid, identity), isTrue); + game.update(0.5); + game.update(0); + expect(component.children.length, 0); + expect(identical(component.warpGrid, identity), isTrue); + }); + + testWithFlameGame('reversed', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add( + WarpEffect.by( + offsets, + EffectController(duration: 1, reverseDuration: 1), + ), + ); + game.update(0); + game.update(1); + expect(_destination(component, 3), closeToVector(Vector2(1.5, 1.25))); + game.update(0.5); + expect(_destination(component, 3), closeToVector(Vector2(1.25, 1.125))); + game.update(0.5); + expect(_destination(component, 3), closeToVector(Vector2(1, 1))); + }); + + testWithFlameGame('combined effects', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.addAll([ + WarpEffect.by(offsets, EffectController(duration: 1)), + WarpEffect.by( + offsets.map((o) => o * -2).toList(), + EffectController(duration: 2), + ), + ]); + game.update(0); + game.update(1); + expect(_destination(component, 3), closeToVector(Vector2(1, 1))); + game.update(1); + expect(_destination(component, 3), closeToVector(Vector2(0.5, 0.75))); + }); + + testWithFlameGame('sequence of grids', (game) async { + final first = identity.replacingDestinationPositions( + identity.destinationPositions..[0] = Vector2(-1, -1), + ); + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add( + SequenceEffect([ + WarpEffect.to(first, EffectController(duration: 1)), + WarpEffect.to(identity, EffectController(duration: 1)), + ]), + ); + game.update(0); + game.update(1); + expect(_destination(component, 0), closeToVector(Vector2(-1, -1))); + game.update(0.5); + expect(_destination(component, 0), closeToVector(Vector2(-0.5, -0.5))); + game.update(0.5); + expect(_destination(component, 0), closeToVector(Vector2.zero())); + }); + + testWithFlameGame('HasWarpGrid target', (game) async { + final component = _WarpedSprite(sprite: Sprite(await generateImage())) + ..warpGrid = identity; + await game.ensureAdd(component); + + component.add(WarpEffect.by(offsets, EffectController(duration: 1))); + game.update(0); + game.update(1); + expect( + component.warpGrid!.destinationPosition(3), + closeToVector(Vector2(1.5, 1.25)), + ); + }); + + testWithFlameGame('relative without grid', (game) async { + final component = _WarpedComponent(); + await game.ensureAdd(component); + + component.add(WarpEffect.by(offsets, EffectController(duration: 1))); + expect(() => game.update(0), throwsStateError); + }); + + testWithFlameGame('grid removed while running', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add(WarpEffect.by(offsets, EffectController(duration: 1))); + game.update(0); + component.warpGrid = null; + expect(() => game.update(0.5), throwsStateError); + }); + + testWithFlameGame('relative with wrong number of offsets', (game) async { + final component = _WarpedComponent() + ..warpGrid = WarpGrid.identity(columns: 2); + await game.ensureAdd(component); + + component.add(WarpEffect.by(offsets, EffectController(duration: 1))); + expect(() => game.update(0), throwsArgumentError); + }); + + testWithFlameGame('absolute with different dimensions', (game) async { + final component = _WarpedComponent()..warpGrid = identity; + await game.ensureAdd(component); + + component.add( + WarpEffect.to( + WarpGrid.identity(rows: 2), + EffectController(duration: 1), + ), + ); + expect(() => game.update(0), throwsArgumentError); + }); + + testWithFlameGame('explicit target', (game) async { + final provider = _WarpedComponent()..warpGrid = identity; + final component = Component(); + await game.ensureAdd(component); + + component.add( + WarpEffect.by( + offsets, + EffectController(duration: 1), + target: provider, + ), + ); + game.update(0); + game.update(1); + expect(_destination(provider, 3), closeToVector(Vector2(1.5, 1.25))); + }); + }); +} + +Vector2 _source(WarpGridProvider provider, int index) => + provider.warpGrid!.sourcePosition(index); + +Vector2 _destination(WarpGridProvider provider, int index) => + provider.warpGrid!.destinationPosition(index); + +class _WarpedComponent() extends Component implements WarpGridProvider { + @override + WarpGrid? warpGrid; +} + +class _WarpedSprite({super.sprite}) extends SpriteComponent with HasWarpGrid; diff --git a/packages/flame/test/sprite_warp/sprite_component_warp_golden_test.dart b/packages/flame/test/sprite_warp/sprite_component_warp_golden_test.dart new file mode 100644 index 00000000000..1c399153383 --- /dev/null +++ b/packages/flame/test/sprite_warp/sprite_component_warp_golden_test.dart @@ -0,0 +1,121 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A 64x64 checkerboard with dark grid lines, a red top-left corner and a +/// blue bottom-right corner. +Future _checkerboard() { + const size = 64; + final pixels = Uint8List(size * size * 4); + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final i = 4 * (y * size + x); + final (r, g, b) = switch ((x, y)) { + _ when x < 8 && y < 8 => (255, 0, 0), + _ when x >= size - 8 && y >= size - 8 => (0, 0, 255), + _ when x % 8 == 0 || y % 8 == 0 => (0, 0, 0), + _ when (x ~/ 8 + y ~/ 8).isEven => (255, 255, 255), + _ => (176, 208, 240), + }; + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = 255; + } + } + return ImageExtension.fromPixels(pixels, size, size); +} + +WarpGrid _centerPull() { + final grid = WarpGrid.identity(columns: 2, rows: 2); + return grid.replacingDestinationPositions( + grid.destinationPositions..[grid.vertexIndex(1, 1)] = Vector2(0.7, 0.3), + ); +} + +WarpGrid _wave() { + final grid = WarpGrid.identity(columns: 4, rows: 4); + return grid.replacingDestinationPositions([ + for (final p in grid.destinationPositions) + p + Vector2(0.08 * sin(2 * pi * p.y), 0), + ]); +} + +WarpGrid _inflate() { + final grid = WarpGrid.identity(columns: 2, rows: 2); + final positions = grid.destinationPositions; + positions[grid.vertexIndex(1, 0)].y -= 0.15; + positions[grid.vertexIndex(1, 2)].y += 0.15; + positions[grid.vertexIndex(0, 1)].x -= 0.15; + positions[grid.vertexIndex(2, 1)].x += 0.15; + return grid.replacingDestinationPositions(positions); +} + +class _WarpedSprite({ + super.sprite, + super.position, + super.size, +}) extends SpriteComponent with HasWarpGrid; + +_WarpedSprite _panel( + Image image, + int index, + WarpGrid grid, { + WarpInterpolation interpolation = WarpInterpolation.bilinear, +}) { + return _WarpedSprite( + sprite: Sprite(image), + position: Vector2(30 + index * 160.0, 30), + size: Vector2.all(120), + ) + ..warpGrid = grid + ..warpInterpolation = interpolation; +} + +void main() { + group('SpriteComponent warping', () { + testGolden( + 'bilinear and Catmull-Rom interpolation', + (game, tester) async { + final image = await _checkerboard(); + game.world.addAll([ + _panel(image, 0, _centerPull()), + _panel( + image, + 1, + _centerPull(), + interpolation: WarpInterpolation.catmullRom, + ), + _panel( + image, + 2, + _wave(), + interpolation: WarpInterpolation.catmullRom, + ), + ]); + game.camera.viewfinder.anchor = Anchor.topLeft; + }, + size: Vector2(480, 180), + goldenFile: '../_goldens/sprite_component_warp_interpolation.png', + ); + + testGolden( + 'paint effects', + (game, tester) async { + final image = await _checkerboard(); + game.world.addAll([ + _panel(image, 0, _inflate())..opacity = 0.5, + _panel(image, 1, _inflate())..tint(const Color(0x8000FF00)), + _panel(image, 2, _inflate())..bleed = 10, + ]); + game.camera.viewfinder.anchor = Anchor.topLeft; + }, + size: Vector2(480, 180), + goldenFile: '../_goldens/sprite_component_warp_paint.png', + ); + }); +} diff --git a/packages/flame/test/sprite_warp/sprite_component_warp_test.dart b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart new file mode 100644 index 00000000000..d1e90b17568 --- /dev/null +++ b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart @@ -0,0 +1,394 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/cache.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _canvasWidth = 80; +const _canvasHeight = 48; + +/// A 32x16 image where every pixel has a different color. +Future _gradientImage() { + const width = 32; + const height = 16; + final pixels = Uint8List(width * height * 4); + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final i = 4 * (y * width + x); + pixels[i] = x * 8; + pixels[i + 1] = y * 16; + pixels[i + 2] = 255 - x * 4; + pixels[i + 3] = 255; + } + } + return ImageExtension.fromPixels(pixels, width, height); +} + +Future _render(List components) async { + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + for (final component in components) { + component.renderTree(canvas); + } + final image = await recorder.endRecording().toImage( + _canvasWidth, + _canvasHeight, + ); + return await image.pixelsInUint8(); +} + +int _maxDifference(Uint8List a, Uint8List b) { + var result = 0; + for (var i = 0; i < a.length; i++) { + final difference = (a[i] - b[i]).abs(); + if (difference > result) { + result = difference; + } + } + return result; +} + +Future _expectSameRendering( + Component actual, + Component expected, +) async { + final difference = _maxDifference( + await _render([actual]), + await _render([expected]), + ); + expect(difference, lessThanOrEqualTo(1)); +} + +class _WarpedSprite({ + super.sprite, + super.position, + super.size, + super.bleed, + super.paint, +}) extends SpriteComponent with HasWarpGrid; + +class _WarpedRasterSprite({ + required super.baseSprite, + super.images, + super.position, + super.size, +}) extends RasterSpriteComponent with HasWarpGrid; + +_WarpedSprite _component( + Sprite sprite, { + WarpGrid? warpGrid, + WarpInterpolation warpInterpolation = WarpInterpolation.bilinear, + Vector2? position, + double? bleed, + Paint? paint, +}) { + return _WarpedSprite( + sprite: sprite, + position: position ?? Vector2(4, 8), + size: Vector2(64, 32), + bleed: bleed, + paint: paint, + ) + ..warpGrid = warpGrid + ..warpInterpolation = warpInterpolation; +} + +/// A 2x2 grid with the center vertex moved towards the top-right. +WarpGrid _centerPull() { + final grid = WarpGrid.identity(columns: 2, rows: 2); + return grid.replacingDestinationPositions( + grid.destinationPositions..[grid.vertexIndex(1, 1)] = Vector2(0.7, 0.3), + ); +} + +Future main() async { + final image = await _gradientImage(); + + group('SpriteComponent warping', () { + for (final interpolation in WarpInterpolation.values) { + group(interpolation.name, () { + test('identity grids render like the unwarped sprite', () async { + final sprite = Sprite(image); + for (final grid in [ + WarpGrid.identity(), + WarpGrid.identity(columns: 3, rows: 2), + ]) { + await _expectSameRendering( + _component( + sprite, + warpGrid: grid, + warpInterpolation: interpolation, + ), + _component(sprite), + ); + } + }); + + test('identity grid on a sprite from a spritesheet', () async { + final sprite = Sprite( + image, + srcPosition: Vector2(8, 4), + srcSize: Vector2(16, 8), + ); + await _expectSameRendering( + _component( + sprite, + warpGrid: WarpGrid.identity(columns: 2, rows: 2), + warpInterpolation: interpolation, + ), + _component(sprite), + ); + }); + }); + } + + test('identity grid with bleed', () async { + final sprite = Sprite(image); + await _expectSameRendering( + _component(sprite, warpGrid: WarpGrid.identity(), bleed: 16), + _component(sprite, bleed: 16), + ); + }); + + test('identity grid at non-integer scales', () async { + // With these scales some pixels sample the image exactly between two + // texels, which every triangle of the mesh must round like + // drawImageRect does. + final sprite = Sprite(image); + for (final bleed in [2.0, 6.0]) { + await _expectSameRendering( + _component( + sprite, + warpGrid: WarpGrid.identity(columns: 3, rows: 2), + bleed: bleed, + ), + _component(sprite, bleed: bleed), + ); + } + }); + + test('identity grid with opacity', () async { + final sprite = Sprite(image); + final warped = _component(sprite, warpGrid: WarpGrid.identity()) + ..opacity = 0.5; + final unwarped = _component(sprite)..opacity = 0.5; + await _expectSameRendering(warped, unwarped); + }); + + test('identity grid with a color filter', () async { + final sprite = Sprite(image); + final warped = _component(sprite, warpGrid: WarpGrid.identity()) + ..tint(const Color(0x8000FF00)); + final unwarped = _component(sprite)..tint(const Color(0x8000FF00)); + await _expectSameRendering(warped, unwarped); + }); + + test('destination positions are relative to the size', () async { + final sprite = Sprite(image); + final grid = WarpGrid.identity(); + final shifted = grid.replacingDestinationPositions([ + for (final p in grid.destinationPositions) p + Vector2(0.125, 0.25), + ]); + await _expectSameRendering( + _component(sprite, warpGrid: shifted), + _component(sprite, position: Vector2(4 + 8, 8 + 8)), + ); + }); + + test('source positions are relative to the sprite', () async { + final sprite = Sprite(image, srcSize: Vector2(16, 16)); + final grid = WarpGrid.identity(); + final rightHalf = grid.replacingSourcePositions([ + for (final p in grid.sourcePositions) Vector2(0.5 + p.x / 2, p.y), + ]); + await _expectSameRendering( + _component(sprite, warpGrid: rightHalf), + _component( + Sprite(image, srcPosition: Vector2(8, 0), srcSize: Vector2(8, 16)), + ), + ); + }); + + test('components sharing a sprite are warped independently', () async { + final sprite = Sprite(image); + final src = sprite.src; + final unwarped = await _render([_component(sprite)]); + final pulled = await _render([ + _component(sprite, warpGrid: _centerPull()), + ]); + final smooth = await _render([ + _component( + sprite, + warpGrid: _centerPull(), + warpInterpolation: WarpInterpolation.catmullRom, + ), + ]); + + expect(_maxDifference(pulled, unwarped), greaterThan(16)); + expect(_maxDifference(smooth, pulled), greaterThan(16)); + expect(sprite.src, src); + expect(sprite.image.debugDisposed, isFalse); + }); + + test('caches the mesh and vertices', () async { + final sprite = Sprite(image); + final component = _component(sprite, warpGrid: _centerPull()); + + await _render([component]); + await _render([component]); + final renderer = component.warpRenderer!; + expect(renderer.meshBuilds, 1); + expect(renderer.verticesBuilds, 1); + + component.size = Vector2(60, 30); + await _render([component]); + expect(renderer.meshBuilds, 1); + expect(renderer.verticesBuilds, 2); + + sprite.srcSize = Vector2(16, 16); + await _render([component]); + expect(renderer.meshBuilds, 1); + expect(renderer.verticesBuilds, 3); + + component.warpInterpolation = WarpInterpolation.catmullRom; + await _render([component]); + expect(renderer.meshBuilds, 2); + expect(renderer.verticesBuilds, 4); + + // An equal but different grid is a new grid. + component.warpGrid = _centerPull(); + await _render([component]); + expect(renderer.meshBuilds, 3); + expect(renderer.verticesBuilds, 5); + }); + + test('renders like a plain SpriteComponent without a grid', () async { + final sprite = Sprite(image); + await _expectSameRendering( + _component(sprite, bleed: 3), + SpriteComponent( + sprite: sprite, + position: Vector2(4, 8), + size: Vector2(64, 32), + bleed: 3, + ), + ); + }); + + testWithFlameGame('works on SpriteComponent subclasses', (game) async { + // Separate caches, since rasterizing the same sprite concurrently into + // the same cache would dispose one of the two rasterized images. + final baseSprite = Sprite(image); + final warped = _WarpedRasterSprite( + baseSprite: baseSprite, + images: Images(), + position: Vector2(4, 8), + size: Vector2(64, 32), + )..warpGrid = WarpGrid.identity(columns: 3, rows: 2); + final plain = RasterSpriteComponent( + baseSprite: baseSprite, + images: Images(), + position: Vector2(4, 8), + size: Vector2(64, 32), + ); + game.world.addAll([warped, plain]); + await game.ready(); + + expect(warped.sprite!.image, isNot(baseSprite.image)); + await _expectSameRendering(warped, plain); + expect(warped.warpRenderer, isNotNull); + + warped.warpGrid = _centerPull(); + final difference = _maxDifference( + await _render([warped]), + await _render([plain]), + ); + expect(difference, greaterThan(16)); + }); + + test('drops the renderer when the grid is removed', () async { + final sprite = Sprite(image); + final component = _component(sprite, warpGrid: _centerPull()); + + await _render([component]); + expect(component.warpRenderer, isNotNull); + + component.warpGrid = null; + await _expectSameRendering(component, _component(sprite)); + expect(component.warpRenderer, isNull); + }); + + testWithFlameGame('drops the renderer on removal', (game) async { + final sprite = Sprite(image); + final component = _component(sprite, warpGrid: _centerPull()); + game.world.add(component); + await game.ready(); + + await _render([component]); + expect(component.warpRenderer, isNotNull); + + component.removeFromParent(); + await game.ready(); + expect(component.warpRenderer, isNull); + expect(sprite.image.debugDisposed, isFalse); + }); + + group('with image eviction', () { + Future evictingCache() async { + return Images() + ..gracePeriod = Duration.zero + ..add('a', await _gradientImage()) + ..add('b', await _gradientImage()); + } + + testWithFlameGame('mounted images are not evicted', (game) async { + final images = await evictingCache(); + final component = _component( + Sprite(images.fromCache('a')), + warpGrid: _centerPull(), + ); + game.world.add(component); + await game.ready(); + await _render([component]); + + images.evictUnused(); + expect(images.containsKey('a'), isTrue); + expect(images.containsKey('b'), isFalse); + await _render([component]); + + component.removeFromParent(); + await game.ready(); + expect(component.warpRenderer, isNull); + images.evictUnused(); + expect(images.containsKey('a'), isFalse); + }); + + testWithFlameGame( + 'changing the sprite drops the renderer of the old image', + (game) async { + final images = await evictingCache(); + final a = images.fromCache('a'); + final component = _component(Sprite(a), warpGrid: _centerPull()); + game.world.add(component); + await game.ready(); + await _render([component]); + expect(component.warpRenderer, isNotNull); + + component.sprite = Sprite(images.fromCache('b')); + expect(component.warpRenderer, isNull); + images.evictUnused(); + expect(a.debugDisposed, isTrue); + expect(images.containsKey('b'), isTrue); + + await _expectSameRendering( + component, + _component(component.sprite!, warpGrid: _centerPull()), + ); + }, + ); + }); + }); +} diff --git a/packages/flame/test/sprite_warp/warp_grid_test.dart b/packages/flame/test/sprite_warp/warp_grid_test.dart new file mode 100644 index 00000000000..9d740447e1a --- /dev/null +++ b/packages/flame/test/sprite_warp/warp_grid_test.dart @@ -0,0 +1,196 @@ +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('WarpGrid', () { + test('identity grid has regularly spaced positions', () { + final grid = WarpGrid.identity(columns: 2, rows: 4); + + expect(grid.columns, 2); + expect(grid.rows, 4); + expect(grid.vertexCount, 15); + for (var row = 0; row <= 4; row++) { + for (var column = 0; column <= 2; column++) { + final index = grid.vertexIndex(column, row); + final expected = Vector2(column / 2, row / 4); + expect(grid.sourcePosition(index), expected); + expect(grid.destinationPosition(index), expected); + } + } + }); + + test('default identity grid is 1x1', () { + final grid = WarpGrid.identity(); + + expect(grid.vertexCount, 4); + expect(grid.destinationPositions, [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(0, 1), + Vector2(1, 1), + ]); + }); + + test('vertices are in row-major order from the top-left', () { + final grid = WarpGrid.identity(columns: 3, rows: 2); + + expect(grid.vertexIndex(0, 0), 0); + expect(grid.vertexIndex(3, 0), 3); + expect(grid.vertexIndex(0, 1), 4); + expect(grid.vertexIndex(3, 2), 11); + expect(grid.sourcePosition(grid.vertexIndex(3, 0)), Vector2(1, 0)); + expect(grid.sourcePosition(grid.vertexIndex(0, 2)), Vector2(0, 1)); + }); + + test('uses the given positions', () { + final source = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(0, 1), + Vector2(1, 1), + ]; + final destination = [ + Vector2(-0.5, 0), + Vector2(1, -0.25), + Vector2(0, 1), + Vector2(1.5, 2), + ]; + final grid = WarpGrid( + columns: 1, + rows: 1, + sourcePositions: source, + destinationPositions: destination, + ); + + expect(grid.sourcePositions, source); + expect(grid.destinationPositions, destination); + }); + + test('omitted positions default to identity', () { + final destination = [ + Vector2(0, 0), + Vector2(1.25, 0), + Vector2(0, 1), + Vector2(1, 1), + ]; + final grid = WarpGrid( + columns: 1, + rows: 1, + destinationPositions: destination, + ); + + expect(grid.sourcePositions, WarpGrid.identity().sourcePositions); + expect(grid.destinationPositions, destination); + }); + + test('is not affected by later changes to the given positions', () { + final destination = WarpGrid.identity().destinationPositions; + final grid = WarpGrid( + columns: 1, + rows: 1, + destinationPositions: destination, + ); + + destination[0].setValues(0.5, 0.5); + grid.destinationPosition(1).setValues(0.5, 0.5); + grid.destinationPositions[2].setValues(0.5, 0.5); + + expect(grid, WarpGrid.identity()); + }); + + test('replacing positions returns a modified copy', () { + final grid = WarpGrid.identity(columns: 2, rows: 2); + final center = grid.vertexIndex(1, 1); + + final destination = grid.destinationPositions; + destination[center].setValues(0.7, 0.3); + final warped = grid.replacingDestinationPositions(destination); + + expect(warped.destinationPosition(center), Vector2(0.7, 0.3)); + expect(warped.sourcePositions, grid.sourcePositions); + expect(grid.destinationPosition(center), Vector2(0.5, 0.5)); + + final source = grid.sourcePositions; + source[center].setValues(0.25, 0.25); + final shifted = warped.replacingSourcePositions(source); + + expect(shifted.sourcePosition(center), Vector2(0.25, 0.25)); + expect(shifted.destinationPositions, warped.destinationPositions); + expect(warped.sourcePosition(center), Vector2(0.5, 0.5)); + }); + + test('compares by value', () { + final a = WarpGrid.identity(columns: 2, rows: 3); + final b = WarpGrid(columns: 2, rows: 3); + final c = WarpGrid.identity(columns: 3, rows: 2); + final d = a.replacingDestinationPositions( + a.destinationPositions..[0] = Vector2(-0.1, 0), + ); + + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(c)); + expect(a, isNot(d)); + expect( + d, + a.replacingDestinationPositions(d.destinationPositions), + ); + }); + + test('fails on invalid dimensions', () { + expect(() => WarpGrid(columns: 0, rows: 1), throwsArgumentError); + expect(() => WarpGrid(columns: 1, rows: -1), throwsArgumentError); + expect( + () => WarpGrid.identity(columns: -1, rows: -1), + throwsArgumentError, + ); + // The rendered mesh must fit 16-bit indices. + expect(WarpGrid.identity(columns: 63, rows: 63).vertexCount, 4096); + expect(() => WarpGrid(columns: 64, rows: 64), throwsArgumentError); + expect( + () => WarpGrid.identity(columns: 4000), + throwsArgumentError, + ); + }); + + test('fails on wrong number of positions', () { + final grid = WarpGrid.identity(columns: 2, rows: 2); + + expect( + () => WarpGrid( + columns: 1, + rows: 1, + sourcePositions: grid.sourcePositions, + ), + throwsArgumentError, + ); + expect( + () => WarpGrid( + columns: 2, + rows: 2, + destinationPositions: [Vector2.zero()], + ), + throwsArgumentError, + ); + expect( + () => grid.replacingSourcePositions( + [...grid.sourcePositions, Vector2.zero()], + ), + throwsArgumentError, + ); + expect( + () => grid.replacingDestinationPositions([Vector2.zero()]), + throwsArgumentError, + ); + }); + + test('fails on invalid vertex coordinates', () { + final grid = WarpGrid.identity(columns: 2, rows: 2); + + expect(() => grid.vertexIndex(3, 0), failsAssert()); + expect(() => grid.vertexIndex(0, -1), failsAssert()); + expect(() => grid.sourcePosition(9), failsAssert()); + }); + }); +} diff --git a/packages/flame/test/sprite_warp/warp_mesh_test.dart b/packages/flame/test/sprite_warp/warp_mesh_test.dart new file mode 100644 index 00000000000..f2df8107621 --- /dev/null +++ b/packages/flame/test/sprite_warp/warp_mesh_test.dart @@ -0,0 +1,194 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flame/components.dart'; +import 'package:flame/src/sprite_warp/warp_mesh.dart'; +import 'package:test/test.dart'; + +/// A 3x2 grid with every vertex displaced, including the border ones. +WarpGrid _warpedGrid() { + final grid = WarpGrid.identity(columns: 3, rows: 2); + final random = Random(42); + return grid.replacingDestinationPositions([ + for (final p in grid.destinationPositions) + p + + Vector2( + random.nextDouble() * 0.2 - 0.1, + random.nextDouble() * 0.2 - 0.1, + ), + ]); +} + +Vector2 _at(Float32List values, int index) => + Vector2(values[2 * index], values[2 * index + 1]); + +Matcher _closeToVector(Vector2 expected) => predicate( + (v) => (v - expected).length < 1e-6, + 'close to $expected', +); + +void main() { + group('WarpMesh', () { + for (final interpolation in WarpInterpolation.values) { + group(interpolation.name, () { + test('has 4x4 sub-cells per cell, 2 triangles each', () { + final mesh = WarpMesh.fromGrid( + WarpGrid.identity(columns: 2, rows: 3), + interpolation, + ); + + expect(mesh.vertexCount, 9 * 13); + expect(mesh.textureCoordinates.length, 2 * 9 * 13); + expect(mesh.indices.length, 3 * 2 * 8 * 12); + expect(mesh.indices.reduce(max), mesh.vertexCount - 1); + }); + + test('passes through the grid vertices', () { + final grid = _warpedGrid(); + final mesh = WarpMesh.fromGrid(grid, interpolation); + const meshColumns = 3 * 4 + 1; + + for (var row = 0; row <= grid.rows; row++) { + for (var column = 0; column <= grid.columns; column++) { + final index = grid.vertexIndex(column, row); + final meshIndex = 4 * row * meshColumns + 4 * column; + expect( + _at(mesh.positions, meshIndex), + _closeToVector(grid.destinationPosition(index)), + ); + expect( + _at(mesh.textureCoordinates, meshIndex), + _closeToVector(grid.sourcePosition(index)), + ); + } + } + }); + + test('keeps an identity grid undistorted', () { + final mesh = WarpMesh.fromGrid( + WarpGrid.identity(columns: 3, rows: 5), + interpolation, + ); + const meshColumns = 3 * 4 + 1; + const meshRows = 5 * 4 + 1; + + for (var row = 0; row < meshRows; row++) { + for (var column = 0; column < meshColumns; column++) { + final index = row * meshColumns + column; + final expected = Vector2( + column / (meshColumns - 1), + row / (meshRows - 1), + ); + expect(_at(mesh.positions, index), _closeToVector(expected)); + expect( + _at(mesh.textureCoordinates, index), + _closeToVector(expected), + ); + } + } + }); + + test('subdivision level 0 uses the grid vertices only', () { + final grid = _warpedGrid(); + final mesh = WarpMesh.fromGrid( + grid, + interpolation, + subdivisionLevels: 0, + ); + + expect(mesh.vertexCount, grid.vertexCount); + for (var i = 0; i < grid.vertexCount; i++) { + expect( + _at(mesh.positions, i), + _closeToVector(grid.destinationPosition(i)), + ); + } + expect(mesh.indices.length, 6 * grid.columns * grid.rows); + }); + + test('bounds contain all the positions', () { + final mesh = WarpMesh.fromGrid(_warpedGrid(), interpolation); + final xs = [ + for (var i = 0; i < mesh.vertexCount; i++) mesh.positions[2 * i], + ]; + final ys = [ + for (var i = 0; i < mesh.vertexCount; i++) + mesh.positions[2 * i + 1], + ]; + + expect(mesh.bounds.left, closeTo(xs.reduce(min), 1e-6)); + expect(mesh.bounds.right, closeTo(xs.reduce(max), 1e-6)); + expect(mesh.bounds.top, closeTo(ys.reduce(min), 1e-6)); + expect(mesh.bounds.bottom, closeTo(ys.reduce(max), 1e-6)); + }); + }); + } + + test('bilinear interpolation within a cell', () { + final grid = WarpGrid.identity().replacingDestinationPositions([ + Vector2(0, 0), + Vector2(1.2, -0.2), + Vector2(0, 1), + Vector2(1, 1), + ]); + final mesh = WarpMesh.fromGrid(grid, WarpInterpolation.bilinear); + + // The center of a cell is the average of its corners. + expect( + _at(mesh.positions, 2 * 5 + 2), + _closeToVector(Vector2(0.55, 0.45)), + ); + // Edges are straight: the middle of the top edge is its midpoint. + expect(_at(mesh.positions, 2), _closeToVector(Vector2(0.6, -0.1))); + }); + + test('Catmull-Rom on a single cell is bilinear', () { + final grid = WarpGrid.identity().replacingDestinationPositions([ + Vector2(0.1, 0), + Vector2(1.2, -0.2), + Vector2(-0.1, 0.9), + Vector2(1, 1.3), + ]); + final bilinear = WarpMesh.fromGrid(grid, WarpInterpolation.bilinear); + final catmullRom = WarpMesh.fromGrid(grid, WarpInterpolation.catmullRom); + + for (var i = 0; i < bilinear.positions.length; i++) { + expect(catmullRom.positions[i], closeTo(bilinear.positions[i], 1e-6)); + } + }); + + test('Catmull-Rom is smooth across cells, bilinear is not', () { + // A zigzag along a 4x1 grid: odd columns are moved down. + final grid = WarpGrid.identity(columns: 4).replacingDestinationPositions( + [ + for (final p in WarpGrid.identity(columns: 4).destinationPositions) + p + Vector2(0, (p.x * 4).round().isOdd ? 0.2 : 0), + ], + ); + + // Jump in slope of the top edge at the interior grid vertex in column + // 2, between its left and right sides, using a finely subdivided mesh + // so that the one-sided slopes approximate the derivatives. + double slopeJump(WarpInterpolation interpolation) { + const levels = 6; + const subdivisions = 1 << levels; + final mesh = WarpMesh.fromGrid( + grid, + interpolation, + subdivisionLevels: levels, + ); + double y(int column) => mesh.positions[2 * column + 1]; + const vertex = 2 * subdivisions; + const h = 1 / (4 * subdivisions); + final right = (y(vertex + 1) - y(vertex)) / h; + final left = (y(vertex) - y(vertex - 1)) / h; + return (right - left).abs(); + } + + // Bilinear edges go straight from 0.2 down to 0 and back up, over 1/4 + // of the width: the slope jumps from -0.8 to 0.8. + expect(slopeJump(WarpInterpolation.bilinear), closeTo(1.6, 1e-3)); + expect(slopeJump(WarpInterpolation.catmullRom), lessThan(0.2)); + }); + }); +}