修复pma 皮肤渲染

This commit is contained in:
tianmo committed 2026-09-10 19:09:43 +08:00
1 parent 02358b8d4c
commit 11b1c95d98
6 files changed
+105 -13

No files matched your search

+3 -1
View File
@@ -61,7 +61,7 @@ SpineViewer/
- 播放 / 暂停(空格)
- 循环开关
- 播放速度调节
- 预乘 Alpha(PMA)贴图渲染开关:仅手动切换时保存当前素材的设置,重新打开自动恢复;默认导入和预览不写入设置
- 预乘 Alpha(PMA)默认读取配套 `.atlas` / `.atlas.txt` 的 `pma:true/false`;用户手动切换后保存为该素材的显式覆盖值,重新打开时继续沿用
- 时间轴拖动、动画时长、秒刻度与关键帧标记
- 零时长动画按单帧静态姿势预览
- 自动适配并居中
@@ -81,6 +81,8 @@ SpineViewer/
“背景”开关默认开启,绘制每格 50×50 世界单位的灰色方格,随骨骼视图一起移动和缩放。背景像素与骨骼在同一个帧缓冲中合成,支持正常、相加、相乘和滤色;关闭方格后使用实色背景。手动缩略图跟随当前背景开关。4.3 等带 `-beta` 后缀的完整版本号也会显示在水印中。
加载骨骼时会先使用骨骼数据声明的默认皮肤;没有默认皮肤时使用皮肤列表中的第一个,并立即按 setup pose 重建槽位附件。手动切换皮肤也会执行同样的附件刷新,避免只有下拉框变化而画面为空。
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
+7 -2
View File
@@ -35,8 +35,13 @@ function imageForCanvas(image, context, premultipliedAlpha) {
try {
const ownerDocument = context.canvas && context.canvas.ownerDocument;
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
if (!doc || !image.width || !image.height) return image;
const canvas = doc.createElement('canvas');
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 });
+9 -3
View File
@@ -152,6 +152,7 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
@@ -162,6 +163,7 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
throw new Error('骨骼数据为空:' + name);
}
const skeleton = new spine.Skeleton(skeletonData);
utils.applySkin(skeleton, utils.preferredSkin(skeletonData));
// 计算所有动画的包围盒,取最合适的姿态用于缩略图。
// 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。
@@ -191,13 +193,17 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
ctx.translate(-cx, -cy);
let renderer = null;
if (runtimeDir === '3.4' || runtimeDir === '3.5') renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir);
else if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
try { renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); }
catch (e) {
if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
}
if (renderer) {
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
if ('triangleRendering' in renderer) renderer.triangleRendering = true;
renderer.premultipliedAlpha = premultipliedAlpha;
renderer.draw(skeleton);
if (renderer.dispose) renderer.dispose();
}
ctx.restore();
}
+34
View File
@@ -644,6 +644,37 @@ function parseAtlasPages(atlasText) {
return pages.filter((p) => !/[=,:]/.test(p));
}
/**
* 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入
* `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。
* Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突
* 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。
*/
function parseAtlasPma(atlasText) {
if (typeof atlasText !== 'string') return undefined;
const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim));
return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined;
}
/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */
function preferredSkin(skeletonData) {
if (!skeletonData) return null;
if (skeletonData.defaultSkin) return skeletonData.defaultSkin;
const skins = skeletonData.skins || [];
return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null;
}
/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */
function applySkin(skeleton, skin) {
if (!skeleton || !skin) return false;
if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin);
else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name);
else return false;
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
return true;
}
module.exports = {
SKELETON_EXTS,
ATLAS_EXTS,
@@ -663,6 +694,9 @@ module.exports = {
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
parseAtlasPma,
preferredSkin,
applySkin,
isEagleItemDir,
getSearchRoots,
collectSpineFiles,
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const assert = require('node:assert/strict');
const utils = require('../js/spine-utils');
const render = require('../js/spine-render');
assert.equal(utils.parseAtlasPma('page.png\nsize:1,1\npma:true\nregion\n'), true);
assert.equal(utils.parseAtlasPma('page.png\r\n pma : false \r\nregion\r\n'), false);
assert.equal(utils.parseAtlasPma('page.png\nsize:1,1\nregion\n'), undefined);
assert.equal(utils.parseAtlasPma('page.png\npma:trueish\n'), undefined);
const first = { name: 'first' }, namedDefault = { name: 'default' }, explicit = { name: 'custom' };
assert.equal(utils.preferredSkin({ defaultSkin: explicit, skins: [first, namedDefault] }), explicit);
assert.equal(utils.preferredSkin({ skins: [first, namedDefault] }), namedDefault);
assert.equal(utils.preferredSkin({ skins: [first] }), first);
assert.equal(utils.preferredSkin({ skins: [] }), null);
for (const version of ['3.4', '3.5', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3']) {
const spine = render.loadRuntime(version);
const skin = new spine.Skin('visible');
const data = new spine.SkeletonData();
data.bones.push(new spine.BoneData(0, 'root', null));
data.slots.push(new spine.SlotData(0, 'slot', data.bones[0]));
data.skins.push(skin);
if ('defaultSkin' in data) data.defaultSkin = skin;
const skeleton = new spine.Skeleton(data);
assert.equal(utils.preferredSkin(data), skin, version + ' preferred skin');
assert(utils.applySkin(skeleton, skin), version + ' apply skin');
assert.equal(skeleton.skin, skin, version + ' active skin');
}
console.log('PASS: Atlas PMA detection and default/first skin selection across Spine 3.4–4.3');
+21 -7
View File
@@ -690,7 +690,7 @@
if (skeletonData.animations.length > 0) sel.value = '0';
}
function fillSkins() {
function fillSkins(selectedSkin) {
const sel = document.getElementById('skinSelect');
sel.innerHTML = '';
(skeletonData.skins || []).forEach((s, i) => {
@@ -699,6 +699,10 @@
sel.appendChild(opt);
});
sel.disabled = (skeletonData.skins || []).length <= 1;
if (selectedSkin) {
const index = (skeletonData.skins || []).indexOf(selectedSkin);
if (index >= 0) sel.value = String(index);
}
}
function setAnimationByName(name) {
@@ -783,8 +787,13 @@
function applySkinByName(name) {
if (!skeleton) return;
if (skeleton.setSkinByName) skeleton.setSkinByName(name);
else if (skeleton.setSkin && skeleton.data.findSkin) skeleton.setSkin(skeleton.data.findSkin(name));
const skin = skeleton.data && skeleton.data.findSkin ? skeleton.data.findSkin(name)
: (skeletonData.skins || []).find((item) => item.name === name);
if (!utils.applySkin(skeleton, skin)) return;
currentSkin = skin;
if (state) state.apply(skeleton);
updateWorld(skeleton);
draw();
}
// ---------- 非 Spine 图片回退:直接显示图片 ----------
@@ -852,13 +861,12 @@
}
return;
}
let savedPma;
try {
const settings = require('../js/spine-preview-settings.js');
const userData = await settings.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
previewSettings = settings.createStore(userData, filePath, urlParams.get('id'));
const saved = previewSettings.read();
if (saved !== undefined) premultipliedAlpha = saved;
document.getElementById('pmaCheck').checked = premultipliedAlpha;
savedPma = previewSettings.read();
} catch (e) { log('读取预乘设置失败: ' + e.message); }
// 检测版本并选择运行时
@@ -891,6 +899,10 @@
let atlas;
try {
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
const atlasPma = utils.parseAtlasPma(atlasText);
// 用户明确切换过的设置用于下次预览;没有用户设置时,以 Atlas 为准。
premultipliedAlpha = savedPma !== undefined ? savedPma : (atlasPma !== undefined ? atlasPma : false);
document.getElementById('pmaCheck').checked = premultipliedAlpha;
atlas = await buildBrowserAtlas(spine, atlasText, assets);
} catch (e) {
showError('Atlas 加载失败', (e && e.message) || String(e));
@@ -918,6 +930,8 @@
// 创建骨架与动画状态
skeleton = new spine.Skeleton(skeletonData);
currentSkin = utils.preferredSkin(skeletonData);
utils.applySkin(skeleton, currentSkin);
stateData = new spine.AnimationStateData(skeletonData);
stateData.defaultMix = 0.2;
state = new spine.AnimationState(stateData);
@@ -927,7 +941,7 @@
document.getElementById('pmaCheck').disabled = false;
fillAnimations();
fillSkins();
fillSkins(currentSkin);
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
if (skeletonData.animations.length > 0) {