// test/render/impostor-bounds.browser.test.js — GitHub #542 (reported by @ImPQlc) in headless Chrome, real WebGL: a crowd // drawn through the impostor atlas (public/dev/render-demo.html?scene=stress: 120 units, impostor interval ≥ 2) with the // three reported operators in it (&ops=: 野鬃, 浊心斯卡蒂, 隐现), each held in its widest pose — 野鬃's flag at 0.27 s of // Idle, 浊心斯卡蒂 at 3.5 s of Skill_3_Begin (the S3 flight), 隐现 at 0.33 s of Skill_2_Loop (the silenced pistol). // Checks: every pose lies inside its unit's slot; after every page is cleared and every impostor redrawn once, no atlas // pixel lies outside a live slot (render/impostor.js clips each slot's skeleton to the slot rectangle — a scissor rect); // the scissor itself on a bare atlas page (a quad 9× its slot paints only the slot, a redraw clears the slot, a neighbour // keeps its pixels); a clip too large for the box (野鬃's deploy drop) is drawn directly while it plays, then the impostor // comes back. Pictures → test/e2e/out/impostor-542-after-*.png (each operator in the crowd, the atlas pages with the slot // outlines). The same pictures of the code before #542 are made by checking out its parent and running this file. // // Opt-in (starts Chrome, needs the downloaded assets): RENDER_E2E=1 node --test test/render/impostor-bounds.browser.test.js // Chrome path: $CHROME_PATH or the macOS default. import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const OUT = path.join(ROOT, 'test/e2e/out'); const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets')); const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)'; const TAG = process.env.IMPOSTOR_SHOT_TAG || 'after'; // file name tag of the pictures ('before' on the parent commit) // [unit id (render-demo &ops= places), chessId, label, clip, loop, seconds into it] const OPS = [ [6, 'chess_char_1_19_a', 'wildmn-idle', 'Idle', true, 0.273], [8, 'chess_char_6_04_a', 'skadi2-s3begin', 'Skill_3_Begin', false, 3.527], [10, 'chess_char_1_01_a', 'inside-s2loop', 'Skill_2_Loop', true, 0.333], ]; const wait = (ms) => new Promise((r) => setTimeout(r, ms)); describe('impostor boxes and slot clipping in headless Chrome (GitHub #542)', { skip }, () => { let srv, browser, page; const problems = []; before(async () => { const puppeteer = (await import('puppeteer-core')).default; const { startServer } = await import('../../server/index.js'); srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); await srv.assetIndex?.whenReady(); browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); mkdirSync(OUT, { recursive: true }); page = await browser.newPage(); page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); }); await page.setViewport({ width: 1600, height: 900 }); await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=stress&panel=0&ops=${OPS.map((o) => o[1]).join(',')}`); await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 }); assert.equal(await page.evaluate(() => window.__demo.error || null), null, 'demo boot'); // the crowd loads, its clips are measured, every unit becomes an impostor await page.waitForFunction((ids) => { const v = window.__demo.view; if (v.stats().impostor < 2) return false; for (const id of ids) { const u = v.debug.views.get(id); if (!u || !u.spineReady || !u.imp || u.imp.bypass) return false; } return true; }, { timeout: 40000, polling: 200 }, OPS.map((o) => o[0])); await wait(2500); // the crowd attacks and casts for a while await page.evaluate(() => window.__demo.pause()); await wait(300); }); after(async () => { await browser?.close(); await srv?.close(); }); test('the three operators held in their widest poses: inside their slots; no atlas pixel outside a live slot', async () => { await page.evaluate((ops) => { const views = window.__demo.view.debug.views; for (const [id, , , clip, loop, t] of ops) { const v = views.get(id); v.actor.mode = 'base'; v.actor._play(clip, loop, { timeScale: 0, mix: 0, start: t }); if (v.imp) v.imp.dirty = true; } }, OPS); await wait(800); // a clean slate: every page cleared, every impostor redrawn once — what lies outside the live slots afterwards was // painted by these redraws (a freed slot's old pixels would count otherwise) await page.evaluate(() => { const view = window.__demo.view; const R = view.debug.app.renderer; for (const pg of view.debug.ctx.impostors.pages) R.render(new PIXI.Container(), { renderTexture: pg.rt, clear: true }); for (const v of view.debug.views.values()) if (v.imp) v.imp.dirty = true; }); await wait(600); const info = await page.evaluate((ops) => { const view = window.__demo.view; const atlas = view.debug.ctx.impostors; const R = view.debug.app.renderer; const res = atlas.res; const units = ops.map(([id, , label, clip]) => { const v = view.debug.views.get(id); const b = v.actor.spine.getLocalBounds(); const s = v.imp && v.imp.slot, box = v._box, sc = v.imp ? v.imp.sc : 0, yK = v.modelKY || 1; const track = v.actor.spine.state.tracks[0]?.animation?.name; if (!s || !box) return { label, track, clip, slot: null }; const x0 = (b.x - box.x0) * sc, y0 = (b.y - box.y0) * sc * yK, x1 = x0 + b.width * sc, y1 = y0 + b.height * sc * yK; return { label, track, clip, slot: [s.x, s.y, s.w, s.h], over: Math.max(-x0, -y0, x1 - s.w, y1 - s.h), sc }; }); const live = []; for (const v of view.debug.views.values()) if (v.imp && v.imp.slot && !v.imp.slot.freed) live.push(v.imp.slot); const pages = [], images = []; for (const pg of atlas.pages) { if (!pg.cleared) continue; const px = R.extract.pixels(pg.rt); const W = Math.round(pg.size * res); const mine = live.filter((s) => s.page === pg); const inSlot = new Uint8Array(W * W); for (const s of mine) for (let y = Math.floor(s.y * res); y < Math.ceil((s.y + s.h) * res); y++) inSlot.fill(1, y * W + Math.floor(s.x * res), y * W + Math.ceil((s.x + s.w) * res)); let stray = 0; for (let i = 0; i < W * W; i++) if (!inSlot[i] && px[i * 4 + 3] > 8) stray++; pages.push({ index: pg.index, kind: pg.kind, slots: mine.length, stray }); // picture: the page over a dark checker, the live slots outlined, the three operators' slots in colour const c = R.extract.canvas(pg.rt); const o = document.createElement('canvas'); o.width = c.width; o.height = c.height; const g = o.getContext('2d'); for (let y = 0; y < o.height; y += 16) for (let x = 0; x < o.width; x += 16) { g.fillStyle = ((x + y) / 16) % 2 ? '#2a2a2e' : '#222226'; g.fillRect(x, y, 16, 16); } g.drawImage(c, 0, 0); g.strokeStyle = 'rgba(255,255,255,0.35)'; for (const s of mine) g.strokeRect(s.x * res + 0.5, s.y * res + 0.5, s.w * res - 1, s.h * res - 1); const colours = ['#ff4d4d', '#4dd2ff', '#ffd24d']; ops.forEach(([id, , label], k) => { const s = view.debug.views.get(id)?.imp?.slot; if (!s || s.page !== pg) return; g.strokeStyle = colours[k]; g.lineWidth = 2; g.strokeRect(s.x * res + 1, s.y * res + 1, s.w * res - 2, s.h * res - 2); g.fillStyle = colours[k]; g.font = '12px sans-serif'; g.fillText(label, s.x * res + 4, s.y * res + 14); g.lineWidth = 1; }); images.push({ index: pg.index, url: o.toDataURL('image/png') }); } return { units, pages, images, screens: ops.map(([id]) => ({ ...view.debug.views.get(id).screen })) }; }, OPS); for (const im of info.images) writeFileSync(path.join(OUT, `impostor-542-${TAG}-atlas-page${im.index}.png`), Buffer.from(im.url.split(',')[1], 'base64')); // each operator alone on the field (the crowd keeps drawing into the atlas, hidden in the main pass; bars hidden) await page.evaluate((ids) => { const view = window.__demo.view; for (const [id, v] of view.debug.views) if (!ids.includes(id)) { v.root.visible = false; v.shadow.visible = false; } view.debug.ctx.layers.bars.visible = false; }, OPS.map((o) => o[0])); await wait(300); const rect = await page.evaluate(() => { const r = document.querySelector('#field canvas').getBoundingClientRect(); return { left: r.left, top: r.top }; }); for (const [k, [, , label]] of OPS.entries()) { const s = info.screens[k]; const w = s.s * 3.4, h = s.s * 3.6; await page.screenshot({ path: path.join(OUT, `impostor-542-${TAG}-${label}.png`), clip: { x: Math.max(0, rect.left + s.x - w / 2), y: Math.max(0, rect.top + s.y - h * 0.84), width: w, height: h } }); } await page.evaluate((ids) => { const view = window.__demo.view; for (const [id, v] of view.debug.views) if (!ids.includes(id)) { v.root.visible = true; v.shadow.visible = true; } view.debug.ctx.layers.bars.visible = true; }, OPS.map((o) => o[0])); for (const u of info.units) { assert.equal(u.track, u.clip, `${u.label}: holding ${u.clip}`); assert.ok(u.slot, `${u.label}: drawn through the atlas`); assert.ok(u.over <= 0.5, `${u.label}: the pose is ${u.over.toFixed(1)} px past its slot`); } for (const p of info.pages) assert.equal(p.stray, 0, `${p.kind} page ${p.index}: ${p.stray} px outside the live slots`); assert.deepEqual(problems, []); }); test('the scissor on a bare atlas page: a quad 9× its slot paints only the slot; a redraw clears it; a neighbour keeps its pixels', async () => { const r = await page.evaluate(async () => { const { ImpostorAtlas } = await import('/js/render/impostor.js'); const R = window.__demo.view.debug.app.renderer; const atlas = new ImpostorAtlas(R); try { const a = atlas.alloc(64, 64), b = atlas.alloc(64, 64); // 16 px white quads, scaled by the draw transform (atlas.draw sets the scale, as for a skeleton) const quad = (tint) => { const s = new PIXI.Sprite(PIXI.Texture.WHITE); s.tint = tint; return s; }; const big = quad(0xff0000), small = quad(0x00ff00), nb = quad(0x0000ff); for (const s of [big, small, nb]) atlas.park(s); const read = () => { const px = R.extract.pixels(a.page.rt), W = Math.round(a.page.size * atlas.res), k = atlas.res; const at = (x, y) => { const i = (Math.floor(y * k) * W + Math.floor(x * k)) * 4; return [px[i], px[i + 1], px[i + 2], px[i + 3]]; }; let outside = 0; for (let y = 0; y < Math.round(160 * k); y++) for (let x = 0; x < Math.round(256 * k); x++) { const cx = x / k, cy = y / k; const inA = cx >= a.x && cx < a.x + a.w && cy >= a.y && cy < a.y + a.h, inB = cx >= b.x && cx < b.x + b.w && cy >= b.y && cy < b.y + b.h; if (!inA && !inB && px[(y * W + x) * 4 + 3] > 8) outside++; } return { outside, aCorner: at(a.x + 60, a.y + 60), aCentre: at(a.x + 8, a.y + 8), bCentre: at(b.x + 32, b.y + 32) }; }; // b: a blue square, drawn once; then a alone: a red quad 3× the slot each way, offset so that, unclipped, it would // paint over b (not redrawn: its eraser stays off) and the free space right of and below the slots atlas.draw(b, nb, { a: 4, d: 4, tx: 0, ty: 0 }); // 64 px: all of b atlas.flush(); atlas.draw(a, big, { a: 12, d: 12, tx: -32, ty: -32 }); // 192 px from (−32, −32) atlas.flush(); const first = read(); // a again with a 16 px green quad in its corner: the rest of the slot must be empty atlas.draw(a, small, { a: 1, d: 1, tx: 0, ty: 0 }); atlas.flush(); const second = read(); return { first, second, a: [a.x, a.y, a.w, a.h], b: [b.x, b.y, b.w, b.h] }; } finally { atlas.destroy(); } }); assert.deepEqual(r.a.slice(2), [64, 64]); assert.equal(r.first.outside, 0, 'nothing painted outside the slots'); assert.deepEqual(r.first.aCorner.slice(0, 1).concat(r.first.aCorner[3]), [255, 255], 'slot a filled red'); assert.deepEqual(r.first.bCentre, [0, 0, 255, 255], 'slot b keeps its blue: a\'s spill was cut at a\'s edge'); assert.equal(r.second.aCorner[3], 0, 'the redraw cleared the rest of slot a'); assert.deepEqual(r.second.aCentre, [0, 255, 0, 255], 'and drew the new quad'); assert.deepEqual(r.second.bCentre, [0, 0, 255, 255], 'b untouched'); assert.equal(r.second.outside, 0); }); test('a clip too large for the box (野鬃\'s deploy drop) is drawn directly while it plays, then the impostor is back', async () => { const r = await page.evaluate(async () => { const v = window.__demo.view.debug.views.get(6); const over = [...v._box.over]; v.actor._play('Idle', true, { mix: 0 }); v.onDeploy(); // the Start clip, as b.ev 'deploy' await new Promise((ok) => setTimeout(ok, 120)); const during = { bypass: !!v.imp?.bypass, sprite: v.imp?.sprite.visible, inBody: v.actor.spine.parent === v.body, slot: !!v.imp?.slot }; await new Promise((ok) => setTimeout(ok, 1600)); // 1 s clip at the demo's 2× clock, then its mix out const afterwards = { bypass: !!v.imp?.bypass, sprite: v.imp?.sprite.visible, track: v.actor.spine.state.tracks[0]?.animation?.name }; return { over, during, afterwards }; }); assert.ok(r.over.includes('Start'), `over: ${r.over}`); assert.deepEqual(r.during, { bypass: true, sprite: false, inBody: true, slot: true }, 'drawn directly, its slot kept'); assert.equal(r.afterwards.bypass, false); assert.equal(r.afterwards.sprite, true, 'the impostor again'); assert.deepEqual(problems, []); }); });