From d4fb17603186090e92ddf29a7dce9a595e13fb24 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 16 Sep 2026 02:10:34 +0000 Subject: [PATCH] perf(renderer): implement Raw 8-bit GPU Index Texture (gl.R8) + Shader Hardware Palette Lookup (#34) --- scripts/pack-entity-assets.ts | 123 +++++++++++++--- src/game/monster-art.ts | 14 +- src/render/atlas.ts | 80 ++++++++++ src/render/renderer.ts | 250 +++++++++++++++++++++++++------- src/scene/act-scene.ts | 125 ++++++++++++---- tests/packed-entity-art.test.ts | 44 ++++++ 6 files changed, 520 insertions(+), 116 deletions(-) diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index 759d042..9610698 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -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 { @@ -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() + 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 diff --git a/src/game/monster-art.ts b/src/game/monster-art.ts index 321f4f7..74f4fe5 100644 --- a/src/game/monster-art.ts +++ b/src/game/monster-art.ts @@ -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 { 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, diff --git a/src/render/atlas.ts b/src/render/atlas.ts index 73bfa81..d7cb012 100644 --- a/src/render/atlas.ts +++ b/src/render/atlas.ts @@ -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 } +} + diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 6dcff7a..6810488 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -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(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 diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index d91fab8..4579b54 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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() 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 { - 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 diff --git a/tests/packed-entity-art.test.ts b/tests/packed-entity-art.test.ts index 201ef52..fd52739 100644 --- a/tests/packed-entity-art.test.ts +++ b/tests/packed-entity-art.test.ts @@ -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() + 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) + } + }) }) +