perf(render): optimize GL state with immutable texStorage2D, uniform caching, and configurable discard (fixes #20)

This commit is contained in:
troytt 2026-09-17 08:16:05 +00:00
parent 1fa3f8b7a2
commit 11e2e42731
3 changed files with 787 additions and 69 deletions

View File

@ -26,6 +26,12 @@ function createMockCanvas(): {
eventListeners: Map<string, Set<EventListener>> eventListeners: Map<string, Set<EventListener>>
glDrawCalls: number glDrawCalls: number
shaderSources: string[] shaderSources: string[]
texStorage2DCalls: number
texSubImage2DCalls: number
uniformUploadCalls: number
uniformCameraCalls: number
uniformViewportCalls: number
uniformZoomCalls: number
} }
setContextLost: (lost: boolean) => void setContextLost: (lost: boolean) => void
dispatchMockEvent: (type: string) => { defaultPrevented: boolean } dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
@ -47,6 +53,12 @@ function createMockCanvas(): {
eventListeners: new Map<string, Set<EventListener>>(), eventListeners: new Map<string, Set<EventListener>>(),
glDrawCalls: 0, glDrawCalls: 0,
shaderSources: [] as string[], shaderSources: [] as string[],
texStorage2DCalls: 0,
texSubImage2DCalls: 0,
uniformUploadCalls: 0,
uniformCameraCalls: 0,
uniformViewportCalls: 0,
uniformZoomCalls: 0,
} }
const gl: any = { const gl: any = {
@ -58,6 +70,9 @@ function createMockCanvas(): {
MAX_TEXTURE_IMAGE_UNITS: 34930, MAX_TEXTURE_IMAGE_UNITS: 34930,
TEXTURE_2D: 3553, TEXTURE_2D: 3553,
RGBA: 6408, RGBA: 6408,
RGBA8: 32856,
R8: 33321,
RED: 6403,
UNSIGNED_BYTE: 5121, UNSIGNED_BYTE: 5121,
UNSIGNED_INT: 5125, UNSIGNED_INT: 5125,
FLOAT: 5126, FLOAT: 5126,
@ -142,12 +157,21 @@ function createMockCanvas(): {
bindTexture: () => {}, bindTexture: () => {},
texParameteri: () => {}, texParameteri: () => {},
texImage2D: () => {}, texImage2D: () => {},
texStorage2D: () => { stats.texStorage2DCalls += 1 },
texSubImage2D: () => { stats.texSubImage2DCalls += 1 },
pixelStorei: () => {}, pixelStorei: () => {},
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0), getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
getUniformLocation: (_p: any, name: string) => ({ name }), getUniformLocation: (_p: any, name: string) => ({ name }),
uniform2f: () => {}, uniform2f: (loc: any) => {
uniform1f: () => {}, 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: () => {}, uniform1i: () => {},
uniform1iv: () => {}, uniform1iv: () => {},
@ -234,7 +258,7 @@ function runTests(): void {
assert(mock.stats.createdPrograms.size === 1, 'Program created') assert(mock.stats.createdPrograms.size === 1, 'Program created')
assert(mock.stats.createdVAOs.size === 1, 'VAO created') assert(mock.stats.createdVAOs.size === 1, 'VAO created')
assert(mock.stats.createdBuffers.size === 2, 'Vertex and index buffers 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') assert(renderer.isDisposed === false, 'Renderer is active and not disposed')
renderer.dispose() renderer.dispose()
@ -246,7 +270,7 @@ function runTests(): void {
const renderer = new SpriteRenderer(mock.canvas) const renderer = new SpriteRenderer(mock.canvas)
const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(64 * 64 * 4), width: 64, height: 64 }) 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 // Draw with this extra atlas
renderer.begin({ x: 0, y: 0, zoom: 1 }) renderer.begin({ x: 0, y: 0, zoom: 1 })
@ -273,11 +297,11 @@ function runTests(): void {
void extra1 void extra1
void extra2 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() renderer.dispose()
assert(renderer.isDisposed === true, 'isDisposed is true after 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.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.deletedVAOs.size === 1, 'VAO was deleted on dispose()')
assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()') assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()')
@ -437,6 +461,112 @@ function runTests(): void {
renderer.dispose() 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 ==`) console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`)
if (failed > 0) process.exit(1) if (failed > 0) process.exit(1)
} }

View File

@ -88,6 +88,12 @@ export interface RendererOptions {
readonly onContextLost?: (event: Event) => void readonly onContextLost?: (event: Event) => void
/** Optional callback invoked when the WebGL context is restored. */ /** Optional callback invoked when the WebGL context is restored. */
readonly onContextRestored?: () => void 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. */ /** 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. * @returns GLSL ES 3.00 source.
*/ */
function vertexShaderSource(): string { export function vertexShaderSource(): string {
return `#version 300 es return `#version 300 es
in vec2 a_position; in vec2 a_position;
in vec2 a_uv; in vec2 a_uv;
@ -142,13 +148,26 @@ void main() {
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`. * textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
* *
* @param units - number of batch samplers to expose. * @param units - number of batch samplers to expose.
* @param options - compilation options including alphaDiscard.
* @returns GLSL ES 3.00 source. * @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[] = [] const cases: string[] = []
for (let unit = 1; unit < units; unit += 1) { for (let unit = 1; unit < units; unit += 1) {
cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`) 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 return `#version 300 es
precision highp float; precision highp float;
in vec2 v_uv; in vec2 v_uv;
@ -164,16 +183,20 @@ void main() {
${cases.join('\n')} ${cases.join('\n')}
default: texel = texture(u_textures[0], v_uv); break; default: texel = texture(u_textures[0], v_uv); break;
} }
if (v_palette >= 0) { ${paletteLookup}${discardAlpha}
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;
outColor = texel * v_tint; 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. */ /** Raised when the context or program cannot be created. */
export class RendererError extends Error {} export class RendererError extends Error {}
@ -185,11 +208,13 @@ export class SpriteRenderer {
readonly canvas: HTMLCanvasElement readonly canvas: HTMLCanvasElement
/** The WebGL2 context, exposed for the few callers that need raw access. */ /** The WebGL2 context, exposed for the few callers that need raw access. */
readonly gl: WebGL2RenderingContext readonly gl: WebGL2RenderingContext
/** Whether the fragment shader discards transparent fragments. */
readonly alphaDiscard: boolean
private readonly program: WebGLProgram private readonly program: WebGLProgram
private readonly vao: WebGLVertexArrayObject private readonly vao: WebGLVertexArrayObject
private readonly buffer: WebGLBuffer private readonly buffer: WebGLBuffer
private readonly indexBuffer: WebGLBuffer private readonly indexBuffer: WebGLBuffer
private readonly atlasTexture: WebGLTexture private atlasTexture: WebGLTexture
private readonly whiteTexture: WebGLTexture private readonly whiteTexture: WebGLTexture
private readonly paletteTexture: WebGLTexture private readonly paletteTexture: WebGLTexture
/** The 1×1 white page used by `drawSolid`. */ /** The 1×1 white page used by `drawSolid`. */
@ -206,6 +231,14 @@ export class SpriteRenderer {
private quadCount = 0 private quadCount = 0
private atlasWidth = 1 private atlasWidth = 1
private atlasHeight = 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). */ /** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */
private readonly maxBatchTextures: number private readonly maxBatchTextures: number
/** Dedicated texture unit for the 256-color hardware palette texture. */ /** 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 }) const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false })
if (gl === null) throw new RendererError('WebGL2 is not available in this browser') if (gl === null) throw new RendererError('WebGL2 is not available in this browser')
this.gl = gl this.gl = gl
this.alphaDiscard = options.alphaDiscard ?? true
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
@ -245,7 +279,11 @@ export class SpriteRenderer {
this.maxBatchTextures = clampedUnits - 1 this.maxBatchTextures = clampedUnits - 1
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null) 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 vao = gl.createVertexArray()
const buffer = gl.createBuffer() const buffer = gl.createBuffer()
const indexBuffer = gl.createBuffer() const indexBuffer = gl.createBuffer()
@ -318,14 +356,15 @@ export class SpriteRenderer {
} }
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) { for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
gl.bindTexture(gl.TEXTURE_2D, texture) initTextureParams(gl, 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)
} }
gl.bindTexture(gl.TEXTURE_2D, whiteTexture) 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 // Initialize default 256x5 greyscale palette until setPalette is called
const defaultPal = new Uint8Array(256 * 3) const defaultPal = new Uint8Array(256 * 3)
for (let i = 0; i < 256; i += 1) { for (let i = 0; i < 256; i += 1) {
@ -344,6 +383,13 @@ export class SpriteRenderer {
options.onContextLost?.(event) options.onContextLost?.(event)
} }
this.handleContextRestored = (): void => { 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') console.info('WebGL2 context restored')
options.onContextRestored?.() options.onContextRestored?.()
} }
@ -395,12 +441,45 @@ export class SpriteRenderer {
) )
} }
this.flush() 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.bindTexture(gl.TEXTURE_2D, this.atlasTexture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texImage2D( if (typeof gl.texStorage2D === 'function') {
gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0, gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, atlas.width, atlas.height)
gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength), 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.atlasWidth = Math.max(atlas.width, 1)
this.atlasHeight = Math.max(atlas.height, 1) this.atlasHeight = Math.max(atlas.height, 1)
this.defaultAtlas = { texture: this.atlasTexture, width: this.atlasWidth, height: this.atlasHeight } this.defaultAtlas = { texture: this.atlasTexture, width: this.atlasWidth, height: this.atlasHeight }
@ -441,25 +520,49 @@ export class SpriteRenderer {
this.allocatedTextures.add(texture) this.allocatedTextures.add(texture)
gl.bindTexture(gl.TEXTURE_2D, texture) gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) initTextureParams(gl, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) if (typeof gl.texStorage2D === 'function') {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) if (isBitmap) {
if (isBitmap) { gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
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.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, indexed.width, indexed.height,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
}
} else { } else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number } if (isBitmap) {
const pixels = indexed.pixels instanceof Uint8Array gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
? indexed.pixels } else {
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength) const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
gl.texImage2D( const pixels = indexed.pixels instanceof Uint8Array
gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0, ? indexed.pixels
gl.RGBA, gl.UNSIGNED_BYTE, 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 } 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. * 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) this.allocatedTextures.add(texture)
gl.bindTexture(gl.TEXTURE_2D, texture) gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) initTextureParams(gl, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) if (typeof gl.texStorage2D === 'function') {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R8, pageWidth, pageHeight)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) gl.texSubImage2D(
gl.texImage2D( gl.TEXTURE_2D,
gl.TEXTURE_2D, 0,
0, 0,
gl.R8, 0,
pageWidth, pageWidth,
pageHeight, pageHeight,
0, gl.RED,
gl.RED, gl.UNSIGNED_BYTE,
gl.UNSIGNED_BYTE, indices,
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 } return { texture, width: pageWidth, height: pageHeight, indexed: true }
} }
@ -563,17 +678,35 @@ export class SpriteRenderer {
} }
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture) gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texImage2D( if (typeof gl.texStorage2D === 'function') {
gl.TEXTURE_2D, if (!this.paletteStorageAllocated) {
0, gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 256, PALETTE_ROWS)
gl.RGBA, this.paletteStorageAllocated = true
256, }
PALETTE_ROWS, gl.texSubImage2D(
0, gl.TEXTURE_2D,
gl.RGBA, 0,
gl.UNSIGNED_BYTE, 0,
rgba, 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.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW) gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount) gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount)
gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y) const camX = this.camera.x
gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight) const camY = this.camera.y
gl.uniform1f(this.uniformZoom, this.camera.zoom) 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.activeTexture(gl.TEXTURE0 + this.paletteUnit)
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture) gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
for (let unit = 0; unit < this.batchTextureCount; unit += 1) { for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
@ -766,6 +918,14 @@ export class SpriteRenderer {
if (this.disposed) return if (this.disposed) return
this.disposed = true 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('webglcontextlost', this.handleContextLost)
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored) this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)

View File

@ -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()
})
})
})