@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.
- package/dist/cache/cache.d.ts +1 -0
- package/dist/cache/cache.js +4 -4
- package/dist/cli/index.js +0 -0
- package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.js +56 -30
- package/dist/extensions/NEEDLE_pmrem/index.d.ts +19 -0
- package/dist/extensions/NEEDLE_pmrem/index.js +44 -0
- package/dist/extensions/index.d.ts +1 -0
- package/dist/extensions/index.js +1 -0
- package/dist/scripts/pack-gltf.js +14 -4
- package/dist/transforms/needle_pmrem.d.ts +16 -0
- package/dist/transforms/needle_pmrem.js +172 -0
- package/dist/transforms/needle_progressive.js +26 -9
- package/dist/transforms/needle_texture_transform.d.ts +2 -0
- package/dist/transforms/needle_texture_transform.js +12 -2
- package/dist/utils/test-assert.d.ts +8 -0
- package/dist/utils/test-assert.js +18 -0
- package/dist/utils/version.gen.d.ts +1 -1
- package/dist/utils/version.gen.js +1 -1
- package/package.json +18 -4
- package/tools/pmrem/basis/linux/basisu +0 -0
- package/tools/pmrem/basis/osx/basisu +0 -0
- package/tools/pmrem/basis/package.json +3 -0
- package/tools/pmrem/basis/wasm/basis_encoder_threads.js +6693 -0
- package/tools/pmrem/basis/wasm/basis_encoder_threads.wasm +0 -0
- package/tools/pmrem/basis/win/basisu.exe +0 -0
- package/tools/pmrem/basis/win/basisu_help.txt +0 -0
- package/tools/pmrem/basis/win/ocl_kernels.cl +1290 -0
- package/tools/pmrem/pkg/pmrem_wasm.d.ts +68 -0
- package/tools/pmrem/pkg/pmrem_wasm.js +401 -0
- package/tools/pmrem/pkg/pmrem_wasm_bg.wasm +0 -0
- package/tsconfig.json +0 -20
package/dist/cache/cache.d.ts
CHANGED
package/dist/cache/cache.js
CHANGED
|
@@ -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
|
|
8
|
+
let xxHashModule = null;
|
|
9
9
|
xxhash().then(module => {
|
|
10
|
-
|
|
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 (
|
|
307
|
+
if (xxHashModule) {
|
|
308
308
|
const view = new Uint8Array(buffer, offset, length);
|
|
309
|
-
return
|
|
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
|
-
|
|
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.
|
|
406
|
-
if (debug)
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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.
|
|
436
|
-
console.warn("WARN: failed to re-assign extension:", this.key,
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
package/dist/extensions/index.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|