perf(render): optimize GL state with immutable texStorage2D, uniform caching, and configurable discard (fixes #20)
This commit is contained in:
parent
1fa3f8b7a2
commit
11e2e42731
|
|
@ -26,6 +26,12 @@ function createMockCanvas(): {
|
|||
eventListeners: Map<string, Set<EventListener>>
|
||||
glDrawCalls: number
|
||||
shaderSources: string[]
|
||||
texStorage2DCalls: number
|
||||
texSubImage2DCalls: number
|
||||
uniformUploadCalls: number
|
||||
uniformCameraCalls: number
|
||||
uniformViewportCalls: number
|
||||
uniformZoomCalls: number
|
||||
}
|
||||
setContextLost: (lost: boolean) => void
|
||||
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
|
||||
|
|
@ -47,6 +53,12 @@ function createMockCanvas(): {
|
|||
eventListeners: new Map<string, Set<EventListener>>(),
|
||||
glDrawCalls: 0,
|
||||
shaderSources: [] as string[],
|
||||
texStorage2DCalls: 0,
|
||||
texSubImage2DCalls: 0,
|
||||
uniformUploadCalls: 0,
|
||||
uniformCameraCalls: 0,
|
||||
uniformViewportCalls: 0,
|
||||
uniformZoomCalls: 0,
|
||||
}
|
||||
|
||||
const gl: any = {
|
||||
|
|
@ -58,6 +70,9 @@ function createMockCanvas(): {
|
|||
MAX_TEXTURE_IMAGE_UNITS: 34930,
|
||||
TEXTURE_2D: 3553,
|
||||
RGBA: 6408,
|
||||
RGBA8: 32856,
|
||||
R8: 33321,
|
||||
RED: 6403,
|
||||
UNSIGNED_BYTE: 5121,
|
||||
UNSIGNED_INT: 5125,
|
||||
FLOAT: 5126,
|
||||
|
|
@ -142,12 +157,21 @@ function createMockCanvas(): {
|
|||
bindTexture: () => {},
|
||||
texParameteri: () => {},
|
||||
texImage2D: () => {},
|
||||
texStorage2D: () => { stats.texStorage2DCalls += 1 },
|
||||
texSubImage2D: () => { stats.texSubImage2DCalls += 1 },
|
||||
pixelStorei: () => {},
|
||||
|
||||
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
|
||||
getUniformLocation: (_p: any, name: string) => ({ name }),
|
||||
uniform2f: () => {},
|
||||
uniform1f: () => {},
|
||||
uniform2f: (loc: any) => {
|
||||
stats.uniformUploadCalls += 1
|
||||
if (loc?.name === 'u_camera') stats.uniformCameraCalls += 1
|
||||
if (loc?.name === 'u_viewport') stats.uniformViewportCalls += 1
|
||||
},
|
||||
uniform1f: (loc: any) => {
|
||||
stats.uniformUploadCalls += 1
|
||||
if (loc?.name === 'u_zoom') stats.uniformZoomCalls += 1
|
||||
},
|
||||
uniform1i: () => {},
|
||||
uniform1iv: () => {},
|
||||
|
||||
|
|
@ -234,7 +258,7 @@ function runTests(): void {
|
|||
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 === 2, 'Initial textures created (atlas + white)')
|
||||
assert(mock.stats.createdTextures.size === 3, 'Initial textures created (atlas + white + palette)')
|
||||
assert(renderer.isDisposed === false, 'Renderer is active and not disposed')
|
||||
|
||||
renderer.dispose()
|
||||
|
|
@ -246,7 +270,7 @@ function runTests(): void {
|
|||
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 === 3, 'addAtlas creates a new GPU texture (3 total)')
|
||||
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 })
|
||||
|
|
@ -273,11 +297,11 @@ function runTests(): void {
|
|||
void extra1
|
||||
void extra2
|
||||
|
||||
assert(mock.stats.createdTextures.size === 4, '4 total textures created before dispose')
|
||||
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 === 4, 'All 4 textures were deleted on 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()')
|
||||
|
|
@ -437,6 +461,112 @@ function runTests(): void {
|
|||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 11 (Issue #20): Immutable texture storage (texStorage2D + texSubImage2D)
|
||||
{
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
|
||||
// In constructor: whiteTexture and setPalette(defaultPal) should have used texStorage2D + texSubImage2D
|
||||
assert(mock.stats.texStorage2DCalls >= 2, 'texStorage2D called for initial whiteTexture and paletteTexture')
|
||||
assert(mock.stats.texSubImage2DCalls >= 2, 'texSubImage2D called for initial whiteTexture and paletteTexture')
|
||||
|
||||
const beforeStorage = mock.stats.texStorage2DCalls
|
||||
const beforeSub = mock.stats.texSubImage2DCalls
|
||||
|
||||
// setAtlas uses texStorage2D on first upload
|
||||
const atlas = {
|
||||
pixels: new Uint8ClampedArray(64 * 64 * 4),
|
||||
width: 64,
|
||||
height: 64,
|
||||
groups: [],
|
||||
}
|
||||
renderer.setAtlas(atlas)
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 1, 'setAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 1, 'setAtlas uploads data via texSubImage2D')
|
||||
|
||||
// Calling setAtlas again with SAME dimensions reuses storage (no extra texStorage2D, only texSubImage2D)
|
||||
renderer.setAtlas(atlas)
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 1, 'setAtlas with same dimensions reuses immutable storage')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 2, 'setAtlas with same dimensions uploads data via texSubImage2D')
|
||||
|
||||
// addAtlas uses texStorage2D and texSubImage2D
|
||||
renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 })
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 2, 'addAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 3, 'addAtlas uploads data via texSubImage2D')
|
||||
|
||||
// createAtlas alias also uses texStorage2D and texSubImage2D
|
||||
renderer.createAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 3, 'createAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 4, 'createAtlas uploads data via texSubImage2D')
|
||||
|
||||
// addIndexedAtlas uses texStorage2D (R8) and texSubImage2D (RED)
|
||||
renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 4, 'addIndexedAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 5, 'addIndexedAtlas uploads data via texSubImage2D')
|
||||
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 12 (Issue #20): Uniform dirty checking and caching in flush()
|
||||
{
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const frame = { x: 0, y: 0, width: 8, height: 8 }
|
||||
|
||||
renderer.begin({ x: 100, y: 200, zoom: 1.5 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 1, 'First flush uploads camera uniform')
|
||||
assert(mock.stats.uniformViewportCalls === 1, 'First flush uploads viewport uniform')
|
||||
assert(mock.stats.uniformZoomCalls === 1, 'First flush uploads zoom uniform')
|
||||
|
||||
// Second flush with identical camera/viewport/zoom should NOT re-upload any uniforms
|
||||
renderer.draw(frame, 8, 8)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 1, 'Consecutive flush with same camera skips camera uniform upload')
|
||||
assert(mock.stats.uniformViewportCalls === 1, 'Consecutive flush with same viewport skips viewport uniform upload')
|
||||
assert(mock.stats.uniformZoomCalls === 1, 'Consecutive flush with same zoom skips zoom uniform upload')
|
||||
|
||||
// Change only camera
|
||||
renderer.begin({ x: 150, y: 200, zoom: 1.5 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 2, 'Only camera uniform is uploaded when camera changes')
|
||||
assert(mock.stats.uniformViewportCalls === 1, 'Viewport uniform is not uploaded when viewport did not change')
|
||||
assert(mock.stats.uniformZoomCalls === 1, 'Zoom uniform is not uploaded when zoom did not change')
|
||||
|
||||
// Change only zoom
|
||||
renderer.begin({ x: 150, y: 200, zoom: 2.0 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 2, 'Camera uniform is not uploaded when camera did not change')
|
||||
assert(mock.stats.uniformZoomCalls === 2, 'Only zoom uniform is uploaded when zoom changes')
|
||||
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 13 (Issue #20): Configurable shader alphaDiscard option
|
||||
{
|
||||
const mockDefault = createMockCanvas()
|
||||
const rendererDefault = new SpriteRenderer(mockDefault.canvas)
|
||||
assert(rendererDefault.alphaDiscard === true, 'alphaDiscard defaults to true')
|
||||
const fsDefault = mockDefault.stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
||||
assert(fsDefault.includes('discard;'), 'Default shader includes discard for transparent fragments')
|
||||
rendererDefault.dispose()
|
||||
|
||||
const mockNoDiscard = createMockCanvas()
|
||||
const rendererNoDiscard = new SpriteRenderer(mockNoDiscard.canvas, { alphaDiscard: false })
|
||||
assert(rendererNoDiscard.alphaDiscard === false, 'alphaDiscard can be disabled via RendererOptions')
|
||||
const fsNoDiscard = mockNoDiscard.stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
||||
assert(!fsNoDiscard.includes('discard;'), 'Shader with alphaDiscard=false omits discard instructions for mobile GPU optimization')
|
||||
assert(fsNoDiscard.includes('outColor = texel * v_tint;'), 'Pure alpha blending used when alphaDiscard=false')
|
||||
rendererNoDiscard.dispose()
|
||||
}
|
||||
|
||||
console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`)
|
||||
if (failed > 0) process.exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,12 @@ export interface RendererOptions {
|
|||
readonly onContextLost?: (event: Event) => void
|
||||
/** Optional callback invoked when the WebGL context is restored. */
|
||||
readonly onContextRestored?: () => void
|
||||
/**
|
||||
* Whether to discard transparent pixels in the fragment shader.
|
||||
* Defaults to true. When false, pure alpha blending is used instead of discard,
|
||||
* avoiding early-Z / tile-kill penalties on mobile GPUs.
|
||||
*/
|
||||
readonly alphaDiscard?: boolean
|
||||
}
|
||||
|
||||
/** Vertices per quad: the four corners, reused by the index buffer. */
|
||||
|
|
@ -110,7 +116,7 @@ const PALETTE_ROWS = 5
|
|||
*
|
||||
* @returns GLSL ES 3.00 source.
|
||||
*/
|
||||
function vertexShaderSource(): string {
|
||||
export function vertexShaderSource(): string {
|
||||
return `#version 300 es
|
||||
in vec2 a_position;
|
||||
in vec2 a_uv;
|
||||
|
|
@ -142,13 +148,26 @@ void main() {
|
|||
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
|
||||
*
|
||||
* @param units - number of batch samplers to expose.
|
||||
* @param options - compilation options including alphaDiscard.
|
||||
* @returns GLSL ES 3.00 source.
|
||||
*/
|
||||
function fragmentShaderSource(units: number): string {
|
||||
export function fragmentShaderSource(units: number, options: { alphaDiscard?: boolean } = {}): string {
|
||||
const alphaDiscard = options.alphaDiscard ?? true
|
||||
const cases: string[] = []
|
||||
for (let unit = 1; unit < units; unit += 1) {
|
||||
cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`)
|
||||
}
|
||||
const paletteLookup = alphaDiscard
|
||||
? ` if (v_palette >= 0) {
|
||||
int idx = int(texel.r * 255.0 + 0.5);
|
||||
if (idx == 0) discard;
|
||||
texel = texelFetch(u_palette, ivec2(idx, v_palette), 0);
|
||||
}`
|
||||
: ` if (v_palette >= 0) {
|
||||
int idx = int(texel.r * 255.0 + 0.5);
|
||||
texel = idx == 0 ? vec4(0.0) : texelFetch(u_palette, ivec2(idx, v_palette), 0);
|
||||
}`
|
||||
const discardAlpha = alphaDiscard ? '\n if (texel.a == 0.0) discard;' : ''
|
||||
return `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 v_uv;
|
||||
|
|
@ -164,16 +183,20 @@ void main() {
|
|||
${cases.join('\n')}
|
||||
default: texel = texture(u_textures[0], v_uv); break;
|
||||
}
|
||||
if (v_palette >= 0) {
|
||||
int idx = int(texel.r * 255.0 + 0.5);
|
||||
if (idx == 0) discard;
|
||||
texel = texelFetch(u_palette, ivec2(idx, v_palette), 0);
|
||||
}
|
||||
if (texel.a == 0.0) discard;
|
||||
${paletteLookup}${discardAlpha}
|
||||
outColor = texel * v_tint;
|
||||
}`
|
||||
}
|
||||
|
||||
/** Configure standard 2D texture filtering and clamping parameters. */
|
||||
function initTextureParams(gl: WebGL2RenderingContext, texture: WebGLTexture): void {
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
}
|
||||
|
||||
/** Raised when the context or program cannot be created. */
|
||||
export class RendererError extends Error {}
|
||||
|
||||
|
|
@ -185,11 +208,13 @@ export class SpriteRenderer {
|
|||
readonly canvas: HTMLCanvasElement
|
||||
/** The WebGL2 context, exposed for the few callers that need raw access. */
|
||||
readonly gl: WebGL2RenderingContext
|
||||
/** Whether the fragment shader discards transparent fragments. */
|
||||
readonly alphaDiscard: boolean
|
||||
private readonly program: WebGLProgram
|
||||
private readonly vao: WebGLVertexArrayObject
|
||||
private readonly buffer: WebGLBuffer
|
||||
private readonly indexBuffer: WebGLBuffer
|
||||
private readonly atlasTexture: WebGLTexture
|
||||
private atlasTexture: WebGLTexture
|
||||
private readonly whiteTexture: WebGLTexture
|
||||
private readonly paletteTexture: WebGLTexture
|
||||
/** The 1×1 white page used by `drawSolid`. */
|
||||
|
|
@ -206,6 +231,14 @@ export class SpriteRenderer {
|
|||
private quadCount = 0
|
||||
private atlasWidth = 1
|
||||
private atlasHeight = 1
|
||||
private atlasStorageAllocated = false
|
||||
private paletteStorageAllocated = false
|
||||
/** Cached uniform values to eliminate redundant uniform uploads across flushes. */
|
||||
private cachedCameraX = Number.NaN
|
||||
private cachedCameraY = Number.NaN
|
||||
private cachedViewportWidth = Number.NaN
|
||||
private cachedViewportHeight = Number.NaN
|
||||
private cachedZoom = Number.NaN
|
||||
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */
|
||||
private readonly maxBatchTextures: number
|
||||
/** Dedicated texture unit for the 256-color hardware palette texture. */
|
||||
|
|
@ -237,6 +270,7 @@ export class SpriteRenderer {
|
|||
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false })
|
||||
if (gl === null) throw new RendererError('WebGL2 is not available in this browser')
|
||||
this.gl = gl
|
||||
this.alphaDiscard = options.alphaDiscard ?? true
|
||||
|
||||
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
|
||||
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
|
||||
|
|
@ -245,7 +279,11 @@ export class SpriteRenderer {
|
|||
this.maxBatchTextures = clampedUnits - 1
|
||||
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
|
||||
|
||||
this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures))
|
||||
this.program = createProgram(
|
||||
gl,
|
||||
vertexShaderSource(),
|
||||
fragmentShaderSource(this.maxBatchTextures, { alphaDiscard: this.alphaDiscard }),
|
||||
)
|
||||
const vao = gl.createVertexArray()
|
||||
const buffer = gl.createBuffer()
|
||||
const indexBuffer = gl.createBuffer()
|
||||
|
|
@ -318,14 +356,15 @@ export class SpriteRenderer {
|
|||
}
|
||||
|
||||
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
initTextureParams(gl, texture)
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1)
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
|
||||
} else {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
|
||||
}
|
||||
// Initialize default 256x5 greyscale palette until setPalette is called
|
||||
const defaultPal = new Uint8Array(256 * 3)
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
|
|
@ -344,6 +383,13 @@ export class SpriteRenderer {
|
|||
options.onContextLost?.(event)
|
||||
}
|
||||
this.handleContextRestored = (): void => {
|
||||
this.cachedCameraX = Number.NaN
|
||||
this.cachedCameraY = Number.NaN
|
||||
this.cachedViewportWidth = Number.NaN
|
||||
this.cachedViewportHeight = Number.NaN
|
||||
this.cachedZoom = Number.NaN
|
||||
this.atlasStorageAllocated = false
|
||||
this.paletteStorageAllocated = false
|
||||
console.info('WebGL2 context restored')
|
||||
options.onContextRestored?.()
|
||||
}
|
||||
|
|
@ -395,12 +441,45 @@ export class SpriteRenderer {
|
|||
)
|
||||
}
|
||||
this.flush()
|
||||
const pixels = new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength)
|
||||
|
||||
if (this.atlasStorageAllocated) {
|
||||
if (this.atlasWidth === atlas.width && this.atlasHeight === atlas.height) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
return this.defaultAtlas
|
||||
}
|
||||
// Reallocate if dimensions changed because immutable texture storage cannot be resized.
|
||||
if (!gl.isContextLost()) {
|
||||
gl.deleteTexture(this.atlasTexture)
|
||||
}
|
||||
this.allocatedTextures.delete(this.atlasTexture)
|
||||
const newTexture = gl.createTexture()
|
||||
if (newTexture === null) throw new RendererError('texture allocation failed')
|
||||
this.atlasTexture = newTexture
|
||||
this.allocatedTextures.add(newTexture)
|
||||
initTextureParams(gl, newTexture)
|
||||
}
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength),
|
||||
)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, atlas.width, atlas.height)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
} else {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
}
|
||||
this.atlasStorageAllocated = true
|
||||
this.atlasWidth = Math.max(atlas.width, 1)
|
||||
this.atlasHeight = Math.max(atlas.height, 1)
|
||||
this.defaultAtlas = { texture: this.atlasTexture, width: this.atlasWidth, height: this.atlasHeight }
|
||||
|
|
@ -441,25 +520,49 @@ export class SpriteRenderer {
|
|||
this.allocatedTextures.add(texture)
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
if (isBitmap) {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
|
||||
initTextureParams(gl, texture)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight)
|
||||
if (isBitmap) {
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
|
||||
} else {
|
||||
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
|
||||
const pixels = indexed.pixels instanceof Uint8Array
|
||||
? indexed.pixels
|
||||
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, indexed.width, indexed.height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
|
||||
const pixels = indexed.pixels instanceof Uint8Array
|
||||
? indexed.pixels
|
||||
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
if (isBitmap) {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
|
||||
} else {
|
||||
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
|
||||
const pixels = indexed.pixels instanceof Uint8Array
|
||||
? indexed.pixels
|
||||
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
}
|
||||
}
|
||||
return { texture, width: pageWidth, height: pageHeight }
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for {@link addAtlas} for creating/uploading an atlas page.
|
||||
*/
|
||||
createAtlas(
|
||||
source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
|
||||
width?: number,
|
||||
height?: number,
|
||||
): AtlasHandle {
|
||||
return this.addAtlas(source, width, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a raw 8-bit palette-indexed (`gl.R8`, 1 byte per pixel) atlas texture.
|
||||
*
|
||||
|
|
@ -487,21 +590,33 @@ export class SpriteRenderer {
|
|||
this.allocatedTextures.add(texture)
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R8,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
0,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
indices,
|
||||
)
|
||||
initTextureParams(gl, texture)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R8, pageWidth, pageHeight)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
indices,
|
||||
)
|
||||
} else {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R8,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
0,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
indices,
|
||||
)
|
||||
}
|
||||
return { texture, width: pageWidth, height: pageHeight, indexed: true }
|
||||
}
|
||||
|
||||
|
|
@ -563,17 +678,35 @@ export class SpriteRenderer {
|
|||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
256,
|
||||
PALETTE_ROWS,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
rgba,
|
||||
)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
if (!this.paletteStorageAllocated) {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 256, PALETTE_ROWS)
|
||||
this.paletteStorageAllocated = true
|
||||
}
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
256,
|
||||
PALETTE_ROWS,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
rgba,
|
||||
)
|
||||
} else {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
256,
|
||||
PALETTE_ROWS,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
rgba,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -742,9 +875,28 @@ export class SpriteRenderer {
|
|||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount)
|
||||
gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y)
|
||||
gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight)
|
||||
gl.uniform1f(this.uniformZoom, this.camera.zoom)
|
||||
const camX = this.camera.x
|
||||
const camY = this.camera.y
|
||||
if (camX !== this.cachedCameraX || camY !== this.cachedCameraY) {
|
||||
gl.uniform2f(this.uniformCamera, camX, camY)
|
||||
this.cachedCameraX = camX
|
||||
this.cachedCameraY = camY
|
||||
}
|
||||
|
||||
const vpWidth = gl.drawingBufferWidth
|
||||
const vpHeight = gl.drawingBufferHeight
|
||||
if (vpWidth !== this.cachedViewportWidth || vpHeight !== this.cachedViewportHeight) {
|
||||
gl.uniform2f(this.uniformViewport, vpWidth, vpHeight)
|
||||
this.cachedViewportWidth = vpWidth
|
||||
this.cachedViewportHeight = vpHeight
|
||||
}
|
||||
|
||||
const zoom = this.camera.zoom
|
||||
if (zoom !== this.cachedZoom) {
|
||||
gl.uniform1f(this.uniformZoom, zoom)
|
||||
this.cachedZoom = zoom
|
||||
}
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0 + this.paletteUnit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
|
||||
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
|
||||
|
|
@ -766,6 +918,14 @@ export class SpriteRenderer {
|
|||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
|
||||
this.cachedCameraX = Number.NaN
|
||||
this.cachedCameraY = Number.NaN
|
||||
this.cachedViewportWidth = Number.NaN
|
||||
this.cachedViewportHeight = Number.NaN
|
||||
this.cachedZoom = Number.NaN
|
||||
this.atlasStorageAllocated = false
|
||||
this.paletteStorageAllocated = false
|
||||
|
||||
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
|
||||
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue