Skip to content

Repository files navigation

spine-html - DOM-first Spine 2D runtime

npm version npm downloads license

Render Spine skeletal animations as plain DOM — one absolutely-positioned <img> per slot, posed with a single CSS matrix() write per frame. No canvas, no WebGL for the rigid tier.

Why

Every Spine web runtime rasterizes into a canvas. But spine-core is fully renderer-independent: it computes bone world transforms — and even deformed mesh vertices — on the CPU. For region attachments (the rigid parts of a skeleton), a bone transform is a plain affine map, and CSS transform: matrix() expresses that exactly. So the rigid tier of a Spine skeleton can live in the DOM, composited by the browser, inspectable in devtools, styled with CSS.

The idea has been floated on the official forum three times since 2014 and was never built — the blockers named were meshes, clipping, and DOM update overhead. This project is the experiment: how far does the DOM actually go, and how cheap is it?

Measured so far (PoC, Apple silicon — headless and on-device numbers are labeled, they do not substitute for each other):

  • Rigid only (spineboy-ess), headless Chromium: 10 skeletons / 180 slot images at ~0.03 ms skeleton math + ~0.22 ms DOM writes per frame — about 1.5% of a 60 fps frame budget. On-device Safari: 10 rigid skeletons hold 60 fps.
  • With meshes (spineboy-pro), headless Chromium: 1 skeleton = ~0.05 ms + ~0.5 ms render (8 mesh canvases, 323 triangles); 10 running skeletons = ~3.5 ms/frame total, smooth.
  • Dirty-skip: 10 static skeletons (pose held) = ~0.4 ms/frame headless and ~0.6 ms on-device Safari — unchanged meshes reuse their raster, so idle or held parts cost nothing anywhere.
  • Real Safari (on-device), canvas2d mesh backend: in-callback JS stays ~4–5 ms/frame for 10 running meshed skeletons — but the rAF rate tells the real story: meshed ×1 = 37 fps, meshed ×10 = 3–4 fps (rigid-only ×10 = 60 fps). The cost lives outside the frame callback, in the compositor/GPU process: Safari antialiases canvas2d clip paths, so every triangle pays for an AA mask. Chromium doesn't antialias clips and stays smooth. This is what the optional WebGL mesh backend (below) removes.
  • Real Safari (on-device), webgl mesh backend: meshed ×1 holds 60 fps (vs 37 on canvas2d), and the meshed ×10 stress scene jumps 3–4 → 45 fps — 80 mesh canvases / 3230 triangles redrawn every frame (0 reused, the worst case), in-callback JS ~5.8 ms at dpr=1. The per-triangle clip-AA tax is gone; the remaining gap to 60 is the blit/compositing cost of a 10-skeleton stress scene, not a per-triangle cost.
  • Headless-WebKit numbers are a software rasterizer and measured up to 28× off real Safari in both directions — useful for visual regression only, never as Safari perf evidence.

Status

Production. The questions this started as a PoC to answer — how far does the DOM actually go, and how cheap is it — are answered with on-device numbers: the practical scenario (a character or two, mostly holding pose, a few parts deforming) holds 60 fps on every engine measured, and the 10-skeleton stress scene holds 45 fps on the weakest one (Safari, webgl backend). Ships with a self-contained test suite (backend visual parity + invariants) and CI.

  • ✅ Region attachments (rigid parts): exact affine mapping, draw-order via z-index, attachment swaps, alpha
  • ✅ Atlas unpacking at load time (90°-packed regions restored), so the rigid per-frame path never touches a canvas — a region that covers its whole page is passed through uncut, and revokeRegions() frees the rest when a skeleton is unloaded
  • ✅ Mesh attachments (deform tier): small per-part canvases sized to the mesh's world bounds, interleaved with the rigid <img> slots in one stacking context — the DOM handles the bones, a rasterizer handles the warps
  • ✅ Blend modes via mix-blend-mode (additive = plus-lighter)
  • ✅ Crack-free mesh seams on clip-antialiasing browsers (Safari): each triangle's clip polygon is expanded 0.5px from its centroid so neighbours overlap — the texture is continuous across shared edges, so the overlap is invisible (?expand=0 shows the cracks for comparison)
  • ✅ RGB tinting (skeleton × slot × attachment color) via an SVG feColorMatrix reference filter per element — an exact channel multiply that works identically on <img> and <canvas> without touching the raster (needs reference-filter support, Safari 15+; verified pixel-level on Chromium and WebKit). Dark/two-color tint is not expressible this way and stays out of scope
  • ✅ DPR-aware mesh canvas backing store: renderer.pixelRatio (defaults to devicePixelRatio; if you scale the root element, fold that scale in)
  • ✅ Dirty-skip: a mesh whose canvas-space vertices didn't change reuses last frame's raster — the CSS translate still tracks it, so parts that hold a pose (or move by whole pixels) pay zero raster. 10 frozen spineboys: WebKit ~141 → ~0.5 ms/frame
  • ⬜ Clipping — deliberately unsupported (counted and skipped); layered transparent parts + overflow: hidden cover the practical cases
  • ✅ Safari mesh cost root-caused by on-device triangulation (two corrections deep): the early "~15× slower per-triangle path" was a headless-WebKit artifact (software rasterization), and the follow-up "on par with Chromium" held only for in-callback JS time. Real-Safari rAF rates — rigid ×10 = 60 fps, meshed ×1 = 37 fps, meshed ×10 = 3–4 fps with JS flat at ~4–5 ms — put the real cost in the GPU process: Safari antialiases canvas2d clip paths, so the per-triangle clip mapping pays a per-triangle AA-mask tax (the same AA that caused the seam cracks)
  • ✅ Optional WebGL blit backend for the mesh tier: renderer.meshBackend = 'webgl' (default stays 'canvas2d'). All dirty meshes are shelf-packed into one shared offscreen WebGL context (module-level — browsers cap contexts at ~16), drawn as textured triangles with premultiplied alpha, then rect-blitted onto the same per-part canvases with an unclipped drawImage — cheap on Safari. Everything element-level is unchanged: z-index interleave, tint filter, mix-blend-mode, dirty-skip, grow-only backing. GL rasterizes shared triangle edges seamlessly, so this path needs no crack overdraw. Falls back to canvas2d when WebGL is unavailable or the context is lost. Backend parity verified by headless Chromium+WebKit screenshot diffs (glow / clipping / tint scenes; sub-pixel edge differences only). On-device Safari, meshed ×10 with every mesh redrawn per frame: 3–4 fps (canvas2d) → 45 fps (webgl)

Install

npm i spine-html @esotericsoftware/spine-core
import { Skeleton, AnimationState, AnimationStateData, Physics }
  from '@esotericsoftware/spine-core';
import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';

const assets = await loadSkeletonAssets({
  atlasUrl: '/spineboy/spineboy.atlas',
  skeletonUrl: '/spineboy/spineboy-pro.json',
});

// A positioned element becomes the skeleton origin (Spine is Y-up: the
// skeleton grows upward from it). Layout and scaling are the caller's.
const skeleton = new Skeleton(assets.data);
const state = new AnimationState(new AnimationStateData(assets.data));
state.setAnimation(0, 'walk', true);
const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);

function frame(delta: number) {
  state.update(delta);
  state.apply(skeleton);
  skeleton.update(delta);
  skeleton.updateWorldTransform(Physics.update);
  renderer.render(skeleton);
}

// Unloading (a cutscene ends, a level swaps): elements first, bitmaps second.
// The unpacked regions are blob URLs — nothing else frees them.
renderer.dispose();
assets.dispose();

spine-html is a third-party renderer and is not affiliated with or endorsed by Esoteric Software.

Loading it yourself

loadSkeletonAssets is optional sugar over five spine-core calls, and the package works without it. Drop to the low-level path whenever you need something it does not do — one atlas shared by several skeletons, a binary export, images that are already in memory:

import { TextureAtlas, AtlasAttachmentLoader, SkeletonJson }
  from '@esotericsoftware/spine-core';
import { DomTexture, unpackRegions, revokeRegions } from 'spine-html';

const atlas = new TextureAtlas(atlasText);
const pageImages = new Map<string, HTMLImageElement>();
for (const page of atlas.pages) {
  const image = await loadImage(page.name); // your loader
  page.setTexture(new DomTexture(image));
  pageImages.set(page.name, image);
}
const regionImages = await unpackRegions(atlas, pageImages);
const data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(jsonText);

// …and on unload, after every renderer using them is disposed:
revokeRegions(regionImages);

unpackRegions mints one blob URL per region; revokeRegions is its counterpart. It only frees URLs unpackRegions created, so a map you built yourself (below) and page images reused by the whole-page pass-through survive it — and calling it twice is a no-op. Load once for the page's lifetime and you can ignore it; load and unload repeatedly without it and you leak an atlas per cycle.

One part per page (loose part PNGs)

Not every pipeline runs the Spine editor's texture packer. If your parts are loose PNGs, declare each one as its own atlas page — a blank line closes a page block, the next line opens the next:

head.png
size: 512, 512
head
bounds: 0, 0, 512, 512

torso.png
size: 640, 480
torso
bounds: 0, 0, 640, 480

spine-core parses this as a normal multi-page atlas and nothing here needs a flag. Two things to know:

  • size: must be the PNG's real pixel size. UVs are derived from it (region.x / page.width), and a wrong value skews the mesh tier while the rigid tier still looks fine — a confusing failure to chase.
  • Regions like these cover their whole page, so unpackRegions hands the page image straight through instead of cutting and re-encoding it. Load cost for this atlas shape is just the image loads.

For a rigid-only skeleton you can skip atlas unpacking altogether and hand the renderer a map you build yourself — meshes cannot, because the deform tier samples the page bitmap through the atlas region:

const regionImages = new Map([
  ['head', { url: '/parts/head.png', width: 512, height: 512 }],
]);
const renderer = new SpineHtmlRenderer(rootElement, regionImages);

Runtime knobs and what they cost

  • renderer.pixelRatio — mesh-canvas backing pixels per CSS pixel (defaults to devicePixelRatio; if you scale the root element, fold that scale in so the raster matches the screen: devicePixelRatio * rootScale). Writing it reallocates every mesh canvas backing store on the next frame, and each reallocation recreates a GPU surface — the cost that took real Safari to ~3 fps when it happened per frame. Set it when a layout settles, never per frame: debounce resize drags and quantize the value instead of tracking it continuously. renderer.canvasReallocCount is the check — it must fall back to zero within a second or two.
  • renderer.meshBackend'canvas2d' (default) or 'webgl'; same output, but heavy deforming scenes on Safari want 'webgl' (see Measured above). Falls back to canvas2d automatically when WebGL is unavailable. Switching re-rasters every mesh once (no reallocation), so it is fine to expose as a user setting.
  • renderer.triangleExpand — clip overdraw in px that closes antialiased mesh seams (default 0.5). Also re-rasters every mesh once when changed.

Demo (this repository)

bun install
bun run dev

The official spineboy example assets are downloaded automatically on first dev/build (they are owned by Esoteric Software and not redistributed in this repository — see NOTICE.md); bun run fetch-assets runs the same idempotent step manually.

Debug knobs (query string): ?skel=pro|ess ?anim=walk ?count=10 pick the scene, ?tint=ff8080 tints the whole skeleton, ?dpr=2 overrides the mesh-canvas backing ratio, ?timescale=0 freezes the pose (every mesh should report "reused"), ?expand=0 disables the crack-closing clip overdraw, ?backend=webgl rasterizes meshes through the shared WebGL blitter (also a live header select; the stats line names the active backend), and ?time=1.2 seeks every instance to the same pose for deterministic captures.

Tests

bun run test   # builds + serves the demo, then runs chromium + webkit

Playwright drives the demo (tests/, config in playwright.config.ts; CI runs the same suite on ubuntu). The visual check is A/B within one run: no golden snapshots are committed (they rot across platforms/GPUs) — instead the same deterministic pose (?time + ?timescale=0) is screenshotted with ?backend=canvas2d and ?backend=webgl in the same engine and the buffers are diffed directly with a shift-tolerant comparison, so missing parts, wrong colors, and tint/blend divergence fail regardless of platform. Hairline seams are guarded by a deterministic canary (?expand=0 must change the canvas2d raster), and counter tests pin the dirty-skip / grow-only-backing / clip-skip invariants plus spine-core's region corner order (BL, UL, UR, BR).

The loading path is not observable in a rendered frame, so it gets its own page (tests/harness.html, a second build entry) that exposes the library to the specs directly. Its oracle is the browser: a revoked object URL stops resolving, so blob ownership — every unpacked URL freed, nothing the caller owns touched, nothing stranded by a failed load — is asserted rather than assumed.

Standing rule: headless numbers are never Safari performance evidence — nothing in the suite asserts timing, and headless-WebKit fps/ms readings do not transfer (software rasterizer, measured up to 28× off real Safari). The perf oracle is the demo's stats line on a real device.

How it works

  1. @esotericsoftware/spine-core loads the skeleton and drives AnimationState, constraints, and physics — all CPU-side, renderer-agnostic.
  2. At load time each atlas region is cut into its own bitmap (rotation restored), one blob URL per region — except regions that already cover their whole page, which are used as they are.
  3. Per frame, for each slot in draw order: computeWorldVertices yields the region's four corners in world space (order BL, UL, UR, BR — note the comments inside computeWorldVertices are stale). Flip Y (Spine is Y-up), derive the affine from three corners, write one matrix() string. That's the whole render path.

License

The code in this repository is MIT licensed.

It depends on the Spine Runtimes (@esotericsoftware/spine-core), which are licensed under the Spine Runtimes License: integrating the Spine Runtimes — including through this project — requires each user to have their own valid Spine Editor license. See NOTICE.md.

Example assets (spineboy) are owned by Esoteric Software and are fetched from the official repository at setup time, not redistributed here.

About

Spine skeletal animation rendered as plain DOM — one CSS matrix per slot, no canvas for the rigid tier

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages