spine-runtimes/spine-flutter/lib/spine_flutter.dart
2025-07-31 00:18:34 +02:00

480 lines
20 KiB
Dart

import 'spine_dart.dart';
export 'spine_dart.dart';
import 'raw_image_provider.dart';
export 'spine_widget.dart';
export 'raw_image_provider.dart';
import 'dart:convert' as convert;
import 'dart:io' if (dart.library.html) 'io_stub.dart';
import 'dart:typed_data';
import 'dart:ui';
import "package:universal_ffi/ffi.dart";
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart' as material;
import 'package:flutter/rendering.dart' as rendering;
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
// Backwards compatibility
Future<void> initSpineFlutter({bool useStaticLinkage = false, bool enableMemoryDebugging = false}) async {
await initSpineDart(useStaticLinkage: useStaticLinkage, enableMemoryDebugging: enableMemoryDebugging);
return;
}
/// Flutter wrapper for Atlas that manages texture loading and Paint creation
class AtlasFlutter extends Atlas {
static FilterQuality filterQuality = FilterQuality.low;
final List<Image> atlasPages;
final List<Map<BlendMode, Paint>> atlasPagePaints;
bool _disposed = false;
AtlasFlutter._(super.ptr, this.atlasPages, this.atlasPagePaints) : super.fromPointer();
/// Internal method to load atlas and images
static Future<AtlasFlutter> _load(String atlasFileName, Future<Uint8List> Function(String name) loadFile) async {
// Load atlas data
final atlasBytes = await loadFile(atlasFileName);
final atlasData = convert.utf8.decode(atlasBytes);
final atlas = loadAtlas(atlasData);
// Load images for each atlas page
final atlasDir = path.dirname(atlasFileName);
final pages = <Image>[];
final paints = <Map<BlendMode, Paint>>[];
// Load images for each atlas page
for (int i = 0; i < atlas.pages.length; i++) {
final page = atlas.pages[i];
if (page == null) continue;
// Get the texture path from the atlas page
final texturePath = page.texturePath;
final imagePath = "$atlasDir/$texturePath";
final imageData = await loadFile(imagePath);
final codec = await instantiateImageCodec(imageData);
final frameInfo = await codec.getNextFrame();
final image = frameInfo.image;
pages.add(image);
// Create paints for each blend mode
final pagePaints = <BlendMode, Paint>{};
for (final blendMode in BlendMode.values) {
pagePaints[blendMode] = Paint()
..shader = ImageShader(
image,
TileMode.clamp,
TileMode.clamp,
Matrix4.identity().storage,
filterQuality: filterQuality,
)
..isAntiAlias = true
..blendMode = blendMode.toFlutterBlendMode();
}
paints.add(pagePaints);
}
return AtlasFlutter._(atlas.nativePtr.cast(), pages, paints);
}
/// Loads an [AtlasFlutter] from the file [atlasFileName] in the root bundle or the optionally provided [bundle].
static Future<AtlasFlutter> fromAsset(String atlasFileName, {AssetBundle? bundle}) async {
bundle ??= rootBundle;
return _load(atlasFileName, (file) async => (await bundle!.load(file)).buffer.asUint8List());
}
/// Loads an [AtlasFlutter] from the file [atlasFileName].
static Future<AtlasFlutter> fromFile(String atlasFileName) async {
if (kIsWeb) {
throw UnsupportedError('File operations are not supported on web. Use fromAsset or fromHttp instead.');
}
return _load(atlasFileName, (file) => File(file).readAsBytes());
}
/// Loads an [AtlasFlutter] from the URL [atlasURL].
static Future<AtlasFlutter> fromHttp(String atlasURL) async {
return _load(atlasURL, (file) async {
final response = await http.get(Uri.parse(file));
if (response.statusCode != 200) {
throw Exception('Failed to load $file: ${response.statusCode}');
}
return response.bodyBytes;
});
}
/// Disposes all resources including the native atlas and images
@override
void dispose() {
if (_disposed) return;
_disposed = true;
super.dispose();
for (final image in atlasPages) {
image.dispose();
}
atlasPagePaints.clear();
}
}
/// Flutter wrapper for SkeletonData that provides convenient loading methods
class SkeletonDataFlutter extends SkeletonData {
SkeletonDataFlutter._(super.ptr) : super.fromPointer();
/// Loads a [SkeletonDataFlutter] from the file [skeletonFile] in the root bundle or the optionally provided [bundle].
/// Uses the provided [atlasFlutter] to resolve attachment images.
///
/// Throws an [Exception] in case the skeleton data could not be loaded.
static Future<SkeletonDataFlutter> fromAsset(AtlasFlutter atlas, String skeletonFile, {AssetBundle? bundle}) async {
bundle ??= rootBundle;
if (skeletonFile.endsWith(".json")) {
final jsonData = await bundle.loadString(skeletonFile);
final skeletonData = loadSkeletonDataJson(atlas, jsonData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
} else {
final binaryData = (await bundle.load(skeletonFile)).buffer.asUint8List();
final skeletonData = loadSkeletonDataBinary(atlas, binaryData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
}
}
/// Loads a [SkeletonDataFlutter] from the file [skeletonFile]. Uses the provided [atlasFlutter] to resolve attachment images.
///
/// Throws an [Exception] in case the skeleton data could not be loaded.
static Future<SkeletonDataFlutter> fromFile(AtlasFlutter atlasFlutter, String skeletonFile) async {
if (kIsWeb) {
throw UnsupportedError('File operations are not supported on web. Use fromAsset or fromHttp instead.');
}
if (skeletonFile.endsWith(".json")) {
final jsonData = await File(skeletonFile).readAsString();
final skeletonData = loadSkeletonDataJson(atlasFlutter, jsonData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
} else {
final binaryData = await File(skeletonFile).readAsBytes();
final skeletonData = loadSkeletonDataBinary(atlasFlutter, binaryData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
}
}
/// Loads a [SkeletonDataFlutter] from the URL [skeletonURL]. Uses the provided [atlasFlutter] to resolve attachment images.
///
/// Throws an [Exception] in case the skeleton data could not be loaded.
static Future<SkeletonDataFlutter> fromHttp(AtlasFlutter atlasFlutter, String skeletonURL) async {
if (skeletonURL.endsWith(".json")) {
final response = await http.get(Uri.parse(skeletonURL));
if (response.statusCode != 200) {
throw Exception('Failed to load skeleton from $skeletonURL: ${response.statusCode}');
}
final skeletonData = loadSkeletonDataJson(atlasFlutter, response.body, path: skeletonURL);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
} else {
final response = await http.get(Uri.parse(skeletonURL));
if (response.statusCode != 200) {
throw Exception('Failed to load skeleton from $skeletonURL: ${response.statusCode}');
}
final skeletonData = loadSkeletonDataBinary(atlasFlutter, response.bodyBytes, path: skeletonURL);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
}
}
}
/// Extension to convert Spine BlendMode to Flutter BlendMode
extension BlendModeExtensions on BlendMode {
rendering.BlendMode toFlutterBlendMode() {
switch (this) {
case BlendMode.normal:
return rendering.BlendMode.srcOver;
case BlendMode.additive:
return rendering.BlendMode.plus;
case BlendMode.multiply:
return rendering.BlendMode.multiply;
case BlendMode.screen:
return rendering.BlendMode.screen;
}
}
}
/// Flutter-specific render command that wraps the native RenderCommand and provides
/// Flutter Vertices for efficient rendering.
class RenderCommandFlutter {
final RenderCommand _nativeCommand;
late final Vertices vertices;
late final int atlasPageIndex;
late final BlendMode blendMode;
RenderCommandFlutter._(this._nativeCommand, double pageWidth, double pageHeight) {
// Get atlas page index from texture pointer (which is actually the page index when using spine_atlas_load)
final texturePtr = _nativeCommand.texture;
atlasPageIndex = texturePtr?.address ?? 0;
final numVertices = _nativeCommand.numVertices;
final numIndices = _nativeCommand.numIndices;
// Get native data pointers
final positionsPtr = _nativeCommand.positions;
final uvsPtr = _nativeCommand.uvs;
final colorsPtr = _nativeCommand.colors;
final indicesPtr = _nativeCommand.indices;
if (positionsPtr == null || uvsPtr == null || colorsPtr == null || indicesPtr == null) {
throw Exception('Invalid render command data');
}
// Convert to typed lists
final positions = positionsPtr.asTypedList(numVertices * 2);
final uvs = uvsPtr.asTypedList(numVertices * 2);
final indices = indicesPtr.asTypedList(numIndices);
// Scale UVs by texture dimensions
for (int i = 0; i < numVertices * 2; i += 2) {
uvs[i] *= pageWidth;
uvs[i + 1] *= pageHeight;
}
// Get blend mode
blendMode = _nativeCommand.blendMode;
// Handle colors - convert Uint32 to Int32 view without copying
final colorsUint32 = colorsPtr.asTypedList(numVertices);
final colors = Int32List.view(colorsUint32.buffer, colorsUint32.offsetInBytes, colorsUint32.length);
if (!kIsWeb) {
// We pass the native data as views directly to Vertices.raw. According to the sources, the data
// is copied, so it doesn't matter that we free up the underlying memory on the next
// render call. See the implementation of Vertices.raw() here:
// https://github.com/flutter/engine/blob/5c60785b802ad2c8b8899608d949342d5c624952/lib/ui/painting/vertices.cc#L21
//
// Impeller is currently using a slow path when using vertex colors.
// See https://github.com/flutter/flutter/issues/127486
//
// We thus batch all meshes not only by atlas page and blend mode, but also vertex color.
//
// If the vertex color equals (1, 1, 1, 1), we do not store
// colors, which will trigger the fast path in Impeller. Otherwise we have to go the slow path, which
// has to render to an offscreen surface.
if (colors.isNotEmpty && colors[0] == -1) {
// Fast path: no vertex colors (all white)
vertices = Vertices.raw(VertexMode.triangles, positions, textureCoordinates: uvs, indices: indices);
} else {
vertices = Vertices.raw(
VertexMode.triangles,
positions,
textureCoordinates: uvs,
colors: colors,
indices: indices,
);
}
} else {
// On web, we need to copy the data
final positionsCopy = Float32List.fromList(positions);
final uvsCopy = Float32List.fromList(uvs);
final colorsCopy = Int32List.fromList(colors);
final indicesCopy = Uint16List.fromList(indices);
vertices = Vertices.raw(
VertexMode.triangles,
positionsCopy,
textureCoordinates: uvsCopy,
colors: colorsCopy,
indices: indicesCopy,
);
}
}
}
/// A SkeletonDrawable bundles loading, updating, and rendering an [AtlasFlutter], [Skeleton], and [AnimationState]
/// into a single easy to use class.
///
/// Use the [fromAsset], [fromFile], or [fromHttp] methods to construct a SkeletonDrawable. To have
/// multiple skeleton drawable instances share the same [AtlasFlutter] and [SkeletonDataFlutter], use the constructor.
///
/// You can then directly access the [atlasFlutter], [skeletonDataFlutter], [skeleton], [animationStateData], and [animationState]
/// to query and animate the skeleton. Use the [AnimationState] to queue animations on one or more tracks
/// via [AnimationState.setAnimation] or [AnimationState.addAnimation].
///
/// To update the [AnimationState] and apply it to the [Skeleton] call the [update] function, providing it
/// a delta time in seconds to advance the animations.
///
/// To render the current pose of the [Skeleton], use the rendering methods [render], [renderToCanvas], [renderToPictureRecorder],
/// [renderToPng], or [renderToRawImageData], depending on your needs.
///
/// When the skeleton drawable is no longer needed, call the [dispose] method to release its resources. If
/// the skeleton drawable was constructed from a shared [AtlasFlutter] and [SkeletonDataFlutter], make sure to dispose the
/// atlas and skeleton data as well, if no skeleton drawable references them anymore.
class SkeletonDrawableFlutter extends SkeletonDrawable {
final AtlasFlutter atlasFlutter;
final SkeletonData skeletonData;
final bool _ownsAtlasAndSkeletonData;
bool _disposed = false;
/// Constructs a new skeleton drawable from the given (possibly shared) [AtlasFlutter] and [SkeletonDataFlutter]. If
/// the atlas and skeleton data are not shared, the drawable can take ownership by passing true for [_ownsAtlasAndSkeletonData].
/// In that case a call to [dispose] will also dispose the atlas and skeleton data.
SkeletonDrawableFlutter(this.atlasFlutter, this.skeletonData, this._ownsAtlasAndSkeletonData) : super(skeletonData);
/// Constructs a new skeleton drawable from the [atlasFile] and [skeletonFile] from the root asset bundle
/// or the optionally provided [bundle].
///
/// Throws an exception in case the data could not be loaded.
static Future<SkeletonDrawableFlutter> fromAsset(String atlasFile, String skeletonFile, {AssetBundle? bundle}) async {
bundle ??= rootBundle;
final atlasFlutter = await AtlasFlutter.fromAsset(atlasFile, bundle: bundle);
final skeletonDataFlutter = await SkeletonDataFlutter.fromAsset(atlasFlutter, skeletonFile, bundle: bundle);
return SkeletonDrawableFlutter(atlasFlutter, skeletonDataFlutter, true);
}
/// Constructs a new skeleton drawable from the [atlasFile] and [skeletonFile].
///
/// Throws an exception in case the data could not be loaded.
static Future<SkeletonDrawableFlutter> fromFile(String atlasFile, String skeletonFile) async {
if (kIsWeb) {
throw UnsupportedError('File operations are not supported on web. Use fromAsset or fromHttp instead.');
}
final atlasFlutter = await AtlasFlutter.fromFile(atlasFile);
final skeletonDataFlutter = await SkeletonDataFlutter.fromFile(atlasFlutter, skeletonFile);
return SkeletonDrawableFlutter(atlasFlutter, skeletonDataFlutter, true);
}
/// Constructs a new skeleton drawable from the [atlasUrl] and [skeletonUrl].
///
/// Throws an exception in case the data could not be loaded.
static Future<SkeletonDrawableFlutter> fromHttp(String atlasUrl, String skeletonUrl) async {
final atlasFlutter = await AtlasFlutter.fromHttp(atlasUrl);
final skeletonDataFlutter = await SkeletonDataFlutter.fromHttp(atlasFlutter, skeletonUrl);
return SkeletonDrawableFlutter(atlasFlutter, skeletonDataFlutter, true);
}
/// Renders to current skeleton pose to a list of [RenderCommandFlutter] instances. The render commands
/// can be rendered via [Canvas.drawVertices].
List<RenderCommandFlutter> renderFlutter() {
if (_disposed) return [];
var commands = <RenderCommandFlutter>[];
var nativeCmd = render();
while (nativeCmd != null) {
// Get page dimensions from atlas
final pageIndex = nativeCmd.texture?.address ?? 0;
final pages = atlasFlutter.pages;
final page = pages[pageIndex];
if (page != null) {
commands.add(RenderCommandFlutter._(nativeCmd, page.width.toDouble(), page.height.toDouble()));
} else {
commands.add(RenderCommandFlutter._(nativeCmd, 1.0, 1.0));
}
nativeCmd = nativeCmd.next;
}
return commands;
}
/// Renders the skeleton drawable's current pose to the given [canvas]. Does not perform any
/// scaling or fitting.
List<RenderCommandFlutter> renderToCanvas(Canvas canvas) {
var commands = renderFlutter();
for (final cmd in commands) {
// Get the paint for this atlas page and blend mode
Paint? paint;
if (cmd.atlasPageIndex < atlasFlutter.atlasPagePaints.length) {
paint = atlasFlutter.atlasPagePaints[cmd.atlasPageIndex][cmd.blendMode];
}
// Fallback to a simple paint if textures aren't loaded
paint ??= Paint()
..color = material.Colors.white
..style = PaintingStyle.fill;
canvas.drawVertices(
cmd.vertices,
rendering.BlendMode.modulate,
paint,
);
}
return commands;
}
/// Renders the skeleton drawable's current pose to a [PictureRecorder] with the given [width] and [height].
/// Uses [bgColor], a 32-bit ARGB color value, to paint the background.
/// Scales and centers the skeleton to fit the within the bounds of [width] and [height].
PictureRecorder renderToPictureRecorder(double width, double height, int bgColor) {
var bounds = skeleton.bounds;
var scale = 1 / (bounds.width > bounds.height ? bounds.width / width : bounds.height / height);
var recorder = PictureRecorder();
var canvas = Canvas(recorder);
var paint = Paint()
..color = material.Color(bgColor)
..style = PaintingStyle.fill;
canvas.drawRect(Rect.fromLTWH(0, 0, width, height), paint);
canvas.translate(width / 2, height / 2);
canvas.scale(scale, scale);
canvas.translate(-(bounds.x + bounds.width / 2), -(bounds.y + bounds.height / 2));
renderToCanvas(canvas);
return recorder;
}
/// Renders the skeleton drawable's current pose to a PNG encoded in a [Uint8List], with the given [width] and [height].
/// Uses [bgColor], a 32-bit ARGB color value, to paint the background.
/// Scales and centers the skeleton to fit the within the bounds of [width] and [height].
Future<Uint8List> renderToPng(double width, double height, int bgColor) async {
final recorder = renderToPictureRecorder(width, height, bgColor);
final image = await recorder.endRecording().toImage(width.toInt(), height.toInt());
return (await image.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
}
/// Renders the skeleton drawable's current pose to a [RawImageData], with the given [width] and [height].
/// Uses [bgColor], a 32-bit ARGB color value, to paint the background.
/// Scales and centers the skeleton to fit the within the bounds of [width] and [height].
Future<RawImageData> renderToRawImageData(double width, double height, int bgColor) async {
final recorder = renderToPictureRecorder(width, height, bgColor);
var rawImageData = (await (await recorder.endRecording().toImage(
width.toInt(),
height.toInt(),
))
.toByteData(format: ImageByteFormat.rawRgba))!
.buffer
.asUint8List();
return RawImageData(rawImageData, width.toInt(), height.toInt());
}
/// Set a listener for all animation state events
void setListener(AnimationStateListener? listener) {
animationState.setListener(listener);
}
/// Disposes the skeleton drawable's resources. If the skeleton drawable owns the atlas
/// and skeleton data, they are disposed as well. Must be called when the skeleton drawable
/// is no longer in use.
@override
void dispose() {
if (_disposed) return;
_disposed = true;
super.dispose();
if (_ownsAtlasAndSkeletonData) {
atlasFlutter.dispose();
skeletonData.dispose();
}
}
}
/// Renders debug information for a [SkeletonDrawableFlutter], like bone locations, to a [Canvas].
/// See [DebugRenderer.render].
class DebugRenderer {
const DebugRenderer();
void render(SkeletonDrawableFlutter drawable, Canvas canvas, List<RenderCommandFlutter> commands) {
final bonePaint = Paint()
..color = material.Colors.blue
..style = PaintingStyle.fill;
for (final bone in drawable.skeleton.bones) {
if (bone == null) continue;
canvas.drawRect(
Rect.fromCenter(center: Offset(bone.appliedPose.worldX, bone.appliedPose.worldY), width: 5, height: 5),
bonePaint,
);
}
}
}