t1k:cocos:playable:spine-fit
| Field | Value |
|---|---|
| Module | playable |
| Version | 2.14.4 |
| Effort | medium |
| 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
How to invoke
Section titled “How to invoke”/t1k:cocos:playable:spine-fitFitting 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”UITransformon asp.Skeletonis not the art. It is a padded AABB that carries the authored/estimated box, not the rendered geometry. ReadingcontentSizeorgetBoundingBox()gives a wrong (usually too large) box, so anything centered or fitted from it lands off.contentSizedoes not resize a skeleton. A Sprite fits bysetContentSize; a spine ignores it entirely. Fit a spine bynode.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, SYNCHRONOUSif (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 / catchreturningnull. 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 vertexThis reads what actually drew. It is only valid after a frame has rendered, so it must be called from a scheduleOnce(…, 0) — never inline.
Gotchas
Section titled “Gotchas”-
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 = 0while 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.CUSTOMorsetContentSizeis 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
outlineWidthrenders 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 spacemat.setProperty('outlineWidth', localSize * relativeWidth); // relativeWidth ≈ 0.025Re-measure on every configure — a pooled
localSizecarried across object types produces a visibly wrong outline. -
Spine caches its render material — re-apply to force a re-clone.
sp.Skeletonclones fromcustomMaterialand caches the result, so setting a material whoseoutlineWidthchanged has no effect. SetcustomMaterial = nullfirst, 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 / texSizein atlas-UV space, so visible thickness depends on texture resolution and atlas packing, and is asymmetric whentexSize.x ≠ texSize.y. PinoutlineWidth = 1and fold each frame’s UV span intotexSize:// uv = spriteFrame.uv (8 floats, 4 corners) → span in U and VtexX = 1 / (relativeWidth * (maxU - minU));texY = 1 / (relativeWidth * (maxV - minV));Two shaders, two coordinate spaces, two formulas. Use a similar
relativeWidthfor 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, anddestroy()inonDestroy. -
A degenerate box is a real outcome.
computeWorldVerticeson a skeleton with no visible attachment yieldsmaxX <= minX. Returnnulland let the caller leave the art at its authored size — never divide by it.
Keep ONE implementation
Section titled “Keep ONE implementation”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 shape | apply 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.
Related
Section titled “Related”t1k:cocos:playable:object-pool— the spawn/recycle lifecycle the staleness trap comes from.t1k:cocos:base:effect-authoring— authoring the outline.effectthese widths feed.t1k:cocos:playable:parameter— how uploaded spine/sprite art reaches the runtime in the first place.