From 11e2e42731f4a8bedcb7c88a9a37ba7ac6cd1af5 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 08:16:05 +0000 Subject: [PATCH] perf(render): optimize GL state with immutable texStorage2D, uniform caching, and configurable discard (fixes #20) --- scripts/verify-renderer-lifecycle.ts | 142 ++++++++- src/render/renderer.ts | 286 ++++++++++++++---- tests/renderer-tuning.test.ts | 428 +++++++++++++++++++++++++++ 3 files changed, 787 insertions(+), 69 deletions(-) create mode 100644 tests/renderer-tuning.test.ts diff --git a/scripts/verify-renderer-lifecycle.ts b/scripts/verify-renderer-lifecycle.ts index 89f1b8b..b560f0b 100644 --- a/scripts/verify-renderer-lifecycle.ts +++ b/scripts/verify-renderer-lifecycle.ts @@ -26,6 +26,12 @@ function createMockCanvas(): { eventListeners: Map> 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>(), 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) } diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 6810488..b16906d 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -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(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) diff --git a/tests/renderer-tuning.test.ts b/tests/renderer-tuning.test.ts new file mode 100644 index 0000000..c95c0fe --- /dev/null +++ b/tests/renderer-tuning.test.ts @@ -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 + deletedTextures: Set + 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 + uniformCameraCalls: number + uniformViewportCalls: number + uniformZoomCalls: number + uniformUploadCalls: number + shaderSources: string[] + } +} { + let nextId = 1 + const stats = { + createdTextures: new Set(), + deletedTextures: new Set(), + 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, + 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() + }) + }) +})