Skip to content

t1k:cocos:playable:spine-fit

FieldValue
Moduleplayable
Version2.14.4
Effortmedium
Tools—

Keywords: ads, bounds, center, cocos, computeWorldVertices, contentSize, fit, hit area, measure, not tappable, off center, outline, outlineWidth, playable, resize, skeleton, skeletonData, touch area, uploaded spine, vertex buffer, wrong size

/t1k:cocos:playable:spine-fit

Fitting a runtime-loaded Spine in Cocos Creator 3.8.7

Section titled “Fitting a runtime-loaded Spine in Cocos Creator 3.8.7”

A playable ad receives its spine art at runtime (dashboard upload, sp.SkeletonData built in code), so nothing about its size is known at author time. Every layout question — fit this spine into a 120px slot, center it on the object origin, size the touch frame to it, scale its outline — needs the skeleton’s real visual bounds. Cocos does not give you those.

The two facts that break the obvious approach

Section titled “The two facts that break the obvious approach”
  1. UITransform on a sp.Skeleton is not the art. It is a padded AABB that carries the authored/estimated box, not the rendered geometry. Reading contentSize or getBoundingBox() gives a wrong (usually too large) box, so anything centered or fitted from it lands off.
  2. contentSize does not resize a skeleton. A Sprite fits by setContentSize; a spine ignores it entirely. Fit a spine by node.setScale(), never by contentSize. This is the single most common wrong first attempt.

The algorithm — exact path first, deferred fallback second

Section titled “The algorithm — exact path first, deferred fallback second”
const local = computeSpineLocalBounds(skel); // exact, SYNCHRONOUS
if (local && apply(local)) { startAnim(); return; }
this.scheduleOnce(() => { // fallback, next frame
const vb = readSpineVertexBounds(skel);
if (!(vb && apply(vb))) { /* last resort: the UITransform box */ }
startAnim();
}, 0);

Path A — core skeleton (always try this first)

Section titled “Path A — core skeleton (always try this first)”

Read the spine-core skeleton behind the component, run the setup pose, and union the world vertices of every rendered attachment:

const core = (skel as unknown as { _skeleton?: sp.spine.Skeleton })._skeleton;
core.updateWorldTransform();
// per slot: RegionAttachment → computeWorldVertices(slot.bone, wv, 0, 2) → 8 floats
// MeshAttachment → computeWorldVertices(slot, 0, n, wv, 0, 2) → worldVerticesLength
  • Iterate only RegionAttachment / MeshAttachment. Bounding-box and clipping attachments are invisible geometry — including them inflates the box for no visible reason.
  • Resolve the attachment as slot.getAttachment ? slot.getAttachment() : slot.attachment — both shapes appear depending on the runtime build.
  • Wrap the whole loop in try / catch returning null. Under the WASM spine runtime these proxy accesses can throw; a throw must degrade to path B, not crash the view.
  • Measure on the setup pose, before starting the animation. Fit first, setAnimation(0, name, true) after — otherwise a mid-animation frame (arm raised, cape out) sets the box and every object ends up sized to a different moment of its loop.

Why this path is mandatory, not merely preferred: it is synchronous and independent of render timing, so it is correct on the very first frame — including immediately after an object-pool spawn.

Path B — render vertex buffer (fallback only, deferred only)

Section titled “Path B — render vertex buffer (fallback only, deferred only)”
const u8 = (skel as any)._vBuffer ?? (skel as any)._model?.vData ?? null;
const f32 = new Float32Array(u8.buffer, u8.byteOffset, Math.floor(u8.byteLength / 4));
const STRIDE = 9; // vfmtPosUvColor — x,y are floats [0],[1] of each vertex

This reads what actually drew. It is only valid after a frame has rendered, so it must be called from a scheduleOnce(…, 0) — never inline.

  • Pool spawn + buffer read = the previous object’s geometry. The render buffer still holds the last pooled occupant until the new spine re-renders, so path B on a fresh spawn silently measures the wrong object. This is exactly why path A exists. When you do fall through to path B, guard the deferred callback with a configure token bumped by every re-configure, plus a recycled flag:

    const token = this._configureToken;
    this.scheduleOnce(() => {
    if (this._recycled || this._configureToken !== token) return; // stale closure
    /* … */
    }, 0);

    Without it a view recycled and re-spawned as a different type before the callback fires gets resized — and possibly hidden — by the old object’s closure.

  • Measuring requires the node to be ACTIVE on path B. The buffer only fills once the spine has rendered, so a spine you intend to keep hidden must still be activated for that frame. Hold it at UIOpacity.opacity = 0 while it measures, then restore — activating it visibly flashes it over whatever should be on screen. On path A this never comes up: it resolves before anything draws.

  • Local space vs parent space. Bounds come back in skeleton-local coordinates. To express them in the parent’s space, multiply by the art node’s scale (vb.width * |artNode.scale.x|), and center by offsetting the art node to -center, not by moving the parent.

  • Fitting a Sprite to a measured spine box requires a trimmed frame. Sizing a sprite child to the spine’s box assumes the frame’s rect is its artwork. An untrimmed frame fits its transparent padding to the box instead — art drawn small, off-center, wrong aspect. Trim uploaded frames first, and force Sprite.SizeMode.CUSTOM or setContentSize is reset to the frame’s natural size.

  • Spine outline width must be scaled per spine. The outline effect offsets vertices in local space, so one fixed outlineWidth renders thin on a large spine and thick on a small one. Derive it from the measured size:

    const localSize = (localW + localH) / 2; // unscaled, a_position space
    mat.setProperty('outlineWidth', localSize * relativeWidth); // relativeWidth ≈ 0.025

    Re-measure on every configure — a pooled localSize carried across object types produces a visibly wrong outline.

  • Spine caches its render material — re-apply to force a re-clone. sp.Skeleton clones from customMaterial and caches the result, so setting a material whose outlineWidth changed has no effect. Set customMaterial = null first, then assign:

    skel.customMaterial = null;
    skel.customMaterial = mat;
  • The Sprite outline is a different formula — do not copy the spine one. The sprite effect samples neighbours at outlineWidth / texSize in atlas-UV space, so visible thickness depends on texture resolution and atlas packing, and is asymmetric when texSize.x ≠ texSize.y. Pin outlineWidth = 1 and fold each frame’s UV span into texSize:

    // uv = spriteFrame.uv (8 floats, 4 corners) → span in U and V
    texX = 1 / (relativeWidth * (maxU - minU));
    texY = 1 / (relativeWidth * (maxV - minV));

    Two shaders, two coordinate spaces, two formulas. Use a similar relativeWidth for both so sprite and spine outlines read the same on screen.

  • Clone outline materials per instance. Several objects visible at once each need their own outlineWidth / texSize; sharing one material makes the last writer win. Clone lazily (new Material(); copy(src)), reuse across pool spawns, and destroy() in onDestroy.

  • A degenerate box is a real outcome. computeWorldVertices on a skeleton with no visible attachment yields maxX <= minX. Return null and let the caller leave the art at its authored size — never divide by it.

These functions read private engine internals (_skeleton, _vBuffer, _model.vData) and encode a stride and an attachment taxonomy. A second copy pasted into another view will drift from the first, and the drift is invisible until a specific spine renders wrong. Put computeSpineLocalBounds / readSpineVertexBounds in one pure, stateless module and import it everywhere.

The apply step is the part that legitimately differs per consumer and should stay local to it:

Consumer shapeapply does
Fit into a square frame (slot, target bar)scale = frameSize / max(w, h), center the art node
Fit a touch/hit area to the art (tappable object)size the ROOT UITransform to w × h, center the art node

Reference implementation: assets/scripts/**/presentation/SlotArtFitter.ts in the TripleMatchCity playable — the two measuring functions are shared; each view keeps its own apply.

  • t1k:cocos:playable:object-pool — the spawn/recycle lifecycle the staleness trap comes from.
  • t1k:cocos:base:effect-authoring — authoring the outline .effect these widths feed.
  • t1k:cocos:playable:parameter — how uploaded spine/sprite art reaches the runtime in the first place.