"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;