Files
2026-08-11 22:17:54 +08:00

316 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// SpineBackendWebGL —— WebGL 渲染后端(主路径)
//
// 在主画布之上叠加一张透明 canvas 绘制 Spine。相比 Canvas 2D 后端,mesh 密集的
// 骨架从"每三角形一次 clip + 整图 drawImage"变为一次 draw call。
//
// 坐标标定:不逆向混淆的 gameabc.min.js,而是每帧从 ctx.getTransform() 读取
// 权威变换矩阵,交由 SpineOverlayTransform 换算成 MVP。
//
// 真机实测确认的完整链路(gameabc_face.dc 属于一张离屏画布,并非 DOM 中可见的
// #canvas,两者是完全不同的两个对象):
// Spine 坐标 --ctx.getTransform()--> 离屏画布像素(gameabc_face.dc.canvas,
// 实测 backing 1280x720,不在 DOM,getBoundingClientRect() 恒为 0)
// --gameabc 内部 blit--> 显示画布 #canvas(DOM 中可见,实测 backing 1912x880)
// --CSS--> 屏幕(实测 rect 956x440)
//
// 因此本文件区分两个几何来源,各司其职、不可合并成一个:
// - NDC 分母(buildMvp 的 canvasW/canvasH):取 gameabc_face.dc.canvas 的
// width/height —— Spine 坐标是喂给 dc 的,分母必须与其同源。
// - 叠加层的定位/CSS 尺寸/drawing buffer:取显示画布 #canvas 的
// getBoundingClientRect() 与 width/height(backing) —— 叠加层要盖在屏幕上
// 可见的画面上,且 drawing buffer 尺寸须与主画布有效分辨率一致。
// 历史教训:曾把两者当成同一个对象 —— 用 #canvas 的尺寸做 NDC 分母则缩放错
// (X/Y 倍数不同、宽高比歪);后又改成全部从 dc.canvas 取,结果叠加层去对齐
// 离屏画布拿到的 rect(0,0,0,0),_syncSize 每帧提前 return,特效完全不显示。
// ============================================================
(function(){
// gameabc 的全局 logmessage 默认走 mode 0,是空操作(见 gameabc.min.js 的实现),
// 诊断日志必须直接走 console 才能在浏览器与真机 WebView 里看到。
function spineLog(msg) {
if (typeof console !== "undefined" && console.log) {
console.log(msg);
}
}
function spineWarn(msg) {
if (typeof console !== "undefined" && console.warn) {
console.warn(msg);
}
}
var SpineBackendWebGL = {
name: "webgl",
_canvas: null,
_gl: null,
_context: null, // spine.ManagedWebGLRenderingContext
_shader: null,
_batcher: null,
_renderer: null,
_mvp: null, // 复用的 16 元素数组,避免每帧分配
_loggedCanvasIdentity: false, // 权威画布诊断日志只打一次,避免每帧刷屏
_contextLost: false,
_hadVisible: false, // 上一帧是否有可见 entry(用于空闲帧跳过)
_assetManager: null,
_lossHandlersBound: false, // context lost 监听只绑一次
// 是否可用:spine 运行时须是 webgl 版(提供 SceneRenderer 等 GL 专有类)
isAvailable: function() {
return !!(window.spine && window.spine.SceneRenderer && window.SpineOverlayTransform);
},
// (内部) 惰性初始化 GL 资源。失败返回 false,SpineMgr 侧不再绘制。
_ensureGL: function() {
if (this._gl) return true;
this._canvas = document.getElementById("spine_gl");
if (!this._canvas) {
spineWarn("[SpineBackendWebGL] 缺少 spine_gl 元素");
return false;
}
var gl = null;
try {
// alpha:true 让叠加层透出下方主画布;premultipliedAlpha 交由 spine 按图集 pma 标记处理
var opts = { alpha: true, premultipliedAlpha: true, antialias: true, depth: false, stencil: false };
gl = this._canvas.getContext("webgl", opts) || this._canvas.getContext("experimental-webgl", opts);
} catch (err) {
spineWarn("[SpineBackendWebGL] getContext 异常: " + err.message);
return false;
}
if (!gl) {
spineWarn("[SpineBackendWebGL] 无法取得 WebGL context");
return false;
}
this._gl = gl;
this._bindContextLossHandlers();
// context 恢复时复用同一个 ManagedWebGLRenderingContext:
// canvas.getContext() 恢复后返回的是同一个 WebGLRenderingContext 对象,
// 重新 new 会让新纹理注册到新 managed context,而 _assetManager 仍挂在旧的上,
// 造成新旧分叉。shader / batcher / renderer 持有失效的 GPU 资源,则必须重建。
if (!this._context) {
this._context = new spine.ManagedWebGLRenderingContext(gl);
}
// 复用 _context 的代价:Shader / Mesh 构造时都会 context.addRestorable(this),
// 只有 dispose() 才 removeRestorable。每次 context 恢复都会重走本段,
// 旧对象若不释放就永久堆在 restorables 里(旧 shader 程序 + batcher 顶点/索引缓冲),
// 恢复次数越多占用越大。context 已丢失时 gl.deleteShader/deleteBuffer 按规范是 no-op,
// 安全;仍加 try/catch 防御,释放失败不能挡住重建。
// (SkeletonRenderer 不持有 GL 资源、无 dispose,不需处理。)
if (this._shader) {
try {
this._shader.dispose();
} catch (errShader) {
spineWarn("[SpineBackendWebGL] 释放旧 shader 失败(已忽略): " + errShader.message);
}
this._shader = null;
}
if (this._batcher) {
try {
this._batcher.dispose();
} catch (errBatcher) {
spineWarn("[SpineBackendWebGL] 释放旧 batcher 失败(已忽略): " + errBatcher.message);
}
this._batcher = null;
}
this._shader = spine.Shader.newTwoColoredTextured(this._context);
this._batcher = new spine.PolygonBatcher(this._context);
// 中和 blend 模式:spine-canvas 的 drawTriangles 只跟踪 slot.data.blendMode 却从不应用
// (该文件全文无 globalCompositeOperation),即迁移前所有槽都是按 source-over 画的。
// spine-webgl 的 SkeletonRenderer 会真的调 batcher.setBlendMode(),在"清成透明黑"的
// 叠加层上,screen 槽会输出非预乘颜色导致过亮/白边,multiply 槽会因 srcRgb=DST_COLOR
// 而 RGB 恒 0 变成纯黑剪影(本项目 start 有 9 个 screen 槽,而 game_start 每局必播)。
// additive 在叠加层上恰好等价于 source-over,无差异。
// 本次迁移只求性能、不引入视觉变化,故在此中和,保持 begin() 设定的 Normal 混合
// (SkeletonRenderer.premultipliedAlpha 默认 false,与 PolygonBatcher 构造时的
// SRC_ALPHA / ONE_MINUS_SRC_ALPHA 默认值一致,即"未中和时 Normal 槽会设成的那一组")。
// 是否放开真实 blend,留作将来一次独立的、经美术确认的改动。
// ⚠️ 必须紧跟每一次 new PolygonBatcher —— context 恢复会重建 batcher,写在别处即失效。
this._batcher.setBlendMode = function() {};
this._renderer = new spine.SkeletonRenderer(this._context);
this._mvp = new Array(16);
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
return true;
},
// (内部) WebGL context lost / restored。
// iOS 在内存压力或切后台返回时会真实触发;不处理的表现是"特效突然全部消失且不再恢复"。
// ⚠️ 只能绑一次:context restored 后 _gl 被置 null,_ensureGL 会重新走一遍,
// 若不加此闩,每恢复一次就多挂一对监听器,重复触发且泄漏。
_bindContextLossHandlers: function() {
if (this._lossHandlersBound) return;
this._lossHandlersBound = true;
var self = this;
this._canvas.addEventListener("webglcontextlost", function(ev){
ev.preventDefault(); // 必须 preventDefault,否则不会触发 restored
self._contextLost = true;
spineWarn("[SpineBackendWebGL] context lost");
}, false);
this._canvas.addEventListener("webglcontextrestored", function(){
spineLog("[SpineBackendWebGL] context restored,重建 GL 资源与纹理");
self._contextLost = false;
self._gl = null; // 迫使 _ensureGL 重建 shader/batcher/renderer
self._hadVisible = false; // 复位空闲帧状态机,避免恢复后多发一次 clear
if (self._onContextRestored) self._onContextRestored();
}, false);
},
// 由 SpineMgr 注入的纹理重建回调(Task 7 接入)
_onContextRestored: null,
createAssetManager: function(basePath) {
if (!this._ensureGL()) {
// GL 不可用时不静默降级成"看似成功",直接抛出让 SpineMgr.init 失败,
// 由 index.html 的探测脚本保证不会走到这里。
throw new Error("[SpineBackendWebGL] GL 初始化失败,无法创建 AssetManager");
}
this._assetManager = new spine.AssetManager(this._context, basePath);
return this._assetManager;
},
// WebGL 后端:位移并入骨骼,使所有 entry 共用同一 MVP,可批量绘制。
// y 取负是因为 MVP 中含 Spine Y-up → 画布 Y-down 的翻转(见 SpineOverlayTransform 推导)。
placeEntry: function(entry) {
entry.skeleton.x = entry.x;
entry.skeleton.y = -entry.y;
},
beginFrame: function(visibleCount) {
if (this._contextLost) return false;
if (!this._ensureGL()) return false;
// 绘制缓冲画布(NDC 分母来源):Spine 坐标是喂给 dc 的,分母必须与其同源,
// 只能每帧从 dc 派生(createAssetManager 阶段 dc 可能还不存在,故不能在
// _ensureGL 里固定取一次)。数据源唯一,取不到即报错跳帧,禁止 || 兜底。
var ctx = gameabc_face.dc;
if (!ctx) return false;
var drawCanvas = ctx.canvas;
if (!drawCanvas) {
spineWarn("[SpineBackendWebGL] gameabc_face.dc.canvas 不存在(非标准 CanvasRenderingContext2D 实现),本帧跳过");
return false;
}
// 显示画布(叠加层定位/CSS 尺寸/drawing buffer 来源):index.html 中
// <div id="ifastgame"><canvas id="canvas"> 是 gameabc 平台约定的固定
// 显示画布契约,并非按 id 猜——但仍须校验存在性/在 DOM/rect 有效,
// 任一项不满足都不能静默继续绘制。
var displayCanvas = (typeof document !== "undefined" && document.getElementById) ? document.getElementById("canvas") : null;
if (!displayCanvas) {
spineWarn("[SpineBackendWebGL] 找不到显示画布 document.getElementById(\"canvas\"),本帧跳过");
return false;
}
if (!displayCanvas.parentNode) {
spineWarn("[SpineBackendWebGL] 显示画布 #canvas 不在 DOM 中,本帧跳过");
return false;
}
var displayRect = displayCanvas.getBoundingClientRect();
if (!displayRect || displayRect.width <= 0 || displayRect.height <= 0) {
spineWarn("[SpineBackendWebGL] 显示画布 #canvas 的 rect 尺寸无效,本帧跳过");
return false;
}
this._logCanvasIdentityOnce(drawCanvas, displayCanvas, displayRect);
// 空闲帧跳过:麻将大部分时间无特效在播,不能因叠加层存在就每帧空 clear 全屏。
// 仅在从"有"变"无"的那一帧 clear 一次收尾。
if (visibleCount === 0) {
if (!this._hadVisible) return false;
this._hadVisible = false;
this._syncSize(displayCanvas);
this._gl.clearColor(0, 0, 0, 0);
this._gl.clear(this._gl.COLOR_BUFFER_BIT);
return false;
}
this._hadVisible = true;
if (!this._syncSize(displayCanvas)) return false;
var m = this._readTransform(ctx);
SpineOverlayTransform.buildMvp(m, drawCanvas.width, drawCanvas.height, this._mvp);
var gl = this._gl;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
this._shader.bind();
this._shader.setUniformi(spine.Shader.SAMPLER, 0);
this._shader.setUniform4x4f(spine.Shader.MVP_MATRIX, this._mvp);
this._batcher.begin(this._shader);
return true;
},
// (内部) 读取 gameabc 绘制上下文的权威变换矩阵。
// getTransform() 自 iOS Safari 11 起支持,远低于本项目已验证的 WebView 基线;
// 取不到时退化为单位矩阵(此时叠加层按绘制缓冲画布 dc.canvas 的 backing 尺寸直接映射)。
_readTransform: function(ctx) {
if (typeof ctx.getTransform === "function") {
var t = ctx.getTransform();
return { a: t.a, b: t.b, c: t.c, d: t.d, e: t.e, f: t.f };
}
return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
},
// (内部) 一次性诊断日志:同时记录两个几何来源的身份与数值,便于后续核对
// ——绘制缓冲画布(dc.canvas,NDC 分母)与显示画布(#canvas,叠加层定位/尺寸)
// 是否为同一个对象、各自的 backing 与显示画布的 rect。
_logCanvasIdentityOnce: function(drawCanvas, displayCanvas, displayRect) {
if (this._loggedCanvasIdentity) return;
this._loggedCanvasIdentity = true;
var drawIdDesc = drawCanvas.id ? ("#" + drawCanvas.id) : "(无 id)";
spineLog("[SpineBackendWebGL] 绘制缓冲画布(gameabc_face.dc.canvas) = " + drawIdDesc +
",backing=" + drawCanvas.width + "x" + drawCanvas.height +
";显示画布(#canvas) backing=" + displayCanvas.width + "x" + displayCanvas.height +
",rect=(" + displayRect.left + "," + displayRect.top + "," + displayRect.width + "," + displayRect.height + ")" +
",两者是否同一对象=" + (drawCanvas === displayCanvas));
},
// (内部) 每帧把叠加层的位置与尺寸镜像到显示画布(#canvas)的实际显示区域。
// drawing buffer 尺寸直接取显示画布的 backing(width/height),不再用
// rect × devicePixelRatio 计算——与主画布有效分辨率一致即可,用 DPR 会开出
// 比主画布 backing 还大的 buffer,白白多占显存。
_syncSize: function(displayCanvas) {
var rect = displayCanvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
var host = this._canvas.parentNode;
host.style.left = rect.left + "px";
host.style.top = rect.top + "px";
this._canvas.style.width = rect.width + "px";
this._canvas.style.height = rect.height + "px";
var bw = displayCanvas.width;
var bh = displayCanvas.height;
if (bw <= 0 || bh <= 0) return false;
if (this._canvas.width !== bw || this._canvas.height !== bh) {
this._canvas.width = bw;
this._canvas.height = bh;
}
this._gl.viewport(0, 0, bw, bh);
return true;
},
drawEntry: function(entry) {
this._renderer.draw(this._batcher, entry.skeleton);
},
endFrame: function() {
this._batcher.end();
this._shader.unbind();
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SpineBackendWebGL;
}
window.SpineBackendWebGL = SpineBackendWebGL;
})();