perf(renderer): implement Raw 8-bit GPU Index Texture (gl.R8) + Shader Hardware Palette Lookup (#34)

This commit is contained in:
troytt 2026-09-16 02:10:34 +00:00
parent 2527678c10
commit d4fb176031
6 changed files with 520 additions and 116 deletions

View File

@ -46,11 +46,18 @@ export interface PackedEntitySheet {
readonly groups: [number, number, number, number][][]
}
export interface PackedEntityR8 {
readonly r8: Uint8Array
readonly width: number
readonly height: number
readonly groups: [number, number, number, number][][]
}
/**
* Shelf-pack all frames of a SpriteSheet into a single indexed PNG buffer.
* Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`).
* Index 0 represents transparency; opaque pixels with original index 0 are remapped to 172 (`rgb(4,4,4)`).
*/
export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedEntitySheet {
const opaqueBlack = findOpaqueBlackIndex(palette)
export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEntityR8 {
const frames = sheet.groups.flatMap(g => g.frames)
const widest = Math.max(1, ...frames.map(f => f.width))
const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0)
@ -79,7 +86,7 @@ export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedE
}
const pageHeight = Math.max(1, shelfY + shelfHeight)
const pixels = new Uint8Array(pageWidth * pageHeight)
const r8 = new Uint8Array(pageWidth * pageHeight)
let idx = 0
for (const frame of frames) {
@ -91,10 +98,10 @@ export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedE
for (let col = 0; col < place.width; col += 1) {
const srcAt = fromRow + col
if (frame.mask[srcAt] === 0) {
pixels[toRow + col] = 0
r8[toRow + col] = 0
} else {
const val = frame.indices[srcAt]!
pixels[toRow + col] = val === 0 ? opaqueBlack : val
r8[toRow + col] = val === 0 ? opaqueBlack : val
}
}
}
@ -112,8 +119,23 @@ export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedE
groups.push(gList)
}
const png = encodeIndexedPng({ width: pageWidth, height: pageHeight, pixels, palette, transparentIndex: 0 })
return { png, width: pageWidth, height: pageHeight, groups }
return { r8, width: pageWidth, height: pageHeight, groups }
}
/**
* Shelf-pack all frames of a SpriteSheet into a single indexed PNG buffer.
*/
export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedEntitySheet {
const opaqueBlack = findOpaqueBlackIndex(palette)
const packed = packSheetToR8(sheet, opaqueBlack)
const png = encodeIndexedPng({
width: packed.width,
height: packed.height,
pixels: packed.r8,
palette,
transparentIndex: 0,
})
return { png, width: packed.width, height: packed.height, groups: packed.groups }
}
export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise<void> {
@ -199,7 +221,53 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
await Promise.all(workers)
console.log(`Composited ${compositedMonsters.size} unique monster specs across Acts 1..5.`)
// 3. Encode indexed PNGs per Act palette
// 3. Emit global act-independent raw 8-bit (.r8) index textures into outDir/entities
const globalEntityDir = join(outDir, 'entities')
await mkdir(globalEntityDir, { recursive: true })
const packedCharR8 = packSheetToR8(charJoined, 172)
await writeFile(join(globalEntityDir, 'char-so.r8'), packedCharR8.r8)
await writeFile(
join(globalEntityDir, 'char-so.json'),
JSON.stringify({
file: 'char-so.r8',
format: 'r8',
width: packedCharR8.width,
height: packedCharR8.height,
directions: charWalk.directions,
walk: 0,
stand: charWalk.sheet.groups.length,
members: charWalk.members.length + charStand.members.length,
groups: packedCharR8.groups,
}),
)
const packedMonstersR8 = new Map<string, PackedEntityR8>()
for (const [key, m] of compositedMonsters) {
const packedMonR8 = packSheetToR8(m.sheet, 172)
packedMonstersR8.set(key, packedMonR8)
await writeFile(join(globalEntityDir, `monster-${key}.r8`), packedMonR8.r8)
await writeFile(
join(globalEntityDir, `monster-${key}.json`),
JSON.stringify({
token: m.token,
weapon: m.weapon,
file: `monster-${key}.r8`,
format: 'r8',
width: packedMonR8.width,
height: packedMonR8.height,
directions: m.directions,
walkOffset: m.walkOffset,
standOffset: m.standOffset,
walkFrames: m.walkFrames,
standFrames: m.standFrames,
groups: packedMonR8.groups,
}),
)
}
console.log(`Global R8 entities: packed char-so.r8 + ${packedMonstersR8.size} monster .r8 atlases into ${globalEntityDir}`)
// 4. Encode indexed PNGs per Act palette (legacy fallback)
for (let act = 1; act <= 5; act += 1) {
try {
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
@ -207,21 +275,27 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
const actEntityDir = join(outDir, `act${String(act)}`, 'entities')
await mkdir(actEntityDir, { recursive: true })
// Pack character
const packedChar = packSheetToPng(charJoined, palette)
// Pack character PNG from cached R8
const charPng = encodeIndexedPng({
width: packedCharR8.width,
height: packedCharR8.height,
pixels: packedCharR8.r8,
palette,
transparentIndex: 0,
})
const charPngName = 'char-so.png'
await writeFile(join(actEntityDir, charPngName), packedChar.png)
await writeFile(join(actEntityDir, charPngName), charPng)
await writeFile(
join(actEntityDir, 'char-so.json'),
JSON.stringify({
file: charPngName,
width: packedChar.width,
height: packedChar.height,
width: packedCharR8.width,
height: packedCharR8.height,
directions: charWalk.directions,
walk: 0,
stand: charWalk.sheet.groups.length,
members: charWalk.members.length + charStand.members.length,
groups: packedChar.groups,
groups: packedCharR8.groups,
}),
)
@ -231,24 +305,31 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (const spec of actSpecs) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
const m = compositedMonsters.get(key)
if (!m) continue
const packedMon = packSheetToPng(m.sheet, palette)
const packedMonR8 = packedMonstersR8.get(key)
if (!m || !packedMonR8) continue
const monPng = encodeIndexedPng({
width: packedMonR8.width,
height: packedMonR8.height,
pixels: packedMonR8.r8,
palette,
transparentIndex: 0,
})
const monPngName = `monster-${key}.png`
await writeFile(join(actEntityDir, monPngName), packedMon.png)
await writeFile(join(actEntityDir, monPngName), monPng)
await writeFile(
join(actEntityDir, `monster-${key}.json`),
JSON.stringify({
token: m.token,
weapon: m.weapon,
file: monPngName,
width: packedMon.width,
height: packedMon.height,
width: packedMonR8.width,
height: packedMonR8.height,
directions: m.directions,
walkOffset: m.walkOffset,
standOffset: m.standOffset,
walkFrames: m.walkFrames,
standFrames: m.standFrames,
groups: packedMon.groups,
groups: packedMonR8.groups,
}),
)
packedCount += 1

View File

@ -228,7 +228,7 @@ export async function compositeMonsterAnimation(
/**
* Composite monster walk ('wl') and neutral ('nu') animations, and build a runtime atlas.
*/
import { buildAtlas } from '../render/atlas.ts'
import { buildIndexedAtlas } from '../render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import type { Palette } from '../formats/pal.ts'
@ -245,13 +245,13 @@ export interface LoadedMonsterArt {
}
/**
* Load and composite a monster's walk and neutral sprite sheets, packing them into an atlas.
* Load and composite a monster's walk and neutral sprite sheets, packing them into an 8-bit R8 atlas.
*/
export async function loadMonsterAtlas(
archives: MountedArchives,
token: string,
weapon: string,
palette: Palette,
_palette: Palette,
renderer: SpriteRenderer,
): Promise<LoadedMonsterArt> {
const [walk, stand] = await Promise.all([
@ -264,12 +264,8 @@ export async function loadMonsterAtlas(
width: null,
}
const atlas = buildAtlas(combined, palette, undefined, { width: 1024 })
const handle = renderer.addAtlas({
pixels: atlas.pixels,
width: atlas.width,
height: atlas.height,
})
const atlas = buildIndexedAtlas(combined, 172, { width: 1024 })
const handle = renderer.addIndexedAtlas(atlas.r8, atlas.width, atlas.height)
return {
token,

View File

@ -126,3 +126,83 @@ export function buildAtlas(
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 })
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 }
}

View File

@ -57,8 +57,10 @@ export interface AtlasHandle {
readonly texture: WebGLTexture
/** Texture width in pixels, for UV maths. */
readonly width: number
/** Texture height in pixels. */
/** Texture height in pixels, for UV maths. */
readonly height: number
/** Whether this atlas stores 8-bit palette indices (R8 internal format). */
readonly indexed?: boolean
}
/** Draw options for one sprite quad. */
@ -69,6 +71,15 @@ export interface DrawOptions {
readonly flipX?: boolean
/** Atlas page to sample; defaults to the most recently set atlas. */
readonly atlas?: AtlasHandle
/**
* Hardware palette row index for indexed (R8) atlases:
* - 0: Normal Act Palette
* - 1: Cold / Chilled Blue Tint
* - 2: Poisoned Green Tint
* - 3: Unique Gold Boss Tint
* - 4: Champion / Elite Blue Tint
*/
readonly paletteRow?: number
}
/** Configuration options and event callbacks for SpriteRenderer. */
@ -83,19 +94,16 @@ export interface RendererOptions {
const VERTICES_PER_QUAD = 4
/** Indices per quad (two triangles). */
const INDICES_PER_QUAD = 6
/** Floats per vertex: x, y, u, v, r, g, b, a, unit. */
const FLOATS_PER_VERTEX = 9
/** Floats per vertex: x, y, u, v, r, g, b, a, unit, paletteRow. */
const FLOATS_PER_VERTEX = 10
/** Initial batch capacity in quads. */
const INITIAL_CAPACITY = 8192
/**
* Texture units the batcher will use at most.
*
* WebGL2 guarantees `MAX_TEXTURE_IMAGE_UNITS >= 16`, and the packs top out at
* ten pages for a level (`act5/109-act-5-town-townwest`) plus one character
* atlas, so sixteen is enough to hold an entire frame's pages at once. The real
* limit is still queried at construction and respected.
* Texture units the batcher will use at most (reserving 1 unit for the hardware palette texture).
*/
const MAX_BATCH_TEXTURES = 16
/** Number of precomputed color-shift rows in the 256-color hardware palette texture. */
const PALETTE_ROWS = 5
/**
* Build the vertex shader.
@ -108,31 +116,32 @@ in vec2 a_position;
in vec2 a_uv;
in vec4 a_tint;
in float a_unit;
in float a_palette;
uniform vec2 u_camera;
uniform vec2 u_viewport;
uniform float u_zoom;
out vec2 v_uv;
out vec4 v_tint;
flat out int v_unit;
flat out int v_palette;
void main() {
vec2 offset = (a_position - u_camera) * u_zoom;
// 'half' is a reserved word in GLSL ES 3.00, hence the longer name.
vec2 halfViewport = u_viewport * 0.5;
gl_Position = vec4(offset.x / halfViewport.x, -offset.y / halfViewport.y, 0.0, 1.0);
v_uv = a_uv;
v_tint = a_tint;
v_unit = int(a_unit);
v_palette = int(a_palette);
}`
}
/**
* Build the fragment shader for a given texture-unit count.
*
* GLSL ES 3.00 forbids indexing a sampler array with a non-constant expression,
* so the sampler is picked by a `switch` over constant cases. The case list is
* generated to match however many units the context actually offers.
* Supports both standard RGBA textures (`v_palette < 0`) and 8-bit `R8` palette-indexed
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
*
* @param units - number of samplers to expose.
* @param units - number of batch samplers to expose.
* @returns GLSL ES 3.00 source.
*/
function fragmentShaderSource(units: number): string {
@ -141,11 +150,13 @@ function fragmentShaderSource(units: number): string {
cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`)
}
return `#version 300 es
precision mediump float;
precision highp float;
in vec2 v_uv;
in vec4 v_tint;
flat in int v_unit;
flat in int v_palette;
uniform sampler2D u_textures[${String(units)}];
uniform sampler2D u_palette;
out vec4 outColor;
void main() {
vec4 texel;
@ -153,6 +164,11 @@ void main() {
${cases.join('\n')}
default: texel = texture(u_textures[0], v_uv); break;
}
if (v_palette >= 0) {
int idx = int(texel.r * 255.0 + 0.5);
if (idx == 0) discard;
texel = texelFetch(u_palette, ivec2(idx, v_palette), 0);
}
if (texel.a == 0.0) discard;
outColor = texel * v_tint;
}`
@ -175,6 +191,7 @@ export class SpriteRenderer {
private readonly indexBuffer: WebGLBuffer
private readonly atlasTexture: WebGLTexture
private readonly whiteTexture: WebGLTexture
private readonly paletteTexture: WebGLTexture
/** The 1×1 white page used by `drawSolid`. */
private readonly whiteAtlas: AtlasHandle
/** The default page (what `setAtlas` uploads into). */
@ -183,13 +200,16 @@ export class SpriteRenderer {
private readonly uniformViewport: WebGLUniformLocation
private readonly uniformZoom: WebGLUniformLocation
private readonly uniformTextures: WebGLUniformLocation
private readonly uniformPalette: WebGLUniformLocation | null
private vertices: Float32Array
private indices: Uint32Array
private quadCount = 0
private atlasWidth = 1
private atlasHeight = 1
/** Texture units this context offers the batcher. */
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */
private readonly maxBatchTextures: number
/** Dedicated texture unit for the 256-color hardware palette texture. */
private readonly paletteUnit: number
/** Pages bound to units `0..batchTextureCount-1` for the pending batch. */
private readonly batchTextures: (AtlasHandle | null)[]
private batchTextureCount = 0
@ -219,10 +239,10 @@ export class SpriteRenderer {
this.gl = gl
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
// WebGL2 guarantees at least 16; clamp to at least 1 so a stubbed context
// (the lifecycle tests) still produces a valid shader.
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
this.maxBatchTextures = Math.max(1, Math.min(MAX_BATCH_TEXTURES, availableUnits))
const clampedUnits = Math.max(2, Math.min(MAX_BATCH_TEXTURES, availableUnits))
this.paletteUnit = clampedUnits - 1
this.maxBatchTextures = clampedUnits - 1
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures))
@ -231,7 +251,15 @@ export class SpriteRenderer {
const indexBuffer = gl.createBuffer()
const atlasTexture = gl.createTexture()
const whiteTexture = gl.createTexture()
if (vao === null || buffer === null || indexBuffer === null || atlasTexture === null || whiteTexture === null) {
const paletteTexture = gl.createTexture()
if (
vao === null ||
buffer === null ||
indexBuffer === null ||
atlasTexture === null ||
whiteTexture === null ||
paletteTexture === null
) {
throw new RendererError('WebGL2 resource allocation failed')
}
this.vao = vao
@ -239,8 +267,10 @@ export class SpriteRenderer {
this.indexBuffer = indexBuffer
this.atlasTexture = atlasTexture
this.whiteTexture = whiteTexture
this.paletteTexture = paletteTexture
this.allocatedTextures.add(atlasTexture)
this.allocatedTextures.add(whiteTexture)
this.allocatedTextures.add(paletteTexture)
this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 }
this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 }
this.vertices = new Float32Array(INITIAL_CAPACITY * VERTICES_PER_QUAD * FLOATS_PER_VERTEX)
@ -250,6 +280,7 @@ export class SpriteRenderer {
const attributeUv = gl.getAttribLocation(this.program, 'a_uv')
const attributeTint = gl.getAttribLocation(this.program, 'a_tint')
const attributeUnit = gl.getAttribLocation(this.program, 'a_unit')
const attributePalette = gl.getAttribLocation(this.program, 'a_palette')
gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
@ -260,12 +291,14 @@ export class SpriteRenderer {
gl.vertexAttribPointer(attributeUv, 2, gl.FLOAT, false, stride, 8)
gl.enableVertexAttribArray(attributeTint)
gl.vertexAttribPointer(attributeTint, 4, gl.FLOAT, false, stride, 16)
// A stubbed context can report -1 for an attribute it does not know about;
// enabling that location would be an INVALID_VALUE.
if (attributeUnit >= 0) {
gl.enableVertexAttribArray(attributeUnit)
gl.vertexAttribPointer(attributeUnit, 1, gl.FLOAT, false, stride, 32)
}
if (attributePalette >= 0) {
gl.enableVertexAttribArray(attributePalette)
gl.vertexAttribPointer(attributePalette, 1, gl.FLOAT, false, stride, 36)
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW)
gl.bindVertexArray(null)
@ -274,14 +307,17 @@ export class SpriteRenderer {
this.uniformViewport = requireUniform(gl, this.program, 'u_viewport')
this.uniformZoom = requireUniform(gl, this.program, 'u_zoom')
this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]')
this.uniformPalette = gl.getUniformLocation(this.program, 'u_palette')
// The sampler array never changes: unit i always reads texture unit i.
const samplerUnits = new Int32Array(this.maxBatchTextures)
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
gl.useProgram(this.program)
gl.uniform1iv(this.uniformTextures, samplerUnits)
if (this.uniformPalette !== null) {
gl.uniform1i(this.uniformPalette, this.paletteUnit)
}
for (const texture of [atlasTexture, whiteTexture]) {
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
@ -290,6 +326,14 @@ export class SpriteRenderer {
}
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
// Initialize default 256x5 greyscale palette until setPalette is called
const defaultPal = new Uint8Array(256 * 3)
for (let i = 0; i < 256; i += 1) {
defaultPal[i * 3] = i
defaultPal[i * 3 + 1] = i
defaultPal[i * 3 + 2] = i
}
this.setPalette(defaultPal)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.disable(gl.DEPTH_TEST)
@ -416,6 +460,122 @@ export class SpriteRenderer {
return { texture, width: pageWidth, height: pageHeight }
}
/**
* Upload a raw 8-bit palette-indexed (`gl.R8`, 1 byte per pixel) atlas texture.
*
* Zero image decoding is performed on the CPU, and GPU memory consumption is
* reduced by 75% compared to RGBA8. Color expansion and status tinting are
* performed in hardware via `texelFetch(u_palette, ...)`.
*
* @param indices - raw 8-bit palette index array (`width * height` bytes, 0 = transparent).
* @param width - atlas width in pixels.
* @param height - atlas height in pixels.
* @returns the indexed atlas handle (`indexed: true`).
*/
addIndexedAtlas(indices: Uint8Array, width: number, height: number): AtlasHandle {
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl
const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
const pageWidth = Math.max(1, width)
const pageHeight = Math.max(1, height)
if (pageWidth > limit || pageHeight > limit) {
throw new RendererError(`indexed atlas ${String(pageWidth)}x${String(pageHeight)} exceeds the ${String(limit)}px texture limit`)
}
this.flush()
const texture = gl.createTexture()
if (texture === null) throw new RendererError('indexed texture allocation failed')
this.allocatedTextures.add(texture)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R8,
pageWidth,
pageHeight,
0,
gl.RED,
gl.UNSIGNED_BYTE,
indices,
)
return { texture, width: pageWidth, height: pageHeight, indexed: true }
}
/**
* Upload the 256-colour Act palette and precompute hardware status tint rows:
* - Row 0: Normal Act Palette
* - Row 1: Cold / Chilled Blue Tint
* - Row 2: Poisoned Green Tint
* - Row 3: Unique Gold Boss Tint
* - Row 4: Champion / Elite Blue Tint
*
* @param rgb - 768-byte array of 256 RGB triplets (`[r, g, b, ...]`).
*/
setPalette(rgb: Uint8Array): void {
if (this.disposed) return
this.flush()
const gl = this.gl
const rgba = new Uint8Array(256 * PALETTE_ROWS * 4)
for (let i = 1; i < 256; i += 1) {
const r = rgb[i * 3] ?? 0
const g = rgb[i * 3 + 1] ?? 0
const b = rgb[i * 3 + 2] ?? 0
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b)
// Row 0: Normal Act Palette
const r0 = i * 4
rgba[r0] = r
rgba[r0 + 1] = g
rgba[r0 + 2] = b
rgba[r0 + 3] = 255
// Row 1: Cold / Chilled Icy Blue
const r1 = (256 + i) * 4
rgba[r1] = Math.min(255, Math.round(r * 0.35))
rgba[r1 + 1] = Math.min(255, Math.round(g * 0.65 + 40))
rgba[r1 + 2] = Math.min(255, Math.round(b * 0.9 + 100))
rgba[r1 + 3] = 255
// Row 2: Poisoned Venom Green
const r2 = (256 * 2 + i) * 4
rgba[r2] = Math.min(255, Math.round(r * 0.35))
rgba[r2 + 1] = Math.min(255, Math.round(g * 0.85 + 85))
rgba[r2 + 2] = Math.min(255, Math.round(b * 0.35))
rgba[r2 + 3] = 255
// Row 3: Unique Boss Golden Aura
const r3 = (256 * 3 + i) * 4
rgba[r3] = Math.min(255, Math.round(lum * 1.25 + 55))
rgba[r3 + 1] = Math.min(255, Math.round(lum * 0.95 + 25))
rgba[r3 + 2] = Math.min(255, Math.round(lum * 0.35))
rgba[r3 + 3] = 255
// Row 4: Champion / Elite Royal Blue
const r4 = (256 * 4 + i) * 4
rgba[r4] = Math.min(255, Math.round(lum * 0.45 + 30))
rgba[r4 + 1] = Math.min(255, Math.round(lum * 0.6 + 45))
rgba[r4 + 2] = Math.min(255, Math.round(lum * 1.3 + 75))
rgba[r4 + 3] = 255
}
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
PALETTE_ROWS,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
rgba,
)
}
/**
* Release an atlas texture handle and free its GPU memory.
*
@ -475,7 +635,7 @@ export class SpriteRenderer {
* @param frame - the atlas placement to draw.
* @param x - world-space left edge.
* @param y - world-space top edge.
* @param options - tint and flipping.
* @param options - tint, flipping, and hardware palette row.
*/
draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void {
if (this.disposed) return
@ -487,6 +647,7 @@ export class SpriteRenderer {
const v1 = (frame.y + frame.height) / page.height
const tint = options.tint
const flipped = options.flipX === true
const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1
this.quad(
x, y, x + frame.width, y + frame.height,
flipped ? u1 : u0, v0,
@ -496,6 +657,7 @@ export class SpriteRenderer {
tint === undefined ? 1 : tint[2],
tint === undefined ? 1 : tint[3],
unit,
paletteRow,
)
}
@ -511,17 +673,13 @@ export class SpriteRenderer {
drawSolid(x: number, y: number, width: number, height: number, color: readonly [number, number, number, number]): void {
if (this.disposed) return
const unit = this.unitFor(this.whiteAtlas)
this.quad(x, y, x + width, y + height, 0.5, 0.5, 0.5, 0.5, color[0], color[1], color[2], color[3], unit)
this.quad(x, y, x + width, y + height, 0.5, 0.5, 0.5, 0.5, color[0], color[1], color[2], color[3], unit, -1)
}
/**
* Find the texture unit a page is bound to for the pending batch, binding it
* to a free unit if this is its first quad.
*
* Running out of units is the only thing that still splits a batch, and with
* sixteen of them against a ten-page worst case that effectively never
* happens for a map.
*
* @param page - the atlas page the caller wants to sample.
* @returns the texture unit index to write into the vertices.
*/
@ -543,30 +701,13 @@ export class SpriteRenderer {
/**
* Append one quad's four vertices.
*
* Written out longhand on purpose: the readable loop-over-temporary-arrays
* version allocated four arrays per quad, and at a few thousand quads a frame
* that dominated the CPU side of the frame (measured 2.3× slower).
*
* @param x0 - left edge.
* @param y0 - top edge.
* @param x1 - right edge.
* @param y1 - bottom edge.
* @param u0 - left texture coordinate.
* @param v0 - top texture coordinate.
* @param u1 - right texture coordinate.
* @param v1 - bottom texture coordinate.
* @param r - tint red.
* @param g - tint green.
* @param b - tint blue.
* @param a - tint alpha.
* @param unit - texture unit to sample.
*/
private quad(
x0: number, y0: number, x1: number, y1: number,
u0: number, v0: number, u1: number, v1: number,
r: number, g: number, b: number, a: number,
unit: number,
paletteRow: number,
): void {
const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
if (needed > this.vertices.length) this.grow(needed)
@ -574,19 +715,19 @@ export class SpriteRenderer {
let at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
// Corner 0: top-left.
v[at] = x0; v[at + 1] = y0; v[at + 2] = u0; v[at + 3] = v0
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow
at += FLOATS_PER_VERTEX
// Corner 1: top-right.
v[at] = x1; v[at + 1] = y0; v[at + 2] = u1; v[at + 3] = v0
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow
at += FLOATS_PER_VERTEX
// Corner 2: bottom-left.
v[at] = x0; v[at + 1] = y1; v[at + 2] = u0; v[at + 3] = v1
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow
at += FLOATS_PER_VERTEX
// Corner 3: bottom-right.
v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v1
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit
v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow
this.quadCount += 1
this.frameQuads += 1
}
@ -599,14 +740,13 @@ export class SpriteRenderer {
gl.useProgram(this.program)
gl.bindVertexArray(this.vao)
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
// Orphan first: without it the driver has to wait until the GPU is done
// reading the range this overwrites, which is a pipeline stall on every
// flush. Handing back a fresh block lets both sides run.
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount)
gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y)
gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight)
gl.uniform1f(this.uniformZoom, this.camera.zoom)
gl.activeTexture(gl.TEXTURE0 + this.paletteUnit)
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
const page = this.batchTextures[unit]
if (page === undefined || page === null) continue

View File

@ -39,7 +39,7 @@ import type { NpcEntity } from "../game/engine.ts"
import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import type { NpcDef } from '../game/quests.ts'
import { buildAtlas } from '../render/atlas.ts'
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts'
import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
@ -877,25 +877,22 @@ async function loadMonsterArtMap(
const loadedBySpecKey = new Map<string, LoadedMonsterArt>()
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
if (packBase !== undefined && act !== undefined) {
const entitiesUrl = `${packBase}/act${String(act)}/entities`
if (packBase !== undefined) {
const globalEntitiesUrl = `${packBase}/entities`
const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined
await Promise.all(
[...tokenSpecs.entries()].map(async ([key, spec]) => {
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${entitiesUrl}/${slug}.json`),
fetch(`${entitiesUrl}/${slug}.png`),
const [jsonResp, r8Resp] = await Promise.all([
fetch(`${globalEntitiesUrl}/${slug}.json`),
fetch(`${globalEntitiesUrl}/${slug}.r8`),
])
if (jsonResp.ok && pngResp.ok) {
if (jsonResp.ok && r8Resp.ok) {
const meta = (await jsonResp.json()) as PackedMonsterJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const indices = new Uint8Array(await r8Resp.arrayBuffer())
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height]) => ({ x, y, width, height }))
)
@ -912,7 +909,43 @@ async function loadMonsterArtMap(
return
}
} catch {
// fall back to MPQ loading below
// fall through to per-act PNG fallback
}
// 2. Fallback to per-act PNG if available
if (actEntitiesUrl !== undefined) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${actEntitiesUrl}/${slug}.json`),
fetch(`${actEntitiesUrl}/${slug}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedMonsterJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height]) => ({ x, y, width, height }))
)
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
directions: meta.directions,
walkOffset: meta.walkOffset,
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
})
return
}
} catch {
// fall back to MPQ loading below
}
}
fallbackSpecs.push([key, spec])
})
@ -962,22 +995,17 @@ async function loadCharacterArt(
packBase?: string,
act?: number,
): Promise<LoadedCharacter | null> {
if (packBase !== undefined && act !== undefined) {
const entitiesUrl = `${packBase}/act${String(act)}/entities`
if (packBase !== undefined) {
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${entitiesUrl}/char-so.json`),
fetch(`${entitiesUrl}/char-so.png`),
const [jsonResp, r8Resp] = await Promise.all([
fetch(`${packBase}/entities/char-so.json`),
fetch(`${packBase}/entities/char-so.r8`),
])
if (jsonResp.ok && pngResp.ok) {
if (jsonResp.ok && r8Resp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const indices = new Uint8Array(await r8Resp.arrayBuffer())
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height]) => ({ x, y, width, height }))
)
@ -991,7 +1019,41 @@ async function loadCharacterArt(
}
}
} catch {
// fall back to MPQ loading below
// fall through to per-act PNG fallback
}
// 2. Fallback to per-act PNG if available
if (act !== undefined) {
const entitiesUrl = `${packBase}/act${String(act)}/entities`
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${entitiesUrl}/char-so.json`),
fetch(`${entitiesUrl}/char-so.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height]) => ({ x, y, width, height }))
)
return {
handle,
groups,
directions: meta.directions,
walk: meta.walk,
stand: meta.stand,
members: meta.members,
}
}
} catch {
// fall back to MPQ loading below
}
}
}
@ -1003,8 +1065,8 @@ async function loadCharacterArt(
loadCharacterSheet(archives, 'so', 'nu', 'hth'),
])
const joined: SpriteSheet = { groups: [...walk.sheet.groups, ...stand.sheet.groups], width: null }
const atlas = buildAtlas(joined, palette, undefined, { width: 1024 })
const handle = renderer.addAtlas({ pixels: atlas.pixels, width: atlas.width, height: atlas.height })
const atlas = buildIndexedAtlas(joined, 172, { width: 1024 })
const handle = renderer.addIndexedAtlas(atlas.r8, atlas.width, atlas.height)
const walkCount = walk.sheet.groups.length
return {
handle,
@ -1404,6 +1466,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
renderer.setPalette(runtime.palette.rgb)
const paletteForEntities = runtime.palette
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined

View File

@ -82,4 +82,48 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
}
}
})
it.skipIf(!hasPacks)('bakes global act-independent raw 8-bit (.r8) index textures into samples/d2-packs/entities/ (Issue #34)', () => {
const globalDir = join(packRoot, 'entities')
const charJsonPath = join(globalDir, 'char-so.json')
const charR8Path = join(globalDir, 'char-so.r8')
expect(existsSync(charJsonPath)).toBe(true)
expect(existsSync(charR8Path)).toBe(true)
const charMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
expect(charMeta.file).toBe('char-so.r8')
expect(charMeta.format).toBe('r8')
const charR8 = readFileSync(charR8Path)
expect(charR8.byteLength).toBe(charMeta.width * charMeta.height)
// Check all 66 unique monster specs exist as global .r8 files
const uniqueSpecs = new Set<string>()
for (let act = 1; act <= 5; act += 1) {
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`)
}
}
expect(uniqueSpecs.size).toBe(66)
for (const key of uniqueSpecs) {
const slug = `monster-${key}`
const jsonPath = join(globalDir, `${slug}.json`)
const r8Path = join(globalDir, `${slug}.r8`)
expect(existsSync(jsonPath), `missing global ${jsonPath}`).toBe(true)
expect(existsSync(r8Path), `missing global ${r8Path}`).toBe(true)
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
expect(meta.file).toBe(`${slug}.r8`)
expect(meta.format).toBe('r8')
const r8 = readFileSync(r8Path)
expect(r8.byteLength).toBe(meta.width * meta.height)
let nonZero = 0
for (let i = 0; i < r8.length; i += 1) {
if (r8[i] !== 0) nonZero += 1
}
expect(nonZero).toBeGreaterThan(0)
}
})
})