import { describe, test, expect } from 'vitest' import * as fs from 'node:fs' import * as path from 'node:path' import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/render/cull.ts' // 视口剔除的回归护栏。 // // 背景(见 issue #16):主循环原本把整张地图无条件提交给渲染器,实测 365 张烘焙 // 关卡平均每帧 2 848 个四边形、最差 14 400 个,而真正落在视口里的只有 7%~11%。 // 剔除本身不难,难的是**别把画家顺序弄坏**:墙和对象是按深度两路归并出来的, // 一旦剔除时让某一路的游标停住,后面所有东西的前后遮挡关系就全乱了。 // // 所以这里除了边界条件,还用真实关卡数据做一次全量重放,断言"开剔除后画出来的 // 序列,必须是不开剔除时那个序列的子序列"——顺序一致、只少不多、不多不错。 /** 一个最小可绘制物。 */ function drawable(x: number, y: number, width: number, height: number) { return { x, y, frame: { width, height } } } describe('视口剔除', () => { test('包围盒摊平成 [x0, y0, x1, y1]', () => { const bounds = buildBounds([drawable(10, 20, 80, 40), drawable(-5, -6, 1, 2)]) expect(Array.from(bounds)).toEqual([10, 20, 90, 60, -5, -6, -4, -4]) }) test('空列表得到空包围盒', () => { expect(buildBounds([]).length).toBe(0) }) test('视口矩形与顶点着色器用同一个半宽公式', () => { const view = viewportRect(1000, 500, 1280, 720, 2) expect(view.left).toBe(1000 - 1280 / 4 - CULL_MARGIN) expect(view.right).toBe(1000 + 1280 / 4 + CULL_MARGIN) expect(view.top).toBe(500 - 720 / 4 - CULL_MARGIN) expect(view.bottom).toBe(500 + 720 / 4 + CULL_MARGIN) }) test('缩小时视口变大,放大时视口变小', () => { const wide = viewportRect(0, 0, 1280, 720, 0.5) const tight = viewportRect(0, 0, 1280, 720, 4) expect(wide.right - wide.left).toBeGreaterThan(tight.right - tight.left) }) test('视口内的留下,视口外的剔掉', () => { const view = viewportRect(0, 0, 200, 200, 1) // 半宽 100 + 余量 32 = 132。 const bounds = buildBounds([ drawable(0, 0, 10, 10), // 正中 drawable(1000, 0, 10, 10), // 右边远处 drawable(0, -5000, 10, 10), // 上方远处 ]) expect(intersects(bounds, 0, view)).toBe(true) expect(intersects(bounds, 1, view)).toBe(false) expect(intersects(bounds, 2, view)).toBe(false) }) test('只有一角搭进视口也算可见', () => { const view = viewportRect(0, 0, 200, 200, 1) // 左上角在视口右下角外,但自身右下角刚好压线。 const bounds = buildBounds([drawable(132, 132, 10, 10)]) expect(intersects(bounds, 0, view)).toBe(true) }) test('大精灵即使锚点在屏外也不能被剔掉', () => { const view = viewportRect(0, 0, 200, 200, 1) // 资源包里最大的帧是 160×960:锚点远在视口上方,画出来的部分却盖住整屏。 const bounds = buildBounds([drawable(0, -900, 160, 960)]) expect(intersects(bounds, 0, view)).toBe(true) // 高度换成 10 之后就真的看不见了,说明上面那条靠的是帧尺寸而不是坐标。 expect(intersects(buildBounds([drawable(0, -900, 160, 10)]), 0, view)).toBe(false) }) test('余量让刚出界的一圈继续留下', () => { const view = viewportRect(0, 0, 200, 200, 1) // 半宽 100,落在 100~132 之间的只有靠余量才留得住。 expect(intersects(buildBounds([drawable(120, 0, 1, 1)]), 0, view)).toBe(true) expect(intersects(buildBounds([drawable(140, 0, 1, 1)]), 0, view)).toBe(false) }) test('换关卡时重建包围盒避免越界误剔除', () => { // 模拟从 57x41 关卡(2337 个图元)切换到 80x80 关卡(6400 个图元) const smallLevelDrawables = Array.from({ length: 2337 }, (_, i) => drawable(i * 10, 0, 80, 40)) const largeLevelDrawables = Array.from({ length: 6400 }, () => drawable(0, 0, 80, 40)) const view = viewportRect(0, 0, 1280, 720, 1) // 旧实现未重建 bounds,largeLevel 使用 smallLevel 的 stale bounds const staleBounds = buildBounds(smallLevelDrawables) let visibleWithStaleBounds = 0 for (let i = 0; i < largeLevelDrawables.length; i += 1) { if (intersects(staleBounds, i, view)) visibleWithStaleBounds += 1 } expect(visibleWithStaleBounds).toBeLessThanOrEqual(2337) // 修复后重建 bounds,所有落在视口内的图元正确通过剔除 const freshBounds = buildBounds(largeLevelDrawables) let visibleWithFreshBounds = 0 for (let i = 0; i < largeLevelDrawables.length; i += 1) { if (intersects(freshBounds, i, view)) visibleWithFreshBounds += 1 } expect(visibleWithFreshBounds).toBe(6400) }) }) /** 资源包位置;缺资源时整组测试跳过而不是报红。 */ const PACKS = path.resolve(__dirname, '../samples/d2-packs') /** 所有含 scene.json 的关卡目录。 */ function levelDirs(): { name: string; scene: string }[] { if (!fs.existsSync(PACKS)) return [] const out: { name: string; scene: string }[] = [] for (const act of fs.readdirSync(PACKS)) { const actPath = path.join(PACKS, act) if (!fs.statSync(actPath).isDirectory()) continue for (const level of fs.readdirSync(actPath)) { const scene = path.join(actPath, level, 'scene.json') if (fs.existsSync(scene)) out.push({ name: `${act}/${level}`, scene }) } } return out } interface Item { x: number; y: number; frame: { width: number; height: number }; depth: number } /** 把一张 scene.json 摊成场景实际持有的四条绘制列表。 */ function loadLevel(scenePath: string) { const json = JSON.parse(fs.readFileSync(scenePath, 'utf8')) as Record const place = (json.framePlacement ?? []) as number[][] const tiles = (rows: number[][] | undefined): Item[] => (rows ?? []).map(row => { const [frameIndex, x, y, cellX, cellY] = row const p = place[frameIndex!] ?? [0, 0, 0, 0, 0] return { x: x!, y: y!, frame: { width: p[3]!, height: p[4]! }, depth: cellX! + cellY! } }) const objs: Item[] = (json.objects ?? []) .filter((o: any) => o.frame) .map((o: any) => ({ x: o.x, y: o.y, frame: { width: o.frame.width, height: o.frame.height }, depth: o.depth ?? 0 })) .sort((a: Item, b: Item) => a.depth - b.depth) const spawn = Array.isArray(json.spawn) ? { x: json.spawn[0] as number, y: json.spawn[1] as number } : { x: 0, y: 0 } return { floors: tiles(json.floors), walls: tiles(json.walls), roofs: tiles(json.roofs), objs, spawn } } /** * 重放一张关卡的绘制顺序。 * * 这里刻意复刻 `act-scene` 的两路深度归并:剔除只跳过"画"这一步,游标照常前进。 * * @param cull - 是否启用视口剔除。 * @returns 依次画出的标记,以及剔除数量。 */ function replay(level: ReturnType, cull: boolean) { const view = viewportRect(level.spawn.x, level.spawn.y - 16, 1280, 720, 1) const floorBounds = buildBounds(level.floors) const wallBounds = buildBounds(level.walls) const roofBounds = buildBounds(level.roofs) const objBounds = buildBounds(level.objs) const order: string[] = [] let culled = 0 const emit = (bounds: Float32Array, index: number, item: Item, tag: string): void => { if (cull && !intersects(bounds, index, view)) { culled += 1; return } order.push(`${tag}:${String(item.x)},${String(item.y)}`) } for (let i = 0; i < level.floors.length; i += 1) emit(floorBounds, i, level.floors[i]!, 'f') let wi = 0 let oi = 0 while (wi < level.walls.length || oi < level.objs.length) { const wd = wi < level.walls.length ? level.walls[wi]!.depth : Infinity const od = oi < level.objs.length ? level.objs[oi]!.depth : Infinity if (wd <= od) { emit(wallBounds, wi, level.walls[wi]!, 'w'); wi += 1 } else { emit(objBounds, oi, level.objs[oi]!, 'o'); oi += 1 } } for (let i = 0; i < level.roofs.length; i += 1) emit(roofBounds, i, level.roofs[i]!, 'r') return { order, culled } } /** `sub` 是否按相同相对顺序出现在 `full` 里。 */ function isSubsequence(sub: string[], full: string[]): boolean { let i = 0 for (const item of full) if (i < sub.length && sub[i] === item) i += 1 return i === sub.length } const levels = levelDirs() describe.runIf(levels.length > 0)('剔除后的画家顺序(全量资源包重放)', () => { test('每张关卡剔除后的绘制序列都是原序列的子序列', () => { const broken: string[] = [] let before = 0 let after = 0 for (const { name, scene } of levels) { const level = loadLevel(scene) const full = replay(level, false) const culled = replay(level, true) if (!isSubsequence(culled.order, full.order)) broken.push(name) before += full.order.length after += culled.order.length } // 具名收集,失败时能直接看出是哪几张关卡乱了序。 expect(broken).toEqual([]) // 顺带守住收益:真剔掉了绝大多数,而不是悄悄退化成"全画"。 expect(after).toBeGreaterThan(0) expect(after).toBeLessThan(before * 0.25) }) })