/** * WebGL2 sprite renderer: one indexed quad batcher with per-quad tint that can * sample several atlas pages inside a single draw call. * * Diablo-style rendering is a flood of axis-aligned quads — floors, walls, * units, items — drawn in painter's order. So the renderer accumulates * world-space quads (position, source rectangle, tint) and submits them in as * few calls as it can. Depth ordering is the caller's job (draw order), not the * GPU's, which is how the original games did it and what keeps the batcher * trivial. * * Three properties keep the batch big and the CPU cost low: * * - **Multi-texture batching.** A Diablo level needs more tiles than one 2048² * page holds, and painter's order interleaves those pages constantly: sorting * by texture is not allowed, because that is exactly the order that makes the * scene correct. Binding one texture per batch therefore used to split the * frame into hundreds of draw calls (measured: up to 3077 on * `act4/107-act-4-lava-1`). Instead every page in flight gets its own texture * unit, the unit index rides along as a vertex attribute, and the fragment * shader picks the sampler — so painter's order survives *and* the whole frame * is usually one `drawElements`. Only when a batch needs more distinct pages * than the hardware has units does it flush and start over. * - **Indexed geometry.** Four vertices per quad instead of six, with a static * element buffer, so a third less vertex data crosses the bus per frame. * - **Zero-allocation vertex writes.** {@link SpriteRenderer.quad} writes the * vertex floats straight out, unrolled. The earlier version built four * throwaway arrays per quad, which at ~2800 quads a frame meant ~11k * short-lived objects per frame handed to the GC. * * Tint is a vertex attribute rather than a uniform, so coloured geometry * (a floor rectangle, a wall) batches together with sprites instead of forcing * a flush per colour. */ import type { AtlasFrame, SpriteAtlas } from './atlas.ts' /** Camera state for one frame. */ export interface Camera { /** World-space centre of the view. */ readonly x: number /** World-space centre of the view. */ readonly y: number /** Pixels per world unit. */ readonly zoom: number } /** * One uploaded atlas texture. * * Diablo II maps need more tiles than a single 2048² page holds, so the renderer * keeps several textures. Each quad records which page it samples; the batcher * assigns pages to texture units so a page switch no longer splits the batch * (see {@link SpriteRenderer.draw}). */ export interface AtlasHandle { /** The GPU texture. */ readonly texture: WebGLTexture /** Texture width in pixels, for UV maths. */ readonly width: number /** Texture height in pixels. */ readonly height: number } /** Draw options for one sprite quad. */ export interface DrawOptions { /** Multiplicative tint, defaulting to opaque white. */ readonly tint?: readonly [number, number, number, number] /** Mirror horizontally. */ readonly flipX?: boolean /** Atlas page to sample; defaults to the most recently set atlas. */ readonly atlas?: AtlasHandle } /** Configuration options and event callbacks for SpriteRenderer. */ export interface RendererOptions { /** Optional callback invoked when the WebGL context is lost. */ readonly onContextLost?: (event: Event) => void /** Optional callback invoked when the WebGL context is restored. */ readonly onContextRestored?: () => void } /** Vertices per quad: the four corners, reused by the index buffer. */ const VERTICES_PER_QUAD = 4 /** Indices per quad (two triangles). */ const INDICES_PER_QUAD = 6 /** Floats per vertex: x, y, u, v, r, g, b, a, unit. */ const FLOATS_PER_VERTEX = 9 /** Initial batch capacity in quads. */ const INITIAL_CAPACITY = 8192 /** * Texture units the batcher will use at most. * * WebGL2 guarantees `MAX_TEXTURE_IMAGE_UNITS >= 16`, and the packs top out at * ten pages for a level (`act5/109-act-5-town-townwest`) plus one character * atlas, so sixteen is enough to hold an entire frame's pages at once. The real * limit is still queried at construction and respected. */ const MAX_BATCH_TEXTURES = 16 /** * Build the vertex shader. * * @returns GLSL ES 3.00 source. */ function vertexShaderSource(): string { return `#version 300 es in vec2 a_position; in vec2 a_uv; in vec4 a_tint; in float a_unit; uniform vec2 u_camera; uniform vec2 u_viewport; uniform float u_zoom; out vec2 v_uv; out vec4 v_tint; flat out int v_unit; void main() { vec2 offset = (a_position - u_camera) * u_zoom; // 'half' is a reserved word in GLSL ES 3.00, hence the longer name. vec2 halfViewport = u_viewport * 0.5; gl_Position = vec4(offset.x / halfViewport.x, -offset.y / halfViewport.y, 0.0, 1.0); v_uv = a_uv; v_tint = a_tint; v_unit = int(a_unit); }` } /** * Build the fragment shader for a given texture-unit count. * * GLSL ES 3.00 forbids indexing a sampler array with a non-constant expression, * so the sampler is picked by a `switch` over constant cases. The case list is * generated to match however many units the context actually offers. * * @param units - number of samplers to expose. * @returns GLSL ES 3.00 source. */ function fragmentShaderSource(units: number): string { const cases: string[] = [] for (let unit = 1; unit < units; unit += 1) { cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`) } return `#version 300 es precision mediump float; in vec2 v_uv; in vec4 v_tint; flat in int v_unit; uniform sampler2D u_textures[${String(units)}]; out vec4 outColor; void main() { vec4 texel; switch (v_unit) { ${cases.join('\n')} default: texel = texture(u_textures[0], v_uv); break; } if (texel.a == 0.0) discard; outColor = texel * v_tint; }` } /** Raised when the context or program cannot be created. */ export class RendererError extends Error {} /** * A quad batcher bound to one canvas. */ export class SpriteRenderer { /** The target canvas. */ readonly canvas: HTMLCanvasElement /** The WebGL2 context, exposed for the few callers that need raw access. */ readonly gl: WebGL2RenderingContext private readonly program: WebGLProgram private readonly vao: WebGLVertexArrayObject private readonly buffer: WebGLBuffer private readonly indexBuffer: WebGLBuffer private readonly atlasTexture: WebGLTexture private readonly whiteTexture: WebGLTexture /** The 1×1 white page used by `drawSolid`. */ private readonly whiteAtlas: AtlasHandle /** The default page (what `setAtlas` uploads into). */ private defaultAtlas: AtlasHandle private readonly uniformCamera: WebGLUniformLocation private readonly uniformViewport: WebGLUniformLocation private readonly uniformZoom: WebGLUniformLocation private readonly uniformTextures: WebGLUniformLocation private vertices: Float32Array private indices: Uint32Array private quadCount = 0 private atlasWidth = 1 private atlasHeight = 1 /** Texture units this context offers the batcher. */ private readonly maxBatchTextures: number /** Pages bound to units `0..batchTextureCount-1` for the pending batch. */ private readonly batchTextures: (AtlasHandle | null)[] private batchTextureCount = 0 private camera: Camera = { x: 0, y: 0, zoom: 1 } /** `drawElements` calls issued since the last {@link begin}. */ private frameDrawCalls = 0 /** Quads submitted since the last {@link begin}. */ private frameQuads = 0 /** All textures allocated and managed by this renderer. */ private readonly allocatedTextures = new Set() /** Whether this renderer has been disposed. */ private disposed = false /** Listener references for cleanup. */ private readonly handleContextLost: (event: Event) => void private readonly handleContextRestored: () => void /** * @param canvas - the canvas to render into. * @param options - optional lifecycle hooks and configuration. */ constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { this.canvas = canvas const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false }) if (gl === null) throw new RendererError('WebGL2 is not available in this browser') this.gl = gl const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null // WebGL2 guarantees at least 16; clamp to at least 1 so a stubbed context // (the lifecycle tests) still produces a valid shader. const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES this.maxBatchTextures = Math.max(1, Math.min(MAX_BATCH_TEXTURES, availableUnits)) this.batchTextures = new Array(this.maxBatchTextures).fill(null) this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures)) const vao = gl.createVertexArray() const buffer = gl.createBuffer() const indexBuffer = gl.createBuffer() const atlasTexture = gl.createTexture() const whiteTexture = gl.createTexture() if (vao === null || buffer === null || indexBuffer === null || atlasTexture === null || whiteTexture === null) { throw new RendererError('WebGL2 resource allocation failed') } this.vao = vao this.buffer = buffer this.indexBuffer = indexBuffer this.atlasTexture = atlasTexture this.whiteTexture = whiteTexture this.allocatedTextures.add(atlasTexture) this.allocatedTextures.add(whiteTexture) this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 } this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 } this.vertices = new Float32Array(INITIAL_CAPACITY * VERTICES_PER_QUAD * FLOATS_PER_VERTEX) this.indices = buildQuadIndices(INITIAL_CAPACITY) const attributePosition = gl.getAttribLocation(this.program, 'a_position') const attributeUv = gl.getAttribLocation(this.program, 'a_uv') const attributeTint = gl.getAttribLocation(this.program, 'a_tint') const attributeUnit = gl.getAttribLocation(this.program, 'a_unit') gl.bindVertexArray(vao) gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW) const stride = FLOATS_PER_VERTEX * 4 gl.enableVertexAttribArray(attributePosition) gl.vertexAttribPointer(attributePosition, 2, gl.FLOAT, false, stride, 0) gl.enableVertexAttribArray(attributeUv) gl.vertexAttribPointer(attributeUv, 2, gl.FLOAT, false, stride, 8) gl.enableVertexAttribArray(attributeTint) gl.vertexAttribPointer(attributeTint, 4, gl.FLOAT, false, stride, 16) // A stubbed context can report -1 for an attribute it does not know about; // enabling that location would be an INVALID_VALUE. if (attributeUnit >= 0) { gl.enableVertexAttribArray(attributeUnit) gl.vertexAttribPointer(attributeUnit, 1, gl.FLOAT, false, stride, 32) } gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW) gl.bindVertexArray(null) this.uniformCamera = requireUniform(gl, this.program, 'u_camera') this.uniformViewport = requireUniform(gl, this.program, 'u_viewport') this.uniformZoom = requireUniform(gl, this.program, 'u_zoom') this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]') // The sampler array never changes: unit i always reads texture unit i. const samplerUnits = new Int32Array(this.maxBatchTextures) for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit gl.useProgram(this.program) gl.uniform1iv(this.uniformTextures, samplerUnits) for (const texture of [atlasTexture, whiteTexture]) { 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) } 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])) gl.enable(gl.BLEND) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) gl.disable(gl.DEPTH_TEST) this.handleContextLost = (event: Event): void => { event.preventDefault() console.warn('WebGL2 context lost') options.onContextLost?.(event) } this.handleContextRestored = (): void => { console.info('WebGL2 context restored') options.onContextRestored?.() } canvas.addEventListener('webglcontextlost', this.handleContextLost) canvas.addEventListener('webglcontextrestored', this.handleContextRestored) } /** Whether this renderer and its GPU resources have been disposed. */ get isDisposed(): boolean { return this.disposed } /** * Draw calls issued since the last {@link begin}. * * This is the real measured count, not an estimate: the HUD used to report a * hardcoded `1` while the frame was actually spending hundreds of calls, which * is precisely why the cost stayed invisible. */ get drawCalls(): number { return this.frameDrawCalls } /** Quads submitted since the last {@link begin}. */ get quadsSubmitted(): number { return this.frameQuads } /** Texture units this batcher can bind at once. */ get textureUnits(): number { return this.maxBatchTextures } /** * Upload an atlas, replacing the current one. * * @param atlas - the packed atlas. * @returns the handle other pages can be drawn with. */ setAtlas(atlas: SpriteAtlas): AtlasHandle { if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed') const gl = this.gl // Uploading past the limit is a silent INVALID_VALUE in GL; say what // actually went wrong instead. const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number if (atlas.width > limit || atlas.height > limit) { throw new RendererError( `atlas ${String(atlas.width)}x${String(atlas.height)} exceeds the ${String(limit)}px texture limit`, ) } this.flush() gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength), ) this.atlasWidth = Math.max(atlas.width, 1) this.atlasHeight = Math.max(atlas.height, 1) this.defaultAtlas = { texture: this.atlasTexture, width: this.atlasWidth, height: this.atlasHeight } return this.defaultAtlas } /** * Upload an extra atlas page and return a handle for it. * * Packed maps ship several pages; the page can load the ones the spawn area * needs first and hand them over as they arrive, instead of waiting for one * gigantic texture to decode. * * @param source - decoded pixels or an `ImageBitmap` (PNG straight from cache). * @param width - page width in pixels. * @param height - page height in pixels. * @returns the handle to pass to {@link draw}. */ addAtlas( source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }, width?: number, height?: number, ): AtlasHandle { if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed') const gl = this.gl const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number const isBitmap = typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap const sourceWidth = isBitmap ? (source as ImageBitmap).width : (source as { width: number }).width const sourceHeight = isBitmap ? (source as ImageBitmap).height : (source as { height: number }).height const pageWidth = Math.max(1, width ?? sourceWidth) const pageHeight = Math.max(1, height ?? sourceHeight) if (pageWidth > limit || pageHeight > limit) { throw new RendererError(`atlas ${String(pageWidth)}x${String(pageHeight)} exceeds the ${String(limit)}px texture limit`) } this.flush() const texture = gl.createTexture() if (texture === null) throw new RendererError('texture allocation failed') this.allocatedTextures.add(texture) gl.bindTexture(gl.TEXTURE_2D, texture) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) if (isBitmap) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap) } else { const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number } const pixels = indexed.pixels instanceof Uint8Array ? indexed.pixels : new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength) gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels, ) } return { texture, width: pageWidth, height: pageHeight } } /** * Release an atlas texture handle and free its GPU memory. * * @param handle - the atlas handle previously returned by addAtlas or setAtlas. * @returns true if the texture was managed by this renderer and deleted, false otherwise. */ deleteAtlas(handle: AtlasHandle): boolean { if (this.disposed || handle === this.whiteAtlas) return false if (!this.allocatedTextures.has(handle.texture)) return false // Quads already queued may reference this page's unit, so they have to go // out before the texture disappears. for (let unit = 0; unit < this.batchTextureCount; unit += 1) { if (this.batchTextures[unit] === handle) { this.flush() break } } if (this.defaultAtlas === handle) { this.defaultAtlas = this.whiteAtlas } if (!this.gl.isContextLost()) { this.gl.deleteTexture(handle.texture) } this.allocatedTextures.delete(handle.texture) return true } /** The page `setAtlas` uploaded into, for callers that draw with handles. */ get defaultAtlasHandle(): AtlasHandle { return this.defaultAtlas } /** * Start a frame: clear, and record the camera. * * @param camera - camera to render with. * @param clear - background colour as `[r, g, b]` in 0..1. */ begin(camera: Camera, clear: readonly [number, number, number] = [0, 0, 0]): void { if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed') const gl = this.gl this.camera = camera this.quadCount = 0 this.batchTextureCount = 0 this.frameDrawCalls = 0 this.frameQuads = 0 gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight) gl.clearColor(clear[0], clear[1], clear[2], 1) gl.clear(gl.COLOR_BUFFER_BIT) } /** * Queue one atlas sprite. * * @param frame - the atlas placement to draw. * @param x - world-space left edge. * @param y - world-space top edge. * @param options - tint and flipping. */ draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void { if (this.disposed) return const page = options.atlas ?? this.defaultAtlas const unit = this.unitFor(page) const u0 = frame.x / page.width const v0 = frame.y / page.height const u1 = (frame.x + frame.width) / page.width const v1 = (frame.y + frame.height) / page.height const tint = options.tint const flipped = options.flipX === true this.quad( x, y, x + frame.width, y + frame.height, flipped ? u1 : u0, v0, flipped ? u0 : u1, v1, tint === undefined ? 1 : tint[0], tint === undefined ? 1 : tint[1], tint === undefined ? 1 : tint[2], tint === undefined ? 1 : tint[3], unit, ) } /** * Queue one solid-colour rectangle. * * @param x - world-space left edge. * @param y - world-space top edge. * @param width - rectangle width. * @param height - rectangle height. * @param color - `[r, g, b, a]` in 0..1. */ drawSolid(x: number, y: number, width: number, height: number, color: readonly [number, number, number, number]): void { if (this.disposed) return const unit = this.unitFor(this.whiteAtlas) this.quad(x, y, x + width, y + height, 0.5, 0.5, 0.5, 0.5, color[0], color[1], color[2], color[3], unit) } /** * Find the texture unit a page is bound to for the pending batch, binding it * to a free unit if this is its first quad. * * Running out of units is the only thing that still splits a batch, and with * sixteen of them against a ten-page worst case that effectively never * happens for a map. * * @param page - the atlas page the caller wants to sample. * @returns the texture unit index to write into the vertices. */ private unitFor(page: AtlasHandle): number { const count = this.batchTextureCount for (let unit = 0; unit < count; unit += 1) { if (this.batchTextures[unit] === page) return unit } if (count === this.maxBatchTextures) { this.flush() this.batchTextures[0] = page this.batchTextureCount = 1 return 0 } this.batchTextures[count] = page this.batchTextureCount = count + 1 return count } /** * Append one quad's four vertices. * * Written out longhand on purpose: the readable loop-over-temporary-arrays * version allocated four arrays per quad, and at a few thousand quads a frame * that dominated the CPU side of the frame (measured 2.3× slower). * * @param x0 - left edge. * @param y0 - top edge. * @param x1 - right edge. * @param y1 - bottom edge. * @param u0 - left texture coordinate. * @param v0 - top texture coordinate. * @param u1 - right texture coordinate. * @param v1 - bottom texture coordinate. * @param r - tint red. * @param g - tint green. * @param b - tint blue. * @param a - tint alpha. * @param unit - texture unit to sample. */ private quad( x0: number, y0: number, x1: number, y1: number, u0: number, v0: number, u1: number, v1: number, r: number, g: number, b: number, a: number, unit: number, ): void { const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX if (needed > this.vertices.length) this.grow(needed) const v = this.vertices let at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX // Corner 0: top-left. v[at] = x0; v[at + 1] = y0; v[at + 2] = u0; v[at + 3] = v0 v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit at += FLOATS_PER_VERTEX // Corner 1: top-right. v[at] = x1; v[at + 1] = y0; v[at + 2] = u1; v[at + 3] = v0 v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit at += FLOATS_PER_VERTEX // Corner 2: bottom-left. v[at] = x0; v[at + 1] = y1; v[at + 2] = u0; v[at + 3] = v1 v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit at += FLOATS_PER_VERTEX // Corner 3: bottom-right. v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v1 v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit this.quadCount += 1 this.frameQuads += 1 } /** Submit every queued quad. */ flush(): void { const gl = this.gl if (this.disposed || this.quadCount === 0) return const floatCount = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX gl.useProgram(this.program) gl.bindVertexArray(this.vao) gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) // Orphan first: without it the driver has to wait until the GPU is done // reading the range this overwrites, which is a pipeline stall on every // flush. Handing back a fresh block lets both sides run. gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW) gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount) gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y) gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight) gl.uniform1f(this.uniformZoom, this.camera.zoom) for (let unit = 0; unit < this.batchTextureCount; unit += 1) { const page = this.batchTextures[unit] if (page === undefined || page === null) continue gl.activeTexture(gl.TEXTURE0 + unit) gl.bindTexture(gl.TEXTURE_2D, page.texture) } gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0) this.frameDrawCalls += 1 this.quadCount = 0 this.batchTextureCount = 0 } /** * Destroy all WebGL2 GPU resources (textures, VAO, VBO, program) and detach event listeners. */ dispose(): void { if (this.disposed) return this.disposed = true this.canvas.removeEventListener('webglcontextlost', this.handleContextLost) this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored) const gl = this.gl if (!gl.isContextLost()) { for (const texture of this.allocatedTextures) { gl.deleteTexture(texture) } this.allocatedTextures.clear() gl.deleteBuffer(this.buffer) gl.deleteBuffer(this.indexBuffer) gl.deleteVertexArray(this.vao) gl.deleteProgram(this.program) } else { this.allocatedTextures.clear() } this.quadCount = 0 this.batchTextureCount = 0 } /** * Grow the vertex and index buffers, preserving queued geometry. * * @param needed - required float count. */ private grow(needed: number): void { let capacity = this.vertices.length while (capacity < needed) capacity *= 2 const grown = new Float32Array(capacity) grown.set(this.vertices) this.vertices = grown const quads = Math.floor(capacity / (VERTICES_PER_QUAD * FLOATS_PER_VERTEX)) this.indices = buildQuadIndices(quads) const gl = this.gl gl.bindVertexArray(this.vao) gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) gl.bufferData(gl.ARRAY_BUFFER, grown.byteLength, gl.DYNAMIC_DRAW) gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW) gl.bindVertexArray(null) } } /** * Build the static index buffer contents for a quad capacity. * * Every quad is two triangles over its four corners: top-left, top-right, * bottom-left and bottom-right, wound the same way the old six-vertex layout * was so nothing about the rasterised result changes. * * @param quads - number of quads to cover. * @returns indices, `6 * quads` long. */ function buildQuadIndices(quads: number): Uint32Array { const indices = new Uint32Array(quads * INDICES_PER_QUAD) for (let quad = 0; quad < quads; quad += 1) { const vertex = quad * VERTICES_PER_QUAD const at = quad * INDICES_PER_QUAD indices[at] = vertex indices[at + 1] = vertex + 1 indices[at + 2] = vertex + 2 indices[at + 3] = vertex + 1 indices[at + 4] = vertex + 3 indices[at + 5] = vertex + 2 } return indices } /** * Compile and link a program, reporting shader logs on failure. * * Shaders are detached and flagged for deletion immediately after linking to * prevent driver-level memory leaks. * * @param gl - the context. * @param vertexSource - vertex shader source. * @param fragmentSource - fragment shader source. * @returns the linked program. */ function createProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram { const compile = (type: number, source: string): WebGLShader => { const shader = gl.createShader(type) if (shader === null) throw new RendererError('could not create shader') gl.shaderSource(shader, source) gl.compileShader(shader) if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) { const log = gl.getShaderInfoLog(shader) ?? 'unknown' gl.deleteShader(shader) throw new RendererError(`shader compile failed: ${log}`) } return shader } const program = gl.createProgram() if (program === null) throw new RendererError('could not create program') const vs = compile(gl.VERTEX_SHADER, vertexSource) const fs = compile(gl.FRAGMENT_SHADER, fragmentSource) gl.attachShader(program, vs) gl.attachShader(program, fs) gl.linkProgram(program) const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true const log = linked ? '' : (gl.getProgramInfoLog(program) ?? 'unknown') gl.detachShader(program, vs) gl.deleteShader(vs) gl.detachShader(program, fs) gl.deleteShader(fs) if (!linked) { gl.deleteProgram(program) throw new RendererError(`program link failed: ${log}`) } return program } /** * Fetch a uniform location or fail loudly. * * @param gl - the context. * @param program - the linked program. * @param name - uniform name. * @returns the location. */ function requireUniform(gl: WebGL2RenderingContext, program: WebGLProgram, name: string): WebGLUniformLocation { const location = gl.getUniformLocation(program, name) if (location === null) throw new RendererError(`uniform ${name} is missing from the program`) return location }