pw-guild-icon-parser
Version:
Parser for Perfect World guild icon lists - converts PNG icons to DDS atlas format with DXT5 compression
244 lines • 8.29 kB
JavaScript
/**
* DXT5 compression with alpha channel support
* Compresses 4x4 pixel blocks to 16 bytes (8 bytes for alpha, 8 bytes for color)
*/
/**
* Compress a 4x4 RGBA block to DXT5 format
*/
export function compressDXT5Block(rgbaData, offset, width) {
// Extract 4x4 block
const block = [];
for (let y = 0; y < 4; y++) {
for (let x = 0; x < 4; x++) {
const idx = offset + (y * width + x) * 4;
if (idx + 3 < rgbaData.length) {
block.push(rgbaData[idx]); // R
block.push(rgbaData[idx + 1]); // G
block.push(rgbaData[idx + 2]); // B
block.push(rgbaData[idx + 3]); // A
}
else {
// Pad with transparent black
block.push(0, 0, 0, 0);
}
}
}
// Compress alpha channel (8 bytes)
const alphaBlock = compressAlphaBlock(block);
// Compress color (RGB, ignoring alpha) - DXT1 style (8 bytes)
const colorBlock = compressColorBlock(block);
return Buffer.concat([alphaBlock, colorBlock]);
}
/**
* Compress alpha channel for 4x4 block
* Returns 8 bytes: 2 alpha endpoints + 6-bit indices
*/
function compressAlphaBlock(block) {
// Extract alpha values (every 4th value starting at index 3)
const alphas = [];
for (let i = 3; i < block.length; i += 4) {
alphas.push(block[i]);
}
// Find min and max alpha
let minAlpha = 255;
let maxAlpha = 0;
for (const alpha of alphas) {
if (alpha < minAlpha)
minAlpha = alpha;
if (alpha > maxAlpha)
maxAlpha = alpha;
}
// If min == max, we need to ensure they're different for the algorithm
if (minAlpha === maxAlpha) {
if (maxAlpha > 0) {
minAlpha = Math.max(0, maxAlpha - 1);
}
else {
maxAlpha = Math.min(255, minAlpha + 1);
}
}
const result = Buffer.alloc(8);
result.writeUInt8(minAlpha, 0);
result.writeUInt8(maxAlpha, 1);
// Calculate indices for each pixel
const indices = [];
for (let i = 0; i < 16; i++) {
const alpha = alphas[i];
let index;
if (maxAlpha > minAlpha) {
// Calculate 6 interpolation values
const a0 = minAlpha;
const a1 = maxAlpha;
const a2 = Math.floor((6 * a0 + 1 * a1) / 7);
const a3 = Math.floor((5 * a0 + 2 * a1) / 7);
const a4 = Math.floor((4 * a0 + 3 * a1) / 7);
const a5 = Math.floor((3 * a0 + 4 * a1) / 7);
const a6 = Math.floor((2 * a0 + 5 * a1) / 7);
const a7 = Math.floor((1 * a0 + 6 * a1) / 7);
// Find closest value
const values = [a0, a1, a2, a3, a4, a5, a6, a7];
let minDist = Infinity;
index = 0;
for (let j = 0; j < 8; j++) {
const dist = Math.abs(alpha - values[j]);
if (dist < minDist) {
minDist = dist;
index = j;
}
}
}
else {
index = 0;
}
indices.push(index);
}
// Pack 3-bit indices into 6 bytes (48 bits total for 16 pixels)
// DXT5 alpha block format: bytes 2-7 contain 16 3-bit indices
// Packed as: [pixel0(3 bits)][pixel1(3 bits)][pixel2(3 bits)]...
// This spans across byte boundaries
// Use BigInt to handle large bit shifts safely
let bitBuffer = BigInt(0);
let bitCount = 0;
let byteOffset = 2;
for (let i = 0; i < 16; i++) {
// Ensure index is only 3 bits
const index = BigInt(indices[i] & 0x7);
// Add to bit buffer
bitBuffer |= (index << BigInt(bitCount));
bitCount += 3;
// Write bytes as they fill up
while (bitCount >= 8 && byteOffset < 8) {
result.writeUInt8(Number(bitBuffer & BigInt(0xFF)), byteOffset);
byteOffset++;
// Shift right by 8 bits
bitBuffer = bitBuffer >> BigInt(8);
bitCount -= 8;
}
}
// Write remaining bits if any
if (bitCount > 0 && byteOffset < 8) {
result.writeUInt8(Number(bitBuffer & BigInt(0xFF)), byteOffset);
}
return result;
}
/**
* Compress RGB color for 4x4 block (DXT1 style)
* Returns 8 bytes: 2 color endpoints + 2-bit indices
*/
function compressColorBlock(block) {
// Extract RGB values
const colors = [];
for (let i = 0; i < block.length; i += 4) {
colors.push([block[i], block[i + 1], block[i + 2]]); // R, G, B
}
// Find two representative colors (min and max in color space)
// Use simple method: find colors that minimize error
let bestColor0 = colors[0];
let bestColor1 = colors[1];
let minError = Infinity;
// Try different color pairs
for (let i = 0; i < colors.length; i++) {
for (let j = i + 1; j < colors.length; j++) {
const error = calculateColorError(colors, colors[i], colors[j]);
if (error < minError) {
minError = error;
bestColor0 = colors[i];
bestColor1 = colors[j];
}
}
}
// Convert RGB565
const color0 = rgbTo565(bestColor0[0], bestColor0[1], bestColor0[2]);
const color1 = rgbTo565(bestColor1[0], bestColor1[1], bestColor1[2]);
const result = Buffer.alloc(8);
result.writeUInt16LE(color0, 0);
result.writeUInt16LE(color1, 2);
// Calculate indices (using BigInt to avoid overflow)
let indices = BigInt(0);
for (let i = 0; i < 16; i++) {
const color = colors[i];
const index = findClosestColorIndex(color, bestColor0, bestColor1, color0, color1);
indices |= (BigInt(index) << BigInt(i * 2));
}
// Convert to uint32, ensuring it fits
const indicesUint32 = Number(indices & BigInt(0xFFFFFFFF));
result.writeUInt32LE(indicesUint32, 4);
return result;
}
function rgbTo565(r, g, b) {
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
}
function findClosestColorIndex(color, c0, c1, color0, color1) {
// Calculate 4 interpolated colors
const colors = [
c0,
c1,
[
Math.floor((2 * c0[0] + c1[0]) / 3),
Math.floor((2 * c0[1] + c1[1]) / 3),
Math.floor((2 * c0[2] + c1[2]) / 3)
],
[
Math.floor((c0[0] + 2 * c1[0]) / 3),
Math.floor((c0[1] + 2 * c1[1]) / 3),
Math.floor((c0[2] + 2 * c1[2]) / 3)
]
];
let minDist = Infinity;
let bestIndex = 0;
for (let i = 0; i < 4; i++) {
const dist = colorDistance(color, colors[i]);
if (dist < minDist) {
minDist = dist;
bestIndex = i;
}
}
return bestIndex;
}
function colorDistance(c1, c2) {
const dr = c1[0] - c2[0];
const dg = c1[1] - c2[1];
const db = c1[2] - c2[2];
return dr * dr + dg * dg + db * db;
}
function calculateColorError(colors, c0, c1) {
let totalError = 0;
for (const color of colors) {
const index = findClosestColorIndex(color, c0, c1, 0, 0);
const colorsArr = [
c0,
c1,
[
Math.floor((2 * c0[0] + c1[0]) / 3),
Math.floor((2 * c0[1] + c1[1]) / 3),
Math.floor((2 * c0[2] + c1[2]) / 3)
],
[
Math.floor((c0[0] + 2 * c1[0]) / 3),
Math.floor((c0[1] + 2 * c1[1]) / 3),
Math.floor((c0[2] + 2 * c1[2]) / 3)
]
];
totalError += colorDistance(color, colorsArr[index]);
}
return totalError;
}
/**
* Compress entire image to DXT5 format
*/
export function compressDXT5(width, height, rgbaData) {
const blocks = [];
const blockWidth = Math.ceil(width / 4);
const blockHeight = Math.ceil(height / 4);
for (let blockY = 0; blockY < blockHeight; blockY++) {
for (let blockX = 0; blockX < blockWidth; blockX++) {
const x = blockX * 4;
const y = blockY * 4;
const offset = (y * width + x) * 4;
const block = compressDXT5Block(rgbaData, offset, width);
blocks.push(block);
}
}
return Buffer.concat(blocks);
}
//# sourceMappingURL=dxt5.js.map