@ludicon/spark.js 0.0.6 → 0.0.8

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/README.md CHANGED
@@ -62,7 +62,7 @@ To run local examples:
62
62
  npm run dev
63
63
  ```
64
64
 
65
- And visit `http://localhost:5174/examples/basic.html`.
65
+ This will open `http://localhost:5174/examples/index.thml` where you can browse the examples.
66
66
 
67
67
  > Note: Browsers treat http://localhost as a secure context, so HTTPS is not required when testing locally on the same machine. However, to access the dev server from another device you must enable HTTPS for WebGPU features to work.
68
68
  >
@@ -81,8 +81,8 @@ Load an image and encode it to a compressed GPU texture.
81
81
 
82
82
  #### Parameters
83
83
 
84
- - **`source`** (`GPUtexture | string | HTMLImageElement | HTMLCanvasElement | Blob | ArrayBuffer`)
85
- The image to encode. Can be a GPUtexture, URL, DOM image/canvas, binary buffer, or blob.
84
+ - **`source`** (`string | HTMLImageElement | ImageBitmap | GPUtexture`)
85
+ The image to encode. Can be a GPUTexture, URL, DOM image or ImageBitmap.
86
86
 
87
87
  - **`options`** *(optional object)*
88
88
  Configuration options for encoding:
@@ -118,6 +118,38 @@ Load an image and encode it to a compressed GPU texture.
118
118
  - `Promise<GPUTexture>` — the compressed GPU texture, ready for use in WebGPU.
119
119
 
120
120
 
121
+ ## Integration with three.js
122
+
123
+ Using spark.js with [three.js](https://threejs.org/) is straightforward. You can encode textures with Spark and expose them to three.js as external textures:
124
+
125
+ ```js
126
+ // Load and encode texture using spark:
127
+ const gpuTexture = await spark.encodeTexture(textureUrl, { srgb: true, flipY: true });
128
+
129
+ // Wrap the GPUTexture for three.js
130
+ const externalTex = new THREE.ExternalTexture(gpuTexture);
131
+
132
+ // Then use as any other texture:
133
+ const material = new THREE.MeshBasicMaterial({ map: externalTex });
134
+
135
+ ```
136
+
137
+ To facilitate the use of Spark when loading GLTF assets, import the provided helper:
138
+
139
+ ```jsd
140
+ import { registerSparkLoader } from "@ludicon/spark.js/three-gltf";
141
+ ```
142
+
143
+ Then register Spark with an existing GLTFLoader instance:
144
+
145
+ ```js
146
+ const loader = new GLTFLoader()
147
+ registerSparkLoader(loader, spark)
148
+ ```
149
+
150
+ After registration, the loader will automatically encode textures with Spark whenever applicable.
151
+
152
+
121
153
  ## License
122
154
 
123
155
  *spark.js* is free for non-commercial use.
package/dist/spark.esm.js CHANGED
@@ -412,17 +412,35 @@ function imageToByteArray(image) {
412
412
  const imageData = ctx.getImageData(0, 0, image.width, image.height);
413
413
  return new Uint8Array(imageData.data.buffer);
414
414
  }
415
- function loadImage(url) {
416
- return new Promise(function(resolve, reject) {
417
- const image = new Image();
418
- image.crossOrigin = "anonymous";
419
- image.onload = function() {
420
- resolve(image);
421
- };
422
- image.onerror = reject;
423
- image.src = url;
415
+ function isSvgUrl(url) {
416
+ return /\.svg(?:$|\?)/i.test(url) || /^data:image\/svg\+xml[,;]/i.test(url);
417
+ }
418
+ function loadImageElement(url) {
419
+ return new Promise((resolve, reject) => {
420
+ const img = new Image();
421
+ img.crossOrigin = "anonymous";
422
+ img.decoding = "async";
423
+ img.onload = () => resolve(img);
424
+ img.onerror = reject;
425
+ img.src = url;
426
+ });
427
+ }
428
+ async function loadImageBitmap(url, opts = {}) {
429
+ const res = await fetch(url, { mode: "cors" });
430
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
431
+ const blob = await res.blob();
432
+ return createImageBitmap(blob, {
433
+ imageOrientation: opts.flipY ? "flipY" : "none",
434
+ colorSpaceConversion: opts.colorSpaceConversion ?? "none"
424
435
  });
425
436
  }
437
+ function loadImage(url) {
438
+ if (isSvgUrl(url)) {
439
+ return loadImageElement(url);
440
+ } else {
441
+ return loadImageBitmap(url);
442
+ }
443
+ }
426
444
  const BYTES_PER_ROW_ALIGNMENT = 256;
427
445
  const MIN_MIP_SIZE = 4;
428
446
  function computeMipmapLayout(w, h, blockSize, mipmaps) {
@@ -457,6 +475,7 @@ class Spark {
457
475
  #querySet;
458
476
  #queryBuffer;
459
477
  #queryReadbackBuffer;
478
+ #encodeCounter = 0;
460
479
  /**
461
480
  * Initialize the encoder by detecting available compression formats.
462
481
  * @param {GPUDevice} device - WebGPU device.
@@ -550,13 +569,13 @@ class Spark {
550
569
  * Try to determine the best compression options automatically. Do not use this in production, this is
551
570
  * for the convenience of the spark.js image viewer only.
552
571
  *
553
- * @param {string | HTMLImageElement | HTMLCanvasElement | Blob | ArrayBuffer | GPUTexture} source - Image input.
572
+ * @param {string | HTMLImageElement | ImageBitmap | GPUTexture} source - Image input.
554
573
  * @param {Object} options - Encoding options.
555
574
  * @returns {Object} - Recommended encoding options with an explicit encoding format.
556
575
  */
557
576
  async selectPreferredOptions(source, options = {}) {
558
577
  if (options.format == void 0 || options.format == "auto") {
559
- const image = source instanceof Image || source instanceof GPUTexture ? source : await loadImage(source);
578
+ const image = source instanceof Image || source instanceof ImageBitmap || source instanceof GPUTexture ? source : await loadImage(source);
560
579
  options.format = "auto";
561
580
  const format = await this.#getBestMatchingFormat(options, image);
562
581
  options.format = SparkFormatName[format];
@@ -572,8 +591,8 @@ class Spark {
572
591
  /**
573
592
  * Load an image and encode it to a compressed GPU texture.
574
593
  *
575
- * @param {GPUTexture | string | HTMLImageElement | HTMLCanvasElement | Blob | ArrayBuffer} source
576
- * The image to encode. Can be a GPUTexture, URL, DOM image/canvas, binary buffer, or Blob.
594
+ * @param {string | HTMLImageElement | ImageBitmap | GPUTexture} source
595
+ * The image to encode. Can be a GPUTexture, URL, DOM image or ImageBitmap.
577
596
  *
578
597
  * @param {Object} [options] - Optional configuration for encoding.
579
598
  *
@@ -610,7 +629,7 @@ class Spark {
610
629
  */
611
630
  async encodeTexture(source, options = {}) {
612
631
  assert(this.#device, "Spark is not initialized");
613
- const image = source instanceof Image || source instanceof GPUTexture ? source : await loadImage(source);
632
+ const image = source instanceof Image || source instanceof ImageBitmap || source instanceof GPUTexture ? source : await loadImage(source);
614
633
  console.log("Loaded image", image);
615
634
  const format = await this.#getBestMatchingFormat(options, image);
616
635
  const pipelinePromise = this.#loadPipeline(format);
@@ -622,7 +641,8 @@ class Spark {
622
641
  const srgb = (options.srgb || options.format?.endsWith("srgb")) && SparkFormatIsRGB[format];
623
642
  const webgpuFormat = SparkWebGPUFormats[format] + (srgb ? "-srgb" : "");
624
643
  const viewFormats = srgb ? ["rgba8unorm", "rgba8unorm-srgb"] : ["rgba8unorm"];
625
- console.time("create input texture");
644
+ const counter = this.#encodeCounter++;
645
+ console.time("create input texture #" + counter);
626
646
  let inputUsage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING;
627
647
  const needsProcessing = options.flipY || width != image.width || height != image.height;
628
648
  if (!needsProcessing && !(image instanceof GPUTexture)) {
@@ -678,7 +698,7 @@ class Spark {
678
698
  this.#generateMipmaps(commandEncoder, inputTexture, mipmapCount, width, height, srgb);
679
699
  }
680
700
  commandEncoder.popDebugGroup?.();
681
- console.timeEnd("create input texture");
701
+ console.timeEnd("create input texture #" + counter);
682
702
  const outputTexture = this.#device.createTexture({
683
703
  size: [width, height, 1],
684
704
  mipLevelCount: mipmapCount,
@@ -689,7 +709,7 @@ class Spark {
689
709
  size: outputSize,
690
710
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
691
711
  });
692
- console.time("dispatch compute shader");
712
+ console.time("dispatch compute shader #" + counter);
693
713
  commandEncoder.pushDebugGroup?.("spark encode texture");
694
714
  let args = {};
695
715
  if (this.#querySet && typeof commandEncoder.writeTimestamp !== "function") {
@@ -757,7 +777,7 @@ class Spark {
757
777
  }
758
778
  commandEncoder.popDebugGroup?.();
759
779
  this.#device.queue.submit([commandEncoder.finish()]);
760
- console.timeEnd("dispatch compute shader");
780
+ console.timeEnd("dispatch compute shader #" + counter);
761
781
  tmpTexture?.destroy();
762
782
  if (inputTexture != image) {
763
783
  inputTexture?.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludicon/spark.js",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Real-Time GPU Texture Codecs for the Web",
5
5
  "main": "dist/spark.esm.js",
6
6
  "module": "dist/spark.esm.js",
@@ -8,10 +8,12 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./dist/spark.esm.js"
11
- }
11
+ },
12
+ "./three-gltf": "./src/three-gltf.js"
12
13
  },
13
14
  "files": [
14
15
  "dist",
16
+ "src/three-gltf.js",
15
17
  "README.md",
16
18
  "LICENSE"
17
19
  ],
@@ -61,8 +63,17 @@
61
63
  "npm-run-all": "^4.1.5",
62
64
  "prettier": "^3.0.0",
63
65
  "rimraf": "^5.0.0",
66
+ "three": "^0.180.0",
64
67
  "vite": "^7.0.0"
65
68
  },
69
+ "peerDependencies": {
70
+ "three": "^0.180.0"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "three": {
74
+ "optional": true
75
+ }
76
+ },
66
77
  "publishConfig": {
67
78
  "access": "public"
68
79
  },
@@ -0,0 +1,189 @@
1
+ import * as THREE from "three/webgpu"
2
+
3
+ const Channel = {
4
+ R: 1, // 0001
5
+ G: 2, // 0010
6
+ B: 4, // 0100
7
+ A: 8, // 1000
8
+ RG: 3, // 0011
9
+ RGB: 7, // 0111
10
+ RGBA: 15 // 1111
11
+ }
12
+
13
+ class GLTFSparkPlugin {
14
+ constructor(name, parser, spark) {
15
+ this.name = name
16
+ this.parser = parser
17
+
18
+ this.loaders = {
19
+ ["rgba"]: new SparkLoader(parser.fileLoader.manager, spark, "rgba"),
20
+ ["rgba-srgb"]: new SparkLoader(parser.fileLoader.manager, spark, "rgba", THREE.SRGBColorSpace),
21
+ ["rgb"]: new SparkLoader(parser.fileLoader.manager, spark, "rgb"),
22
+ ["rgb-srgb"]: new SparkLoader(parser.fileLoader.manager, spark, "rgb", THREE.SRGBColorSpace),
23
+ ["rg"]: new SparkLoader(parser.fileLoader.manager, spark, "rg"),
24
+ ["r"]: new SparkLoader(parser.fileLoader.manager, spark, "r"),
25
+ [""]: new THREE.TextureLoader()
26
+ }
27
+
28
+ const textureCount = this.parser.json.textures?.length || 0
29
+ const textureColorSpaces = new Array(textureCount).fill(THREE.NoColorSpace)
30
+ const textureChannels = new Array(textureCount).fill(0)
31
+ const textureIsNormal = new Array(textureCount).fill(false)
32
+ const textureIsUncompressed = new Array(textureCount).fill(false)
33
+
34
+ function assignTexture(index, channels, colorSpace, isNormal, isUncompressed) {
35
+ if (index === undefined) return
36
+
37
+ textureChannels[index] |= channels
38
+
39
+ if (colorSpace) {
40
+ textureColorSpaces[index] = colorSpace
41
+ }
42
+ if (isNormal) {
43
+ textureIsNormal[index] = true
44
+
45
+ // Normal map unpacking not supported in three.js prior to r181
46
+ if (!("NormalRGPacking" in THREE)) {
47
+ textureChannels[index] |= Channel.RGB
48
+ }
49
+ }
50
+ if (isUncompressed) {
51
+ textureIsUncompressed[index] = true
52
+ }
53
+ }
54
+
55
+ for (const materialDef of this.parser.json.materials) {
56
+ const baseColorTextureIndex = materialDef.pbrMetallicRoughness?.baseColorTexture?.index
57
+ if (baseColorTextureIndex !== undefined) {
58
+ textureColorSpaces[baseColorTextureIndex] = THREE.SRGBColorSpace
59
+ textureChannels[baseColorTextureIndex] |= Channel.RGB
60
+
61
+ // Base color texture expects alpha when alpha mode is MASK or BLEND.
62
+ if (materialDef.alphaMode == "MASK" || materialDef.alphaMode == "BLEND") {
63
+ textureChannels[baseColorTextureIndex] |= Channel.A
64
+ }
65
+ }
66
+
67
+ assignTexture(materialDef.normalTexture?.index, Channel.RG, THREE.NoColorSpace, true)
68
+ assignTexture(materialDef.emissiveTexture?.index, Channel.RGB, THREE.SRGBColorSpace)
69
+ assignTexture(materialDef.occlusionTexture?.index, Channel.R)
70
+ assignTexture(materialDef.pbrMetallicRoughness?.metallicRoughnessTexture?.index, Channel.G | Channel.B)
71
+
72
+ // KHR_materials_anisotropy - RG contains direction, B contains strength.
73
+ assignTexture(materialDef.anisotropyTexture?.index, Channel.RGB)
74
+
75
+ // KHR_materials_clearcoat
76
+ assignTexture(materialDef.clearcoatTexture?.index, Channel.RGB, THREE.SRGBColorSpace)
77
+ assignTexture(materialDef.clearcoatRoughnessTexture?.index, Channel.R)
78
+ assignTexture(materialDef.clearcoatNormalTexture?.index, Channel.RG, THREE.NoColorSpace, true)
79
+
80
+ // KHR_materials_diffuse_transmission
81
+ assignTexture(materialDef.diffuseTransmissionTexture?.index, Channel.A)
82
+ assignTexture(materialDef.diffuseTransmissionColorTexture?.index, Channel.RGB, THREE.SRGBColorSpace)
83
+
84
+ // KHR_materials_iridescence
85
+ assignTexture(materialDef.iridescenceTexture?.index, Channel.R)
86
+ assignTexture(materialDef.iridescenceThicknessTexture?.index, Channel.G)
87
+
88
+ // KHR_materials_sheen
89
+ assignTexture(materialDef.sheenColorTexture?.index, Channel.RGB, THREE.SRGBColorSpace)
90
+ assignTexture(materialDef.sheenRoughnessTextureIndex?.index, Channel.A)
91
+
92
+ // KHR_materials_specular
93
+ assignTexture(materialDef.specularTexture?.index, Channel.RGB, THREE.SRGBColorSpace)
94
+ assignTexture(materialDef.specularColorTexture?.index, Channel.A)
95
+
96
+ // KHR_materials_transmission
97
+ assignTexture(materialDef.transmissionTexture?.index, Channel.R)
98
+
99
+ // KHR_materials_volume
100
+ assignTexture(materialDef.thicknessTexture?.index, Channel.G)
101
+ }
102
+
103
+ this.textureColorSpaces = textureColorSpaces
104
+ this.textureChannels = textureChannels
105
+ this.textureIsNormal = textureIsNormal
106
+ this.textureIsUncompressed = textureIsUncompressed
107
+ }
108
+
109
+ loadTexture(textureIndex) {
110
+ const tex = this.parser.json.textures[textureIndex]
111
+ const imageIndex = tex.source ?? tex.extensions.EXT_texture_webp?.source ?? tex.extensions.EXT_texture_avif?.source
112
+ const colorSpace = this.textureColorSpaces[textureIndex]
113
+ const channels = this.textureChannels[textureIndex]
114
+ const isUncompressed = this.textureIsUncompressed[textureIndex]
115
+
116
+ let format = "rgba" // Default to 'rgba'
117
+ if ((channels & Channel.R) == channels) {
118
+ format = "r"
119
+ } else if ((channels & Channel.RG) == channels) {
120
+ format = "rg"
121
+ } else if ((channels & Channel.RGB) == channels) {
122
+ format = "rgb" + (colorSpace === THREE.SRGBColorSpace ? "-srgb" : "")
123
+ } else {
124
+ format = "rgba" + (colorSpace === THREE.SRGBColorSpace ? "-srgb" : "")
125
+ }
126
+ if (isUncompressed) {
127
+ format = ""
128
+ }
129
+
130
+ const loader = this.loaders[format]
131
+
132
+ return this.parser.loadTextureImage(textureIndex, imageIndex, loader)
133
+ }
134
+ }
135
+
136
+ class SparkLoader extends THREE.TextureLoader {
137
+ constructor(manager, spark, format, colorSpace = THREE.NoColorSpace) {
138
+ super(manager)
139
+ this.spark = spark
140
+ this.format = format
141
+ this.colorSpace = colorSpace
142
+ }
143
+
144
+ load(url, onLoad, onProgress, onError) {
145
+ const format = this.format
146
+ const srgb = this.colorSpace === THREE.SRGBColorSpace
147
+ const mips = true
148
+
149
+ this.spark
150
+ .encodeTexture(url, { format, srgb, mips })
151
+ .then(gpuTexture => {
152
+ const texture = new THREE.ExternalTexture(gpuTexture)
153
+ if (this.format == "rg" && "NormalRGPacking" in THREE) {
154
+ texture.userData.unpackNormal = THREE.NormalRGPacking
155
+ }
156
+ onLoad(texture)
157
+ })
158
+ .catch(err => {
159
+ // Fallback: load the original image uncompressed
160
+ super.load(
161
+ url,
162
+ tex => {
163
+ tex.colorSpace = this.colorSpace
164
+ onLoad?.(tex)
165
+ },
166
+ onProgress,
167
+ // If the fallback also fails, surface the original encoder error first
168
+ fallbackErr => onError?.(err ?? fallbackErr)
169
+ )
170
+ })
171
+ }
172
+ }
173
+
174
+ export function registerSparkLoader(loader, spark) {
175
+ // Remove existing webp and avif plugins:
176
+ for (let i = 0; i < loader.pluginCallbacks.length; i++) {
177
+ const plugin = loader.pluginCallbacks[i](loader)
178
+
179
+ if (plugin.name == "EXT_texture_webp" || plugin.name == "EXT_texture_avif") {
180
+ loader.unregister(loader.pluginCallbacks[i])
181
+ i--
182
+ }
183
+ }
184
+
185
+ // Install plugin for standard textures, and textures using webp and avif extensions.
186
+ loader.register(parser => new GLTFSparkPlugin("spark", parser, spark))
187
+ loader.register(parser => new GLTFSparkPlugin("EXT_texture_webp", parser, spark))
188
+ loader.register(parser => new GLTFSparkPlugin("EXT_texture_avif", parser, spark))
189
+ }