Files

444 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Texture Studio maths (Texture tab): selections on RGBA pixels (magic wand, colour range, lasso,
// rectangle), recolouring a selection, resizing, and .dds writing the way the game's textures are
// stored (DXT1 / DXT3 / DXT5 or A8R8G8B8, with mip maps). Pure functions on Uint8(Clamped)Arrays,
// no DOM: the Texture tab draws, this computes.
const TexKit = (() => {
// =====================================================================
// selections: a mask of w × h bytes (0 = out, 255 = in, between = feathered)
// =====================================================================
const dist = (px, i, c) => Math.max(Math.abs(px[i] - c[0]), Math.abs(px[i + 1] - c[1]), Math.abs(px[i + 2] - c[2]), Math.abs(px[i + 3] - c[3]));
/** Pixels like the one at (x, y), within tol (0–255 per channel): touching it (contiguous) or anywhere. */
function wand(px, w, h, x, y, tol, contiguous = true) {
const out = new Uint8Array(w * h);
if (x < 0 || y < 0 || x >= w || y >= h) return out;
const s = (y * w + x) * 4;
const c = [px[s], px[s + 1], px[s + 2], px[s + 3]];
if (!contiguous) {
for (let i = 0; i < w * h; i++) if (dist(px, i * 4, c) <= tol) out[i] = 255;
return out;
}
const stack = new Int32Array(w * h);
let n = 0;
stack[n++] = y * w + x;
out[y * w + x] = 255;
while (n) {
const i = stack[--n];
const ix = i % w, iy = (i - ix) / w;
for (const j of [ix > 0 ? i - 1 : -1, ix < w - 1 ? i + 1 : -1, iy > 0 ? i - w : -1, iy < h - 1 ? i + w : -1]) {
if (j < 0 || out[j] || dist(px, j * 4, c) > tol) continue;
out[j] = 255;
stack[n++] = j;
}
}
return out;
}
/** A polygon [[x, y]…] (image pixels) filled even-odd, sampled at pixel centres. */
function polygon(w, h, pts) {
const out = new Uint8Array(w * h);
if (pts.length < 3) return out;
const xs = [];
for (let y = 0; y < h; y++) {
const cy = y + 0.5;
xs.length = 0;
for (let i = 0; i < pts.length; i++) {
const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length];
if ((y0 <= cy && y1 > cy) || (y1 <= cy && y0 > cy)) xs.push(x0 + ((cy - y0) / (y1 - y0)) * (x1 - x0));
}
xs.sort((a, b) => a - b);
for (let k = 0; k + 1 < xs.length; k += 2) {
const a = Math.max(0, Math.ceil(xs[k] - 0.5)), b = Math.min(w - 1, Math.floor(xs[k + 1] - 0.5));
for (let x = a; x <= b; x++) out[y * w + x] = 255;
}
}
return out;
}
const rect = (w, h, x0, y0, x1, y1) => polygon(w, h, [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);
/** Mask a combined with b: "new" | "add" | "sub" | "and". */
function combine(a, b, mode) {
if (mode === "new" || !a) return b;
const out = new Uint8Array(a.length);
for (let i = 0; i < a.length; i++)
out[i] = mode === "add" ? Math.max(a[i], b[i]) : mode === "sub" ? Math.min(a[i], 255 - b[i]) : Math.min(a[i], b[i]);
return out;
}
const invert = (m) => m.map((v) => 255 - v);
/** Grows (r > 0) or shrinks (r < 0) a mask by |r| pixels (square neighbourhood). */
function grow(m, w, h, r) {
if (!r) return m;
const pick = r > 0 ? Math.max : Math.min;
let cur = m;
for (let it = 0; it < Math.abs(r); it++) {
const out = new Uint8Array(cur.length);
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) {
let v = cur[y * w + x];
if (x > 0) v = pick(v, cur[y * w + x - 1]);
if (x < w - 1) v = pick(v, cur[y * w + x + 1]);
if (y > 0) v = pick(v, cur[(y - 1) * w + x]);
if (y < h - 1) v = pick(v, cur[(y + 1) * w + x]);
out[y * w + x] = v;
}
cur = out;
}
return cur;
}
/** Soft edges: a box blur of radius r (twice, about a Gaussian). */
function feather(m, w, h, r) {
if (!r) return m;
let a = Float32Array.from(m);
const pass = (src, horiz) => {
const out = new Float32Array(src.length);
const n = horiz ? w : h, lines = horiz ? h : w;
for (let l = 0; l < lines; l++) {
const at = (k) => (horiz ? l * w + k : k * w + l);
let sum = 0;
for (let k = -r; k <= r; k++) sum += src[at(Math.max(0, Math.min(n - 1, k)))];
for (let k = 0; k < n; k++) {
out[at(k)] = sum / (2 * r + 1);
sum += src[at(Math.min(n - 1, k + r + 1))] - src[at(Math.max(0, k - r))];
}
}
return out;
};
for (let i = 0; i < 2; i++) a = pass(pass(a, true), false);
return Uint8Array.from(a, (v) => Math.round(v));
}
const count = (m) => m.reduce((s, v) => s + (v >= 128 ? 1 : 0), 0);
// =====================================================================
// recolour
// =====================================================================
function rgb2hsl(r, g, b) {
r /= 255, g /= 255, b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
if (mx === mn) return [0, 0, l];
const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
const hh = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [hh / 6, s, l];
}
function hsl2rgb(hh, s, l) {
if (!s) return [l * 255, l * 255, l * 255];
const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
const f = (t) => {
t = ((t % 1) + 1) % 1;
return 255 * (t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p);
};
return [f(hh + 1 / 3), f(hh), f(hh - 1 / 3)];
}
/** How bright the selection's brightest parts are (98th percentile of max(r, g, b)), for colourise. */
function peak(px, mask) {
const hist = new Uint32Array(256);
let n = 0;
for (let i = 0; i < px.length / 4; i++) {
if (mask && mask[i] < 128) continue;
hist[Math.max(px[i * 4], px[i * 4 + 1], px[i * 4 + 2])]++;
n++;
}
let acc = 0;
for (let v = 0; v < 256; v++) if ((acc += hist[v]) >= n * 0.98) return Math.max(1, v);
return 255;
}
/**
* src → dst (same size) with the selection recoloured; outside it dst = src. opts:
* { mode: "colorize" | "adjust", color: [r, g, b], strength (0–1), bright (−1…1), hue (−180…180°),
* sat (−1…1), light (−1…1), contrast (−1…1), ref (colorize: the source's peak) }
* colorize: each pixel's brightness (its max channel against the selection's brightest) times the
* colour, so a dark hair texture keeps its strands; adjust: hue / saturation / lightness shifts.
*/
function recolor(src, dst, mask, opts) {
const o = { mode: "colorize", color: [200, 160, 60], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0, ...opts };
const ref = o.ref || peak(src, mask);
const gain = 1 + o.bright;
const k = 1 + o.contrast;
for (let i = 0; i < src.length / 4; i++) {
const p = i * 4, m = mask ? mask[i] / 255 : 1;
dst[p + 3] = src[p + 3];
if (!m) {
dst[p] = src[p], dst[p + 1] = src[p + 1], dst[p + 2] = src[p + 2];
continue;
}
let r = src[p], g = src[p + 1], b = src[p + 2];
if (o.mode === "colorize") {
const t = Math.max(r, g, b) / ref;
const c = o.color;
r = r + (c[0] * t * gain - r) * o.strength;
g = g + (c[1] * t * gain - g) * o.strength;
b = b + (c[2] * t * gain - b) * o.strength;
} else {
let [hh, s, l] = rgb2hsl(r, g, b);
hh += o.hue / 360;
s = o.sat >= 0 ? s + (1 - s) * o.sat : s * (1 + o.sat);
l = o.light >= 0 ? l + (1 - l) * o.light : l * (1 + o.light);
[r, g, b] = hsl2rgb(hh, Math.max(0, Math.min(1, s)), Math.max(0, Math.min(1, l)));
}
if (o.contrast) (r = (r - 128) * k + 128), (g = (g - 128) * k + 128), (b = (b - 128) * k + 128);
dst[p] = src[p] + (r - src[p]) * m;
dst[p + 1] = src[p + 1] + (g - src[p + 1]) * m;
dst[p + 2] = src[p + 2] + (b - src[p + 2]) * m;
}
return dst;
}
// =====================================================================
// sizes
// =====================================================================
const pow2 = (n) => 2 ** Math.floor(Math.log2(Math.max(1, n)));
/** RGBA w × h → nw × nh (area average going down, bilinear going up). */
function resize(px, w, h, nw, nh) {
if (nw === w && nh === h) return Uint8ClampedArray.from(px);
const out = new Uint8ClampedArray(nw * nh * 4);
const sx = w / nw, sy = h / nh;
for (let y = 0; y < nh; y++)
for (let x = 0; x < nw; x++) {
const acc = [0, 0, 0, 0];
let n = 0;
if (sx >= 1 && sy >= 1) {
for (let yy = Math.floor(y * sy); yy < Math.min(h, Math.ceil((y + 1) * sy)); yy++)
for (let xx = Math.floor(x * sx); xx < Math.min(w, Math.ceil((x + 1) * sx)); xx++) {
const p = (yy * w + xx) * 4;
for (let c = 0; c < 4; c++) acc[c] += px[p + c];
n++;
}
} else {
const fx = Math.max(0, (x + 0.5) * sx - 0.5), fy = Math.max(0, (y + 0.5) * sy - 0.5);
const x0 = Math.min(w - 1, Math.floor(fx)), y0 = Math.min(h - 1, Math.floor(fy));
const x1 = Math.min(w - 1, x0 + 1), y1 = Math.min(h - 1, y0 + 1), ax = fx - x0, ay = fy - y0;
for (const [xx, yy, f] of [[x0, y0, (1 - ax) * (1 - ay)], [x1, y0, ax * (1 - ay)], [x0, y1, (1 - ax) * ay], [x1, y1, ax * ay]]) {
const p = (yy * w + xx) * 4;
for (let c = 0; c < 4; c++) acc[c] += px[p + c] * f;
}
n = 1;
}
const q = (y * nw + x) * 4;
for (let c = 0; c < 4; c++) out[q + c] = acc[c] / n;
}
return out;
}
/** Every pixel opaque / alpha only 0 or 255 / real alpha. */
function alphaKind(px) {
let binary = true, opaque = true;
for (let i = 3; i < px.length; i += 4) {
const a = px[i];
if (a !== 255) opaque = false;
if (a !== 0 && a !== 255) {
binary = false;
break;
}
}
return opaque ? "opaque" : binary ? "binary" : "alpha";
}
// =====================================================================
// DXT (BC1 / BC2 / BC3) encoding
// =====================================================================
const to565 = (r, g, b) => ((Math.round((r * 31) / 255) << 11) | (Math.round((g * 63) / 255) << 5) | Math.round((b * 31) / 255)) >>> 0;
const from565 = (c) => [(((c >> 11) & 31) * 255) / 31, (((c >> 5) & 63) * 255) / 63, ((c & 31) * 255) / 31];
/** The 16 pixels of block (bx, by), edge pixels repeated: Float32Array(64). */
function block(px, w, h, bx, by) {
const b = new Float32Array(64);
for (let j = 0; j < 4; j++)
for (let i = 0; i < 4; i++) {
const x = Math.min(w - 1, bx * 4 + i), y = Math.min(h - 1, by * 4 + j);
const p = (y * w + x) * 4, q = (j * 4 + i) * 4;
b[q] = px[p], b[q + 1] = px[p + 1], b[q + 2] = px[p + 2], b[q + 3] = px[p + 3];
}
return b;
}
/** Colour part of a block (8 bytes) into out at o; use: which of the 16 pixels count (DXT1 transparent ones don't). */
function colorBlock(b, out, o, dxt1Alpha) {
const use = [];
for (let k = 0; k < 16; k++) if (!dxt1Alpha || b[k * 4 + 3] >= 128) use.push(k);
const dv = new DataView(out.buffer, out.byteOffset);
if (!use.length) {
// all transparent: 3-colour mode, every index 3 (transparent black)
dv.setUint16(o, 0, true), dv.setUint16(o + 2, 0, true), dv.setUint32(o + 4, 0xffffffff, true);
return;
}
// principal axis of the colours (a few power iterations), endpoints at the extremes along it
const mean = [0, 0, 0];
for (const k of use) for (let c = 0; c < 3; c++) mean[c] += b[k * 4 + c] / use.length;
const cov = [0, 0, 0, 0, 0, 0];
for (const k of use) {
const r = b[k * 4] - mean[0], g = b[k * 4 + 1] - mean[1], bl = b[k * 4 + 2] - mean[2];
cov[0] += r * r, cov[1] += r * g, cov[2] += r * bl, cov[3] += g * g, cov[4] += g * bl, cov[5] += bl * bl;
}
let ax = [1, 1, 1];
for (let it = 0; it < 6; it++) {
const n = [cov[0] * ax[0] + cov[1] * ax[1] + cov[2] * ax[2], cov[1] * ax[0] + cov[3] * ax[1] + cov[4] * ax[2], cov[2] * ax[0] + cov[4] * ax[1] + cov[5] * ax[2]];
const l = Math.hypot(...n);
if (l < 1e-6) break;
ax = n.map((v) => v / l);
}
let lo = Infinity, hi = -Infinity, kl = use[0], kh = use[0];
for (const k of use) {
const t = (b[k * 4] - mean[0]) * ax[0] + (b[k * 4 + 1] - mean[1]) * ax[1] + (b[k * 4 + 2] - mean[2]) * ax[2];
if (t < lo) (lo = t), (kl = k);
if (t > hi) (hi = t), (kh = k);
}
// the endpoints a touch inside the extremes (less error for the middle colours)
const inset = (k, toward) => [0, 1, 2].map((c) => b[k * 4 + c] + (b[toward * 4 + c] - b[k * 4 + c]) / 16);
const e0 = inset(kh, kl), e1 = inset(kl, kh);
let c0 = to565(...e0), c1 = to565(...e1);
const threeColor = dxt1Alpha && use.length < 16;
if (threeColor ? c0 > c1 : c0 < c1) [c0, c1] = [c1, c0];
if (!threeColor && c0 === c1) {
// one colour: 4-colour mode needs c0 > c1
if (c1 > 0) c1--;
else c0++;
}
const p0 = from565(c0), p1 = from565(c1);
const pal = threeColor
? [p0, p1, p0.map((v, i) => (v + p1[i]) / 2)]
: [p0, p1, p0.map((v, i) => (2 * v + p1[i]) / 3), p0.map((v, i) => (v + 2 * p1[i]) / 3)];
let bits = 0;
for (let k = 15; k >= 0; k--) {
let idx;
if (threeColor && b[k * 4 + 3] < 128) idx = 3;
else {
let best = Infinity;
idx = 0;
for (let i = 0; i < pal.length; i++) {
const c = pal[i], dr = c[0] - b[k * 4], dg = c[1] - b[k * 4 + 1], db = c[2] - b[k * 4 + 2];
const d = dr * dr + dg * dg + db * db;
if (d < best) (best = d), (idx = i);
}
}
bits = (bits << 2) | idx;
}
dv.setUint16(o, c0, true);
dv.setUint16(o + 2, c1, true);
dv.setUint32(o + 4, bits >>> 0, true);
}
/** DXT5 alpha (8 bytes): two endpoints, 3-bit indices. */
function alphaBlock5(b, out, o) {
let lo = 255, hi = 0;
for (let k = 0; k < 16; k++) (lo = Math.min(lo, b[k * 4 + 3])), (hi = Math.max(hi, b[k * 4 + 3]));
lo = Math.round(lo), hi = Math.round(hi);
// a0 > a1: the 8-value mode (one alpha: a0 = a1, every pixel takes index 0 exactly)
const a0 = hi, a1 = lo;
const pal = [a0, a1];
for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7);
out[o] = a0, out[o + 1] = a1;
// 16 indices × 3 bits: pixels 0–7 in the first 3 bytes, 8–15 in the next 3
for (let half = 0; half < 2; half++) {
let bits = 0;
for (let k = 7; k >= 0; k--) {
const a = b[(half * 8 + k) * 4 + 3];
let best = Infinity, idx = 0;
for (let i = 0; i < 8; i++) {
const d = Math.abs(pal[i] - a);
if (d < best) (best = d), (idx = i);
}
bits = (bits << 3) | idx;
}
for (let i = 0; i < 3; i++) out[o + 2 + half * 3 + i] = (bits >> (8 * i)) & 255;
}
}
/** DXT3 alpha (8 bytes): 4 bits a pixel. */
function alphaBlock3(b, out, o) {
for (let k = 0; k < 16; k += 2) out[o + k / 2] = (Math.round(b[k * 4 + 3] / 17) & 15) | ((Math.round(b[(k + 1) * 4 + 3] / 17) & 15) << 4);
}
/** One mip level → its DXT bytes. */
function dxt(px, w, h, kind) {
const bw = Math.max(1, Math.ceil(w / 4)), bh = Math.max(1, Math.ceil(h / 4));
const size = kind === "DXT1" ? 8 : 16;
const out = new Uint8Array(bw * bh * size);
for (let by = 0; by < bh; by++)
for (let bx = 0; bx < bw; bx++) {
const b = block(px, w, h, bx, by), o = (by * bw + bx) * size;
if (kind === "DXT1") colorBlock(b, out, o, true);
else {
(kind === "DXT5" ? alphaBlock5 : alphaBlock3)(b, out, o);
colorBlock(b, out, o + 8, false);
}
}
return out;
}
/** "auto" → what the game would use: opaque or cut-out (transparent black) DXT1, real alpha DXT5. */
const pickFormat = (px, format) => (format && format !== "auto" ? format : alphaKind(px) === "alpha" ? "DXT5" : "DXT1");
/**
* RGBA → a .dds: { format: "auto" | "DXT1" | "DXT3" | "DXT5" | "ARGB", mips: true }. → { bytes, format, levels }
*/
function encodeDds(px, w, h, opts = {}) {
const format = pickFormat(px, opts.format);
const levels = [];
let cur = px, cw = w, ch = h;
for (;;) {
levels.push(format === "ARGB" ? argb(cur) : dxt(cur, cw, ch, format));
if (!opts.mips || (cw === 1 && ch === 1)) break;
const nw = Math.max(1, cw >> 1), nh = Math.max(1, ch >> 1);
cur = resize(cur, cw, ch, nw, nh);
(cw = nw), (ch = nh);
}
const total = levels.reduce((s, l) => s + l.length, 0);
const out = new Uint8Array(128 + total);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x20534444, true); // "DDS "
dv.setUint32(4, 124, true);
const compressed = format !== "ARGB";
// caps | height | width | pixel format, + pitch or linear size, + mip map count
dv.setUint32(8, 0x1007 | (compressed ? 0x80000 : 0x8) | (levels.length > 1 ? 0x20000 : 0), true);
dv.setUint32(12, h, true);
dv.setUint32(16, w, true);
dv.setUint32(20, compressed ? levels[0].length : w * 4, true);
dv.setUint32(28, levels.length > 1 ? levels.length : 0, true);
dv.setUint32(76, 32, true);
if (compressed) {
dv.setUint32(80, 0x4, true); // FOURCC
for (let i = 0; i < 4; i++) out[84 + i] = format.charCodeAt(i);
} else {
dv.setUint32(80, 0x41, true); // RGB | alpha pixels
dv.setUint32(88, 32, true);
dv.setUint32(92, 0x00ff0000, true);
dv.setUint32(96, 0x0000ff00, true);
dv.setUint32(100, 0x000000ff, true);
dv.setUint32(104, 0xff000000, true);
}
dv.setUint32(108, 0x1000 | (levels.length > 1 ? 0x400008 : 0), true); // texture (| mip map | complex)
let o = 128;
for (const l of levels) out.set(l, o), (o += l.length);
return { bytes: out, format, levels: levels.length };
}
function argb(px) {
const out = new Uint8Array(px.length);
for (let i = 0; i < px.length; i += 4) (out[i] = px[i + 2]), (out[i + 1] = px[i + 1]), (out[i + 2] = px[i]), (out[i + 3] = px[i + 3]);
return out;
}
/** What a .dds is stored as (its header): { format, w, h, mips }. */
function ddsInfo(bytes) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < 128 || dv.getUint32(0, true) !== 0x20534444) return null;
const pf = dv.getUint32(80, true);
const format = pf & 4 ? String.fromCharCode(...bytes.subarray(84, 88)) : `RGB${dv.getUint32(88, true)}`;
return { format, w: dv.getUint32(16, true), h: dv.getUint32(12, true), mips: Math.max(1, dv.getUint32(28, true)) };
}
return { wand, polygon, rect, combine, invert, grow, feather, count, peak, recolor, rgb2hsl, hsl2rgb, resize, pow2, alphaKind, pickFormat, encodeDds, ddsInfo };
})();
if (typeof window !== "undefined") window.TexKit = TexKit;