diablo2-web/tests/render-performance.test.ts

557 lines
19 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 { SpriteRenderer, RendererError, type AtlasHandle } from '../src/render/renderer.ts'
import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/render/cull.ts'
import { countLoadedPages } from '../src/scene/act-scene.ts'
interface MockResource {
id: number
type: string
}
function createMockCanvas(): {
canvas: any
stats: {
createdTextures: Set<MockResource>
deletedTextures: Set<MockResource>
createdBuffers: Set<MockResource>
deletedBuffers: Set<MockResource>
createdVAOs: Set<MockResource>
deletedVAOs: Set<MockResource>
createdPrograms: Set<MockResource>
deletedPrograms: Set<MockResource>
createdShaders: Set<MockResource>
deletedShaders: Set<MockResource>
eventListeners: Map<string, Set<EventListener>>
glDrawCalls: number
shaderSources: string[]
}
setContextLost: (lost: boolean) => void
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
} {
let nextId = 1
let contextLost = false
const stats = {
createdTextures: new Set<MockResource>(),
deletedTextures: new Set<MockResource>(),
createdBuffers: new Set<MockResource>(),
deletedBuffers: new Set<MockResource>(),
createdVAOs: new Set<MockResource>(),
deletedVAOs: new Set<MockResource>(),
createdPrograms: new Set<MockResource>(),
deletedPrograms: new Set<MockResource>(),
createdShaders: new Set<MockResource>(),
deletedShaders: new Set<MockResource>(),
eventListeners: new Map<string, Set<EventListener>>(),
glDrawCalls: 0,
shaderSources: [] as string[],
}
const gl: any = {
VERTEX_SHADER: 35633,
FRAGMENT_SHADER: 35632,
COMPILE_STATUS: 35713,
LINK_STATUS: 35714,
MAX_TEXTURE_SIZE: 3379,
MAX_TEXTURE_IMAGE_UNITS: 34930,
TEXTURE_2D: 3553,
RGBA: 6408,
R8: 33321,
RED: 6403,
UNSIGNED_BYTE: 5121,
UNSIGNED_INT: 5125,
FLOAT: 5126,
ARRAY_BUFFER: 34962,
ELEMENT_ARRAY_BUFFER: 34963,
DYNAMIC_DRAW: 35048,
STATIC_DRAW: 35044,
TRIANGLES: 4,
TEXTURE_MIN_FILTER: 10241,
TEXTURE_MAG_FILTER: 10240,
TEXTURE_WRAP_S: 10242,
TEXTURE_WRAP_T: 10243,
CLAMP_TO_EDGE: 33071,
NEAREST: 9728,
UNPACK_ALIGNMENT: 3317,
BLEND: 3042,
SRC_ALPHA: 770,
ONE_MINUS_SRC_ALPHA: 771,
DEPTH_TEST: 2929,
COLOR_BUFFER_BIT: 16384,
TEXTURE0: 33984,
drawingBufferWidth: 1280,
drawingBufferHeight: 720,
isContextLost: () => contextLost,
createProgram: () => {
const res: MockResource = { id: nextId++, type: 'program' }
stats.createdPrograms.add(res)
return res
},
deleteProgram: (p: MockResource) => { stats.deletedPrograms.add(p) },
attachShader: () => {},
detachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => '',
useProgram: () => {},
createShader: (type: number) => {
const res: MockResource = { id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' }
stats.createdShaders.add(res)
return res
},
shaderSource: (_shader: MockResource, source: string) => { stats.shaderSources.push(source) },
compileShader: () => {},
getShaderParameter: () => true,
getShaderInfoLog: () => '',
deleteShader: (s: MockResource) => { stats.deletedShaders.add(s) },
createVertexArray: () => {
const res: MockResource = { id: nextId++, type: 'vao' }
stats.createdVAOs.add(res)
return res
},
deleteVertexArray: (vao: MockResource) => { stats.deletedVAOs.add(vao) },
bindVertexArray: () => {},
createBuffer: () => {
const res: MockResource = { id: nextId++, type: 'buffer' }
stats.createdBuffers.add(res)
return res
},
deleteBuffer: (b: MockResource) => { stats.deletedBuffers.add(b) },
bindBuffer: () => {},
bufferData: () => {},
bufferSubData: () => {},
getAttribLocation: (_p: any, name: string) => (
name === 'a_position' ? 0 : name === 'a_uv' ? 1 : name === 'a_tint' ? 2 : name === 'a_unit' ? 3 : 4
),
enableVertexAttribArray: () => {},
vertexAttribPointer: () => {},
createTexture: () => {
const res: MockResource = { id: nextId++, type: 'texture' }
stats.createdTextures.add(res)
return res
},
deleteTexture: (t: MockResource) => { stats.deletedTextures.add(t) },
bindTexture: () => {},
texParameteri: () => {},
texImage2D: () => {},
pixelStorei: () => {},
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
getUniformLocation: (_p: any, name: string) => ({ name }),
uniform2f: () => {},
uniform1f: () => {},
uniform1i: () => {},
uniform1iv: () => {},
enable: () => {},
disable: () => {},
blendFunc: () => {},
viewport: () => {},
clearColor: () => {},
clear: () => {},
activeTexture: () => {},
drawArrays: () => { stats.glDrawCalls += 1 },
drawElements: () => { stats.glDrawCalls += 1 },
}
const eventListeners = stats.eventListeners
const canvas: any = {
getContext: (type: string) => (type === 'webgl2' ? gl : null),
addEventListener: (type: string, listener: EventListener) => {
if (!eventListeners.has(type)) eventListeners.set(type, new Set())
eventListeners.get(type)!.add(listener)
},
removeEventListener: (type: string, listener: EventListener) => {
eventListeners.get(type)?.delete(listener)
},
width: 1280,
height: 720,
drawingBufferWidth: 1280,
drawingBufferHeight: 720,
}
return {
canvas,
stats,
setContextLost: (lost: boolean) => { contextLost = lost },
dispatchMockEvent: (type: string) => {
let defaultPrevented = false
const event: any = {
type,
preventDefault: () => { defaultPrevented = true },
}
const set = eventListeners.get(type)
if (set) {
for (const listener of set) {
listener(event)
}
}
return { defaultPrevented }
},
}
}
function createAtlasPage(renderer: SpriteRenderer, width = 32, height = 32): AtlasHandle {
return renderer.addAtlas({ pixels: new Uint8ClampedArray(width * height * 4), width, height })
}
describe('多纹理合批与 Draw Call 预算 (方案 B / Issue #16)', () => {
test('多图集页关卡在画家顺序重度交错下维持 Draw Call <= 1', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
// 典型多图集关卡构成:
// 地面(2页)+ 墙体(2页)+ 屋顶(1页)+ 物件(1页)+ 角色(1页)+ 怪物(2页)= 9页
const pages = Array.from({ length: 9 }, () => createAtlasPage(renderer))
const frame = { x: 0, y: 0, width: 32, height: 32 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const initialCalls = mock.stats.glDrawCalls
// 1. 地面层(400 个四边形在 page 0 和 page 1 交替)
for (let i = 0; i < 400; i += 1) {
renderer.draw(frame, i * 16, 0, { atlas: pages[i % 2] })
}
// 2. 墙体与物件深度归并层(600 个四边形在 pages 2, 3, 5 频繁交替)
for (let i = 0; i < 600; i += 1) {
const p = i % 3 === 0 ? 5 : i % 3 === 1 ? 2 : 3
renderer.draw(frame, i * 16, 50, { atlas: pages[p] })
}
// 3. 实体层:玩家与怪物(150 个四边形在 pages 6, 7, 8 频繁交替)
for (let i = 0; i < 150; i += 1) {
renderer.draw(frame, i * 20, 100, { atlas: pages[6 + (i % 3)] })
}
// 4. 屋顶层(200 个四边形在 page 4)
for (let i = 0; i < 200; i += 1) {
renderer.draw(frame, i * 16, 150, { atlas: pages[4] })
}
renderer.flush()
expect(renderer.quadsSubmitted).toBe(1350)
expect(mock.stats.glDrawCalls - initialCalls).toBe(1)
expect(renderer.drawCalls).toBe(1)
renderer.dispose()
})
test('精灵纹理与纯色矩形混合绘制不打断合批(单次 Draw Call)', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const pageA = createAtlasPage(renderer)
const pageB = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 16, height: 16 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const initialCalls = mock.stats.glDrawCalls
for (let i = 0; i < 50; i += 1) {
renderer.draw(frame, i * 8, 0, { atlas: i % 2 === 0 ? pageA : pageB })
renderer.drawSolid(i * 8, 20, 10, 10, [1, 0.5, 0, 1])
}
renderer.flush()
expect(renderer.quadsSubmitted).toBe(100)
expect(mock.stats.glDrawCalls - initialCalls).toBe(1)
expect(renderer.drawCalls).toBe(1)
renderer.dispose()
})
test('超出 GPU 纹理单元上限时严格按需断批,保持最小 Draw Call 开销', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const units = renderer.textureUnits
expect(units).toBeGreaterThanOrEqual(8)
// 创建 units + 3 个不同纹理页
const totalPages = units + 3
const pages = Array.from({ length: totalPages }, () => createAtlasPage(renderer))
const frame = { x: 0, y: 0, width: 8, height: 8 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const initialCalls = mock.stats.glDrawCalls
// 前 units 个页在 batch 1
for (let i = 0; i < units; i += 1) {
renderer.draw(frame, i * 10, 0, { atlas: pages[i] })
}
// 接下来 3 个页触发断批,进入 batch 2
for (let i = units; i < totalPages; i += 1) {
renderer.draw(frame, i * 10, 0, { atlas: pages[i] })
}
renderer.flush()
const issuedCalls = mock.stats.glDrawCalls - initialCalls
expect(issuedCalls).toBe(2)
expect(renderer.drawCalls).toBe(2)
expect(renderer.drawCalls).toBeLessThanOrEqual(Math.ceil(totalPages / units))
renderer.dispose()
})
test('begin() 重置每帧计数器,防止跨帧累加污染性能监控', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 10, height: 10 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
renderer.draw(frame, 0, 0, { atlas: page })
renderer.flush()
expect(renderer.drawCalls).toBe(1)
expect(renderer.quadsSubmitted).toBe(1)
renderer.begin({ x: 0, y: 0, zoom: 1 })
expect(renderer.drawCalls).toBe(0)
expect(renderer.quadsSubmitted).toBe(0)
renderer.dispose()
})
})
describe('视口剔除与剔除率守卫 (方案 A / Issue #16)', () => {
test('大关卡 (80x80 = 6400 四边形) 视口剔除率稳定超过 80%', () => {
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
const cols = 80
const rows = 80
for (let y = 0; y < rows; y += 1) {
for (let x = 0; x < cols; x += 1) {
quads.push({
x: x * 80,
y: y * 40,
frame: { width: 80, height: 40 },
})
}
}
expect(quads.length).toBe(6400)
const bounds = buildBounds(quads)
// 相机放置于地图中央 (3200, 1600),视口 1280x720,缩放 1.0
const view = viewportRect(3200, 1600, 1280, 720, 1.0)
let visible = 0
let culled = 0
const visibleIndices: number[] = []
for (let i = 0; i < quads.length; i += 1) {
if (intersects(bounds, i, view)) {
visible += 1
visibleIndices.push(i)
} else {
culled += 1
}
}
const cullingRatio = culled / quads.length
expect(visible + culled).toBe(quads.length)
// 80x80 关卡在 1280x720 视口下实测剔除率约为 94%
expect(cullingRatio).toBeGreaterThan(0.80)
expect(cullingRatio).toBeGreaterThanOrEqual(0.88)
// 严格断言画家算法顺序:保留的四边形相对下标必须单调递增,不能错乱
for (let i = 1; i < visibleIndices.length; i += 1) {
expect(visibleIndices[i]).toBeGreaterThan(visibleIndices[i - 1]!)
}
})
test('放大 (zoom = 2.0) 时视口变小,剔除率进一步提高', () => {
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
for (let y = 0; y < 60; y += 1) {
for (let x = 0; x < 60; x += 1) {
quads.push({ x: x * 80, y: y * 40, frame: { width: 80, height: 40 } })
}
}
const bounds = buildBounds(quads)
const view1x = viewportRect(2400, 1200, 1280, 720, 1.0)
const view2x = viewportRect(2400, 1200, 1280, 720, 2.0)
let culled1x = 0
let culled2x = 0
for (let i = 0; i < quads.length; i += 1) {
if (!intersects(bounds, i, view1x)) culled1x += 1
if (!intersects(bounds, i, view2x)) culled2x += 1
}
const ratio1x = culled1x / quads.length
const ratio2x = culled2x / quads.length
expect(ratio1x).toBeGreaterThan(0.80)
expect(ratio2x).toBeGreaterThan(ratio1x)
})
test('边界临界条件:搭界不剔除、屏外大精灵不剔除', () => {
const view = viewportRect(1000, 1000, 800, 600, 1)
// view: left: 1000 - 400 - 32 = 568, right: 1432, top: 1000 - 300 - 32 = 668, bottom: 1332
// 1. 完全屏外
const farAway = buildBounds([{ x: 0, y: 0, frame: { width: 10, height: 10 } }])
expect(intersects(farAway, 0, view)).toBe(false)
// 2. 恰好搭在边界边缘(利用 CULL_MARGIN 余量)
const borderQuad = buildBounds([{ x: 570, y: 700, frame: { width: 10, height: 10 } }])
expect(intersects(borderQuad, 0, view)).toBe(true)
// 3. 锚点在屏外上方 (x: 1000, y: 0),但精灵尺寸达 200x800,覆盖到底部
const tallSprite = buildBounds([{ x: 1000, y: 0, frame: { width: 200, height: 800 } }])
expect(intersects(tallSprite, 0, view)).toBe(true)
})
})
describe('HUD 动态度量与真实性能指标', () => {
test('HUD 数据源自渲染器实时测量值,而非硬编码假值', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 16, height: 16 }
// Frame 1 模拟:提交 150 个四边形,剔除 1850 个,耗时真实记录
const start1 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 150; i += 1) {
renderer.draw(frame, i * 4, 0, { atlas: page })
}
renderer.flush()
const renderMs1 = performance.now() - start1
const state1 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 1850,
renderMs: renderMs1,
}
const hudText1 = `${String(state1.quadsDrawn)} 图元 / ${String(state1.drawCalls)} 批次(剔除 ${String(state1.culledDraws)})· ${state1.renderMs.toFixed(1)} ms`
expect(state1.drawCalls).toBe(1)
expect(state1.quadsDrawn).toBe(150)
expect(state1.culledDraws).toBe(1850)
expect(state1.renderMs).toBeGreaterThanOrEqual(0)
expect(hudText1).toContain('150 图元 / 1 批次(剔除 1850)')
// Frame 2 模拟:多图集导致 2 次 Draw Call,提交 300 个四边形,剔除 3700 个
const overPages = Array.from({ length: renderer.textureUnits + 1 }, () => createAtlasPage(renderer))
const start2 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 250; i += 1) {
renderer.draw(frame, i * 4, 0, { atlas: overPages[i % renderer.textureUnits] })
}
for (let i = 0; i < 50; i += 1) {
renderer.draw(frame, i * 4, 50, { atlas: overPages[renderer.textureUnits] })
}
renderer.flush()
const renderMs2 = performance.now() - start2
const state2 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 3700,
renderMs: renderMs2,
}
const hudText2 = `${String(state2.quadsDrawn)} 图元 / ${String(state2.drawCalls)} 批次(剔除 ${String(state2.culledDraws)})· ${state2.renderMs.toFixed(1)} ms`
// 验证不是硬编码的常量
expect(state2.drawCalls).toBe(2)
expect(state2.quadsDrawn).toBe(300)
expect(state2.culledDraws).toBe(3700)
expect(hudText2).toContain('300 图元 / 2 批次(剔除 3700)')
expect(state2.drawCalls).not.toBe(state1.drawCalls)
expect(state2.quadsDrawn).not.toBe(state1.quadsDrawn)
renderer.dispose()
})
})
describe('零分配顶点写入与 GPU 资源生命周期审计 (方案 C & 方案 D/E/G 护栏)', () => {
test('动态顶点缓冲容量自动翻倍,并保全已有几何数据', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 8, height: 8 }
// 初始容量 8192,这里提交 10,000 个四边形触发 grow()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 10000; i += 1) {
renderer.draw(frame, i % 100, i % 100, { atlas: page })
}
expect(renderer.quadsSubmitted).toBe(10000)
renderer.flush()
expect(renderer.drawCalls).toBe(1)
renderer.dispose()
})
test('countLoadedPages 统计加载页数零临时数组分配', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page1 = createAtlasPage(renderer)
const page2 = createAtlasPage(renderer)
const pages = [page1, null, page2, undefined, null]
expect(countLoadedPages(pages as any)).toBe(2)
const allNull = [null, null, null]
expect(countLoadedPages(allNull)).toBe(0)
const empty: any[] = []
expect(countLoadedPages(empty)).toBe(0)
renderer.dispose()
})
test('WebGL 着色器与纹理生命周期完整释放,无显存泄漏', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
expect(mock.stats.createdShaders.size).toBe(2)
expect(mock.stats.deletedShaders.size).toBe(2)
expect(mock.stats.createdPrograms.size).toBe(1)
expect(mock.stats.createdTextures.size).toBe(3) // atlas + white + palette
const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
expect(mock.stats.createdTextures.size).toBe(4)
// 删除单个 atlas
renderer.deleteAtlas(extra)
expect(mock.stats.deletedTextures.has(extra.texture as any)).toBe(true)
// 释放整个渲染器
renderer.dispose()
expect(renderer.isDisposed).toBe(true)
expect(mock.stats.deletedTextures.size).toBe(4)
expect(mock.stats.deletedBuffers.size).toBe(2) // vertex + index buffer
expect(mock.stats.deletedVAOs.size).toBe(1)
expect(mock.stats.deletedPrograms.size).toBe(1)
})
test('上下文丢失时安全降级与清理', () => {
const mock = createMockCanvas()
let lostTriggered = false
const renderer = new SpriteRenderer(mock.canvas, {
onContextLost: () => { lostTriggered = true },
})
const eventResult = mock.dispatchMockEvent('webglcontextlost')
expect(eventResult.defaultPrevented).toBe(true)
expect(lostTriggered).toBe(true)
mock.setContextLost(true)
// 在上下文已丢失状态下 dispose 不应报错
expect(() => { renderer.dispose() }).not.toThrow()
expect(renderer.isDisposed).toBe(true)
})
})