1529 lines
56 KiB
TypeScript
1529 lines
56 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
|
||
/** 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 }
|
||
|