From 270c743aef7a4fbbb7aa606b0cff703da826b42e Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 16 Sep 2026 01:16:38 +0000 Subject: [PATCH] perf(entities): offline pre-bake Sorceress and monster atlases into Act packs (#30) --- scripts/pack-act-assets.ts | 2 + scripts/pack-entity-assets.ts | 252 ++++++++++++++++++++++++++++++++ src/scene/act-scene.ts | 178 +++++++++++++++++++--- tests/packed-entity-art.test.ts | 70 +++++++++ 4 files changed, 480 insertions(+), 22 deletions(-) create mode 100644 scripts/pack-entity-assets.ts create mode 100644 tests/packed-entity-art.test.ts diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index d332d70..3f90ccf 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -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}`) diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts new file mode 100644 index 0000000..c771176 --- /dev/null +++ b/scripts/pack-entity-assets.ts @@ -0,0 +1,252 @@ +/** + * Bake character and monster animations into web-native indexed PNG atlases. + * + * Pre-packing entity animations into `samples/d2-packs/act/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 { + 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() + 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() + + 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) + }) +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 5a97750..d91fab8 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -821,48 +821,129 @@ async function getMountedCharArchives(bases: readonly string[]): Promise> { const map = new Map() 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() 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() - 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() + 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 { + 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) } diff --git a/tests/packed-entity-art.test.ts b/tests/packed-entity-art.test.ts new file mode 100644 index 0000000..1a6e010 --- /dev/null +++ b/tests/packed-entity-art.test.ts @@ -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() + 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) + } + } + }) +})