diablo2-web/scripts/verify-renderer-lifecycle.ts

649 lines
24 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.

/**
* Verification test for SpriteRenderer GPU resource lifecycle, deleteAtlas,
* dispose, and WebGL context loss handling (Issue #9), plus the multi-texture
* batching, indexed geometry and draw-call instrumentation added for Issue #16.
*/
import { SpriteRenderer, RendererError } from '../src/render/renderer.ts'
import { buildBounds, intersects, viewportRect } from '../src/render/cull.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: 800,
drawingBufferHeight: 600,
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 : 3
),
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)
},
drawingBufferWidth: 800,
drawingBufferHeight: 600,
}
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 }
},
}
}
/**
* Make a throwaway atlas page handle.
*
* @param renderer - the renderer to allocate on.
* @returns a handle backed by a real (mock) texture.
*/
function page(renderer: SpriteRenderer): ReturnType<SpriteRenderer['addAtlas']> {
return renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
}
function runTests(): void {
console.log('== Running SpriteRenderer Lifecycle Verification ==\n')
let passed = 0
let failed = 0
function assert(condition: boolean, name: string): void {
if (condition) {
console.log(` ✓ ${name}`)
passed += 1
} else {
console.error(` ✗ ${name}`)
failed += 1
}
}
// Test 1: Shader cleanup after compilation
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
assert(mock.stats.createdShaders.size === 2, 'Two shaders created (VS & FS)')
assert(mock.stats.deletedShaders.size === 2, 'Both shaders detached and deleted right after program link')
assert(mock.stats.createdPrograms.size === 1, 'Program created')
assert(mock.stats.createdVAOs.size === 1, 'VAO created')
assert(mock.stats.createdBuffers.size === 2, 'Vertex and index buffers created')
assert(mock.stats.createdTextures.size === 3, 'Initial textures created (atlas + white + palette)')
assert(renderer.isDisposed === false, 'Renderer is active and not disposed')
renderer.dispose()
}
// Test 2: addAtlas & deleteAtlas
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(64 * 64 * 4), width: 64, height: 64 })
assert(mock.stats.createdTextures.size === 4, 'addAtlas creates a new GPU texture (4 total)')
// Draw with this extra atlas
renderer.begin({ x: 0, y: 0, zoom: 1 })
renderer.draw({ x: 0, y: 0, width: 32, height: 32 }, 0, 0, { atlas: extra })
// Now delete this atlas
const deleted = renderer.deleteAtlas(extra)
assert(deleted === true, 'deleteAtlas returned true for managed handle')
assert(mock.stats.deletedTextures.has(extra.texture as any), 'GPU texture was deleted via gl.deleteTexture')
// Deleting again should be safe and return false
const deletedAgain = renderer.deleteAtlas(extra)
assert(deletedAgain === false, 'deleteAtlas returned false on already deleted handle')
renderer.dispose()
}
// Test 3: dispose() cleans up all resources
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const extra1 = renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 })
const extra2 = renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 })
void extra1
void extra2
assert(mock.stats.createdTextures.size === 5, '5 total textures created before dispose')
renderer.dispose()
assert(renderer.isDisposed === true, 'isDisposed is true after dispose()')
assert(mock.stats.deletedTextures.size === 5, 'All 5 textures were deleted on dispose()')
assert(mock.stats.deletedBuffers.size === 2, 'Vertex and index buffers were deleted on dispose()')
assert(mock.stats.deletedVAOs.size === 1, 'VAO was deleted on dispose()')
assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()')
// Idempotent dispose
renderer.dispose()
assert(mock.stats.deletedPrograms.size === 1, 'Second dispose() is safe and idempotent')
// Calling methods after dispose throws RendererError
let threwOnBegin = false
try {
renderer.begin({ x: 0, y: 0, zoom: 1 })
} catch (err) {
if (err instanceof RendererError) threwOnBegin = true
}
assert(threwOnBegin, 'begin() throws RendererError when called on disposed renderer')
let threwOnAdd = false
try {
renderer.addAtlas({ pixels: new Uint8ClampedArray(16), width: 2, height: 2 })
} catch (err) {
if (err instanceof RendererError) threwOnAdd = true
}
assert(threwOnAdd, 'addAtlas() throws RendererError when called on disposed renderer')
}
// Test 4: WebGL Context Lost & Restored
{
const mock = createMockCanvas()
const tracker = { lostFired: false, restoredFired: false }
const renderer = new SpriteRenderer(mock.canvas, {
onContextLost: () => { tracker.lostFired = true },
onContextRestored: () => { tracker.restoredFired = true },
})
const lostEvent = mock.dispatchMockEvent('webglcontextlost')
assert(lostEvent.defaultPrevented === true, 'webglcontextlost calls event.preventDefault() to allow recovery')
assert(tracker.lostFired === true, 'onContextLost callback fired')
mock.dispatchMockEvent('webglcontextrestored')
assert(tracker.restoredFired === true, 'onContextRestored callback fired')
// Dispose cleans up event listeners
renderer.dispose()
assert(mock.stats.eventListeners.get('webglcontextlost')?.size === 0, 'Context lost listener removed on dispose')
assert(mock.stats.eventListeners.get('webglcontextrestored')?.size === 0, 'Context restored listener removed on dispose')
}
// Test 5: Dispose when context is already lost
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
mock.setContextLost(true)
// Dispose should not attempt invalid GL deletes when context is lost
renderer.dispose()
assert(renderer.isDisposed === true, 'dispose() safely completes when context is already lost')
}
// Test 6 (Issue #16): several atlas pages interleaved in painter's order stay
// in ONE draw call. This is the regression that mattered: sorting by texture
// is not an option here, because draw order *is* the depth order.
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const a = page(renderer)
const b = page(renderer)
const c = page(renderer)
const frame = { x: 0, y: 0, width: 8, height: 8 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const before = mock.stats.glDrawCalls
// Worst case for the old batcher: every quad switches page.
for (let i = 0; i < 60; i += 1) {
renderer.draw(frame, i, i, { atlas: i % 3 === 0 ? a : i % 3 === 1 ? b : c })
}
renderer.flush()
assert(mock.stats.glDrawCalls - before === 1, '60 quads ping-ponging across 3 pages cost exactly 1 draw call')
assert(renderer.drawCalls === 1, 'renderer.drawCalls reports the real measured count')
assert(renderer.quadsSubmitted === 60, 'renderer.quadsSubmitted counts every queued quad')
renderer.dispose()
}
// Test 7 (Issue #16): solid rectangles batch together with sprites rather
// than forcing a flush per colour or per texture switch.
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const a = page(renderer)
const frame = { x: 0, y: 0, width: 8, height: 8 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const before = mock.stats.glDrawCalls
renderer.draw(frame, 0, 0, { atlas: a })
renderer.drawSolid(0, 0, 4, 4, [1, 0, 0, 1])
renderer.draw(frame, 8, 8, { atlas: a })
renderer.drawSolid(8, 8, 4, 4, [0, 1, 0, 1])
renderer.flush()
assert(mock.stats.glDrawCalls - before === 1, 'Sprites and solid rects interleave in a single draw call')
renderer.dispose()
}
// Test 8 (Issue #16): the batch only splits once it runs out of texture units.
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const units = renderer.textureUnits
const frame = { x: 0, y: 0, width: 8, height: 8 }
// One more distinct page than there are units: the extra one must flush.
const pages = Array.from({ length: units + 1 }, () => page(renderer))
renderer.begin({ x: 0, y: 0, zoom: 1 })
const before = mock.stats.glDrawCalls
for (const atlas of pages) renderer.draw(frame, 0, 0, { atlas })
renderer.flush()
assert(units >= 8, `Context exposes a usable number of texture units (${String(units)})`)
assert(mock.stats.glDrawCalls - before === 2, 'Exceeding the texture-unit budget costs exactly one extra draw call')
renderer.dispose()
}
// Test 9 (Issue #16): begin() resets the per-frame counters, so the HUD reads
// this frame's cost rather than an ever-growing total.
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const a = page(renderer)
const frame = { x: 0, y: 0, width: 8, height: 8 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
renderer.draw(frame, 0, 0, { atlas: a })
renderer.flush()
assert(renderer.drawCalls === 1 && renderer.quadsSubmitted === 1, 'First frame counted')
renderer.begin({ x: 0, y: 0, zoom: 1 })
assert(renderer.drawCalls === 0 && renderer.quadsSubmitted === 0, 'begin() resets the per-frame counters')
renderer.dispose()
}
// Test 10 (Issue #16): indexed geometry and the generated sampler switch.
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const fragment = mock.stats.shaderSources.find(source => source.includes('u_textures')) ?? ''
const vertex = mock.stats.shaderSources.find(source => source.includes('a_position')) ?? ''
assert(fragment.includes('switch (v_unit)'), 'Fragment shader selects the sampler by a constant-case switch')
assert(
fragment.includes(`u_textures[${String(renderer.textureUnits)}]`),
'Sampler array is sized to the units the context actually reports',
)
assert(vertex.includes('flat out int v_unit'), 'Texture unit is carried per-vertex, flat-interpolated')
renderer.dispose()
}
// Test 11 (Issue #16): Multi-page level maintains draw calls <= 1 (within batch limits)
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
// A complex multi-page level with 8 pages:
// Floors (2 pages), Walls (2 pages), Roofs (1 page), Objects (1 page), Character (1 page), Monsters (1 page)
const pages = Array.from({ length: 8 }, () => page(renderer))
const frame = { x: 0, y: 0, width: 16, height: 16 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const beforeDrawCalls = mock.stats.glDrawCalls
// Simulate drawing a full frame in painter's order across these 8 pages (1200 quads total)
// 1. Floors (pages 0 & 1)
for (let i = 0; i < 400; i += 1) {
renderer.draw(frame, i * 4, 0, { atlas: pages[i % 2] })
}
// 2. Walls and objects interleaved (pages 2, 3, 5)
for (let i = 0; i < 500; i += 1) {
const pageIndex = i % 3 === 0 ? 5 : i % 3 === 1 ? 2 : 3
renderer.draw(frame, i * 4, 100, { atlas: pages[pageIndex] })
}
// 3. Characters and monsters (pages 6, 7)
for (let i = 0; i < 100; i += 1) {
renderer.draw(frame, i * 10, 200, { atlas: pages[6 + (i % 2)] })
}
// 4. Roofs (page 4)
for (let i = 0; i < 200; i += 1) {
renderer.draw(frame, i * 4, 300, { atlas: pages[4] })
}
renderer.flush()
assert(renderer.quadsSubmitted === 1200, 'Multi-page level submitted all 1200 quads')
assert(mock.stats.glDrawCalls - beforeDrawCalls === 1, 'Multi-page level (8 pages) rendered in exactly 1 draw call')
assert(renderer.drawCalls === 1, 'renderer.drawCalls reports 1 draw call')
// Now verify when distinct pages exceed the hardware texture unit budget
const overBudgetPages = Array.from({ length: renderer.textureUnits + 4 }, () => page(renderer))
renderer.begin({ x: 0, y: 0, zoom: 1 })
const beforeOverCalls = mock.stats.glDrawCalls
for (let i = 0; i < overBudgetPages.length; i += 1) {
renderer.draw(frame, i * 16, 0, { atlas: overBudgetPages[i] })
}
renderer.flush()
const extraCalls = mock.stats.glDrawCalls - beforeOverCalls
assert(
extraCalls <= Math.ceil(overBudgetPages.length / renderer.textureUnits),
'Exceeding texture unit budget flushes minimally within batch unit limits',
)
renderer.dispose()
}
// Test 12 (Issue #16): Viewport culling achieves > 80% quad culling ratio on large levels
{
// Generate an 80x80 level (6400 quads) with standard tile size 80x40
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
const gridCols = 80
const gridRows = 80
for (let y = 0; y < gridRows; y += 1) {
for (let x = 0; x < gridCols; x += 1) {
quads.push({
x: x * 80,
y: y * 40,
frame: { width: 80, height: 40 },
})
}
}
const totalQuads = quads.length // 6400
const bounds = buildBounds(quads)
// Center camera at (3200, 1600) with standard 1280x720 viewport and zoom = 1
const view1x = viewportRect(3200, 1600, 1280, 720, 1.0)
let visible1x = 0
let culled1x = 0
const visibleIndices: number[] = []
for (let i = 0; i < totalQuads; i += 1) {
if (intersects(bounds, i, view1x)) {
visible1x += 1
visibleIndices.push(i)
} else {
culled1x += 1
}
}
const cullingRatio1x = culled1x / totalQuads
assert(totalQuads === 6400, 'Large level has 6400 quads')
assert(visible1x + culled1x === totalQuads, 'All quads accounted for in culling pass')
assert(
cullingRatio1x > 0.80,
`Viewport culling achieves > 80% culling ratio on 80x80 level (measured: ${(cullingRatio1x * 100).toFixed(1)}%)`,
)
// Verify painter's order preservation: visible indices must be strictly monotonic
let monotonic = true
for (let i = 1; i < visibleIndices.length; i += 1) {
if (visibleIndices[i]! <= visibleIndices[i - 1]!) {
monotonic = false
break
}
}
assert(monotonic, "Culled stream maintains strictly monotonic painter's order")
// Test with zoom = 2.0 (tighter viewport -> even higher culling ratio)
const view2x = viewportRect(3200, 1600, 1280, 720, 2.0)
let culled2x = 0
for (let i = 0; i < totalQuads; i += 1) {
if (!intersects(bounds, i, view2x)) culled2x += 1
}
const cullingRatio2x = culled2x / totalQuads
assert(
cullingRatio2x > cullingRatio1x && cullingRatio2x > 0.80,
`Zooming in increases culling ratio (measured: ${(cullingRatio2x * 100).toFixed(1)}%)`,
)
}
// Test 13 (Issue #16): HUD displays accurate measured metrics rather than hardcoded dummies
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const a = page(renderer)
const frame = { x: 0, y: 0, width: 16, height: 16 }
// Frame 1: Render 120 quads
const t0 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 120; i += 1) {
renderer.draw(frame, i, i, { atlas: a })
}
renderer.flush()
const renderMs1 = performance.now() - t0
const state1 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 880,
renderMs: renderMs1,
}
const hudText1 = `${String(state1.quadsDrawn)} 图元 / ${String(state1.drawCalls)} 批次(剔除 ${String(state1.culledDraws)})· ${state1.renderMs.toFixed(1)} ms`
assert(state1.drawCalls === 1, 'HUD state drawCalls accurately records 1 draw call')
assert(state1.quadsDrawn === 120, 'HUD state quadsDrawn accurately records 120 quads')
assert(state1.culledDraws === 880, 'HUD state culledDraws accurately records 880 culled quads')
assert(state1.renderMs >= 0, 'HUD state renderMs is a valid measured frame duration')
assert(hudText1.includes('120 图元 / 1 批次(剔除 880)'), 'HUD string accurately formats measured metrics')
// Frame 2: Render with over-budget pages to force 2 draw calls and 250 quads
const overPages = Array.from({ length: renderer.textureUnits + 1 }, () => page(renderer))
const t1 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 200; i += 1) {
renderer.draw(frame, i, i, { atlas: overPages[i % renderer.textureUnits] })
}
for (let i = 0; i < 50; i += 1) {
renderer.draw(frame, i, i, { atlas: overPages[renderer.textureUnits] })
}
renderer.flush()
const renderMs2 = performance.now() - t1
const state2 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 1750,
renderMs: renderMs2,
}
const hudText2 = `${String(state2.quadsDrawn)} 图元 / ${String(state2.drawCalls)} 批次(剔除 ${String(state2.culledDraws)})· ${state2.renderMs.toFixed(1)} ms`
assert(state2.drawCalls === 2, 'HUD state drawCalls dynamically updates to 2 (not a hardcoded dummy)')
assert(state2.quadsDrawn === 250, 'HUD state quadsDrawn dynamically updates to 250 (not a hardcoded dummy)')
assert(state2.culledDraws === 1750, 'HUD state culledDraws dynamically updates to 1750')
assert(hudText2.includes('250 图元 / 2 批次(剔除 1750)'), 'HUD string reflects dynamic update without hardcoded values')
renderer.dispose()
}
// Test 14 (Issue #16): Memory allocation and performance regression audit
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const a = page(renderer)
const frame = { x: 0, y: 0, width: 8, height: 8 }
// 1. Buffer growth audit: submitting > INITIAL_CAPACITY (8192) quads
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 9000; i += 1) {
renderer.draw(frame, i % 100, i % 100, { atlas: a })
}
assert(renderer.quadsSubmitted === 9000, 'Batch buffer dynamically grows past initial capacity (9000 quads)')
renderer.flush()
assert(renderer.drawCalls === 1, 'Large batch (>8192 quads) flushed in single draw call without memory error')
// 2. Texture cleanup audit on deleteAtlas
const tempAtlas = page(renderer)
const texturesBefore = mock.stats.deletedTextures.size
renderer.deleteAtlas(tempAtlas)
assert(mock.stats.deletedTextures.size === texturesBefore + 1, 'deleteAtlas promptly frees GPU texture memory')
renderer.dispose()
}
console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`)
if (failed > 0) process.exit(1)
}
runTests()