From 91751981ac9adf06b00571ef085b4a6037e6f7bc Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 00:46:34 +0200 Subject: [PATCH 01/21] feat: Add WarpGrid to describe sprite warping Add an immutable WarpGrid (columns x rows cells, normalized source and destination positions, row-major from the top-left) and the WarpInterpolation enum, as the first building block for warping SpriteComponent rendering in the style of SpriteKit's SKWarpGeometryGrid. --- packages/flame/lib/components.dart | 1 + .../flame/lib/src/sprite_warp/warp_grid.dart | 220 ++++++++++++++++++ .../test/sprite_warp/warp_grid_test.dart | 174 ++++++++++++++ 3 files changed, 395 insertions(+) create mode 100644 packages/flame/lib/src/sprite_warp/warp_grid.dart create mode 100644 packages/flame/test/sprite_warp/warp_grid_test.dart diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 8e314bb52c5..9dd69ab8f59 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -63,5 +63,6 @@ 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/warp_grid.dart' show WarpGrid, WarpInterpolation; export 'src/text/renderers/text_paint.dart'; export 'src/timer.dart'; 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..dcdb8c6053f --- /dev/null +++ b/packages/flame/lib/src/sprite_warp/warp_grid.dart @@ -0,0 +1,220 @@ +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 WarpGrid { + WarpGrid({ + required this.columns, + required this.rows, + List? sourcePositions, + List? destinationPositions, + }) : assert(columns > 0 && rows > 0, 'Columns and rows must be positive'), + assert( + (columns * (1 << warpSubdivisionLevels) + 1) * + (rows * (1 << warpSubdivisionLevels) + 1) <= + _maxMeshVertices, + 'Too many columns and rows: $columns x $rows', + ), + _source = _positionsOrIdentity(sourcePositions, columns, rows), + _destination = _positionsOrIdentity(destinationPositions, columns, rows); + + /// Creates a grid that renders the sprite without distortion. + WarpGrid.identity({int columns = 1, int rows = 1}) + : this(columns: columns, rows: rows); + + const WarpGrid._(this.columns, this.rows, this._source, this._destination); + + /// 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; + + /// 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]. + WarpGrid replacingSourcePositions(List positions) { + return WarpGrid._( + columns, + rows, + _fromVectors(positions, vertexCount), + _destination, + ); + } + + /// Returns a copy of this grid with the given destination [positions]. + WarpGrid replacingDestinationPositions(List positions) { + return WarpGrid._( + 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) { + assert( + positions.length == vertexCount, + '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/test/sprite_warp/warp_grid_test.dart b/packages/flame/test/sprite_warp/warp_grid_test.dart new file mode 100644 index 00000000000..55e740994d2 --- /dev/null +++ b/packages/flame/test/sprite_warp/warp_grid_test.dart @@ -0,0 +1,174 @@ +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), failsAssert()); + expect(() => WarpGrid(columns: 1, rows: -1), failsAssert()); + // The rendered mesh must fit 16-bit indices. + expect(WarpGrid.identity(columns: 63, rows: 63).vertexCount, 4096); + expect(() => WarpGrid(columns: 64, rows: 64), failsAssert()); + }); + + test('fails on wrong number of positions', () { + final grid = WarpGrid.identity(columns: 2, rows: 2); + + expect( + () => WarpGrid( + columns: 1, + rows: 1, + sourcePositions: grid.sourcePositions, + ), + failsAssert(), + ); + expect( + () => grid.replacingDestinationPositions([Vector2.zero()]), + failsAssert(), + ); + }); + + 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()); + }); + }); +} From 464aeaf59fd41e3f902a4cd2f0ed93ff4d14905e Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 00:51:45 +0200 Subject: [PATCH 02/21] feat: Add mesh builder for WarpGrid Build the triangle mesh used to render a warped sprite: each grid cell is subdivided into 4x4 sub-cells, with positions and texture coordinates interpolated either bilinearly (matching SpriteKit) or with Catmull-Rom splines, through a single tensor-product path over a linearly extrapolated grid border. --- .../flame/lib/src/sprite_warp/warp_mesh.dart | 225 ++++++++++++++++++ .../test/sprite_warp/warp_mesh_test.dart | 194 +++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 packages/flame/lib/src/sprite_warp/warp_mesh.dart create mode 100644 packages/flame/test/sprite_warp/warp_mesh_test.dart 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/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)); + }); + }); +} From 52ff0f162a660674d9733c4f51964f204f46b828 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 01:55:49 +0200 Subject: [PATCH 03/21] feat: Support warp grids in SpriteComponent Add warpGrid and warpInterpolation to SpriteComponent. When a grid is set, the sprite is drawn as a cached textured mesh through drawVertices, working around Impeller ignoring the paint's color filter (applied via a layer) and crashing on some vertices blend modes (always srcOver). The image shader is never disposed, since on CanvasKit that also disposes the sprite's shared image. --- .../lib/src/components/sprite_component.dart | 65 ++++- .../src/sprite_warp/sprite_warp_renderer.dart | 178 ++++++++++++ .../sprite_component_warp_test.dart | 265 ++++++++++++++++++ 3 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart create mode 100644 packages/flame/test/sprite_warp/sprite_component_warp_test.dart diff --git a/packages/flame/lib/src/components/sprite_component.dart b/packages/flame/lib/src/components/sprite_component.dart index 9b08e74007f..12eb2fb4aaf 100644 --- a/packages/flame/lib/src/components/sprite_component.dart +++ b/packages/flame/lib/src/components/sprite_component.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:flame/components.dart'; +import 'package:flame/src/sprite_warp/sprite_warp_renderer.dart'; import 'package:meta/meta.dart'; export '../sprite.dart'; @@ -24,6 +25,17 @@ class SpriteComponent({ super.children, super.priority, var double? bleed, + + /// The grid used to warp (distort) the [sprite], or `null` to render it + /// undistorted. + /// + /// The grid's destination positions are relative to the component's [size]. + /// Only the visual rendering is affected: [size], hit testing and + /// collisions are not. + var WarpGrid? warpGrid, + + /// How [warpGrid] is interpolated between its vertices. + var WarpInterpolation warpInterpolation = WarpInterpolation.bilinear, super.key, }) extends PositionComponent with HasPaint, ImageRetainer { /// When set to true, the component is auto-resized to match the @@ -62,6 +74,8 @@ class SpriteComponent({ int? priority, ComponentKey? key, double? bleed, + WarpGrid? warpGrid, + WarpInterpolation warpInterpolation = WarpInterpolation.bilinear, }) : this( sprite: Sprite( image, @@ -79,9 +93,17 @@ class SpriteComponent({ children: children, priority: priority, bleed: bleed, + warpGrid: warpGrid, + warpInterpolation: warpInterpolation, key: key, ); + SpriteWarpRenderer? _warpRenderer; + + /// The renderer used while [warpGrid] is set. + @visibleForTesting + SpriteWarpRenderer? get warpRenderer => _warpRenderer; + /// Returns current value of auto resize flag. bool get autoResize => _autoResize; @@ -123,15 +145,46 @@ class SpriteComponent({ super.onMount(); } + @override + @mustCallSuper + void onRemove() { + _disposeWarpRenderer(); + super.onRemove(); + } + @mustCallSuper @override void render(Canvas canvas) { - sprite?.render( - canvas, - size: size, - overridePaint: paint, - bleed: bleed, - ); + final sprite = _sprite; + if (sprite == null) { + return; + } + final warpGrid = this.warpGrid; + if (warpGrid == null) { + _disposeWarpRenderer(); + sprite.render( + canvas, + size: size, + overridePaint: paint, + bleed: bleed, + ); + } else { + (_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; } /// Updates the size [sprite]'s srcSize if [autoResize] is true. 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..8aeee63d76e --- /dev/null +++ b/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart @@ -0,0 +1,178 @@ +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, // +]); + +/// 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; + textureCoordinates[k + 1] = + src.top + mesh.textureCoordinates[k + 1] * src.height; + } + _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/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..e17acd9fdc4 --- /dev/null +++ b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart @@ -0,0 +1,265 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +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)); +} + +SpriteComponent _component( + Sprite sprite, { + WarpGrid? warpGrid, + WarpInterpolation warpInterpolation = WarpInterpolation.bilinear, + Vector2? position, + double? bleed, + Paint? paint, +}) { + return SpriteComponent( + sprite: sprite, + position: position ?? Vector2(4, 8), + size: Vector2(64, 32), + warpGrid: warpGrid, + warpInterpolation: warpInterpolation, + bleed: bleed, + paint: paint, + ); +} + +/// 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 { + // The bleed keeps integer scales (96 / 32 and 64 / 16), so that no + // pixel samples the image exactly between two texels, where + // drawImageRect and drawVertices may round differently. + final sprite = Sprite(image); + await _expectSameRendering( + _component(sprite, warpGrid: WarpGrid.identity(), bleed: 16), + _component(sprite, bleed: 16), + ); + }); + + 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('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); + }); + }); +} From 2e6504d2da8bbcbf3a03b2bb3910095bd3765497 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 02:20:37 +0200 Subject: [PATCH 04/21] test: Add golden and eviction tests for warped SpriteComponent Add golden tests for bilinear and Catmull-Rom warping and for paint effects on warped sprites, and tests for the interaction with image eviction. Drop the warp renderer when the sprite changes, so that its image shader does not keep the previous image alive after it is released. --- .../lib/src/components/sprite_component.dart | 3 + .../sprite_component_warp_interpolation.png | Bin 0 -> 5305 bytes .../_goldens/sprite_component_warp_paint.png | Bin 0 -> 6824 bytes .../sprite_component_warp_golden_test.dart | 115 ++++++++++++++++++ .../sprite_component_warp_test.dart | 56 +++++++++ 5 files changed, 174 insertions(+) create mode 100644 packages/flame/test/_goldens/sprite_component_warp_interpolation.png create mode 100644 packages/flame/test/_goldens/sprite_component_warp_paint.png create mode 100644 packages/flame/test/sprite_warp/sprite_component_warp_golden_test.dart diff --git a/packages/flame/lib/src/components/sprite_component.dart b/packages/flame/lib/src/components/sprite_component.dart index 12eb2fb4aaf..aef693668b5 100644 --- a/packages/flame/lib/src/components/sprite_component.dart +++ b/packages/flame/lib/src/components/sprite_component.dart @@ -126,6 +126,9 @@ class SpriteComponent({ set sprite(Sprite? value) { _sprite = value; _resizeToSprite(); + // The warp renderer holds an image shader, which would keep the previous + // image alive after it is released (and possibly evicted) below. + _disposeWarpRenderer(); updateRetainedImages(); } 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 0000000000000000000000000000000000000000..ee722a0571dddad5b7d32170db3c7039774bd8df GIT binary patch literal 5305 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5719774fad5effe0a9963d4719898e4bec7a3d50 GIT binary patch literal 6824 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 _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); +} + +SpriteComponent _panel( + Image image, + int index, + WarpGrid grid, { + WarpInterpolation interpolation = WarpInterpolation.bilinear, +}) { + return SpriteComponent( + 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 index e17acd9fdc4..c2401bcb2bc 100644 --- a/packages/flame/test/sprite_warp/sprite_component_warp_test.dart +++ b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart @@ -1,6 +1,7 @@ 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'; @@ -261,5 +262,60 @@ Future main() async { 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()), + ); + }, + ); + }); }); } From 81eb1100f9c21aad146b3a81d862bb1a184ccf5a Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 12:45:39 +0200 Subject: [PATCH 05/21] fix: Round texel ties consistently in warped sprites Offset the texture coordinates of warped sprites by -1/1024 texel, so that pixels sampling the image exactly between two texels (e.g. at non-integer scales with nearest filtering) round the same way on every triangle of the mesh, like drawImageRect does, instead of showing 1-texel steps along the triangles' edges. --- .../src/sprite_warp/sprite_warp_renderer.dart | 18 ++++++++++++++-- .../sprite_component_warp_interpolation.png | Bin 5305 -> 5275 bytes .../_goldens/sprite_component_warp_paint.png | Bin 6824 -> 6825 bytes .../sprite_component_warp_test.dart | 20 +++++++++++++++--- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart b/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart index 8aeee63d76e..8945099eea2 100644 --- a/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart +++ b/packages/flame/lib/src/sprite_warp/sprite_warp_renderer.dart @@ -13,6 +13,19 @@ final Float64List _identityMatrix = Float64List.fromList([ 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. /// @@ -155,9 +168,10 @@ class SpriteWarpRenderer() { 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; + textureCoordinates[k] = + src.left + mesh.textureCoordinates[k] * src.width + _texelOffset; textureCoordinates[k + 1] = - src.top + mesh.textureCoordinates[k + 1] * src.height; + src.top + mesh.textureCoordinates[k + 1] * src.height + _texelOffset; } _vertices?.dispose(); _vertices = Vertices.raw( diff --git a/packages/flame/test/_goldens/sprite_component_warp_interpolation.png b/packages/flame/test/_goldens/sprite_component_warp_interpolation.png index ee722a0571dddad5b7d32170db3c7039774bd8df..02369126b8ea8042497c4316c3d33da5b49379aa 100644 GIT binary patch literal 5275 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 literal 5305 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 diff --git a/packages/flame/test/_goldens/sprite_component_warp_paint.png b/packages/flame/test/_goldens/sprite_component_warp_paint.png index 5719774fad5effe0a9963d4719898e4bec7a3d50..36f91988c7462d101275ab86097dc5c98e8e00a1 100644 GIT binary patch delta 6770 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 diff --git a/packages/flame/test/sprite_warp/sprite_component_warp_test.dart b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart index c2401bcb2bc..3976a785bb5 100644 --- a/packages/flame/test/sprite_warp/sprite_component_warp_test.dart +++ b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart @@ -131,9 +131,6 @@ Future main() async { } test('identity grid with bleed', () async { - // The bleed keeps integer scales (96 / 32 and 64 / 16), so that no - // pixel samples the image exactly between two texels, where - // drawImageRect and drawVertices may round differently. final sprite = Sprite(image); await _expectSameRendering( _component(sprite, warpGrid: WarpGrid.identity(), bleed: 16), @@ -141,6 +138,23 @@ Future main() async { ); }); + 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()) From ec3ba1d1d7863b64e312916d3e44c3313fc41c39 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 14:03:41 +0200 Subject: [PATCH 06/21] docs: Add docs and example for warping SpriteComponent Document WarpGrid and WarpInterpolation in the SpriteComponent docs, and add a "Sprite Warp" example comparing bilinear and Catmull-Rom warping, with draggable grid vertices. --- .github/.cspell/people_usernames.txt | 2 + .github/.cspell/words_dictionary.txt | 3 + doc/flame/components/sprite_components.md | 64 ++++++++++ .../stories/sprites/sprite_warp_example.dart | 115 ++++++++++++++++++ examples/lib/stories/sprites/sprites.dart | 7 ++ 5 files changed, 191 insertions(+) create mode 100644 examples/lib/stories/sprites/sprite_warp_example.dart diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index bdf170b2099..e1c5a8c8ba5 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -1,6 +1,8 @@ # specific people's names and/or usernames akida # github.com/akida bdero # github.com/bdero +catmull # Catmull/Rom +Catmull # Catmull/Rom bluefireteam # github.com/bluefireteam erayzesen # erayzesen.itch.io erickzanardo # github.com/erickzanardo diff --git a/.github/.cspell/words_dictionary.txt b/.github/.cspell/words_dictionary.txt index 40745a7e6af..1df55213889 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -1,4 +1,6 @@ # actual english words (or common abbreviations) missing from CSpell +bilinear +bilinearly bloodlust collidable collidables @@ -30,3 +32,4 @@ thumbstick trackpad underutilize untinted +unwarped diff --git a/doc/flame/components/sprite_components.md b/doc/flame/components/sprite_components.md index d32a71c01d4..4486727b87c 100644 --- a/doc/flame/components/sprite_components.md +++ b/doc/flame/components/sprite_components.md @@ -37,6 +37,70 @@ class MyGame extends FlameGame { ``` +### Warping + +A `SpriteComponent` 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 +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 = SpriteComponent( + sprite: sprite, + size: Vector2.all(128), + warpGrid: grid.replacingDestinationPositions(positions), +); +``` + +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. + +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. + + ## SpriteAnimationComponent This class is used to represent a Component that has sprites that run in a single cyclic animation. 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..ee500f53178 --- /dev/null +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -0,0 +1,115 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:material_ui/material_ui.dart'; + +class SpriteWarpExample() extends FlameGame with DoubleTapCallbacks { + static const String description = ''' + In this example two `SpriteComponent`s share the same sprite and are + warped by the same `WarpGrid` of 4x4 cells, which has 5x5 = 25 vertices: + the left one interpolates the grid bilinearly (like SpriteKit does), the + right one with Catmull-Rom splines. + + Drag the 25 handles on the left sprite to move the vertices of the grid, + and double tap to reset it. + '''; + + static const int _columns = 4; + static const int _rows = 4; + + late final SpriteComponent _bilinear; + late final SpriteComponent _catmullRom; + final List<_Handle> _handles = []; + + @override + Future onLoad() async { + final sprite = await loadSprite('assets/images/flame.png'); + final scale = min( + size.x * 0.35 / sprite.srcSize.x, + size.y * 0.6 / sprite.srcSize.y, + ); + final spriteSize = sprite.srcSize * scale; + final grid = WarpGrid.identity(columns: _columns, rows: _rows); + + _bilinear = SpriteComponent( + sprite: sprite, + size: spriteSize, + position: Vector2(-spriteSize.x * 0.75, 0), + anchor: Anchor.center, + warpGrid: grid, + ); + _catmullRom = SpriteComponent( + sprite: sprite, + size: spriteSize, + position: Vector2(spriteSize.x * 0.75, 0), + anchor: Anchor.center, + warpGrid: grid, + warpInterpolation: WarpInterpolation.catmullRom, + ); + + for (final position in grid.destinationPositions) { + _handles.add( + _Handle( + position: position..multiply(spriteSize), + onMoved: _updateGrid, + ), + ); + } + _bilinear.addAll(_handles); + + world.addAll([ + _bilinear, + _catmullRom, + _label('Bilinear', _bilinear), + _label('Catmull-Rom', _catmullRom), + ]); + } + + TextComponent _label(String text, SpriteComponent component) { + return TextComponent( + text: text, + position: component.position + Vector2(0, component.size.y * 0.7), + anchor: Anchor.center, + ); + } + + void _updateGrid() { + final size = _bilinear.size; + final grid = _bilinear.warpGrid!.replacingDestinationPositions([ + for (final handle in _handles) handle.position.clone()..divide(size), + ]); + _bilinear.warpGrid = grid; + _catmullRom.warpGrid = grid; + } + + @override + void onDoubleTapDown(DoubleTapDownEvent event) { + final identity = WarpGrid.identity(columns: _columns, rows: _rows); + final positions = identity.destinationPositions; + for (var i = 0; i < _handles.length; i++) { + _handles[i].position = positions[i]..multiply(_bilinear.size); + } + _bilinear.warpGrid = identity; + _catmullRom.warpGrid = identity; + } +} + +class _Handle({ + required super.position, + required final void Function() onMoved, +}) extends CircleComponent with DragCallbacks { + this + : super( + radius: 8, + anchor: Anchor.center, + paint: Paint()..color = const Color(0xCCFF00FF), + ); + + @override + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; + onMoved(); + } +} diff --git a/examples/lib/stories/sprites/sprites.dart b/examples/lib/stories/sprites/sprites.dart index dc9780e93b0..a2e174436f9 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -7,6 +7,7 @@ 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/game.dart'; import 'package:widgetbook/widgetbook.dart'; @@ -56,6 +57,12 @@ WidgetbookComponent spritesStories() { codeLink: baseLink('sprites/sprite_group_example.dart'), info: SpriteGroupExample.description, ), + ExampleUseCase( + name: 'Sprite Warp', + builder: (_) => GameWidget(game: SpriteWarpExample()), + codeLink: baseLink('sprites/sprite_warp_example.dart'), + info: SpriteWarpExample.description, + ), ], ); } From d01b5234f21b677e1b97d090fd84373438a7e88c Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 14:37:36 +0200 Subject: [PATCH 07/21] feat: Show the warp grid and add a Grid Size knob to the Sprite Warp example Draw the grid vertices as stroked circles connected by line segments, and let the grid size be chosen between 3x3 and 8x8 cells with a knob. --- .../stories/sprites/sprite_warp_example.dart | 62 +++++++++++++------ examples/lib/stories/sprites/sprites.dart | 12 +++- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/examples/lib/stories/sprites/sprite_warp_example.dart b/examples/lib/stories/sprites/sprite_warp_example.dart index ee500f53178..3a0c9f865fb 100644 --- a/examples/lib/stories/sprites/sprite_warp_example.dart +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -5,19 +5,21 @@ import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:material_ui/material_ui.dart'; -class SpriteWarpExample() extends FlameGame with DoubleTapCallbacks { +class SpriteWarpExample({final int gridSize = 4}) + extends FlameGame + with DoubleTapCallbacks { static const String description = ''' In this example two `SpriteComponent`s share the same sprite and are - warped by the same `WarpGrid` of 4x4 cells, which has 5x5 = 25 vertices: - the left one interpolates the grid bilinearly (like SpriteKit does), the - right one with Catmull-Rom splines. + warped by the same `WarpGrid`: the left one interpolates the grid + bilinearly (like SpriteKit does), the right one with Catmull-Rom splines. - Drag the 25 handles on the left sprite to move the vertices of the grid, - and double tap to reset it. - '''; + The grid has NxN cells, and therefore (N+1)x(N+1) vertices: use the + `Grid Size` knob to choose N between 3 and 8 (4 by default, i.e. 25 + vertices). Changing the size resets the grid. - static const int _columns = 4; - static const int _rows = 4; + Drag the handles on the left sprite to move the vertices of the grid, and + double tap to reset it. + '''; late final SpriteComponent _bilinear; late final SpriteComponent _catmullRom; @@ -31,7 +33,7 @@ class SpriteWarpExample() extends FlameGame with DoubleTapCallbacks { size.y * 0.6 / sprite.srcSize.y, ); final spriteSize = sprite.srcSize * scale; - final grid = WarpGrid.identity(columns: _columns, rows: _rows); + final grid = WarpGrid.identity(columns: gridSize, rows: gridSize); _bilinear = SpriteComponent( sprite: sprite, @@ -57,7 +59,9 @@ class SpriteWarpExample() extends FlameGame with DoubleTapCallbacks { ), ); } - _bilinear.addAll(_handles); + _bilinear + ..add(_GridLines(_handles, gridSize)) + ..addAll(_handles); world.addAll([ _bilinear, @@ -86,7 +90,7 @@ class SpriteWarpExample() extends FlameGame with DoubleTapCallbacks { @override void onDoubleTapDown(DoubleTapDownEvent event) { - final identity = WarpGrid.identity(columns: _columns, rows: _rows); + final identity = WarpGrid.identity(columns: gridSize, rows: gridSize); final positions = identity.destinationPositions; for (var i = 0; i < _handles.length; i++) { _handles[i].position = positions[i]..multiply(_bilinear.size); @@ -96,16 +100,16 @@ class SpriteWarpExample() extends FlameGame with DoubleTapCallbacks { } } +final Paint _overlayPaint = Paint() + ..color = const Color(0x80FFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + class _Handle({ required super.position, required final void Function() onMoved, }) extends CircleComponent with DragCallbacks { - this - : super( - radius: 8, - anchor: Anchor.center, - paint: Paint()..color = const Color(0xCCFF00FF), - ); + this : super(radius: 8, anchor: Anchor.center, paint: _overlayPaint); @override void onDragUpdate(DragUpdateEvent event) { @@ -113,3 +117,25 @@ class _Handle({ onMoved(); } } + +/// Connects each handle to its right and bottom neighbors. +class _GridLines(final List<_Handle> _handles, final int _gridSize) + extends Component { + @override + void render(Canvas canvas) { + final stride = _gridSize + 1; + for (var row = 0; row <= _gridSize; row++) { + for (var column = 0; column <= _gridSize; column++) { + final from = _handles[row * stride + column].position.toOffset(); + if (column < _gridSize) { + final to = _handles[row * stride + column + 1].position; + canvas.drawLine(from, to.toOffset(), _overlayPaint); + } + if (row < _gridSize) { + final to = _handles[(row + 1) * stride + column].position; + canvas.drawLine(from, to.toOffset(), _overlayPaint); + } + } + } + } +} diff --git a/examples/lib/stories/sprites/sprites.dart b/examples/lib/stories/sprites/sprites.dart index a2e174436f9..f0bc8dd9b8c 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -59,7 +59,17 @@ WidgetbookComponent spritesStories() { ), ExampleUseCase( name: 'Sprite Warp', - builder: (_) => GameWidget(game: SpriteWarpExample()), + builder: (context) => GameWidget( + game: SpriteWarpExample( + gridSize: context.knobs.int.slider( + label: 'Grid Size', + initialValue: 4, + min: 3, + max: 8, + divisions: 5, + ), + ), + ), codeLink: baseLink('sprites/sprite_warp_example.dart'), info: SpriteWarpExample.description, ), From 9c0d89b158d7072525d435c998a6cfcc6de28feb Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 15:06:26 +0200 Subject: [PATCH 08/21] refactor: Move sprite warping into the HasWarpGrid mixin Move warpGrid and warpInterpolation from SpriteComponent into a new HasWarpGrid mixin on SpriteComponent, which overrides a new protected SpriteComponent.renderSprite method, so that warping composes with any SpriteComponent subclass. Update tests, example and docs accordingly. --- doc/flame/components/sprite_components.md | 24 +++--- .../stories/sprites/sprite_warp_example.dart | 39 +++++---- packages/flame/lib/components.dart | 1 + .../lib/src/components/sprite_component.dart | 73 ++++------------- .../lib/src/sprite_warp/has_warp_grid.dart | 78 ++++++++++++++++++ .../sprite_component_warp_golden_test.dart | 22 ++++-- .../sprite_component_warp_test.dart | 79 ++++++++++++++++--- 7 files changed, 213 insertions(+), 103 deletions(-) create mode 100644 packages/flame/lib/src/sprite_warp/has_warp_grid.dart diff --git a/doc/flame/components/sprite_components.md b/doc/flame/components/sprite_components.md index 4486727b87c..7da0d85dbd8 100644 --- a/doc/flame/components/sprite_components.md +++ b/doc/flame/components/sprite_components.md @@ -39,25 +39,29 @@ class MyGame extends FlameGame { ### Warping -A `SpriteComponent` 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. +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 = SpriteComponent( - sprite: sprite, - size: Vector2.all(128), - warpGrid: grid.replacingDestinationPositions(positions), -); +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 diff --git a/examples/lib/stories/sprites/sprite_warp_example.dart b/examples/lib/stories/sprites/sprite_warp_example.dart index 3a0c9f865fb..aef88e60da5 100644 --- a/examples/lib/stories/sprites/sprite_warp_example.dart +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -9,9 +9,10 @@ class SpriteWarpExample({final int gridSize = 4}) extends FlameGame with DoubleTapCallbacks { static const String description = ''' - In this example two `SpriteComponent`s share the same sprite and are - warped by the same `WarpGrid`: the left one interpolates the grid - bilinearly (like SpriteKit does), the right one with Catmull-Rom splines. + In this example two `SpriteComponent`s with the `HasWarpGrid` mixin share + the same sprite and are warped by the same `WarpGrid`: the left one + interpolates the grid bilinearly (like SpriteKit does), the right one + with Catmull-Rom splines. The grid has NxN cells, and therefore (N+1)x(N+1) vertices: use the `Grid Size` knob to choose N between 3 and 8 (4 by default, i.e. 25 @@ -21,8 +22,8 @@ class SpriteWarpExample({final int gridSize = 4}) double tap to reset it. '''; - late final SpriteComponent _bilinear; - late final SpriteComponent _catmullRom; + late final _WarpedSprite _bilinear; + late final _WarpedSprite _catmullRom; final List<_Handle> _handles = []; @override @@ -35,21 +36,19 @@ class SpriteWarpExample({final int gridSize = 4}) final spriteSize = sprite.srcSize * scale; final grid = WarpGrid.identity(columns: gridSize, rows: gridSize); - _bilinear = SpriteComponent( + _bilinear = _WarpedSprite( sprite: sprite, size: spriteSize, position: Vector2(-spriteSize.x * 0.75, 0), - anchor: Anchor.center, - warpGrid: grid, - ); - _catmullRom = SpriteComponent( - sprite: sprite, - size: spriteSize, - position: Vector2(spriteSize.x * 0.75, 0), - anchor: Anchor.center, - warpGrid: grid, - warpInterpolation: WarpInterpolation.catmullRom, - ); + )..warpGrid = grid; + _catmullRom = + _WarpedSprite( + sprite: sprite, + size: spriteSize, + position: Vector2(spriteSize.x * 0.75, 0), + ) + ..warpGrid = grid + ..warpInterpolation = WarpInterpolation.catmullRom; for (final position in grid.destinationPositions) { _handles.add( @@ -100,6 +99,12 @@ class SpriteWarpExample({final int gridSize = 4}) } } +class _WarpedSprite({super.sprite, super.size, super.position}) + extends SpriteComponent + with HasWarpGrid { + this : super(anchor: Anchor.center); +} + final Paint _overlayPaint = Paint() ..color = const Color(0x80FFFFFF) ..style = PaintingStyle.stroke diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 9dd69ab8f59..3afd85a169e 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -63,6 +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/src/components/sprite_component.dart b/packages/flame/lib/src/components/sprite_component.dart index aef693668b5..380a0ea52fb 100644 --- a/packages/flame/lib/src/components/sprite_component.dart +++ b/packages/flame/lib/src/components/sprite_component.dart @@ -1,7 +1,6 @@ import 'dart:ui'; import 'package:flame/components.dart'; -import 'package:flame/src/sprite_warp/sprite_warp_renderer.dart'; import 'package:meta/meta.dart'; export '../sprite.dart'; @@ -25,17 +24,6 @@ class SpriteComponent({ super.children, super.priority, var double? bleed, - - /// The grid used to warp (distort) the [sprite], or `null` to render it - /// undistorted. - /// - /// The grid's destination positions are relative to the component's [size]. - /// Only the visual rendering is affected: [size], hit testing and - /// collisions are not. - var WarpGrid? warpGrid, - - /// How [warpGrid] is interpolated between its vertices. - var WarpInterpolation warpInterpolation = WarpInterpolation.bilinear, super.key, }) extends PositionComponent with HasPaint, ImageRetainer { /// When set to true, the component is auto-resized to match the @@ -74,8 +62,6 @@ class SpriteComponent({ int? priority, ComponentKey? key, double? bleed, - WarpGrid? warpGrid, - WarpInterpolation warpInterpolation = WarpInterpolation.bilinear, }) : this( sprite: Sprite( image, @@ -93,17 +79,9 @@ class SpriteComponent({ children: children, priority: priority, bleed: bleed, - warpGrid: warpGrid, - warpInterpolation: warpInterpolation, key: key, ); - SpriteWarpRenderer? _warpRenderer; - - /// The renderer used while [warpGrid] is set. - @visibleForTesting - SpriteWarpRenderer? get warpRenderer => _warpRenderer; - /// Returns current value of auto resize flag. bool get autoResize => _autoResize; @@ -126,9 +104,6 @@ class SpriteComponent({ set sprite(Sprite? value) { _sprite = value; _resizeToSprite(); - // The warp renderer holds an image shader, which would keep the previous - // image alive after it is released (and possibly evicted) below. - _disposeWarpRenderer(); updateRetainedImages(); } @@ -148,46 +123,28 @@ class SpriteComponent({ super.onMount(); } - @override - @mustCallSuper - void onRemove() { - _disposeWarpRenderer(); - super.onRemove(); - } - @mustCallSuper @override void render(Canvas canvas) { final sprite = _sprite; - if (sprite == null) { - return; - } - final warpGrid = this.warpGrid; - if (warpGrid == null) { - _disposeWarpRenderer(); - sprite.render( - canvas, - size: size, - overridePaint: paint, - bleed: bleed, - ); - } else { - (_warpRenderer ??= SpriteWarpRenderer()).render( - canvas, - sprite: sprite, - grid: warpGrid, - interpolation: warpInterpolation, - width: size.x, - height: size.y, - paint: paint, - bleed: bleed ?? 0, - ); + if (sprite != null) { + renderSprite(canvas, sprite); } } - void _disposeWarpRenderer() { - _warpRenderer?.dispose(); - _warpRenderer = null; + /// 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, + bleed: bleed, + ); } /// Updates the size [sprite]'s srcSize if [autoResize] is true. 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..805aa983ea5 --- /dev/null +++ b/packages/flame/lib/src/sprite_warp/has_warp_grid.dart @@ -0,0 +1,78 @@ +import 'dart:ui'; + +import 'package:flame/src/components/sprite_component.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 { + /// 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. + 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/test/sprite_warp/sprite_component_warp_golden_test.dart b/packages/flame/test/sprite_warp/sprite_component_warp_golden_test.dart index a826d95cc30..1c399153383 100644 --- a/packages/flame/test/sprite_warp/sprite_component_warp_golden_test.dart +++ b/packages/flame/test/sprite_warp/sprite_component_warp_golden_test.dart @@ -55,19 +55,25 @@ WarpGrid _inflate() { return grid.replacingDestinationPositions(positions); } -SpriteComponent _panel( +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 SpriteComponent( - sprite: Sprite(image), - position: Vector2(30 + index * 160.0, 30), - size: Vector2.all(120), - warpGrid: grid, - warpInterpolation: interpolation, - ); + return _WarpedSprite( + sprite: Sprite(image), + position: Vector2(30 + index * 160.0, 30), + size: Vector2.all(120), + ) + ..warpGrid = grid + ..warpInterpolation = interpolation; } void main() { diff --git a/packages/flame/test/sprite_warp/sprite_component_warp_test.dart b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart index 3976a785bb5..d1e90b17568 100644 --- a/packages/flame/test/sprite_warp/sprite_component_warp_test.dart +++ b/packages/flame/test/sprite_warp/sprite_component_warp_test.dart @@ -62,7 +62,22 @@ Future _expectSameRendering( expect(difference, lessThanOrEqualTo(1)); } -SpriteComponent _component( +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, @@ -70,15 +85,15 @@ SpriteComponent _component( double? bleed, Paint? paint, }) { - return SpriteComponent( - sprite: sprite, - position: position ?? Vector2(4, 8), - size: Vector2(64, 32), - warpGrid: warpGrid, - warpInterpolation: warpInterpolation, - bleed: 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. @@ -250,6 +265,50 @@ Future main() async { 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()); From 3ff9bfce1cea8eed5f6d9fcb7a51bace7cf7ac4f Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Tue, 6 Oct 2026 15:27:47 +0200 Subject: [PATCH 09/21] feat: Add an Image knob to the Sprite Warp example Let the Sprite Warp example warp one of several images (flame, zap, pizza, player, enemy), chosen with a dropdown knob. Changing the image keeps the current warp grid, while changing the grid size still resets it. --- .../stories/sprites/sprite_warp_example.dart | 172 ++++++++++++------ examples/lib/stories/sprites/sprites.dart | 22 ++- 2 files changed, 132 insertions(+), 62 deletions(-) diff --git a/examples/lib/stories/sprites/sprite_warp_example.dart b/examples/lib/stories/sprites/sprite_warp_example.dart index aef88e60da5..cf093650525 100644 --- a/examples/lib/stories/sprites/sprite_warp_example.dart +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -5,9 +5,62 @@ import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:material_ui/material_ui.dart'; -class SpriteWarpExample({final int gridSize = 4}) - extends FlameGame - with DoubleTapCallbacks { +/// 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'), +} + +/// Hosts a [SpriteWarpExample] and shows the given [image] on it, so that +/// changing the image keeps the current warp, while changing the [gridSize] +/// creates a new game. +class const SpriteWarpStory({ + required final int gridSize, + required final SpriteWarpImage image, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _SpriteWarpGame( + gridSize: gridSize, + image: image, + key: ValueKey(gridSize), + ); + } +} + +class const _SpriteWarpGame({ + required final int gridSize, + required final SpriteWarpImage image, + 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, + ); + + @override + void didUpdateWidget(_SpriteWarpGame oldWidget) { + super.didUpdateWidget(oldWidget); + _game.setImage(widget.image); + } + + @override + Widget build(BuildContext context) => GameWidget(game: _game); +} + +class SpriteWarpExample({ + final int gridSize = 4, + SpriteWarpImage image = SpriteWarpImage.flame, +}) extends FlameGame with DoubleTapCallbacks { static const String description = ''' In this example two `SpriteComponent`s with the `HasWarpGrid` mixin share the same sprite and are warped by the same `WarpGrid`: the left one @@ -18,64 +71,83 @@ class SpriteWarpExample({final int gridSize = 4}) `Grid Size` knob to choose N between 3 and 8 (4 by default, i.e. 25 vertices). Changing the size resets the grid. + Use the `Image` knob to choose the sprite: the current warp is kept. + Drag the handles on the left sprite to move the vertices of the grid, and double tap to reset it. '''; + SpriteWarpImage _image = image; late final _WarpedSprite _bilinear; late final _WarpedSprite _catmullRom; + late final TextComponent _bilinearLabel; + late final TextComponent _catmullRomLabel; final List<_Handle> _handles = []; @override Future onLoad() async { - final sprite = await loadSprite('assets/images/flame.png'); - final scale = min( - size.x * 0.35 / sprite.srcSize.x, - size.y * 0.6 / sprite.srcSize.y, - ); - final spriteSize = sprite.srcSize * scale; final grid = WarpGrid.identity(columns: gridSize, rows: gridSize); - - _bilinear = _WarpedSprite( - sprite: sprite, - size: spriteSize, - position: Vector2(-spriteSize.x * 0.75, 0), - )..warpGrid = grid; - _catmullRom = - _WarpedSprite( - sprite: sprite, - size: spriteSize, - position: Vector2(spriteSize.x * 0.75, 0), - ) - ..warpGrid = grid - ..warpInterpolation = WarpInterpolation.catmullRom; - - for (final position in grid.destinationPositions) { - _handles.add( - _Handle( - position: position..multiply(spriteSize), - onMoved: _updateGrid, - ), - ); + _bilinear = _WarpedSprite()..warpGrid = grid; + _catmullRom = _WarpedSprite() + ..warpGrid = grid + ..warpInterpolation = WarpInterpolation.catmullRom; + for (var i = 0; i < grid.vertexCount; i++) { + _handles.add(_Handle(onMoved: _updateGrid)); } _bilinear ..add(_GridLines(_handles, gridSize)) ..addAll(_handles); + _bilinearLabel = _label('Bilinear'); + _catmullRomLabel = _label('Catmull-Rom'); - world.addAll([ - _bilinear, - _catmullRom, - _label('Bilinear', _bilinear), - _label('Catmull-Rom', _catmullRom), - ]); + world.addAll([_bilinear, _catmullRom, _bilinearLabel, _catmullRomLabel]); + await _showImage(_image); + } + + /// Shows [image] on both sprites, keeping the current warp. + void setImage(SpriteWarpImage image) { + if (image == _image) { + return; + } + _image = image; + loaded.then((_) => _showImage(image)); } - TextComponent _label(String text, SpriteComponent component) { - return TextComponent( - text: text, - position: component.position + Vector2(0, component.size.y * 0.7), - anchor: Anchor.center, + Future _showImage(SpriteWarpImage image) async { + final sprite = await loadSprite('assets/images/${image.path}'); + if (image != _image) { + // A newer image was chosen while this one was loading. + return; + } + final scale = min( + size.x * 0.35 / sprite.srcSize.x, + size.y * 0.6 / sprite.srcSize.y, ); + final spriteSize = sprite.srcSize * scale; + _bilinear + ..sprite = sprite + ..size = spriteSize + ..position = Vector2(-spriteSize.x * 0.75, 0); + _catmullRom + ..sprite = sprite + ..size = spriteSize + ..position = Vector2(spriteSize.x * 0.75, 0); + final labelOffset = Vector2(0, spriteSize.y * 0.7); + _bilinearLabel.position = _bilinear.position + labelOffset; + _catmullRomLabel.position = _catmullRom.position + labelOffset; + _placeHandles(_bilinear.warpGrid!); + } + + TextComponent _label(String text) { + return TextComponent(text: text, anchor: Anchor.center); + } + + /// Moves the handles to the destination positions of [grid]. + void _placeHandles(WarpGrid grid) { + final positions = grid.destinationPositions; + for (var i = 0; i < _handles.length; i++) { + _handles[i].position = positions[i]..multiply(_bilinear.size); + } } void _updateGrid() { @@ -90,18 +162,13 @@ class SpriteWarpExample({final int gridSize = 4}) @override void onDoubleTapDown(DoubleTapDownEvent event) { final identity = WarpGrid.identity(columns: gridSize, rows: gridSize); - final positions = identity.destinationPositions; - for (var i = 0; i < _handles.length; i++) { - _handles[i].position = positions[i]..multiply(_bilinear.size); - } + _placeHandles(identity); _bilinear.warpGrid = identity; _catmullRom.warpGrid = identity; } } -class _WarpedSprite({super.sprite, super.size, super.position}) - extends SpriteComponent - with HasWarpGrid { +class _WarpedSprite() extends SpriteComponent with HasWarpGrid { this : super(anchor: Anchor.center); } @@ -110,10 +177,9 @@ final Paint _overlayPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2; -class _Handle({ - required super.position, - required final void Function() onMoved, -}) extends CircleComponent with DragCallbacks { +class _Handle({required final void Function() onMoved}) + extends CircleComponent + with DragCallbacks { this : super(radius: 8, anchor: Anchor.center, paint: _overlayPaint); @override diff --git a/examples/lib/stories/sprites/sprites.dart b/examples/lib/stories/sprites/sprites.dart index f0bc8dd9b8c..532b31ae996 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -59,15 +59,19 @@ WidgetbookComponent spritesStories() { ), ExampleUseCase( name: 'Sprite Warp', - builder: (context) => GameWidget( - game: SpriteWarpExample( - gridSize: context.knobs.int.slider( - label: 'Grid Size', - initialValue: 4, - min: 3, - max: 8, - divisions: 5, - ), + builder: (context) => 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, ), ), codeLink: baseLink('sprites/sprite_warp_example.dart'), From 4dab3de5b1e5ba32eea7dcb6098ba6c0b3b02723 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 12:55:53 +0200 Subject: [PATCH 10/21] feat: Add a Reset button knob to the Sprite Warp example Add a `Reset` button to the knobs panel of the Sprite Warp example, which resets the warp grid to the identity without recreating the game. Widgetbook has no button knob, so a small custom `ButtonKnob` (with a `context.knobs.button()` extension) is added to the examples' commons: its value is the number of presses, and the story resets the grid whenever the count changes. While the `Animate` knob is on, both the button and the double-tap reset are disabled. --- examples/lib/commons/button_knob.dart | 57 ++++ .../stories/sprites/sprite_warp_example.dart | 107 ++++++- examples/lib/stories/sprites/sprites.dart | 36 ++- packages/flame/lib/effects.dart | 4 +- .../lib/src/effects/provider_interfaces.dart | 8 + .../flame/lib/src/effects/warp_effect.dart | 170 +++++++++++ .../lib/src/sprite_warp/has_warp_grid.dart | 6 +- .../flame/lib/src/sprite_warp/warp_grid.dart | 16 +- .../flame/test/effects/warp_effect_test.dart | 270 ++++++++++++++++++ 9 files changed, 642 insertions(+), 32 deletions(-) create mode 100644 examples/lib/commons/button_knob.dart create mode 100644 packages/flame/lib/src/effects/warp_effect.dart create mode 100644 packages/flame/test/effects/warp_effect_test.dart 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/stories/sprites/sprite_warp_example.dart b/examples/lib/stories/sprites/sprite_warp_example.dart index cf093650525..551dbafb031 100644 --- a/examples/lib/stories/sprites/sprite_warp_example.dart +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -1,6 +1,7 @@ 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'; @@ -15,11 +16,13 @@ enum SpriteWarpImage(final String path) { } /// Hosts a [SpriteWarpExample] and shows the given [image] on it, so that -/// changing the image keeps the current warp, while changing the [gridSize] -/// creates a new game. +/// changing the image or [animate] keeps the current warp, while changing the +/// [gridSize] creates a new game. Every change of [resets] resets the grid. class const SpriteWarpStory({ required final int gridSize, required final SpriteWarpImage image, + final bool animate = false, + final int resets = 0, super.key, }) extends StatelessWidget { @override @@ -27,6 +30,8 @@ class const SpriteWarpStory({ return _SpriteWarpGame( gridSize: gridSize, image: image, + animate: animate, + resets: resets, key: ValueKey(gridSize), ); } @@ -35,6 +40,8 @@ class const SpriteWarpStory({ class const _SpriteWarpGame({ required final int gridSize, required final SpriteWarpImage image, + required final bool animate, + required final int resets, super.key, }) extends StatefulWidget { @override @@ -45,12 +52,18 @@ class _SpriteWarpGameState() extends State<_SpriteWarpGame> { late final SpriteWarpExample _game = SpriteWarpExample( gridSize: widget.gridSize, image: widget.image, + animate: widget.animate, ); @override void didUpdateWidget(_SpriteWarpGame oldWidget) { super.didUpdateWidget(oldWidget); - _game.setImage(widget.image); + _game + ..setImage(widget.image) + ..animate = widget.animate; + if (widget.resets != oldWidget.resets) { + _game.resetGrid(); + } } @override @@ -60,6 +73,7 @@ class _SpriteWarpGameState() extends State<_SpriteWarpGame> { class SpriteWarpExample({ final int gridSize = 4, SpriteWarpImage image = SpriteWarpImage.flame, + bool animate = false, }) extends FlameGame with DoubleTapCallbacks { static const String description = ''' In this example two `SpriteComponent`s with the `HasWarpGrid` mixin share @@ -73,11 +87,19 @@ class SpriteWarpExample({ Use the `Image` knob to choose the sprite: the current warp is kept. + Turn on the `Animate` knob to make the grid wave back and forth with a + `WarpEffect`. The effect changes the grid incrementally, so dragging the + handles keeps working while it runs. + Drag the handles on the left sprite to move the vertices of the grid, and - double tap to reset it. + double tap or press the `Reset` knob button to reset it (both are + disabled while the animation runs). '''; SpriteWarpImage _image = image; + bool _animate = animate; + Effect? _animation; + WarpGrid? _shownGrid; late final _WarpedSprite _bilinear; late final _WarpedSprite _catmullRom; late final TextComponent _bilinearLabel; @@ -102,6 +124,64 @@ class SpriteWarpExample({ world.addAll([_bilinear, _catmullRom, _bilinearLabel, _catmullRomLabel]); await _showImage(_image); + _updateAnimation(); + } + + /// Starts or stops waving the grid. + set animate(bool value) { + if (value == _animate) { + return; + } + _animate = value; + loaded.then((_) => _updateAnimation()); + } + + void _updateAnimation() { + if (!_animate) { + // The grid stays as it is when the animation stops. + _animation?.removeFromParent(); + _animation = null; + return; + } + if (_animation != null) { + return; + } + // Rows sway horizontally and columns vertically, the outer edges stay. + final offsets = [ + for (var row = 0; row <= gridSize; row++) + for (var column = 0; column <= gridSize; column++) + Vector2( + 0.1 * sin(2 * pi * row / gridSize), + 0.1 * sin(2 * pi * column / gridSize), + ), + ]; + _bilinear.add( + _animation = WarpEffect.by( + offsets, + EffectController( + duration: 1, + reverseDuration: 1, + infinite: true, + curve: Curves.easeInOut, + ), + ), + ); + } + + @override + void update(double dt) { + super.update(dt); + if (!isLoaded) { + return; + } + // Drags and the animation change the grid of the left sprite: mirror it + // on the right sprite and on the handles. + final grid = _bilinear.warpGrid!; + if (!identical(grid, _shownGrid)) { + _shownGrid = grid; + _catmullRom.warpGrid = grid; + _placeHandles(grid); + } } /// Shows [image] on both sprites, keeping the current warp. @@ -152,19 +232,24 @@ class SpriteWarpExample({ void _updateGrid() { final size = _bilinear.size; - final grid = _bilinear.warpGrid!.replacingDestinationPositions([ + _bilinear.warpGrid = _bilinear.warpGrid!.replacingDestinationPositions([ for (final handle in _handles) handle.position.clone()..divide(size), ]); - _bilinear.warpGrid = grid; - _catmullRom.warpGrid = grid; + } + + /// Removes any distortion from the grid. + void resetGrid() { + loaded.then((_) { + _bilinear.warpGrid = WarpGrid.identity(columns: gridSize, rows: gridSize); + }); } @override void onDoubleTapDown(DoubleTapDownEvent event) { - final identity = WarpGrid.identity(columns: gridSize, rows: gridSize); - _placeHandles(identity); - _bilinear.warpGrid = identity; - _catmullRom.warpGrid = identity; + // Like the `Reset` knob button, resetting is disabled while animating. + if (!_animate) { + resetGrid(); + } } } diff --git a/examples/lib/stories/sprites/sprites.dart b/examples/lib/stories/sprites/sprites.dart index 532b31ae996..fff7d943980 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -1,3 +1,4 @@ +import 'package:examples/commons/button_knob.dart'; import 'package:examples/commons/commons.dart'; import 'package:examples/commons/example_use_case.dart'; import 'package:examples/stories/sprites/base64_sprite_example.dart'; @@ -59,21 +60,26 @@ WidgetbookComponent spritesStories() { ), ExampleUseCase( name: 'Sprite Warp', - builder: (context) => 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, - ), - ), + 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, + ), + animate: animate, + resets: context.knobs.button(label: 'Reset', enabled: !animate), + ); + }, codeLink: baseLink('sprites/sprite_warp_example.dart'), info: SpriteWarpExample.description, ), 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/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..90ef445daf4 --- /dev/null +++ b/packages/flame/lib/src/effects/warp_effect.dart @@ -0,0 +1,170 @@ +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; + if (dProgress == 0) { + return; + } + final grid = target.warpGrid!; + 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 index 805aa983ea5..0a2f821c8ef 100644 --- a/packages/flame/lib/src/sprite_warp/has_warp_grid.dart +++ b/packages/flame/lib/src/sprite_warp/has_warp_grid.dart @@ -1,6 +1,7 @@ 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'; @@ -19,12 +20,13 @@ import 'package:meta/meta.dart'; /// 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 { +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. + /// warp, or animate it with a `WarpEffect`. + @override WarpGrid? warpGrid; /// How [warpGrid] is interpolated between its vertices. diff --git a/packages/flame/lib/src/sprite_warp/warp_grid.dart b/packages/flame/lib/src/sprite_warp/warp_grid.dart index dcdb8c6053f..fdc2d67cbdf 100644 --- a/packages/flame/lib/src/sprite_warp/warp_grid.dart +++ b/packages/flame/lib/src/sprite_warp/warp_grid.dart @@ -54,7 +54,17 @@ class WarpGrid { WarpGrid.identity({int columns = 1, int rows = 1}) : this(columns: columns, rows: rows); - const WarpGrid._(this.columns, this.rows, this._source, this._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 + const WarpGrid.raw( + this.columns, + this.rows, + this._source, + this._destination, + ); /// The number of cells in the horizontal direction. final int columns; @@ -102,7 +112,7 @@ class WarpGrid { /// Returns a copy of this grid with the given source [positions]. WarpGrid replacingSourcePositions(List positions) { - return WarpGrid._( + return WarpGrid.raw( columns, rows, _fromVectors(positions, vertexCount), @@ -112,7 +122,7 @@ class WarpGrid { /// Returns a copy of this grid with the given destination [positions]. WarpGrid replacingDestinationPositions(List positions) { - return WarpGrid._( + return WarpGrid.raw( columns, rows, _source, 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..022bb41e789 --- /dev/null +++ b/packages/flame/test/effects/warp_effect_test.dart @@ -0,0 +1,270 @@ +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('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('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; From 20bc525e5b7bc6d508776aaabe91b69f0c46670a Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 12:56:48 +0200 Subject: [PATCH 11/21] docs: Add documentation for WarpEffect. --- doc/flame/components/sprite_components.md | 2 +- doc/flame/effects/effects.md | 3 ++ doc/flame/effects/warp_effects.md | 52 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 doc/flame/effects/warp_effects.md diff --git a/doc/flame/components/sprite_components.md b/doc/flame/components/sprite_components.md index 7da0d85dbd8..6a756e2576e 100644 --- a/doc/flame/components/sprite_components.md +++ b/doc/flame/components/sprite_components.md @@ -81,7 +81,7 @@ 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. +undistorted again. To animate the warp, use a [`WarpEffect`](../effects/warp_effects.md). The grid is interpolated according to `warpInterpolation`: 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..d37f7589b5c --- /dev/null +++ b/doc/flame/effects/warp_effects.md @@ -0,0 +1,52 @@ +# 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. From 31111e0bf3c694c8862f37eb0d1f6ce4e7a334c2 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 13:03:19 +0200 Subject: [PATCH 12/21] docs: Describe the Sprite Warp example knobs Link the Sprite Warp example from the Warping section and the Warp Effects page, describing its `Animate` knob and its `Reset` knob button, which resets the grid to the identity and, like double-tap reset, is disabled while the animation runs. --- doc/flame/components/sprite_components.md | 5 +++++ doc/flame/effects/warp_effects.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/doc/flame/components/sprite_components.md b/doc/flame/components/sprite_components.md index 6a756e2576e..bbe4706b890 100644 --- a/doc/flame/components/sprite_components.md +++ b/doc/flame/components/sprite_components.md @@ -104,6 +104,11 @@ A few more things to keep in mind: - 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 +both interpolations side by side. Drag the handles to move the grid's vertices, and use the +`Animate` knob to wave the grid with a `WarpEffect`. The `Reset` knob button, or a double tap, +resets the grid to the identity; both are disabled while the animation runs. + ## SpriteAnimationComponent diff --git a/doc/flame/effects/warp_effects.md b/doc/flame/effects/warp_effects.md index d37f7589b5c..a999514bc77 100644 --- a/doc/flame/effects/warp_effects.md +++ b/doc/flame/effects/warp_effects.md @@ -50,3 +50,8 @@ To animate through several grids, like SpriteKit's `SKAction.animate(withWarps:t 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) runs an +infinite `WarpEffect.by` while still allowing the grid to be dragged. Its `Reset` knob button, +which resets the grid to the identity, is disabled while the animation runs. From 56a0f19266f4f552c254023377947138e8566eb7 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 16:11:25 +0200 Subject: [PATCH 13/21] fix: cspell verification failing due to dictionary errors --- .github/.cspell/people_usernames.txt | 3 +-- .github/.cspell/words_dictionary.txt | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index e1c5a8c8ba5..436a45af319 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -1,9 +1,8 @@ # specific people's names and/or usernames akida # github.com/akida bdero # github.com/bdero -catmull # Catmull/Rom -Catmull # Catmull/Rom 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 1df55213889..97c485b5000 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -1,5 +1,4 @@ # actual english words (or common abbreviations) missing from CSpell -bilinear bilinearly bloodlust collidable From f00bf2cafcd22c050b6b4255e90bcf22be6f9d07 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 17:10:43 +0200 Subject: [PATCH 14/21] docs: Fully describe the Sprite Warp example The Warping section of the sprite components docs only mentioned the latest example changes. It now describes the whole example: two HasWarpGrid sprites sharing a grid with bilinear and Catmull-Rom interpolation, the draggable vertex handles, and the Grid Size, Image, Animate and Reset knobs. The warp effects docs link to it. --- doc/flame/components/sprite_components.md | 16 +++++++++++++--- doc/flame/effects/warp_effects.md | 3 ++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/doc/flame/components/sprite_components.md b/doc/flame/components/sprite_components.md index bbe4706b890..54f43aebe03 100644 --- a/doc/flame/components/sprite_components.md +++ b/doc/flame/components/sprite_components.md @@ -105,9 +105,19 @@ A few more things to keep in mind: an undistorted sprite repeats its border pixels instead. The [Sprite Warp example](https://examples.flame-engine.org/#/?path=sprites/sprite-warp) shows -both interpolations side by side. Drag the handles to move the grid's vertices, and use the -`Animate` knob to wave the grid with a `WarpEffect`. The `Reset` knob button, or a double tap, -resets the grid to the identity; both are disabled while the animation runs. +two components with the `HasWarpGrid` mixin that share the same sprite and the same `WarpGrid`: +the left one uses `WarpInterpolation.bilinear`, the right one `WarpInterpolation.catmullRom`. +The left sprite has a draggable handle on each vertex, connected by grid lines: dragging a handle +moves the vertex's destination position, and the right sprite mirrors the change. The example +has the following knobs: + +- `Grid Size`: the number of cells per side (3 to 8, 4 by default). Changing it starts over with + an undistorted grid. +- `Image`: switches between a few sprites, keeping the current warp. +- `Animate`: waves the grid back and forth with an infinite `WarpEffect.by`. Since the effect + changes the grid incrementally, the handles can still be dragged while it runs. +- `Reset`: a button that resets the grid to the identity, like a double tap on the game. Both + are disabled while the animation runs. ## SpriteAnimationComponent diff --git a/doc/flame/effects/warp_effects.md b/doc/flame/effects/warp_effects.md index a999514bc77..4512cfe04ef 100644 --- a/doc/flame/effects/warp_effects.md +++ b/doc/flame/effects/warp_effects.md @@ -54,4 +54,5 @@ rebuilt every frame. The `Animate` knob of the [Sprite Warp example](https://examples.flame-engine.org/#/?path=sprites/sprite-warp) runs an infinite `WarpEffect.by` while still allowing the grid to be dragged. Its `Reset` knob button, -which resets the grid to the identity, is disabled while the animation runs. +which resets the grid to the identity, is disabled while the animation runs. The example is fully +described in [Warping](../components/sprite_components.md#warping). From 2982ef2edaff8703e443d7176954bb2d56d30352 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 17:22:30 +0200 Subject: [PATCH 15/21] fix: Keep the grid when WarpEffect.to has nothing to change When the target's grid already matched the destination grid, WarpEffect.to still assigned a new grid every frame, rebuilding the warped sprite's mesh for no visible change. The effect now leaves the grid untouched when it has no offsets to apply. --- packages/flame/lib/src/effects/warp_effect.dart | 5 ++++- .../flame/test/effects/warp_effect_test.dart | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/flame/lib/src/effects/warp_effect.dart b/packages/flame/lib/src/effects/warp_effect.dart index 90ef445daf4..d7598e81e8b 100644 --- a/packages/flame/lib/src/effects/warp_effect.dart +++ b/packages/flame/lib/src/effects/warp_effect.dart @@ -74,7 +74,10 @@ class WarpEffect.by( @override void apply(double progress) { final dProgress = progress - previousProgress; - if (dProgress == 0) { + // 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!; diff --git a/packages/flame/test/effects/warp_effect_test.dart b/packages/flame/test/effects/warp_effect_test.dart index 022bb41e789..e645c960fee 100644 --- a/packages/flame/test/effects/warp_effect_test.dart +++ b/packages/flame/test/effects/warp_effect_test.dart @@ -134,6 +134,22 @@ void main() { ); }); + 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); From eb271d94d9580c036b1e89db9a6389f5735a4caf Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 17:24:40 +0200 Subject: [PATCH 16/21] fix: Throw a StateError when a warped grid is removed WarpEffect assumed that the target's grid stayed set while the effect ran, and failed with a null check error when it was set to null. It now throws a StateError explaining that the grid was removed. --- packages/flame/lib/src/effects/warp_effect.dart | 5 ++++- packages/flame/test/effects/warp_effect_test.dart | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/flame/lib/src/effects/warp_effect.dart b/packages/flame/lib/src/effects/warp_effect.dart index d7598e81e8b..a4fb089872f 100644 --- a/packages/flame/lib/src/effects/warp_effect.dart +++ b/packages/flame/lib/src/effects/warp_effect.dart @@ -80,7 +80,10 @@ class WarpEffect.by( (_sourceOffsets == null && _destinationOffsets == null)) { return; } - final grid = target.warpGrid!; + final grid = target.warpGrid; + if (grid == null) { + throw StateError('The grid was removed while being warped'); + } target.warpGrid = WarpGrid.raw( grid.columns, grid.rows, diff --git a/packages/flame/test/effects/warp_effect_test.dart b/packages/flame/test/effects/warp_effect_test.dart index e645c960fee..fdd9ed94967 100644 --- a/packages/flame/test/effects/warp_effect_test.dart +++ b/packages/flame/test/effects/warp_effect_test.dart @@ -231,6 +231,16 @@ void main() { 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); From 77b0cf687e34b2edb302ad15e69303ffa52cac6e Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 17:28:22 +0200 Subject: [PATCH 17/21] fix: Throw an ArgumentError for warp grids with too many cells The limit on the number of cells of a WarpGrid, which keeps the rendered mesh within 16-bit indices, was only checked by an assert, so release builds silently rendered a garbled mesh. The default and identity constructors are now factories that throw an ArgumentError before allocating the grid, and WarpGrid.raw is the primary constructor. --- .../flame/lib/src/sprite_warp/warp_grid.dart | 75 +++++++++++-------- .../test/sprite_warp/warp_grid_test.dart | 6 +- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/packages/flame/lib/src/sprite_warp/warp_grid.dart b/packages/flame/lib/src/sprite_warp/warp_grid.dart index fdc2d67cbdf..56b78884c7d 100644 --- a/packages/flame/lib/src/sprite_warp/warp_grid.dart +++ b/packages/flame/lib/src/sprite_warp/warp_grid.dart @@ -34,46 +34,57 @@ const int _maxMeshVertices = 1 << 16; /// 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 WarpGrid { - WarpGrid({ - required this.columns, - required this.rows, - List? sourcePositions, - List? destinationPositions, - }) : assert(columns > 0 && rows > 0, 'Columns and rows must be positive'), - assert( - (columns * (1 << warpSubdivisionLevels) + 1) * - (rows * (1 << warpSubdivisionLevels) + 1) <= - _maxMeshVertices, - 'Too many columns and rows: $columns x $rows', - ), - _source = _positionsOrIdentity(sourcePositions, columns, rows), - _destination = _positionsOrIdentity(destinationPositions, columns, rows); - - /// Creates a grid that renders the sprite without distortion. - WarpGrid.identity({int columns = 1, int rows = 1}) - : this(columns: columns, rows: rows); +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 - const WarpGrid.raw( - this.columns, - this.rows, - this._source, - this._destination, - ); - - /// The number of cells in the horizontal direction. - final int columns; + this; - /// The number of cells in the vertical direction. - final int rows; + /// Creates a grid with the given positions, which default to those of an + /// undistorted grid. + /// + /// Throws an [ArgumentError] when the rendered mesh of the grid would have + /// more than 65536 vertices, e.g. with more than 63 x 63 cells. + factory WarpGrid({ + required int columns, + required int rows, + List? sourcePositions, + List? destinationPositions, + }) { + assert(columns > 0 && rows > 0, 'Columns and rows must be positive'); + 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), + ); + } - final Float32List _source; - final Float32List _destination; + /// 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); diff --git a/packages/flame/test/sprite_warp/warp_grid_test.dart b/packages/flame/test/sprite_warp/warp_grid_test.dart index 55e740994d2..ca22a44693b 100644 --- a/packages/flame/test/sprite_warp/warp_grid_test.dart +++ b/packages/flame/test/sprite_warp/warp_grid_test.dart @@ -143,7 +143,11 @@ void main() { expect(() => WarpGrid(columns: 1, rows: -1), failsAssert()); // The rendered mesh must fit 16-bit indices. expect(WarpGrid.identity(columns: 63, rows: 63).vertexCount, 4096); - expect(() => WarpGrid(columns: 64, rows: 64), failsAssert()); + expect(() => WarpGrid(columns: 64, rows: 64), throwsArgumentError); + expect( + () => WarpGrid.identity(columns: 4000), + throwsArgumentError, + ); }); test('fails on wrong number of positions', () { From 056df718d0fc21dc4b68e623720fe6f3e7d31cc8 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 17:30:26 +0200 Subject: [PATCH 18/21] fix: Throw an ArgumentError for a wrong number of warp grid positions The number of positions given to WarpGrid, or to its replacing methods, was only checked by an assert, so release builds silently ignored extra positions or failed with a RangeError when some were missing. It now throws an ArgumentError. --- .../flame/lib/src/sprite_warp/warp_grid.dart | 16 +++++++++++----- .../flame/test/sprite_warp/warp_grid_test.dart | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/flame/lib/src/sprite_warp/warp_grid.dart b/packages/flame/lib/src/sprite_warp/warp_grid.dart index 56b78884c7d..69d2fed580e 100644 --- a/packages/flame/lib/src/sprite_warp/warp_grid.dart +++ b/packages/flame/lib/src/sprite_warp/warp_grid.dart @@ -54,7 +54,8 @@ class const WarpGrid.raw( /// undistorted grid. /// /// Throws an [ArgumentError] when the rendered mesh of the grid would have - /// more than 65536 vertices, e.g. with more than 63 x 63 cells. + /// 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, @@ -122,6 +123,8 @@ class const WarpGrid.raw( 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, @@ -132,6 +135,8 @@ class const WarpGrid.raw( } /// 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, @@ -182,10 +187,11 @@ class const WarpGrid.raw( } static Float32List _fromVectors(List positions, int vertexCount) { - assert( - positions.length == vertexCount, - 'Expected $vertexCount positions, got ${positions.length}', - ); + 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; diff --git a/packages/flame/test/sprite_warp/warp_grid_test.dart b/packages/flame/test/sprite_warp/warp_grid_test.dart index ca22a44693b..6ccb4db5f8f 100644 --- a/packages/flame/test/sprite_warp/warp_grid_test.dart +++ b/packages/flame/test/sprite_warp/warp_grid_test.dart @@ -159,11 +159,25 @@ void main() { rows: 1, sourcePositions: grid.sourcePositions, ), - failsAssert(), + throwsArgumentError, + ); + expect( + () => WarpGrid( + columns: 2, + rows: 2, + destinationPositions: [Vector2.zero()], + ), + throwsArgumentError, + ); + expect( + () => grid.replacingSourcePositions( + [...grid.sourcePositions, Vector2.zero()], + ), + throwsArgumentError, ); expect( () => grid.replacingDestinationPositions([Vector2.zero()]), - failsAssert(), + throwsArgumentError, ); }); From 59097725f531b2e85ded305bd457bd1781ebe5e5 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Wed, 7 Oct 2026 17:34:32 +0200 Subject: [PATCH 19/21] fix: Throw an ArgumentError for warp grids without cells WarpGrid only asserted that its columns and rows were positive, so release builds created grids with NaN positions, and negative dimensions could pass the mesh size check. It now throws an ArgumentError. --- packages/flame/lib/src/sprite_warp/warp_grid.dart | 13 +++++++++---- packages/flame/test/sprite_warp/warp_grid_test.dart | 8 ++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/flame/lib/src/sprite_warp/warp_grid.dart b/packages/flame/lib/src/sprite_warp/warp_grid.dart index 69d2fed580e..6434c075eec 100644 --- a/packages/flame/lib/src/sprite_warp/warp_grid.dart +++ b/packages/flame/lib/src/sprite_warp/warp_grid.dart @@ -53,16 +53,21 @@ class const WarpGrid.raw( /// Creates a grid with the given positions, which default to those of an /// undistorted grid. /// - /// Throws an [ArgumentError] 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. + /// 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, }) { - assert(columns > 0 && rows > 0, 'Columns and rows must be positive'); + 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); diff --git a/packages/flame/test/sprite_warp/warp_grid_test.dart b/packages/flame/test/sprite_warp/warp_grid_test.dart index 6ccb4db5f8f..9d740447e1a 100644 --- a/packages/flame/test/sprite_warp/warp_grid_test.dart +++ b/packages/flame/test/sprite_warp/warp_grid_test.dart @@ -139,8 +139,12 @@ void main() { }); test('fails on invalid dimensions', () { - expect(() => WarpGrid(columns: 0, rows: 1), failsAssert()); - expect(() => WarpGrid(columns: 1, rows: -1), failsAssert()); + 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); From e1ac97128ec836a9c0a5db412c006d1d1a8c813c Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 12 Oct 2026 03:54:58 +0200 Subject: [PATCH 20/21] feat: Extend SpriteWarpExample with editable start and target grids. The example now animates between two editable grids with WarpEffect.to, adding the 'Default Target' knob to select between some sample warp effects. --- doc/flame/components/sprite_components.md | 23 +- doc/flame/effects/warp_effects.md | 7 +- examples/lib/commons/dropdown_knob.dart | 79 +++ .../stories/sprites/sprite_warp_example.dart | 523 ++++++++++++------ examples/lib/stories/sprites/sprites.dart | 23 +- 5 files changed, 479 insertions(+), 176 deletions(-) create mode 100644 examples/lib/commons/dropdown_knob.dart diff --git a/doc/flame/components/sprite_components.md b/doc/flame/components/sprite_components.md index 54f43aebe03..685b31cadf2 100644 --- a/doc/flame/components/sprite_components.md +++ b/doc/flame/components/sprite_components.md @@ -105,19 +105,16 @@ A few more things to keep in mind: an undistorted sprite repeats its border pixels instead. The [Sprite Warp example](https://examples.flame-engine.org/#/?path=sprites/sprite-warp) shows -two components with the `HasWarpGrid` mixin that share the same sprite and the same `WarpGrid`: -the left one uses `WarpInterpolation.bilinear`, the right one `WarpInterpolation.catmullRom`. -The left sprite has a draggable handle on each vertex, connected by grid lines: dragging a handle -moves the vertex's destination position, and the right sprite mirrors the change. The example -has the following knobs: - -- `Grid Size`: the number of cells per side (3 to 8, 4 by default). Changing it starts over with - an undistorted grid. -- `Image`: switches between a few sprites, keeping the current warp. -- `Animate`: waves the grid back and forth with an infinite `WarpEffect.by`. Since the effect - changes the grid incrementally, the handles can still be dragged while it runs. -- `Reset`: a button that resets the grid to the identity, like a double tap on the game. Both - are disabled while the animation runs. +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 diff --git a/doc/flame/effects/warp_effects.md b/doc/flame/effects/warp_effects.md index 4512cfe04ef..0ad053a234b 100644 --- a/doc/flame/effects/warp_effects.md +++ b/doc/flame/effects/warp_effects.md @@ -52,7 +52,6 @@ Note that a new grid is assigned at every tick while the effect runs, so the spr rebuilt every frame. The `Animate` knob of the -[Sprite Warp example](https://examples.flame-engine.org/#/?path=sprites/sprite-warp) runs an -infinite `WarpEffect.by` while still allowing the grid to be dragged. Its `Reset` knob button, -which resets the grid to the identity, is disabled while the animation runs. The example is fully -described in [Warping](../components/sprite_components.md#warping). +[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/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 index 551dbafb031..fd4a256479f 100644 --- a/examples/lib/stories/sprites/sprite_warp_example.dart +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -13,16 +13,123 @@ enum SpriteWarpImage(final String path) { pizza('pizza.png'), player('layers/player.png'), enemy('layers/enemy.png'), + background('layers/background.png'), + mountain('parallax/mountain-far.png'), } -/// Hosts a [SpriteWarpExample] and shows the given [image] on it, so that -/// changing the image or [animate] keeps the current warp, while changing the -/// [gridSize] creates a new game. Every change of [resets] resets the grid. +/// 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 cornerness = sqrt(4 * (u - 0.5).abs() * (v - 0.5).abs()); + return Vector2(0.5 - u, 0.5 - v) * amount * (0.4 + 0.6 * cornerness); + } + + /// 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 resets = 0, + final int startResets = 0, + final int targetResets = 0, super.key, }) extends StatelessWidget { @override @@ -30,8 +137,11 @@ class const SpriteWarpStory({ return _SpriteWarpGame( gridSize: gridSize, image: image, + interpolation: interpolation, + defaultTarget: defaultTarget, animate: animate, - resets: resets, + startResets: startResets, + targetResets: targetResets, key: ValueKey(gridSize), ); } @@ -40,8 +150,11 @@ class const SpriteWarpStory({ 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 resets, + required final int startResets, + required final int targetResets, super.key, }) extends StatefulWidget { @override @@ -52,6 +165,8 @@ 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, ); @@ -60,9 +175,14 @@ class _SpriteWarpGameState() extends State<_SpriteWarpGame> { super.didUpdateWidget(oldWidget); _game ..setImage(widget.image) + ..interpolation = widget.interpolation + ..defaultTarget = widget.defaultTarget ..animate = widget.animate; - if (widget.resets != oldWidget.resets) { - _game.resetGrid(); + if (widget.startResets != oldWidget.startResets) { + _game.resetStart(); + } + if (widget.targetResets != oldWidget.targetResets) { + _game.resetTarget(); } } @@ -73,130 +193,64 @@ class _SpriteWarpGameState() extends State<_SpriteWarpGame> { class SpriteWarpExample({ final int gridSize = 4, SpriteWarpImage image = SpriteWarpImage.flame, + WarpInterpolation interpolation = WarpInterpolation.bilinear, + DefaultWarpTarget defaultTarget = DefaultWarpTarget.none, bool animate = false, -}) extends FlameGame with DoubleTapCallbacks { +}) extends FlameGame { static const String description = ''' - In this example two `SpriteComponent`s with the `HasWarpGrid` mixin share - the same sprite and are warped by the same `WarpGrid`: the left one - interpolates the grid bilinearly (like SpriteKit does), the right one - with Catmull-Rom splines. + Two sprites with the `HasWarpGrid` mixin show the start grid (left) and + the target grid (right) of a `WarpEffect.to`. - The grid has NxN cells, and therefore (N+1)x(N+1) vertices: use the - `Grid Size` knob to choose N between 3 and 8 (4 by default, i.e. 25 - vertices). Changing the size resets the grid. + 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. - Use the `Image` knob to choose the sprite: the current warp is kept. - - Turn on the `Animate` knob to make the grid wave back and forth with a - `WarpEffect`. The effect changes the grid incrementally, so dragging the - handles keeps working while it runs. - - Drag the handles on the left sprite to move the vertices of the grid, and - double tap or press the `Reset` knob button to reset it (both are - disabled while the animation runs). + 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; - Effect? _animation; - WarpGrid? _shownGrid; - late final _WarpedSprite _bilinear; - late final _WarpedSprite _catmullRom; - late final TextComponent _bilinearLabel; - late final TextComponent _catmullRomLabel; - final List<_Handle> _handles = []; + final List _animations = []; - @override - Future onLoad() async { - final grid = WarpGrid.identity(columns: gridSize, rows: gridSize); - _bilinear = _WarpedSprite()..warpGrid = grid; - _catmullRom = _WarpedSprite() - ..warpGrid = grid - ..warpInterpolation = WarpInterpolation.catmullRom; - for (var i = 0; i < grid.vertexCount; i++) { - _handles.add(_Handle(onMoved: _updateGrid)); - } - _bilinear - ..add(_GridLines(_handles, gridSize)) - ..addAll(_handles); - _bilinearLabel = _label('Bilinear'); - _catmullRomLabel = _label('Catmull-Rom'); - - world.addAll([_bilinear, _catmullRom, _bilinearLabel, _catmullRomLabel]); - await _showImage(_image); - _updateAnimation(); - } + /// The grids to restore when the animation stops. + WarpGrid? _startGrid; + WarpGrid? _targetGrid; - /// Starts or stops waving the grid. - set animate(bool value) { - if (value == _animate) { - return; - } - _animate = value; - loaded.then((_) => _updateAnimation()); - } + late final _EditableWarpSprite _startSprite = _EditableWarpSprite( + gridSize: gridSize, + label: 'Start', + ); - void _updateAnimation() { - if (!_animate) { - // The grid stays as it is when the animation stops. - _animation?.removeFromParent(); - _animation = null; - return; - } - if (_animation != null) { - return; - } - // Rows sway horizontally and columns vertically, the outer edges stay. - final offsets = [ - for (var row = 0; row <= gridSize; row++) - for (var column = 0; column <= gridSize; column++) - Vector2( - 0.1 * sin(2 * pi * row / gridSize), - 0.1 * sin(2 * pi * column / gridSize), - ), - ]; - _bilinear.add( - _animation = WarpEffect.by( - offsets, - EffectController( - duration: 1, - reverseDuration: 1, - infinite: true, - curve: Curves.easeInOut, - ), - ), - ); - } + late final _EditableWarpSprite _targetSprite = _EditableWarpSprite( + gridSize: gridSize, + label: 'Target', + ); @override - void update(double dt) { - super.update(dt); - if (!isLoaded) { - return; - } - // Drags and the animation change the grid of the left sprite: mirror it - // on the right sprite and on the handles. - final grid = _bilinear.warpGrid!; - if (!identical(grid, _shownGrid)) { - _shownGrid = grid; - _catmullRom.warpGrid = grid; - _placeHandles(grid); - } + Future onLoad() async { + _applyInterpolation(); + _targetSprite.warpGrid = _defaultTarget.grid(gridSize); + world.addAll([_startSprite, _targetSprite]); + await _showImage(); + _updateAnimation(); } - /// Shows [image] on both sprites, keeping the current warp. + /// Shows [image] on both sprites. void setImage(SpriteWarpImage image) { if (image == _image) { return; } _image = image; - loaded.then((_) => _showImage(image)); + loaded.then((_) => _showImage()); } - Future _showImage(SpriteWarpImage image) async { + Future _showImage() async { + final image = _image; final sprite = await loadSprite('assets/images/${image.path}'); if (image != _image) { - // A newer image was chosen while this one was loading. + // Superseded by a newer image. return; } final scale = min( @@ -204,94 +258,247 @@ class SpriteWarpExample({ size.y * 0.6 / sprite.srcSize.y, ); final spriteSize = sprite.srcSize * scale; - _bilinear + _startSprite ..sprite = sprite ..size = spriteSize ..position = Vector2(-spriteSize.x * 0.75, 0); - _catmullRom + _targetSprite ..sprite = sprite ..size = spriteSize ..position = Vector2(spriteSize.x * 0.75, 0); - final labelOffset = Vector2(0, spriteSize.y * 0.7); - _bilinearLabel.position = _bilinear.position + labelOffset; - _catmullRomLabel.position = _catmullRom.position + labelOffset; - _placeHandles(_bilinear.warpGrid!); } - TextComponent _label(String text) { - return TextComponent(text: text, anchor: Anchor.center); + /// Sets how both grids are interpolated. + set interpolation(WarpInterpolation value) { + _interpolation = value; + _applyInterpolation(); } - /// Moves the handles to the destination positions of [grid]. - void _placeHandles(WarpGrid grid) { - final positions = grid.destinationPositions; - for (var i = 0; i < _handles.length; i++) { - _handles[i].position = positions[i]..multiply(_bilinear.size); + 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(); } - void _updateGrid() { - final size = _bilinear.size; - _bilinear.warpGrid = _bilinear.warpGrid!.replacingDestinationPositions([ - for (final handle in _handles) handle.position.clone()..divide(size), - ]); + /// Starts or stops warping both sprites between the two grids. + set animate(bool value) { + if (value == _animate) { + return; + } + _animate = value; + loaded.then((_) => _updateAnimation()); } - /// Removes any distortion from the grid. - void resetGrid() { + 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((_) { - _bilinear.warpGrid = WarpGrid.identity(columns: gridSize, rows: gridSize); + 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 onDoubleTapDown(DoubleTapDownEvent event) { - // Like the `Reset` knob button, resetting is disabled while animating. - if (!_animate) { - resetGrid(); + void render(Canvas canvas) { + if (gridBehind) { + super.renderChild(canvas, _overlay); } + super.render(canvas); } -} -class _WarpedSprite() extends SpriteComponent with HasWarpGrid { - this : super(anchor: Anchor.center); + @override + void renderChild(Canvas canvas, Component child) { + // When behind, [render] already rendered the overlay. + if (!gridBehind || child != _overlay) { + super.renderChild(canvas, child); + } + } } -final Paint _overlayPaint = Paint() - ..color = const Color(0x80FFFFFF) - ..style = PaintingStyle.stroke - ..strokeWidth = 2; +/// 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); -class _Handle({required final void Function() onMoved}) - extends CircleComponent - with DragCallbacks { - this : super(radius: 8, anchor: Anchor.center, paint: _overlayPaint); + /// 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 onDragUpdate(DragUpdateEvent event) { - position += event.localDelta; - onMoved(); + void onLoad() { + for (var i = 0; i < parent.warpGrid!.vertexCount; i++) { + _handles.add(_Handle(this, i)); + } + addAll(_handles); } -} -/// Connects each handle to its right and bottom neighbors. -class _GridLines(final List<_Handle> _handles, final int _gridSize) - extends Component { + @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 stride = _gridSize + 1; - for (var row = 0; row <= _gridSize; row++) { - for (var column = 0; column <= _gridSize; column++) { + 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 < _gridSize) { + if (column < grid.columns) { final to = _handles[row * stride + column + 1].position; - canvas.drawLine(from, to.toOffset(), _overlayPaint); + canvas.drawLine(from, to.toOffset(), _paint); } - if (row < _gridSize) { + if (row < grid.rows) { final to = _handles[(row + 1) * stride + column].position; - canvas.drawLine(from, to.toOffset(), _overlayPaint); + 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 fff7d943980..df46667ba5a 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -1,5 +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'; @@ -9,6 +10,7 @@ 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'; @@ -76,8 +78,27 @@ WidgetbookComponent spritesStories() { 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, - resets: context.knobs.button(label: 'Reset', enabled: !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'), From 09997105cac62333b2f41bf6614c6c6ba7831760 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 12 Oct 2026 04:13:35 +0200 Subject: [PATCH 21/21] fix: Satisfy cspell in a variable name. --- examples/lib/stories/sprites/sprite_warp_example.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/lib/stories/sprites/sprite_warp_example.dart b/examples/lib/stories/sprites/sprite_warp_example.dart index fd4a256479f..33f63d2185e 100644 --- a/examples/lib/stories/sprites/sprite_warp_example.dart +++ b/examples/lib/stories/sprites/sprite_warp_example.dart @@ -104,8 +104,8 @@ enum DefaultWarpTarget(final String label) { /// 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 cornerness = sqrt(4 * (u - 0.5).abs() * (v - 0.5).abs()); - return Vector2(0.5 - u, 0.5 - v) * amount * (0.4 + 0.6 * cornerness); + 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