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:?}");
}
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;
};
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 {
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
}
#[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 {
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);
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]
}