@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/dist/pack.js ADDED
@@ -0,0 +1,147 @@
1
+ class SkylinePacker {
2
+ width;
3
+ height;
4
+ /** Skyline segments: x, y (top of the free space), width. */
5
+ xs = [0];
6
+ ys = [0];
7
+ ws;
8
+ constructor(width, height) {
9
+ this.width = width;
10
+ this.height = height;
11
+ this.ws = [width];
12
+ }
13
+ /** Places a w×h rectangle; returns its top-left, or undefined when it doesn't fit. */
14
+ insert(w, h) {
15
+ let bestY = Infinity;
16
+ let bestX = 0;
17
+ let bestIndex = -1;
18
+ let bestWaste = Infinity;
19
+ for (let i = 0; i < this.xs.length; i++) {
20
+ const x = this.xs[i];
21
+ if (x + w > this.width) break;
22
+ let y = 0;
23
+ let covered = 0;
24
+ let j = i;
25
+ while (covered < w) {
26
+ y = Math.max(y, this.ys[j]);
27
+ covered += this.ws[j];
28
+ j++;
29
+ }
30
+ if (y + h > this.height) continue;
31
+ let waste = 0;
32
+ covered = 0;
33
+ for (let k = i; k < j; k++) {
34
+ const segW = Math.min(this.ws[k], w - covered);
35
+ waste += (y - this.ys[k]) * segW;
36
+ covered += segW;
37
+ }
38
+ if (y < bestY || y === bestY && waste < bestWaste) {
39
+ bestY = y;
40
+ bestX = x;
41
+ bestIndex = i;
42
+ bestWaste = waste;
43
+ }
44
+ }
45
+ if (bestIndex < 0) return void 0;
46
+ this.place(bestIndex, bestX, bestY + h, w);
47
+ return { x: bestX, y: bestY };
48
+ }
49
+ place(index, x, top, w) {
50
+ const end = x + w;
51
+ const i = index;
52
+ while (i < this.xs.length && this.xs[i] < end) {
53
+ const segEnd = this.xs[i] + this.ws[i];
54
+ if (segEnd <= end) {
55
+ this.xs.splice(i, 1);
56
+ this.ys.splice(i, 1);
57
+ this.ws.splice(i, 1);
58
+ } else {
59
+ this.ws[i] = segEnd - end;
60
+ this.xs[i] = end;
61
+ break;
62
+ }
63
+ }
64
+ this.xs.splice(index, 0, x);
65
+ this.ys.splice(index, 0, top);
66
+ this.ws.splice(index, 0, w);
67
+ for (let k = 0; k < this.xs.length - 1; k++) {
68
+ if (this.ys[k] === this.ys[k + 1]) {
69
+ this.ws[k] = this.ws[k] + this.ws[k + 1];
70
+ this.xs.splice(k + 1, 1);
71
+ this.ys.splice(k + 1, 1);
72
+ this.ws.splice(k + 1, 1);
73
+ k--;
74
+ }
75
+ }
76
+ }
77
+ }
78
+ function tryPack(items, order, w, h, padding, placements, page, partial) {
79
+ const packer = new SkylinePacker(w, h);
80
+ const left = [];
81
+ for (const i of order) {
82
+ const item = items[i];
83
+ const spot = packer.insert(item.width + padding * 2, item.height + padding * 2);
84
+ if (!spot) {
85
+ if (!partial) return order.slice();
86
+ left.push(i);
87
+ continue;
88
+ }
89
+ placements[i] = { page, x: spot.x + padding, y: spot.y + padding };
90
+ }
91
+ return left;
92
+ }
93
+ function packRects(items, maxSize = 2048, padding = 1) {
94
+ const placements = new Array(items.length);
95
+ const pages = [];
96
+ let remaining = items.map((_, i) => i).sort((a, b) => items[b].height - items[a].height || items[b].width - items[a].width);
97
+ for (const i of remaining) {
98
+ const it = items[i];
99
+ if (it.width + padding * 2 > maxSize || it.height + padding * 2 > maxSize) {
100
+ throw new RangeError(`A ${it.width}x${it.height} item doesn't fit a ${maxSize} page`);
101
+ }
102
+ }
103
+ while (remaining.length > 0) {
104
+ let area = 0;
105
+ let maxW = 0;
106
+ let maxH = 0;
107
+ for (const i of remaining) {
108
+ const it = items[i];
109
+ area += (it.width + padding * 2) * (it.height + padding * 2);
110
+ maxW = Math.max(maxW, it.width + padding * 2);
111
+ maxH = Math.max(maxH, it.height + padding * 2);
112
+ }
113
+ let packed = false;
114
+ for (let s = 64; s <= maxSize && !packed; s *= 2) {
115
+ for (const [w, h] of [
116
+ [s, s / 2],
117
+ [s, s]
118
+ ]) {
119
+ if (h < 32 || w * h < area || w < maxW || h < maxH || h > maxSize) continue;
120
+ const left2 = tryPack(items, remaining, w, h, padding, placements, pages.length, false);
121
+ if (left2.length === 0) {
122
+ pages.push({ width: w, height: h });
123
+ packed = true;
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ if (packed) break;
129
+ const left = tryPack(
130
+ items,
131
+ remaining,
132
+ maxSize,
133
+ maxSize,
134
+ padding,
135
+ placements,
136
+ pages.length,
137
+ true
138
+ );
139
+ pages.push({ width: maxSize, height: maxSize });
140
+ remaining = left;
141
+ }
142
+ return { pages, placements };
143
+ }
144
+ export {
145
+ SkylinePacker,
146
+ packRects
147
+ };
@@ -0,0 +1,97 @@
1
+ import { AssetStore } from '@aethervtt/shard-assets';
2
+ import { type TranscodeTarget } from './basis.js';
3
+ import { type TextureUsage } from './mips.js';
4
+ /** GPU formats a Texture can hold. */
5
+ export type TextureFormat = 'rgba8unorm' | 'rgba16float' | 'bc7-rgba-unorm' | 'bc5-rg-unorm' | 'astc-4x4-unorm' | 'etc2-rgba8unorm' | 'eac-rg11unorm';
6
+ /** Bytes per 4x4 block for compressed formats; bytes per texel otherwise (as blockBytes, block 1). */
7
+ export declare const FORMAT_INFO: Record<TextureFormat, {
8
+ block: number;
9
+ bytes: number;
10
+ srgbView?: string;
11
+ }>;
12
+ export interface TextureInit {
13
+ width: number;
14
+ height: number;
15
+ format?: TextureFormat;
16
+ usage?: TextureUsage;
17
+ /** Level data, level 0 first. With `mipmaps` and one RGBA8 level, the rest are generated. */
18
+ mips: Uint8Array[];
19
+ mipmaps?: boolean;
20
+ /** Keep CPU pixels after upload (default true for textures made in code). */
21
+ cpu?: boolean;
22
+ /** 6 for a cube map: each level holds the faces +X, -X, +Y, -Y, +Z, -Z one after another. */
23
+ faces?: number;
24
+ /**
25
+ * Layers of a 2D texture array (materials bind it as `texture_2d_array`): each level holds the
26
+ * layers one after another. Default 1.
27
+ */
28
+ layers?: number;
29
+ /** Color is already multiplied by alpha (sprites blend it as is). */
30
+ premultiplied?: boolean;
31
+ }
32
+ /**
33
+ * A texture on the CPU, ready to upload. The renderer uploads it on first use and whenever
34
+ * `version` changes; imported textures drop their CPU pixels after upload (`levels` becomes
35
+ * undefined) and reload from their artifact if the GPU device is lost.
36
+ */
37
+ export declare class Texture {
38
+ width: number;
39
+ height: number;
40
+ format: TextureFormat;
41
+ usage: TextureUsage;
42
+ levels: Uint8Array[] | undefined;
43
+ mipCount: number;
44
+ keepCpu: boolean;
45
+ /** 1, or 6 for a cube map. */
46
+ faces: number;
47
+ /** Layers of a texture array (1 for a plain texture). */
48
+ layers: number;
49
+ version: number;
50
+ /**
51
+ * Made and written on the GPU (`Texture.gpu`): no CPU pixels, and the GPU copy can be bound as
52
+ * a storage texture so compute passes fill it.
53
+ */
54
+ gpuOnly: boolean;
55
+ /** Color is already multiplied by alpha. */
56
+ premultiplied: boolean;
57
+ constructor(init: Required<Omit<TextureInit, 'mipmaps' | 'faces' | 'premultiplied' | 'layers'>> & {
58
+ faces?: number;
59
+ layers?: number;
60
+ premultiplied?: boolean;
61
+ });
62
+ static create(init: TextureInit): Texture;
63
+ /**
64
+ * A texture the GPU writes (a compute pass through a storage binding), with no CPU pixels: one
65
+ * level, `layers` array layers. Its GPU copy is made on first use and kept (see GpuAssets.texture).
66
+ */
67
+ static gpu(init: {
68
+ width: number;
69
+ height: number;
70
+ layers?: number;
71
+ format?: TextureFormat;
72
+ usage?: TextureUsage;
73
+ }): Texture;
74
+ /** Replaces the pixels; bumps the version so the GPU copy re-uploads. */
75
+ update(init: Partial<TextureInit> & {
76
+ mips: Uint8Array[];
77
+ }): void;
78
+ /** GPU bytes for all levels. */
79
+ get byteSize(): number;
80
+ private static validate;
81
+ }
82
+ export declare const Textures: import("@aethervtt/shard-core").ResourceDef<AssetStore<Texture, "Texture">>;
83
+ /** What the GPU can sample, set by the renderer once it has a device. Decides Basis transcoding. */
84
+ export interface TextureCapabilities {
85
+ bc: boolean;
86
+ astc: boolean;
87
+ etc2: boolean;
88
+ }
89
+ export declare function setTextureCapabilities(next: TextureCapabilities): void;
90
+ export declare function textureCapabilities(): TextureCapabilities;
91
+ /** The best transcode target for a usage on this device. */
92
+ export declare function transcodeTarget(usage: TextureUsage, caps?: TextureCapabilities): TranscodeTarget;
93
+ /** Turns a KTX2 artifact into a Texture, transcoding Basis payloads for this device. */
94
+ export declare function textureFromKtx2(bytes: Uint8Array, options?: {
95
+ cpu?: boolean;
96
+ }): Promise<Texture>;
97
+ export declare const TextureAssetType: import("@aethervtt/shard-assets").AssetTypeDef<Texture>;
@@ -0,0 +1,218 @@
1
+ import { AssetStore, defineAssetType } from "@aethervtt/shard-assets";
2
+ import { defineResource, ShardError } from "@aethervtt/shard-core";
3
+ import { transcodeBasis } from "./basis.js";
4
+ import { buildMips } from "./mips.js";
5
+ const FORMAT_INFO = {
6
+ rgba8unorm: { block: 1, bytes: 4, srgbView: "rgba8unorm-srgb" },
7
+ rgba16float: { block: 1, bytes: 8 },
8
+ "bc7-rgba-unorm": { block: 4, bytes: 16, srgbView: "bc7-rgba-unorm-srgb" },
9
+ "bc5-rg-unorm": { block: 4, bytes: 16 },
10
+ "astc-4x4-unorm": { block: 4, bytes: 16, srgbView: "astc-4x4-unorm-srgb" },
11
+ "etc2-rgba8unorm": { block: 4, bytes: 16, srgbView: "etc2-rgba8unorm-srgb" },
12
+ "eac-rg11unorm": { block: 4, bytes: 16 }
13
+ };
14
+ class Texture {
15
+ width;
16
+ height;
17
+ format;
18
+ usage;
19
+ levels;
20
+ mipCount;
21
+ keepCpu;
22
+ /** 1, or 6 for a cube map. */
23
+ faces;
24
+ /** Layers of a texture array (1 for a plain texture). */
25
+ layers;
26
+ version = 0;
27
+ /**
28
+ * Made and written on the GPU (`Texture.gpu`): no CPU pixels, and the GPU copy can be bound as
29
+ * a storage texture so compute passes fill it.
30
+ */
31
+ gpuOnly = false;
32
+ /** Color is already multiplied by alpha. */
33
+ premultiplied;
34
+ constructor(init) {
35
+ this.width = init.width;
36
+ this.height = init.height;
37
+ this.format = init.format;
38
+ this.usage = init.usage;
39
+ this.levels = init.mips;
40
+ this.mipCount = init.mips.length;
41
+ this.keepCpu = init.cpu;
42
+ this.faces = init.faces ?? 1;
43
+ this.layers = init.layers ?? 1;
44
+ this.premultiplied = init.premultiplied ?? false;
45
+ Texture.validate(this);
46
+ }
47
+ static create(init) {
48
+ const format = init.format ?? "rgba8unorm";
49
+ const usage = init.usage ?? "color";
50
+ let mips = init.mips;
51
+ if (init.mipmaps && mips.length === 1 && format === "rgba8unorm" && (init.faces ?? 1) === 1 && (init.layers ?? 1) === 1) {
52
+ mips = buildMips(
53
+ { width: init.width, height: init.height, kind: "u8", data: mips[0] },
54
+ { usage, mipmaps: true, maxSize: 16384, flipY: false, premultiplyAlpha: false }
55
+ ).levels;
56
+ }
57
+ return new Texture({
58
+ width: init.width,
59
+ height: init.height,
60
+ format,
61
+ usage,
62
+ mips,
63
+ cpu: init.cpu ?? true,
64
+ faces: init.faces ?? 1,
65
+ layers: init.layers ?? 1,
66
+ premultiplied: init.premultiplied ?? false
67
+ });
68
+ }
69
+ /**
70
+ * A texture the GPU writes (a compute pass through a storage binding), with no CPU pixels: one
71
+ * level, `layers` array layers. Its GPU copy is made on first use and kept (see GpuAssets.texture).
72
+ */
73
+ static gpu(init) {
74
+ const t = new Texture({
75
+ width: init.width,
76
+ height: init.height,
77
+ format: init.format ?? "rgba8unorm",
78
+ usage: init.usage ?? "data",
79
+ mips: [],
80
+ cpu: false,
81
+ layers: init.layers ?? 1
82
+ });
83
+ t.levels = void 0;
84
+ t.mipCount = 1;
85
+ t.gpuOnly = true;
86
+ return t;
87
+ }
88
+ /** Replaces the pixels; bumps the version so the GPU copy re-uploads. */
89
+ update(init) {
90
+ const next = Texture.create({
91
+ width: init.width ?? this.width,
92
+ height: init.height ?? this.height,
93
+ format: init.format ?? this.format,
94
+ usage: init.usage ?? this.usage,
95
+ mips: init.mips,
96
+ mipmaps: init.mipmaps ?? this.mipCount > 1,
97
+ cpu: this.keepCpu,
98
+ faces: init.faces ?? this.faces,
99
+ layers: init.layers ?? this.layers,
100
+ premultiplied: init.premultiplied ?? this.premultiplied
101
+ });
102
+ this.width = next.width;
103
+ this.height = next.height;
104
+ this.format = next.format;
105
+ this.usage = next.usage;
106
+ this.levels = next.levels;
107
+ this.mipCount = next.mipCount;
108
+ this.faces = next.faces;
109
+ this.layers = next.layers;
110
+ this.premultiplied = next.premultiplied;
111
+ this.version++;
112
+ }
113
+ /** GPU bytes for all levels. */
114
+ get byteSize() {
115
+ const info = FORMAT_INFO[this.format];
116
+ let total = 0;
117
+ for (let l = 0; l < this.mipCount; l++) {
118
+ const w = Math.max(1, this.width >> l);
119
+ const h = Math.max(1, this.height >> l);
120
+ total += Math.ceil(w / info.block) * Math.ceil(h / info.block) * info.bytes;
121
+ }
122
+ return total * this.faces * this.layers;
123
+ }
124
+ static validate(t) {
125
+ const info = FORMAT_INFO[t.format];
126
+ if (!info)
127
+ throw new ShardError("texture/unsupported-format", `Unknown texture format "${t.format}"`);
128
+ t.levels?.forEach((level, l) => {
129
+ const w = Math.max(1, t.width >> l);
130
+ const h = Math.max(1, t.height >> l);
131
+ const expected = Math.ceil(w / info.block) * Math.ceil(h / info.block) * info.bytes * t.faces * t.layers;
132
+ if (level.byteLength < expected) {
133
+ throw new ShardError(
134
+ "texture/invalid",
135
+ `Level ${l} has ${level.byteLength} bytes; ${t.format} ${w}x${h} needs ${expected}`
136
+ );
137
+ }
138
+ });
139
+ }
140
+ }
141
+ const Textures = defineResource("texture/Textures", {
142
+ description: "Loaded textures by guid.",
143
+ init: () => new AssetStore("Texture")
144
+ });
145
+ let capabilities = { bc: false, astc: false, etc2: false };
146
+ function setTextureCapabilities(next) {
147
+ capabilities = { ...next };
148
+ }
149
+ function textureCapabilities() {
150
+ return { ...capabilities };
151
+ }
152
+ function transcodeTarget(usage, caps = capabilities) {
153
+ if (usage === "normal")
154
+ return caps.bc ? "bc5" : caps.astc ? "astc" : caps.etc2 ? "eac-rg11" : "rgba8";
155
+ return caps.bc ? "bc7" : caps.astc ? "astc" : caps.etc2 ? "etc2" : "rgba8";
156
+ }
157
+ const TARGET_FORMAT = {
158
+ bc7: "bc7-rgba-unorm",
159
+ bc5: "bc5-rg-unorm",
160
+ astc: "astc-4x4-unorm",
161
+ etc2: "etc2-rgba8unorm",
162
+ "eac-rg11": "eac-rg11unorm",
163
+ rgba8: "rgba8unorm"
164
+ };
165
+ async function textureFromKtx2(bytes, options = {}) {
166
+ const { readKtx2 } = await import("./ktx2.js");
167
+ const ktx = readKtx2(bytes);
168
+ if (ktx.basis) {
169
+ const out = await transcodeBasis(bytes, transcodeTarget(ktx.usage));
170
+ return new Texture({
171
+ width: out.width,
172
+ height: out.height,
173
+ format: TARGET_FORMAT[out.format],
174
+ usage: ktx.usage,
175
+ mips: out.levels,
176
+ cpu: options.cpu ?? false,
177
+ premultiplied: ktx.premultiplied
178
+ });
179
+ }
180
+ const format = ktx.usage === "hdr" ? "rgba16float" : "rgba8unorm";
181
+ return new Texture({
182
+ width: ktx.width,
183
+ height: ktx.height,
184
+ format,
185
+ usage: ktx.usage,
186
+ mips: ktx.levels,
187
+ cpu: options.cpu ?? false,
188
+ faces: ktx.faces,
189
+ layers: ktx.layers,
190
+ premultiplied: ktx.premultiplied
191
+ });
192
+ }
193
+ const TextureAssetType = defineAssetType("Texture", {
194
+ store: Textures,
195
+ load: (artifact) => textureFromKtx2(artifact.bytes),
196
+ update: (existing, next) => {
197
+ existing.width = next.width;
198
+ existing.height = next.height;
199
+ existing.format = next.format;
200
+ existing.usage = next.usage;
201
+ existing.levels = next.levels;
202
+ existing.mipCount = next.mipCount;
203
+ existing.faces = next.faces;
204
+ existing.layers = next.layers;
205
+ existing.premultiplied = next.premultiplied;
206
+ existing.version++;
207
+ }
208
+ });
209
+ export {
210
+ FORMAT_INFO,
211
+ Texture,
212
+ TextureAssetType,
213
+ Textures,
214
+ setTextureCapabilities,
215
+ textureCapabilities,
216
+ textureFromKtx2,
217
+ transcodeTarget
218
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@aethervtt/shard-texture",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "vendor",
16
+ "LICENSE",
17
+ "README.md"
18
+ ],
19
+ "dependencies": {
20
+ "@jsquash/webp": "^1.5.0",
21
+ "@aethervtt/shard-assets": "0.0.1",
22
+ "@aethervtt/shard-core": "0.0.1",
23
+ "fzstd": "^0.1.1",
24
+ "jpeg-js": "^0.4.4",
25
+ "ktx-parse": "^2.0.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/aethervtt/shard.git",
30
+ "directory": "packages/texture"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "shard": {
37
+ "buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
38
+ }
39
+ }