spine_runtime 0.2.2

Unofficial native Rust port of the Spine 4.3 runtime. Renderer-agnostic.
Documentation
// Spine Runtimes License Agreement
// Last updated April 5, 2025. Replaces all prior versions.
//
// Copyright (c) 2013-2025, Esoteric Software LLC
//
// Integration of the Spine Runtimes into software or otherwise creating
// derivative works of the Spine Runtimes is permitted under the terms and
// conditions of Section 2 of the Spine Editor License Agreement:
// http://esotericsoftware.com/spine-editor-license
//
// Otherwise, it is permitted to integrate the Spine Runtimes into software
// or otherwise create derivative works of the Spine Runtimes (collectively,
// "Products"), provided that each user of the Products must obtain their own
// Spine Editor license and redistribution of the Products in any form must
// include this license and copyright notice.
//
// THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
// BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
// THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

//! CPU rasterizer that renders a skeleton's `RenderCommand`s to a PNG, with no
//! GPU or windowing. Checks the runtime's output independent of any engine.
//!
//! Nearest-neighbour sampling, premultiplied-alpha OVER blending, and only the
//! `Normal` blend mode; commands with other blend modes are skipped.
//!
//! Usage: `cargo run --example software_render`
//!
//! Environment:
//! - `SPINE_RIG`: directory under `../spine-runtimes/examples` (default `spineboy`)
//! - `SPINE_SKEL`: `.skel` file stem (default `spineboy-pro`)
//! - `SPINE_ATLAS`: `.atlas` file stem (default `spineboy-pma`)
//! - `SPINE_ANIM`: animation name, or empty for the setup pose (default `walk`)
//! - `SPINE_TIME`: seconds to advance, in 1/60 s steps (default `1.0`)
//! - `SPINE_OUT`: output PNG path (default `software_render.png`)
//! - `SPINE_W`, `SPINE_H`: output size in pixels (default 1280x720)
//! - `SPINE_CAM_Y`: world Y placed at the vertical centre (default `300`)
//! - `SPINE_UNBATCHED`: if set, render one command per slot and print each
//!   command's vertex count and bounds

use std::path::{Path, PathBuf};
use std::sync::Arc;

use image::{Rgba, RgbaImage};

use spine_runtime::animation::{AnimationState, AnimationStateData};
use spine_runtime::atlas::Atlas;
use spine_runtime::data::BlendMode;
use spine_runtime::load::{AtlasAttachmentLoader, SkeletonBinary};
use spine_runtime::render::SkeletonRenderer;
use spine_runtime::skeleton::{Physics, Skeleton};

const FIXED_STEP: f32 = 1.0 / 60.0;

fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
    std::env::var(key)
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(default)
}

fn env_str(key: &str, default: &str) -> String {
    std::env::var(key).unwrap_or_else(|_| default.to_string())
}

fn main() {
    let rig = env_str("SPINE_RIG", "spineboy");
    let skel_stem = env_str("SPINE_SKEL", "spineboy-pro");
    let atlas_stem = env_str("SPINE_ATLAS", "spineboy-pma");
    let anim = env_str("SPINE_ANIM", "walk");
    let total_time: f32 = env_or("SPINE_TIME", 1.0);
    let out_path = env_str("SPINE_OUT", "software_render.png");
    let width: u32 = env_or("SPINE_W", 1280);
    let height: u32 = env_or("SPINE_H", 720);
    let cam_y: f32 = env_or("SPINE_CAM_Y", 300.0);

    let export_dir: PathBuf = ["..", "spine-runtimes", "examples", &rig, "export"]
        .iter()
        .collect();

    let atlas_path = export_dir.join(format!("{atlas_stem}.atlas"));
    let skel_path = export_dir.join(format!("{skel_stem}.skel"));

    let atlas_src = std::fs::read_to_string(&atlas_path)
        .unwrap_or_else(|e| panic!("read {}: {e}", atlas_path.display()));
    let atlas = Atlas::parse(&atlas_src).unwrap();
    let pages = load_atlas_pages(&atlas, &export_dir);

    let mut attachment_loader = AtlasAttachmentLoader::new(&atlas);
    let bytes = std::fs::read(&skel_path).unwrap();
    let data = SkeletonBinary::with_loader(&mut attachment_loader)
        .read(&bytes)
        .unwrap();
    let data = Arc::new(data);

    let mut skeleton = Skeleton::new(Arc::clone(&data));
    skeleton.setup_pose();
    skeleton.update_world_transform(Physics::None);

    let state_data = Arc::new(AnimationStateData::new(Arc::clone(&data)));
    let mut animation_state = AnimationState::new(state_data);

    if !anim.is_empty() {
        if let Err(e) = animation_state.set_animation_by_name(0, &anim, true) {
            panic!("set_animation_by_name({anim:?}): {e:?}");
        }
        // Fixed steps keep physics output identical across runs.
        let mut remaining = total_time;
        while remaining > 0.0 {
            let step = remaining.min(FIXED_STEP);
            animation_state.update(step);
            let mut events = Vec::new();
            animation_state.apply(&mut skeleton, &mut events);
            skeleton.update_world_transform(Physics::Update);
            remaining -= step;
        }
    }

    let mut renderer = SkeletonRenderer::new();
    let unbatched = std::env::var("SPINE_UNBATCHED").is_ok();
    let cmds: &[_] = if unbatched {
        renderer.render_unbatched(&skeleton)
    } else {
        renderer.render(&skeleton)
    };
    if unbatched {
        eprintln!("software_render: {} unbatched commands", cmds.len());
        for (i, c) in cmds.iter().enumerate() {
            let (xmin, xmax, ymin, ymax) = c.position_bounds().unwrap_or((0.0, 0.0, 0.0, 0.0));
            eprintln!(
                "  cmd[{i:2}] verts={:3} tris={:3} x=[{xmin:7.1}..{xmax:7.1}] y=[{ymin:7.1}..{ymax:7.1}]",
                c.num_vertices(),
                c.indices.len() / 3
            );
        }
    }

    let mut img = RgbaImage::from_pixel(width, height, Rgba([30, 30, 30, 255]));

    let mut total_tris_drawn = 0usize;
    let mut total_tris_culled = 0usize;

    for cmd in cmds {
        if cmd.blend_mode != BlendMode::Normal {
            eprintln!(
                "software_render: skipping {:?} blend-mode command ({} tris)",
                cmd.blend_mode,
                cmd.indices.len() / 3
            );
            continue;
        }
        let Some(page) = pages.get(cmd.texture.0 as usize) else {
            eprintln!(
                "software_render: no page for texture {:?} (commands will be dropped)",
                cmd.texture
            );
            continue;
        };

        // Default batching only merges slots with equal colors, so the first
        // vertex's color tints the whole command.
        let light = cmd.colors.first().copied().unwrap_or(0xffff_ffff);
        let lc = unpack_argb(light);

        for tri in cmd.indices.as_chunks::<3>().0 {
            let i0 = tri[0] as usize;
            let i1 = tri[1] as usize;
            let i2 = tri[2] as usize;
            let p0 = [cmd.positions[i0 * 2], cmd.positions[i0 * 2 + 1]];
            let p1 = [cmd.positions[i1 * 2], cmd.positions[i1 * 2 + 1]];
            let p2 = [cmd.positions[i2 * 2], cmd.positions[i2 * 2 + 1]];
            let uv0 = [cmd.uvs[i0 * 2], cmd.uvs[i0 * 2 + 1]];
            let uv1 = [cmd.uvs[i1 * 2], cmd.uvs[i1 * 2 + 1]];
            let uv2 = [cmd.uvs[i2 * 2], cmd.uvs[i2 * 2 + 1]];

            let drew = rasterize_triangle(
                &mut img, width, height, cam_y, p0, p1, p2, uv0, uv1, uv2, page, lc,
            );
            if drew {
                total_tris_drawn += 1;
            } else {
                total_tris_culled += 1;
            }
        }
    }

    img.save(&out_path).unwrap();
    eprintln!(
        "software_render: saved {} ({width}x{height}); {} commands, {} tris drawn, {} tris off-screen / degenerate",
        out_path,
        cmds.len(),
        total_tris_drawn,
        total_tris_culled,
    );
}

fn load_atlas_pages(atlas: &Atlas, dir: &Path) -> Vec<RgbaImage> {
    let mut pages = Vec::with_capacity(atlas.pages.len());
    for page in &atlas.pages {
        let path = dir.join(&page.name);
        let img = image::open(&path)
            .unwrap_or_else(|e| panic!("open atlas page {}: {e}", path.display()))
            .to_rgba8();
        if page.pma {
            pages.push(img);
        } else {
            // Premultiply straight-alpha pages so blending is uniform.
            let mut pma = img;
            for p in pma.pixels_mut() {
                let a = f32::from(p[3]) / 255.0;
                p[0] = (f32::from(p[0]) * a) as u8;
                p[1] = (f32::from(p[1]) * a) as u8;
                p[2] = (f32::from(p[2]) * a) as u8;
            }
            pages.push(pma);
        }
    }
    pages
}

/// Fills one world-space triangle, sampling `page` nearest-neighbour,
/// multiplying each channel by `light`, and blending premultiplied OVER.
///
/// Returns `false` if the triangle is degenerate or draws no pixels.
#[allow(clippy::too_many_arguments)]
fn rasterize_triangle(
    img: &mut RgbaImage,
    width: u32,
    height: u32,
    cam_y: f32,
    p0: [f32; 2],
    p1: [f32; 2],
    p2: [f32; 2],
    uv0: [f32; 2],
    uv1: [f32; 2],
    uv2: [f32; 2],
    page: &RgbaImage,
    light: [f32; 4],
) -> bool {
    // Spine is y-up and the image is y-down. World x = 0 and y = cam_y land
    // at the image centre.
    let wx = width as f32 * 0.5;
    let hy = height as f32 * 0.5;
    let to_screen = |p: [f32; 2]| [p[0] + wx, hy - (p[1] - cam_y)];

    let s0 = to_screen(p0);
    let s1 = to_screen(p1);
    let s2 = to_screen(p2);

    // Pineda edge function.
    let edge = |a: [f32; 2], b: [f32; 2], c: [f32; 2]| {
        (c[0] - a[0]) * (b[1] - a[1]) - (c[1] - a[1]) * (b[0] - a[0])
    };
    let area = edge(s0, s1, s2);
    if area.abs() < 1e-6 {
        return false;
    }
    let inv_area = 1.0 / area;

    let min_x = s0[0].min(s1[0]).min(s2[0]).floor().max(0.0) as u32;
    let max_x = s0[0].max(s1[0]).max(s2[0]).ceil().min(width as f32 - 1.0) as u32;
    let min_y = s0[1].min(s1[1]).min(s2[1]).floor().max(0.0) as u32;
    let max_y = s0[1].max(s1[1]).max(s2[1]).ceil().min(height as f32 - 1.0) as u32;
    if min_x > max_x || min_y > max_y {
        return false;
    }

    let tex_w = page.width() as f32;
    let tex_h = page.height() as f32;

    let mut any_drawn = false;
    for y in min_y..=max_y {
        for x in min_x..=max_x {
            let pc = [x as f32 + 0.5, y as f32 + 0.5];
            let w0 = edge(s1, s2, pc) * inv_area;
            let w1 = edge(s2, s0, pc) * inv_area;
            let w2 = edge(s0, s1, pc) * inv_area;
            if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
                continue;
            }
            let u = w0 * uv0[0] + w1 * uv1[0] + w2 * uv2[0];
            let v = w0 * uv0[1] + w1 * uv1[1] + w2 * uv2[1];
            let tx = (u * tex_w).clamp(0.0, tex_w - 1.0) as u32;
            let ty = (v * tex_h).clamp(0.0, tex_h - 1.0) as u32;
            let sample = page.get_pixel(tx, ty);
            let sr = f32::from(sample[0]) / 255.0;
            let sg = f32::from(sample[1]) / 255.0;
            let sb = f32::from(sample[2]) / 255.0;
            let sa = f32::from(sample[3]) / 255.0;
            let fr = sr * light[0];
            let fg = sg * light[1];
            let fb = sb * light[2];
            let fa = sa * light[3];
            if fa <= 0.0 {
                continue;
            }
            let dst = img.get_pixel_mut(x, y);
            let dr = f32::from(dst[0]) / 255.0;
            let dg = f32::from(dst[1]) / 255.0;
            let db = f32::from(dst[2]) / 255.0;
            let da = f32::from(dst[3]) / 255.0;
            let inv_sa = 1.0 - fa;
            let out_r = (fr + dr * inv_sa).clamp(0.0, 1.0);
            let out_g = (fg + dg * inv_sa).clamp(0.0, 1.0);
            let out_b = (fb + db * inv_sa).clamp(0.0, 1.0);
            let out_a = (fa + da * inv_sa).clamp(0.0, 1.0);
            dst[0] = (out_r * 255.0) as u8;
            dst[1] = (out_g * 255.0) as u8;
            dst[2] = (out_b * 255.0) as u8;
            dst[3] = (out_a * 255.0) as u8;
            any_drawn = true;
        }
    }
    any_drawn
}

fn unpack_argb(v: u32) -> [f32; 4] {
    let a = ((v >> 24) & 0xff) as f32 / 255.0;
    let r = ((v >> 16) & 0xff) as f32 / 255.0;
    let g = ((v >> 8) & 0xff) as f32 / 255.0;
    let b = (v & 0xff) as f32 / 255.0;
    [r, g, b, a]
}