diablo2-web/src/render/atlas.ts

227 lines
7.9 KiB
TypeScript

/**
* 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 }
}