diablo2-web/src/client/render/renderer.ts

1529 lines
56 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
/** Destination width in pixels (defaults to frame.width). */
readonly width?: number
/** Destination height in pixels (defaults to frame.height). */
readonly height?: number
/** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */
readonly blendMode?: 'normal' | 'additive'
/**
* Optional vertical screen height (in scene pixels) of the sprite's top edge above its ground base.
* Compensates isometric lightmap UV projection for upright geometry (walls, pillars, actors)
* while preserving the 10-float vertex stride invariant.
*/
readonly elevationY?: number
}
/** Dynamic scene lighting configuration for Glide-style illumination. */
export interface LightingConfig {
/** Ambient light color in range 0..1, e.g. [0.03, 0.03, 0.04]. */
readonly ambient: readonly [number, number, number]
/** Sub-tile origin of the 48x48 light grid. */
readonly originSubX: number
readonly originSubY: number
/** Scene pixel origin (e.g. grid.originX, grid.originY). */
readonly sceneOriginX: number
readonly sceneOriginY: number
/** 48x48 RGBA8 lightmap texture buffer (48 * 48 * 4 bytes). If omitted, existing lightmap texture is retained. */
readonly lightmap?: Uint8Array | null
}
/** 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. */
export 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;
uniform vec2 u_sceneOrigin;
uniform vec2 u_lightOrigin;
out vec2 v_uv;
out vec4 v_tint;
out vec2 v_lightUv;
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);
float liftY = floor((a_palette + 1.5) * (1.0 / 16.0));
float rawPalette = a_palette - liftY * 16.0;
v_palette = int(round(rawPalette));
// Inverse isometric projection to ground-plane sub-tiles (compensating for vertical height liftY):
// x_scene = (subX - subY) * 16.0 + originX
// y_scene = (subX + subY) * 8.0 + originY - liftY
vec2 d = vec2(a_position.x, a_position.y + liftY) - u_sceneOrigin;
float subX = (d.x * (1.0 / 16.0) + d.y * (1.0 / 8.0)) * 0.5;
float subY = (d.y * (1.0 / 8.0) - d.x * (1.0 / 16.0)) * 0.5;
// Light grid spans 48 cells * 8 sub-tiles = 384 sub-tiles
v_lightUv = vec2(subX - u_lightOrigin.x, subY - u_lightOrigin.y) * (1.0 / 384.0);
}`
}
/**
* 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`.
* When dynamic lighting is active (`u_lightMode == 1`), modulates normal non-additive fragments
* with bilinear-filtered lightmap samples and ambient light.
*
* @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;
in vec2 v_lightUv;
flat in int v_unit;
flat in int v_palette;
uniform sampler2D u_textures[${String(units)}];
uniform sampler2D u_palette;
uniform sampler2D u_lightmap;
uniform vec4 u_ambient;
uniform int u_lightMode;
uniform int u_blendMode;
out vec4 outColor;
void main() {
vec4 texel;
switch (v_unit) {
${cases.join('\n')}
default: texel = texture(u_textures[0], v_uv); break;
}
${paletteLookup}${discardAlpha}
if (u_lightMode == 1 && u_blendMode == 0) {
vec4 lightSample = texture(u_lightmap, v_lightUv);
vec3 dynamicLight = lightSample.rgb * (0.22 + 0.78 * lightSample.a);
vec3 totalLight = clamp(u_ambient.rgb + dynamicLight, 0.0, 1.0);
outColor = vec4(texel.rgb * totalLight * v_tint.rgb, texel.a * v_tint.a);
} else {
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 {}
/**
* Monotonically increasing generation token paired with an AbortController
* to cancel and discard stale asynchronous scene/hero/monster/lighting loads.
*/
export class LoadGeneration {
private gen = 0
private controller = new AbortController()
get current(): number {
return this.gen
}
get signal(): AbortSignal {
return this.controller.signal
}
next(): { gen: number; signal: AbortSignal } {
this.controller.abort()
this.controller = new AbortController()
return { gen: ++this.gen, signal: this.controller.signal }
}
isCurrent(gen: number): boolean {
return gen === this.gen && !this.controller.signal.aborted
}
invalidate(): void {
this.gen += 1
this.controller.abort()
}
}
/**
* 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
private lightmapTexture: WebGLTexture | null = null
/** 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 readonly uniformLightmap: WebGLUniformLocation | null
private readonly uniformAmbient: WebGLUniformLocation | null
private readonly uniformSceneOrigin: WebGLUniformLocation | null
private readonly uniformLightOrigin: WebGLUniformLocation | null
private readonly uniformLightMode: WebGLUniformLocation | null
private readonly uniformBlendMode: 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
private cachedAmbientR = Number.NaN
private cachedAmbientG = Number.NaN
private cachedAmbientB = Number.NaN
private cachedSceneOriginX = Number.NaN
private cachedSceneOriginY = Number.NaN
private cachedOriginSubX = Number.NaN
private cachedOriginSubY = Number.NaN
private cachedLightMode = -1
private cachedBlendMode = -1
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette and 1 for lightmap). */
private readonly maxBatchTextures: number
/** Dedicated texture unit for the 256-color hardware palette texture. */
private readonly paletteUnit: number
/** Dedicated texture unit for the 48x48 dynamic lightmap texture. */
private readonly lightmapUnit: 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 }
/** Lighting parameters. */
private lightingEnabled = false
private ambientR = 1
private ambientG = 1
private ambientB = 1
private originSubX = 0
private originSubY = 0
private sceneOriginX = 0
private sceneOriginY = 0
/** `drawElements` calls issued since the last {@link begin}. */
private frameDrawCalls = 0
/** Quads submitted since the last {@link begin}. */
private frameQuads = 0
/** Current active blend mode ('normal' or 'additive'). */
private currentBlendMode: 'normal' | 'additive' = 'normal'
/** 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 = typeof gl.getParameter === 'function'
? (gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null)
: null
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
const clampedUnits = Math.max(3, Math.min(MAX_BATCH_TEXTURES, availableUnits))
this.paletteUnit = clampedUnits - 1
this.lightmapUnit = clampedUnits - 2
this.maxBatchTextures = clampedUnits - 2
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 = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_position') : 0
const attributeUv = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_uv') : 1
const attributeTint = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_tint') : 2
const attributeUnit = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_unit') : 3
const attributePalette = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_palette') : 4
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')
this.uniformLightmap = gl.getUniformLocation(this.program, 'u_lightmap')
this.uniformAmbient = gl.getUniformLocation(this.program, 'u_ambient')
this.uniformSceneOrigin = gl.getUniformLocation(this.program, 'u_sceneOrigin')
this.uniformLightOrigin = gl.getUniformLocation(this.program, 'u_lightOrigin')
this.uniformLightMode = gl.getUniformLocation(this.program, 'u_lightMode')
this.uniformBlendMode = gl.getUniformLocation(this.program, 'u_blendMode')
const samplerUnits = new Int32Array(this.maxBatchTextures)
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
gl.useProgram(this.program)
if (typeof gl.uniform1iv === 'function') {
gl.uniform1iv(this.uniformTextures, samplerUnits)
}
if (this.uniformPalette !== null) {
gl.uniform1i(this.uniformPalette, this.paletteUnit)
}
if (this.uniformLightmap !== null) {
gl.uniform1i(this.uniformLightmap, this.lightmapUnit)
}
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)
if (typeof gl.disable === 'function') {
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
}
/** Number of live WebGL textures currently allocated and managed by this renderer. */
get liveTextureCount(): number {
return this.allocatedTextures.size
}
/**
* 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
}
/** Current blending mode ('normal' or 'additive'). */
get blendMode(): 'normal' | 'additive' {
return this.currentBlendMode
}
/**
* Set the blend mode for subsequent quads.
* Flushes any pending quads before switching GPU blend states.
*/
setBlendMode(mode: 'normal' | 'additive'): void {
if (this.disposed) return
if (this.currentBlendMode === mode) return
this.flush()
this.currentBlendMode = mode
const gl = this.gl
if (mode === 'additive') {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
} else {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
}
}
/**
* 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 = typeof gl.getParameter === 'function' ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 4096
if (typeof limit === 'number' && limit > 0 && (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.
const contextLost = typeof gl.isContextLost === 'function' ? gl.isContextLost() : false
if (!contextLost) {
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 | Uint8Array | Uint8ClampedArray | { 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 = typeof gl.getParameter === 'function' ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 4096
const isBitmap = typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap
const isRawBytes = source instanceof Uint8Array || source instanceof Uint8ClampedArray
const sourceWidth = isBitmap
? (source as ImageBitmap).width
: isRawBytes
? (width ?? 1)
: (source as { width: number }).width
const sourceHeight = isBitmap
? (source as ImageBitmap).height
: isRawBytes
? (height ?? 1)
: (source as { height: number }).height
const pageWidth = Math.max(1, width ?? sourceWidth)
const pageHeight = Math.max(1, height ?? sourceHeight)
if (typeof limit === 'number' && limit > 0 && (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 if (isRawBytes) {
const pixels = source instanceof Uint8Array
? source
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
} 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 if (isRawBytes) {
const pixels = source instanceof Uint8Array
? source
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, pageWidth, pageHeight, 0,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
} 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 | Uint8Array | Uint8ClampedArray | { 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 = typeof gl.getParameter === 'function' ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 4096
const pageWidth = Math.max(1, width)
const pageHeight = Math.max(1, height)
if (typeof limit === 'number' && limit > 0 && (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,
)
}
}
/**
* Lazily acquire or allocate the 48x48 RGBA8 dynamic lightmap texture.
*/
private getLightmapTexture(): WebGLTexture {
if (this.lightmapTexture !== null) return this.lightmapTexture
const gl = this.gl
const texture = gl.createTexture()
if (texture === null) throw new RendererError('lightmap texture allocation failed')
this.lightmapTexture = texture
this.allocatedTextures.add(texture)
initTextureParams(gl, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
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)
const emptyLightmap = new Uint8Array(48 * 48 * 4)
if (typeof gl.texStorage2D === 'function') {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 48, 48)
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 48, 48, gl.RGBA, gl.UNSIGNED_BYTE, emptyLightmap)
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 48, 48, 0, gl.RGBA, gl.UNSIGNED_BYTE, emptyLightmap)
}
return texture
}
/**
* Configure dynamic scene lighting.
*
* @param config - lighting configuration, or null to disable lighting.
*/
setLighting(config: LightingConfig | null): void {
if (this.disposed) return
if (config === null) {
if (this.lightingEnabled) {
this.flush()
this.lightingEnabled = false
}
return
}
if (!this.lightingEnabled) {
this.flush()
this.lightingEnabled = true
}
this.ambientR = config.ambient[0]
this.ambientG = config.ambient[1]
this.ambientB = config.ambient[2]
this.originSubX = config.originSubX
this.originSubY = config.originSubY
this.sceneOriginX = config.sceneOriginX
this.sceneOriginY = config.sceneOriginY
if (config.lightmap) {
const gl = this.gl
const texture = this.getLightmapTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, 48, 48,
gl.RGBA, gl.UNSIGNED_BYTE, config.lightmap,
)
}
}
/**
* 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 | number | null | undefined): boolean {
if (this.disposed || !handle || typeof handle !== 'object' || 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
}
const contextLost = typeof this.gl.isContextLost === 'function' ? this.gl.isContextLost() : false
if (!contextLost) {
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, or world X coordinate.
* @param clearOrY - background colour as `[r, g, b]` in 0..1, or world Y coordinate.
*/
begin(
camera: Camera | number,
clearOrY: readonly [number, number, number] | number = [0, 0, 0],
clearFallback: readonly [number, number, number] = [0, 0, 0],
): void {
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl
this.setBlendMode('normal')
if (typeof camera === 'number') {
this.camera = {
x: camera,
y: typeof clearOrY === 'number' ? clearOrY : 0,
zoom: 1,
}
} else {
this.camera = camera
}
const clear = Array.isArray(clearOrY) ? clearOrY : clearFallback
this.quadCount = 0
this.batchTextureCount = 0
this.frameDrawCalls = 0
this.frameQuads = 0
this.lightingEnabled = false
if (this.currentBlendMode !== 'normal') {
this.currentBlendMode = 'normal'
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
}
const vpWidth = gl.drawingBufferWidth ?? this.canvas.width ?? 800
const vpHeight = gl.drawingBufferHeight ?? this.canvas.height ?? 600
gl.viewport(0, 0, vpWidth, vpHeight)
gl.clearColor(clear[0], clear[1], clear[2], 1)
gl.clear(gl.COLOR_BUFFER_BIT)
}
/** Finish a frame by flushing all pending quads. */
end(): void {
this.flush()
}
/**
* 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 blendMode = options.blendMode ?? 'normal'
if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode)
}
const rawPage = options.atlas ?? this.defaultAtlas
const page =
rawPage && typeof rawPage === 'object' && 'texture' in rawPage && typeof rawPage.width === 'number'
? rawPage
: 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
const drawW = options.width ?? frame.width
const drawH = options.height ?? frame.height
this.quad(
x, y,
x + drawW, y,
x, y + drawH,
x + drawW, y + drawH,
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 arbitrarily projected atlas quad with four explicit corners.
*
* Corner order matches index winding:
* - (x0, y0): Top-Left
* - (x1, y1): Top-Right
* - (x2, y2): Bottom-Left
* - (x3, y3): Bottom-Right
*
* Allows authentic isometric perspective ground projection, sheared decals,
* and affine sprite tilting.
*/
drawQuad(
frame: AtlasFrame,
x0: number, y0: number,
x1: number, y1: number,
x2: number, y2: number,
x3: number, y3: number,
options: DrawOptions = {},
): void {
if (this.disposed) return
const blendMode = options.blendMode ?? 'normal'
if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode)
}
const rawPage = options.atlas ?? this.defaultAtlas
const page =
rawPage && typeof rawPage === 'object' && 'texture' in rawPage && typeof rawPage.width === 'number'
? rawPage
: 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
const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0
const topPalette = elevationY > 0 ? paletteRow + elevationY * 16 : paletteRow
this.quad(
x0, y0, x1, y1, x2, y2, x3, y3,
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,
topPalette,
)
}
/**
* 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.
* @param options - optional draw options like blendMode.
*/
drawSolid(
x: number,
y: number,
width: number,
height: number,
color: readonly [number, number, number, number],
options: { blendMode?: 'normal' | 'additive' } = {},
): void {
if (this.disposed) return
const blendMode = options.blendMode ?? 'normal'
if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode)
}
const unit = this.unitFor(this.whiteAtlas)
this.quad(
x, y,
x + width, y,
x, y + height,
x + width, y + height,
0.5, 0.5, 0.5, 0.5,
color[0], color[1], color[2], color[3],
unit, -1,
)
}
/**
* Queue one arbitrarily projected solid color quad with four explicit corners.
*/
drawSolidQuad(
x0: number, y0: number,
x1: number, y1: number,
x2: number, y2: number,
x3: number, y3: number,
color: readonly [number, number, number, number],
options: { blendMode?: 'normal' | 'additive' } = {},
): void {
if (this.disposed) return
const blendMode = options.blendMode ?? 'normal'
if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode)
}
const unit = this.unitFor(this.whiteAtlas)
this.quad(
x0, y0, x1, y1, x2, y2, x3, y3,
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.
*
* Corners:
* - 0: Top-Left (x0, y0, u0, v0)
* - 1: Top-Right (x1, y1, u1, v0)
* - 2: Bottom-Left (x2, y2, u0, v1)
* - 3: Bottom-Right (x3, y3, u1, v1)
*/
private quad(
x0: number, y0: number,
x1: number, y1: number,
x2: number, y2: number,
x3: number, y3: number,
u0: number, v0: number, u1: number, v1: number,
r: number, g: number, b: number, a: number,
unit: number,
paletteRow: number,
topPalette = paletteRow,
): 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] = topPalette
at += FLOATS_PER_VERTEX
// Corner 1: top-right.
v[at] = x1; v[at + 1] = y1; 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] = topPalette
at += FLOATS_PER_VERTEX
// Corner 2: bottom-left.
v[at] = x2; v[at + 1] = y2; 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] = x3; v[at + 1] = y3; 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 ?? this.canvas.width ?? 800
const vpHeight = gl.drawingBufferHeight ?? this.canvas.height ?? 600
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) {
if (typeof gl.uniform1f === 'function') {
gl.uniform1f(this.uniformZoom, zoom)
}
this.cachedZoom = zoom
}
const lightMode = this.lightingEnabled ? 1 : 0
if (lightMode !== this.cachedLightMode && this.uniformLightMode !== null) {
gl.uniform1i(this.uniformLightMode, lightMode)
this.cachedLightMode = lightMode
}
const blendMode = this.currentBlendMode === 'additive' ? 1 : 0
if (blendMode !== this.cachedBlendMode && this.uniformBlendMode !== null) {
gl.uniform1i(this.uniformBlendMode, blendMode)
this.cachedBlendMode = blendMode
}
if (this.lightingEnabled) {
if (
(this.ambientR !== this.cachedAmbientR ||
this.ambientG !== this.cachedAmbientG ||
this.ambientB !== this.cachedAmbientB) &&
this.uniformAmbient !== null
) {
if (typeof gl.uniform4f === 'function') {
gl.uniform4f(this.uniformAmbient, this.ambientR, this.ambientG, this.ambientB, 1.0)
}
this.cachedAmbientR = this.ambientR
this.cachedAmbientG = this.ambientG
this.cachedAmbientB = this.ambientB
}
if (
(this.sceneOriginX !== this.cachedSceneOriginX ||
this.sceneOriginY !== this.cachedSceneOriginY) &&
this.uniformSceneOrigin !== null
) {
gl.uniform2f(this.uniformSceneOrigin, this.sceneOriginX, this.sceneOriginY)
this.cachedSceneOriginX = this.sceneOriginX
this.cachedSceneOriginY = this.sceneOriginY
}
if (
(this.originSubX !== this.cachedOriginSubX ||
this.originSubY !== this.cachedOriginSubY) &&
this.uniformLightOrigin !== null
) {
gl.uniform2f(this.uniformLightOrigin, this.originSubX, this.originSubY)
this.cachedOriginSubX = this.originSubX
this.cachedOriginSubY = this.originSubY
}
}
gl.activeTexture(gl.TEXTURE0 + this.paletteUnit)
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
if (this.lightingEnabled) {
const lightmap = this.getLightmapTexture()
gl.activeTexture(gl.TEXTURE0 + this.lightmapUnit)
gl.bindTexture(gl.TEXTURE_2D, lightmap)
}
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)
}
if (typeof gl.drawElements === 'function') {
gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0)
} else if (typeof gl.drawArrays === 'function') {
gl.drawArrays(gl.TRIANGLES, 0, this.quadCount * INDICES_PER_QUAD)
}
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.cachedAmbientR = Number.NaN
this.cachedAmbientG = Number.NaN
this.cachedAmbientB = Number.NaN
this.cachedSceneOriginX = Number.NaN
this.cachedSceneOriginY = Number.NaN
this.cachedOriginSubX = Number.NaN
this.cachedOriginSubY = Number.NaN
this.cachedLightMode = -1
this.cachedBlendMode = -1
this.atlasStorageAllocated = false
this.paletteStorageAllocated = false
this.lightmapTexture = null
this.canvas.removeEventListener?.('webglcontextlost', this.handleContextLost)
this.canvas.removeEventListener?.('webglcontextrestored', this.handleContextRestored)
const gl = this.gl
const contextLost = typeof gl.isContextLost === 'function' ? gl.isContextLost() : false
if (!contextLost) {
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')
if (typeof gl.detachShader === 'function') {
gl.detachShader(program, vs)
}
gl.deleteShader(vs)
if (typeof gl.detachShader === 'function') {
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
}
export { SpriteRenderer as WebGLRenderer2D }