From 036e98995497d74d120bf6c0a93ccb878aacb038 Mon Sep 17 00:00:00 2001 From: beauchama Date: Tue, 7 Jul 2026 18:55:57 -0400 Subject: [PATCH 1/3] Added primitive animation --- .../Interop/SDL/Primitives/IMG_Animation.cs | 27 +++++++++++++ .../SDL/Primitives/IMG_AnimationDecoder.cs | 6 +++ .../SDL/Primitives/IMG_AnimationEncoder.cs | 6 +++ src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs | 40 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_Animation.cs create mode 100644 src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationDecoder.cs create mode 100644 src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationEncoder.cs diff --git a/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_Animation.cs b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_Animation.cs new file mode 100644 index 0000000..e20b663 --- /dev/null +++ b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_Animation.cs @@ -0,0 +1,27 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Interop.SDL.Primitives; + +[StructLayout(LayoutKind.Sequential)] +internal readonly struct IMG_Animation +{ + internal IMG_Animation(int width, int height, int count, SDL_Surface** frames, int* delays) + { + Width = width; + Height = height; + Count = count; + Frames = frames; + Delays = delays; + } + + internal readonly int Width { get; } + + internal readonly int Height { get; } + + internal readonly int Count { get; } + + internal readonly SDL_Surface** Frames { get; } + + internal readonly int* Delays { get; } +} diff --git a/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationDecoder.cs b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationDecoder.cs new file mode 100644 index 0000000..1690a88 --- /dev/null +++ b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationDecoder.cs @@ -0,0 +1,6 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Interop.SDL.Primitives; + +internal readonly struct IMG_AnimationDecoder; diff --git a/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationEncoder.cs b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationEncoder.cs new file mode 100644 index 0000000..185a120 --- /dev/null +++ b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationEncoder.cs @@ -0,0 +1,6 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Interop.SDL.Primitives; + +internal readonly struct IMG_AnimationEncoder; diff --git a/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs b/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs index 82c6a8d..c47ac60 100644 --- a/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs +++ b/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs @@ -7,6 +7,28 @@ namespace KappaDuck.Quack.Interop.SDL; internal static partial class SDL3_image { + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_AddAnimationEncoderFrame")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool AddAnimationEncoderFrame(IMG_AnimationEncoder* encoder, SDL_Surface* frame, ulong duration); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CloseAnimationEncoder")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool CloseAnimationEncoder(IMG_AnimationEncoder* encoder); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimatedCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial SDL_Cursor* CreateAnimatedCursor(IMG_Animation* animation, int hotX, int hotY); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimationEncoderWithProperties")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial IMG_AnimationEncoder* CreateAnimationEncoderWithProperties(uint properties); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_FreeAnimation")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial void FreeAnimation(IMG_Animation* animation); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_Load", StringMarshalling = StringMarshalling.Utf8)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial SDL_Surface* FromFile(string file); @@ -23,6 +45,14 @@ internal static partial class SDL3_image [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial SDL_Texture* FromStream(SDL_Renderer* renderer, SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_LoadAnimation", StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial IMG_Animation* LoadAnimation(string file); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_LoadAnimation_IO")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial IMG_Animation* LoadAnimation(SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_Save", StringMarshalling = StringMarshalling.Utf8)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [return: MarshalAs(UnmanagedType.I1)] @@ -33,6 +63,16 @@ internal static partial class SDL3_image [return: MarshalAs(UnmanagedType.I1)] internal static partial bool Save(SDL_Surface* surface, SDL_IOStream* stream, string type); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_SaveAnimation", StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool SaveAnimation(IMG_Animation* animation, string file); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_SaveAnimationTyped_IO", StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool SaveAnimation(IMG_Animation* animation, SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO, string type); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_SaveAVIF", StringMarshalling = StringMarshalling.Utf8)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [return: MarshalAs(UnmanagedType.I1)] From 93fcd36ece1b6003d87a1eb4b3e6490fe2f7d1c3 Mon Sep 17 00:00:00 2001 From: beauchama Date: Tue, 7 Jul 2026 21:22:36 -0400 Subject: [PATCH 2/3] Added Animation, Encoder and Decoder --- src/KappaDuck.Quack/Input/Cursor.cs | 24 +- src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs | 41 ++++ src/KappaDuck.Quack/Video/AnimationFormat.cs | 51 +++++ src/KappaDuck.Quack/Video/Pixels/Animation.cs | 157 +++++++++++++ .../Video/Pixels/AnimationDecoder.cs | 214 ++++++++++++++++++ .../Video/Pixels/AnimationDecoderStatus.cs | 30 +++ .../Video/Pixels/AnimationEncoder.cs | 122 ++++++++++ .../Video/Pixels/AnimationEncoderOptions.cs | 47 ++++ 8 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 src/KappaDuck.Quack/Video/AnimationFormat.cs create mode 100644 src/KappaDuck.Quack/Video/Pixels/Animation.cs create mode 100644 src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs create mode 100644 src/KappaDuck.Quack/Video/Pixels/AnimationDecoderStatus.cs create mode 100644 src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs create mode 100644 src/KappaDuck.Quack/Video/Pixels/AnimationEncoderOptions.cs diff --git a/src/KappaDuck.Quack/Input/Cursor.cs b/src/KappaDuck.Quack/Input/Cursor.cs index d7d4ac1..ba02140 100644 --- a/src/KappaDuck.Quack/Input/Cursor.cs +++ b/src/KappaDuck.Quack/Input/Cursor.cs @@ -88,8 +88,7 @@ public Cursor(ReadOnlySpan data, ReadOnlySpan mask, Size size, Point /// exactly / 8 * bytes long. /// /// Failed to create the cursor. - public Cursor(ReadOnlySpan data, ReadOnlySpan mask, int width, int height, Point hotspot) - : this(CreateMonochrome(data, mask, width, height, hotspot), true) + public Cursor(ReadOnlySpan data, ReadOnlySpan mask, int width, int height, Point hotspot) : this(CreateMonochrome(data, mask, width, height, hotspot), true) { } @@ -115,6 +114,25 @@ public Cursor(ReadOnlySpan frames, Point hotspot) : this(CreateAnim { } + /// + /// Creates an animated cursor from an already loaded or built animation. + /// + /// + /// + /// The hotspot applies to every frame, and every frame in shares the same dimensions. + /// + /// + /// Useful when the frames come from a file like a GIF, APNG, or Windows .ani cursor loaded with . + /// For building one from scratch out of individual frames, is more direct. + /// + /// + /// The animation to cycle through. + /// The cursor hotspot, shared by every frame. + /// Failed to create the cursor. + public Cursor(Animation animation, Point hotspot) : this(SDL3_image.CreateAnimatedCursor(animation.Handle, hotspot.X, hotspot.Y), true) + { + } + private Cursor(SDL_Cursor* handle, bool owned) { QuackEngine.EnsureInitialized(Subsystem.Video); @@ -213,7 +231,7 @@ public static Cursor FromStream(Stream stream, Point hotspot) { ArgumentOutOfRangeException.ThrowIfZero(frames.Length); - SDL_CursorFrameInfo[] info = new SDL_CursorFrameInfo[frames.Length]; + Span info = new SDL_CursorFrameInfo[frames.Length]; for (int i = 0; i < frames.Length; i++) { diff --git a/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs b/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs index c47ac60..c6e56c4 100644 --- a/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs +++ b/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using KappaDuck.Quack.Interop.SDL.Primitives; +using KappaDuck.Quack.Video.Pixels; namespace KappaDuck.Quack.Interop.SDL; @@ -12,6 +13,11 @@ internal static partial class SDL3_image [return: MarshalAs(UnmanagedType.I1)] internal static partial bool AddAnimationEncoderFrame(IMG_AnimationEncoder* encoder, SDL_Surface* frame, ulong duration); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CloseAnimationDecoder")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool CloseAnimationDecoder(IMG_AnimationDecoder* decoder); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CloseAnimationEncoder")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [return: MarshalAs(UnmanagedType.I1)] @@ -21,6 +27,14 @@ internal static partial class SDL3_image [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial SDL_Cursor* CreateAnimatedCursor(IMG_Animation* animation, int hotX, int hotY); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimationDecoder", StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial IMG_AnimationDecoder* CreateAnimationDecoder(string file); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimationDecoder_IO", StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial IMG_AnimationDecoder* CreateAnimationDecoder(SDL_IOStream* source, [MarshalAs(UnmanagedType.I1)] bool closeIO, string? type); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimationEncoderWithProperties")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial IMG_AnimationEncoder* CreateAnimationEncoderWithProperties(uint properties); @@ -45,6 +59,28 @@ internal static partial class SDL3_image [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial SDL_Texture* FromStream(SDL_Renderer* renderer, SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderFrame")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool GetAnimationDecoderFrame(IMG_AnimationDecoder* decoder, SDL_Surface** frame, ulong* duration); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderStatus")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial AnimationDecoderStatus GetAnimationDecoderStatus(IMG_AnimationDecoder* decoder); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderPresentationTimestampMS")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial int GetAnimationDecoderPresentationTimestampMS(IMG_AnimationDecoder* decoder, long pts); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderProperties")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial uint GetAnimationDecoderProperties(IMG_AnimationDecoder* decoder); + + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetNextAnimationDecoderFrame")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool GetNextAnimationDecoderFrame(IMG_AnimationDecoder* decoder, SDL_Surface** frame, long* pts); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_LoadAnimation", StringMarshalling = StringMarshalling.Utf8)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial IMG_Animation* LoadAnimation(string file); @@ -53,6 +89,11 @@ internal static partial class SDL3_image [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial IMG_Animation* LoadAnimation(SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_ResetAnimationDecoder")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool ResetAnimationDecoder(IMG_AnimationDecoder* decoder); + [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_Save", StringMarshalling = StringMarshalling.Utf8)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [return: MarshalAs(UnmanagedType.I1)] diff --git a/src/KappaDuck.Quack/Video/AnimationFormat.cs b/src/KappaDuck.Quack/Video/AnimationFormat.cs new file mode 100644 index 0000000..3580ac9 --- /dev/null +++ b/src/KappaDuck.Quack/Video/AnimationFormat.cs @@ -0,0 +1,51 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Video; + +/// +/// Represents an animated image format. +/// +public enum AnimationFormat +{ + /// + /// The ANI (Windows animated cursor) format. + /// + ANI = 0, + + /// + /// The APNG (Animated PNG) format. + /// + APNG = 1, + + /// + /// The AVIFS (AVIF image sequence) format. + /// + AVIFS = 2, + + /// + /// The GIF format. + /// + GIF = 3, + + /// + /// The WEBP format. + /// + WEBP = 4 +} + +internal static class AnimationFormatExtensions +{ + extension(AnimationFormat format) + { + public string Type => format switch + { + AnimationFormat.ANI => nameof(AnimationFormat.ANI), + AnimationFormat.APNG => nameof(AnimationFormat.APNG), + AnimationFormat.AVIFS => nameof(AnimationFormat.AVIFS), + AnimationFormat.GIF => nameof(AnimationFormat.GIF), + AnimationFormat.WEBP => nameof(AnimationFormat.WEBP), + _ => throw new NotImplementedException(), + }; + } +} diff --git a/src/KappaDuck.Quack/Video/Pixels/Animation.cs b/src/KappaDuck.Quack/Video/Pixels/Animation.cs new file mode 100644 index 0000000..0a88212 --- /dev/null +++ b/src/KappaDuck.Quack/Video/Pixels/Animation.cs @@ -0,0 +1,157 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Exceptions; +using KappaDuck.Quack.Interop.SDL.Primitives; + +namespace KappaDuck.Quack.Video.Pixels; + +/// +/// A fully decoded animated image (such as a GIF, APNG, animated WebP, AVIF sequence, or Windows ANI cursor), +/// exposing every frame and its display duration. +/// +/// +/// Unlike , which decodes one frame at a time, an holds every +/// frame in memory at once. Prefer it for short clips and cursors; prefer for long or +/// unbounded animations. Frames are owned by the animation and become invalid once it is disposed; disposing an +/// individual frame from does nothing. +/// +public sealed class Animation : IDisposable +{ + private Animation(IMG_Animation* handle, Surface[] frames, TimeSpan[] delays, int loopCount) + { + Handle = handle; + Frames = frames; + Delays = delays; + LoopCount = loopCount; + + unsafe + { + Width = Handle->Width; + Height = Handle->Height; + } + } + + /// + /// Gets the width of every frame, in pixels. + /// + public int Width { get; } + + /// + /// Gets the height of every frame, in pixels. + /// + public int Height { get; } + + /// + /// Gets the number of frames. + /// + public int FrameCount { get; } + + /// + /// Gets every frame, in playback order. + /// + /// Frames are owned by the animation; disposing one individually does nothing, and they become invalid once the animation is disposed. + public IReadOnlyList Frames { get; } + + /// + /// Gets how long each corresponding frame in is shown. + /// + public IReadOnlyList Delays { get; } + + /// + /// Gets the number of times the animation is intended to play, or 0 to loop forever. + /// + public int LoopCount { get; } + + internal IMG_Animation* Handle { get; private set; } + + /// + public void Dispose() + { + if (Handle is null) + return; + + SDL3_image.FreeAnimation(Handle); + Handle = null; + } + + /// + /// Loads every frame of an animated image file into memory. + /// + /// The path to the animated image file. + /// The loaded animation. + /// The file does not exist. + /// The animation could not be loaded. + public static Animation Load(string path) + { + if (!File.Exists(path)) + ThrowHelper.ThrowFileNotFound("The file path does not exist.", path); + + IMG_Animation* handle = SDL3_image.LoadAnimation(path); + SDLThrowHelper.ThrowIfNull(handle); + + return Create(handle); + } + + /// + /// Loads every frame of an animated image from a stream into memory. + /// + /// The stream to read the animated image from. + /// The loaded animation. + /// The animation could not be loaded. + public static Animation Load(Stream stream) + { + using IOStream source = IOStream.FromStream(stream); + + IMG_Animation* handle = SDL3_image.LoadAnimation(source.Handle, closeIO: false); + SDLThrowHelper.ThrowIfNull(handle); + + return Create(handle); + } + + /// + /// Saves the animation to a file. The format is chosen from the file extension. + /// + /// The path to write the animated image to. + /// The animation could not be saved. + /// The animation is disposed. + public void Save(string path) + { + ThrowIfDisposed(); + SDLThrowHelper.ThrowIfFailed(SDL3_image.SaveAnimation(Handle, path)); + } + + /// + /// Saves the animation to a stream, in the given format. + /// + /// The stream to write the animated image to. + /// The animation format to save as. + /// The animation could not be saved. + /// The animation is disposed. + public void Save(Stream stream, AnimationFormat format) + { + ThrowIfDisposed(); + + using IOStream destination = IOStream.FromStream(stream); + SDLThrowHelper.ThrowIfFailed(SDL3_image.SaveAnimation(Handle, destination.Handle, closeIO: false, format.Type)); + } + + private static unsafe Animation Create(IMG_Animation* handle) + { + int count = handle->Count; + + Surface[] frames = new Surface[count]; + TimeSpan[] delays = new TimeSpan[count]; + + for (int i = 0; i < count; i++) + { + frames[i] = new Surface(handle->Frames[i], false); + delays[i] = TimeSpan.FromMilliseconds(handle->Delays[i]); + } + + int loopCount = (int)SDL3.GetNumberProperty(SDL3.GetSurfaceProperties(handle->Frames[0]), "SDL_image.metadata.loop_count", 0); + return new Animation(handle, frames, delays, loopCount); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Handle is null, typeof(Animation)); +} diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs new file mode 100644 index 0000000..2fc99ca --- /dev/null +++ b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs @@ -0,0 +1,214 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Exceptions; +using KappaDuck.Quack.Interop.SDL.Primitives; + +namespace KappaDuck.Quack.Video.Pixels; + +/// +/// Decodes an animated image (such as a GIF, WebP, APNG, or AVIF) one frame at a time. +/// +/// +/// Frames are produced on demand, so only one is decoded into memory at a time — unlike loading a whole animation up +/// front with . Each frame is an independent that the caller owns and +/// must dispose. Read frames with (which reports how long each frame is shown) or +/// (which reports the timestamp to show each frame at), and use +/// to loop back to the first frame. +/// +public sealed class AnimationDecoder : IDisposable +{ + private IMG_AnimationDecoder* _decoder; + private IOStream? _stream; + + /// + /// Opens an animated image file for decoding. The format is detected from the file contents. + /// + /// The path to the animated image file. + /// A decoder positioned before the first frame. + /// The file could not be opened for decoding. + public AnimationDecoder(string path) + { + _decoder = SDL3_image.CreateAnimationDecoder(path); + SDLThrowHelper.ThrowIfNull(_decoder); + + UpdateProperties(); + } + + /// + /// Opens an animated image from a stream for decoding. + /// + /// The stream is read on demand as frames are decoded, so it must stay readable until the decoder is disposed. + /// The stream to read the animated image from. + /// The animation format to decode as. + /// A decoder positioned before the first frame. + /// The stream could not be opened for decoding. + public AnimationDecoder(Stream stream, AnimationFormat format) + { + _stream = IOStream.FromStream(stream); + + try + { + _decoder = SDL3_image.CreateAnimationDecoder(_stream.Handle, false, format.Type); + SDLThrowHelper.ThrowIfNull(_decoder); + + UpdateProperties(); + } + catch + { + _stream.Dispose(); + throw; + } + } + + /// + /// Gets the current state of the decoder. + /// + public AnimationDecoderStatus Status => SDL3_image.GetAnimationDecoderStatus(_decoder); + + /// + /// Gets the number of frames in the animation, or 0 if the format does not report it. + /// + public int FrameCount { get; private set; } + + /// + /// Gets the number of times the animation is intended to play, or 0 to loop forever. + /// + public int LoopCount { get; private set; } + + /// + /// Gets the animation's title, or an empty string if the format does not report one. + /// + public string Title { get; private set; } + + /// + /// Gets the animation's author, or an empty string if the format does not report one. + /// + public string Author { get; private set; } + + /// + /// Gets the animation's description, or an empty string if the format does not report one. + /// + public string Description { get; private set; } + + /// + /// Gets the animation's copyright notice, or an empty string if the format does not report one. + /// + public string Copyright { get; private set; } + + /// + /// Gets the animation's creation time, or an empty string if the format does not report one. + /// + public string CreationTime { get; private set; } + + /// + public void Dispose() + { + if (_decoder is null) + return; + + SDL3_image.CloseAnimationDecoder(_decoder); + _decoder = null; + + _stream?.Dispose(); + _stream = null; + } + + /// + /// Rewinds the decoder to the first frame. + /// + /// The decoder could not be reset. + public void Reset() => SDLThrowHelper.ThrowIfFailed(SDL3_image.ResetAnimationDecoder(_decoder)); + + /// + /// Reads the next frame of the animation. + /// + /// + /// When this method returns , the decoded frame; the caller owns it and must dispose it. + /// Otherwise . + /// + /// + /// When this method returns , how long the frame is shown before the next one. Otherwise + /// . + /// + /// + /// if a frame was decoded; once the animation is complete. + /// + /// Decoding failed. + public bool TryReadFrame([NotNullWhen(true)] out Surface? frame, out TimeSpan duration) + { + SDL_Surface* surface; + ulong milliseconds; + + if (SDL3_image.GetAnimationDecoderFrame(_decoder, &surface, &milliseconds)) + { + frame = new Surface(surface, true); + duration = TimeSpan.FromMilliseconds(milliseconds); + + return true; + } + + frame = null; + duration = TimeSpan.Zero; + + SDLThrowHelper.ThrowIf(Status == AnimationDecoderStatus.Failed); + return false; + } + + /// + /// Reads the next frame of the animation along with the time it should be shown at. + /// + /// + /// Unlike , which reports how long each frame is shown, this reports the timestamp at + /// which the frame should be presented, measured from the start of the animation — convenient for synchronising + /// playback to a clock. + /// + /// + /// When this method returns , the decoded frame; the caller owns it and must dispose it. + /// Otherwise . + /// + /// + /// When this method returns , the time from the start of the animation at which the frame + /// should be shown. Otherwise . + /// + /// + /// if a frame was decoded; once the animation is complete. + /// + /// Decoding failed. + public bool TryReadNextFrame([NotNullWhen(true)] out Surface? frame, out TimeSpan presentationTimestamp) + { + SDL_Surface* surface; + long pts; + + if (SDL3_image.GetNextAnimationDecoderFrame(_decoder, &surface, &pts)) + { + frame = new Surface(surface, owned: true); + + int milliseconds = SDL3_image.GetAnimationDecoderPresentationTimestampMS(_decoder, pts); + presentationTimestamp = TimeSpan.FromMilliseconds(milliseconds); + + return true; + } + + frame = null; + presentationTimestamp = TimeSpan.Zero; + + SDLThrowHelper.ThrowIf(Status == AnimationDecoderStatus.Failed); + return false; + } + + [MemberNotNull(nameof(Title), nameof(Author), nameof(Description), nameof(Copyright), nameof(CreationTime))] + private void UpdateProperties() + { + uint properties = SDL3_image.GetAnimationDecoderProperties(_decoder); + + FrameCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.frame_count", 0); + LoopCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.loop_count", 0); + + Title = SDL3.GetStringProperty(properties, "SDL_image.metadata.title", string.Empty); + Author = SDL3.GetStringProperty(properties, "SDL_image.metadata.author", string.Empty); + Description = SDL3.GetStringProperty(properties, "SDL_image.metadata.description", string.Empty); + Copyright = SDL3.GetStringProperty(properties, "SDL_image.metadata.copyright", string.Empty); + CreationTime = SDL3.GetStringProperty(properties, "SDL_image.metadata.creation_time", string.Empty); + } +} diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationDecoderStatus.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoderStatus.cs new file mode 100644 index 0000000..a5eb9ea --- /dev/null +++ b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoderStatus.cs @@ -0,0 +1,30 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Video.Pixels; + +/// +/// Describes the current state of an . +/// +public enum AnimationDecoderStatus +{ + /// + /// The decoder is invalid, for example because it has already been closed. + /// + Invalid = -1, + + /// + /// The decoder is active and more frames may still be available. + /// + Decoding = 0, + + /// + /// Decoding failed. The most recent read did not produce a frame because of an error. + /// + Failed = 1, + + /// + /// The end of the animation has been reached and every frame has been read. + /// + Complete = 2 +} diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs new file mode 100644 index 0000000..7f62b1c --- /dev/null +++ b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs @@ -0,0 +1,122 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Exceptions; +using KappaDuck.Quack.Interop.SDL.Primitives; + +namespace KappaDuck.Quack.Video.Pixels; + +/// +/// Encodes frames into an animated image (such as a GIF, APNG, WebP, or AVIF sequence) one frame at a time. +/// +/// +/// Frames are written as they are added, so memory use stays proportional to a single frame rather than the whole +/// animation — the inverse of , which holds every frame in memory at once. Add every frame +/// with , then dispose the encoder to finish writing. +/// +public sealed class AnimationEncoder : IDisposable +{ + private IMG_AnimationEncoder* _encoder; + private IOStream? _stream; + + /// + /// Creates an encoder that writes to a file. The format is chosen from the file extension. + /// + /// The path to write the animated image to. + /// Additional encoding options, or for the format's defaults. + /// An encoder ready to accept frames. + /// The file could not be opened for encoding. + public AnimationEncoder(string path, AnimationEncoderOptions? options = null) + { + using Properties properties = BuildProperties(options); + properties.Set("SDL_image.animation_encoder.create.filename", path); + + _encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties); + SDLThrowHelper.ThrowIfNull(_encoder); + } + + /// + /// Creates an encoder that writes to a stream. + /// + /// The stream to write the animated image to. + /// The animation format to encode as. + /// Additional encoding options, or for the format's defaults. + /// An encoder ready to accept frames. + /// The stream could not be opened for encoding. + public AnimationEncoder(Stream stream, AnimationFormat format, AnimationEncoderOptions? options = null) + { + _stream = IOStream.FromStream(stream); + + using Properties properties = BuildProperties(options); + properties.Set("SDL_image.animation_encoder.create.iostream", _stream.Handle); + properties.Set("SDL_image.animation_encoder.create.type", format.Type); + + try + { + _encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties); + SDLThrowHelper.ThrowIfNull(_encoder); + } + catch + { + _stream.Dispose(); + throw; + } + } + + /// + /// Adds the next frame to the animation. + /// + /// The frame to encode. + /// How long the frame is shown before the next one. + /// The frame could not be encoded. + /// The encoder is disposed. + public void AddFrame(Surface frame, TimeSpan duration) + { + ObjectDisposedException.ThrowIf(_encoder is null, typeof(AnimationEncoder)); + SDLThrowHelper.ThrowIfFailed(SDL3_image.AddAnimationEncoderFrame(_encoder, frame.Handle, (ulong)duration.TotalMilliseconds)); + } + + /// + /// Finishes encoding and releases the encoder's resources. + /// + /// The animation could not be finalized. + public void Dispose() + { + if (_encoder is null) + _encoder = null; + + SDLThrowHelper.ThrowIfFailed(SDL3_image.CloseAnimationEncoder(_encoder)); + _encoder = null; + + _stream?.Dispose(); + _stream = null; + } + + private static Properties BuildProperties(AnimationEncoderOptions? options) + { + Properties properties = new(); + + if (options is null) + return properties; + + if (options.Quality.HasValue) + properties.Set("SDL_image.animation_encoder.create.quality", options.Quality.Value); + + if (options.TimebaseNumerator.HasValue) + properties.Set("SDL_image.animation_encoder.create.timebase.numerator", options.TimebaseNumerator.Value); + + if (options.TimebaseDenominator.HasValue) + properties.Set("SDL_image.animation_encoder.create.timebase.denominator", options.TimebaseDenominator.Value); + + if (options.GifUseLookupTable.HasValue) + properties.Set("SDL_image.animation_encoder.create.gif.use_lut", options.GifUseLookupTable.Value); + + if (options.AvifKeyframeInterval.HasValue) + properties.Set("SDL_image.animation_encoder.create.avif.keyframe_interval", options.AvifKeyframeInterval.HasValue); + + if (options.AvifMaxThreads.HasValue) + properties.Set("SDL_image.animation_encoder.create.avif.max_threads", options.AvifMaxThreads.Value); + + return properties; + } +} diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationEncoderOptions.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoderOptions.cs new file mode 100644 index 0000000..121ef0a --- /dev/null +++ b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoderOptions.cs @@ -0,0 +1,47 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Video.Pixels; + +/// +/// Optional settings for . +/// +/// +/// Every setting is optional; unset ones fall back to the format's own default. Settings that don't apply to the +/// chosen format (for example when encoding a GIF) are ignored. +/// +public sealed class AnimationEncoderOptions +{ + /// + /// Gets or sets the encoding quality, from 0 to 100, for formats that support it (AVIF, WebP). Higher is better + /// quality and larger output. + /// + public int? Quality { get; set; } + + /// + /// Gets or sets the numerator of the fraction used to convert a frame's duration to seconds. Defaults to 1. + /// + public int? TimebaseNumerator { get; set; } + + /// + /// Gets or sets the denominator of the fraction used to convert a frame's duration to seconds. Defaults to 1000, + /// meaning durations passed to are interpreted as milliseconds. + /// + public int? TimebaseDenominator { get; set; } + + /// + /// Gets or sets whether GIF encoding reuses a single shared color table across every frame instead of computing + /// a fresh palette per frame. + /// + public bool? GifUseLookupTable { get; set; } + + /// + /// Gets or sets how often AVIF encoding inserts a keyframe. + /// + public int? AvifKeyframeInterval { get; set; } + + /// + /// Gets or sets the maximum number of threads used for AVIF encoding. + /// + public int? AvifMaxThreads { get; set; } +} From c1d8876a62b15467df4dbfcf27df20aaf77c7a0c Mon Sep 17 00:00:00 2001 From: beauchama Date: Tue, 7 Jul 2026 22:18:38 -0400 Subject: [PATCH 3/3] Added AnimatedSprite --- .../Graphics/Drawing/AnimatedSprite.cs | 131 +++++++++++++ .../Graphics/Drawing/SpriteAnimation.cs | 82 ++++++++ .../Graphics/Drawing/SpriteAtlas.cs | 185 ++++++++++++++++++ .../Graphics/Drawing/SpriteSheet.cs | 173 ++++++++++++++++ src/KappaDuck.Quack/Video/Pixels/Animation.cs | 4 +- .../Video/Pixels/AnimationDecoder.cs | 69 +++---- .../Video/Pixels/AnimationEncoder.cs | 53 +++-- 7 files changed, 641 insertions(+), 56 deletions(-) create mode 100644 src/KappaDuck.Quack/Graphics/Drawing/AnimatedSprite.cs create mode 100644 src/KappaDuck.Quack/Graphics/Drawing/SpriteAnimation.cs create mode 100644 src/KappaDuck.Quack/Graphics/Drawing/SpriteAtlas.cs create mode 100644 src/KappaDuck.Quack/Graphics/Drawing/SpriteSheet.cs diff --git a/src/KappaDuck.Quack/Graphics/Drawing/AnimatedSprite.cs b/src/KappaDuck.Quack/Graphics/Drawing/AnimatedSprite.cs new file mode 100644 index 0000000..aacd14e --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Drawing/AnimatedSprite.cs @@ -0,0 +1,131 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Rendering; + +namespace KappaDuck.Quack.Graphics.Drawing; + +/// +/// A that plays a from a , cycling its +/// displayed frame over time. +/// +/// +/// Choose a clip with , advance it with +/// once per frame, then draw it like any drawable with . Flip the sprite +/// with a negative . is idempotent, so calling it +/// every frame while a key is held keeps the animation running rather than restarting it. +/// +/// +/// Creates an animated sprite that draws frames from the given sheet. +/// +/// The sheet the animation frames are taken from. +public sealed class AnimatedSprite(SpriteSheet sheet) + : Sprite(sheet.Texture, sheet.Count > 0 ? sheet[0] : new RectI(0, 0, sheet.Texture.Width, sheet.Texture.Height)) +{ + private TimeSpan _elapsed; + private int _frameIndex; + + /// + /// Gets or sets the sheet the animation frames are taken from. + /// + public SpriteSheet Sheet + { + get; + set + { + field = value; + Texture = value.Texture; + + UpdateRegion(); + } + } = sheet; + + /// + /// Gets the animation currently playing, or if none has been played yet. + /// + public SpriteAnimation? Animation { get; private set; } + + /// + /// Gets a value indicating whether an animation is currently playing. + /// + public bool IsPlaying { get; private set; } + + /// + /// Plays an animation from its first frame, or does nothing if it is already the animation playing. + /// + /// The animation to play. + public void Play(SpriteAnimation animation) + { + if (ReferenceEquals(Animation, animation) && IsPlaying) + return; + + Animation = animation; + _elapsed = TimeSpan.Zero; + _frameIndex = 0; + IsPlaying = true; + + UpdateRegion(); + } + + /// + /// Stops the animation and rewinds it to its first frame. + /// + public void Stop() + { + IsPlaying = false; + _elapsed = TimeSpan.Zero; + _frameIndex = 0; + + UpdateRegion(); + } + + /// + /// Pauses the animation on its current frame. + /// + public void Pause() => IsPlaying = false; + + /// + /// Resumes a paused animation from its current frame. + /// + public void Resume() => IsPlaying = Animation is not null; + + /// + /// Advances the current animation by the elapsed time. + /// + /// The time since the last update, such as the frame delta. + public void Update(TimeSpan deltaTime) + { + if (!IsPlaying || Animation is null) + return; + + _elapsed += deltaTime; + + while (_elapsed >= Animation.DurationAt(_frameIndex)) + { + _elapsed -= Animation.DurationAt(_frameIndex); + _frameIndex++; + + if (_frameIndex < Animation.FrameCount) + continue; + + if (Animation.Loop) + { + _frameIndex = 0; + continue; + } + + _frameIndex = Animation.FrameCount - 1; + IsPlaying = false; + break; + } + + UpdateRegion(); + } + + private void UpdateRegion() + { + if (Animation is not null) + Region = Sheet[Animation.FrameAt(_frameIndex)]; + } +} diff --git a/src/KappaDuck.Quack/Graphics/Drawing/SpriteAnimation.cs b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAnimation.cs new file mode 100644 index 0000000..a670762 --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAnimation.cs @@ -0,0 +1,82 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Graphics.Drawing; + +/// +/// A reusable animation clip: a sequence of frame indices, each shown for a duration, with +/// an optional loop. +/// +/// +/// A clip is independent of any particular sprite; the same clip can be played by several +/// instances. Frame indices refer to the sheet the animation is played on. +/// +public sealed class SpriteAnimation +{ + private readonly int[] _frames; + private readonly TimeSpan[] _durations; + + /// + /// Creates an animation whose frames are all shown for the same duration. + /// + /// The sheet frame indices, in playback order. + /// How long each frame is shown. + /// to restart from the first frame after the last; otherwise . + public SpriteAnimation(ReadOnlySpan frames, TimeSpan frameDuration, bool loop = true) + { + _frames = frames.ToArray(); + _durations = new TimeSpan[_frames.Length]; + + Array.Fill(_durations, frameDuration); + + Loop = loop; + Duration = frameDuration * _frames.Length; + } + + /// + /// Creates an animation with a separate duration for each frame. + /// + /// The sheet frame indices, in playback order. + /// How long each corresponding frame is shown. Must have the same length as . + /// to restart from the first frame after the last; otherwise . + /// and have different lengths. + public SpriteAnimation(ReadOnlySpan frames, ReadOnlySpan durations, bool loop = true) + { + ArgumentOutOfRangeException.ThrowIfNotEqual(frames.Length, durations.Length); + + _frames = frames.ToArray(); + _durations = durations.ToArray(); + + Loop = loop; + Duration = Sum(durations); + } + + /// + /// Gets or sets a value indicating whether the animation restarts after its last frame. + /// + public bool Loop { get; set; } + + /// + /// Gets the number of frames in the animation. + /// + public int FrameCount => _frames.Length; + + /// + /// Gets the total time for one pass through every frame. + /// + public TimeSpan Duration { get; } + + internal int FrameAt(int index) => _frames[index]; + + internal TimeSpan DurationAt(int index) => _durations[index]; + + private static TimeSpan Sum(ReadOnlySpan durations) + { + TimeSpan total = TimeSpan.Zero; + + foreach (TimeSpan duration in durations) + total += duration; + + return total; + } +} diff --git a/src/KappaDuck.Quack/Graphics/Drawing/SpriteAtlas.cs b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAtlas.cs new file mode 100644 index 0000000..6d7597c --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAtlas.cs @@ -0,0 +1,185 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Exceptions; +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Rendering; +using KappaDuck.Quack.Video.Pixels; + +namespace KappaDuck.Quack.Graphics.Drawing; + +/// +/// A set of pages divided into frames, addressed by index. +/// +/// +/// The multi-page counterpart to : where a sheet always packs frames into exactly one +/// texture and fails if they don't fit, an atlas spreads them across as many pages as needed, so animations with a +/// large frame count or large frame dimensions can still be loaded with . +/// The trade-off is that consecutive frames can land on different pages, so drawing across a page boundary costs a +/// texture rebind. Animations that comfortably fit one texture, prefer . +/// +public sealed class SpriteAtlas : IDisposable +{ + private readonly Texture[] _pages; + private readonly (int Page, RectI Region)[] _frames; + private bool _disposed; + + private SpriteAtlas(List pages, (int Page, RectI Region)[] frames) + { + _pages = [.. pages]; + _frames = frames; + } + + /// + /// Gets every texture page. Frames in reference these by index. + /// + public IReadOnlyList Pages => _pages; + + /// + /// Gets the number of frames in the atlas. + /// + public int Count => _frames.Length; + + /// + /// Gets the page and source rectangle of a frame, in that page's texture pixels. + /// + /// The frame index, from 0 to minus one. + /// The page the frame is packed on, and its source rectangle within that page. + public (Texture Texture, RectI Region) this[int index] + { + get + { + (int page, RectI region) = _frames[index]; + return (_pages[page], region); + } + } + + /// + /// Loads an animated image (such as a GIF, APNG or animated WebP) into a sprite atlas and a matching animation. + /// + /// + /// Frames are packed into as few pages as possible: each page is filled as a grid bounded by + /// before a new one is started, so a page is only ever as large as the frames + /// it actually holds. Every returned texture is created here and owned by the caller; + /// disposing the atlas disposes every page. The returned animation uses each frame's own duration and loops. + /// + /// The renderer used to create the packed textures. + /// The path to the animated image file. + /// + /// The maximum width and height, in pixels, of each packed page. 4096 is a conservative default that fits within + /// common GPU limits; raise it if the target hardware supports larger textures. + /// + /// The packed atlas and an animation that plays its frames in order. + /// The file does not exist. + /// is negative or zero. + /// A single frame is larger than in either dimension. + /// The animation could not be loaded. + public static (SpriteAtlas Atlas, SpriteAnimation Animation) LoadAnimation(Renderer renderer, string path, int maxAtlasWidth = 4096) + { + using Animation animation = Animation.Load(path); + return Pack(renderer, animation, maxAtlasWidth); + } + + /// + /// Loads an animated image from a stream into a sprite atlas and a matching animation. + /// + /// + /// Frames are packed into as few pages as possible: each page is filled as a grid bounded by + /// before a new one is started, so a page is only ever as large as the frames + /// it actually holds. Every returned texture is created here and owned by the caller; + /// disposing the atlas disposes every page. The returned animation uses each frame's own duration and loops. + /// + /// The renderer used to create the packed textures. + /// The stream to read the animated image from. + /// + /// The maximum width and height, in pixels, of each packed page. 4096 is a conservative default that fits within + /// common GPU limits; raise it if the target hardware supports larger textures. + /// + /// The packed atlas and an animation that plays its frames in order. + /// is negative or zero. + /// A single frame is larger than in either dimension. + /// The animation could not be loaded. + public static (SpriteAtlas Atlas, SpriteAnimation Animation) LoadAnimation(Renderer renderer, Stream stream, int maxAtlasWidth = 4096) + { + using Animation animation = Animation.Load(stream); + return Pack(renderer, animation, maxAtlasWidth); + } + + /// + /// Disposes every page in the atlas. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + foreach (Texture page in _pages) + page.Dispose(); + } + + private static (SpriteAtlas Atlas, SpriteAnimation Animation) Pack(Renderer renderer, Animation animation, int maxAtlasWidth) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxAtlasWidth); + + int frameWidth = animation.Width; + int frameHeight = animation.Height; + int count = animation.FrameCount; + + if (frameWidth > maxAtlasWidth || frameHeight > maxAtlasWidth) + { + ThrowHelper.ThrowInvalidOperation($"This animation's frames are {frameWidth}x{frameHeight}, which alone exceeds the {maxAtlasWidth} maximum atlas dimension. Raise maxAtlasWidth if the target hardware supports a larger texture."); + } + + int columns = Math.Max(1, maxAtlasWidth / frameWidth); + int rowsPerPage = Math.Max(1, maxAtlasWidth / frameHeight); + int framesPerPage = columns * rowsPerPage; + + List pages = []; + (int Page, RectI Region)[] frames = new (int, RectI)[count]; + TimeSpan[] durations = new TimeSpan[count]; + + int frameIndex = 0; + + try + { + while (frameIndex < count) + { + int framesOnPage = Math.Min(framesPerPage, count - frameIndex); + int rows = (framesOnPage + columns - 1) / columns; + + using Surface page = new(columns * frameWidth, rows * frameHeight, animation.Frames[0].Format); + + for (int i = 0; i < framesOnPage; i++) + { + RectI region = new(i % columns * frameWidth, i / columns * frameHeight, frameWidth, frameHeight); + + page.Blit(animation.Frames[frameIndex], destination: region); + + frames[frameIndex] = (pages.Count, region); + durations[frameIndex] = animation.Delays[frameIndex]; + + frameIndex++; + } + + pages.Add(Texture.FromSurface(renderer, page)); + } + } + catch + { + foreach (Texture page in pages) + page.Dispose(); + + throw; + } + + SpriteAtlas atlas = new(pages, frames); + + int[] indexes = new int[count]; + for (int i = 0; i < count; i++) + indexes[i] = i; + + return (atlas, new SpriteAnimation(indexes, durations)); + } +} diff --git a/src/KappaDuck.Quack/Graphics/Drawing/SpriteSheet.cs b/src/KappaDuck.Quack/Graphics/Drawing/SpriteSheet.cs new file mode 100644 index 0000000..16fafc7 --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Drawing/SpriteSheet.cs @@ -0,0 +1,173 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Exceptions; +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Rendering; +using KappaDuck.Quack.Video.Pixels; + +namespace KappaDuck.Quack.Graphics.Drawing; + +/// +/// A divided into a set of frames, addressed by index. +/// +/// +/// Build one from a uniform grid (the common case for character and tile sheets), from an explicit list of rectangles +/// for irregular sheets, or from an animated image with . It is a +/// lightweight view onto the texture; it does not own or dispose it, except a sheet produced by +/// , whose texture the caller owns. +/// +public sealed class SpriteSheet +{ + private readonly RectI[] _frames; + + /// + /// Creates a sprite sheet from a uniform grid of frames. + /// + /// Frames are numbered left to right, then top to bottom. Any partial frame at the right or bottom edge is ignored. + /// The texture to slice. + /// The width of each frame, in texture pixels. + /// The height of each frame, in texture pixels. + /// or is negative or zero. + public SpriteSheet(Texture texture, int frameWidth, int frameHeight) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameWidth); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameHeight); + + Texture = texture; + + int columns = texture.Width / frameWidth; + int rows = texture.Height / frameHeight; + + _frames = new RectI[columns * rows]; + + for (int row = 0; row < rows; row++) + { + for (int column = 0; column < columns; column++) + _frames[(row * columns) + column] = new RectI(column * frameWidth, row * frameHeight, frameWidth, frameHeight); + } + } + + /// + /// Creates a sprite sheet from an explicit list of frame rectangles. + /// + /// The texture to slice. + /// The frame rectangles, in texture pixels. + public SpriteSheet(Texture texture, params ReadOnlySpan frames) + { + Texture = texture; + _frames = frames.ToArray(); + } + + /// + /// Gets the texture the frames are taken from. + /// + public Texture Texture { get; } + + /// + /// Gets the number of frames in the sheet. + /// + public int Count => _frames.Length; + + /// + /// Gets the source rectangle of a frame, in texture pixels. + /// + /// The frame index, from 0 to minus one. + /// The frame's source rectangle. + public RectI this[int index] => _frames[index]; + + /// + /// Loads an animated image (such as a GIF, APNG or animated WebP) into a sprite sheet and a matching animation. + /// + /// + /// Every frame is packed into a single texture — one texture bind for every sprite that plays this clip, instead + /// of one per frame. Frames are arranged in a grid bounded by rather than a single + /// row, so long or wide animations don't produce a texture wider than the GPU can allocate. The returned sheet's + /// is created here and is owned by the caller; dispose it when finished. The returned + /// animation uses each frame's own duration and loops. + /// + /// The renderer used to create the packed texture. + /// The path to the animated image file. + /// + /// The maximum width, in pixels, of the packed texture. 4096 is a conservative default that fits within common + /// GPU limits; raise it if the target hardware supports wider textures. + /// + /// The packed sheet and an animation that plays its frames in order. + /// The file does not exist. + /// is negative or zero. + /// The animation's frames don't fit in a single texture within . + /// The animation could not be loaded. + public static (SpriteSheet Sheet, SpriteAnimation Animation) LoadAnimation(Renderer renderer, string path, int maxAtlasWidth = 4096) + { + using Animation animation = Animation.Load(path); + return Pack(renderer, animation, maxAtlasWidth); + } + + /// + /// Loads an animated image from a stream into a sprite sheet and a matching animation. + /// + /// + /// Every frame is packed into a single texture — one texture bind for every sprite that plays this clip, instead + /// of one per frame. Frames are arranged in a grid bounded by rather than a single + /// row, so long or wide animations don't produce a texture wider than the GPU can allocate. The returned sheet's + /// is created here and is owned by the caller; dispose it when finished. The returned + /// animation uses each frame's own duration and loops. + /// + /// The renderer used to create the packed texture. + /// The stream to read the animated image from. + /// + /// The maximum width, in pixels, of the packed texture. 4096 is a conservative default that fits within common + /// GPU limits; raise it if the target hardware supports wider textures. + /// + /// The packed sheet and an animation that plays its frames in order. + /// is negative or zero. + /// The animation's frames don't fit in a single texture within . + /// The animation could not be loaded. + public static (SpriteSheet Sheet, SpriteAnimation Animation) LoadAnimation(Renderer renderer, Stream stream, int maxAtlasWidth = 4096) + { + using Animation animation = Animation.Load(stream); + return Pack(renderer, animation, maxAtlasWidth); + } + + private static (SpriteSheet Sheet, SpriteAnimation Animation) Pack(Renderer renderer, Animation animation, int maxAtlasWidth) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxAtlasWidth); + + int frameWidth = animation.Width; + int frameHeight = animation.Height; + int count = animation.FrameCount; + + int columns = Math.Clamp(maxAtlasWidth / frameWidth, 1, count); + int rows = (count + columns - 1) / columns; + + int atlasWidth = columns * frameWidth; + int atlasHeight = rows * frameHeight; + + if (atlasWidth > maxAtlasWidth || atlasHeight > maxAtlasWidth) + ThrowHelper.ThrowInvalidOperation($"This animation has {count} frames of {frameWidth}x{frameHeight}; packed as a grid it needs a {atlasWidth}x{atlasHeight} texture, which exceeds the {maxAtlasWidth} maximum atlas dimension. Raise maxAtlasWidth if the target hardware supports a larger texture, or decode this animation frame-by-frame with AnimationDecoder instead of atlasing it."); + + using Surface atlas = new(atlasWidth, atlasHeight, animation.Frames[0].Format); + + RectI[] regions = new RectI[count]; + TimeSpan[] durations = new TimeSpan[count]; + + for (int i = 0; i < count; i++) + { + RectI region = new(i % columns * frameWidth, i / columns * frameHeight, frameWidth, frameHeight); + + atlas.Blit(animation.Frames[i], destination: region); + + regions[i] = region; + durations[i] = animation.Delays[i]; + } + + Texture texture = Texture.FromSurface(renderer, atlas); + SpriteSheet sheet = new(texture, regions); + + int[] frames = new int[count]; + for (int i = 0; i < count; i++) + frames[i] = i; + + return (sheet, new SpriteAnimation(frames, durations)); + } +} diff --git a/src/KappaDuck.Quack/Video/Pixels/Animation.cs b/src/KappaDuck.Quack/Video/Pixels/Animation.cs index 0a88212..d962261 100644 --- a/src/KappaDuck.Quack/Video/Pixels/Animation.cs +++ b/src/KappaDuck.Quack/Video/Pixels/Animation.cs @@ -103,7 +103,7 @@ public static Animation Load(Stream stream) { using IOStream source = IOStream.FromStream(stream); - IMG_Animation* handle = SDL3_image.LoadAnimation(source.Handle, closeIO: false); + IMG_Animation* handle = SDL3_image.LoadAnimation(source.Handle, false); SDLThrowHelper.ThrowIfNull(handle); return Create(handle); @@ -133,7 +133,7 @@ public void Save(Stream stream, AnimationFormat format) ThrowIfDisposed(); using IOStream destination = IOStream.FromStream(stream); - SDLThrowHelper.ThrowIfFailed(SDL3_image.SaveAnimation(Handle, destination.Handle, closeIO: false, format.Type)); + SDLThrowHelper.ThrowIfFailed(SDL3_image.SaveAnimation(Handle, destination.Handle, false, format.Type)); } private static unsafe Animation Create(IMG_Animation* handle) diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs index 2fc99ca..842914c 100644 --- a/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs +++ b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs @@ -21,18 +21,35 @@ public sealed class AnimationDecoder : IDisposable private IMG_AnimationDecoder* _decoder; private IOStream? _stream; + private AnimationDecoder(IMG_AnimationDecoder* decoder, IOStream? stream) + { + _decoder = decoder; + _stream = stream; + + uint properties = SDL3_image.GetAnimationDecoderProperties(decoder); + + FrameCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.frame_count", 0); + LoopCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.loop_count", 0); + + Title = SDL3.GetStringProperty(properties, "SDL_image.metadata.title", string.Empty); + Author = SDL3.GetStringProperty(properties, "SDL_image.metadata.author", string.Empty); + Description = SDL3.GetStringProperty(properties, "SDL_image.metadata.description", string.Empty); + Copyright = SDL3.GetStringProperty(properties, "SDL_image.metadata.copyright", string.Empty); + CreationTime = SDL3.GetStringProperty(properties, "SDL_image.metadata.creation_time", string.Empty); + } + /// /// Opens an animated image file for decoding. The format is detected from the file contents. /// /// The path to the animated image file. /// A decoder positioned before the first frame. /// The file could not be opened for decoding. - public AnimationDecoder(string path) + public static AnimationDecoder Open(string path) { - _decoder = SDL3_image.CreateAnimationDecoder(path); - SDLThrowHelper.ThrowIfNull(_decoder); + IMG_AnimationDecoder* decoder = SDL3_image.CreateAnimationDecoder(path); + SDLThrowHelper.ThrowIfNull(decoder); - UpdateProperties(); + return new AnimationDecoder(decoder, null); } /// @@ -43,22 +60,23 @@ public AnimationDecoder(string path) /// The animation format to decode as. /// A decoder positioned before the first frame. /// The stream could not be opened for decoding. - public AnimationDecoder(Stream stream, AnimationFormat format) + public static AnimationDecoder Open(Stream stream, AnimationFormat format) { - _stream = IOStream.FromStream(stream); + IOStream source = IOStream.FromStream(stream); + IMG_AnimationDecoder* decoder; try { - _decoder = SDL3_image.CreateAnimationDecoder(_stream.Handle, false, format.Type); - SDLThrowHelper.ThrowIfNull(_decoder); - - UpdateProperties(); + decoder = SDL3_image.CreateAnimationDecoder(source.Handle, false, format.Type); + SDLThrowHelper.ThrowIfNull(decoder); } catch { - _stream.Dispose(); + source.Dispose(); throw; } + + return new AnimationDecoder(decoder, source); } /// @@ -69,37 +87,37 @@ public AnimationDecoder(Stream stream, AnimationFormat format) /// /// Gets the number of frames in the animation, or 0 if the format does not report it. /// - public int FrameCount { get; private set; } + public int FrameCount { get; } /// /// Gets the number of times the animation is intended to play, or 0 to loop forever. /// - public int LoopCount { get; private set; } + public int LoopCount { get; } /// /// Gets the animation's title, or an empty string if the format does not report one. /// - public string Title { get; private set; } + public string Title { get; } /// /// Gets the animation's author, or an empty string if the format does not report one. /// - public string Author { get; private set; } + public string Author { get; } /// /// Gets the animation's description, or an empty string if the format does not report one. /// - public string Description { get; private set; } + public string Description { get; } /// /// Gets the animation's copyright notice, or an empty string if the format does not report one. /// - public string Copyright { get; private set; } + public string Copyright { get; } /// /// Gets the animation's creation time, or an empty string if the format does not report one. /// - public string CreationTime { get; private set; } + public string CreationTime { get; } /// public void Dispose() @@ -196,19 +214,4 @@ public bool TryReadNextFrame([NotNullWhen(true)] out Surface? frame, out TimeSpa SDLThrowHelper.ThrowIf(Status == AnimationDecoderStatus.Failed); return false; } - - [MemberNotNull(nameof(Title), nameof(Author), nameof(Description), nameof(Copyright), nameof(CreationTime))] - private void UpdateProperties() - { - uint properties = SDL3_image.GetAnimationDecoderProperties(_decoder); - - FrameCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.frame_count", 0); - LoopCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.loop_count", 0); - - Title = SDL3.GetStringProperty(properties, "SDL_image.metadata.title", string.Empty); - Author = SDL3.GetStringProperty(properties, "SDL_image.metadata.author", string.Empty); - Description = SDL3.GetStringProperty(properties, "SDL_image.metadata.description", string.Empty); - Copyright = SDL3.GetStringProperty(properties, "SDL_image.metadata.copyright", string.Empty); - CreationTime = SDL3.GetStringProperty(properties, "SDL_image.metadata.creation_time", string.Empty); - } } diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs index 7f62b1c..88e056d 100644 --- a/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs +++ b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs @@ -19,6 +19,25 @@ public sealed class AnimationEncoder : IDisposable private IMG_AnimationEncoder* _encoder; private IOStream? _stream; + private AnimationEncoder(IMG_AnimationEncoder* encoder, IOStream? stream) + { + _encoder = encoder; + _stream = stream; + } + + /// + /// Adds the next frame to the animation. + /// + /// The frame to encode. + /// How long the frame is shown before the next one. + /// The frame could not be encoded. + /// The encoder is disposed. + public void AddFrame(Surface frame, TimeSpan duration) + { + ObjectDisposedException.ThrowIf(_encoder is null, typeof(AnimationEncoder)); + SDLThrowHelper.ThrowIfFailed(SDL3_image.AddAnimationEncoderFrame(_encoder, frame.Handle, (ulong)duration.TotalMilliseconds)); + } + /// /// Creates an encoder that writes to a file. The format is chosen from the file extension. /// @@ -26,13 +45,15 @@ public sealed class AnimationEncoder : IDisposable /// Additional encoding options, or for the format's defaults. /// An encoder ready to accept frames. /// The file could not be opened for encoding. - public AnimationEncoder(string path, AnimationEncoderOptions? options = null) + public static AnimationEncoder Create(string path, AnimationEncoderOptions? options = null) { using Properties properties = BuildProperties(options); properties.Set("SDL_image.animation_encoder.create.filename", path); - _encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties); - SDLThrowHelper.ThrowIfNull(_encoder); + IMG_AnimationEncoder* encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties); + SDLThrowHelper.ThrowIfNull(encoder); + + return new AnimationEncoder(encoder, null); } /// @@ -43,37 +64,27 @@ public AnimationEncoder(string path, AnimationEncoderOptions? options = null) /// Additional encoding options, or for the format's defaults. /// An encoder ready to accept frames. /// The stream could not be opened for encoding. - public AnimationEncoder(Stream stream, AnimationFormat format, AnimationEncoderOptions? options = null) + public static AnimationEncoder Create(Stream stream, AnimationFormat format, AnimationEncoderOptions? options = null) { - _stream = IOStream.FromStream(stream); + IOStream destination = IOStream.FromStream(stream); using Properties properties = BuildProperties(options); - properties.Set("SDL_image.animation_encoder.create.iostream", _stream.Handle); + properties.Set("SDL_image.animation_encoder.create.iostream", destination.Handle); properties.Set("SDL_image.animation_encoder.create.type", format.Type); + IMG_AnimationEncoder* encoder; try { - _encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties); - SDLThrowHelper.ThrowIfNull(_encoder); + encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties); + SDLThrowHelper.ThrowIfNull(encoder); } catch { - _stream.Dispose(); + destination.Dispose(); throw; } - } - /// - /// Adds the next frame to the animation. - /// - /// The frame to encode. - /// How long the frame is shown before the next one. - /// The frame could not be encoded. - /// The encoder is disposed. - public void AddFrame(Surface frame, TimeSpan duration) - { - ObjectDisposedException.ThrowIf(_encoder is null, typeof(AnimationEncoder)); - SDLThrowHelper.ThrowIfFailed(SDL3_image.AddAnimationEncoderFrame(_encoder, frame.Handle, (ulong)duration.TotalMilliseconds)); + return new(encoder, destination); } ///