perf(renderer): implement Raw 8-bit GPU Index Texture (gl.R8) + Shader Hardware Palette Lookup (#34)
This commit is contained in:
parent
2527678c10
commit
d4fb176031
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue