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][][]
|
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 {
|
export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEntityR8 {
|
||||||
const opaqueBlack = findOpaqueBlackIndex(palette)
|
|
||||||
const frames = sheet.groups.flatMap(g => g.frames)
|
const frames = sheet.groups.flatMap(g => g.frames)
|
||||||
const widest = Math.max(1, ...frames.map(f => f.width))
|
const widest = Math.max(1, ...frames.map(f => f.width))
|
||||||
const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0)
|
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 pageHeight = Math.max(1, shelfY + shelfHeight)
|
||||||
const pixels = new Uint8Array(pageWidth * pageHeight)
|
const r8 = new Uint8Array(pageWidth * pageHeight)
|
||||||
|
|
||||||
let idx = 0
|
let idx = 0
|
||||||
for (const frame of frames) {
|
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) {
|
for (let col = 0; col < place.width; col += 1) {
|
||||||
const srcAt = fromRow + col
|
const srcAt = fromRow + col
|
||||||
if (frame.mask[srcAt] === 0) {
|
if (frame.mask[srcAt] === 0) {
|
||||||
pixels[toRow + col] = 0
|
r8[toRow + col] = 0
|
||||||
} else {
|
} else {
|
||||||
const val = frame.indices[srcAt]!
|
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)
|
groups.push(gList)
|
||||||
}
|
}
|
||||||
|
|
||||||
const png = encodeIndexedPng({ width: pageWidth, height: pageHeight, pixels, palette, transparentIndex: 0 })
|
return { r8, width: pageWidth, height: pageHeight, groups }
|
||||||
return { png, 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> {
|
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)
|
await Promise.all(workers)
|
||||||
console.log(`Composited ${compositedMonsters.size} unique monster specs across Acts 1..5.`)
|
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) {
|
for (let act = 1; act <= 5; act += 1) {
|
||||||
try {
|
try {
|
||||||
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
|
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')
|
const actEntityDir = join(outDir, `act${String(act)}`, 'entities')
|
||||||
await mkdir(actEntityDir, { recursive: true })
|
await mkdir(actEntityDir, { recursive: true })
|
||||||
|
|
||||||
// Pack character
|
// Pack character PNG from cached R8
|
||||||
const packedChar = packSheetToPng(charJoined, palette)
|
const charPng = encodeIndexedPng({
|
||||||
|
width: packedCharR8.width,
|
||||||
|
height: packedCharR8.height,
|
||||||
|
pixels: packedCharR8.r8,
|
||||||
|
palette,
|
||||||
|
transparentIndex: 0,
|
||||||
|
})
|
||||||
const charPngName = 'char-so.png'
|
const charPngName = 'char-so.png'
|
||||||
await writeFile(join(actEntityDir, charPngName), packedChar.png)
|
await writeFile(join(actEntityDir, charPngName), charPng)
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(actEntityDir, 'char-so.json'),
|
join(actEntityDir, 'char-so.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
file: charPngName,
|
file: charPngName,
|
||||||
width: packedChar.width,
|
width: packedCharR8.width,
|
||||||
height: packedChar.height,
|
height: packedCharR8.height,
|
||||||
directions: charWalk.directions,
|
directions: charWalk.directions,
|
||||||
walk: 0,
|
walk: 0,
|
||||||
stand: charWalk.sheet.groups.length,
|
stand: charWalk.sheet.groups.length,
|
||||||
members: charWalk.members.length + charStand.members.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) {
|
for (const spec of actSpecs) {
|
||||||
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||||
const m = compositedMonsters.get(key)
|
const m = compositedMonsters.get(key)
|
||||||
if (!m) continue
|
const packedMonR8 = packedMonstersR8.get(key)
|
||||||
const packedMon = packSheetToPng(m.sheet, palette)
|
if (!m || !packedMonR8) continue
|
||||||
|
const monPng = encodeIndexedPng({
|
||||||
|
width: packedMonR8.width,
|
||||||
|
height: packedMonR8.height,
|
||||||
|
pixels: packedMonR8.r8,
|
||||||
|
palette,
|
||||||
|
transparentIndex: 0,
|
||||||
|
})
|
||||||
const monPngName = `monster-${key}.png`
|
const monPngName = `monster-${key}.png`
|
||||||
await writeFile(join(actEntityDir, monPngName), packedMon.png)
|
await writeFile(join(actEntityDir, monPngName), monPng)
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(actEntityDir, `monster-${key}.json`),
|
join(actEntityDir, `monster-${key}.json`),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
token: m.token,
|
token: m.token,
|
||||||
weapon: m.weapon,
|
weapon: m.weapon,
|
||||||
file: monPngName,
|
file: monPngName,
|
||||||
width: packedMon.width,
|
width: packedMonR8.width,
|
||||||
height: packedMon.height,
|
height: packedMonR8.height,
|
||||||
directions: m.directions,
|
directions: m.directions,
|
||||||
walkOffset: m.walkOffset,
|
walkOffset: m.walkOffset,
|
||||||
standOffset: m.standOffset,
|
standOffset: m.standOffset,
|
||||||
walkFrames: m.walkFrames,
|
walkFrames: m.walkFrames,
|
||||||
standFrames: m.standFrames,
|
standFrames: m.standFrames,
|
||||||
groups: packedMon.groups,
|
groups: packedMonR8.groups,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
packedCount += 1
|
packedCount += 1
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ export async function compositeMonsterAnimation(
|
||||||
/**
|
/**
|
||||||
* Composite monster walk ('wl') and neutral ('nu') animations, and build a runtime atlas.
|
* 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 { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
||||||
import type { AtlasFrame } from '../render/atlas.ts'
|
import type { AtlasFrame } from '../render/atlas.ts'
|
||||||
import type { Palette } from '../formats/pal.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(
|
export async function loadMonsterAtlas(
|
||||||
archives: MountedArchives,
|
archives: MountedArchives,
|
||||||
token: string,
|
token: string,
|
||||||
weapon: string,
|
weapon: string,
|
||||||
palette: Palette,
|
_palette: Palette,
|
||||||
renderer: SpriteRenderer,
|
renderer: SpriteRenderer,
|
||||||
): Promise<LoadedMonsterArt> {
|
): Promise<LoadedMonsterArt> {
|
||||||
const [walk, stand] = await Promise.all([
|
const [walk, stand] = await Promise.all([
|
||||||
|
|
@ -264,12 +264,8 @@ export async function loadMonsterAtlas(
|
||||||
width: null,
|
width: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const atlas = buildAtlas(combined, palette, undefined, { width: 1024 })
|
const atlas = buildIndexedAtlas(combined, 172, { width: 1024 })
|
||||||
const handle = renderer.addAtlas({
|
const handle = renderer.addIndexedAtlas(atlas.r8, atlas.width, atlas.height)
|
||||||
pixels: atlas.pixels,
|
|
||||||
width: atlas.width,
|
|
||||||
height: atlas.height,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
|
|
|
||||||
|
|
@ -126,3 +126,83 @@ export function buildAtlas(
|
||||||
|
|
||||||
return { pixels, width: atlasWidth, height: atlasHeight, groups }
|
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
|
readonly texture: WebGLTexture
|
||||||
/** Texture width in pixels, for UV maths. */
|
/** Texture width in pixels, for UV maths. */
|
||||||
readonly width: number
|
readonly width: number
|
||||||
/** Texture height in pixels. */
|
/** Texture height in pixels, for UV maths. */
|
||||||
readonly height: number
|
readonly height: number
|
||||||
|
/** Whether this atlas stores 8-bit palette indices (R8 internal format). */
|
||||||
|
readonly indexed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Draw options for one sprite quad. */
|
/** Draw options for one sprite quad. */
|
||||||
|
|
@ -69,6 +71,15 @@ export interface DrawOptions {
|
||||||
readonly flipX?: boolean
|
readonly flipX?: boolean
|
||||||
/** Atlas page to sample; defaults to the most recently set atlas. */
|
/** Atlas page to sample; defaults to the most recently set atlas. */
|
||||||
readonly atlas?: AtlasHandle
|
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. */
|
/** Configuration options and event callbacks for SpriteRenderer. */
|
||||||
|
|
@ -83,19 +94,16 @@ export interface RendererOptions {
|
||||||
const VERTICES_PER_QUAD = 4
|
const VERTICES_PER_QUAD = 4
|
||||||
/** Indices per quad (two triangles). */
|
/** Indices per quad (two triangles). */
|
||||||
const INDICES_PER_QUAD = 6
|
const INDICES_PER_QUAD = 6
|
||||||
/** Floats per vertex: x, y, u, v, r, g, b, a, unit. */
|
/** Floats per vertex: x, y, u, v, r, g, b, a, unit, paletteRow. */
|
||||||
const FLOATS_PER_VERTEX = 9
|
const FLOATS_PER_VERTEX = 10
|
||||||
/** Initial batch capacity in quads. */
|
/** Initial batch capacity in quads. */
|
||||||
const INITIAL_CAPACITY = 8192
|
const INITIAL_CAPACITY = 8192
|
||||||
/**
|
/**
|
||||||
* Texture units the batcher will use at most.
|
* Texture units the batcher will use at most (reserving 1 unit for the hardware palette texture).
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const MAX_BATCH_TEXTURES = 16
|
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.
|
* Build the vertex shader.
|
||||||
|
|
@ -108,31 +116,32 @@ in vec2 a_position;
|
||||||
in vec2 a_uv;
|
in vec2 a_uv;
|
||||||
in vec4 a_tint;
|
in vec4 a_tint;
|
||||||
in float a_unit;
|
in float a_unit;
|
||||||
|
in float a_palette;
|
||||||
uniform vec2 u_camera;
|
uniform vec2 u_camera;
|
||||||
uniform vec2 u_viewport;
|
uniform vec2 u_viewport;
|
||||||
uniform float u_zoom;
|
uniform float u_zoom;
|
||||||
out vec2 v_uv;
|
out vec2 v_uv;
|
||||||
out vec4 v_tint;
|
out vec4 v_tint;
|
||||||
flat out int v_unit;
|
flat out int v_unit;
|
||||||
|
flat out int v_palette;
|
||||||
void main() {
|
void main() {
|
||||||
vec2 offset = (a_position - u_camera) * u_zoom;
|
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;
|
vec2 halfViewport = u_viewport * 0.5;
|
||||||
gl_Position = vec4(offset.x / halfViewport.x, -offset.y / halfViewport.y, 0.0, 1.0);
|
gl_Position = vec4(offset.x / halfViewport.x, -offset.y / halfViewport.y, 0.0, 1.0);
|
||||||
v_uv = a_uv;
|
v_uv = a_uv;
|
||||||
v_tint = a_tint;
|
v_tint = a_tint;
|
||||||
v_unit = int(a_unit);
|
v_unit = int(a_unit);
|
||||||
|
v_palette = int(a_palette);
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the fragment shader for a given texture-unit count.
|
* Build the fragment shader for a given texture-unit count.
|
||||||
*
|
*
|
||||||
* GLSL ES 3.00 forbids indexing a sampler array with a non-constant expression,
|
* Supports both standard RGBA textures (`v_palette < 0`) and 8-bit `R8` palette-indexed
|
||||||
* so the sampler is picked by a `switch` over constant cases. The case list is
|
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
|
||||||
* generated to match however many units the context actually offers.
|
|
||||||
*
|
*
|
||||||
* @param units - number of samplers to expose.
|
* @param units - number of batch samplers to expose.
|
||||||
* @returns GLSL ES 3.00 source.
|
* @returns GLSL ES 3.00 source.
|
||||||
*/
|
*/
|
||||||
function fragmentShaderSource(units: number): string {
|
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;`)
|
cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`)
|
||||||
}
|
}
|
||||||
return `#version 300 es
|
return `#version 300 es
|
||||||
precision mediump float;
|
precision highp float;
|
||||||
in vec2 v_uv;
|
in vec2 v_uv;
|
||||||
in vec4 v_tint;
|
in vec4 v_tint;
|
||||||
flat in int v_unit;
|
flat in int v_unit;
|
||||||
|
flat in int v_palette;
|
||||||
uniform sampler2D u_textures[${String(units)}];
|
uniform sampler2D u_textures[${String(units)}];
|
||||||
|
uniform sampler2D u_palette;
|
||||||
out vec4 outColor;
|
out vec4 outColor;
|
||||||
void main() {
|
void main() {
|
||||||
vec4 texel;
|
vec4 texel;
|
||||||
|
|
@ -153,6 +164,11 @@ void main() {
|
||||||
${cases.join('\n')}
|
${cases.join('\n')}
|
||||||
default: texel = texture(u_textures[0], v_uv); break;
|
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;
|
if (texel.a == 0.0) discard;
|
||||||
outColor = texel * v_tint;
|
outColor = texel * v_tint;
|
||||||
}`
|
}`
|
||||||
|
|
@ -175,6 +191,7 @@ export class SpriteRenderer {
|
||||||
private readonly indexBuffer: WebGLBuffer
|
private readonly indexBuffer: WebGLBuffer
|
||||||
private readonly atlasTexture: WebGLTexture
|
private readonly atlasTexture: WebGLTexture
|
||||||
private readonly whiteTexture: WebGLTexture
|
private readonly whiteTexture: WebGLTexture
|
||||||
|
private readonly paletteTexture: WebGLTexture
|
||||||
/** The 1×1 white page used by `drawSolid`. */
|
/** The 1×1 white page used by `drawSolid`. */
|
||||||
private readonly whiteAtlas: AtlasHandle
|
private readonly whiteAtlas: AtlasHandle
|
||||||
/** The default page (what `setAtlas` uploads into). */
|
/** The default page (what `setAtlas` uploads into). */
|
||||||
|
|
@ -183,13 +200,16 @@ export class SpriteRenderer {
|
||||||
private readonly uniformViewport: WebGLUniformLocation
|
private readonly uniformViewport: WebGLUniformLocation
|
||||||
private readonly uniformZoom: WebGLUniformLocation
|
private readonly uniformZoom: WebGLUniformLocation
|
||||||
private readonly uniformTextures: WebGLUniformLocation
|
private readonly uniformTextures: WebGLUniformLocation
|
||||||
|
private readonly uniformPalette: WebGLUniformLocation | null
|
||||||
private vertices: Float32Array
|
private vertices: Float32Array
|
||||||
private indices: Uint32Array
|
private indices: Uint32Array
|
||||||
private quadCount = 0
|
private quadCount = 0
|
||||||
private atlasWidth = 1
|
private atlasWidth = 1
|
||||||
private atlasHeight = 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
|
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. */
|
/** Pages bound to units `0..batchTextureCount-1` for the pending batch. */
|
||||||
private readonly batchTextures: (AtlasHandle | null)[]
|
private readonly batchTextures: (AtlasHandle | null)[]
|
||||||
private batchTextureCount = 0
|
private batchTextureCount = 0
|
||||||
|
|
@ -219,10 +239,10 @@ export class SpriteRenderer {
|
||||||
this.gl = gl
|
this.gl = gl
|
||||||
|
|
||||||
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
|
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
|
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.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
|
||||||
|
|
||||||
this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures))
|
this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures))
|
||||||
|
|
@ -231,7 +251,15 @@ export class SpriteRenderer {
|
||||||
const indexBuffer = gl.createBuffer()
|
const indexBuffer = gl.createBuffer()
|
||||||
const atlasTexture = gl.createTexture()
|
const atlasTexture = gl.createTexture()
|
||||||
const whiteTexture = 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')
|
throw new RendererError('WebGL2 resource allocation failed')
|
||||||
}
|
}
|
||||||
this.vao = vao
|
this.vao = vao
|
||||||
|
|
@ -239,8 +267,10 @@ export class SpriteRenderer {
|
||||||
this.indexBuffer = indexBuffer
|
this.indexBuffer = indexBuffer
|
||||||
this.atlasTexture = atlasTexture
|
this.atlasTexture = atlasTexture
|
||||||
this.whiteTexture = whiteTexture
|
this.whiteTexture = whiteTexture
|
||||||
|
this.paletteTexture = paletteTexture
|
||||||
this.allocatedTextures.add(atlasTexture)
|
this.allocatedTextures.add(atlasTexture)
|
||||||
this.allocatedTextures.add(whiteTexture)
|
this.allocatedTextures.add(whiteTexture)
|
||||||
|
this.allocatedTextures.add(paletteTexture)
|
||||||
this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 }
|
this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 }
|
||||||
this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 }
|
this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 }
|
||||||
this.vertices = new Float32Array(INITIAL_CAPACITY * VERTICES_PER_QUAD * FLOATS_PER_VERTEX)
|
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 attributeUv = gl.getAttribLocation(this.program, 'a_uv')
|
||||||
const attributeTint = gl.getAttribLocation(this.program, 'a_tint')
|
const attributeTint = gl.getAttribLocation(this.program, 'a_tint')
|
||||||
const attributeUnit = gl.getAttribLocation(this.program, 'a_unit')
|
const attributeUnit = gl.getAttribLocation(this.program, 'a_unit')
|
||||||
|
const attributePalette = gl.getAttribLocation(this.program, 'a_palette')
|
||||||
gl.bindVertexArray(vao)
|
gl.bindVertexArray(vao)
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
|
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.vertexAttribPointer(attributeUv, 2, gl.FLOAT, false, stride, 8)
|
||||||
gl.enableVertexAttribArray(attributeTint)
|
gl.enableVertexAttribArray(attributeTint)
|
||||||
gl.vertexAttribPointer(attributeTint, 4, gl.FLOAT, false, stride, 16)
|
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) {
|
if (attributeUnit >= 0) {
|
||||||
gl.enableVertexAttribArray(attributeUnit)
|
gl.enableVertexAttribArray(attributeUnit)
|
||||||
gl.vertexAttribPointer(attributeUnit, 1, gl.FLOAT, false, stride, 32)
|
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.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
|
||||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW)
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW)
|
||||||
gl.bindVertexArray(null)
|
gl.bindVertexArray(null)
|
||||||
|
|
@ -274,14 +307,17 @@ export class SpriteRenderer {
|
||||||
this.uniformViewport = requireUniform(gl, this.program, 'u_viewport')
|
this.uniformViewport = requireUniform(gl, this.program, 'u_viewport')
|
||||||
this.uniformZoom = requireUniform(gl, this.program, 'u_zoom')
|
this.uniformZoom = requireUniform(gl, this.program, 'u_zoom')
|
||||||
this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]')
|
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)
|
const samplerUnits = new Int32Array(this.maxBatchTextures)
|
||||||
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
|
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
|
||||||
gl.useProgram(this.program)
|
gl.useProgram(this.program)
|
||||||
gl.uniform1iv(this.uniformTextures, samplerUnits)
|
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.bindTexture(gl.TEXTURE_2D, texture)
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
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_MAG_FILTER, gl.NEAREST)
|
||||||
|
|
@ -290,6 +326,14 @@ export class SpriteRenderer {
|
||||||
}
|
}
|
||||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
|
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]))
|
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.enable(gl.BLEND)
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||||
gl.disable(gl.DEPTH_TEST)
|
gl.disable(gl.DEPTH_TEST)
|
||||||
|
|
@ -416,6 +460,122 @@ export class SpriteRenderer {
|
||||||
return { texture, width: pageWidth, height: pageHeight }
|
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.
|
* 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 frame - the atlas placement to draw.
|
||||||
* @param x - world-space left edge.
|
* @param x - world-space left edge.
|
||||||
* @param y - world-space top 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 {
|
draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void {
|
||||||
if (this.disposed) return
|
if (this.disposed) return
|
||||||
|
|
@ -487,6 +647,7 @@ export class SpriteRenderer {
|
||||||
const v1 = (frame.y + frame.height) / page.height
|
const v1 = (frame.y + frame.height) / page.height
|
||||||
const tint = options.tint
|
const tint = options.tint
|
||||||
const flipped = options.flipX === true
|
const flipped = options.flipX === true
|
||||||
|
const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1
|
||||||
this.quad(
|
this.quad(
|
||||||
x, y, x + frame.width, y + frame.height,
|
x, y, x + frame.width, y + frame.height,
|
||||||
flipped ? u1 : u0, v0,
|
flipped ? u1 : u0, v0,
|
||||||
|
|
@ -496,6 +657,7 @@ export class SpriteRenderer {
|
||||||
tint === undefined ? 1 : tint[2],
|
tint === undefined ? 1 : tint[2],
|
||||||
tint === undefined ? 1 : tint[3],
|
tint === undefined ? 1 : tint[3],
|
||||||
unit,
|
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 {
|
drawSolid(x: number, y: number, width: number, height: number, color: readonly [number, number, number, number]): void {
|
||||||
if (this.disposed) return
|
if (this.disposed) return
|
||||||
const unit = this.unitFor(this.whiteAtlas)
|
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
|
* 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.
|
* 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.
|
* @param page - the atlas page the caller wants to sample.
|
||||||
* @returns the texture unit index to write into the vertices.
|
* @returns the texture unit index to write into the vertices.
|
||||||
*/
|
*/
|
||||||
|
|
@ -543,30 +701,13 @@ export class SpriteRenderer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append one quad's four vertices.
|
* 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(
|
private quad(
|
||||||
x0: number, y0: number, x1: number, y1: number,
|
x0: number, y0: number, x1: number, y1: number,
|
||||||
u0: number, v0: number, u1: number, v1: number,
|
u0: number, v0: number, u1: number, v1: number,
|
||||||
r: number, g: number, b: number, a: number,
|
r: number, g: number, b: number, a: number,
|
||||||
unit: number,
|
unit: number,
|
||||||
|
paletteRow: number,
|
||||||
): void {
|
): void {
|
||||||
const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
|
const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
|
||||||
if (needed > this.vertices.length) this.grow(needed)
|
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
|
let at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX
|
||||||
// Corner 0: top-left.
|
// Corner 0: top-left.
|
||||||
v[at] = x0; v[at + 1] = y0; v[at + 2] = u0; v[at + 3] = v0
|
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
|
at += FLOATS_PER_VERTEX
|
||||||
// Corner 1: top-right.
|
// Corner 1: top-right.
|
||||||
v[at] = x1; v[at + 1] = y0; v[at + 2] = u1; v[at + 3] = v0
|
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
|
at += FLOATS_PER_VERTEX
|
||||||
// Corner 2: bottom-left.
|
// Corner 2: bottom-left.
|
||||||
v[at] = x0; v[at + 1] = y1; v[at + 2] = u0; v[at + 3] = v1
|
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
|
at += FLOATS_PER_VERTEX
|
||||||
// Corner 3: bottom-right.
|
// Corner 3: bottom-right.
|
||||||
v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v1
|
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.quadCount += 1
|
||||||
this.frameQuads += 1
|
this.frameQuads += 1
|
||||||
}
|
}
|
||||||
|
|
@ -599,14 +740,13 @@ export class SpriteRenderer {
|
||||||
gl.useProgram(this.program)
|
gl.useProgram(this.program)
|
||||||
gl.bindVertexArray(this.vao)
|
gl.bindVertexArray(this.vao)
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
|
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.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
|
||||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount)
|
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount)
|
||||||
gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y)
|
gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y)
|
||||||
gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight)
|
gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight)
|
||||||
gl.uniform1f(this.uniformZoom, this.camera.zoom)
|
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) {
|
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
|
||||||
const page = this.batchTextures[unit]
|
const page = this.batchTextures[unit]
|
||||||
if (page === undefined || page === null) continue
|
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 { 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 { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
import type { NpcDef } from '../game/quests.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 type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
|
||||||
import { SpriteRenderer } from '../render/renderer.ts'
|
import { SpriteRenderer } from '../render/renderer.ts'
|
||||||
import type { AtlasHandle } 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 loadedBySpecKey = new Map<string, LoadedMonsterArt>()
|
||||||
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
|
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
|
||||||
|
|
||||||
if (packBase !== undefined && act !== undefined) {
|
if (packBase !== undefined) {
|
||||||
const entitiesUrl = `${packBase}/act${String(act)}/entities`
|
const globalEntitiesUrl = `${packBase}/entities`
|
||||||
|
const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...tokenSpecs.entries()].map(async ([key, spec]) => {
|
[...tokenSpecs.entries()].map(async ([key, spec]) => {
|
||||||
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||||
|
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
|
||||||
try {
|
try {
|
||||||
const [jsonResp, pngResp] = await Promise.all([
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
fetch(`${entitiesUrl}/${slug}.json`),
|
fetch(`${globalEntitiesUrl}/${slug}.json`),
|
||||||
fetch(`${entitiesUrl}/${slug}.png`),
|
fetch(`${globalEntitiesUrl}/${slug}.r8`),
|
||||||
])
|
])
|
||||||
if (jsonResp.ok && pngResp.ok) {
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
const meta = (await jsonResp.json()) as PackedMonsterJson
|
const meta = (await jsonResp.json()) as PackedMonsterJson
|
||||||
const bitmap = await createImageBitmap(await pngResp.blob())
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
let handle: AtlasHandle
|
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
try {
|
|
||||||
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
|
||||||
} finally {
|
|
||||||
bitmap.close()
|
|
||||||
}
|
|
||||||
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
group.map(([x, y, width, height]) => ({ x, y, width, height }))
|
group.map(([x, y, width, height]) => ({ x, y, width, height }))
|
||||||
)
|
)
|
||||||
|
|
@ -912,7 +909,43 @@ async function loadMonsterArtMap(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} 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])
|
fallbackSpecs.push([key, spec])
|
||||||
})
|
})
|
||||||
|
|
@ -962,22 +995,17 @@ async function loadCharacterArt(
|
||||||
packBase?: string,
|
packBase?: string,
|
||||||
act?: number,
|
act?: number,
|
||||||
): Promise<LoadedCharacter | null> {
|
): Promise<LoadedCharacter | null> {
|
||||||
if (packBase !== undefined && act !== undefined) {
|
if (packBase !== undefined) {
|
||||||
const entitiesUrl = `${packBase}/act${String(act)}/entities`
|
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
|
||||||
try {
|
try {
|
||||||
const [jsonResp, pngResp] = await Promise.all([
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
fetch(`${entitiesUrl}/char-so.json`),
|
fetch(`${packBase}/entities/char-so.json`),
|
||||||
fetch(`${entitiesUrl}/char-so.png`),
|
fetch(`${packBase}/entities/char-so.r8`),
|
||||||
])
|
])
|
||||||
if (jsonResp.ok && pngResp.ok) {
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
const meta = (await jsonResp.json()) as PackedCharJson
|
const meta = (await jsonResp.json()) as PackedCharJson
|
||||||
const bitmap = await createImageBitmap(await pngResp.blob())
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
let handle: AtlasHandle
|
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
try {
|
|
||||||
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
|
||||||
} finally {
|
|
||||||
bitmap.close()
|
|
||||||
}
|
|
||||||
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
group.map(([x, y, width, height]) => ({ x, y, width, height }))
|
group.map(([x, y, width, height]) => ({ x, y, width, height }))
|
||||||
)
|
)
|
||||||
|
|
@ -991,7 +1019,41 @@ async function loadCharacterArt(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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'),
|
loadCharacterSheet(archives, 'so', 'nu', 'hth'),
|
||||||
])
|
])
|
||||||
const joined: SpriteSheet = { groups: [...walk.sheet.groups, ...stand.sheet.groups], width: null }
|
const joined: SpriteSheet = { groups: [...walk.sheet.groups, ...stand.sheet.groups], width: null }
|
||||||
const atlas = buildAtlas(joined, palette, undefined, { width: 1024 })
|
const atlas = buildIndexedAtlas(joined, 172, { width: 1024 })
|
||||||
const handle = renderer.addAtlas({ pixels: atlas.pixels, width: atlas.width, height: atlas.height })
|
const handle = renderer.addIndexedAtlas(atlas.r8, atlas.width, atlas.height)
|
||||||
const walkCount = walk.sheet.groups.length
|
const walkCount = walk.sheet.groups.length
|
||||||
return {
|
return {
|
||||||
handle,
|
handle,
|
||||||
|
|
@ -1404,6 +1466,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
||||||
|
|
||||||
preloadRemaining(runtime, renderer)
|
preloadRemaining(runtime, renderer)
|
||||||
if (runtime.palette !== null) {
|
if (runtime.palette !== null) {
|
||||||
|
renderer.setPalette(runtime.palette.rgb)
|
||||||
const paletteForEntities = runtime.palette
|
const paletteForEntities = runtime.palette
|
||||||
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||||||
const packEntityAct = runtime.source === 'pack' ? runtime.act : 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