284 lines
11 KiB
JavaScript
284 lines
11 KiB
JavaScript
// ==========================================
|
|
// Spine 3.8 渲染逻辑 (Timeline + DebugDraw)
|
|
// ==========================================
|
|
|
|
var canvas, gl, shader, batcher, mvp, skeletonRenderer;
|
|
var skeleton, animationState;
|
|
var lastTime = Date.now();
|
|
var camX=0, camY=0, camZoom=1.0;
|
|
// var rendererRequestId = 0; // 移除局部变量
|
|
|
|
function startSpine38(canvasElement, files) {
|
|
// 强制停止之前的循环 (双重保险,防止 index.html 没有清理干净)
|
|
if(window.viewerConfig.animRequestId) cancelAnimationFrame(window.viewerConfig.animRequestId);
|
|
|
|
canvas = canvasElement;
|
|
// 关键修改:开启 alpha 通道,并设置 premultipliedAlpha: false
|
|
// 这告诉浏览器:Canvas 的内容是 Straight Alpha 的(未预乘)。
|
|
// 即使我们画的是 PMA 数据,如果浏览器认为它是 PMA 并在合成时不再处理,就会导致透明边缘黑边。
|
|
// 这里设为 false,是为了让浏览器在合成到网页背景时,帮我们处理好边缘混合。
|
|
gl = canvas.getContext("webgl", { alpha: true, preserveDrawingBuffer: true, premultipliedAlpha: false });
|
|
|
|
// 如果 Context 丢失 (因为 Canvas 被移除),直接返回
|
|
if(!gl) return;
|
|
|
|
// === 关键:全局禁用 WebGL 自动预乘 ===
|
|
// 我们的纹理已经是 PMA 的了,所以不能让 WebGL 再乘一次。
|
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
|
|
// 移除之前的 gl.blendFunc Hack,回归标准流程
|
|
|
|
shader = spine.webgl.Shader.newTwoColoredTextured(gl);
|
|
batcher = new spine.webgl.PolygonBatcher(gl);
|
|
mvp = new spine.webgl.Matrix4();
|
|
skeletonRenderer = new spine.webgl.SkeletonRenderer(gl);
|
|
|
|
// 恢复 PMA 模式 (Normal 混合无黑边)
|
|
skeletonRenderer.premultipliedAlpha = true;
|
|
addPmaToggle38();
|
|
loadFiles38(files);
|
|
}
|
|
|
|
function addPmaToggle38() {
|
|
const chk = document.getElementById('chk-pma');
|
|
if (chk) {
|
|
// 恢复默认勾选
|
|
chk.checked = true;
|
|
window.viewerConfig.pmaEnabled = true;
|
|
|
|
chk.onchange = (e) => {
|
|
window.viewerConfig.pmaEnabled = e.target.checked;
|
|
};
|
|
}
|
|
}
|
|
|
|
async function loadFiles38(files) {
|
|
let map = { atlas: null, png: null, main: null, type: null };
|
|
for(let f of files) {
|
|
let name = f.name.toLowerCase();
|
|
if(name.endsWith('.atlas') || name.endsWith('.atlas.txt')) map.atlas = f;
|
|
else if(name.endsWith('.png') || name.endsWith('.jpg')) map.png = f;
|
|
else if(name.endsWith('.skel') || name.endsWith('.skel.bytes')) { map.main = f; map.type = 'binary'; }
|
|
else if(name.endsWith('.json') || name.endsWith('.json.txt')) { map.main = f; map.type = 'json'; }
|
|
}
|
|
|
|
try {
|
|
const atlasText = await readFileAsText(map.atlas);
|
|
const imgUrl = await readFileAsDataURL(map.png);
|
|
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
// 确保不预乘 (防止黑边)
|
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
|
|
const texture = new spine.webgl.GLTexture(gl, img);
|
|
const spineAtlas = new spine.TextureAtlas(atlasText, (path) => texture);
|
|
const atlasLoader = new spine.AtlasAttachmentLoader(spineAtlas);
|
|
|
|
let skeletonData;
|
|
if(map.type === 'binary') {
|
|
const buffer = await readFileAsArrayBuffer(map.main);
|
|
const skeletonBinary = new spine.SkeletonBinary(atlasLoader);
|
|
skeletonBinary.scale = 1.0;
|
|
skeletonData = skeletonBinary.readSkeletonData(new Uint8Array(buffer));
|
|
} else {
|
|
const text = await readFileAsText(map.main);
|
|
const skeletonJson = new spine.SkeletonJson(atlasLoader);
|
|
skeletonJson.scale = 1.0;
|
|
skeletonData = skeletonJson.readSkeletonData(JSON.parse(text));
|
|
}
|
|
|
|
setupSkeleton38(skeletonData);
|
|
|
|
document.getElementById('ui').style.display = 'block';
|
|
document.getElementById('controls').style.display = 'flex';
|
|
document.getElementById('version-label').innerText = "Spine 3.8 (Binary/JSON)";
|
|
document.getElementById('version-label').style.color = "#ff0055";
|
|
|
|
if(window.viewerConfig.animRequestId) cancelAnimationFrame(window.viewerConfig.animRequestId);
|
|
render38();
|
|
};
|
|
img.src = imgUrl;
|
|
|
|
} catch(e) {
|
|
alert("3.8 加载错误: " + e);
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
function setupSkeleton38(skeletonData) {
|
|
skeleton = new spine.Skeleton(skeletonData);
|
|
skeleton.setToSetupPose();
|
|
skeleton.updateWorldTransform();
|
|
skeleton.x = 0; skeleton.y = 0;
|
|
|
|
// 暴露实例供导出使用
|
|
window.spineInstance = { skeleton: skeleton, state: animationState, gl: gl };
|
|
|
|
var stateData = new spine.AnimationStateData(skeleton.data);
|
|
animationState = new spine.AnimationState(stateData);
|
|
|
|
const animSelect = document.getElementById('anim-select');
|
|
animSelect.innerHTML = "";
|
|
skeleton.data.animations.forEach(a => {
|
|
const opt = document.createElement('option');
|
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
|
});
|
|
if(skeleton.data.animations.length > 0) animationState.setAnimation(0, skeleton.data.animations[0].name, true);
|
|
|
|
const skinSelect = document.getElementById('skin-select');
|
|
skinSelect.innerHTML = "";
|
|
skeleton.data.skins.forEach(s => {
|
|
const opt = document.createElement('option');
|
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
|
});
|
|
if(skeleton.data.defaultSkin) skinSelect.value = skeleton.data.defaultSkin.name;
|
|
|
|
animSelect.onchange = () => animationState.setAnimation(0, animSelect.value, true);
|
|
skinSelect.onchange = () => { skeleton.setSkinByName(skinSelect.value); skeleton.setSlotsToSetupPose(); };
|
|
|
|
bindControls38();
|
|
updateControls38();
|
|
}
|
|
|
|
function render38() {
|
|
// 将 requestId 存储到全局配置中,以便其他地方可以取消
|
|
window.viewerConfig.animRequestId = requestAnimationFrame(render38);
|
|
|
|
// 检查 canvas 是否还存在于 DOM 中 (防止僵尸循环)
|
|
if(!canvas.parentElement) {
|
|
cancelAnimationFrame(window.viewerConfig.animRequestId);
|
|
return;
|
|
}
|
|
|
|
let now = Date.now();
|
|
const targetFps = window.viewerConfig.targetFps || 30;
|
|
const interval = 1000 / targetFps;
|
|
const elapsed = now - lastTime;
|
|
|
|
if (elapsed < interval) return;
|
|
|
|
lastTime = now - (elapsed % interval);
|
|
let delta = elapsed / 1000;
|
|
|
|
// 应用倍速
|
|
delta *= (window.viewerConfig.speed || 1.0);
|
|
|
|
// === 导出模式判断 ===
|
|
const isExporting = window.viewerConfig.isExporting;
|
|
|
|
if (!isExporting) {
|
|
// 只有非导出模式下才跟随窗口大小
|
|
if(canvas.width !== window.innerWidth) canvas.width = window.innerWidth;
|
|
if(canvas.height !== window.innerHeight) canvas.height = window.innerHeight;
|
|
}
|
|
|
|
let w = canvas.width;
|
|
let h = canvas.height;
|
|
gl.viewport(0, 0, w, h);
|
|
|
|
if (isExporting) {
|
|
gl.clearColor(0, 0, 0, 0); // 强制全透明
|
|
} else {
|
|
const bg = window.viewerConfig ? window.viewerConfig.bgColor : [0.2, 0.2, 0.2, 1];
|
|
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
|
|
}
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
// === 关键修复:同步 PMA 设置 ===
|
|
// 确保每一帧都从全局配置读取 PMA 状态,并应用到 skeletonRenderer
|
|
if (window.viewerConfig) {
|
|
skeletonRenderer.premultipliedAlpha = window.viewerConfig.pmaEnabled;
|
|
}
|
|
|
|
if (skeleton && animationState) {
|
|
const ctrl = window.animControl;
|
|
let track = animationState.tracks[0];
|
|
|
|
if (track) {
|
|
if (ctrl) ctrl.updateUI(track.trackTime, track.animation.duration);
|
|
if (ctrl && ctrl.isScrubbing && ctrl.targetTime >= 0) {
|
|
track.trackTime = ctrl.targetTime;
|
|
}
|
|
else if (ctrl && !ctrl.isPlaying) {
|
|
animationState.update(0);
|
|
}
|
|
else {
|
|
animationState.update(delta);
|
|
}
|
|
} else {
|
|
animationState.update(delta);
|
|
}
|
|
|
|
animationState.apply(skeleton);
|
|
skeleton.updateWorldTransform();
|
|
|
|
shader.bind();
|
|
mvp.ortho2d(camX - w/2/camZoom, camY - h/2/camZoom, w/camZoom, h/camZoom);
|
|
shader.setUniform4x4f(spine.webgl.Shader.MVP_MATRIX, mvp.values);
|
|
shader.setUniformi(spine.webgl.Shader.SAMPLER, 0);
|
|
|
|
// 关键修复:不要手动调用 gl.blendFunc,因为 skeletonRenderer.draw 会根据 Slot 模式自己设置
|
|
// 我们只需要确保 skeletonRenderer.premultipliedAlpha 是对的
|
|
if (window.viewerConfig) {
|
|
skeletonRenderer.premultipliedAlpha = window.viewerConfig.pmaEnabled;
|
|
}
|
|
|
|
batcher.begin(shader);
|
|
skeletonRenderer.draw(batcher, skeleton);
|
|
batcher.end();
|
|
shader.unbind();
|
|
|
|
// === 绘制骨骼调试线 (导出时不绘制) ===
|
|
if(!isExporting) {
|
|
if(window.debugRenderer && window.viewerConfig.showBones) {
|
|
window.debugRenderer.drawBones(skeleton, camX, camY, camZoom, h);
|
|
} else if(window.debugRenderer) {
|
|
window.debugRenderer.drawBones(null, 0,0,1,0); // 清空
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateControls38() {
|
|
document.getElementById('posX').value = skeleton.x;
|
|
document.getElementById('posY').value = skeleton.y;
|
|
}
|
|
|
|
function bindControls38() {
|
|
const inputX = document.getElementById('posX');
|
|
const inputY = document.getElementById('posY');
|
|
inputX.oninput = () => { if(skeleton) skeleton.x = parseFloat(inputX.value); };
|
|
inputY.oninput = () => { if(skeleton) skeleton.y = parseFloat(inputY.value); };
|
|
|
|
window.resetView = function() {
|
|
skeleton.x = 0; skeleton.y = 0;
|
|
camX=0; camY=0; camZoom=1.0;
|
|
updateControls38();
|
|
};
|
|
|
|
let isDragging=false, startX, startY, isRightDrag=false;
|
|
canvas.oncontextmenu = e => e.preventDefault();
|
|
canvas.onmousedown = e => {
|
|
if(e.button === 2) isRightDrag = true; else isDragging = true;
|
|
startX = e.clientX; startY = e.clientY;
|
|
};
|
|
window.onmouseup = () => { isDragging=false; isRightDrag=false; };
|
|
window.onmousemove = e => {
|
|
if(isDragging) {
|
|
camX -= (e.clientX - startX) / camZoom;
|
|
camY += (e.clientY - startY) / camZoom;
|
|
startX = e.clientX; startY = e.clientY;
|
|
} else if(isRightDrag) {
|
|
let dy = e.clientY - startY;
|
|
let newZoom = camZoom * (1 + dy * 0.01);
|
|
if(newZoom > 0.1 && newZoom < 10) camZoom = newZoom;
|
|
startY = e.clientY;
|
|
}
|
|
};
|
|
canvas.onwheel = e => {
|
|
let newZoom = camZoom * (e.deltaY > 0 ? 0.9 : 1.1);
|
|
if(newZoom > 0.1 && newZoom < 10) camZoom = newZoom;
|
|
};
|
|
}
|