修复pma 皮肤渲染
This commit is contained in:
1 parent
02358b8d4c
commit
11b1c95d98
6 files changed
+105
-13
No files matched your search
@@ -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。
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user