207 lines
9.0 KiB
TypeScript
207 lines
9.0 KiB
TypeScript
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<string, any>
|
||
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<typeof loadLevel>, 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)
|
||
})
|
||
})
|