@aethervtt/shard-texture 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ let ready;
3
+ function decoder() {
4
+ ready ??= (async () => {
5
+ const mod = await import("@jsquash/webp/decode");
6
+ if (typeof process !== "undefined" && process.versions?.node) {
7
+ const wasmUrl = new URL(
8
+ "./codec/dec/webp_dec.wasm",
9
+ import.meta.resolve("@jsquash/webp/decode")
10
+ );
11
+ const { readFile } = await import("node:fs/promises");
12
+ await mod.init(await WebAssembly.compile(await readFile(wasmUrl)));
13
+ }
14
+ return mod.default;
15
+ })();
16
+ return ready;
17
+ }
18
+ async function decodeWebp(bytes) {
19
+ try {
20
+ const decode = await decoder();
21
+ const copy = bytes.slice();
22
+ const img = await decode(copy.buffer);
23
+ return {
24
+ width: img.width,
25
+ height: img.height,
26
+ kind: "u8",
27
+ data: new Uint8Array(img.data.buffer)
28
+ };
29
+ } catch (cause) {
30
+ throw new ShardError(
31
+ "texture/decode-failed",
32
+ `WebP decode failed: ${cause.message}`,
33
+ { cause }
34
+ );
35
+ }
36
+ }
37
+ export {
38
+ decodeWebp
39
+ };
@@ -0,0 +1,7 @@
1
+ /** A decoded image: RGBA8 (`u8`) or RGBA float (`f32`, linear, for HDR). Top row first. */
2
+ export interface Image {
3
+ width: number;
4
+ height: number;
5
+ kind: 'u8' | 'f32';
6
+ data: Uint8Array | Float32Array;
7
+ }
package/dist/image.js ADDED
File without changes
@@ -0,0 +1,34 @@
1
+ import { type JsonValue } from '@aethervtt/shard-core';
2
+ import type { Image } from './image.js';
3
+ import { type TextureUsage } from './mips.js';
4
+ export declare const TextureImportSettings: import("@aethervtt/shard-core").ComponentDef<{
5
+ readonly usage: import("@aethervtt/shard-core").FieldType<"color" | "data" | "hdr" | "normal", "u8">;
6
+ readonly mipmaps: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
7
+ readonly compression: import("@aethervtt/shard-core").FieldType<"etc1s" | "none" | "uastc", "u8">;
8
+ readonly maxSize: import("@aethervtt/shard-core").FieldType<number, "u32">;
9
+ readonly flipY: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
10
+ readonly premultiplyAlpha: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
11
+ readonly normalMap: import("@aethervtt/shard-core").FieldType<"directx" | "opengl", "u8">;
12
+ }>;
13
+ export interface TextureSettings {
14
+ usage: TextureUsage;
15
+ mipmaps: boolean;
16
+ compression: 'none' | 'etc1s' | 'uastc';
17
+ maxSize: number;
18
+ flipY: boolean;
19
+ premultiplyAlpha: boolean;
20
+ normalMap?: 'opengl' | 'directx';
21
+ }
22
+ /** Flips a normal map's green channel in place (DirectX → OpenGL, or back). */
23
+ export declare function flipGreen(image: Image): Image;
24
+ /** Usage from the file name, for a new `.meta`. */
25
+ export declare function usageFromName(path: string): TextureUsage;
26
+ /**
27
+ * Imports image bytes into a KTX2 artifact. Shared by the file importer and by glTF (embedded
28
+ * images), so the same bytes become the same artifact wherever they come from.
29
+ */
30
+ export declare function importImageBytes(bytes: Uint8Array, settings: TextureSettings, warn?: (message: string) => void): Promise<{
31
+ bytes: Uint8Array;
32
+ info: Record<string, JsonValue>;
33
+ }>;
34
+ export declare const TextureImporter: import("@aethervtt/shard-assets").ImporterDef;
@@ -0,0 +1,168 @@
1
+ import { defineImporter } from "@aethervtt/shard-assets";
2
+ import { defineSchema, ShardError, t } from "@aethervtt/shard-core";
3
+ import { encodeBasis } from "./basis.js";
4
+ import { decodeImage, sniffImage } from "./decode/index.js";
5
+ import { readKtx2, tagKtx2, writeKtx2 } from "./ktx2.js";
6
+ import { buildMips } from "./mips.js";
7
+ const TextureImportSettings = defineSchema(
8
+ "texture/ImportSettings",
9
+ {
10
+ usage: t.enum(["color", "data", "normal", "hdr"], {
11
+ description: "color: sRGB albedo/emissive. data: linear masks (roughness, metallic, AO). normal: tangent-space normal map. hdr: linear float (environment maps)."
12
+ }),
13
+ mipmaps: t.bool({ default: true, description: "Generate a full mip chain." }),
14
+ compression: t.enum(["none", "etc1s", "uastc"], {
15
+ description: "Basis Universal. uastc: high quality, larger. etc1s: small, lossy. Encoding takes seconds per texture on first import; the cache keeps the result."
16
+ }),
17
+ maxSize: t.u32({ default: 4096, min: 1, max: 16384, description: "Downscale larger images." }),
18
+ flipY: t.bool({ description: "Flip vertically on import." }),
19
+ premultiplyAlpha: t.bool({ description: "Multiply color by alpha on import." }),
20
+ normalMap: t.enum(["opengl", "directx"], {
21
+ description: "Normal maps: opengl (+Y up, the engine convention) or directx (+Y down: green is flipped on import)."
22
+ })
23
+ },
24
+ { description: "Import settings for images (PNG, JPEG, WebP, .hdr, KTX2)." }
25
+ );
26
+ function flipGreen(image) {
27
+ const d = image.data;
28
+ if (image.kind === "u8") for (let i = 1; i < d.length; i += 4) d[i] = 255 - d[i];
29
+ else for (let i = 1; i < d.length; i += 4) d[i] = 1 - d[i];
30
+ return image;
31
+ }
32
+ function usageFromName(path) {
33
+ const name = path.toLowerCase().split("/").pop();
34
+ if (name.endsWith(".hdr")) return "hdr";
35
+ if (/normal|_n\.|_nrm/.test(name)) return "normal";
36
+ if (/rough|metal|_orm|occlusion|_ao[._]|_arm|mask/.test(name)) return "data";
37
+ return "color";
38
+ }
39
+ function padTo4(rgba, w, h) {
40
+ const pw = Math.ceil(w / 4) * 4;
41
+ const ph = Math.ceil(h / 4) * 4;
42
+ if (pw === w && ph === h) return { data: rgba, width: w, height: h };
43
+ const out = new Uint8Array(pw * ph * 4);
44
+ for (let y = 0; y < ph; y++) {
45
+ const sy = Math.min(h - 1, y);
46
+ for (let x = 0; x < pw; x++) {
47
+ const sx = Math.min(w - 1, x);
48
+ out.set(rgba.subarray((sy * w + sx) * 4, (sy * w + sx) * 4 + 4), (y * pw + x) * 4);
49
+ }
50
+ }
51
+ return { data: out, width: pw, height: ph };
52
+ }
53
+ function toLinearFloat(image) {
54
+ if (image.kind === "f32") return image;
55
+ const src = image.data;
56
+ const out = new Float32Array(src.length);
57
+ for (let i = 0; i < src.length; i++) {
58
+ const c = src[i] / 255;
59
+ out[i] = i % 4 === 3 ? c : c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
60
+ }
61
+ return { width: image.width, height: image.height, kind: "f32", data: out };
62
+ }
63
+ async function importImageBytes(bytes, settings, warn = () => {
64
+ }) {
65
+ const format = sniffImage(bytes);
66
+ if (format === "ktx2") {
67
+ const ktx = readKtx2(bytes);
68
+ const tagged = tagKtx2(bytes, settings.usage);
69
+ return {
70
+ bytes: tagged,
71
+ info: {
72
+ width: ktx.width,
73
+ height: ktx.height,
74
+ source: "ktx2",
75
+ compression: ktx.basis ?? "none",
76
+ mips: ktx.levels.length,
77
+ usage: settings.usage
78
+ }
79
+ };
80
+ }
81
+ let image = await decodeImage(bytes);
82
+ const usage = settings.usage;
83
+ if (usage === "normal" && settings.normalMap === "directx") image = flipGreen(image);
84
+ if (usage === "hdr") image = toLinearFloat(image);
85
+ else if (image.kind === "f32") {
86
+ warn(
87
+ 'A float image imported as LDR is clamped to [0, 1]; set usage to "hdr" to keep its range.'
88
+ );
89
+ }
90
+ const info = { source: format ?? "unknown", usage };
91
+ if (settings.compression !== "none" && usage !== "hdr") {
92
+ const base = buildMips(image, { ...settings, mipmaps: false });
93
+ const padded = padTo4(base.levels[0], base.width, base.height);
94
+ if (padded.width !== base.width || padded.height !== base.height) {
95
+ warn(
96
+ `Padded ${base.width}x${base.height} to ${padded.width}x${padded.height}: Basis needs multiples of 4.`
97
+ );
98
+ }
99
+ const encoded = await encodeBasis(
100
+ padded.data,
101
+ padded.width,
102
+ padded.height,
103
+ usage,
104
+ settings.compression,
105
+ settings.mipmaps
106
+ );
107
+ const out2 = tagKtx2(encoded, usage, settings.premultiplyAlpha);
108
+ const ktx = readKtx2(out2);
109
+ return {
110
+ bytes: out2,
111
+ info: {
112
+ ...info,
113
+ width: ktx.width,
114
+ height: ktx.height,
115
+ mips: ktx.levels.length,
116
+ compression: settings.compression,
117
+ bytes: out2.byteLength
118
+ }
119
+ };
120
+ }
121
+ if (settings.compression !== "none") warn("HDR textures are stored uncompressed (rgba16float).");
122
+ const chain = buildMips(image, settings);
123
+ const out = writeKtx2(chain, usage, 1, settings.premultiplyAlpha);
124
+ return {
125
+ bytes: out,
126
+ info: {
127
+ ...info,
128
+ width: chain.width,
129
+ height: chain.height,
130
+ mips: chain.levels.length,
131
+ compression: "none",
132
+ bytes: out.byteLength
133
+ }
134
+ };
135
+ }
136
+ const TextureImporter = defineImporter({
137
+ name: "texture",
138
+ version: 1,
139
+ extensions: [".png", ".jpg", ".jpeg", ".webp", ".hdr", ".ktx2"],
140
+ settings: TextureImportSettings,
141
+ defaults: (path) => ({ usage: usageFromName(path) }),
142
+ async import(source, ctx) {
143
+ const settings = ctx.settings;
144
+ let result;
145
+ try {
146
+ result = await importImageBytes(source.bytes, settings, (m) => ctx.warn(m));
147
+ } catch (err) {
148
+ if (err instanceof ShardError) throw err;
149
+ throw new ShardError("texture/decode-failed", `${source.path}: ${err.message}`, {
150
+ cause: err
151
+ });
152
+ }
153
+ const asset = {
154
+ label: "",
155
+ type: "Texture",
156
+ bytes: result.bytes,
157
+ info: result.info
158
+ };
159
+ return { assets: [asset] };
160
+ }
161
+ });
162
+ export {
163
+ TextureImportSettings,
164
+ TextureImporter,
165
+ flipGreen,
166
+ importImageBytes,
167
+ usageFromName
168
+ };
@@ -0,0 +1,9 @@
1
+ export { resizeImage, type TextureArrayFile, TextureArrayImporter } from './array.js';
2
+ export { encodeBasis, type TranscodedTexture, type TranscodeTarget, transcodeBasis } from './basis.js';
3
+ export { decodeHdr, decodeImage, decodeJpeg, decodePngImage, decodeWebp, type ImageFormat, sniffImage, } from './decode/index.js';
4
+ export type { Image } from './image.js';
5
+ export { flipGreen, importImageBytes, TextureImporter, TextureImportSettings, type TextureSettings, usageFromName, } from './importer.js';
6
+ export { type Ktx2Data, readKtx2, tagKtx2, writeKtx2 } from './ktx2.js';
7
+ export { buildMips, type MipChain, type MipOptions, type TextureUsage, toHalf } from './mips.js';
8
+ export { type PackItem, type PackResult, packRects, SkylinePacker } from './pack.js';
9
+ export { FORMAT_INFO, setTextureCapabilities, Texture, TextureAssetType, type TextureCapabilities, type TextureFormat, type TextureInit, Textures, textureCapabilities, textureFromKtx2, transcodeTarget, } from './texture.js';
package/dist/index.js ADDED
@@ -0,0 +1,62 @@
1
+ import { resizeImage, TextureArrayImporter } from "./array.js";
2
+ import { encodeBasis, transcodeBasis } from "./basis.js";
3
+ import {
4
+ decodeHdr,
5
+ decodeImage,
6
+ decodeJpeg,
7
+ decodePngImage,
8
+ decodeWebp,
9
+ sniffImage
10
+ } from "./decode/index.js";
11
+ import {
12
+ flipGreen,
13
+ importImageBytes,
14
+ TextureImporter,
15
+ TextureImportSettings,
16
+ usageFromName
17
+ } from "./importer.js";
18
+ import { readKtx2, tagKtx2, writeKtx2 } from "./ktx2.js";
19
+ import { buildMips, toHalf } from "./mips.js";
20
+ import { packRects, SkylinePacker } from "./pack.js";
21
+ import {
22
+ FORMAT_INFO,
23
+ setTextureCapabilities,
24
+ Texture,
25
+ TextureAssetType,
26
+ Textures,
27
+ textureCapabilities,
28
+ textureFromKtx2,
29
+ transcodeTarget
30
+ } from "./texture.js";
31
+ export {
32
+ FORMAT_INFO,
33
+ SkylinePacker,
34
+ Texture,
35
+ TextureArrayImporter,
36
+ TextureAssetType,
37
+ TextureImportSettings,
38
+ TextureImporter,
39
+ Textures,
40
+ buildMips,
41
+ decodeHdr,
42
+ decodeImage,
43
+ decodeJpeg,
44
+ decodePngImage,
45
+ decodeWebp,
46
+ encodeBasis,
47
+ flipGreen,
48
+ importImageBytes,
49
+ packRects,
50
+ readKtx2,
51
+ resizeImage,
52
+ setTextureCapabilities,
53
+ sniffImage,
54
+ tagKtx2,
55
+ textureCapabilities,
56
+ textureFromKtx2,
57
+ toHalf,
58
+ transcodeBasis,
59
+ transcodeTarget,
60
+ usageFromName,
61
+ writeKtx2
62
+ };
package/dist/ktx2.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { type MipChain, type TextureUsage } from './mips.js';
2
+ /**
3
+ * Writes an uncompressed KTX2: RGBA8 (sRGB-tagged for color) or RGBA16F for hdr, every level. With
4
+ * `faces: 6` it's a cube map, and each level holds the six faces one after another.
5
+ */
6
+ /**
7
+ * A mip chain as KTX2. `premultiplied` marks color already multiplied by alpha (the DFD flag,
8
+ * plus a key the engine reads back).
9
+ */
10
+ export declare function writeKtx2(chain: MipChain, usage: TextureUsage, faces?: number, premultiplied?: boolean, layers?: number): Uint8Array;
11
+ export interface Ktx2Data {
12
+ width: number;
13
+ height: number;
14
+ vkFormat: number;
15
+ /** Level data, uncompressed (Zstandard supercompression is undone here). Basis data stays encoded. */
16
+ levels: Uint8Array[];
17
+ /** Basis Universal payloads need transcoding before upload. */
18
+ basis: 'etc1s' | 'uastc' | undefined;
19
+ usage: TextureUsage;
20
+ srgb: boolean;
21
+ /** The original file, for the Basis transcoder. */
22
+ bytes: Uint8Array;
23
+ /** 1, or 6 for a cube map (each level holds the faces in order +X, -X, +Y, -Y, +Z, -Z). */
24
+ faces: number;
25
+ /** Array layers (1 for a plain texture); each level holds them one after another. */
26
+ layers: number;
27
+ /** Color is already multiplied by alpha. */
28
+ premultiplied: boolean;
29
+ }
30
+ export declare function readKtx2(bytes: Uint8Array): Ktx2Data;
31
+ /** Re-tags a KTX2 (e.g. one the Basis encoder wrote) with the engine's usage key. */
32
+ export declare function tagKtx2(bytes: Uint8Array, usage: TextureUsage, premultiplied?: boolean): Uint8Array;
package/dist/ktx2.js ADDED
@@ -0,0 +1,124 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { decompress } from "fzstd";
3
+ import {
4
+ createDefaultContainer,
5
+ KHR_DF_MODEL_UASTC,
6
+ KHR_SUPERCOMPRESSION_BASISLZ,
7
+ KHR_SUPERCOMPRESSION_NONE,
8
+ KHR_SUPERCOMPRESSION_ZSTD,
9
+ read,
10
+ VK_FORMAT_R8G8B8A8_SRGB,
11
+ VK_FORMAT_R8G8B8A8_UNORM,
12
+ VK_FORMAT_R16G16B16A16_SFLOAT,
13
+ write
14
+ } from "ktx-parse";
15
+ import { toHalf } from "./mips.js";
16
+ const USAGE_KEY = "shard.usage";
17
+ const PREMULTIPLIED_KEY = "shard.premultiplied";
18
+ const KHR_DF_FLAG_ALPHA_PREMULTIPLIED = 1;
19
+ function writeKtx2(chain, usage, faces = 1, premultiplied = false, layers = 1) {
20
+ const hdr = usage === "hdr";
21
+ const c = createDefaultContainer();
22
+ c.vkFormat = hdr ? VK_FORMAT_R16G16B16A16_SFLOAT : usage === "color" ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM;
23
+ c.typeSize = hdr ? 2 : 1;
24
+ c.pixelWidth = chain.width;
25
+ c.pixelHeight = chain.height;
26
+ c.levelCount = chain.levels.length;
27
+ c.faceCount = faces;
28
+ c.layerCount = layers > 1 ? layers : 0;
29
+ c.supercompressionScheme = KHR_SUPERCOMPRESSION_NONE;
30
+ c.levels = chain.levels.map((level) => {
31
+ const bytes = hdr ? new Uint8Array(toHalf(level).buffer) : new Uint8Array(level.slice().buffer);
32
+ return { levelData: bytes, uncompressedByteLength: bytes.byteLength };
33
+ });
34
+ const dfd = c.dataFormatDescriptor[0];
35
+ dfd.colorModel = 1;
36
+ dfd.colorPrimaries = 1;
37
+ dfd.transferFunction = usage === "color" ? 2 : 1;
38
+ dfd.bytesPlane = [hdr ? 8 : 4, 0, 0, 0, 0, 0, 0, 0];
39
+ const bits = hdr ? 16 : 8;
40
+ dfd.samples = [0, 1, 2, 15].map((channel, i) => ({
41
+ bitOffset: i * bits,
42
+ bitLength: bits - 1,
43
+ // Float channels are signed floats; sRGB textures keep alpha linear.
44
+ channelType: channel | (hdr ? 192 : 0) | (usage === "color" && channel === 15 ? 16 : 0),
45
+ samplePosition: [0, 0, 0, 0],
46
+ sampleLower: hdr ? 3212836864 : 0,
47
+ sampleUpper: hdr ? 1065353216 : 255
48
+ }));
49
+ if (premultiplied) {
50
+ dfd.flags = KHR_DF_FLAG_ALPHA_PREMULTIPLIED;
51
+ c.keyValue[PREMULTIPLIED_KEY] = "true";
52
+ }
53
+ c.keyValue[USAGE_KEY] = usage;
54
+ return write(c);
55
+ }
56
+ function readKtx2(bytes) {
57
+ let c;
58
+ try {
59
+ c = read(bytes);
60
+ } catch (cause) {
61
+ throw new ShardError(
62
+ "texture/decode-failed",
63
+ `Invalid KTX2 file: ${cause.message}`,
64
+ { cause }
65
+ );
66
+ }
67
+ if (c.faceCount !== 1 && c.faceCount !== 6 || c.pixelDepth > 1 || c.layerCount > 1 && c.faceCount !== 1) {
68
+ throw new ShardError(
69
+ "texture/unsupported-format",
70
+ "3D and cube-array KTX2 textures come later",
71
+ {
72
+ hint: "Use a 2D image, a 2D array (*.texarray.json), or a cube map (6 faces)."
73
+ }
74
+ );
75
+ }
76
+ const dfd = c.dataFormatDescriptor[0];
77
+ const basis = c.vkFormat === 0 ? c.supercompressionScheme === KHR_SUPERCOMPRESSION_BASISLZ ? "etc1s" : dfd?.colorModel === KHR_DF_MODEL_UASTC ? "uastc" : void 0 : void 0;
78
+ if ((c.faceCount === 6 || c.layerCount > 1) && basis) {
79
+ throw new ShardError(
80
+ "texture/unsupported-format",
81
+ "Basis-compressed cube maps and arrays come later",
82
+ {
83
+ hint: "Store cube maps and texture arrays uncompressed."
84
+ }
85
+ );
86
+ }
87
+ if (c.vkFormat === 0 && !basis) {
88
+ throw new ShardError(
89
+ "texture/unsupported-format",
90
+ "KTX2 file has an undefined format and no Basis payload"
91
+ );
92
+ }
93
+ const levels = c.levels.map(
94
+ (l) => !basis && c.supercompressionScheme === KHR_SUPERCOMPRESSION_ZSTD ? decompress(l.levelData) : l.levelData
95
+ );
96
+ const srgb = dfd?.transferFunction === 2;
97
+ const raw = c.keyValue[USAGE_KEY];
98
+ const tagged = typeof raw === "string" ? raw : raw instanceof Uint8Array ? new TextDecoder().decode(raw) : void 0;
99
+ const usage = c.vkFormat === VK_FORMAT_R16G16B16A16_SFLOAT ? "hdr" : tagged ? tagged.replace(/\0+$/, "") : srgb ? "color" : "data";
100
+ return {
101
+ width: c.pixelWidth,
102
+ height: Math.max(1, c.pixelHeight),
103
+ vkFormat: c.vkFormat,
104
+ levels,
105
+ basis,
106
+ usage,
107
+ srgb,
108
+ bytes,
109
+ faces: c.faceCount,
110
+ layers: Math.max(1, c.layerCount),
111
+ premultiplied: c.keyValue[PREMULTIPLIED_KEY] !== void 0 || ((dfd?.flags ?? 0) & KHR_DF_FLAG_ALPHA_PREMULTIPLIED) !== 0
112
+ };
113
+ }
114
+ function tagKtx2(bytes, usage, premultiplied = false) {
115
+ const c = read(bytes);
116
+ c.keyValue[USAGE_KEY] = usage;
117
+ if (premultiplied) c.keyValue[PREMULTIPLIED_KEY] = "true";
118
+ return write(c);
119
+ }
120
+ export {
121
+ readKtx2,
122
+ tagKtx2,
123
+ writeKtx2
124
+ };
package/dist/mips.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { Image } from './image.js';
2
+ export type TextureUsage = 'color' | 'data' | 'normal' | 'hdr';
3
+ export interface MipChain {
4
+ width: number;
5
+ height: number;
6
+ /** Level 0 first; RGBA8 (sRGB-encoded for color) or RGBA float for hdr. */
7
+ levels: (Uint8Array | Float32Array)[];
8
+ }
9
+ export interface MipOptions {
10
+ usage: TextureUsage;
11
+ mipmaps: boolean;
12
+ maxSize: number;
13
+ flipY: boolean;
14
+ premultiplyAlpha: boolean;
15
+ }
16
+ /**
17
+ * Prepares an image for upload: flip, premultiply, then a full mip chain filtered in linear light
18
+ * (normal maps renormalized per level). Levels larger than `maxSize` are dropped.
19
+ */
20
+ export declare function buildMips(image: Image, options: MipOptions): MipChain;
21
+ /** Float32 → IEEE half floats (for rgba16float). */
22
+ export declare function toHalf(data: Float32Array): Uint16Array;
package/dist/mips.js ADDED
@@ -0,0 +1,125 @@
1
+ const SRGB_TO_LINEAR = new Float32Array(256);
2
+ for (let i = 0; i < 256; i++) {
3
+ const c = i / 255;
4
+ SRGB_TO_LINEAR[i] = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
5
+ }
6
+ const LUT_SIZE = 16384;
7
+ const LINEAR_TO_SRGB = new Uint8Array(LUT_SIZE + 1);
8
+ for (let i = 0; i <= LUT_SIZE; i++) {
9
+ const v = i / LUT_SIZE;
10
+ const c = v <= 31308e-7 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055;
11
+ LINEAR_TO_SRGB[i] = Math.round(c * 255);
12
+ }
13
+ function linearToSrgb8(v) {
14
+ return LINEAR_TO_SRGB[v <= 0 ? 0 : v >= 1 ? LUT_SIZE : Math.round(v * LUT_SIZE)];
15
+ }
16
+ function toFloats(image, usage) {
17
+ if (image.kind === "f32") return Float32Array.from(image.data);
18
+ const src = image.data;
19
+ const out = new Float32Array(src.length);
20
+ for (let i = 0; i < src.length; i += 4) {
21
+ for (let c = 0; c < 3; c++) {
22
+ const v = src[i + c];
23
+ out[i + c] = usage === "color" ? SRGB_TO_LINEAR[v] : usage === "normal" ? v / 255 * 2 - 1 : v / 255;
24
+ }
25
+ out[i + 3] = src[i + 3] / 255;
26
+ }
27
+ return out;
28
+ }
29
+ function fromFloats(data, usage) {
30
+ if (usage === "hdr") return data;
31
+ const out = new Uint8Array(data.length);
32
+ for (let i = 0; i < data.length; i += 4) {
33
+ for (let c = 0; c < 3; c++) {
34
+ const v = data[i + c];
35
+ out[i + c] = usage === "color" ? linearToSrgb8(v) : usage === "normal" ? Math.round(Math.min(1, Math.max(0, v * 0.5 + 0.5)) * 255) : Math.round(Math.min(1, Math.max(0, v)) * 255);
36
+ }
37
+ out[i + 3] = Math.round(Math.min(1, Math.max(0, data[i + 3])) * 255);
38
+ }
39
+ return out;
40
+ }
41
+ function downsample(src, w, h, usage) {
42
+ const nw = Math.max(1, w >> 1);
43
+ const nh = Math.max(1, h >> 1);
44
+ const out = new Float32Array(nw * nh * 4);
45
+ for (let y = 0; y < nh; y++) {
46
+ const y0 = Math.min(h - 1, y * 2);
47
+ const y1 = Math.min(h - 1, y * 2 + 1);
48
+ for (let x = 0; x < nw; x++) {
49
+ const x0 = Math.min(w - 1, x * 2);
50
+ const x1 = Math.min(w - 1, x * 2 + 1);
51
+ const a = (y0 * w + x0) * 4;
52
+ const b = (y0 * w + x1) * 4;
53
+ const c = (y1 * w + x0) * 4;
54
+ const d = (y1 * w + x1) * 4;
55
+ const o = (y * nw + x) * 4;
56
+ for (let k = 0; k < 4; k++)
57
+ out[o + k] = (src[a + k] + src[b + k] + src[c + k] + src[d + k]) * 0.25;
58
+ if (usage === "normal") {
59
+ const len = Math.sqrt(out[o] ** 2 + out[o + 1] ** 2 + out[o + 2] ** 2) || 1;
60
+ out[o] = out[o] / len;
61
+ out[o + 1] = out[o + 1] / len;
62
+ out[o + 2] = out[o + 2] / len;
63
+ }
64
+ }
65
+ }
66
+ return { data: out, width: nw, height: nh };
67
+ }
68
+ function buildMips(image, options) {
69
+ const { usage } = options;
70
+ let data = toFloats(image, usage);
71
+ let w = image.width;
72
+ let h = image.height;
73
+ if (options.flipY) {
74
+ const flipped = new Float32Array(data.length);
75
+ for (let y = 0; y < h; y++)
76
+ flipped.set(data.subarray(y * w * 4, (y + 1) * w * 4), (h - 1 - y) * w * 4);
77
+ data = flipped;
78
+ }
79
+ if (options.premultiplyAlpha) {
80
+ for (let i = 0; i < data.length; i += 4) {
81
+ data[i] = data[i] * data[i + 3];
82
+ data[i + 1] = data[i + 1] * data[i + 3];
83
+ data[i + 2] = data[i + 2] * data[i + 3];
84
+ }
85
+ }
86
+ const chain = [
87
+ { data, width: w, height: h }
88
+ ];
89
+ while ((options.mipmaps || w > options.maxSize || h > options.maxSize) && (w > 1 || h > 1)) {
90
+ const next = downsample(data, w, h, usage);
91
+ chain.push(next);
92
+ data = next.data;
93
+ w = next.width;
94
+ h = next.height;
95
+ if (!options.mipmaps && w <= options.maxSize && h <= options.maxSize) break;
96
+ }
97
+ let first = chain.findIndex((l) => l.width <= options.maxSize && l.height <= options.maxSize);
98
+ if (first === -1) first = chain.length - 1;
99
+ const kept = options.mipmaps ? chain.slice(first) : [chain[first]];
100
+ return {
101
+ width: kept[0].width,
102
+ height: kept[0].height,
103
+ levels: kept.map((l) => fromFloats(l.data, usage))
104
+ };
105
+ }
106
+ function toHalf(data) {
107
+ const out = new Uint16Array(data.length);
108
+ const f = new Float32Array(1);
109
+ const u = new Uint32Array(f.buffer);
110
+ for (let i = 0; i < data.length; i++) {
111
+ f[0] = data[i];
112
+ const x = u[0];
113
+ const sign = x >>> 16 & 32768;
114
+ const exp = (x >>> 23 & 255) - 127 + 15;
115
+ const mant = x & 8388607;
116
+ if (exp <= 0) out[i] = sign;
117
+ else if (exp >= 31) out[i] = sign | 31744;
118
+ else out[i] = sign | exp << 10 | mant >>> 13;
119
+ }
120
+ return out;
121
+ }
122
+ export {
123
+ buildMips,
124
+ toHalf
125
+ };
package/dist/pack.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Skyline bottom-left rectangle packing (y down: "bottom" is the lowest top edge). Each rectangle
3
+ * is placed where its top edge sits highest, ties going left.
4
+ */
5
+ export declare class SkylinePacker {
6
+ readonly width: number;
7
+ readonly height: number;
8
+ /** Skyline segments: x, y (top of the free space), width. */
9
+ private xs;
10
+ private ys;
11
+ private ws;
12
+ constructor(width: number, height: number);
13
+ /** Places a w×h rectangle; returns its top-left, or undefined when it doesn't fit. */
14
+ insert(w: number, h: number): {
15
+ x: number;
16
+ y: number;
17
+ } | undefined;
18
+ private place;
19
+ }
20
+ export interface PackItem {
21
+ width: number;
22
+ height: number;
23
+ }
24
+ export interface PackResult {
25
+ pages: {
26
+ width: number;
27
+ height: number;
28
+ }[];
29
+ /** Per item: page, x, y (top-left, in pixels, padding already applied). */
30
+ placements: {
31
+ page: number;
32
+ x: number;
33
+ y: number;
34
+ }[];
35
+ }
36
+ /**
37
+ * Packs items onto the fewest power-of-two pages no larger than `maxSize`: the smallest page that
38
+ * fits everything left, else a full `maxSize` page and repeat. Padding surrounds every item.
39
+ */
40
+ export declare function packRects(items: readonly PackItem[], maxSize?: number, padding?: number): PackResult;