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