PURPOSE

WebGL2 instanced sprite batch renderer. Draws up to 2048 textured quads per draw call from a shared texture atlas, rendered to a separate offscreen WebGL2 canvas that the game composites onto its main Canvas 2D via ctx.drawImage. Supports normal alpha and additive blend modes per flush. Falls back gracefully (returns false from init) if WebGL2 is unavailable.

OWNS

  • SpriteBatch class — encapsulates one offscreen WebGL2 canvas, shader program, VAO, instance buffer, atlas texture, and per-frame camera state.
  • AtlasRegion interface — UV rectangle (u0, v0, u1, v1) into the atlas texture.
  • Compiled vertex/fragment shader sources (VERT_SRC, FRAG_SRC) — instanced quad with rotation, camera transform, UV interpolation, and atlas-tinted output.
  • Quad corner buffer (6 vertices, static), instance buffer (MAX_SPRITES * FLOATS_PER_INSTANCE floats, dynamic).
  • instanceData: Float32Array — CPU-side scratch for the current frame’s sprite instances.
  • Two module-level singletons: _batch (alpha-blend) and _glows (additive). Module factory functions initSpriteBatch, getSpriteBatch, getGlowBatch.

READS FROM

  • Caller-supplied atlas via uploadAtlas(atlasCanvas) / reuploadAtlas(atlasCanvas) — uploaded as a TEXTURE_2D with LINEAR filtering and CLAMP_TO_EDGE wrap.
  • Per-frame caller state in beginFrame(camX, camY, zoom) — camera position and zoom in CSS-pixel space (must match Camera.toS).
  • Per-sprite caller state in add(x, y, w, h, rotation, region, r, g, b, a) — world position (sprite center), size, radians, AtlasRegion, and unpremultiplied RGBA tint.
  • Viewport dimensions via resize(w, h, dpr) — CSS width/height plus device pixel ratio.

PUSHES TO

  • Its own offscreen HTMLCanvasElement (the canvas public field). The compositor reads it via ctx.drawImage(spriteBatch.canvas, 0, 0).
  • GPU instance buffer via gl.bufferSubData once per flush.
  • GPU framebuffer via gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, spriteCount).

DOES NOT

  • Does not composite to the main canvas. Caller must drawImage the offscreen canvas itself.
  • Does not own or load the atlas image — caller supplies the source canvas.
  • Does not depth-sort, cull, or clip. Sprites draw in insertion order; overflow past MAX_SPRITES is silently dropped.
  • Does not auto-flush. Caller must call flush(mode) to issue the draw, and may call it multiple times per frame (e.g., once per blend mode).
  • Does not retain sprite state across frames. beginFrame zeroes spriteCount; flush also zeroes it after drawing.
  • Does not handle WebGL context loss / restore.
  • Does not premultiply texels — premultiplication of the per-instance color tint happens CPU-side in add (r*a, g*a, b*a, a).

Signals

  • ready (getter) — true once uploadAtlas has been called and a texture exists.
  • initSpriteBatch() returns false if WebGL2 context creation throws.
  • getSpriteBatch() / getGlowBatch() return null until initSpriteBatch succeeds.
  • Shader compile failure throws Error('Shader compile failed: ' + info). Link failure throws Error('Shader link failed: ' + info). Missing WebGL2 throws Error('WebGL2 not available') from the constructor (caught by initSpriteBatch).

Entry points

  • new SpriteBatch() — creates the offscreen canvas, requests a WebGL2 context (alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true), compiles and links shaders, builds the VAO with per-vertex corners and per-instance attributes (posSize, uvRegion, color, rotation; all with vertexAttribDivisor(_, 1)), and sets default blend ONE, ONE_MINUS_SRC_ALPHA.
  • uploadAtlas(atlasCanvas) — first-time atlas upload. Deletes any prior texture, creates a new one, and uploads RGBA pixels.
  • reuploadAtlas(atlasCanvas) — re-uploads pixels into the existing texture (for atlas patching with real sprites after generation).
  • resize(w, h, dpr) — sets backing-store size to round(w*dpr) × round(h*dpr), CSS size to w × h, stores cssW/cssH for the shader uniform, and calls gl.viewport to backing-store size.
  • beginFrame(camX, camY, zoom) — stores camera state, resets spriteCount = 0, clears the WebGL canvas to transparent rgba(0,0,0,0).
  • add(x, y, w, h, rotation, region, r, g, b, a) — writes 13 floats into instanceData at offset spriteCount * 13: x, y, w, h, u0, v0, u1, v1, r*a, g*a, b*a, a, rotation. Increments spriteCount. Drops the sprite if at cap.
  • flush(mode = 'normal') — early-returns if spriteCount === 0. Binds program + VAO, uploads only the used prefix of instanceData via bufferSubData, sets u_viewport to CSS pixels, u_camera and u_zoom, binds the atlas to texture unit 0, sets blend func (ONE, ONE for additive; ONE, ONE_MINUS_SRC_ALPHA for normal), and issues drawArraysInstanced(TRIANGLES, 0, 6, spriteCount). Zeroes spriteCount afterward.
  • flushPendingGL() — diagnostic helper that calls gl.flush() so a subsequent performance.now() reading captures GPU work submitted earlier (used by the gpuSyncBeforeShipInitMs probe in sticker setup).
  • initSpriteBatch() / getSpriteBatch() / getGlowBatch() — module-level factory and accessors for the two singletons.

Pattern notes

  • Two singleton batches: _batch for alpha-blended content (enemies, ship, particles, debris) and _glows for additive content (bullets, ship aura, boss aura, explosion glows). Each owns its own WebGL2 canvas and atlas texture; the glow batch is composited after the alpha batch so additive light layers on top of opaque sprites.
  • Instance layout: 13 floats per sprite — posSize (4) + uvRegion (4) + color (4) + rotation (1). Stride 52 bytes; attribute offsets 0, 16, 32, 48 with all instance attributes using vertexAttribDivisor(_, 1).
  • Quad geometry is two triangles drawn as TRIANGLES (6 vertices) shared across all instances via attribute 0.
  • Vertex shader transforms each per-instance quad: corner re-centered to [-0.5, 0.5], rotated by a_rotation, scaled by a_posSize.zw, translated to world by a_posSize.xy, mapped to screen with (world - u_camera) * u_zoom + u_viewport * 0.5, then to clip space. Y is flipped (1.0 - ...) so Y grows downward in world space, matching the Canvas 2D convention used elsewhere.
  • Fragment shader is texture(u_atlas, v_uv) * v_color — atlas alpha gates the tint, and color is already premultiplied CPU-side.
  • Color premultiplication happens in add (r*a, g*a, b*a, a) to match the context’s premultipliedAlpha: true and the blend func ONE, ONE_MINUS_SRC_ALPHA.
  • preserveDrawingBuffer: true is required because the game reads the WebGL canvas via drawImage after flush; without it the browser may discard the framebuffer between paint cycles.
  • The shader’s u_viewport uniform is set to CSS pixels (cssW, cssH), not backing-store pixels, so the math agrees with the rest of the camera pipeline (Camera.toS). The gl.viewport itself is set to backing-store pixels in resize.
  • Overflow past MAX_SPRITES = 2048 is silently dropped in add. There is no auto-flush-on-overflow.
  • flush can be invoked more than once per frame (e.g., one alpha flush then one additive flush on the glow batch). Each flush re-uploads only the used prefix of the instance buffer.
  • Constructor explicitly disables depth and stencil and turns off antialias — this is a 2D pipeline, ordering is the caller’s responsibility.