176 lines
5.6 KiB
JavaScript
176 lines
5.6 KiB
JavaScript
"use strict";
|
||
|
||
// DTF – Dusk Texture Format
|
||
//
|
||
// Header (13 bytes):
|
||
// [0–2] "DTF" magic
|
||
// [3] 0x01 version
|
||
// [4–7] uint32 width (little-endian)
|
||
// [8–11] uint32 height (little-endian)
|
||
// [12] uint8 format
|
||
//
|
||
// Formats:
|
||
// 0x01 Alpha – 1 byte per pixel (alpha channel only)
|
||
// 0x03 RGB – 3 bytes per pixel (no alpha)
|
||
// 0x04 RGBA – 4 bytes per pixel
|
||
//
|
||
// Followed by width × height × bpp bytes of tightly-packed pixel data.
|
||
|
||
const DTF = (() => {
|
||
const MAGIC = [0x44, 0x54, 0x46]; // "DTF"
|
||
const VERSION = 0x01;
|
||
const FORMAT_ALPHA = 0x01;
|
||
const FORMAT_RGB = 0x03;
|
||
const FORMAT_RGBA = 0x04;
|
||
const HEADER_SIZE = 13;
|
||
|
||
// Bytes per pixel for each format.
|
||
const BPP = {
|
||
[FORMAT_ALPHA]: 1,
|
||
[FORMAT_RGB]: 3,
|
||
[FORMAT_RGBA]: 4,
|
||
};
|
||
|
||
// Encode RGBA source pixels into a DTF ArrayBuffer at the given format.
|
||
// When format is FORMAT_ALPHA and redAsAlpha is true, the red channel is
|
||
// used as the alpha value instead of the actual alpha channel.
|
||
function encode(width, height, rgbaData, format, redAsAlpha) {
|
||
if (format === undefined) format = FORMAT_RGBA;
|
||
const bpp = BPP[format];
|
||
if (bpp === undefined) throw new Error(`Unknown DTF format: 0x${format.toString(16)}`);
|
||
|
||
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
|
||
const buf = new ArrayBuffer(HEADER_SIZE + width * height * bpp);
|
||
const bytes = new Uint8Array(buf);
|
||
const view = new DataView(buf);
|
||
|
||
bytes[0] = MAGIC[0];
|
||
bytes[1] = MAGIC[1];
|
||
bytes[2] = MAGIC[2];
|
||
bytes[3] = VERSION;
|
||
view.setUint32(4, width, true);
|
||
view.setUint32(8, height, true);
|
||
bytes[12] = format;
|
||
|
||
let dst = HEADER_SIZE;
|
||
for (let i = 0; i < width * height; i++) {
|
||
const o = i * 4;
|
||
switch (format) {
|
||
case FORMAT_ALPHA:
|
||
bytes[dst++] = redAsAlpha ? src[o] : src[o + 3];
|
||
break;
|
||
case FORMAT_RGB:
|
||
bytes[dst++] = src[o];
|
||
bytes[dst++] = src[o + 1];
|
||
bytes[dst++] = src[o + 2];
|
||
break;
|
||
default: // FORMAT_RGBA
|
||
bytes[dst++] = src[o];
|
||
bytes[dst++] = src[o + 1];
|
||
bytes[dst++] = src[o + 2];
|
||
bytes[dst++] = src[o + 3];
|
||
}
|
||
}
|
||
|
||
return buf;
|
||
}
|
||
|
||
// Decode a DTF ArrayBuffer. Always returns RGBA pixel data for internal use.
|
||
// Alpha-format files decode as {R=0, G=0, B=0, A=alpha} so the alpha channel
|
||
// is preserved and toImageData() can display it correctly.
|
||
function decode(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
const view = new DataView(buffer);
|
||
|
||
if (bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DTF");
|
||
if (bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] || bytes[2] !== MAGIC[2]) {
|
||
throw new Error("Invalid DTF magic bytes – not a DTF file");
|
||
}
|
||
|
||
const version = bytes[3];
|
||
if (version !== VERSION) {
|
||
throw new Error(`Unsupported DTF version: 0x${version.toString(16).padStart(2, "0")}`);
|
||
}
|
||
|
||
const width = view.getUint32(4, true);
|
||
const height = view.getUint32(8, true);
|
||
const format = bytes[12];
|
||
const bpp = BPP[format];
|
||
|
||
if (bpp === undefined) {
|
||
throw new Error(`Unsupported DTF format: 0x${format.toString(16).padStart(2, "0")}`);
|
||
}
|
||
|
||
const expected = HEADER_SIZE + width * height * bpp;
|
||
if (bytes.length < expected) {
|
||
throw new Error(`DTF pixel data truncated (expected ${expected} bytes, got ${bytes.length})`);
|
||
}
|
||
|
||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||
let src = HEADER_SIZE;
|
||
for (let i = 0; i < width * height; i++) {
|
||
const o = i * 4;
|
||
switch (format) {
|
||
case FORMAT_ALPHA:
|
||
rgba[o] = rgba[o + 1] = rgba[o + 2] = 0;
|
||
rgba[o + 3] = bytes[src++];
|
||
break;
|
||
case FORMAT_RGB:
|
||
rgba[o] = bytes[src++];
|
||
rgba[o + 1] = bytes[src++];
|
||
rgba[o + 2] = bytes[src++];
|
||
rgba[o + 3] = 255;
|
||
break;
|
||
default: // FORMAT_RGBA
|
||
rgba[o] = bytes[src++];
|
||
rgba[o + 1] = bytes[src++];
|
||
rgba[o + 2] = bytes[src++];
|
||
rgba[o + 3] = bytes[src++];
|
||
}
|
||
}
|
||
|
||
return { width, height, format, data: rgba };
|
||
}
|
||
|
||
// Convert RGBA source pixels to a display-ready ImageData for the given format.
|
||
// Shows exactly how the texture will look after a DTF encode/decode round-trip.
|
||
// Alpha → grayscale from alpha channel (or red if redAsAlpha), out-alpha=255
|
||
// RGB → discard alpha, fully opaque
|
||
// RGBA → pass-through
|
||
function toImageData(width, height, rgbaData, format, redAsAlpha) {
|
||
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
|
||
const out = new Uint8ClampedArray(width * height * 4);
|
||
|
||
for (let i = 0; i < width * height; i++) {
|
||
const o = i * 4;
|
||
switch (format) {
|
||
case FORMAT_ALPHA: {
|
||
const v = redAsAlpha ? src[o] : src[o + 3];
|
||
out[o] = out[o + 1] = out[o + 2] = v;
|
||
out[o + 3] = v; // use value as canvas alpha so background shows through transparent areas
|
||
break;
|
||
}
|
||
case FORMAT_RGB:
|
||
out[o] = src[o];
|
||
out[o + 1] = src[o + 1];
|
||
out[o + 2] = src[o + 2];
|
||
out[o + 3] = 255;
|
||
break;
|
||
default: // FORMAT_RGBA
|
||
out[o] = src[o];
|
||
out[o + 1] = src[o + 1];
|
||
out[o + 2] = src[o + 2];
|
||
out[o + 3] = src[o + 3];
|
||
}
|
||
}
|
||
|
||
return new ImageData(out, width, height);
|
||
}
|
||
|
||
return Object.freeze({
|
||
encode, decode, toImageData,
|
||
FORMAT_ALPHA, FORMAT_RGB, FORMAT_RGBA,
|
||
VERSION, HEADER_SIZE, BPP,
|
||
});
|
||
})();
|