Load and render sprat-cli sprite sheets with SDL3.
A small C99 library in the style of SDL's own satellite libraries (SDL_image,
SDL_ttf): SPRAT_ functions that return NULL or false and set SDL_GetError(),
SDL types (SDL_FRect, SDL_FPoint, SDL_FlipMode, SDL_IOStream), and no
dependencies beyond SDL. It reads the metadata written by
spratconvert --transform raw through sprat-lib,
loads the atlas textures next to it, and renders sprites the way they were
authored: placed by their pivot, with trimming and packing rotation undone.
spratlayout ./frames --trim-transparent --rotate > layout.txt
spratpack -a atlas.png < layout.txt > /dev/null
spratconvert --transform raw -a atlas.png \
--markers markers.txt --animations animations.txt < layout.txt > hero.sprat#define SDL_MAIN_USE_CALLBACKS 1
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_sprat/SDL_sprat.h>
static SDL_Window *window;
static SDL_Renderer *renderer;
static SPRAT_Sheet *sheet;
static SPRAT_AnimationPlayer run;
static Uint64 last;
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
{
SDL_Init(SDL_INIT_VIDEO);
SDL_CreateWindowAndRenderer("sprat-sdl", 800, 450, 0, &window, &renderer);
sheet = SPRAT_LoadSheet(renderer, "resources/hero.sprat");
if (!sheet) {
SDL_Log("Couldn't load the sheet: %s", SDL_GetError());
return SDL_APP_FAILURE;
}
SPRAT_InitAnimationPlayer(&run, SPRAT_GetAnimationFromName(sheet, "run"));
last = SDL_GetTicksNS();
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppIterate(void *appstate)
{
Uint64 now = SDL_GetTicksNS();
SPRAT_UpdateAnimation(&run, (float)(now - last) / SDL_NS_PER_SECOND);
last = now;
SDL_SetRenderDrawColor(renderer, 245, 245, 245, 255);
SDL_RenderClear(renderer);
SPRAT_RenderAnimation(renderer, sheet, &run, 400.0f, 300.0f);
SDL_RenderPresent(renderer);
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
{
return (event->type == SDL_EVENT_QUIT) ? SDL_APP_SUCCESS : SDL_APP_CONTINUE;
}
void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
SPRAT_DestroySheet(sheet);
}sprat-sdl needs SDL 3.4 or newer and sprat-lib 0.7 or newer. With CMake,
sprat-sdl::sprat-sdl brings SDL3 along:
add_subdirectory(sprat-sdl) # or find_package(sprat-sdl) after an install
target_link_libraries(game PRIVATE sprat-sdl::sprat-sdl)#include <SDL3_sprat/SDL_sprat.h>Without CMake, compile src/SDL_sprat.c with your game:
cc game.c src/SDL_sprat.c -Iinclude -I../sprat-lib/include ../sprat-lib/src/*.c $(pkg-config --cflags --libs sdl3)The CMake build uses installed SDL3 and sprat packages when it finds them and
downloads them otherwise. To build against a local checkout, pass
-DFETCHCONTENT_SOURCE_DIR_SPRAT=../sprat-lib (or FETCHCONTENT_SOURCE_DIR_SDL3).
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build --parallel
ctest --test-dir build --output-on-failure
./build/bin/sprite_sheet| Option | Default | Effect |
|---|---|---|
BUILD_SHARED_LIBS |
OFF |
Build a shared library |
SPRAT_SDL_BUILD_EXAMPLES |
ON when top level |
Build sprite_sheet |
SPRAT_SDL_SANITIZE |
OFF |
Build with AddressSanitizer and UBSan (GCC/Clang) |
SPRAT_SDL_INSTALL |
ON with installed SDL3 and sprat |
Generate install rules |
int SPRAT_Version(void);
/* Sheets */
SPRAT_Sheet *SPRAT_LoadSheet(SDL_Renderer *renderer, const char *file);
SPRAT_Sheet *SPRAT_LoadSheet_IO(SDL_Renderer *renderer, SDL_IOStream *src, bool closeio, const char *atlas_dir);
void SPRAT_DestroySheet(SPRAT_Sheet *sheet);
/* Frames */
int SPRAT_GetFrameFromName(const SPRAT_Sheet *sheet, const char *name);
const SPRAT_Marker *SPRAT_GetFrameMarker(const SPRAT_Sheet *sheet, int frame, const char *name);
bool SPRAT_RenderFrame(SDL_Renderer *renderer, const SPRAT_Sheet *sheet, int frame, float x, float y);
bool SPRAT_RenderFrame9Grid(SDL_Renderer *renderer, const SPRAT_Sheet *sheet, int frame, float scale, const SDL_FRect *dstrect);
/* Sprites: frames placed on the screen, and their markers */
bool SPRAT_RenderSprite(SDL_Renderer *renderer, const SPRAT_Sprite *sprite);
const SPRAT_Marker *SPRAT_GetSpriteMarker(const SPRAT_Sprite *sprite, const char *name);
bool SPRAT_GetSpritePoint(const SPRAT_Sprite *sprite, const SDL_FPoint *point, SDL_FPoint *result);
bool SPRAT_GetMarkerCenter(const SPRAT_Sprite *sprite, const SPRAT_Marker *marker, SDL_FPoint *result);
int SPRAT_GetMarkerOutline(const SPRAT_Sprite *sprite, const SPRAT_Marker *marker, SDL_FPoint *points, int max_points);
bool SPRAT_MarkersOverlap(const SPRAT_Sprite *sprite_a, const SPRAT_Marker *marker_a,
const SPRAT_Sprite *sprite_b, const SPRAT_Marker *marker_b);
/* Animations */
const SPRAT_Animation *SPRAT_GetAnimationFromName(const SPRAT_Sheet *sheet, const char *name);
void SPRAT_InitAnimationPlayer(SPRAT_AnimationPlayer *player, const SPRAT_Animation *animation);
void SPRAT_SetAnimation(SPRAT_AnimationPlayer *player, const SPRAT_Animation *animation);
void SPRAT_UpdateAnimation(SPRAT_AnimationPlayer *player, float seconds);
void SPRAT_PlayAnimation(SPRAT_AnimationPlayer *player);
void SPRAT_StopAnimation(SPRAT_AnimationPlayer *player);
void SPRAT_PauseAnimation(SPRAT_AnimationPlayer *player);
void SPRAT_ResumeAnimation(SPRAT_AnimationPlayer *player);
bool SPRAT_IsAnimationFinished(const SPRAT_AnimationPlayer *player);
int SPRAT_GetAnimationFrame(const SPRAT_AnimationPlayer *player);
void SPRAT_SetSpriteAnimationFrame(SPRAT_Sprite *sprite, const SPRAT_AnimationPlayer *player);
void SPRAT_UpdateSpriteAnimation(SPRAT_Sprite *sprite, SPRAT_AnimationPlayer *player, float seconds);
bool SPRAT_RenderAnimation(SDL_Renderer *renderer, const SPRAT_Sheet *sheet, const SPRAT_AnimationPlayer *player, float x, float y);
bool SPRAT_RenderAnimationRotated(SDL_Renderer *renderer, const SPRAT_Sheet *sheet, const SPRAT_AnimationPlayer *player,
float x, float y, float scale_x, float scale_y, double angle, SDL_FlipMode flip);Every function is documented in SDL_sprat.h,
in SDL's format.
As in SDL, functions that can fail return NULL, -1 or false and set the
error that SDL_GetError() returns, with sprat-lib's message naming the value
at fault and where it is:
Couldn't load sprite sheet resources/hero.sprat: 4:14: sprites[0].rect.w: expected an integer, found "4x"
Rendering an out-of-range frame, or a frame whose atlas isn't loaded, renders
nothing and returns false. Nothing is logged.
Names and paths from the sheet are escaped with sprat-lib's sprat_escape()
before they go into a message, so a hostile sheet can't put terminal escape
sequences or reordered text into your logs.
A SPRAT_Sheet is read-only, like an SDL_Surface's fields: read its arrays
directly, and destroy it once.
typedef struct SPRAT_Sheet
{
int num_atlases; /* number of atlases */
SPRAT_Atlas *atlases; /* path, size and SDL_Texture of each atlas */
int num_frames; /* number of frames */
const SPRAT_Frame *frames; /* the frames, in sheet order */
int num_animations; /* number of animations */
const SPRAT_Animation *animations; /* the animations, in sheet order */
float scale; /* the scale the art was packed at */
} SPRAT_Sheet;- Frames are the sheet's sprites, in its order. A
SPRAT_Framekeeps the atlas region as stored (source,rotation), the original image's size (w,h), the trimmedcontentregion inside it, thepivot, the nine-slice insets (slice) and the frame'smarkers. Frame geometry is in original-image pixels, scaled to atlas pixels, with Y pointing down. - Animations list frame indexes in play order, with an
fps, and theSDL_FlipModeandSPRAT_LoopModethey are played with. Aliases are already resolved:run-left, an alias ofrunwithflip h, hasrun's frames (the same array, not a copy) andloop_mode, andflipset toSDL_FLIP_HORIZONTAL. Sheets written before loop modes (sprat-raw 1) load withSPRAT_LOOPMODE_REPEAT. An animation may have no frames. - Names: a frame's
nameis its file's basename and can repeat across folders; itspathis unique.SPRAT_GetFrameFromName()takes either: it matches the path first, then a name that only one frame has, and returns -1 otherwise. Look names up once, after loading, and keep the indexes.
Frames and animations keep the order of the sprat file, so the indexes that
sprat-lib's sprat_sheet_bind() resolves (and its animation frame indexes)
index sheet->frames and sheet->animations as they are.
Every render function places a frame by its pivot, the point
spratconvert was given with a pivot marker (0, 0, the top-left of the
original image, when there was none). Trimmed margins are left out, and
frames packed turned are rendered upright. Each frame is a single
SDL_RenderTexture() call, or SDL_RenderTextureRotated() when it is turned or
flipped, with SDL_FRect rectangles that keep subpixel positions on hardware
renderers. The software renderer rounds a rotated rectangle to whole pixels
first, so there the same copy is given by its corners, with
SDL_RenderTextureAffine(), and lands where the hardware renderers put it.
SPRAT_RenderFrame() puts a frame's pivot at a point. To scale, mirror or
turn it, describe it with a sprite: a frame placed on the screen, a plain
struct you keep as part of your game's state, as you keep an SDL_FRect:
typedef struct SPRAT_Sprite
{
const SPRAT_Sheet *sheet;
int frame; /* an index into sheet->frames */
float x, y; /* where the frame's pivot is */
float scale_x, scale_y; /* negative to mirror */
double angle; /* clockwise, in degrees, around the pivot */
SDL_FlipMode flip; /* mirroring around the pivot */
} SPRAT_Sprite;
SPRAT_Sprite hero = { sheet, idle, 400.0f, 380.0f, 3.0f, 3.0f, 0.0, SDL_FLIP_NONE };
hero.x += speed * dt;
SPRAT_RenderSprite(renderer, &hero);SPRAT_RenderSprite() scales the frame, mirrors it about its pivot with
SDL_FLIP_HORIZONTAL and/or SDL_FLIP_VERTICAL, then turns it clockwise
around the pivot by angle degrees, the same convention as
SDL_RenderTextureRotated(). The marker functions take the same sprite, so a
hitbox is always where its frame is drawn. Set both scales: a sprite
initialized as { sheet, frame, x, y } has a scale of 0 and is not visible.
In the sheet's own records each image is called a sprite; here a sprite is one
of them placed, sheet->frames[frame] put on the screen.
How a frame is drawn is up to its atlas texture, as with any SDL texture: set
SDL_SetTextureColorMod(), SDL_SetTextureAlphaMod() or
SDL_SetTextureBlendMode() on sheet->atlases[i].texture to tint or fade it.
For pixel art, call SDL_SetDefaultTextureScaleMode(renderer, SDL_SCALEMODE_PIXELART)
(or SDL_SCALEMODE_NEAREST) before loading the sheet.
scale_x and scale_y are yours to choose and are not sheet->scale: frame
geometry is in atlas pixels, so 1.0f renders atlas pixels one to one. For a
sheet packed at --scale 0.5, render at 1.0f / sheet->scale to show the art
at its original size.
An animation from the sheet is a template: SPRAT_GetAnimationFromName()
returns it by name, or NULL when there is none. Playing it takes a
SPRAT_AnimationPlayer, a plain struct you keep one of per animated thing:
typedef struct SPRAT_AnimationPlayer
{
const SPRAT_Animation *animation; /* the animation played, NULL for none */
float time; /* seconds into one pass of the animation, from 0 to its duration */
float speed; /* 1.0f plays at the animation's fps, negative plays in reverse */
bool playing; /* advanced by SPRAT_UpdateAnimation() */
SPRAT_LoopMode loop_mode; /* how the animation plays, the animation's own by default */
} SPRAT_AnimationPlayer;SPRAT_InitAnimationPlayer() sets one up playing from the start, at speed
1.0f, with the animation's loop mode; it
allocates nothing, so copy it or drop it freely. Call
SPRAT_UpdateAnimation(&player, seconds) once per frame, with the time since
the last one (a difference of SDL_GetTicksNS() divided by
SDL_NS_PER_SECOND): time only moves while you update it, so pausing the game,
slow motion or a fixed time step apply to animations too.
SPRAT_PauseAnimation() and SPRAT_ResumeAnimation() keep the time,
SPRAT_PlayAnimation() restarts and SPRAT_StopAnimation() rewinds without
playing.
A negative speed plays in reverse, and SPRAT_PlayAnimation() then starts
from the end of the pass: the last frame, or with ping-pong the one the pass
ends on. The fields are yours to read and set, time included.
The player plays the loop mode the sheet gave the animation (sprat-cli's
loop, once, pingpong or once-pingpong):
loop_mode |
Frames 0 1 2 play as |
|---|---|
SPRAT_LOOPMODE_REPEAT |
0 1 2, 0 1 2, ... |
SPRAT_LOOPMODE_ONCE |
0 1 2, stopping on 2 |
SPRAT_LOOPMODE_PINGPONG |
0 1 2 1, 0 1 2 1, ... |
SPRAT_LOOPMODE_ONCE_PINGPONG |
0 1 2 1 0, stopping on 0 |
time covers one pass, so a ping-pong pass of two frames or more lasts
(2 * num_frames - 2) / fps ((2 * num_frames - 1) / fps once); a single frame
lasts 1 / fps in every mode. A SPRAT_LOOPMODE_ONCE animation stops at
the end of its pass (at its start when reversed) and
SPRAT_IsAnimationFinished() becomes true. The values are flags, so test
loop_mode & SPRAT_LOOPMODE_ONCE. To play an animation another way, set the
player's loop_mode; the sheet's animation is left as it is:
SPRAT_InitAnimationPlayer(&attack, SPRAT_GetAnimationFromName(sheet, "attack"));
attack.loop_mode = SPRAT_LOOPMODE_ONCE;SPRAT_GetAnimationFrame() gives the frame shown now, an index into
sheet->frames. SPRAT_RenderAnimation() renders it with the animation's
flip, and SPRAT_RenderAnimationRotated() also scaled and turned, with a flip
combined with the animation's (flipping an animation already flipped on an
axis undoes it).
When the frame shown also places markers, keep a sprite and a player together.
SPRAT_SetAnimation() switches the animation played, restarting it with its
loop mode and keeping the speed; for the one already played it does nothing,
so set it every update. SPRAT_UpdateSpriteAnimation() advances the player,
then sets the sprite's frame and flip from it, leaving the rest alone: mirror
the sprite with a negative scale.
typedef struct Enemy
{
SPRAT_Sprite sprite;
SPRAT_AnimationPlayer animation;
bool facing_left;
} Enemy;
SPRAT_SetAnimation(&enemy.animation, moving ? walk : idle);
SPRAT_UpdateSpriteAnimation(&enemy.sprite, &enemy.animation, dt);
enemy.sprite.scale_x = enemy.facing_left ? -1.0f : 1.0f;
hitbox = SPRAT_GetSpriteMarker(&enemy.sprite, "hitbox");
SPRAT_RenderSprite(renderer, &enemy.sprite);An animation is the one played when it is the same pointer, as the sheet's
animations are, so any other one restarts the player, an alias or a copy
included. To play the one played another way, set the player's speed or
loop_mode, or the sprite's scale, rather than switching to a copy; for a pose
such as idle, add an animation to the sheet, as the example does. After
SPRAT_StopAnimation() or SPRAT_PlayAnimation(), 0 seconds only sets the
sprite. SPRAT_SetSpriteAnimationFrame() sets a sprite without advancing the
player, for several sprites sharing one.
Markers (pivot, hitboxes, hands, spawn points...) are in original-image
pixels: they mean something once placed with a sprite of their frame, and the
marker functions place them as SPRAT_RenderSprite() places the frame.
SPRAT_GetSpriteMarker() looks one up by name in the sprite's frame; look it up
again when the frame changes, since each frame has its own markers.
SPRAT_GetMarkerCenter() gives where a marker is: a point marker's point, or
the center of another shape (a polygon's centroid). Use it to attach items to
a hand, spawn things at a muzzle, or aim at a hitbox:
const SPRAT_Marker *hand = SPRAT_GetSpriteMarker(&hero, "hand");
SDL_FPoint grip;
if (hand && SPRAT_GetMarkerCenter(&hero, hand, &grip)) {
sword.x = grip.x;
sword.y = grip.y;
SPRAT_RenderSprite(renderer, &sword);
}SPRAT_GetMarkerOutline() gives a marker's outline, closed, to draw with
SDL_RenderLines(). It fills a buffer and returns how many points the outline
has, like snprintf(); a circle gets up to 257 points, as many as keep it
within a quarter of a pixel of the curve:
SDL_FPoint outline[257];
int count = SPRAT_GetMarkerOutline(&hero, hitbox, outline, SDL_arraysize(outline));
if (count > 0) {
SDL_RenderLines(renderer, outline, SDL_min(count, (int)SDL_arraysize(outline)));
}So the marker functions cover what a marker is used for, and its fields are
there for anything else. SPRAT_GetSpritePoint() places any other point of the
original image.
SPRAT_MarkersOverlap() tells whether markers of two sprites overlap, such as
two hitboxes, comparing them where the sprites are rendered:
if (SPRAT_MarkersOverlap(&hero, SPRAT_GetSpriteMarker(&hero, "hitbox"),
&coin, SPRAT_GetSpriteMarker(&coin, "hitbox"))) {
/* picked */
}The shapes are compared exactly, whatever their types and placements: a
rectangle turns with its sprite, a circle scaled unevenly is an ellipse, and a
polygon may be concave. A point overlaps the shapes it lies in, and shapes that
only touch overlap. A marker missing from its frame (NULL) is an error, which
returns false like no overlap; check SDL_GetError() when it matters. The
sprites may come from different sheets, and a marker need not come from its
sprite's frame: a range the program makes, in the frame's pixels, works too.
SPRAT_RenderFrame9Grid() renders a frame stretched to fill dstrect, keeping
its slice corners unstretched, like SDL_RenderTexture9Grid() with the frame's
slice as the corner sizes. scale scales the corners as it does there
(0.0f keeps them at their size). dstrect covers the whole original image,
trimmed margins included, and the pivot plays no part. It cuts the slices in
original-image space, so it handles trimmed frames and frames packed turned,
which SDL_RenderTexture9Grid() cannot. Corners shrink proportionally when
dstrect is smaller than them, and a frame without a slice is simply
stretched.
Every fill mode is rendered as SPRAT_SLICEMODE_STRETCH. slice.horizontal_mode
and slice.vertical_mode say which mode the artist chose, for code that tiles
the middle itself.
SPRAT_LoadSheet() reads the file with SDL_LoadFile() and loads each atlas
with SDL_LoadSurface(), which reads BMP and PNG, joining its path with the
sheet's folder (absolute paths are used as written). SPRAT_LoadSheet_IO()
reads the metadata from an SDL_IOStream and takes the folder to resolve atlas
paths against, or NULL to use them as written.
A sheet loads whole or not at all: when an atlas is missing, can't be read, or
isn't the size the sheet was packed for, the load fails and SDL_GetError()
names the file.
Pass a NULL renderer to load only the metadata, on any thread. Then load the
atlases yourself, for instance with SDL_image for other formats, and hand them
to the sheet, which destroys them with itself:
SPRAT_Sheet *sheet = SPRAT_LoadSheet(NULL, "resources/hero.sprat");
for (int i = 0; i < sheet->num_atlases; i++) {
char *path;
SDL_asprintf(&path, "resources/%s", sheet->atlases[i].path);
sheet->atlases[i].texture = IMG_LoadTexture(renderer, path);
SDL_free(path);
}Destroy sheets before the renderer their textures belong to.
A sheet is one allocation (with SDL_calloc(), so SDL_SetMemoryFunctions()
applies), starting at the SPRAT_Sheet itself, and sprat-lib's parsed sheet is
freed before the loader returns. Every array and string of a sheet points into
that allocation and stays valid until SPRAT_DestroySheet().
examples/sprite_sheet.c, written with SDL's main
callbacks, renders every frame of the example sheet (most of them stored turned
in the atlas), plays the run, run-left, spin and spin-pingpong
animations, resizes a nine-slice panel, and lets you run a hero holding a sword
at its hand marker.
The hero, the sword and the coins are SPRAT_Sprites, rendered and collided
alike.
The hero picks up the coins its hitbox runs into, or that the sword's blade
polygon cuts while it swings, with SPRAT_MarkersOverlap(). D shows pivots,
hitboxes and the blade, P pauses the spins.
The sheet in examples/resources/ was packed with sprat-cli by
make_resources.sh, from frames drawn by make_sources.py.
tests/test_sprat_sdl.c needs no visible window: it renders to a target
texture on an offscreen window, with a hardware renderer, and compares every
pixel with the original image placed at its pivot. That covers every packing
rotation, flip, scale (fractional too), angle and position off the pixel grid,
nine-slices, and each frame of the example sheet against the source PNG it was
packed from.
Marker overlaps are checked case by case for every pair of shapes and every part of a placement, against a reference that samples one marker and looks the samples up in the other through the inverse placement, and at exact thresholds: an ellipse grown to just short of touching another marker, then just past it.
Without a hardware renderer, the test falls back to the software renderer and
checks only the renders that land on whole pixels in quarter turns. Set
SPRAT_TEST_REQUIRE_HARDWARE=1 to fail instead, as the Linux CI job does. To
try a given renderer, set SDL_RENDER_DRIVER (opengl, vulkan, gpu,
software...).
- Normal and collision maps are not loaded. sprat-lib gives their paths if you need them.
- Atlas paths are used as written, so a sheet from an untrusted source could
name any file. Load such a sheet without a renderer, check each
sheet->atlases[i].pathwith sprat-lib'ssprat_path_is_safe(), and load the atlases yourself.
MIT. See LICENSE.