@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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/array.d.ts +21 -0
- package/dist/array.js +111 -0
- package/dist/basis.d.ts +16 -0
- package/dist/basis.js +122 -0
- package/dist/decode/hdr.d.ts +3 -0
- package/dist/decode/hdr.js +68 -0
- package/dist/decode/index.d.ts +11 -0
- package/dist/decode/index.js +40 -0
- package/dist/decode/jpeg.d.ts +3 -0
- package/dist/decode/jpeg.js +21 -0
- package/dist/decode/png.d.ts +10 -0
- package/dist/decode/png.js +193 -0
- package/dist/decode/webp.d.ts +2 -0
- package/dist/decode/webp.js +39 -0
- package/dist/image.d.ts +7 -0
- package/dist/image.js +0 -0
- package/dist/importer.d.ts +34 -0
- package/dist/importer.js +168 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +62 -0
- package/dist/ktx2.d.ts +32 -0
- package/dist/ktx2.js +124 -0
- package/dist/mips.d.ts +22 -0
- package/dist/mips.js +125 -0
- package/dist/pack.d.ts +40 -0
- package/dist/pack.js +147 -0
- package/dist/texture.d.ts +97 -0
- package/dist/texture.js +218 -0
- package/package.json +39 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/basis_encoder.js +2 -0
- package/vendor/basis/basis_encoder.wasm +0 -0
- package/vendor/basis/basis_transcoder.js +2 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
|
@@ -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
|
+
};
|
package/dist/image.d.ts
ADDED
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;
|
package/dist/importer.js
ADDED
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|