diablo2-web/src/render/renderer.ts

1054 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<WebGLTexture>()
/** 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<AtlasHandle | null>(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
}