316 lines
14 KiB
JavaScript
316 lines
14 KiB
JavaScript
// ============================================================
|
||
// 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;
|
||
|
||
})();
|