perf(entities): offline pre-bake Sorceress and monster atlases into Act packs (#30)

This commit is contained in:
troytt 2026-09-16 01:16:38 +00:00
parent d6e624829a
commit 270c743aef
4 changed files with 480 additions and 22 deletions

View File

@ -1079,5 +1079,7 @@ for (const entry of LEVELS) {
await mkdir(outDir, { recursive: true })
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
const { bakeEntities } = await import('./pack-entity-assets.ts')
await bakeEntities(archiveDir, outDir)
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)

View File

@ -0,0 +1,252 @@
/**
* Bake character and monster animations into web-native indexed PNG atlases.
*
* Pre-packing entity animations into `samples/d2-packs/act<N>/entities/`
* eliminates runtime HTTP Range requests to `d2char.mpq` / `d2data.mpq` / `d2exp.mpq`
* and removes JS-side DCC/COF decompression & compositing during level boot.
* The browser loads small (~30-80 KB) indexed PNGs via hardware-accelerated
* `createImageBitmap`, reducing character + monster load times from >5s to <200ms.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { loadCharacterSheet } from '../src/game/character.ts'
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
import { MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
import type { SpriteSheet } from '../src/formats/sprite.ts'
import { encodeIndexedPng } from './png.ts'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const PAGE_SIZE = 2048
function findOpaqueBlackIndex(palette: Uint8Array): number {
let bestIdx = 1
let bestDist = Infinity
for (let i = 1; i < 256; i += 1) {
const r = palette[i * 3]!
const g = palette[i * 3 + 1]!
const b = palette[i * 3 + 2]!
const dist = r * r + g * g + b * b
if (dist < bestDist) {
bestDist = dist
bestIdx = i
if (dist === 0) break
}
}
return bestIdx
}
export interface PackedEntitySheet {
readonly png: 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.
*/
export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedEntitySheet {
const opaqueBlack = findOpaqueBlackIndex(palette)
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)
let pageWidth = Math.min(PAGE_SIZE, Math.max(widest + 2, 512))
while (pageWidth < PAGE_SIZE && pageWidth * pageWidth < area * 1.15) {
pageWidth *= 2
}
pageWidth = Math.min(PAGE_SIZE, pageWidth)
let cursorX = 0
let shelfY = 0
let shelfHeight = 0
const placements: { x: number; y: number; width: number; height: number }[] = []
for (const frame of frames) {
const w = Math.max(1, Math.min(frame.width, pageWidth))
const h = Math.max(1, Math.min(frame.height, PAGE_SIZE))
if (cursorX + w > pageWidth) {
shelfY += shelfHeight + 1
shelfHeight = 0
cursorX = 0
}
placements.push({ x: cursorX, y: shelfY, width: w, height: h })
cursorX += w + 1
shelfHeight = Math.max(shelfHeight, h)
}
const pageHeight = Math.max(1, shelfY + shelfHeight)
const pixels = new Uint8Array(pageWidth * pageHeight)
let idx = 0
for (const frame of frames) {
const place = placements[idx]!
idx += 1
for (let row = 0; row < place.height; row += 1) {
const fromRow = row * frame.width
const toRow = (place.y + row) * pageWidth + place.x
for (let col = 0; col < place.width; col += 1) {
const srcAt = fromRow + col
if (frame.mask[srcAt] === 0) {
pixels[toRow + col] = 0
} else {
const val = frame.indices[srcAt]!
pixels[toRow + col] = val === 0 ? opaqueBlack : val
}
}
}
}
const groups: [number, number, number, number][][] = []
let consumed = 0
for (const group of sheet.groups) {
const gList: [number, number, number, number][] = []
for (let i = 0; i < group.frames.length; i += 1) {
const p = placements[consumed]!
consumed += 1
gList.push([p.x, p.y, p.width, p.height])
}
groups.push(gList)
}
const png = encodeIndexedPng({ width: pageWidth, height: pageHeight, pixels, palette, transparentIndex: 0 })
return { png, width: pageWidth, height: pageHeight, groups }
}
export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise<void> {
const archives = new MountedArchives()
for (const name of MOUNTS) {
try {
const archive = await MpqArchive.open(await fileSource(join(archiveDir, name)))
archives.add(name, archive)
} catch (err) {
console.warn(`skip ${name}: ${(err as Error).message}`)
}
}
// 1. Load character sheets once (independent of palette until PNG encoding)
console.log('Composing Sorceress (so) walk & stand animations...')
const [charWalk, charStand] = await Promise.all([
loadCharacterSheet(archives, 'so', 'wl', 'hth'),
loadCharacterSheet(archives, 'so', 'nu', 'hth'),
])
const charJoined: SpriteSheet = {
groups: [...charWalk.sheet.groups, ...charStand.sheet.groups],
width: null,
}
// 2. Collect unique monster (token, weapon) specs
const uniqueSpecs = new Map<string, { token: string; weapon: string }>()
for (const spec of Object.values(MONSTER_ART_MAP)) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
}
}
// Composite each monster animation once
const compositedMonsters = new Map<string, {
key: string
token: string
weapon: string
directions: number
walkOffset: number
standOffset: number
walkFrames: number
standFrames: number
sheet: SpriteSheet
}>()
for (const [key, spec] of uniqueSpecs) {
try {
console.log(`Composing monster ${spec.token} (${spec.weapon})...`)
const [walk, stand] = await Promise.all([
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),
compositeMonsterAnimation(archives, spec.token, 'nu', spec.weapon),
])
const sheet: SpriteSheet = {
groups: [...walk.sheet.groups, ...stand.sheet.groups],
width: null,
}
compositedMonsters.set(key, {
key,
token: spec.token,
weapon: spec.weapon,
directions: walk.directions,
walkOffset: 0,
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
sheet,
})
} catch (err) {
console.warn(`Failed to composite monster ${key}: ${(err as Error).message}`)
}
}
// 3. Encode indexed PNGs per Act palette
for (let act = 1; act <= 5; act += 1) {
try {
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
const palette = pl2.rgb
const actEntityDir = join(outDir, `act${String(act)}`, 'entities')
await mkdir(actEntityDir, { recursive: true })
// Pack character
const packedChar = packSheetToPng(charJoined, palette)
const charPngName = 'char-so.png'
await writeFile(join(actEntityDir, charPngName), packedChar.png)
await writeFile(
join(actEntityDir, 'char-so.json'),
JSON.stringify({
file: charPngName,
width: packedChar.width,
height: packedChar.height,
directions: charWalk.directions,
walk: 0,
stand: charWalk.sheet.groups.length,
members: charWalk.members.length + charStand.members.length,
groups: packedChar.groups,
}),
)
// Pack monsters
for (const [key, m] of compositedMonsters) {
const packedMon = packSheetToPng(m.sheet, palette)
const monPngName = `monster-${key}.png`
await writeFile(join(actEntityDir, monPngName), packedMon.png)
await writeFile(
join(actEntityDir, `monster-${key}.json`),
JSON.stringify({
token: m.token,
weapon: m.weapon,
file: monPngName,
width: packedMon.width,
height: packedMon.height,
directions: m.directions,
walkOffset: m.walkOffset,
standOffset: m.standOffset,
walkFrames: m.walkFrames,
standFrames: m.standFrames,
groups: packedMon.groups,
}),
)
}
console.log(`Act ${act}: packed char-so + ${compositedMonsters.size} monster atlases into ${actEntityDir}`)
} catch (err) {
console.warn(`Act ${act} entity packing skipped: ${(err as Error).message}`)
}
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const archiveDir = process.argv[2] ?? 'samples/d2'
const outDir = process.argv[3] ?? 'samples/d2-packs'
bakeEntities(archiveDir, outDir).catch(err => {
console.error(err)
process.exit(1)
})
}

View File

@ -821,48 +821,129 @@ async function getMountedCharArchives(bases: readonly string[]): Promise<Mounted
return null
}
interface PackedMonsterJson {
readonly token: string
readonly weapon: string
readonly file: string
readonly width: number
readonly height: number
readonly directions: number
readonly walkOffset: number
readonly standOffset: number
readonly walkFrames: number
readonly standFrames: number
readonly groups: readonly (readonly [number, number, number, number])[][]
}
interface PackedCharJson {
readonly file: string
readonly width: number
readonly height: number
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
readonly groups: readonly (readonly [number, number, number, number])[][]
}
/**
* Load monster atlases on-demand for all monster types present in the scene.
* Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms),
* falling back to runtime MPQ HTTP Range extraction only if pre-packed assets are missing.
*/
async function loadMonsterArtMap(
bases: readonly string[],
monsterIds: readonly string[],
palette: Palette,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<Map<string, LoadedMonsterArt>> {
const map = new Map<string, LoadedMonsterArt>()
if (monsterIds.length === 0) return map
try {
const archives = await getMountedDataArchives(bases)
if (!archives) return map
// Resolve unique tokens needed
// Resolve unique (token, weapon) specs needed
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null && !tokenSpecs.has(spec.token)) {
tokenSpecs.set(spec.token, spec)
if (spec !== null) {
const key = `${spec.token}-${spec.weapon}`
if (!tokenSpecs.has(key)) {
tokenSpecs.set(key, spec)
}
}
}
// Load each monster token atlas in parallel
const loadedByToken = new Map<string, LoadedMonsterArt>()
await Promise.all(
[...tokenSpecs.entries()].map(async ([token, spec]) => {
try {
const loaded = await loadMonsterAtlas(archives, token, spec.weapon, palette, renderer)
loadedByToken.set(token, loaded)
} catch (err) {
console.warn(`monster art ${token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
}
})
)
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`
await Promise.all(
[...tokenSpecs.entries()].map(async ([key, spec]) => {
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${entitiesUrl}/${slug}.json`),
fetch(`${entitiesUrl}/${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])
})
)
} else {
fallbackSpecs.push(...tokenSpecs.entries())
}
// Load any remaining specs via runtime MPQ archives
if (fallbackSpecs.length > 0) {
const archives = await getMountedDataArchives(bases)
if (archives) {
await Promise.all(
fallbackSpecs.map(async ([key, spec]) => {
try {
const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer)
loadedBySpecKey.set(key, loaded)
} catch (err) {
console.warn(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
}
})
)
}
}
// Map monster id to loaded art
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null) {
const loaded = loadedByToken.get(spec.token)
const key = `${spec.token}-${spec.weapon}`
const loaded = loadedBySpecKey.get(key)
if (loaded !== undefined) {
map.set(id.toLowerCase(), loaded)
}
@ -878,7 +959,42 @@ async function loadCharacterArt(
bases: readonly string[],
palette: Palette,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<LoadedCharacter | null> {
if (packBase !== undefined && 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
}
}
try {
const archives = await getMountedCharArchives(bases)
if (!archives) return null
@ -1289,18 +1405,33 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
const paletteForEntities = runtime.palette
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
// Collect monster types to load
const activeMonsterIds = runtime.monsterTypes.length > 0
? runtime.monsterTypes
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
if (activeMonsterIds.length > 0) {
void loadMonsterArtMap(DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer).then((loadedMap) => {
void loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
paletteForEntities,
renderer,
packEntityBase,
packEntityAct,
).then((loadedMap) => {
monsterArtMap = loadedMap
})
}
void loadCharacterArt(runtime.charBases, paletteForEntities, renderer).then((loaded) => {
void loadCharacterArt(
runtime.charBases,
paletteForEntities,
renderer,
packEntityBase,
packEntityAct,
).then((loaded) => {
character = loaded
state.character = loaded !== null
if (loaded !== null) state.characterMembers = loaded.members
@ -1313,7 +1444,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
: / 角色[^。]*。/u,
loaded === null
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
: ` 角色为真·女法师(DCC+COF 合成,${String(loaded.members)} 层)。`,
: ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(loaded.members)} 层)。`,
)
})
}
@ -1322,6 +1453,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
loop.stop()
input.detach()
if (character?.handle) renderer.deleteAtlas(character.handle)
for (const art of monsterArtMap.values()) {
renderer.deleteAtlas(art.handle)
}
for (const page of runtime.pages) {
if (page !== null) renderer.deleteAtlas(page)
}

View File

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
describe('offline pre-baked entity atlases (Issue #30)', () => {
const packRoot = join(process.cwd(), 'samples', 'd2-packs')
const hasPacks = existsSync(join(packRoot, 'act1', 'entities', 'char-so.json'))
it.skipIf(!hasPacks)('bakes Sorceress char-so PNG + JSON for Acts 1..5', () => {
for (let act = 1; act <= 5; act += 1) {
const dir = join(packRoot, `act${act}`, 'entities')
const jsonPath = join(dir, 'char-so.json')
const pngPath = join(dir, 'char-so.png')
expect(existsSync(jsonPath)).toBe(true)
expect(existsSync(pngPath)).toBe(true)
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
expect(meta.file).toBe('char-so.png')
expect(meta.directions).toBe(16)
expect(meta.walk).toBe(0)
expect(meta.stand).toBe(16)
expect(meta.members).toBeGreaterThanOrEqual(16)
expect(meta.groups.length).toBe(32) // 16 walk + 16 stand
const png = readFileSync(pngPath)
// Check PNG magic header
expect(png[0]).toBe(137)
expect(png[1]).toBe(80) // P
expect(png[2]).toBe(78) // N
expect(png[3]).toBe(71) // G
// Read IHDR width and height
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
expect(view.getUint32(16)).toBe(meta.width)
expect(view.getUint32(20)).toBe(meta.height)
}
})
it.skipIf(!hasPacks)('bakes monster PNG + JSON atlases for all mapped monster specs across Acts 1..5', () => {
const uniqueSpecs = new Map<string, { token: string; weapon: string }>()
for (const spec of Object.values(MONSTER_ART_MAP)) {
const key = `${spec.token}-${spec.weapon}`
if (!uniqueSpecs.has(key)) uniqueSpecs.set(key, spec)
}
for (let act = 1; act <= 5; act += 1) {
const dir = join(packRoot, `act${act}`, 'entities')
for (const spec of uniqueSpecs.values()) {
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
const jsonPath = join(dir, `${slug}.json`)
const pngPath = join(dir, `${slug}.png`)
expect(existsSync(jsonPath), `missing ${jsonPath}`).toBe(true)
expect(existsSync(pngPath), `missing ${pngPath}`).toBe(true)
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
expect(meta.token).toBe(spec.token)
expect(meta.weapon).toBe(spec.weapon)
expect(meta.directions).toBe(8)
expect(meta.walkOffset).toBe(0)
expect(meta.standOffset).toBe(8)
expect(meta.groups.length).toBe(16) // 8 walk + 8 stand
const png = readFileSync(pngPath)
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
expect(view.getUint32(16)).toBe(meta.width)
expect(view.getUint32(20)).toBe(meta.height)
}
}
})
})