@needle-tools/gltf-build-pipeline 2.14.0 → 2.15.0-next.18cdb52

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.
@@ -1,4 +1,5 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node" resolution-mode="require"/>
2
3
  import { ExtensibleProperty } from "@gltf-transform/core";
3
4
  import { Logger } from "@donmccurdy/caporal";
4
5
  /**
@@ -5,9 +5,9 @@ import path from 'path';
5
5
  import { getVersion } from "../utils/version.js";
6
6
  // import * as CHECKDISCSPACE from 'check-disk-space'
7
7
  import xxhash from "xxhash-wasm";
8
- let hasher = null;
8
+ let xxHashModule = null;
9
9
  xxhash().then(module => {
10
- hasher = module.create32();
10
+ xxHashModule = module;
11
11
  });
12
12
  /**
13
13
  * Get the available space on the cache directory in bytes.
@@ -304,9 +304,9 @@ function hashObject(obj, depth = 0) {
304
304
  }
305
305
  }
306
306
  function hashBuffer(buffer, offset, length) {
307
- if (hasher) {
307
+ if (xxHashModule) {
308
308
  const view = new Uint8Array(buffer, offset, length);
309
- return hasher.update(view).digest();
309
+ return xxHashModule.h32Raw(view);
310
310
  }
311
311
  let hash = 0;
312
312
  const view = new Uint8Array(buffer, offset, length);
package/dist/cli/index.js CHANGED
File without changes
@@ -1,6 +1,8 @@
1
1
  import { Extension, ExtensionProperty, PropertyType, Node, TextureChannel } from '@gltf-transform/core';
2
2
  import { getTexture, tryGetInArray, writeExtension } from '../utils.js';
3
3
  import { isLightmap } from '../../utils/texture.js';
4
+ import { NEEDLE_progressive_texture_settings } from '../NEEDLE_progressive_texture_settings/index.js';
5
+ import { testAssert } from '../../utils/test-assert.js';
4
6
  const ALL_PROPERTY_TYPES = [];
5
7
  for (const key in PropertyType) {
6
8
  ALL_PROPERTY_TYPES.push(PropertyType[key]);
@@ -123,8 +125,11 @@ export function createOpaqueExtension(name, types, opts) {
123
125
  const prop = this.createExtension();
124
126
  this.jsonPointerHandler.read(context, texture, ext, prop);
125
127
  texture.setExtension(EXTENSION_NAME, prop);
126
- const handler = new TextureHandler(this.document, textureDefinition, EXTENSION_NAME, ext, debugLog);
128
+ const handler = new TextureHandler(this.document, textureDefinition, EXTENSION_NAME, ext, debugLog, texture);
127
129
  prop.add(handler);
130
+ const sourceIndex = textureDefinition.source;
131
+ if (debugLog)
132
+ console.log("Found texture with extension", EXTENSION_NAME, "at index", index, "source image index", sourceIndex, "name", texture.getName() || "<unnamed>");
128
133
  });
129
134
  break;
130
135
  case PropertyType.NODE:
@@ -188,11 +193,11 @@ export function createOpaqueExtension(name, types, opts) {
188
193
  const textures = this.document.getRoot().listTextures();
189
194
  for (let i = 0; i < textures.length; i++) {
190
195
  const tex = textures[i];
191
- const texDef = textureDefinitions[i];
192
- const name = texDef?.name;
193
196
  if (isLightmap(tex) && tex.getMimeType() !== "image/exr") {
194
197
  prop.setReference("lightmapTexture", tex, { channels: TextureChannel.R | TextureChannel.G | TextureChannel.B | TextureChannel.A });
195
- logger.info(`Found lightmap texture ${name}: ${tex.getMimeType()} in ${EXTENSION_NAME}`);
198
+ const texDef = textureDefinitions[i]; // @TODO: textureDefinitions and textures might not be in the same order - we need to find the texture by source index instead of assuming the order is the same
199
+ const name = tex.getName() || texDef?.name;
200
+ logger.info(`Found lightmap texture \"${name}\" (${tex.getMimeType()}) in ${EXTENSION_NAME}`);
196
201
  }
197
202
  }
198
203
  }
@@ -389,39 +394,52 @@ class GenericWriter {
389
394
  class TextureHandler {
390
395
  document;
391
396
  textureDefinition;
392
- index;
393
397
  key;
394
398
  ext;
395
399
  debug = false;
400
+ /** Reference to the gltf-transform Texture object, used to resolve the current image index at write time via imageIndexMap */
401
+ textureRef;
396
402
  assignTo(obj) {
397
403
  Object.assign(obj, this.ext);
398
404
  }
399
- constructor(doc, def, key, ext, debug) {
405
+ constructor(doc, def, key, ext, debug, textureRef) {
400
406
  this.document = doc;
401
407
  this.textureDefinition = def;
402
408
  this.key = key;
403
409
  this.ext = ext;
404
410
  this.debug = debug;
405
- this.index = this.tryGetSourceIndex(this.textureDefinition);
406
- if (debug)
407
- console.log(def);
411
+ this.textureRef = textureRef;
412
+ if (debug) {
413
+ const index = this.tryGetSourceIndex(this.textureDefinition);
414
+ console.log(index, this.textureDefinition);
415
+ }
408
416
  }
409
417
  write(ctx) {
410
- let foundTexture = false;
411
- const textureDefs = ctx.jsonDoc.json.textures || [];
412
- const imageIndex = this.index !== undefined ? this.index : this.tryGetSourceIndex(this.textureDefinition);
418
+ // If the texture was converted (e.g. EXR → KTX2 via PMREM), skip writing the old extension
419
+ if (this.key === "EXT_texture_exr" && this.textureRef.getMimeType() !== "image/exr") {
420
+ return;
421
+ }
422
+ // Resolve the current image index from the gltf-transform graph reference.
423
+ // This is stable even after dedup/prune operations that re-index images.
424
+ const imageIndex = ctx.imageIndexMap.get(this.textureRef);
425
+ testAssert(imageIndex !== undefined || this.key === "EXT_texture_exr", `imageIndexMap missing texture ref for "${this.key}": ${this.textureRef.getName() || "<unnamed>"}`);
413
426
  if (imageIndex === undefined) {
414
- console.warn("WARN: no source index found for texture", this.key);
427
+ if (this.debug) {
428
+ console.warn("WARN: failed to resolve image index for texture via imageIndexMap:", this.key, this.textureRef.getName() || "<unnamed>");
429
+ }
430
+ return;
415
431
  }
432
+ let foundTexture = false;
433
+ const textureDefs = ctx.jsonDoc.json.textures || [];
416
434
  textureDefs?.forEach((textureDef, i) => {
417
435
  if (foundTexture)
418
436
  return;
419
437
  const textureImageIndex = this.tryGetSourceIndex(textureDef);
420
438
  if (imageIndex === textureImageIndex) {
421
- if (!textureDef.extensions || !textureDef.extensions[this.key]) {
422
- // assign texture extension again
439
+ foundTexture = true;
440
+ if (!textureDef.extensions?.[this.key]) {
441
+ // assign texture extension
423
442
  textureDef.extensions = textureDef.extensions || {};
424
- foundTexture = true;
425
443
  if (this.debug)
426
444
  console.log("Re-Assign extension:", this.key, "at", i);
427
445
  const newExt = { ...this.ext };
@@ -429,20 +447,16 @@ class TextureHandler {
429
447
  newExt.source = imageIndex;
430
448
  textureDef.extensions[this.key] = newExt;
431
449
  }
450
+ // else: extension already written by another handler for the same image
451
+ // (happens when gltf-transform dedup merges texture defs that shared the same source)
432
452
  }
433
453
  });
454
+ testAssert(foundTexture || this.key === "EXT_texture_exr", `failed to match texture for extension "${this.key}", imageIndex=${imageIndex}, textures=${textureDefs.length}, name=${this.textureRef.getName() || "<unnamed>"}, type=${this.textureRef.getMimeType()}`);
434
455
  if (foundTexture === false) {
435
- if (this.key !== "EXT_texture_exr" && this.debug)
436
- console.warn("WARN: failed to re-assign extension:", this.key, this.index, this.tryGetSourceIndex(this.textureDefinition), textureDefs.length);
437
- }
438
- // this.document
439
- // .getRoot()
440
- // .listTextures()
441
- // .forEach((texture, index) => {
442
- // if (foundTexture) return;
443
- // if (!texture.getExtension(this.key)) return;
444
- // const imageIndex = ctx.imageIndexMap.get(texture);
445
- // });
456
+ if (this.debug) {
457
+ console.warn("WARN: failed to re-assign extension:", this.key, "imageIndex", imageIndex, textureDefs.length, this.textureRef.getName() || "<unnamed>", this.textureDefinition);
458
+ }
459
+ }
446
460
  }
447
461
  tryGetSourceIndex(def) {
448
462
  let index = def.source;
@@ -857,7 +871,8 @@ class TextureIndexResolver extends PointerResolver {
857
871
  resolve(context) {
858
872
  const imgIndex = context.imageIndexMap.get(this.inputObject);
859
873
  if (imgIndex === undefined) {
860
- console.error("ERR: image is missing", imgIndex);
874
+ testAssert(false, `imageIndexMap missing texture: ${this.inputObject.getName() || "<unnamed>"}`);
875
+ console.error("ERR: image is missing");
861
876
  return -1;
862
877
  }
863
878
  // ensure we have a texture array
@@ -878,11 +893,22 @@ class TextureIndexResolver extends PointerResolver {
878
893
  // create a new texture def entry
879
894
  if (this.debug)
880
895
  console.log("Could not find texture, creating a new one: " + this._debugName);
881
- const hasExtensionWithSource = this.inputDefinition.extensions && Object.keys(this.inputDefinition.extensions).some(key => this.inputDefinition.extensions[key]["source"] !== undefined);
896
+ // Remove extensions that are consumed during processing and should not appear in the output.
897
+ let extensions = this.inputDefinition.extensions ? { ...this.inputDefinition.extensions } : undefined;
898
+ if (extensions) {
899
+ delete extensions[NEEDLE_progressive_texture_settings.EXTENSION_NAME];
900
+ // Remove EXT_texture_exr if the texture was converted (e.g. EXR → KTX2 via PMREM)
901
+ if (extensions["EXT_texture_exr"] && this.inputObject.getMimeType() !== "image/exr") {
902
+ delete extensions["EXT_texture_exr"];
903
+ }
904
+ if (Object.keys(extensions).length === 0)
905
+ extensions = undefined;
906
+ }
907
+ const hasExtensionWithSource = extensions && Object.keys(extensions).some(key => extensions[key]?.["source"] !== undefined);
882
908
  const textureDef = {
883
909
  source: hasExtensionWithSource ? undefined : imgIndex,
884
910
  sampler: this.findOrCreateSampler(context.jsonDoc.json),
885
- extensions: this.inputDefinition.extensions,
911
+ extensions: extensions,
886
912
  extras: this.inputDefinition.extras,
887
913
  };
888
914
  context.jsonDoc.json.textures.push(textureDef);
@@ -0,0 +1,19 @@
1
+ import { Extension, Texture, WriterContext } from '@gltf-transform/core';
2
+ /**
3
+ * NEEDLE_pmrem extension for gltf-transform.
4
+ * Marks textures that have been pre-processed with PMREM (EXR → KTX2 HDR).
5
+ * At write time, adds `NEEDLE_pmrem: { source: imageIndex }` to each marked texture
6
+ * so the Needle Engine runtime can load them correctly with CubeUVReflectionMapping.
7
+ */
8
+ export declare class NEEDLE_pmrem extends Extension {
9
+ static readonly EXTENSION_NAME = "NEEDLE_pmrem";
10
+ readonly extensionName = "NEEDLE_pmrem";
11
+ /** Textures that were converted from EXR via PMREM */
12
+ private readonly pmremTextures;
13
+ /** Mark a texture as PMREM-converted */
14
+ addTexture(texture: Texture): void;
15
+ /** @hidden */
16
+ read(): this;
17
+ /** @hidden */
18
+ write(context: WriterContext): this;
19
+ }
@@ -0,0 +1,44 @@
1
+ import { Extension } from '@gltf-transform/core';
2
+ const EXTENSION_NAME = 'NEEDLE_pmrem';
3
+ /**
4
+ * NEEDLE_pmrem extension for gltf-transform.
5
+ * Marks textures that have been pre-processed with PMREM (EXR → KTX2 HDR).
6
+ * At write time, adds `NEEDLE_pmrem: { source: imageIndex }` to each marked texture
7
+ * so the Needle Engine runtime can load them correctly with CubeUVReflectionMapping.
8
+ */
9
+ export class NEEDLE_pmrem extends Extension {
10
+ static EXTENSION_NAME = EXTENSION_NAME;
11
+ extensionName = EXTENSION_NAME;
12
+ /** Textures that were converted from EXR via PMREM */
13
+ pmremTextures = new Set();
14
+ /** Mark a texture as PMREM-converted */
15
+ addTexture(texture) {
16
+ this.pmremTextures.add(texture);
17
+ }
18
+ /** @hidden */
19
+ read() {
20
+ return this;
21
+ }
22
+ /** @hidden */
23
+ write(context) {
24
+ const jsonDoc = context.jsonDoc;
25
+ for (const texture of this.pmremTextures) {
26
+ const imageIndex = context.imageIndexMap.get(texture);
27
+ if (imageIndex === undefined)
28
+ continue;
29
+ // Find texture defs that reference this image
30
+ for (const textureDef of jsonDoc.json.textures ?? []) {
31
+ const source = textureDef.source
32
+ ?? textureDef.extensions?.['KHR_texture_basisu']?.source;
33
+ if (source === imageIndex) {
34
+ textureDef.extensions = textureDef.extensions || {};
35
+ textureDef.extensions[EXTENSION_NAME] = { source: imageIndex };
36
+ // Remove KHR_texture_basisu so the runtime NEEDLE_pmrem plugin handles loading
37
+ // (otherwise three.js's built-in KHR_texture_basisu handler intercepts it first)
38
+ delete textureDef.extensions['KHR_texture_basisu'];
39
+ }
40
+ }
41
+ }
42
+ return this;
43
+ }
44
+ }
@@ -4,4 +4,5 @@ export * from "./NEEDLE_compression_texture/index.js";
4
4
  export * from "./NEEDLE_progressive/index.js";
5
5
  export * from "./NEEDLE_progressive_texture_settings/index.js";
6
6
  export * from "./NEEDLE_progressive_mesh_settings/index.js";
7
+ export * from "./NEEDLE_pmrem/index.js";
7
8
  export declare function registerExtensions(node: any): void;
@@ -5,6 +5,7 @@ export * from "./NEEDLE_compression_texture/index.js";
5
5
  export * from "./NEEDLE_progressive/index.js";
6
6
  export * from "./NEEDLE_progressive_texture_settings/index.js";
7
7
  export * from "./NEEDLE_progressive_mesh_settings/index.js";
8
+ export * from "./NEEDLE_pmrem/index.js";
8
9
  import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
9
10
  import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS } from "./NEEDLE_opaque/index.js";
10
11
  import { NEEDLE_progressive } from './NEEDLE_progressive/index.js';
@@ -5,10 +5,11 @@ import draco3d from 'draco3dgltf';
5
5
  import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
6
6
  import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
7
7
  import { dedup, metalRough, prune, resample } from '@gltf-transform/functions';
8
- import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS, NEEDLE_compression_texture, NEEDLE_mesh_compression, } from '../extensions/index.js';
8
+ import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS, NEEDLE_compression_texture, NEEDLE_mesh_compression, NEEDLE_pmrem, } from '../extensions/index.js';
9
9
  import { isLOD, isMeshLOD, needle_animation_transform, needle_asset, needle_mesh_transform, needle_texture_transform, } from '../transforms/index.js';
10
10
  import { getOutputPath, getVersion, ioTryReadWithMissingResources, writeNodeIO } from "../utils/index.js";
11
11
  import { addToCache, getHash, tryGetFromCache, } from "../cache/index.js";
12
+ import { TestAssertionError } from '../utils/test-assert.js';
12
13
  /** Compress a glTF file
13
14
  */
14
15
  export async function packGLTF(inputFile, outputFile, options) {
@@ -56,6 +57,7 @@ export async function packGLTF(inputFile, outputFile, options) {
56
57
  .registerExtensions([
57
58
  NEEDLE_compression_texture,
58
59
  NEEDLE_mesh_compression,
60
+ NEEDLE_pmrem,
59
61
  ])
60
62
  .registerDependencies({
61
63
  'draco3d.decoder': await draco3d.createDecoderModule(),
@@ -64,8 +66,13 @@ export async function packGLTF(inputFile, outputFile, options) {
64
66
  'meshopt.encoder': MeshoptEncoder,
65
67
  });
66
68
  const document = await ioTryReadWithMissingResources(io, inputFile); // await io.read(inputFile);
67
- /** @ts-ignore */
68
- document.getLogger().verbosity = options.verbose ? Verbosity.DEBUG : options.debug ? Verbosity.INFO : Verbosity.WARN;
69
+ if (options.logger) {
70
+ document.setLogger(options.logger);
71
+ }
72
+ else {
73
+ /** @ts-ignore */
74
+ document.getLogger().verbosity = options.verbose ? Verbosity.DEBUG : options.debug ? Verbosity.INFO : Verbosity.WARN;
75
+ }
69
76
  let totalGPUTextureMemory = 0;
70
77
  /** @type {Array<import('@gltf-transform/core').Transform>} */
71
78
  const transforms = [
@@ -128,7 +135,10 @@ export async function packGLTF(inputFile, outputFile, options) {
128
135
  return true;
129
136
  }
130
137
  catch (err) {
131
- logger.error(`Packing of "${path.basename(inputFile)}" using version ${getVersion()} failed with error:\n${err.message}`);
138
+ if (err instanceof TestAssertionError)
139
+ throw err;
140
+ const shortedStackTrace = err instanceof Error && err.stack ? err.stack.split('\n').slice(0, 2).join('\n') : '';
141
+ logger.error(`Packing of "${path.basename(inputFile)}" using version ${getVersion()} failed with error:\n${err.message}\n${shortedStackTrace}`);
132
142
  if (debug)
133
143
  throw err;
134
144
  return false;
@@ -0,0 +1,16 @@
1
+ import { Document, Texture } from '@gltf-transform/core';
2
+ import { NEEDLE_compression_texture_schema } from '../extensions/index.js';
3
+ import { INeedleTextureTransformContext, NeedleTransformContext } from './needle_common.js';
4
+ export declare class NeedlePmremContext implements INeedleTextureTransformContext {
5
+ private wasmModule;
6
+ private pmremDir;
7
+ private basisuExe;
8
+ private tmpDir;
9
+ private processed;
10
+ private available;
11
+ private logger;
12
+ private readonly convertedTextures;
13
+ prepare(document: Document): Promise<boolean>;
14
+ process(index: number, texture: Texture, _settings: NEEDLE_compression_texture_schema | null, _context: NeedleTransformContext): Promise<boolean>;
15
+ finalize(document: Document): Promise<void>;
16
+ }
@@ -0,0 +1,172 @@
1
+ import { NEEDLE_pmrem } from '../extensions/index.js';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, chmodSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath, pathToFileURL } from 'url';
5
+ import { execFile } from 'child_process';
6
+ import { v4 as uuid } from 'uuid';
7
+ import tmp from 'tmp';
8
+ /** Resolve the tools/pmrem/ directory relative to this file.
9
+ * At runtime this file is at dist/transforms/needle_pmrem.js,
10
+ * so tools/pmrem/ is at ../../tools/pmrem/ */
11
+ function getPmremDir() {
12
+ const thisDir = dirname(fileURLToPath(import.meta.url));
13
+ return join(thisDir, '..', '..', 'tools', 'pmrem');
14
+ }
15
+ function resolveBasisuExe(pmremDir) {
16
+ const platform = process.platform;
17
+ const base = join(pmremDir, 'basis');
18
+ if (platform === 'win32')
19
+ return join(base, 'win', 'basisu.exe');
20
+ if (platform === 'darwin')
21
+ return join(base, 'osx', 'basisu');
22
+ return join(base, 'linux', 'basisu');
23
+ }
24
+ function encodeKTX2WithBasisu(basisuExe, exrPath, outPath, variant, mipmaps) {
25
+ return new Promise((resolve, reject) => {
26
+ const args = [];
27
+ args.push(variant === 'hdr6x6' ? '-hdr_6x6' : '-hdr_4x4');
28
+ if (mipmaps)
29
+ args.push('-mipmap');
30
+ args.push('-file', exrPath, '-output_file', outPath, '-y_flip');
31
+ execFile(basisuExe, args, { windowsHide: true, encoding: 'utf8' }, (err, _stdout, stderr) => {
32
+ if (err) {
33
+ reject(new Error(`basisu failed: ${stderr || err.message}`));
34
+ return;
35
+ }
36
+ resolve();
37
+ });
38
+ });
39
+ }
40
+ export class NeedlePmremContext {
41
+ wasmModule = null;
42
+ pmremDir = '';
43
+ basisuExe = '';
44
+ tmpDir = '';
45
+ processed = 0;
46
+ available = false;
47
+ logger;
48
+ convertedTextures = [];
49
+ async prepare(document) {
50
+ this.logger = document.getLogger();
51
+ this.processed = 0;
52
+ this.pmremDir = getPmremDir();
53
+ // Check WASM exists
54
+ const wasmJsPath = join(this.pmremDir, 'pkg', 'pmrem_wasm.js');
55
+ const wasmBinPath = join(this.pmremDir, 'pkg', 'pmrem_wasm_bg.wasm');
56
+ if (!existsSync(wasmJsPath) || !existsSync(wasmBinPath)) {
57
+ this.logger.warn('pmrem: WASM not found — EXR textures will not be processed');
58
+ return false;
59
+ }
60
+ // Check basisu binary
61
+ this.basisuExe = resolveBasisuExe(this.pmremDir);
62
+ if (!existsSync(this.basisuExe)) {
63
+ this.logger.warn(`pmrem: basisu binary not found at ${this.basisuExe} — EXR textures will not be processed`);
64
+ return false;
65
+ }
66
+ // Set executable permission
67
+ if (process.platform !== 'win32') {
68
+ try {
69
+ chmodSync(this.basisuExe, 0o755);
70
+ }
71
+ catch { /* ignore */ }
72
+ }
73
+ // Load WASM
74
+ try {
75
+ const mod = await import(pathToFileURL(wasmJsPath).href);
76
+ const wasmBytes = readFileSync(wasmBinPath);
77
+ await mod.default({ module_or_path: wasmBytes });
78
+ this.wasmModule = mod;
79
+ }
80
+ catch (err) {
81
+ this.logger.warn(`pmrem: Failed to load WASM — ${err.message}`);
82
+ return false;
83
+ }
84
+ // Create temp directory
85
+ this.tmpDir = join(tmp.tmpdir, 'needle-pmrem');
86
+ mkdirSync(this.tmpDir, { recursive: true });
87
+ this.available = true;
88
+ this.logger.debug('pmrem: Ready (WASM + basisu loaded)');
89
+ return true;
90
+ }
91
+ async process(index, texture, _settings, _context) {
92
+ if (!this.available)
93
+ return false;
94
+ if (texture.getMimeType() !== 'image/exr')
95
+ return false;
96
+ const image = texture.getImage();
97
+ if (!image)
98
+ return false;
99
+ const name = texture.getName() || `texture_${index}`;
100
+ const id = uuid();
101
+ // 1. Run PMREM on the EXR bytes
102
+ this.logger.info(`pmrem:texture[${index}] ${name}: Running PMREM...`);
103
+ const exrBytes = new Uint8Array(image.buffer, image.byteOffset, image.byteLength);
104
+ const pmremExrBytes = this.wasmModule.pmrem_exr(exrBytes);
105
+ // 2. Write PMREM EXR to temp file
106
+ const tempExr = join(this.tmpDir, `${id}.pmrem.exr`);
107
+ const tempKtx2 = join(this.tmpDir, `${id}.ktx2`);
108
+ writeFileSync(tempExr, Buffer.from(pmremExrBytes));
109
+ // 3. Encode to KTX2 HDR via basisu
110
+ this.logger.debug(`pmrem:texture[${index}] ${name}: Encoding KTX2 HDR...`);
111
+ const variant = 'hdr4x4'; // 6x6 needs an update in Needle
112
+ await encodeKTX2WithBasisu(this.basisuExe, tempExr, tempKtx2, variant, true);
113
+ if (!existsSync(tempKtx2)) {
114
+ throw new Error(`pmrem:texture[${index}] ${name}: basisu did not produce output`);
115
+ }
116
+ // 4. Read KTX2 and update texture
117
+ const ktx2Bytes = readFileSync(tempKtx2);
118
+ texture.setImage(new Uint8Array(ktx2Bytes));
119
+ texture.setMimeType('image/ktx2');
120
+ // Remove EXT_texture_exr property since the texture is now KTX2
121
+ const exrProp = texture.getExtension('EXT_texture_exr');
122
+ if (exrProp) {
123
+ texture.setExtension('EXT_texture_exr', null);
124
+ exrProp.dispose();
125
+ }
126
+ // Update URI
127
+ const uri = texture.getURI();
128
+ if (uri) {
129
+ const newUri = uri.replace(/\.exr$/i, '.pmrem.ktx2');
130
+ texture.setURI(newUri !== uri ? newUri : uri + '.pmrem.ktx2');
131
+ }
132
+ // Report size (< 100 KB report in KB otherwise in MB)
133
+ const sizeStr = image.byteLength < 100 * 1024
134
+ ? `${(image.byteLength / 1024).toFixed(0)} KB`
135
+ : `${(image.byteLength / (1024 * 1024)).toFixed(2)} MB`;
136
+ const dstSizestr = ktx2Bytes.length < 100 * 1024
137
+ ? `${(ktx2Bytes.length / 1024).toFixed(0)} KB`
138
+ : `${(ktx2Bytes.length / (1024 * 1024)).toFixed(2)} MB`;
139
+ this.logger.info(`pmrem:texture[${index}] ${name}: ${sizeStr} EXR → ${dstSizestr} KTX2`);
140
+ this.convertedTextures.push(texture);
141
+ this.processed++;
142
+ return true;
143
+ }
144
+ async finalize(document) {
145
+ if (this.processed > 0) {
146
+ // Register NEEDLE_pmrem extension and mark converted textures
147
+ // Note: we do NOT register KHR_texture_basisu here — the NEEDLE_pmrem extension
148
+ // writer removes KHR_texture_basisu from texture defs so the runtime NEEDLE_pmrem
149
+ // plugin handles loading instead of three.js's built-in handler.
150
+ const pmremExt = document.createExtension(NEEDLE_pmrem);
151
+ for (const tex of this.convertedTextures) {
152
+ pmremExt.addTexture(tex);
153
+ }
154
+ // If no textures use EXR anymore, remove the EXT_texture_exr extension from the document
155
+ const hasExr = document.getRoot().listTextures().some(t => t.getMimeType() === 'image/exr');
156
+ if (!hasExr) {
157
+ const exrExt = document.getRoot().listExtensionsUsed()
158
+ .find(e => e.extensionName === 'EXT_texture_exr');
159
+ if (exrExt) {
160
+ exrExt.dispose();
161
+ }
162
+ }
163
+ }
164
+ // Clean up temp directory
165
+ if (this.tmpDir && existsSync(this.tmpDir)) {
166
+ try {
167
+ rmSync(this.tmpDir, { recursive: true });
168
+ }
169
+ catch { /* ignore */ }
170
+ }
171
+ }
172
+ }
@@ -13,6 +13,7 @@ import { needle_asset, writeNeedleAsset } from './needle_asset.js';
13
13
  import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
14
14
  import { compressPrimitive, determineMeshCompression } from './needle_mesh_transform.js';
15
15
  import { calculateMeshDensity, getMeshInformation } from "../utils/mesh.js";
16
+ import { TestAssertionError } from '../utils/test-assert.js';
16
17
  export async function make_progressive(opts) {
17
18
  const file = opts.path;
18
19
  let success = true;
@@ -78,9 +79,14 @@ async function onProcess(file, opts) {
78
79
  });
79
80
  let document = await ioTryReadWithMissingResources(io, file); // await io.read(file);
80
81
  if (document) {
81
- const logger = document.getLogger();
82
- //@ts-ignore
83
- logger.verbosity = (opts.debug || opts.verbose) ? Verbosity.DEBUG : Verbosity.WARN;
82
+ if (opts.logger) {
83
+ document.setLogger(opts.logger);
84
+ }
85
+ else {
86
+ //@ts-ignore
87
+ document.getLogger().verbosity = (opts.debug || opts.verbose) ? Verbosity.DEBUG : Verbosity.WARN;
88
+ }
89
+ const logger = opts.logger || document.getLogger();
84
90
  let smallesTextureSize = 128;
85
91
  if (opts.config.usecase === "product") {
86
92
  smallesTextureSize = 512;
@@ -120,6 +126,8 @@ async function onProcess(file, opts) {
120
126
  }
121
127
  }
122
128
  catch (err) {
129
+ if (err instanceof TestAssertionError)
130
+ throw err;
123
131
  console.log("ERR: failed to process file \"" + file + "\", Reason: " + err.message, "\nStack:\n" + err.stack);
124
132
  return false;
125
133
  }
@@ -151,6 +159,7 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
151
159
  return Promise.resolve();
152
160
  }
153
161
  let index = 0;
162
+ const textureHashCache = new Map();
154
163
  for (const texture of doc.getRoot().listTextures()) {
155
164
  const textureIndex = index++;
156
165
  const name = texture.getName();
@@ -177,8 +186,10 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
177
186
  continue;
178
187
  }
179
188
  else {
189
+ if (!textureHashCache.has(texture))
190
+ textureHashCache.set(texture, getHash(texture));
180
191
  settings = {
181
- guid: generateGuid(filename + "_texture_" + index.toString()),
192
+ guid: generateGuid(textureHashCache.get(texture)),
182
193
  maxSize: _options.size[0],
183
194
  };
184
195
  logger.debug(`[${NAME}] No settings found for texture[${index}] - will use default settings (maxSize: ${settings.maxSize}, guid: ${settings.guid})`);
@@ -213,7 +224,13 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
213
224
  logger.debug(`[${NAME}] Skipping, texture[${textureIndex}] is already smaller than the requested size: ${currentSize}px <= ${settings.maxSize}px`);
214
225
  continue;
215
226
  }
216
- const textureGuid = settings.guid?.length ? settings.guid : generateGuid(filename + "_texture_" + index.toString());
227
+ if (!settings.guid?.length) {
228
+ if (!textureHashCache.has(texture))
229
+ textureHashCache.set(texture, getHash(texture));
230
+ const hash = textureHashCache.get(texture);
231
+ settings.guid = generateGuid(hash);
232
+ }
233
+ const textureGuid = settings.guid;
217
234
  /** This is the size of the texture that is embedded in the glTF. No LOD should be smaller than this */
218
235
  const maxSize = settings.maxSize;
219
236
  const LOD_LEVEL = new Array();
@@ -360,7 +377,7 @@ async function createTextureLod(args, opts) {
360
377
  // see https://linear.app/needle/issue/NE-4985
361
378
  newMaterial.setAlphaMode("BLEND");
362
379
  newMaterial.setBaseColorTexture(newTexture);
363
- continue;
380
+ break;
364
381
  }
365
382
  if (slot === "specularColorTexture") {
366
383
  logger.info("Pass-through KHR_materials_pbrSpecularGlossiness specularColorTexture");
@@ -368,7 +385,7 @@ async function createTextureLod(args, opts) {
368
385
  const prob = ext.createSpecular();
369
386
  prob.setSpecularColorTexture(newTexture);
370
387
  newMaterial.setExtension(KHRMaterialsSpecular.EXTENSION_NAME, prob);
371
- continue;
388
+ break;
372
389
  }
373
390
  else if (slot === "specularTexture") {
374
391
  logger.info("Pass-through KHR_materials_specular specularTexture");
@@ -376,7 +393,7 @@ async function createTextureLod(args, opts) {
376
393
  const prob = ext.createSpecular();
377
394
  prob.setSpecularTexture(newTexture);
378
395
  newMaterial.setExtension(KHRMaterialsSpecular.EXTENSION_NAME, prob);
379
- continue;
396
+ break;
380
397
  }
381
398
  logger.warn(`WARN: Unknown texture slot: ${slot} for texture ${textureIndex} → can not load progressively (all slots: ${slots.join(", ")})`);
382
399
  return null;
@@ -524,7 +541,7 @@ async function resizeTexture(options, texture, logger, uri, slots) {
524
541
  }
525
542
  dstWidth = Math.floor(dstWidth);
526
543
  dstHeight = Math.floor(dstHeight);
527
- logger.info(`→ Resize texture (${uri || texture.getName() || slots.join(", ")}) from ${srcWidth}x${srcHeight} to ${dstWidth}x${dstHeight}px`);
544
+ logger.info(`→ Resize texture (${uri || texture.getName() || slots?.join(", ")}) from ${srcWidth}x${srcHeight} to ${dstWidth}x${dstHeight}px`);
528
545
  // https://sharp.pixelplumbing.com/api-resize
529
546
  const buffer = await sharp(texture.getImage())
530
547
  .resize(dstWidth, dstHeight, {
@@ -3,10 +3,12 @@ import { Transform } from '@gltf-transform/core';
3
3
  import { NeedleWebPContext } from "./needle_webp.js";
4
4
  import { NeedleTransformContext } from "./needle_common.js";
5
5
  import { NeedleToktxContext } from "./needle_toktx.js";
6
+ import { NeedlePmremContext } from "./needle_pmrem.js";
6
7
  export declare function resizeTextureIfNecessary(texture: Texture): Promise<void>;
7
8
  interface NeedleTextureTransformContext extends NeedleTransformContext {
8
9
  webPContext: NeedleWebPContext;
9
10
  toktxContext: NeedleToktxContext;
11
+ pmremContext: NeedlePmremContext;
10
12
  reportGPUMemory?: (bytes: number) => void;
11
13
  }
12
14
  export declare const needle_texture_transform: (options: NeedleTransformContext & Pick<NeedleTextureTransformContext, "reportGPUMemory">) => Transform;