From 1e78648ad45e3f3c61c1396fdad3035c0052042c Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:18:01 +0800 Subject: [PATCH] =?UTF-8?q?feedback7=20R22-RENDER:=20closed=20eyes=20stay?= =?UTF-8?q?=20closed=20without=20the=20stencil=20masks=20=E2=80=94=20the?= =?UTF-8?q?=20eyelid=20clips'=20covered=20slots=20hide=20when=20their=20ce?= =?UTF-8?q?ntre=20leaves=20the=20clip=20polygon=20(GitHub=20#177,=20re-imp?= =?UTF-8?q?lemented=20from=20PR=20#384=20by=20@Convey123);=20golden:=20unc?= =?UTF-8?q?hanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eyelids of 佩佩, 仇白, 隐德来希, 琳琅诗怀雅 and other models are Spine clipping attachments: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after it are clipped to it, so a blink or a knock-down closes the eye through the mask. render/app.js pickClipping keeps the masks only for a lone clipped skeleton at high quality, so in a battle the eyeballs showed through the closed lids. SpineActor._eyeMaskFallback now runs after each update while the masks are off: it walks the draw order like pixi-spine (from a slot whose attachment in force is a clip, to its endSlot — any skin) and hides the slot container of a covered slot whose drawn centre (a mesh's mean vertex, a region's quad centre) lies outside the clip polygon [ASSUMED: all or nothing per slot]; turning the masks on shows them again. PR #384's version hid `slot.currentSprite`, which pixi-spine leaves null on a mesh slot — every covered slot of the 417 local operator skeletons' 73 clipped ones is a mesh (313) or a region (54; regions have no worldVerticesLength, so it never measured them) — and its test stubbed a currentSprite onto every slot, so nothing was hidden in the game. Kept from it: the diagnosis, the ray-casting pointInPolygon and the key-frame expectations. Tests: test/render/eyemask.test.js (new, 7, on real runtime objects — the official skeletons in @pixi-spine/runtime-3.8's real Spine, @pixi/core display objects with a 16 px canvas stand-in, the real SpineActor: the eyeballs are meshes in the clip's container; 佩佩 / 仇白 Die and 隐德来希 / 琳琅诗怀雅 Idle hide exactly the eye slots in the closed frames and nothing open; a 30 Hz sweep of six clips; masks on hide nothing, turning them on shows all) 7/7 — the five skeleton tests skip without public/assets (not committed; npm run assets), and fail on PR #384's code; render unitview, enemy attack clips, texas skill clip 45/45. --- public/js/render/app.js | 5 +- public/js/render/spine.js | 99 ++++++++++++++++++++++++-- test/render/eyemask.test.js | 135 ++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 test/render/eyemask.test.js diff --git a/public/js/render/app.js b/public/js/render/app.js index 40b505a7..3370685b 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1569,8 +1569,9 @@ export async function createFieldView(host, options = {}) { // Crowded fields render skeletons through staggered RenderTexture impostors (units.js): the interval grows with // the number of Spine units so the per-frame vertex work stays roughly constant (hysteresis: re-evaluated // every 30 frames). Prep and ordinary fields keep full-rate direct rendering. - // Spine clipping masks (only eyeball clips on the current roster, invisible at chibi scale) each cost a stencil - // render-pass break (~2–5 ms of GPU on tiled GPUs): kept only for a lone clipped skeleton at high quality + // Spine clipping masks (the eyelids on the current roster) each cost a stencil render-pass break (~2–5 ms of GPU on + // tiled GPUs): kept only for a lone clipped skeleton at high quality; without them SpineActor hides the eyeball slots a + // closed lid would cut (GitHub #177, spine.js _eyeMaskFallback) let clipAllowed = true; function pickClipping() { let n = 0; diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 8025f17b..d86bce68 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -62,20 +62,53 @@ export function windUpPlan(loopDur, hit, interval, lead) { return { ts, tsWind, start }; } -/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */ +/** A clipping attachment (pixi-spine AttachmentType.Clipping = 6). */ +const isClip = (a) => !!a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a))); + +/** Whether any skin of the skeleton data has a clipping attachment. */ export function hasClipping(data) { try { for (const skin of data?.skins || []) { const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : []; - for (const e of list) { - const a = e && e.attachment; - if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true; - } + for (const e of list) if (isClip(e && e.attachment)) return true; } } catch { /* unknown runtime shape: assume none */ } return false; } +/** + * Is (x, y) inside the polygon of `n` points `pts` (flat [x0, y0, x1, y1, …])? Ray casting (PR #384 by @Convey123). + */ +export function pointInPolygon(x, y, pts, n) { + let inside = false; + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = pts[i * 2], yi = pts[i * 2 + 1], xj = pts[j * 2], yj = pts[j * 2 + 1]; + if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** + * World centre of what a slot draws, into `out` ([x, y]); false when it draws nothing measurable. A mesh: the mean of its + * world vertices (`buf` grows to fit); a region: its quad's centre — the attachment's (x, y) through the bone (the four + * corners sit symmetrically around it; RegionAttachment.computeWorldVertices takes a bone in runtime 3.8, a slot in 4.1). + */ +function slotCentre(slot, att, buf, out) { + const n = att.worldVerticesLength | 0; + if (n >= 2 && typeof att.computeWorldVertices === 'function') { + if (buf.v.length < n) buf.v = new Float32Array(n); + const v = buf.v; + att.computeWorldVertices(slot, 0, n, v, 0, 2); + let cx = 0, cy = 0; + for (let k = 0; k < n; k += 2) { cx += v[k]; cy += v[k + 1]; } + out[0] = cx / (n >> 1); out[1] = cy / (n >> 1); + } else if (att.region !== undefined && Number.isFinite(att.x) && Number.isFinite(att.y) && slot.bone) { + const b = slot.bone; + out[0] = b.a * att.x + b.b * att.y + b.worldX; out[1] = b.c * att.x + b.d * att.y + b.worldY; + } else return false; + return Number.isFinite(out[0]) && Number.isFinite(out[1]); +} + export class SpineActor { /** * @param {any} spineData PIXI.spine skeleton data @@ -91,11 +124,12 @@ export class SpineActor { this.names = new Set((spineData.animations || []).map((a) => a.name)); /** * Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons - * are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots, - * visually negligible on battle chibis) when too many of them share a field (app.js budget). + * are drawn through the impostor atlas while clipping is on, and clipping is switched off when too many of them + * share a field (app.js budget) — then _eyeMaskFallback hides what a closed eyelid would cut (GitHub #177). */ this.clipped = hasClipping(spineData); this.clipOn = true; + this._clipHidden = new Set(); // slot indexes the eyelid fallback hid in its last pass (_eyeMaskFallback) if (this.clipped) { const sp = this.spine; const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null; @@ -134,6 +168,7 @@ export class SpineActor { on = !!on; if (!this.clipped || on === this.clipOn) return; this.clipOn = on; + if (on) this._showClipHidden(); // the stencil takes over from the eyelid fallback for (const slot of this.spine?.skeleton?.slots || []) { if (!slot.clippingContainer) continue; slot.clippingContainer.mask = on ? slot.currentGraphics || null : null; @@ -546,6 +581,56 @@ export class SpineActor { if (!this.frozen) { try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ } } + if (this.clipped && !this.clipOn) this._eyeMaskFallback(); + } + + /** + * The eyelids without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented). On this roster the + * clipping attachments are eyelids: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after + * it are clipped to it, so a closing eye (a blink, a knock-down) is hidden by the mask, not by the art. The masks + * are off on a crowded field or below high quality (render/app.js pickClipping), and the eyeballs showed through + * the closed lids (佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …). While they are off, every slot a clip covers — walked in draw + * order from the clipping slot to its endSlot, with the attachments in force (any skin), as pixi-spine does — is + * hidden for the frame when the centre of what it draws lies outside the clip polygon [ASSUMED: all or nothing per + * slot, an approximation of the mask's cut]. pixi-spine shows every attached slot container again on each update. + */ + _eyeMaskFallback() { + const sp = this.spine, skel = sp && sp.skeleton, boxes = sp && sp.slotContainers; + if (!skel || !Array.isArray(skel.drawOrder) || !boxes) return; + this._showClipHidden(); // undo the last pass (a pixi-spine update re-shows them anyway; a frozen one does not) + try { + const buf = this._clipBuf || (this._clipBuf = { v: new Float32Array(64), poly: new Float32Array(16), c: [0, 0] }); + let pn = -1, end = null; // pn: points of the clip in force (−1: none; < 3: degenerate, hides nothing) + for (const slot of skel.drawOrder) { + const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment; + if (isClip(att)) { + const n = att.worldVerticesLength | 0; + if (buf.poly.length < n) buf.poly = new Float32Array(n); + if (n >= 6) att.computeWorldVertices(slot, 0, n, buf.poly, 0, 2); + pn = n >> 1; + end = att.endSlot && att.endSlot !== slot.data ? att.endSlot : null; + continue; + } + if (pn < 0) continue; + const box = boxes[slot.data.index]; + if (pn >= 3 && att && box && slotCentre(slot, att, buf, buf.c) && !pointInPolygon(buf.c[0], buf.c[1], buf.poly, pn)) { + box.visible = false; + this._clipHidden.add(slot.data.index); + } + if (end && slot.data === end) { pn = -1; end = null; } + } + } catch { /* an unknown runtime shape must never stop the frame */ } + } + + /** Show the slot containers the fallback hid (those still drawing an attachment). */ + _showClipHidden() { + if (!this._clipHidden.size) return; + const slots = this.spine?.skeleton?.slots, boxes = this.spine?.slotContainers; + for (const i of this._clipHidden) { + const slot = slots && slots[i]; + if (boxes && boxes[i] && slot && (typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment)) boxes[i].visible = true; + } + this._clipHidden.clear(); } /** Model height in skeleton units (setup-pose bounds, else a chibi default). */ diff --git a/test/render/eyemask.test.js b/test/render/eyemask.test.js new file mode 100644 index 00000000..da4288b2 --- /dev/null +++ b/test/render/eyemask.test.js @@ -0,0 +1,135 @@ +// test/render/eyemask.test.js — closed eyes without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented): +// the eyelids of these models are Spine clipping attachments, the masks are off on a crowded field or below high +// quality (render/app.js pickClipping), and SpineActor._eyeMaskFallback (render/spine.js) hides a covered slot whose +// centre leaves the clip polygon. +// +// Real runtime objects throughout: the official skeletons read by @pixi-spine/runtime-3.8, drawn by its real Spine +// (pixi-spine's slot containers and meshes on @pixi/core display objects — a 16 px canvas stand-in is all it needs +// headless) inside the real SpineActor. The eyeballs are meshes, drawn through `slot.currentMesh` — PR #384 hid +// `slot.currentSprite` (null on a mesh slot) and its test stubbed one onto every slot, so nothing was hidden in the +// game. The skeletons are not in the repository (public/assets: `npm run assets`): without them those tests skip. +// Run: node --test test/render/eyemask.test.js + +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const require = createRequire(path.join(ROOT, 'package.json')); +const ASSETS = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const dirOf = (id) => path.join(ROOT, 'public', 'assets', 'spine', 'op', id, 'front'); +const have = (id) => existsSync(path.join(dirOf(id), `${id}.skel`)) && existsSync(path.join(dirOf(id), `${id}.atlas`)); +const MODELS = ['char_4058_pepe', 'char_4082_qiubai', 'char_4010_etlchi', 'char_1033_swire2']; + +let SpineActor, pointInPolygon, rt, base, core; +before(async () => { + // headless pixi: its shader-precision probe and Texture.WHITE ask for a canvas; no WebGL context is needed to build + // and update a Spine's display tree + class HeadlessCanvas { constructor() { this.width = 16; this.height = 16; } getContext(kind) { return kind === '2d' ? { fillStyle: '', fillRect() {}, clearRect() {}, drawImage() {} } : null; } } + globalThis.HTMLCanvasElement ??= HeadlessCanvas; + globalThis.document ??= { createElement: () => new HeadlessCanvas() }; + rt = require('@pixi-spine/runtime-3.8'); + base = require('@pixi-spine/base'); + core = require('@pixi/core'); + globalThis.PIXI = { spine: { Spine: rt.Spine } }; // SpineActor builds `new PIXI.spine.Spine(data)` + ({ SpineActor, pointInPolygon } = await import('../../public/js/render/spine.js')); +}); + +const cache = new Map(); +function skeletonData(id) { + if (!cache.has(id)) { + const atlas = new base.TextureAtlas(readFileSync(path.join(dirOf(id), `${id}.atlas`), 'utf8'), (line, cb) => cb(new core.BaseTexture(null, { width: 1024, height: 1024 }))); + cache.set(id, new rt.SkeletonBinary(new rt.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(path.join(dirOf(id), `${id}.skel`))))); + } + return cache.get(id); +} +/** A real SpineActor of `id` with its manifest entry, clipping masks on or off. */ +function actor(id, clip) { + const a = new SpineActor(skeletonData(id), ASSETS.chars[id].spine.front); + a.setClipping(clip); + return a; +} +/** Slots whose container the fallback hid: still attached (pixi-spine shows those), yet not visible. */ +const hidden = (a) => a.spine.skeleton.slots.filter((s) => s.getAttachment() && a.spine.slotContainers[s.data.index].visible === false).map((s) => s.data.name).sort(); +/** Hidden slots `t` s into clip `clip` ('Die' through SpineActor.die — a knock-down — or 'Idle'), masks off. A fresh + * actor per time: pixi-spine's state.update takes a delta. */ +function hiddenAt(id, clip, t) { + const a = actor(id, false); + if (clip === 'Die') a.die(); else a.spine.state.setAnimation(0, clip, true); + a.update(t); + return hidden(a); +} + +test('pointInPolygon: inside / outside of a convex and a concave polygon', () => { + const square = [0, 0, 10, 0, 10, 10, 0, 10]; + assert.equal(pointInPolygon(5, 5, square, 4), true); + for (const [x, y] of [[-1, 5], [11, 5], [5, -3]]) assert.equal(pointInPolygon(x, y, square, 4), false); + const l = [0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10]; // an L: the notch is outside + assert.equal(pointInPolygon(6, 6, l, 6), false); + assert.equal(pointInPolygon(2, 6, l, 6), true); + assert.equal(pointInPolygon(8, 2, l, 6), true); +}); + +describe('eyelids on the official skeletons, drawn by the real pixi-spine runtime', { skip: !MODELS.every(have) && 'the operator skeletons are not in public/assets (npm run assets)' }, () => { + test('the eyeballs are meshes drawn through slot.currentMesh, inside the eyelid clip\'s container', () => { + const a = actor('char_4058_pepe', true); + a.update(0.1); + const slots = a.spine.skeleton.slots.filter((s) => /^F_[LR]_Eye_[BC]$/.test(s.data.name)); + assert.equal(slots.length, 4); + const clip = a.spine.skeleton.slots.find((s) => s.clippingContainer); + for (const s of slots) { + assert.equal(s.getAttachment().constructor.name, 'MeshAttachment', s.data.name); + assert.ok(s.currentMesh && !s.currentSprite, `${s.data.name}: a mesh, no sprite`); + assert.equal(a.spine.slotContainers[s.data.index].parent, clip.clippingContainer, `${s.data.name} is clipped`); + } + }); + + test('佩佩 knocked down (Die): the four eyeball / eye-white slots hide once the eyes close (0.13 → 1.00 s), none before', () => { + for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), [], `open at ${t}`); + for (const t of [0.3, 0.52, 1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), ['F_L_Eye_B', 'F_L_Eye_C', 'F_R_Eye_B', 'F_R_Eye_C'], `closed at ${t}`); + }); + + test('仇白 knocked down: both eyeballs hide once the eyes close', () => { + for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), [], `open at ${t}`); + for (const t of [0.2, 0.52, 1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), ['F_L_Eyeball', 'F_R_Eyeball'], `closed at ${t}`); + }); + + test('隐德来希 / 琳琅诗怀雅 blink in Idle (2.13–2.37 s / 2.90–3.00 s): hidden then, never with the eyes open', () => { + for (const t of [0.5, 1.9, 3]) assert.deepEqual(hiddenAt('char_4010_etlchi', 'Idle', t), [], `隐德来希 open at ${t}`); + for (const t of [2.13, 2.25, 2.37]) assert.ok(hiddenAt('char_4010_etlchi', 'Idle', t).length >= 4, `隐德来希 blinking at ${t}`); + for (const t of [0.5, 2.6, 3.2]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), [], `琳琅诗怀雅 open at ${t}`); + for (const t of [2.9, 2.97, 3]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), ['F_H_L_Eyeball', 'F_H_L_Eyew', 'F_H_R_Eyeball', 'F_H_R_Eyew'], `琳琅诗怀雅 at ${t}`); + }); + + test('a whole clip sampled at 30 Hz: hiding is rare outside a knock-down and never takes more than the eyes', () => { + // [model, clip, seconds, share of frames with something hidden: at least, at most] + for (const [id, clip, dur, lo, hi] of [ + ['char_4058_pepe', 'Idle', 4, 0, 0], ['char_4058_pepe', 'Die', 1.5, 0.5, 1], + ['char_4082_qiubai', 'Idle', 4, 0, 0.35], ['char_4082_qiubai', 'Die', 1, 0.5, 1], + ['char_4010_etlchi', 'Idle', 4, 0.02, 0.2], ['char_1033_swire2', 'Idle', 4, 0.01, 0.2], + ]) { + let hit = 0, frames = 0, most = 0; + for (let t = 0; t <= dur; t += 1 / 30) { const n = hiddenAt(id, clip, t).length; frames++; if (n) hit++; most = Math.max(most, n); } + assert.ok(hit / frames >= lo && hit / frames <= hi, `${id} ${clip}: ${Math.round(hit / frames * 100)} % of frames (want ${lo * 100}–${hi * 100} %)`); + assert.ok(most <= 8, `${id} ${clip}: at most ${most} slots in one frame`); + } + }); + + test('with the masks on nothing is hidden (the stencil cuts), and turning them back on shows what the fallback hid', () => { + const on = actor('char_4058_pepe', true); + on.die(); + on.update(0.52); + assert.deepEqual(hidden(on), [], 'masks on: the fallback stays out'); + const off = actor('char_4058_pepe', false); + off.die(); + off.update(0.52); + assert.equal(hidden(off).length, 4, 'masks off: the closed eyes are hidden'); + off.setClipping(true); + assert.deepEqual(hidden(off), [], 'shown again for the stencil'); + const clip = off.spine.skeleton.slots.find((s) => s.clippingContainer); + assert.equal(clip.clippingContainer.mask, clip.currentGraphics, 'the mask is back on'); + }); +});