modelstage 0.0.0-stage → 0.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
// render/live2d.js —— Live2D 渲染器(注册到 StageSurface 的渲染面实现)
|
|
2
|
+
//
|
|
3
|
+
// 契约(StageSurface.renderer):
|
|
4
|
+
// { id, capabilities, mount(document, handle) → info, dispose(), isActive(), camera }
|
|
5
|
+
// 边界:画布与尺寸由 StageSurface 提供(handle),本文件不读 DOM 尺寸、不建画布。
|
|
6
|
+
|
|
7
|
+
import { createCamera } from './camera/index.js';
|
|
8
|
+
import { trackModelStage, markModelFailed } from '../infra/ready.js';
|
|
9
|
+
import { createFrameGate } from '../engine/frame-gate.js';
|
|
10
|
+
import { buildDocument } from '../infra/document.js';
|
|
11
|
+
import { DEFAULT_FILTER, DEFAULT_TEXEL, FILTER_PRESETS } from '../engine/filters.js';
|
|
12
|
+
// 效果链的组装与签名**与 Spine 共用同一份实现**(顺序/激活/参数换算/结构签名都在这里),
|
|
13
|
+
// Live2D 侧只负责"怎么把这个链挂到 PIXI.Filter 上"——这是两条链路唯一该有的差异。
|
|
14
|
+
import { buildChainBody, chainSignature, chainUniforms, effectPrelude, uniformDeclarations } from '../engine/effect-chain.js';
|
|
15
|
+
// 滤镜实例创建与挂载统一走 adapter:业务侧不再出现 PIXI.Filter / stage.filters 字样
|
|
16
|
+
import { createFilter, setFilters } from '../live2d/runtime-adapter.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 创建 Live2D 渲染器。
|
|
20
|
+
* @param {{live2d: object}} options live2d 为覆盖层实现(提供 mount/dispose/isActive/bridge)
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* 把任意尺寸的图等比居中贴到**画布尺寸**的 2D canvas 上。
|
|
24
|
+
* 为什么需要:extract 给的是模型包围盒(例如 1058×1058),直接导出会与常态的
|
|
25
|
+
* 1920×1080 不一致——用户拿到一张尺寸不同的图会以为出错。
|
|
26
|
+
* @param {HTMLCanvasElement} src 源图
|
|
27
|
+
* @param {object} app PIXI app(取 view 的画布尺寸)
|
|
28
|
+
* @returns {{canvas: HTMLCanvasElement, width: number, height: number, source: string, digest: number}}
|
|
29
|
+
*/
|
|
30
|
+
function fitIntoCanvas(src, app) {
|
|
31
|
+
const view = app && app.view;
|
|
32
|
+
const w = Math.max(1, Math.round((view && view.width) || app.screen.width || src.width));
|
|
33
|
+
const h = Math.max(1, Math.round((view && view.height) || app.screen.height || src.height));
|
|
34
|
+
const out = document.createElement('canvas');
|
|
35
|
+
out.width = w;
|
|
36
|
+
out.height = h;
|
|
37
|
+
const ctx = out.getContext('2d');
|
|
38
|
+
if (!ctx) return null;
|
|
39
|
+
const scale = Math.min(w / src.width, h / src.height);
|
|
40
|
+
const dw = Math.max(1, Math.round(src.width * scale));
|
|
41
|
+
const dh = Math.max(1, Math.round(src.height * scale));
|
|
42
|
+
ctx.drawImage(src, Math.round((w - dw) / 2), Math.round((h - dh) / 2), dw, dh);
|
|
43
|
+
return { canvas: out, width: w, height: h, source: 'extract-fit', digest: digestOf(out) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 从 PIXI 画布缓冲直接取一张 2D canvas(整画布尺寸);读不到有效像素返回 null */
|
|
47
|
+
function captureFromView(app) {
|
|
48
|
+
const view = app && app.view;
|
|
49
|
+
if (!view || !view.width || !view.height) return null;
|
|
50
|
+
const w = view.width;
|
|
51
|
+
const h = view.height;
|
|
52
|
+
const gl = view.getContext('webgl2') || view.getContext('webgl');
|
|
53
|
+
if (!gl || typeof gl.readPixels !== 'function') return null;
|
|
54
|
+
try {
|
|
55
|
+
const buf = new Uint8Array(w * h * 4);
|
|
56
|
+
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
|
57
|
+
let alpha = 0;
|
|
58
|
+
for (let i = 3; i < buf.length; i += 4 * 37) if (buf[i] > 8) alpha += 1; // 抽样:确认不是空缓冲
|
|
59
|
+
if (alpha === 0) return null;
|
|
60
|
+
const out = document.createElement('canvas');
|
|
61
|
+
out.width = w;
|
|
62
|
+
out.height = h;
|
|
63
|
+
const ctx = out.getContext('2d');
|
|
64
|
+
if (!ctx) return null;
|
|
65
|
+
const img = ctx.createImageData(w, h);
|
|
66
|
+
for (let y = 0; y < h; y += 1) { // WebGL 原点在左下 → 逐行翻转
|
|
67
|
+
const src = (h - 1 - y) * w * 4;
|
|
68
|
+
img.data.set(buf.subarray(src, src + w * 4), y * w * 4);
|
|
69
|
+
}
|
|
70
|
+
ctx.putImageData(img, 0, 0);
|
|
71
|
+
return { canvas: out, width: w, height: h, source: 'buffer', digest: digestOf(out) };
|
|
72
|
+
} catch { return null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 画面指纹(同 spine 侧实现,用于断言截的是哪一面) */
|
|
76
|
+
function digestOf(canvas) {
|
|
77
|
+
try {
|
|
78
|
+
const ctx = canvas.getContext('2d');
|
|
79
|
+
if (!ctx) return 0;
|
|
80
|
+
const { width, height } = canvas;
|
|
81
|
+
const data = ctx.getImageData(0, 0, width, height).data;
|
|
82
|
+
let sum = 0;
|
|
83
|
+
for (let i = 0; i < data.length; i += 997) sum = (sum + data[i]) % 1000003;
|
|
84
|
+
return sum;
|
|
85
|
+
} catch { return 0; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createLive2DRenderer({ live2d } = {}) {
|
|
89
|
+
if (!live2d) throw new Error('createLive2DRenderer: 需要 live2d 实现');
|
|
90
|
+
/** 当前生效的纹理过滤与限帧(回显用) */
|
|
91
|
+
let filterMode = 'linear';
|
|
92
|
+
/** 播放速度倍率(Live2D 侧:缩放进 model.update 的 dt;与 spine 的 timeScale 对齐为线性) */
|
|
93
|
+
let speedValue = 1;
|
|
94
|
+
/** 暂停标记:暂停期间相机/设置变化后必须自己补一帧(ticker 停着不会自动画) */
|
|
95
|
+
let pausedRef = false;
|
|
96
|
+
/** 立刻画一帧(暂停时的按需渲染) */
|
|
97
|
+
function repaintFrame() {
|
|
98
|
+
try {
|
|
99
|
+
const app = live2d.getApp && live2d.getApp();
|
|
100
|
+
if (app && app.renderer && app.stage) { app.renderer.render(app.stage); return true; }
|
|
101
|
+
} catch { /* 忽略 */ }
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
let fpsLimit = 60;
|
|
105
|
+
/**
|
|
106
|
+
* 收集舞台上全部 PIXI 纹理(实测挂点是 model.textures 数组,模型对象没有 _texture)。
|
|
107
|
+
* @param {object} app PIXI.Application
|
|
108
|
+
* @returns {Array<object>}
|
|
109
|
+
*/
|
|
110
|
+
function texturesOf(app) {
|
|
111
|
+
const out = [];
|
|
112
|
+
const walk = (node, depth) => {
|
|
113
|
+
if (!node || depth > 4) return;
|
|
114
|
+
if (Array.isArray(node.textures)) for (const t of node.textures) if (t) out.push(t);
|
|
115
|
+
if (node.texture) out.push(node.texture);
|
|
116
|
+
for (const child of node.children || []) walk(child, depth + 1);
|
|
117
|
+
};
|
|
118
|
+
try { walk(app.stage, 0); } catch { /* 忽略 */ }
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
// 相机统一入口:档位/范围/复位语义与 Spine 完全一致,差异只在 backend
|
|
122
|
+
const camera = createCamera({
|
|
123
|
+
kind: 'live2d',
|
|
124
|
+
backend: {
|
|
125
|
+
/** 当前缩放(bridge 是唯一真相) */
|
|
126
|
+
getScale: () => { try { return live2d.bridge.readState().zoom; } catch { return null; } },
|
|
127
|
+
/** 写入缩放(bridge 内部仍会按 CAMERA 常量夹取) */
|
|
128
|
+
setScale: (v) => { try { live2d.bridge.setZoomRatio(v); } catch { /* 忽略 */ } if (pausedRef) repaintFrame(); },
|
|
129
|
+
/** 平移:屏幕像素增量,1:1 交给覆盖层 */
|
|
130
|
+
pan: (dx, dy) => { try { live2d.bridge.pan(dx, dy); } catch { /* 忽略 */ } if (pausedRef) repaintFrame(); },
|
|
131
|
+
/** 复位:bridge 归 1 并重排 */
|
|
132
|
+
resetView: () => { try { live2d.bridge.resetView(); } catch { /* 忽略 */ } },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
let handleRef = null;
|
|
136
|
+
/** 当前滤镜预设 id(original = 不挂滤镜) */
|
|
137
|
+
let presetId = DEFAULT_FILTER;
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------- 帧率门控
|
|
140
|
+
//
|
|
141
|
+
// 为什么不用 PIXI 的 ticker.maxFPS:它按 deltaTime 判断且带容差,90Hz 屏上相位抖动时会放行,
|
|
142
|
+
// 实测 60/30 档经常跑到 90。这里把 ticker 的限帧关掉(maxFPS=0),并把 PIXI 自己的渲染回调
|
|
143
|
+
// 从 ticker 上摘下来,改由本门控决定何时调 app.render()。
|
|
144
|
+
// ticker 本身继续跑 ⇒ 模型动作/物理仍按真实时间推进,只有**出画**被门控。
|
|
145
|
+
const frameGate = createFrameGate(() => fpsLimit);
|
|
146
|
+
// 实测渲染帧率:只有**真的调了 app.render()** 才计数。
|
|
147
|
+
// 为什么不能拿 app.ticker.FPS 当读数:ticker 是"rAF 触发频率"。把渲染从 ticker 上摘下来门控后,
|
|
148
|
+
// tick 仍按 rAF 跑(≈屏幕刷新率),用它显示会永远等于刷新率(90Hz 屏上设 60 也可能显示 89)——
|
|
149
|
+
// 那是**读数失真**,与"限帧有没有生效"是两件事,必须分开修、分开说。
|
|
150
|
+
let fpWindowStart = 0;
|
|
151
|
+
let fpWindowFrames = 0;
|
|
152
|
+
let measuredFps = 0;
|
|
153
|
+
// 累计真实绘制帧数:painted 的**硬计数**证据(契约 §六-2 禁止用像素采样当"画过帧"的证明)
|
|
154
|
+
let renderedTotal = 0;
|
|
155
|
+
let gateApp = null; // 已装门控的 app(换 app 后重装)
|
|
156
|
+
let gateHandler = null; // ticker 上的门控回调(换 app 时先摘掉)
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 把帧率门控装到给定 PIXI app 上(幂等:同一个 app 不重复装)。
|
|
160
|
+
* @param {object} app PIXI.Application
|
|
161
|
+
* @returns {boolean} 是否装好
|
|
162
|
+
*/
|
|
163
|
+
function installFrameGate(app) {
|
|
164
|
+
if (!app || !app.ticker || typeof app.ticker.add !== 'function') return false;
|
|
165
|
+
if (gateApp === app && gateHandler) return true;
|
|
166
|
+
if (gateApp && gateHandler && gateApp.ticker && typeof gateApp.ticker.remove === 'function') {
|
|
167
|
+
try { gateApp.ticker.remove(gateHandler); } catch { /* 旧 app 已销毁 */ }
|
|
168
|
+
}
|
|
169
|
+
try { app.ticker.maxFPS = 0; } catch { /* 老版本无此属性 */ }
|
|
170
|
+
// 摘掉 PIXI 自带的渲染回调(Application.start() 里以 app.render + app 为键挂上去的)
|
|
171
|
+
try {
|
|
172
|
+
if (typeof app.ticker.remove === 'function' && typeof app.render === 'function') app.ticker.remove(app.render, app);
|
|
173
|
+
} catch { /* 忽略 */ }
|
|
174
|
+
frameGate.reset();
|
|
175
|
+
gateHandler = () => {
|
|
176
|
+
// deltaMS 由 PIXI ticker 维护:本次 tick 距上次的真实毫秒间隔
|
|
177
|
+
if (!frameGate.shouldRender(app.ticker.deltaMS)) return;
|
|
178
|
+
try { app.render(); } catch { /* 单帧渲染失败不打断 ticker */ }
|
|
179
|
+
// 实测帧率:每 ≥500ms 结算一次,与实际出画严格同源
|
|
180
|
+
renderedTotal += 1;
|
|
181
|
+
const nowMs = performance.now();
|
|
182
|
+
if (!fpWindowStart) fpWindowStart = nowMs;
|
|
183
|
+
fpWindowFrames += 1;
|
|
184
|
+
const span = nowMs - fpWindowStart;
|
|
185
|
+
if (span >= 500) {
|
|
186
|
+
measuredFps = (fpWindowFrames * 1000) / span;
|
|
187
|
+
fpWindowFrames = 0;
|
|
188
|
+
fpWindowStart = nowMs;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
app.ticker.add(gateHandler);
|
|
192
|
+
gateApp = app;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
/** 已挂上的 PIXI.Filter(original 且无插件链时为 null) */
|
|
196
|
+
let activeFilter = null;
|
|
197
|
+
/** 当前滤镜的结构签名(预设 + 效果链):只有它变了才重建 PIXI.Filter(热更新判据) */
|
|
198
|
+
let filterSignature = '';
|
|
199
|
+
/** 当前生效的插件效果链(由面板设置变化时下发;语义与 Spine 共用 effect-chain.js) */
|
|
200
|
+
let effectChain = [];
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 用引擎那 12 段预设表达式拼一份 PIXI 片元着色器。
|
|
204
|
+
* 为什么能复用:预设表里的 body 都是"对直通色 c 的表达式",与渲染器无关;
|
|
205
|
+
* 这里只换外壳(PIXI 的 vTextureCoord/uSampler + 预乘还原),效果与 Spine 侧同一套公式。
|
|
206
|
+
*/
|
|
207
|
+
function pixiFragmentOf(id, chainIn) {
|
|
208
|
+
const chain = Array.isArray(chainIn) ? chainIn : [];
|
|
209
|
+
const preset = FILTER_PRESETS.find((x) => x.id === id) || FILTER_PRESETS[0];
|
|
210
|
+
// 链的拼装走共用层:预设在前、插件效果按清单声明顺序叠加
|
|
211
|
+
const body = buildChainBody({ presetBody: preset.body, effects: chain });
|
|
212
|
+
// 前缀**保持原样**(precision + vTextureCoord + uSampler 三件套):这套在改动前是好的,
|
|
213
|
+
// 不要为了"避免重复声明"去删它 —— 删了 precision 直接编译失败整屏空白(实测),
|
|
214
|
+
// 删另外两行也会编译失败。插件链需要的额外声明插在三件套**之后**。
|
|
215
|
+
const pre = effectPrelude({ sampler: 'uSampler', texCoords: 'vTextureCoord' });
|
|
216
|
+
return [
|
|
217
|
+
'precision mediump float;',
|
|
218
|
+
'varying vec2 vTextureCoord;',
|
|
219
|
+
'uniform sampler2D uSampler;',
|
|
220
|
+
...(chain.length ? [pre.global, uniformDeclarations(chain)] : []),
|
|
221
|
+
'const vec3 LUMA = vec3(0.299, 0.587, 0.114);',
|
|
222
|
+
'vec3 toGray(vec3 c) { return vec3(dot(c, LUMA)); }',
|
|
223
|
+
'vec3 withSat(vec3 c, float s) { float l = dot(c, LUMA); return mix(vec3(l), c, s); }',
|
|
224
|
+
'void main() {',
|
|
225
|
+
' vec4 texColor = texture2D(uSampler, vTextureCoord);', // PIXI 纹理是预乘的
|
|
226
|
+
' float a = texColor.a;',
|
|
227
|
+
' vec3 base = texColor.rgb / max(a, 0.00001);', // 还原直通色(链的起点)
|
|
228
|
+
...(chain.length ? [pre.local] : []),
|
|
229
|
+
` ${body}`,
|
|
230
|
+
' gl_FragColor = vec4(f * a, a);', // 再乘回预乘
|
|
231
|
+
'}',
|
|
232
|
+
].join('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 把当前 presetId + 插件效果链落到 PIXI stage 上(都为空 ⇒ 摘掉滤镜) */
|
|
236
|
+
function applyPreset() {
|
|
237
|
+
const app = (() => { try { return live2d.getApp ? live2d.getApp() : null; } catch { return null; } })();
|
|
238
|
+
if (!app || !app.stage) return false;
|
|
239
|
+
const chain = effectChain;
|
|
240
|
+
// 签名只看"结构"(预设 + 链上效果与表达式):**参数值不进签名** ⇒ 切档位只改 uniform,不重建滤镜
|
|
241
|
+
const signature = `${presetId}|${chainSignature(chain)}`;
|
|
242
|
+
const uniforms = { ...chainUniforms(chain) };
|
|
243
|
+
// u_texel 必须有非零值:未赋值时 texelSample(uv-texel) 会退化成取自己,锐化恒等(Spine 侧踩过同一个坑)
|
|
244
|
+
if (chain.length) uniforms.u_texel = [DEFAULT_TEXEL, DEFAULT_TEXEL];
|
|
245
|
+
try {
|
|
246
|
+
if (presetId === DEFAULT_FILTER && !chain.length) {
|
|
247
|
+
// 可逆:摘掉挂载即可。**这里不 destroy** —— 实例销毁后 PIXI 仍可能在当帧拿着它,
|
|
248
|
+
// 实测会引入新的卡顿/异常;原实现也是只置 null,让 GC 随实例一起回收。
|
|
249
|
+
setFilters(app, null);
|
|
250
|
+
activeFilter = null;
|
|
251
|
+
filterSignature = '';
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (activeFilter && filterSignature === signature) {
|
|
255
|
+
// 结构没变 ⇒ 热更新:只写 uniform,绝不重建实例(重建会黑一帧)
|
|
256
|
+
// PIXI v6 的 filter.uniforms 是**真实对象**,直接赋值即可(与 spine-webgl Shader 的
|
|
257
|
+
// setUniformf API 完全不同,不要照搬那边)。
|
|
258
|
+
for (const [key, value] of Object.entries(uniforms)) activeFilter.uniforms[key] = value;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
const next = createFilter(pixiFragmentOf(presetId, chain), uniforms);
|
|
262
|
+
if (!next) return false;
|
|
263
|
+
activeFilter = next;
|
|
264
|
+
// 邻域采样必需:默认 filterArea 会裁到边界内,边缘缺一个 texel
|
|
265
|
+
activeFilter.padding = 1;
|
|
266
|
+
filterSignature = signature;
|
|
267
|
+
// filterArea 固定整屏由 adapter 处理(否则 PIXI 会按 stage 包围盒裁剪,边缘缺一块)
|
|
268
|
+
setFilters(app, [activeFilter]);
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
// 着色器编译失败等:保持原渲染,不抛
|
|
272
|
+
activeFilter = null;
|
|
273
|
+
filterSignature = '';
|
|
274
|
+
try { setFilters(app, null); } catch { /* 忽略 */ }
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// DPR 档位:'auto' = 跟随 Surface 下发的 res;'1'/'1.5'/'2' = 显式指定档位
|
|
279
|
+
let dprWanted = 'auto';
|
|
280
|
+
|
|
281
|
+
/** 几何唯一来源:Surface 句柄(渲染器不自行测量 DOM) */
|
|
282
|
+
function geometryOf() {
|
|
283
|
+
try { return handleRef && handleRef.geometry ? handleRef.geometry() : null; } catch { return null; }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 当前生效 DPR。
|
|
288
|
+
* auto 用 Surface 下发的 res(像素比口径由引擎侧决定,渲染器不自己读 devicePixelRatio);
|
|
289
|
+
* 显式档直接采用 —— 这正是"用户在面板选了 2 就必须是 2"的落点。
|
|
290
|
+
* @param {number} [fallback] 几何取不到时的兜底值
|
|
291
|
+
*/
|
|
292
|
+
function effectiveRes(fallback) {
|
|
293
|
+
const g = geometryOf();
|
|
294
|
+
const base = Math.max(1, (g && (g.res || g.dpr)) || fallback || 1);
|
|
295
|
+
if (dprWanted === 'auto') return base;
|
|
296
|
+
const n = parseFloat(dprWanted);
|
|
297
|
+
return Number.isFinite(n) && n > 0 ? n : base;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 按当前 DPR 重排覆盖层画布与 PIXI 后缓冲。
|
|
302
|
+
* 两处尺寸必须同源(画布宽高 = css × res,renderer.resolution = res),
|
|
303
|
+
* 否则 PIXI 会把画面按错误比例拉伸,表现为"角色变大/模糊"。
|
|
304
|
+
* @returns {boolean} 是否成功落到 PIXI
|
|
305
|
+
*/
|
|
306
|
+
function applyResolution() {
|
|
307
|
+
const g = geometryOf();
|
|
308
|
+
const cssW = Math.max(1, Math.round((g && g.cssW) || 0));
|
|
309
|
+
const cssH = Math.max(1, Math.round((g && g.cssH) || 0));
|
|
310
|
+
if (!cssW || !cssH) return false;
|
|
311
|
+
const res = effectiveRes();
|
|
312
|
+
const app = (() => { try { return live2d.getApp ? live2d.getApp() : null; } catch { return null; } })();
|
|
313
|
+
if (!app || !app.renderer) return false;
|
|
314
|
+
try {
|
|
315
|
+
app.renderer.resolution = res;
|
|
316
|
+
app.renderer.resize(cssW, cssH);
|
|
317
|
+
} catch { return false; }
|
|
318
|
+
try { if (live2d.syncSize) live2d.syncSize({ size: { width: cssW, height: cssH }, dpr: res }); } catch { /* 忽略 */ }
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
id: 'live2d',
|
|
324
|
+
// 声明的 kind 供 surface.register 做面元数据表校验(防"注册了渲染器却忘了加表项")
|
|
325
|
+
kind: 'live2d',
|
|
326
|
+
capabilities: {
|
|
327
|
+
// Live2D 不支持 Spine 的插槽/网格类设置项:只声明确实生效的
|
|
328
|
+
// 只声明确实生效的三项:纹理过滤(PIXI scaleMode)、限帧(ticker.maxFPS)、
|
|
329
|
+
// DPR(PIXI renderer.resolution + 覆盖层画布同源放大)。
|
|
330
|
+
// presetFilter:12 档滤镜预设,经 PIXI.Filter 挂到 overlay stage(复用引擎同一套表达式)
|
|
331
|
+
// msaa / pma / fit 不声明:msaa 要重建 PIXI app(运行期做不到);
|
|
332
|
+
// pma 由 PIXI 纹理管线托管、无法按页切直通/转预乘;fit 的"全部部件/动画全程"依赖 Spine 骨架包围盒数据(Live2D 无此数据)。
|
|
333
|
+
// animation:动作切换经 bridge.playLabel 落地(本面唯一的"内容操作"能力)。
|
|
334
|
+
// skin / mixMode 不声明——本面没有对应实现,声明了就是骗面板。
|
|
335
|
+
settings: ['textureFilter', 'fps', 'dpr', 'presetFilter', 'animation', 'speed'],
|
|
336
|
+
debugDraw: [],
|
|
337
|
+
camera: 'ortho-2d',
|
|
338
|
+
},
|
|
339
|
+
/** Assets 层入口(测试/工具用):catalog entry → ready Document */
|
|
340
|
+
buildDocument,
|
|
341
|
+
camera,
|
|
342
|
+
/**
|
|
343
|
+
* 装载:把 Surface 句柄透传给覆盖层实现(画布/尺寸/DPR 都从这里来)。
|
|
344
|
+
* @param {object} document
|
|
345
|
+
* @param {object} handle
|
|
346
|
+
*/
|
|
347
|
+
async mount(document, handle) {
|
|
348
|
+
// 只接受 ready 的 Document(契约 Document契约 §二 硬规则 1)
|
|
349
|
+
// 只接受 ready 的 Document,**不提供旧形状兜底**(理由同 render/spine.js)
|
|
350
|
+
if (!document || document.state !== 'ready') {
|
|
351
|
+
const why = (document && (document.warnings || []).join(';')) || '未提供 Document';
|
|
352
|
+
markModelFailed(`Document 不可用:${why}`, { kind: 'live2d' });
|
|
353
|
+
throw new Error(`live2d.mount 只接受 ready 的 Document(请先 buildDocument):${why}`);
|
|
354
|
+
}
|
|
355
|
+
handleRef = handle || null;
|
|
356
|
+
try {
|
|
357
|
+
const info = await live2d.mount(document, handle);
|
|
358
|
+
// 装载完成补一次播放速度:换模型/切到本面后速度要保持(speed 存在设置层,不随模型走)
|
|
359
|
+
try { live2d.bridge.setSpeed(speedValue); } catch { /* 速度补不上不影响装载 */ }
|
|
360
|
+
// 每次装载后确保门控装在当前 app 上(unmount 会销毁 app,重建后是新的实例)
|
|
361
|
+
try { installFrameGate(live2d.getApp ? live2d.getApp() : null); } catch { /* 门控装不上不应阻断装载 */ }
|
|
362
|
+
applyPreset(); // 新模型挂上后把当前滤镜补一次(切模型/重建后不断档)
|
|
363
|
+
// 就绪信号(契约 §2.2):painted 用门控里累计的**真实绘制帧数**,不用像素采样
|
|
364
|
+
trackModelStage({
|
|
365
|
+
kind: 'live2d',
|
|
366
|
+
id: document.id || (document.meta && document.meta.id) || null,
|
|
367
|
+
canvas: handle && handle.canvas,
|
|
368
|
+
getFrames: () => renderedTotal,
|
|
369
|
+
getActions: () => ((live2d.bridge && live2d.bridge.motionItems ? live2d.bridge.motionItems().length : 0)),
|
|
370
|
+
});
|
|
371
|
+
return info;
|
|
372
|
+
} catch (e) {
|
|
373
|
+
markModelFailed(`装载失败:${(e && e.message) || e}`, { kind: 'live2d' });
|
|
374
|
+
throw e;
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
/**
|
|
378
|
+
* 尺寸变化:覆盖层 canvas 的像素尺寸按**引擎同一口径**的 DPR 设置。
|
|
379
|
+
* 不做设备像素比推断(软件渲染 / 封顶 2 / dprMode 只有引擎知道),
|
|
380
|
+
* 统一由 StageSurface 的 geometry().res 下发。
|
|
381
|
+
* @param {{cssW:number, cssH:number}} box
|
|
382
|
+
*/
|
|
383
|
+
resize(box) {
|
|
384
|
+
// 尺寸唯一来源:handle.geometry()(Surface 提供);box 只作兜底
|
|
385
|
+
const g = (() => { try { return handleRef && handleRef.geometry ? handleRef.geometry() : null; } catch { return null; } })();
|
|
386
|
+
const cssW = Math.max(1, Math.round((g && g.cssW) || (box && box.cssW) || 0));
|
|
387
|
+
const cssH = Math.max(1, Math.round((g && g.cssH) || (box && box.cssH) || 0));
|
|
388
|
+
const res = effectiveRes(box && box.dpr);
|
|
389
|
+
if (!cssW || !cssH) return;
|
|
390
|
+
if (typeof live2d.syncSize === 'function') {
|
|
391
|
+
try { live2d.syncSize({ size: { width: cssW, height: cssH }, dpr: res }); } catch { /* 忽略 */ }
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
/** 按需渲染一帧(暂停中交互/改设置后调用;ticker 停着不会自动画) */
|
|
395
|
+
/**
|
|
396
|
+
* 截图取像素:从**当前 Live2D 画布**取(覆盖层自己的 canvas / PIXI 舞台)。
|
|
397
|
+
*
|
|
398
|
+
* 两条路,按可靠性排序:
|
|
399
|
+
* ① PIXI 的 extract.canvas(stage):直接把舞台渲染成一张 2D canvas(不依赖 GL 缓冲保留);
|
|
400
|
+
* ② 读 app.view 的 WebGL 缓冲:必须在 repaintFrame() 之后**同一任务内**取,
|
|
401
|
+
* 因为 WebGL 默认不保留绘制缓冲(换任务就可能是空/旧内容)。
|
|
402
|
+
* @returns {{canvas: HTMLCanvasElement, width: number, height: number, source: string}|null}
|
|
403
|
+
*/
|
|
404
|
+
capture() {
|
|
405
|
+
const app = (() => { try { return live2d.getApp && live2d.getApp(); } catch { return null; } })();
|
|
406
|
+
if (!app) return null;
|
|
407
|
+
repaintFrame(); // 先画一帧,保证拿到的是当前姿态(暂停时尤其必要)
|
|
408
|
+
// ① 优先读**画布缓冲**:尺寸与屏幕所见一致(整画布,含透明背景);
|
|
409
|
+
// PIXI 默认不保留绘制缓冲,所以必须在上面 repaintFrame() 的**同一任务内**读,
|
|
410
|
+
// 读不到(全 0)再退到 extract。
|
|
411
|
+
const direct = captureFromView(app);
|
|
412
|
+
if (direct) return direct;
|
|
413
|
+
// ② extract 兜底:PIXI 的 extract 返回的是**模型包围盒**尺寸,
|
|
414
|
+
// 直接用会让导出图忽大忽小(用户会当成 bug)。这里按画布尺寸等比居中贴一张,
|
|
415
|
+
// 保证"任何状态下截图尺寸都等于当前画布尺寸"。
|
|
416
|
+
try {
|
|
417
|
+
const ext = app.renderer && app.renderer.extract;
|
|
418
|
+
if (ext && typeof ext.canvas === 'function' && app.stage) {
|
|
419
|
+
const c = ext.canvas(app.stage);
|
|
420
|
+
const sized = c && c.width && c.height ? fitIntoCanvas(c, app) : null;
|
|
421
|
+
if (sized) return sized;
|
|
422
|
+
}
|
|
423
|
+
} catch { /* 落到读缓冲 */ }
|
|
424
|
+
// ② 读缓冲(含逐行翻转)
|
|
425
|
+
const view = app.view;
|
|
426
|
+
if (!view || !view.width || !view.height) return null;
|
|
427
|
+
const w = view.width;
|
|
428
|
+
const h = view.height;
|
|
429
|
+
const out = document.createElement('canvas');
|
|
430
|
+
out.width = w;
|
|
431
|
+
out.height = h;
|
|
432
|
+
const ctx = out.getContext('2d');
|
|
433
|
+
const gl = view.getContext('webgl2') || view.getContext('webgl');
|
|
434
|
+
if (!ctx || !gl || typeof gl.readPixels !== 'function') return null;
|
|
435
|
+
try {
|
|
436
|
+
const buf = new Uint8Array(w * h * 4);
|
|
437
|
+
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
|
438
|
+
const img = ctx.createImageData(w, h);
|
|
439
|
+
for (let y = 0; y < h; y += 1) {
|
|
440
|
+
const src = (h - 1 - y) * w * 4;
|
|
441
|
+
img.data.set(buf.subarray(src, src + w * 4), y * w * 4);
|
|
442
|
+
}
|
|
443
|
+
ctx.putImageData(img, 0, 0);
|
|
444
|
+
return { canvas: out, width: w, height: h, source: 'buffer', digest: digestOf(out) };
|
|
445
|
+
} catch { return null; }
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
render() { return repaintFrame(); },
|
|
449
|
+
/** 显式补一帧(统一入口 repaintActive 用) */
|
|
450
|
+
repaint() { return repaintFrame(); },
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 暂停:停掉 PIXI ticker。
|
|
454
|
+
* 为什么用 ticker:Live2D 的时间推进完全由 ticker 驱动(模型 update 在 ticker 回调里),
|
|
455
|
+
* 停 ticker 才是"真的不动";只改标志位模型照样每帧更新。
|
|
456
|
+
* 与 frame-gate 的关系:门控只决定"这帧要不要渲染",ticker 停了连门控都不会被调用,
|
|
457
|
+
* 恢复时直接 start() 即可,不需要额外补帧。
|
|
458
|
+
*/
|
|
459
|
+
pause() {
|
|
460
|
+
pausedRef = true;
|
|
461
|
+
try { const app = live2d.getApp && live2d.getApp(); if (app && app.ticker && app.ticker.stop) { app.ticker.stop(); return true; } } catch { /* 忽略 */ }
|
|
462
|
+
return false;
|
|
463
|
+
},
|
|
464
|
+
/** 继续:重启 ticker */
|
|
465
|
+
resume() {
|
|
466
|
+
pausedRef = false;
|
|
467
|
+
try { const app = live2d.getApp && live2d.getApp(); if (app && app.ticker && app.ticker.start) { app.ticker.start(); return true; } } catch { /* 忽略 */ }
|
|
468
|
+
return false;
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
/** DPR 变化:与引擎保持同口径(Live2D 自身不决定像素比) */
|
|
472
|
+
setDpr() { /* 由 resize() 统一按引擎口径同步 */ },
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* 应用一项设置(契约 renderer/v1)。未知键返回 false 不抛。
|
|
477
|
+
* @param {string} key textureFilter | fps
|
|
478
|
+
* @param {any} value
|
|
479
|
+
* @returns {boolean}
|
|
480
|
+
*/
|
|
481
|
+
setSetting(key, value) {
|
|
482
|
+
// animation 例外:重复点同一条动作是"重播",不能被同值短路吞掉
|
|
483
|
+
if (key !== 'animation') {
|
|
484
|
+
const cur = this.getSetting(key);
|
|
485
|
+
if (cur !== null && cur !== undefined && String(cur) === String(value)) return true;
|
|
486
|
+
}
|
|
487
|
+
if (key === 'animation') {
|
|
488
|
+
// 空值 = 无操作(能力自检同值回灌时会走到这里)
|
|
489
|
+
const label = value === null || value === undefined ? '' : String(value);
|
|
490
|
+
if (!label) return true;
|
|
491
|
+
try { return live2d.bridge.playLabel(label) === true; } catch { return false; }
|
|
492
|
+
}
|
|
493
|
+
// 未声明的两项(skin / mixMode):本面确实不支持,如实返回 false
|
|
494
|
+
if (key === 'skin' || key === 'mixMode') return false;
|
|
495
|
+
if (key === 'speed') {
|
|
496
|
+
const v = Math.max(0.1, Math.min(3, Number(value) || 1));
|
|
497
|
+
speedValue = v;
|
|
498
|
+
// bridge.setSpeed 在模型未装载时只记住值,装载后会补;所以这里只要没抛错就算成功
|
|
499
|
+
try { live2d.bridge.setSpeed(v); return true; } catch { return false; }
|
|
500
|
+
}
|
|
501
|
+
if (key === 'presetFilter') {
|
|
502
|
+
const id = String(value || DEFAULT_FILTER);
|
|
503
|
+
if (!FILTER_PRESETS.some((x) => x.id === id)) return false; // 未知档位不认
|
|
504
|
+
presetId = id;
|
|
505
|
+
// 模型还没挂时 applyPreset 返回 false,但档位已记住:mount 之后会补挂,
|
|
506
|
+
// 所以这里仍算成功(否则能力自检在"没加载模型"时会把已实现的能力误判为不支持)
|
|
507
|
+
applyPreset();
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
if (key === 'dpr') {
|
|
511
|
+
// 先记档位:即使当前没挂载也接受(下次 mount/resize 生效),这样能力自检不会误报不支持
|
|
512
|
+
const v = String(value);
|
|
513
|
+
if (v === 'auto') dprWanted = 'auto';
|
|
514
|
+
else {
|
|
515
|
+
const n = parseFloat(v);
|
|
516
|
+
if (!Number.isFinite(n) || n <= 0) return false;
|
|
517
|
+
dprWanted = String(n);
|
|
518
|
+
}
|
|
519
|
+
const appDpr = live2d.getApp ? live2d.getApp() : null;
|
|
520
|
+
if (!appDpr) return true;
|
|
521
|
+
return applyResolution();
|
|
522
|
+
}
|
|
523
|
+
const app = live2d.getApp ? live2d.getApp() : null;
|
|
524
|
+
if (!app) return false;
|
|
525
|
+
try {
|
|
526
|
+
if (key === 'textureFilter') {
|
|
527
|
+
const nearest = String(value) === 'nearest';
|
|
528
|
+
// PIXI.SCALE_MODES 由运行时库提供;取不到就判不支持(不猜常量值)
|
|
529
|
+
const modes = window.PIXI && window.PIXI.SCALE_MODES;
|
|
530
|
+
if (!modes) return false;
|
|
531
|
+
const mode = nearest ? modes.NEAREST : modes.LINEAR;
|
|
532
|
+
let touched = 0;
|
|
533
|
+
for (const tex of texturesOf(app)) {
|
|
534
|
+
const bt = tex && tex.baseTexture;
|
|
535
|
+
if (!bt) continue;
|
|
536
|
+
try {
|
|
537
|
+
bt.scaleMode = mode;
|
|
538
|
+
bt.update(); // 让已上传的纹理按新采样方式重传,否则改了属性也不生效
|
|
539
|
+
touched += 1;
|
|
540
|
+
} catch { /* 单张失败继续其它 */ }
|
|
541
|
+
}
|
|
542
|
+
if (!touched) return false;
|
|
543
|
+
filterMode = nearest ? 'nearest' : 'linear';
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
if (key === 'fps') {
|
|
547
|
+
// 1~120:90 档要能接受(高刷屏)。实际限制由 frameGate 做,PIXI 的 maxFPS 保持 0。
|
|
548
|
+
const n = Math.max(1, Math.min(120, Number(value) || 60));
|
|
549
|
+
fpsLimit = n;
|
|
550
|
+
try { app.ticker.maxFPS = 0; } catch { /* 忽略 */ }
|
|
551
|
+
installFrameGate(app);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
} catch { return false; }
|
|
555
|
+
return false;
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 下发插件效果链(**与 Spine 同一套语义**:顺序/激活/参数换算/销毁都在 effect-chain.js,
|
|
560
|
+
* 这里只管"怎么挂到 PIXI.Filter 上")。
|
|
561
|
+
* 参数值变化只更新 `filter.uniforms`;只有链**结构**变化才重建滤镜实例(热更新判据)。
|
|
562
|
+
* @param {Array<{id:string, fragment:string, uniforms:object}>} chain
|
|
563
|
+
* @returns {boolean} 是否成功应用
|
|
564
|
+
*/
|
|
565
|
+
setEffectChain(chain) {
|
|
566
|
+
effectChain = Array.isArray(chain) ? chain : [];
|
|
567
|
+
return applyPreset();
|
|
568
|
+
},
|
|
569
|
+
/** 当前生效链的结构签名(断言用:切档位时它不应变化) */
|
|
570
|
+
effectChainSignature() {
|
|
571
|
+
return activeFilter || filterSignature ? filterSignature : '';
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
/** 回显实际生效值(不是请求值) */
|
|
575
|
+
getSetting(key) {
|
|
576
|
+
if (key === 'dpr') {
|
|
577
|
+
// 回显"实际生效值":已挂载时读 PIXI 真实分辨率,未挂载时按档位与几何推算
|
|
578
|
+
const app = (() => { try { return live2d.getApp ? live2d.getApp() : null; } catch { return null; } })();
|
|
579
|
+
if (app && app.renderer && app.renderer.resolution) return app.renderer.resolution;
|
|
580
|
+
return effectiveRes();
|
|
581
|
+
}
|
|
582
|
+
if (key === 'animation') {
|
|
583
|
+
// 无模型时回显 ''(不是 null,理由同 spine)
|
|
584
|
+
try { return (live2d.bridge.currentLabel && live2d.bridge.currentLabel()) || ''; } catch { return ''; }
|
|
585
|
+
}
|
|
586
|
+
if (key === 'speed') return speedValue;
|
|
587
|
+
if (key === 'presetFilter') return presetId;
|
|
588
|
+
if (key === 'textureFilter') return filterMode;
|
|
589
|
+
if (key === 'fps') {
|
|
590
|
+
const app = live2d.getApp ? live2d.getApp() : null;
|
|
591
|
+
// 回显"目标档位":maxFPS 已被关掉(恒 0),真实限制在我们的门控里
|
|
592
|
+
return fpsLimit;
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
},
|
|
596
|
+
/**
|
|
597
|
+
* 卸载:释放内容 + 自建 GL 资源(PIXI app)+ 移除覆盖层容器;幂等。
|
|
598
|
+
* 与 dispose 同一释放路径(渲染器实例本身仍存活,下次 mount 全量重建 app 与 DOM)。
|
|
599
|
+
* 为什么不像 spine 那样保留:PIXI 的 destroy(true) 会摘掉 canvas 只留容器,
|
|
600
|
+
* 那个中间态正是"页面空白但 stats 正常"的来源;重建成本低,不值得为此维护两套生命周期。
|
|
601
|
+
*/
|
|
602
|
+
/**
|
|
603
|
+
* 实测渲染帧率(每 ≥500ms 结算一次,只统计真正出画的帧)。
|
|
604
|
+
* 面板读数必须用它,不能用 PIXI 的 ticker.FPS(那是 rAF 频率)。
|
|
605
|
+
* @returns {number} 最近窗口的 frames/s;0 表示暂无数据
|
|
606
|
+
*/
|
|
607
|
+
getMeasuredFps() { return measuredFps; },
|
|
608
|
+
/** 累计真实绘制帧数(painted 判据的硬计数来源,验收用) */
|
|
609
|
+
getRenderedFrames() { return renderedTotal; },
|
|
610
|
+
unmount() {
|
|
611
|
+
try { if (typeof live2d.unmount === 'function') live2d.unmount(); } catch { /* 忽略 */ }
|
|
612
|
+
},
|
|
613
|
+
/** 彻底释放:销毁 PIXI app 与覆盖层 DOM;幂等(Surface 销毁时走这里) */
|
|
614
|
+
dispose() {
|
|
615
|
+
try { if (typeof live2d.dispose === 'function') live2d.dispose(); } catch { /* 忽略 */ }
|
|
616
|
+
},
|
|
617
|
+
isActive() {
|
|
618
|
+
try { return !!live2d.isActive(); } catch { return false; }
|
|
619
|
+
},
|
|
620
|
+
/** Surface 的帧入口(Live2D 由 PIXI 自己的 ticker 驱动,这里只对齐画布尺寸) */
|
|
621
|
+
render(handle) {
|
|
622
|
+
try { if (live2d.syncSize && handle) live2d.syncSize(handle); } catch { /* 忽略 */ }
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export default createLive2DRenderer;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// render/playback.js —— 「播放/暂停」的唯一入口(与 reset-view.js 同一套收口思路)
|
|
2
|
+
//
|
|
3
|
+
// 为什么收口:
|
|
4
|
+
// ① 底栏按钮、键盘快捷键、未来的自动暂停都调这一个函数,避免"某处只改图标不改引擎";
|
|
5
|
+
// ② 暂停必须**落在当前渲染面**上:Spine 停引擎时间推进、Live2D 停 PIXI ticker;
|
|
6
|
+
// 以前底栏直连 stage(恒为 Spine 舞台),Live2D 面点了没反应;
|
|
7
|
+
// ③ 调用方不判断"现在是哪一面",只把意图(暂停/继续)交给当前渲染器。
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 暂停或继续当前渲染面的播放。
|
|
11
|
+
* @param {object|null} surface 舞台表面(提供 activeRenderer())
|
|
12
|
+
* @param {boolean} paused true=暂停,false=继续
|
|
13
|
+
* @returns {'renderer.pause'|'renderer.resume'|'none'|'no-renderer'} 实际走的通路(便于断言)
|
|
14
|
+
*/
|
|
15
|
+
export function setPlaybackPaused(surface, paused) {
|
|
16
|
+
const renderer = surface && typeof surface.activeRenderer === 'function' ? surface.activeRenderer() : null;
|
|
17
|
+
if (!renderer) return 'no-renderer';
|
|
18
|
+
const method = paused ? 'pause' : 'resume';
|
|
19
|
+
if (typeof renderer[method] !== 'function') return 'none';
|
|
20
|
+
try {
|
|
21
|
+
renderer[method]();
|
|
22
|
+
return `renderer.${method}`;
|
|
23
|
+
} catch {
|
|
24
|
+
return 'none';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 按需渲染一帧(暂停期间的交互/改设置/切动作/截图都走这里)。
|
|
30
|
+
*
|
|
31
|
+
* 为什么也要收口:门面(renderer-2d)不转发渲染器自有方法(repaint/render),
|
|
32
|
+
* 直接 `state.currentRenderer.repaint()` 是空操作——实测 frames 增量 0。
|
|
33
|
+
* 这里按当前渲染器取真实实例,再退到它的 render()。
|
|
34
|
+
* @param {object|null} surface 舞台表面(提供 activeRenderer())
|
|
35
|
+
* @returns {'renderer.repaint'|'renderer.render'|'none'|'no-renderer'} 实际通路
|
|
36
|
+
*/
|
|
37
|
+
export function repaintActive(surface) {
|
|
38
|
+
const renderer = surface && typeof surface.activeRenderer === 'function' ? surface.activeRenderer() : null;
|
|
39
|
+
if (!renderer) return 'no-renderer';
|
|
40
|
+
if (typeof renderer.repaint === 'function') {
|
|
41
|
+
try { renderer.repaint(); return 'renderer.repaint'; } catch { return 'none'; }
|
|
42
|
+
}
|
|
43
|
+
if (typeof renderer.render === 'function') {
|
|
44
|
+
try { renderer.render(); return 'renderer.render'; } catch { return 'none'; }
|
|
45
|
+
}
|
|
46
|
+
return 'none';
|
|
47
|
+
}
|