spriteforge/docs/procedural.md

5.4 KiB

Procedural backend

procedural is deterministic: identical manifest data and seed produce byte-identical RGBA frames. Variant n derives a stable RNG stream from the asset seed. Unknown parameters fail the build.

The backend serves two kinds of generators, selected by the manifest generator name. A name may live in only one of the two registries, so the choice is never ambiguous.

kind signature dirs returns
single frame (params, rng) -> (rgba, (pivot_x, pivot_y)) must be 1 one image per variant
sequence (spec, rng) -> RawAsset any 1..32 frames, directions, animations

Single-frame generators

from spriteforge.backends.procedural import register_generator

register_generator("floor_tile", floor_tile)

The backend repeats the generator params.variants times, one frame per variant, with duration_ms taken from fps, one direction and the animation default.

floor_tile

Isometric diamond. Parameters: width (64), height (width/2), color, edge_color, noise, variants, dither.

wall_tile

Isometric top plus two vertical faces. Parameters: width (64), top_height (width/2), height (64), top_color, left_color, right_color, variants, dither.

decal

Irregular soft ellipse. Parameters: width (48), height (24), color, irregularity, variants, dither.

Colors are #RRGGBB or #RRGGBBAA.

Sequence generators

A sequence generator owns its whole asset: it receives the validated AssetSpec (so dirs, fps, pivot, size, anims, params and seed are all visible) plus one seeded numpy.random.Generator, and returns a RawAsset. This is the shape used by animated multi-direction sprites such as characters.

from spriteforge.backends.procedural import register_sequence_generator
from spriteforge.backends.sequence import (build_sequence_asset, direction_centidegrees,
                                           duration_from_fps, resolve_pivot)

def walker(spec, rng):
    directions = direction_centidegrees(spec.dirs)
    duration = duration_from_fps(spec.fps)
    animations = {}
    for name in spec.anims:
        animations[name] = [[draw(name, direction, frame, duration, rng)
                             for frame in range(4)]
                            for direction in range(len(directions))]
    return build_sequence_asset(animations, directions)

register_sequence_generator("walker", walker)
hero:
  backend: procedural
  generator: walker
  dirs: 8
  fps: 12
  anims: [idle, walk, attack]
  size: 48x64
  params: {palette: bone}

Registration happens at import time, so the module that calls register_sequence_generator must be imported by the package — add it to spriteforge/backends/__init__.py. Otherwise sf build reports an unknown generator and lists the registered names of both kinds.

The result is checked against the manifest before anything is encoded: the direction count must equal dirs, animation names must be unique and non-empty, every animation requested in anims must be present, and the frame count must equal what the animation table describes. Failures name the generator.

Everything downstream is unchanged: the common postprocess thresholds alpha, crops each frame while preserving its pivot, optionally scales down to manifest size, builds one median-cut RGB palette, optionally applies ordered dithering, and generates estimated XY normals and local depth. Cropping means frames of one animation differ in size, so the runtime aligns them by pivot, never by corner.

Directions and animations

direction_centidegrees(count) returns count evenly spaced angles in hundredths of a degree, starting at 0 — the same table the Blender driver emits, so an asset keeps facing the same way whichever backend built it.

build_sequence_asset(animations, directions) takes {animation_name: [direction][frame]} and lays the frames out the way the .sfa format expects: animations are stored back to back, and inside one animation the order is direction-major, that is all frames of direction 0, then direction 1, and so on.

index = animation.first_frame + direction * animation.frames_per_direction + frame

That formula lives in exactly one function, sequence.frame_index, used both by the layout code and by the tests. A generator must never index frames by hand: a wrong order does not fail the build, it silently ships sprites that face the wrong way. Animation order in the file follows the key order of the mapping, and the first key becomes the asset's default animation.

Optional arguments mirror the format: layer_count with one layer_orders permutation per frame, and warnings surfaced by sf validate.

Drawing helpers

spriteforge.pixel is a backend-agnostic pixel-art toolbox over HxWx4 uint8 arrays — the same buffer RawFrame expects. It has canvas and fill, bounds checked pixels, Bresenham lines, thick lines, rectangles, ellipses, capsules for limbs, silhouette outlining, colour shading and three-step ramps, a directional rim light, and depth-sorted limbs that darken the far arm and leg. Randomness is never global: every noisy helper takes the numpy.random.Generator handed to the generator, which is what keeps builds reproducible.

sf build assets/environment.yaml --backend procedural --output build

The result is one .sfa per asset, palettes.sfp, and a compact index.json.