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
SpriteAddinterface — per-sprite struct:x,y,w,h,rot(radians),layerIndex,frameIndex,gridCols,gridRows,tint(RGBA tuple).SpriteBatchArrayclass: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 inTRIANGLE_STRIPorder with interleavedaQuad(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 onCOMPILE_STATUSfailure.
READS FROM
WebGL2RenderingContextobtained fromcanvas.getContext('webgl2'); throwsWebGL2 unavailableif null.globalThis.document(presence detection) — picksOffscreenCanvaswhen absent andOffscreenCanvasis defined.- Caller-supplied
SpriteAddrecords pushed viaadd(...). - Caller-supplied raw
Uint8ArrayRGBA pixel data passed touploadLayer(...).
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, sizedMAX_SPRITES * FLOATS_PER_INSTANCE * 4bytes) — locs 2–8,vertexAttribDivisor(loc, 1):- 2:
iPosvec2 - 3:
iSizevec2 - 4:
iRotfloat - 5:
iLayerfloat - 6:
iFramefloat - 7:
iGridvec2 - 8:
iTintvec4 - +1 pad float at end of each instance (intentionally unused).
- 2:
TEXTURE_2D_ARRAYatlas (RGBA8,LINEARmin/mag,CLAMP_TO_EDGEwrap S/T) created increateEmptyAtlasor assigned viasetAtlasTexture.- Uniforms:
u_viewSize(vec2 px),u_atlas(sampler2DArray, unit 0). - Blend state:
gl.BLENDenabled,blendFunc(ONE, ONE_MINUS_SRC_ALPHA)(premultiplied alpha). - Draw:
drawArraysInstanced(TRIANGLE_STRIP, 0, 4, instanceCount).
- Quad VBO (
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;
LINEARfilter only. - Does not validate
frameIndexagainstgridCols * gridRows— out-of-range frames sample whatever the grid math resolves to. - Does not auto-grow past
MAX_SPRITES;add()silently drops sprites onceinstanceCount >= 4096. - Does not handle context loss / restore.
- Does not bake atlas pixel data — accepts pre-baked
Uint8Arrayper layer.
Signals
beginFrame()→ resetsinstanceCount = 0.add(s: SpriteAdd)→ appends one instance; no-op if at capacity.flush()→ uploadsinstanceData[0 .. instanceCount * FLOATS_PER_INSTANCE]viabufferSubData, binds program/VAO/atlas, sets uniforms + blend, issues onedrawArraysInstanced, resetsinstanceCount = 0.setAtlasTexture(tex)→ adopts an externally-created atlas (does not delete prioratlasTex).createEmptyAtlas(tileSize, gridCols, gridRows, layerCount)→ allocatesRGBA8TEXTURE_2D_ARRAYsizedtileSize*gridCols × tileSize*gridRows × layerCount, stores + returns it.uploadLayer(layerIndex, tileSize, gridCols, gridRows, pixels)→texSubImage3Dinto 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, allocatesFloat32Array(MAX_SPRITES * 14)CPU-side instance buffer.- Typical caller flow (workbench):
const sb = new SpriteBatchArray(W, H).sb.createEmptyAtlas(tileSize, gridCols, gridRows, N)once.sb.uploadLayer(i, tileSize, gridCols, gridRows, bytes)per baked component.- Per frame:
beginFrame()→ manyadd(...)→flush(). - 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), thentileUv = ((col,row) + aUv) / iGrid— UVs span [0,1] across the layer; the grid math carves it intogridCols × gridRowstiles.vUvLayer = vec3(tileUv, iLayer)feeds thesampler2DArray. - 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,
uploadLayerbeforecreateEmptyAtlas. - 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, thegl.createProgram+ link + check block ininitProgram) duplicate logic that likely also lives in production WebGL paths. Candidate for aengine/rendering/gl-shader-utils.tsshared module once the workbench path proves out. - The per-instance attribute layout (locs 2–8 with
vertexAttribDivisor=1) and theFLOATS_PER_INSTANCEpacking convention could be lifted into a typedInstanceLayoutdescriptor 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 au_viewmatrix uniform to compose with the camera, matching production conventions. MAX_SPRITES = 4096and the silent drop inadd()should become either (a) a configurable constructor arg or (b) an auto-flush + reset, depending on whether the workbench will exceed 4096 sprites/frame.