diablo2-web/src/render/renderer.ts

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