/** * 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, for UV maths. */ readonly height: number /** Whether this atlas stores 8-bit palette indices (R8 internal format). */ readonly indexed?: boolean } /** 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 /** * Hardware palette row index for indexed (R8) atlases: * - 0: Normal Act Palette * - 1: Cold / Chilled Blue Tint * - 2: Poisoned Green Tint * - 3: Unique Gold Boss Tint * - 4: Champion / Elite Blue Tint */ readonly paletteRow?: number } /** 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 /** * 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. */ 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, paletteRow. */ const FLOATS_PER_VERTEX = 10 /** Initial batch capacity in quads. */ const INITIAL_CAPACITY = 8192 /** * Texture units the batcher will use at most (reserving 1 unit for the hardware palette texture). */ const MAX_BATCH_TEXTURES = 16 /** Number of precomputed color-shift rows in the 256-color hardware palette texture. */ const PALETTE_ROWS = 5 /** * Build the vertex shader. * * @returns GLSL ES 3.00 source. */ export function vertexShaderSource(): string { return `#version 300 es in vec2 a_position; in vec2 a_uv; in vec4 a_tint; in float a_unit; in float a_palette; 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; flat out int v_palette; void main() { vec2 offset = (a_position - u_camera) * u_zoom; 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); v_palette = int(a_palette); }` } /** * Build the fragment shader for a given texture-unit count. * * Supports both standard RGBA textures (`v_palette < 0`) and 8-bit `R8` palette-indexed * textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`. * * @param units - number of batch samplers to expose. * @param options - compilation options including alphaDiscard. * @returns GLSL ES 3.00 source. */ export function fragmentShaderSource(units: number, options: { alphaDiscard?: boolean } = {}): string { const alphaDiscard = options.alphaDiscard ?? true const cases: string[] = [] for (let unit = 1; unit < units; unit += 1) { cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`) } const paletteLookup = alphaDiscard ? ` if (v_palette >= 0) { int idx = int(texel.r * 255.0 + 0.5); if (idx == 0) discard; texel = texelFetch(u_palette, ivec2(idx, v_palette), 0); }` : ` if (v_palette >= 0) { int idx = int(texel.r * 255.0 + 0.5); texel = idx == 0 ? vec4(0.0) : texelFetch(u_palette, ivec2(idx, v_palette), 0); }` const discardAlpha = alphaDiscard ? '\n if (texel.a == 0.0) discard;' : '' return `#version 300 es precision highp float; in vec2 v_uv; in vec4 v_tint; flat in int v_unit; flat in int v_palette; uniform sampler2D u_textures[${String(units)}]; uniform sampler2D u_palette; out vec4 outColor; void main() { vec4 texel; switch (v_unit) { ${cases.join('\n')} default: texel = texture(u_textures[0], v_uv); break; } ${paletteLookup}${discardAlpha} outColor = texel * v_tint; }` } /** Configure standard 2D texture filtering and clamping parameters. */ function initTextureParams(gl: WebGL2RenderingContext, texture: WebGLTexture): void { gl.bindTexture(gl.TEXTURE_2D, texture) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) } /** Raised when the context or program cannot be created. */ export class RendererError extends Error {} /** * 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 /** Whether the fragment shader discards transparent fragments. */ readonly alphaDiscard: boolean private readonly program: WebGLProgram private readonly vao: WebGLVertexArrayObject private readonly buffer: WebGLBuffer private readonly indexBuffer: WebGLBuffer private atlasTexture: WebGLTexture private readonly whiteTexture: WebGLTexture private readonly paletteTexture: 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 readonly uniformPalette: WebGLUniformLocation | null private vertices: Float32Array private indices: Uint32Array private quadCount = 0 private atlasWidth = 1 private atlasHeight = 1 private atlasStorageAllocated = false private paletteStorageAllocated = false /** Cached uniform values to eliminate redundant uniform uploads across flushes. */ private cachedCameraX = Number.NaN private cachedCameraY = Number.NaN private cachedViewportWidth = Number.NaN private cachedViewportHeight = Number.NaN private cachedZoom = Number.NaN /** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */ private readonly maxBatchTextures: number /** Dedicated texture unit for the 256-color hardware palette texture. */ private readonly paletteUnit: 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 this.alphaDiscard = options.alphaDiscard ?? true const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES const clampedUnits = Math.max(2, Math.min(MAX_BATCH_TEXTURES, availableUnits)) this.paletteUnit = clampedUnits - 1 this.maxBatchTextures = clampedUnits - 1 this.batchTextures = new Array(this.maxBatchTextures).fill(null) this.program = createProgram( gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures, { alphaDiscard: this.alphaDiscard }), ) const vao = gl.createVertexArray() const buffer = gl.createBuffer() const indexBuffer = gl.createBuffer() const atlasTexture = gl.createTexture() const whiteTexture = gl.createTexture() const paletteTexture = gl.createTexture() if ( vao === null || buffer === null || indexBuffer === null || atlasTexture === null || whiteTexture === null || paletteTexture === null ) { throw new RendererError('WebGL2 resource allocation failed') } this.vao = vao this.buffer = buffer this.indexBuffer = indexBuffer this.atlasTexture = atlasTexture this.whiteTexture = whiteTexture this.paletteTexture = paletteTexture this.allocatedTextures.add(atlasTexture) this.allocatedTextures.add(whiteTexture) this.allocatedTextures.add(paletteTexture) 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') const attributePalette = gl.getAttribLocation(this.program, 'a_palette') 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) if (attributeUnit >= 0) { gl.enableVertexAttribArray(attributeUnit) gl.vertexAttribPointer(attributeUnit, 1, gl.FLOAT, false, stride, 32) } if (attributePalette >= 0) { gl.enableVertexAttribArray(attributePalette) gl.vertexAttribPointer(attributePalette, 1, gl.FLOAT, false, stride, 36) } 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]') this.uniformPalette = gl.getUniformLocation(this.program, 'u_palette') 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) if (this.uniformPalette !== null) { gl.uniform1i(this.uniformPalette, this.paletteUnit) } for (const texture of [atlasTexture, whiteTexture, paletteTexture]) { initTextureParams(gl, texture) } gl.bindTexture(gl.TEXTURE_2D, whiteTexture) if (typeof gl.texStorage2D === 'function') { gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1) gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255])) } else { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255])) } // Initialize default 256x5 greyscale palette until setPalette is called const defaultPal = new Uint8Array(256 * 3) for (let i = 0; i < 256; i += 1) { defaultPal[i * 3] = i defaultPal[i * 3 + 1] = i defaultPal[i * 3 + 2] = i } this.setPalette(defaultPal) 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 => { this.cachedCameraX = Number.NaN this.cachedCameraY = Number.NaN this.cachedViewportWidth = Number.NaN this.cachedViewportHeight = Number.NaN this.cachedZoom = Number.NaN this.atlasStorageAllocated = false this.paletteStorageAllocated = false console.info('WebGL2 context restored') options.onContextRestored?.() } 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() const pixels = new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength) if (this.atlasStorageAllocated) { if (this.atlasWidth === atlas.width && this.atlasHeight === atlas.height) { gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.texSubImage2D( gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels, ) return this.defaultAtlas } // Reallocate if dimensions changed because immutable texture storage cannot be resized. if (!gl.isContextLost()) { gl.deleteTexture(this.atlasTexture) } this.allocatedTextures.delete(this.atlasTexture) const newTexture = gl.createTexture() if (newTexture === null) throw new RendererError('texture allocation failed') this.atlasTexture = newTexture this.allocatedTextures.add(newTexture) initTextureParams(gl, newTexture) } gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) if (typeof gl.texStorage2D === 'function') { gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, atlas.width, atlas.height) gl.texSubImage2D( gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels, ) } else { gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels, ) } this.atlasStorageAllocated = true this.atlasWidth = Math.max(atlas.width, 1) this.atlasHeight = Math.max(atlas.height, 1) this.defaultAtlas = { texture: this.atlasTexture, width: this.atlasWidth, height: this.atlasHeight } 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) initTextureParams(gl, texture) if (typeof gl.texStorage2D === 'function') { gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight) if (isBitmap) { gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap) } else { const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number } const pixels = indexed.pixels instanceof Uint8Array ? indexed.pixels : new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength) gl.texSubImage2D( gl.TEXTURE_2D, 0, 0, 0, indexed.width, indexed.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels, ) } } else { if (isBitmap) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap) } else { const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number } const pixels = indexed.pixels instanceof Uint8Array ? indexed.pixels : new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength) gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels, ) } } return { texture, width: pageWidth, height: pageHeight } } /** * Alias for {@link addAtlas} for creating/uploading an atlas page. */ createAtlas( source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }, width?: number, height?: number, ): AtlasHandle { return this.addAtlas(source, width, height) } /** * Upload a raw 8-bit palette-indexed (`gl.R8`, 1 byte per pixel) atlas texture. * * Zero image decoding is performed on the CPU, and GPU memory consumption is * reduced by 75% compared to RGBA8. Color expansion and status tinting are * performed in hardware via `texelFetch(u_palette, ...)`. * * @param indices - raw 8-bit palette index array (`width * height` bytes, 0 = transparent). * @param width - atlas width in pixels. * @param height - atlas height in pixels. * @returns the indexed atlas handle (`indexed: true`). */ addIndexedAtlas(indices: Uint8Array, 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 pageWidth = Math.max(1, width) const pageHeight = Math.max(1, height) if (pageWidth > limit || pageHeight > limit) { throw new RendererError(`indexed 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('indexed texture allocation failed') this.allocatedTextures.add(texture) gl.bindTexture(gl.TEXTURE_2D, texture) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) initTextureParams(gl, texture) if (typeof gl.texStorage2D === 'function') { gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R8, pageWidth, pageHeight) gl.texSubImage2D( gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RED, gl.UNSIGNED_BYTE, indices, ) } else { gl.texImage2D( gl.TEXTURE_2D, 0, gl.R8, pageWidth, pageHeight, 0, gl.RED, gl.UNSIGNED_BYTE, indices, ) } return { texture, width: pageWidth, height: pageHeight, indexed: true } } /** * Upload the 256-colour Act palette and precompute hardware status tint rows: * - Row 0: Normal Act Palette * - Row 1: Cold / Chilled Blue Tint * - Row 2: Poisoned Green Tint * - Row 3: Unique Gold Boss Tint * - Row 4: Champion / Elite Blue Tint * * @param rgb - 768-byte array of 256 RGB triplets (`[r, g, b, ...]`). */ setPalette(rgb: Uint8Array): void { if (this.disposed) return this.flush() const gl = this.gl const rgba = new Uint8Array(256 * PALETTE_ROWS * 4) for (let i = 1; i < 256; i += 1) { const r = rgb[i * 3] ?? 0 const g = rgb[i * 3 + 1] ?? 0 const b = rgb[i * 3 + 2] ?? 0 const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b) // Row 0: Normal Act Palette const r0 = i * 4 rgba[r0] = r rgba[r0 + 1] = g rgba[r0 + 2] = b rgba[r0 + 3] = 255 // Row 1: Cold / Chilled Icy Blue const r1 = (256 + i) * 4 rgba[r1] = Math.min(255, Math.round(r * 0.35)) rgba[r1 + 1] = Math.min(255, Math.round(g * 0.65 + 40)) rgba[r1 + 2] = Math.min(255, Math.round(b * 0.9 + 100)) rgba[r1 + 3] = 255 // Row 2: Poisoned Venom Green const r2 = (256 * 2 + i) * 4 rgba[r2] = Math.min(255, Math.round(r * 0.35)) rgba[r2 + 1] = Math.min(255, Math.round(g * 0.85 + 85)) rgba[r2 + 2] = Math.min(255, Math.round(b * 0.35)) rgba[r2 + 3] = 255 // Row 3: Unique Boss Golden Aura const r3 = (256 * 3 + i) * 4 rgba[r3] = Math.min(255, Math.round(lum * 1.25 + 55)) rgba[r3 + 1] = Math.min(255, Math.round(lum * 0.95 + 25)) rgba[r3 + 2] = Math.min(255, Math.round(lum * 0.35)) rgba[r3 + 3] = 255 // Row 4: Champion / Elite Royal Blue const r4 = (256 * 4 + i) * 4 rgba[r4] = Math.min(255, Math.round(lum * 0.45 + 30)) rgba[r4 + 1] = Math.min(255, Math.round(lum * 0.6 + 45)) rgba[r4 + 2] = Math.min(255, Math.round(lum * 1.3 + 75)) rgba[r4 + 3] = 255 } gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) if (typeof gl.texStorage2D === 'function') { if (!this.paletteStorageAllocated) { gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 256, PALETTE_ROWS) this.paletteStorageAllocated = true } gl.texSubImage2D( gl.TEXTURE_2D, 0, 0, 0, 256, PALETTE_ROWS, gl.RGBA, gl.UNSIGNED_BYTE, rgba, ) } else { gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, 256, PALETTE_ROWS, 0, gl.RGBA, gl.UNSIGNED_BYTE, rgba, ) } } /** * 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, flipping, and hardware palette row. */ 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 const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1 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, paletteRow, ) } /** * 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, -1) } /** * 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. * * @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. */ 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, paletteRow: 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; v[at + 9] = paletteRow 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; v[at + 9] = paletteRow 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; v[at + 9] = paletteRow 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; v[at + 9] = paletteRow 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) gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW) gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount) const camX = this.camera.x const camY = this.camera.y if (camX !== this.cachedCameraX || camY !== this.cachedCameraY) { gl.uniform2f(this.uniformCamera, camX, camY) this.cachedCameraX = camX this.cachedCameraY = camY } const vpWidth = gl.drawingBufferWidth const vpHeight = gl.drawingBufferHeight if (vpWidth !== this.cachedViewportWidth || vpHeight !== this.cachedViewportHeight) { gl.uniform2f(this.uniformViewport, vpWidth, vpHeight) this.cachedViewportWidth = vpWidth this.cachedViewportHeight = vpHeight } const zoom = this.camera.zoom if (zoom !== this.cachedZoom) { gl.uniform1f(this.uniformZoom, zoom) this.cachedZoom = zoom } gl.activeTexture(gl.TEXTURE0 + this.paletteUnit) gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture) for (let unit = 0; unit < this.batchTextureCount; unit += 1) { 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.cachedCameraX = Number.NaN this.cachedCameraY = Number.NaN this.cachedViewportWidth = Number.NaN this.cachedViewportHeight = Number.NaN this.cachedZoom = Number.NaN this.atlasStorageAllocated = false this.paletteStorageAllocated = false this.canvas.removeEventListener('webglcontextlost', this.handleContextLost) this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored) 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 }