'use strict'; // The official spine-canvas SkeletonRenderer does not process clipping // attachments. Keep its triangle rasterizer, but feed it geometry produced by // SkeletonClipping so the viewer matches Spine's draw order and clipping rules. const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen']; const unpremultipliedImages = new WeakMap(); function rendererConstructor(spine) { return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer; } function imageFromAttachment(attachment) { const region = attachment && attachment.region; // 4.0 stores the texture on the atlas page, while the other bundled // runtimes expose it directly on the attachment region. const renderRegion = region && region.renderObject; const texture = region && (region.texture || (region.page && region.page.texture) || (renderRegion && renderRegion.texture) || (renderRegion && renderRegion.page && renderRegion.page.texture)); return texture && typeof texture.getImage === 'function' ? texture.getImage() : null; } // Canvas 2D always applies source-alpha blending. A Spine texture exported // with PMA already has RGB multiplied by alpha, so drawing it directly would // multiply translucent edge pixels a second time. Convert it once to straight // alpha and cache the result; Canvas can then blend it correctly. function imageForCanvas(image, context, premultipliedAlpha) { if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image; const cached = unpremultipliedImages.get(image); if (cached) return cached; try { const ownerDocument = context.canvas && context.canvas.ownerDocument; const doc = ownerDocument || (typeof document !== 'undefined' ? document : null); if (!image.width || !image.height) return image; let canvas; if (doc) canvas = doc.createElement('canvas'); else { try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); } catch (e) { return image; } } canvas.width = image.width; canvas.height = image.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(image, 0, 0); const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = pixels.data; for (let i = 0; i < data.length; i += 4) { const alpha = data[i + 3]; if (alpha > 0 && alpha < 255) { const factor = 255 / alpha; data[i] = Math.min(255, Math.round(data[i] * factor)); data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor)); data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor)); } } ctx.putImageData(pixels, 0, 0); unpremultipliedImages.set(image, canvas); return canvas; } catch (e) { // If pixel access is unavailable, retain the original texture so preview // remains usable instead of failing the whole frame. return image; } } function unpackVertices(vertices, count) { const positions = new Array(count * 2); const uvs = new Array(count * 2); for (let i = 0, p = 0; i < count; i++, p += 2) { const source = i * 8; positions[p] = vertices[source]; positions[p + 1] = vertices[source + 1]; uvs[p] = vertices[source + 6]; uvs[p + 1] = vertices[source + 7]; } return { positions, uvs }; } function drawInterleaved(base, image, vertices, triangles, darkColor) { for (let i = 0; i < triangles.length; i += 3) { const a = triangles[i] * 8; const b = triangles[i + 1] * 8; const c = triangles[i + 2] * 8; base.drawTriangle(image, vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7], vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7], vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7], [...vertices.slice(a+2,a+6), ...vertices.slice(b+2,b+6), ...vertices.slice(c+2,c+6)], darkColor ? Array.from({length:3}, () => [darkColor.r,darkColor.g,darkColor.b,0]).flat() : undefined); } } function createLegacyRenderer(spine, context) { const Renderer = rendererConstructor(spine); if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整'); const base = new Renderer(context); base.triangleRendering = true; base.premultipliedAlpha = false; const clipper = new spine.SkeletonClipping(); const dark = new spine.Color(0, 0, 0, 0); base.draw = function drawWithClipping(skeleton) { const composite = context.globalCompositeOperation; const drawOrder = skeleton.drawOrder; const pma = !!base.premultipliedAlpha; try { for (let i = 0; i < drawOrder.length; i++) { const slot = drawOrder[i]; if (slot.bone && slot.bone.active === false) { clipper.clipEndWithSlot(slot); continue; } const attachment = slot.getAttachment(); if (attachment instanceof spine.ClippingAttachment) { clipper.clipStart(slot, attachment); continue; } try { if (!attachment) continue; let vertices; let triangles; let vertexCount; if (attachment instanceof spine.RegionAttachment) { vertices = base.computeRegionVertices(slot, attachment, false); triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES; vertexCount = 4; } else if (attachment instanceof spine.MeshAttachment) { vertices = base.computeMeshVertices(slot, attachment, false); triangles = attachment.triangles; vertexCount = attachment.worldVerticesLength >> 1; } else { continue; } const sourceImage = imageFromAttachment(attachment); const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma); if (!image) continue; context.globalAlpha = base.tempColor.a; context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over'; if (clipper.isClipping()) { const unpacked = unpackVertices(vertices, vertexCount); clipper.clipTriangles(unpacked.positions, unpacked.positions.length, triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false); drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles, slot.darkColor); } else { drawInterleaved(base, image, vertices, triangles, slot.darkColor); } } finally { clipper.clipEndWithSlot(slot); } } } finally { clipper.clipEnd(); context.globalAlpha = 1; context.globalCompositeOperation = composite; } }; base.clipper = clipper; return base; } // 3.4/3.5 predate clipping attachments and compute packed vertices on attachments. function create34Renderer(spine, context) { const base = new (rendererConstructor(spine))(context); base.triangleRendering = true; base.premultipliedAlpha = false; base.draw = skeleton => { const composite = context.globalCompositeOperation; try { for (const slot of skeleton.drawOrder) { const attachment = slot.getAttachment(); if (!(attachment instanceof spine.RegionAttachment) && !(attachment instanceof spine.MeshAttachment)) continue; const vertices = attachment.updateWorldVertices(slot, false); const sourceImage = imageFromAttachment(attachment); const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, base.premultipliedAlpha); if (!image) continue; context.globalAlpha = vertices[5]; context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over'; drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES); } } finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; } }; return base; } function create43Renderer(spine, context) { const Renderer = rendererConstructor(spine); if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整'); const base = new Renderer(context); base.triangleRendering = true; base.premultipliedAlpha = false; const core = new spine.SkeletonRendererCore(); base.draw = function drawWithCoreClipping(skeleton) { const composite = context.globalCompositeOperation; try { const pma = !!base.premultipliedAlpha; for (let command = core.render(skeleton, false); command; command = command.next) { const sourceImage = command.texture && command.texture.getImage && command.texture.getImage(); const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma); if (!image) continue; context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255; context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over'; const positions = command.positions; const uvs = command.uvs; const indices = command.indices; for (let i = 0; i < command.numIndices; i += 3) { const a = indices[i] * 2; const b = indices[i + 1] * 2; const c = indices[i + 2] * 2; const unpack = packed => [((packed>>>16)&255)/255,((packed>>>8)&255)/255,(packed&255)/255,((packed>>>24)&255)/255]; const colors = [a,b,c].flatMap(index => unpack(command.colors[index/2])); const darkColors = [a,b,c].flatMap(index => unpack(command.darkColors[index/2])); base.drawTriangle(image, positions[a], positions[a + 1], uvs[a], uvs[a + 1], positions[b], positions[b + 1], uvs[b], uvs[b + 1], positions[c], positions[c + 1], uvs[c], uvs[c + 1], colors, darkColors); } } } finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; } }; base.clipper = core.clipping; return base; } function createRenderer(spine, context, runtimeDir) { const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3' ? create43Renderer(spine, context) : createLegacyRenderer(spine, context); // Explicitly disable debug drawing in every supported runtime. renderer.debugRendering = false; return require('./spine-webgl-rasterizer.js').attach(renderer, context); } module.exports = { createRenderer };