1054 lines
39 KiB
TypeScript
1054 lines
39 KiB
TypeScript
/**
|
||
* 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
|
||
}
|