/** * Sprite atlases: decoded palette-indexed frames packed once into one RGBA * texture. * * Indexed frames stay the source of truth in the format layer (that is what * makes colour variants cheap), but the GPU wants RGBA. Expanding into a single * atlas keeps both: one texture bind per frame batch, and a variant is another * atlas rather than a shader permutation. */ import { indicesToRgba } from '../formats/pal.ts' import type { Palette } from '../formats/pal.ts' import type { SpriteSheet } from '../formats/sprite.ts' /** One frame's placement inside the atlas, in atlas pixels. */ export interface AtlasFrame { /** Left edge. */ readonly x: number /** Top edge. */ readonly y: number /** Frame width. */ readonly width: number /** Frame height. */ readonly height: number /** Horizontal anchor offset in sprite space (box.left in DCC coordinates). */ readonly anchorX?: number /** Vertical anchor offset in sprite space (box.top in DCC coordinates). */ readonly anchorY?: number } /** A packed sheet: pixels plus per-group frame placements. */ export interface SpriteAtlas { /** RGBA8 pixels, `width * height * 4`. */ readonly pixels: Uint8ClampedArray /** Atlas width in pixels. */ readonly width: number /** Atlas height in pixels. */ readonly height: number /** Frame placements, grouped exactly like the source sheet. */ readonly groups: readonly (readonly AtlasFrame[])[] } /** One pixel of padding between frames, so nearest sampling cannot bleed. */ const PADDING = 1 /** Cap on the atlas' short side, comfortably under every GL implementation's limit. */ const MAX_ATLAS_WIDTH = 2048 /** * Round up to a power of two (atlas dimensions stay power-of-two-ish so the * layout is easy to reason about, not because the hardware requires it). * * @param value - the value to round. * @returns the next power of two, at least 1. */ function nextPowerOfTwo(value: number): number { let result = 1 while (result < value) result *= 2 return result } /** * Pack a decoded sheet into an RGBA atlas. * * Frames are laid out in shelves: left to right, wrapping to a new shelf when * the atlas width would be exceeded. Sheets here are small (a few hundred * frames of at most a couple hundred pixels), so a shelf packer keeps the code * short without wasting meaningful space. * * @param sheet - the decoded sheet. * @param palette - palette to resolve indices with. * @param trn - optional index remap (unit colour variant). * @param options - atlas width and background. * @returns the packed atlas. */ export function buildAtlas( sheet: SpriteSheet, palette: Palette, trn?: Uint8Array, options: { width?: number } = {}, ): SpriteAtlas { const frames = sheet.groups.flatMap(group => group.frames) if (frames.length === 0) { return { pixels: new Uint8ClampedArray(0), width: 0, height: 0, groups: [] } } const widest = Math.max(...frames.map(frame => frame.width)) // Sizing the atlas to its widest frame would stack every frame in one // kilometre-tall column and blow past the GL texture limit; aiming for a // roughly square sheet keeps both dimensions sane. const area = frames.reduce((total, frame) => total + (frame.width + PADDING) * (frame.height + PADDING), 0) const square = nextPowerOfTwo(Math.ceil(Math.sqrt(area))) const atlasWidth = Math.max(options.width ?? 0, widest + PADDING, Math.min(square, MAX_ATLAS_WIDTH)) // Shelf layout: walk the frames, starting a new shelf when one does not fit. const placements: AtlasFrame[] = [] let shelfY = 0 let shelfHeight = 0 let cursorX = 0 for (const frame of frames) { if (cursorX + frame.width + PADDING > atlasWidth) { shelfY += shelfHeight + PADDING shelfHeight = 0 cursorX = 0 } placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height, ...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}), ...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}), }) cursorX += frame.width + PADDING shelfHeight = Math.max(shelfHeight, frame.height) } const atlasHeight = shelfY + shelfHeight + PADDING const pixels = new Uint8ClampedArray(atlasWidth * atlasHeight * 4) let index = 0 for (const frame of frames) { const place = placements[index]! const rgba = indicesToRgba(frame.indices, frame.mask, palette, trn) for (let row = 0; row < frame.height; row += 1) { const from = row * frame.width * 4 const to = ((place.y + row) * atlasWidth + place.x) * 4 pixels.set(rgba.subarray(from, from + frame.width * 4), to) } index += 1 } // Re-group placements to mirror the sheet, so a caller indexes // `atlas.groups[direction][frame]` exactly as it indexes the sprite sheet. let consumed = 0 const groups = sheet.groups.map(group => { const slice = placements.slice(consumed, consumed + group.frames.length) consumed += group.frames.length return slice }) return { pixels, width: atlasWidth, height: atlasHeight, groups } } /** A packed 8-bit palette-indexed sheet: 1 byte per pixel plus per-group frame placements. */ export interface IndexedSpriteAtlas { /** R8 palette indices, `width * height`. Index 0 = transparent. */ readonly r8: Uint8Array /** Atlas width in pixels. */ readonly width: number /** Atlas height in pixels. */ readonly height: number /** Frame placements, grouped exactly like the source sheet. */ readonly groups: readonly (readonly AtlasFrame[])[] } /** * Pack a decoded sheet into a single-channel 8-bit (R8) palette-indexed atlas. * * Transparent pixels (`mask[i] === 0`) are mapped to `0`. Opaque pixels whose * palette index happens to be `0` are remapped to `opaqueBlack` (default 172, * which is `rgb(4,4,4)` across all Diablo II Act palettes). */ export function buildIndexedAtlas( sheet: SpriteSheet, opaqueBlack = 172, options: { width?: number } = {}, ): IndexedSpriteAtlas { const frames = sheet.groups.flatMap(group => group.frames) if (frames.length === 0) { return { r8: new Uint8Array(0), width: 0, height: 0, groups: [] } } const widest = Math.max(...frames.map(frame => frame.width)) const area = frames.reduce((total, frame) => total + (frame.width + PADDING) * (frame.height + PADDING), 0) const square = nextPowerOfTwo(Math.ceil(Math.sqrt(area))) const atlasWidth = Math.max(options.width ?? 0, widest + PADDING, Math.min(square, MAX_ATLAS_WIDTH)) const placements: AtlasFrame[] = [] let shelfY = 0 let shelfHeight = 0 let cursorX = 0 for (const frame of frames) { if (cursorX + frame.width + PADDING > atlasWidth) { shelfY += shelfHeight + PADDING shelfHeight = 0 cursorX = 0 } placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height, ...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}), ...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}), }) cursorX += frame.width + PADDING shelfHeight = Math.max(shelfHeight, frame.height) } const atlasHeight = shelfY + shelfHeight + PADDING const r8 = new Uint8Array(atlasWidth * atlasHeight) let index = 0 for (const frame of frames) { const place = placements[index]! index += 1 for (let row = 0; row < place.height; row += 1) { const fromRow = row * frame.width const toRow = (place.y + row) * atlasWidth + place.x for (let col = 0; col < place.width; col += 1) { const srcAt = fromRow + col if (frame.mask[srcAt] === 0) { r8[toRow + col] = 0 } else { const val = frame.indices[srcAt]! r8[toRow + col] = val === 0 ? opaqueBlack : val } } } } let consumed = 0 const groups = sheet.groups.map(group => { const slice = placements.slice(consumed, consumed + group.frames.length) consumed += group.frames.length return slice }) return { r8, width: atlasWidth, height: atlasHeight, groups } }