sprite-batch-array.ts

PURPOSE

Workbench-only WebGL2 TEXTURE_2D_ARRAY instanced sprite batcher. One atlas layer per baked component; per-instance attributes carry layerIndex + frameIndex so the vertex shader resolves UV into the correct tile within that layer’s grid. Decoupled from production src/starship-survivors/engine/rendering/sprite-batch.ts (does NOT touch it).

OWNS

  • SpriteAdd interface — per-sprite struct: x, y, w, h, rot (radians), layerIndex, frameIndex, gridCols, gridRows, tint (RGBA tuple).
  • SpriteBatchArray class:
    • canvas: HTMLCanvasElement | OffscreenCanvas — auto-selected backend.
    • instanceCount: number — public counter, current frame’s sprite count.
    • width, height — viewport size (constructor args, stored).
    • Private: gl, program, vao, quadVbo, instanceVbo, instanceData: Float32Array, uViewSize, uAtlas, atlasTex.
  • Constants:
    • MAX_SPRITES = 4096.
    • FLOATS_PER_INSTANCE = 14 (pos2 + size2 + rot1 + layer1 + frame1 + grid2 + tint4 + pad1).
    • QUAD_VERTICES — unit quad in TRIANGLE_STRIP order with interleaved aQuad(xy) + aUv(xy).
  • GLSL ES 3.00 vertex + fragment shaders inlined as string constants (VERT_SHADER, FRAG_SHADER).
  • compileShader(gl, type, src) — module-local helper that throws on COMPILE_STATUS failure.

READS FROM

  • WebGL2RenderingContext obtained from canvas.getContext('webgl2'); throws WebGL2 unavailable if null.
  • globalThis.document (presence detection) — picks OffscreenCanvas when absent and OffscreenCanvas is defined.
  • Caller-supplied SpriteAdd records pushed via add(...).
  • Caller-supplied raw Uint8Array RGBA pixel data passed to uploadLayer(...).

PUSHES TO

  • WebGL2 GPU state via this.gl:
    • Quad VBO (STATIC_DRAW) — locs 0 (aQuad vec2), 1 (aUv vec2), stride 16 bytes.
    • Instance VBO (DYNAMIC_DRAW, sized MAX_SPRITES * FLOATS_PER_INSTANCE * 4 bytes) — locs 2–8, vertexAttribDivisor(loc, 1):
      • 2: iPos vec2
      • 3: iSize vec2
      • 4: iRot float
      • 5: iLayer float
      • 6: iFrame float
      • 7: iGrid vec2
      • 8: iTint vec4
      • +1 pad float at end of each instance (intentionally unused).
    • TEXTURE_2D_ARRAY atlas (RGBA8, LINEAR min/mag, CLAMP_TO_EDGE wrap S/T) created in createEmptyAtlas or assigned via setAtlasTexture.
    • Uniforms: u_viewSize (vec2 px), u_atlas (sampler2DArray, unit 0).
    • Blend state: gl.BLEND enabled, blendFunc(ONE, ONE_MINUS_SRC_ALPHA) (premultiplied alpha).
    • Draw: drawArraysInstanced(TRIANGLE_STRIP, 0, 4, instanceCount).

DOES NOT

  • Does not touch src/starship-survivors/engine/rendering/sprite-batch.ts (production batcher) — workbench-isolated by design.
  • Does not allocate or own a camera; world-px → NDC conversion is (world / u_viewSize) * 2.0, origin at viewport center, Y up — no camera offset or zoom.
  • Does not request clear() or set a clear color; caller manages framebuffer clearing.
  • Does not mipmap the atlas; LINEAR filter only.
  • Does not validate frameIndex against gridCols * gridRows — out-of-range frames sample whatever the grid math resolves to.
  • Does not auto-grow past MAX_SPRITES; add() silently drops sprites once instanceCount >= 4096.
  • Does not handle context loss / restore.
  • Does not bake atlas pixel data — accepts pre-baked Uint8Array per layer.

Signals

  • beginFrame() → resets instanceCount = 0.
  • add(s: SpriteAdd) → appends one instance; no-op if at capacity.
  • flush() → uploads instanceData[0 .. instanceCount * FLOATS_PER_INSTANCE] via bufferSubData, binds program/VAO/atlas, sets uniforms + blend, issues one drawArraysInstanced, resets instanceCount = 0.
  • setAtlasTexture(tex) → adopts an externally-created atlas (does not delete prior atlasTex).
  • createEmptyAtlas(tileSize, gridCols, gridRows, layerCount) → allocates RGBA8 TEXTURE_2D_ARRAY sized tileSize*gridCols × tileSize*gridRows × layerCount, stores + returns it.
  • uploadLayer(layerIndex, tileSize, gridCols, gridRows, pixels)texSubImage3D into one layer; throws if no atlas.
  • destroy() → deletes program, both buffers, VAO, and atlas texture if owned.

Entry points

  • new SpriteBatchArray(width, height) — only public entry point. Picks canvas backend, acquires WebGL2, compiles program, allocates VBOs/VAO, allocates Float32Array(MAX_SPRITES * 14) CPU-side instance buffer.
  • Typical caller flow (workbench):
    1. const sb = new SpriteBatchArray(W, H).
    2. sb.createEmptyAtlas(tileSize, gridCols, gridRows, N) once.
    3. sb.uploadLayer(i, tileSize, gridCols, gridRows, bytes) per baked component.
    4. Per frame: beginFrame() → many add(...)flush().
    5. On teardown: destroy().

Pattern notes

  • Single draw call per flush() — all sprites batched regardless of which atlas layer they sample, because the layer index is a per-instance attribute, not a shader-program switch.
  • Vertex shader resolves the tile by col = mod(iFrame, iGrid.x), row = floor(iFrame / iGrid.x), then tileUv = ((col,row) + aUv) / iGrid — UVs span [0,1] across the layer; the grid math carves it into gridCols × gridRows tiles. vUvLayer = vec3(tileUv, iLayer) feeds the sampler2DArray.
  • NDC mapping puts world origin at viewport center with Y up. Caller passes already-world-space sprite centers; no camera transform applied here.
  • Premultiplied alpha is assumed for the source pixels — blendFunc(ONE, ONE_MINUS_SRC_ALPHA) is the conventional premult composite.
  • Padding float at instance offset 13 keeps the per-instance struct at 14 floats (56 bytes) — convenient round number; no alignment requirement enforced.
  • Throws (no silent fallbacks): no canvas backend, no WebGL2, shader compile failure, program link failure, buffer/VAO/texture creation failure, uploadLayer before createEmptyAtlas.
  • Workbench-only: this file is a parallel implementation; production rendering still flows through engine/rendering/sprite-batch.ts.

EXTRACT-CANDIDATE

  • The compile/link helpers (compileShader, the gl.createProgram + link + check block in initProgram) duplicate logic that likely also lives in production WebGL paths. Candidate for a engine/rendering/gl-shader-utils.ts shared module once the workbench path proves out.
  • The per-instance attribute layout (locs 2–8 with vertexAttribDivisor=1) and the FLOATS_PER_INSTANCE packing convention could be lifted into a typed InstanceLayout descriptor that both this batcher and the production batcher consume — reduces drift between the two implementations.
  • The world-px → NDC math in VERT_SHADER ((world / u_viewSize) * 2.0) is camera-less; if/when this graduates from workbench, it needs a u_view matrix uniform to compose with the camera, matching production conventions.
  • MAX_SPRITES = 4096 and the silent drop in add() should become either (a) a configurable constructor arg or (b) an auto-flush + reset, depending on whether the workbench will exceed 4096 sprites/frame.