diablo2-web/tests/cull.test.ts

183 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
})
})
/** 资源包位置;缺资源时整组测试跳过而不是报红。 */
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)
})
})