429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import { describe, test, expect } from 'vitest'
|
|
import {
|
|
SpriteRenderer,
|
|
vertexShaderSource,
|
|
fragmentShaderSource,
|
|
type AtlasHandle,
|
|
} from '../src/render/renderer.ts'
|
|
|
|
interface MockResource {
|
|
id: number
|
|
type: string
|
|
}
|
|
|
|
function createMockCanvas(hasTexStorage = true): {
|
|
canvas: any
|
|
gl: any
|
|
stats: {
|
|
createdTextures: Set<MockResource>
|
|
deletedTextures: Set<MockResource>
|
|
texStorage2DCalls: Array<{ target: number; levels: number; internalformat: number; width: number; height: number }>
|
|
texSubImage2DCalls: Array<{ target: number; level: number; xoffset: number; yoffset: number; width: number; height: number; format: number; type: number; data: any }>
|
|
texImage2DCalls: Array<any>
|
|
uniformCameraCalls: number
|
|
uniformViewportCalls: number
|
|
uniformZoomCalls: number
|
|
uniformUploadCalls: number
|
|
shaderSources: string[]
|
|
}
|
|
} {
|
|
let nextId = 1
|
|
const stats = {
|
|
createdTextures: new Set<MockResource>(),
|
|
deletedTextures: new Set<MockResource>(),
|
|
texStorage2DCalls: [] as Array<{ target: number; levels: number; internalformat: number; width: number; height: number }>,
|
|
texSubImage2DCalls: [] as Array<{ target: number; level: number; xoffset: number; yoffset: number; width: number; height: number; format: number; type: number; data: any }>,
|
|
texImage2DCalls: [] as Array<any>,
|
|
uniformCameraCalls: 0,
|
|
uniformViewportCalls: 0,
|
|
uniformZoomCalls: 0,
|
|
uniformUploadCalls: 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,
|
|
RGBA8: 32856,
|
|
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: () => false,
|
|
|
|
createProgram: () => ({ id: nextId++, type: 'program' }),
|
|
deleteProgram: () => {},
|
|
attachShader: () => {},
|
|
detachShader: () => {},
|
|
linkProgram: () => {},
|
|
getProgramParameter: () => true,
|
|
getProgramInfoLog: () => '',
|
|
useProgram: () => {},
|
|
|
|
createShader: (type: number) => ({ id: nextId++, type: type === 35633 ? 'vs' : 'fs' }),
|
|
shaderSource: (_shader: any, source: string) => { stats.shaderSources.push(source) },
|
|
compileShader: () => {},
|
|
getShaderParameter: () => true,
|
|
getShaderInfoLog: () => '',
|
|
deleteShader: () => {},
|
|
|
|
createVertexArray: () => ({ id: nextId++, type: 'vao' }),
|
|
deleteVertexArray: () => {},
|
|
bindVertexArray: () => {},
|
|
|
|
createBuffer: () => ({ id: nextId++, type: 'buffer' }),
|
|
deleteBuffer: () => {},
|
|
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: () => {},
|
|
pixelStorei: () => {},
|
|
|
|
texImage2D: (...args: any[]) => {
|
|
stats.texImage2DCalls.push(args)
|
|
},
|
|
texSubImage2D: (
|
|
target: number,
|
|
level: number,
|
|
xoffset: number,
|
|
yoffset: number,
|
|
width: number,
|
|
height: number,
|
|
format: number,
|
|
type: number,
|
|
data: any,
|
|
) => {
|
|
stats.texSubImage2DCalls.push({ target, level, xoffset, yoffset, width, height, format, type, data })
|
|
},
|
|
|
|
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
|
|
getUniformLocation: (_p: any, name: string) => ({ name }),
|
|
|
|
uniform2f: (loc: any, x: number, y: number) => {
|
|
stats.uniformUploadCalls += 1
|
|
if (loc?.name === 'u_camera') stats.uniformCameraCalls += 1
|
|
if (loc?.name === 'u_viewport') stats.uniformViewportCalls += 1
|
|
},
|
|
uniform1f: (loc: any, x: number) => {
|
|
stats.uniformUploadCalls += 1
|
|
if (loc?.name === 'u_zoom') stats.uniformZoomCalls += 1
|
|
},
|
|
uniform1i: () => {},
|
|
uniform1iv: () => {},
|
|
|
|
enable: () => {},
|
|
disable: () => {},
|
|
blendFunc: () => {},
|
|
viewport: () => {},
|
|
clearColor: () => {},
|
|
clear: () => {},
|
|
activeTexture: () => {},
|
|
drawArrays: () => {},
|
|
drawElements: () => {},
|
|
}
|
|
|
|
if (hasTexStorage) {
|
|
gl.texStorage2D = (target: number, levels: number, internalformat: number, width: number, height: number) => {
|
|
stats.texStorage2DCalls.push({ target, levels, internalformat, width, height })
|
|
}
|
|
}
|
|
|
|
const canvas: any = {
|
|
getContext: (type: string) => (type === 'webgl2' ? gl : null),
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
drawingBufferWidth: 800,
|
|
drawingBufferHeight: 600,
|
|
}
|
|
|
|
return { canvas, gl, stats }
|
|
}
|
|
|
|
describe('WebGL2 SpriteRenderer Performance Tuning (Issue #20)', () => {
|
|
describe('Immutable Texture Storage (texStorage2D + texSubImage2D)', () => {
|
|
test('allocates whiteTexture and paletteTexture with texStorage2D in constructor', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
|
|
expect(stats.texStorage2DCalls.length).toBeGreaterThanOrEqual(2)
|
|
// whiteTexture: 1x1 RGBA8
|
|
const whiteStorage = stats.texStorage2DCalls.find(call => call.width === 1 && call.height === 1)
|
|
expect(whiteStorage).toBeDefined()
|
|
expect(whiteStorage?.internalformat).toBe(32856) // gl.RGBA8
|
|
expect(whiteStorage?.levels).toBe(1)
|
|
|
|
// paletteTexture: 256x5 RGBA8
|
|
const palStorage = stats.texStorage2DCalls.find(call => call.width === 256 && call.height === 5)
|
|
expect(palStorage).toBeDefined()
|
|
expect(palStorage?.internalformat).toBe(32856) // gl.RGBA8
|
|
|
|
// Both should have data uploaded via texSubImage2D
|
|
expect(stats.texSubImage2DCalls.length).toBeGreaterThanOrEqual(2)
|
|
renderer.dispose()
|
|
})
|
|
|
|
test('setAtlas allocates immutable storage via texStorage2D and uploads via texSubImage2D', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const storageCallsBefore = stats.texStorage2DCalls.length
|
|
const subCallsBefore = stats.texSubImage2DCalls.length
|
|
|
|
const atlas = {
|
|
pixels: new Uint8ClampedArray(128 * 64 * 4),
|
|
width: 128,
|
|
height: 64,
|
|
groups: [],
|
|
}
|
|
renderer.setAtlas(atlas)
|
|
|
|
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
|
|
const lastStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
|
|
expect(lastStorage?.width).toBe(128)
|
|
expect(lastStorage?.height).toBe(64)
|
|
expect(lastStorage?.internalformat).toBe(32856) // gl.RGBA8
|
|
|
|
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
|
|
const lastSub = stats.texSubImage2DCalls[stats.texSubImage2DCalls.length - 1]
|
|
expect(lastSub?.width).toBe(128)
|
|
expect(lastSub?.height).toBe(64)
|
|
|
|
// Re-uploading atlas with SAME dimensions reuses the immutable storage
|
|
renderer.setAtlas(atlas)
|
|
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1) // no extra texStorage2D
|
|
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 2) // extra texSubImage2D
|
|
|
|
// Uploading atlas with DIFFERENT dimensions deletes old texture and allocates new storage
|
|
const resizedAtlas = {
|
|
pixels: new Uint8ClampedArray(256 * 256 * 4),
|
|
width: 256,
|
|
height: 256,
|
|
groups: [],
|
|
}
|
|
renderer.setAtlas(resizedAtlas)
|
|
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 2)
|
|
const resizedStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
|
|
expect(resizedStorage?.width).toBe(256)
|
|
expect(resizedStorage?.height).toBe(256)
|
|
|
|
renderer.dispose()
|
|
})
|
|
|
|
test('addAtlas and createAtlas allocate immutable storage via texStorage2D', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const storageCallsBefore = stats.texStorage2DCalls.length
|
|
const subCallsBefore = stats.texSubImage2DCalls.length
|
|
|
|
const handle1 = renderer.addAtlas({
|
|
pixels: new Uint8ClampedArray(32 * 32 * 4),
|
|
width: 32,
|
|
height: 32,
|
|
})
|
|
expect(handle1.width).toBe(32)
|
|
expect(handle1.height).toBe(32)
|
|
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
|
|
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
|
|
|
|
const handle2 = renderer.createAtlas({
|
|
pixels: new Uint8ClampedArray(64 * 32 * 4),
|
|
width: 64,
|
|
height: 32,
|
|
})
|
|
expect(handle2.width).toBe(64)
|
|
expect(handle2.height).toBe(32)
|
|
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 2)
|
|
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 2)
|
|
|
|
renderer.dispose()
|
|
})
|
|
|
|
test('addIndexedAtlas allocates R8 single-channel texture via texStorage2D', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const storageCallsBefore = stats.texStorage2DCalls.length
|
|
const subCallsBefore = stats.texSubImage2DCalls.length
|
|
|
|
const handle = renderer.addIndexedAtlas(new Uint8Array(64 * 64), 64, 64)
|
|
expect(handle.indexed).toBe(true)
|
|
expect(handle.width).toBe(64)
|
|
expect(handle.height).toBe(64)
|
|
|
|
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
|
|
const lastStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
|
|
expect(lastStorage?.width).toBe(64)
|
|
expect(lastStorage?.height).toBe(64)
|
|
expect(lastStorage?.internalformat).toBe(33321) // gl.R8
|
|
|
|
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
|
|
const lastSub = stats.texSubImage2DCalls[stats.texSubImage2DCalls.length - 1]
|
|
expect(lastSub?.format).toBe(6403) // gl.RED
|
|
|
|
renderer.dispose()
|
|
})
|
|
|
|
test('gracefully falls back to texImage2D when texStorage2D is unavailable', () => {
|
|
const { canvas, stats } = createMockCanvas(false) // without texStorage2D
|
|
const renderer = new SpriteRenderer(canvas)
|
|
expect(stats.texImage2DCalls.length).toBeGreaterThan(0)
|
|
|
|
const atlas = {
|
|
pixels: new Uint8ClampedArray(16 * 16 * 4),
|
|
width: 16,
|
|
height: 16,
|
|
groups: [],
|
|
}
|
|
renderer.setAtlas(atlas)
|
|
renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
|
|
renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
|
|
expect(stats.texImage2DCalls.length).toBeGreaterThanOrEqual(4)
|
|
renderer.dispose()
|
|
})
|
|
})
|
|
|
|
describe('Uniform Caching & Dirty Checking', () => {
|
|
test('only issues uniform uploads when camera, viewport, or zoom values change', () => {
|
|
const { canvas, gl, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const frame = { x: 0, y: 0, width: 8, height: 8 }
|
|
|
|
// Frame 1: Initial flush
|
|
renderer.begin({ x: 50, y: 100, zoom: 1.0 })
|
|
renderer.draw(frame, 0, 0)
|
|
renderer.flush()
|
|
|
|
expect(stats.uniformCameraCalls).toBe(1)
|
|
expect(stats.uniformViewportCalls).toBe(1)
|
|
expect(stats.uniformZoomCalls).toBe(1)
|
|
|
|
// Frame 2: Same camera, same viewport, same zoom -> 0 uniform uploads
|
|
renderer.begin({ x: 50, y: 100, zoom: 1.0 })
|
|
renderer.draw(frame, 0, 0)
|
|
renderer.flush()
|
|
|
|
expect(stats.uniformCameraCalls).toBe(1)
|
|
expect(stats.uniformViewportCalls).toBe(1)
|
|
expect(stats.uniformZoomCalls).toBe(1)
|
|
|
|
// Frame 3: Multiple flushes within the same frame -> 0 redundant uploads
|
|
renderer.draw(frame, 8, 8)
|
|
renderer.flush()
|
|
renderer.draw(frame, 16, 16)
|
|
renderer.flush()
|
|
|
|
expect(stats.uniformCameraCalls).toBe(1)
|
|
expect(stats.uniformViewportCalls).toBe(1)
|
|
expect(stats.uniformZoomCalls).toBe(1)
|
|
|
|
// Frame 4: Camera changes -> only camera uniform uploaded
|
|
renderer.begin({ x: 60, y: 100, zoom: 1.0 })
|
|
renderer.draw(frame, 0, 0)
|
|
renderer.flush()
|
|
|
|
expect(stats.uniformCameraCalls).toBe(2)
|
|
expect(stats.uniformViewportCalls).toBe(1)
|
|
expect(stats.uniformZoomCalls).toBe(1)
|
|
|
|
// Frame 5: Zoom changes -> only zoom uniform uploaded
|
|
renderer.begin({ x: 60, y: 100, zoom: 2.0 })
|
|
renderer.draw(frame, 0, 0)
|
|
renderer.flush()
|
|
|
|
expect(stats.uniformCameraCalls).toBe(2)
|
|
expect(stats.uniformViewportCalls).toBe(1)
|
|
expect(stats.uniformZoomCalls).toBe(2)
|
|
|
|
// Frame 6: Viewport changes -> only viewport uniform uploaded
|
|
gl.drawingBufferWidth = 1024
|
|
gl.drawingBufferHeight = 768
|
|
renderer.begin({ x: 60, y: 100, zoom: 2.0 })
|
|
renderer.draw(frame, 0, 0)
|
|
renderer.flush()
|
|
|
|
expect(stats.uniformCameraCalls).toBe(2)
|
|
expect(stats.uniformViewportCalls).toBe(2)
|
|
expect(stats.uniformZoomCalls).toBe(2)
|
|
|
|
renderer.dispose()
|
|
})
|
|
})
|
|
|
|
describe('Configurable Shader Discard / Alpha Optimization', () => {
|
|
test('fragmentShaderSource generates discard when alphaDiscard is true', () => {
|
|
const src = fragmentShaderSource(8, { alphaDiscard: true })
|
|
expect(src).toContain('if (idx == 0) discard;')
|
|
expect(src).toContain('if (texel.a == 0.0) discard;')
|
|
})
|
|
|
|
test('fragmentShaderSource omits discard when alphaDiscard is false', () => {
|
|
const src = fragmentShaderSource(8, { alphaDiscard: false })
|
|
expect(src).not.toContain('discard')
|
|
expect(src).toContain('texel = idx == 0 ? vec4(0.0) : texelFetch(u_palette, ivec2(idx, v_palette), 0);')
|
|
expect(src).toContain('outColor = texel * v_tint;')
|
|
})
|
|
|
|
test('SpriteRenderer defaults to alphaDiscard = true', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
expect(renderer.alphaDiscard).toBe(true)
|
|
|
|
const fs = stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
|
expect(fs).toContain('discard;')
|
|
renderer.dispose()
|
|
})
|
|
|
|
test('SpriteRenderer respects alphaDiscard = false option', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas, { alphaDiscard: false })
|
|
expect(renderer.alphaDiscard).toBe(false)
|
|
|
|
const fs = stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
|
expect(fs).not.toContain('discard')
|
|
expect(fs).toContain('outColor = texel * v_tint;')
|
|
renderer.dispose()
|
|
})
|
|
})
|
|
})
|