@needle-tools/gltf-build-pipeline 2.13.0 → 2.13.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +520 -517
  2. package/README.md +38 -38
  3. package/dist/cache/cache.d.ts +55 -55
  4. package/dist/cache/cache.js +326 -326
  5. package/dist/cache/index.d.ts +1 -1
  6. package/dist/cache/index.js +1 -1
  7. package/dist/cli/index.d.ts +2 -2
  8. package/dist/cli/index.js +198 -198
  9. package/dist/config/index.d.ts +53 -53
  10. package/dist/config/index.js +152 -152
  11. package/dist/constants.d.ts +7 -7
  12. package/dist/constants.js +7 -7
  13. package/dist/extensions/NEEDLE_compression_texture/NEEDLE_compression_texture.d.ts +24 -24
  14. package/dist/extensions/NEEDLE_compression_texture/NEEDLE_compression_texture.js +54 -54
  15. package/dist/extensions/NEEDLE_compression_texture/index.d.ts +1 -1
  16. package/dist/extensions/NEEDLE_compression_texture/index.js +1 -1
  17. package/dist/extensions/NEEDLE_mesh_compression/NEEDLE_mesh_compression.d.ts +27 -27
  18. package/dist/extensions/NEEDLE_mesh_compression/NEEDLE_mesh_compression.js +88 -88
  19. package/dist/extensions/NEEDLE_mesh_compression/index.d.ts +1 -1
  20. package/dist/extensions/NEEDLE_mesh_compression/index.js +1 -1
  21. package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.d.ts +27 -27
  22. package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.js +980 -980
  23. package/dist/extensions/NEEDLE_opaque/index.d.ts +3 -3
  24. package/dist/extensions/NEEDLE_opaque/index.js +30 -30
  25. package/dist/extensions/NEEDLE_progressive/NEEDLE_progressive.d.ts +48 -48
  26. package/dist/extensions/NEEDLE_progressive/NEEDLE_progressive.js +69 -69
  27. package/dist/extensions/NEEDLE_progressive/index.d.ts +1 -1
  28. package/dist/extensions/NEEDLE_progressive/index.js +1 -1
  29. package/dist/extensions/NEEDLE_progressive_mesh_settings/index.d.ts +23 -23
  30. package/dist/extensions/NEEDLE_progressive_mesh_settings/index.js +41 -41
  31. package/dist/extensions/NEEDLE_progressive_texture_settings/index.d.ts +25 -25
  32. package/dist/extensions/NEEDLE_progressive_texture_settings/index.js +38 -38
  33. package/dist/extensions/index.d.ts +7 -7
  34. package/dist/extensions/index.js +17 -17
  35. package/dist/extensions/utils.d.ts +6 -6
  36. package/dist/extensions/utils.js +72 -72
  37. package/dist/functions/weld.3.d.ts +76 -76
  38. package/dist/functions/weld.3.js +414 -414
  39. package/dist/index.d.ts +8 -8
  40. package/dist/index.js +8 -8
  41. package/dist/scripts/index.d.ts +2 -2
  42. package/dist/scripts/index.js +2 -2
  43. package/dist/scripts/pack-gltf.d.ts +15 -15
  44. package/dist/scripts/pack-gltf.js +135 -133
  45. package/dist/scripts/stats.d.ts +2 -2
  46. package/dist/scripts/stats.js +19 -19
  47. package/dist/transforms/index.d.ts +9 -9
  48. package/dist/transforms/index.js +9 -9
  49. package/dist/transforms/needle_animation_transform.d.ts +2 -2
  50. package/dist/transforms/needle_animation_transform.js +5 -5
  51. package/dist/transforms/needle_asset.d.ts +3 -3
  52. package/dist/transforms/needle_asset.js +50 -50
  53. package/dist/transforms/needle_common.d.ts +17 -17
  54. package/dist/transforms/needle_common.js +1 -1
  55. package/dist/transforms/needle_mesh_transform.d.ts +15 -15
  56. package/dist/transforms/needle_mesh_transform.js +223 -223
  57. package/dist/transforms/needle_progressive.d.ts +45 -45
  58. package/dist/transforms/needle_progressive.js +1043 -1043
  59. package/dist/transforms/needle_texture_transform.d.ts +13 -13
  60. package/dist/transforms/needle_texture_transform.js +257 -257
  61. package/dist/transforms/needle_toktx.d.ts +26 -26
  62. package/dist/transforms/needle_toktx.js +243 -243
  63. package/dist/transforms/needle_webp.d.ts +15 -15
  64. package/dist/transforms/needle_webp.js +53 -53
  65. package/dist/transforms/toktx.d.ts +52 -52
  66. package/dist/transforms/toktx.js +221 -221
  67. package/dist/transforms/toktx_types.d.ts +78 -78
  68. package/dist/transforms/toktx_types.js +62 -62
  69. package/dist/transforms/util.d.ts +21 -21
  70. package/dist/transforms/util.js +90 -90
  71. package/dist/utils/compression_utils.d.ts +2 -2
  72. package/dist/utils/compression_utils.js +6 -6
  73. package/dist/utils/fileutils.d.ts +19 -19
  74. package/dist/utils/fileutils.js +177 -177
  75. package/dist/utils/guid.d.ts +1 -1
  76. package/dist/utils/guid.js +5 -5
  77. package/dist/utils/index.d.ts +10 -10
  78. package/dist/utils/index.js +10 -10
  79. package/dist/utils/merge.d.ts +12 -12
  80. package/dist/utils/merge.js +31 -31
  81. package/dist/utils/mesh.d.ts +13 -13
  82. package/dist/utils/mesh.js +124 -124
  83. package/dist/utils/nodeio.d.ts +6 -6
  84. package/dist/utils/nodeio.js +86 -86
  85. package/dist/utils/stats.d.ts +70 -70
  86. package/dist/utils/stats.js +258 -258
  87. package/dist/utils/texture.d.ts +5 -5
  88. package/dist/utils/texture.js +14 -14
  89. package/dist/utils/validate.d.ts +5 -5
  90. package/dist/utils/validate.js +17 -17
  91. package/dist/utils/version.d.ts +1 -1
  92. package/dist/utils/version.gen.d.ts +1 -1
  93. package/dist/utils/version.gen.js +1 -1
  94. package/dist/utils/version.js +4 -4
  95. package/package.json +79 -79
  96. package/scripts/clear-caches.mjs +3 -3
  97. package/scripts/defaults.mjs +11 -11
  98. package/scripts/fileutils.mjs +5 -5
  99. package/scripts/index.mjs +41 -41
  100. package/scripts/limit-caches.mjs +3 -3
  101. package/scripts/make-progressive.mjs +16 -16
  102. package/scripts/pack-gltf.mjs +106 -106
  103. package/tsconfig.json +19 -19
@@ -1,1043 +1,1043 @@
1
- import { BufferUtils, Document, NodeIO, PropertyType, TextureChannel, Verbosity } from '@gltf-transform/core';
2
- import { SIMPLIFY_DEFAULTS, WELD_DEFAULTS, listTextureChannels, listTextureSlots, sparse } from '@gltf-transform/functions';
3
- import draco3d from 'draco3dgltf';
4
- import { NEEDLE_PROGRESSIVE_EXTENSION_NAME, NEEDLE_progressive } from "../extensions/NEEDLE_progressive/index.js";
5
- import { createOpaqueExtension, currentGenerator, NEEDLE_compression_texture, NEEDLE_mesh_compression, NEEDLE_progressive_mesh_settings, NEEDLE_progressive_texture_settings, registerExtensions as registerDefaultExtensions } from '../extensions/index.js';
6
- import path, { basename, resolve } from 'path';
7
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
8
- import { addMetaToCache, addToCache, getHash, getKey, tryGetFromCache, tryGetMetaFromCache } from '../cache/index.js';
9
- import sharp from "sharp";
10
- import { detectTextureCompressionMode, ensureIsDirectory, foreachGLTF, generateGuid, getOutputPath, isLightmap, setLightmapName, ioTryReadWithMissingResources, validateImageMimeType, writeNodeIO } from '../utils/index.js';
11
- import { needle_asset, writeNeedleAsset } from './needle_asset.js';
12
- import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
13
- import { compressPrimitive, determineMeshCompression } from './needle_mesh_transform.js';
14
- import { calculateMeshDensity, getMeshInformation } from "../utils/mesh.js";
15
- export async function make_progressive(opts) {
16
- const file = opts.path;
17
- let success = true;
18
- await foreachGLTF(file, async (f) => {
19
- // Should we stop if one file fails?
20
- if (!success) {
21
- return;
22
- }
23
- // Don't re-process files that are already LODs
24
- if (isMeshLOD(f) || isImageLOD(f)) {
25
- opts.logger.info(`[Progressive Transform]: Ignore existing LOD file at ${f}`);
26
- return;
27
- }
28
- const res = await onProcess(f, opts);
29
- if (!res)
30
- success = false;
31
- });
32
- if (success) {
33
- // Make sure all results exist
34
- // It's possible that files were deleted while omitting LODs - we want to remove them from the results list
35
- const resultsCount = opts.results.length;
36
- for (let i = opts.results.length - 1; i >= 0; i--) {
37
- if (!existsSync(opts.results[i])) {
38
- opts.results.splice(i, 1);
39
- }
40
- }
41
- const resultsChanged = resultsCount !== opts.results.length;
42
- if (opts.debug)
43
- console.log(`[Progressive Transform]: ${opts.results.length} files processed. ${resultsChanged ? "Some results were removed" : ""}`);
44
- }
45
- return success;
46
- }
47
- function registerExtensions(io) {
48
- registerDefaultExtensions(io);
49
- io.registerExtensions([
50
- NEEDLE_progressive,
51
- NEEDLE_progressive_texture_settings,
52
- NEEDLE_progressive_mesh_settings,
53
- NEEDLE_mesh_compression,
54
- //@ts-ignore
55
- createOpaqueExtension(NEEDLE_compression_texture.EXTENSION_NAME, PropertyType.TEXTURE),
56
- //@ts-ignore
57
- // createOpaqueExtension(NEEDLE_mesh_compression.EXTENSION_NAME, [PropertyType.ROOT, PropertyType.MESH, PropertyType.PRIMITIVE]),
58
- ]);
59
- }
60
- async function onProcess(file, opts) {
61
- if (!file.endsWith(".glb") && !file.endsWith(".gltf") && !file.endsWith(".vrm")) {
62
- console.log("ERR: file is not gltf/glb");
63
- return false;
64
- }
65
- if (!existsSync(file)) {
66
- console.log("ERR: file does not exist: " + file);
67
- return false;
68
- }
69
- try {
70
- const io = new NodeIO();
71
- registerExtensions(io);
72
- io.registerDependencies({
73
- 'meshopt.decoder': MeshoptDecoder,
74
- 'meshopt.encoder': MeshoptEncoder,
75
- 'draco3d.decoder': await draco3d.createDecoderModule(),
76
- 'draco3d.encoder': await draco3d.createEncoderModule(),
77
- });
78
- let document = await ioTryReadWithMissingResources(io, file); // await io.read(file);
79
- if (document) {
80
- const logger = document.getLogger();
81
- //@ts-ignore
82
- logger.verbosity = (opts.debug || opts.verbose) ? Verbosity.DEBUG : Verbosity.WARN;
83
- let smallesTextureSize = 128;
84
- if (opts.config.usecase === "product") {
85
- smallesTextureSize = 512;
86
- }
87
- const transforms = [
88
- needle_asset(),
89
- ];
90
- if (opts.config?.textures?.lods !== false) {
91
- transforms.push(make_progressive_textures(file, { size: [smallesTextureSize, smallesTextureSize] }, opts));
92
- }
93
- // TODO: progressive meshes aren't supported yet for VRMs
94
- const isVRM = file.toLowerCase().endsWith(".vrm");
95
- if (!isVRM && opts.config?.meshes?.lods !== false) {
96
- transforms.push(make_progressive_meshes(file, {}, opts));
97
- }
98
- else if (isVRM)
99
- logger.warn("WARN: Progressive meshes are not supported for VRM files yet");
100
- transforms.push(sparse());
101
- logger.info("→ Transforming: " + file);
102
- await document.transform(...transforms);
103
- const filename = getOutputPath(file, opts.outpath);
104
- ;
105
- await writeNodeIO(io, filename, document);
106
- opts.results.push(filename);
107
- logger.info("← Transforming done: " + filename);
108
- }
109
- }
110
- catch (err) {
111
- console.log("ERR: failed to process file \"" + file + "\", Reason: " + err.message, "\nStack:\n" + err.stack);
112
- return false;
113
- }
114
- return true;
115
- }
116
- // basic idea:
117
- // 1. iterate all images that have an extension marking them to be progressively loaded
118
- // 2. for each image, create a new image that is a downsampled version of the original and create a new gltf that contains the high res texture
119
- // 3. add extension to low res image containing info about where to find the high res texture
120
- export function createTransform(name, fn) {
121
- Object.defineProperty(fn, 'name', { value: name });
122
- return fn;
123
- }
124
- const NAME = 'NEEDLE_progressive';
125
- export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DEFAULTS, opts) {
126
- const startTime = Date.now();
127
- const filename = basename(filePath);
128
- const options = { ...TEXTURE_RESIZE_DEFAULTS, ..._options };
129
- let directory = path.dirname(filePath);
130
- if (opts.outpath) {
131
- directory = ensureIsDirectory(opts.outpath);
132
- }
133
- // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
134
- const textureLodBasePath = directory + "/";
135
- return createTransform(NAME, async (doc) => {
136
- const logger = doc.getLogger();
137
- if (isLOD(filePath)) {
138
- logger.info("Progressive Image Transform: skip lod at " + filePath);
139
- return Promise.resolve();
140
- }
141
- let index = 0;
142
- for (const texture of doc.getRoot().listTextures()) {
143
- const textureIndex = index++;
144
- const name = texture.getName();
145
- const uri = texture.getURI();
146
- const match = !options.pattern || options.pattern.test(name) || options.pattern.test(uri);
147
- if (!match) {
148
- logger.debug(`[${NAME}] Skipping, excluded by "pattern" parameter. ${textureIndex}`);
149
- continue;
150
- }
151
- if (texture.getMimeType() !== 'image/png' && texture.getMimeType() !== 'image/jpeg') {
152
- logger.warn(`[${NAME}] Skipping, unsupported texture type "${texture.getMimeType()} at ${textureIndex}".`);
153
- continue;
154
- }
155
- const slots = listTextureSlots(texture);
156
- if (options.slots && !slots.some((slot) => options.slots?.test(slot))) {
157
- logger.debug(`[${NAME}] Skipping, [${slots.join(', ')}] excluded by "slots" parameter. ${textureIndex}`);
158
- continue;
159
- }
160
- let settings = texture.getExtension(NEEDLE_progressive_texture_settings.EXTENSION_NAME);
161
- if (!settings) {
162
- const size = texture.getSize();
163
- if (size?.length >= 2 && size[0] <= options.size[0] && size[1] <= options.size[1]) {
164
- logger.warn(`[${NAME}] Skipping texture[${textureIndex}], no settings found and the texture is already <= ${size[0]}x${size[1]}`);
165
- continue;
166
- }
167
- else {
168
- settings = {
169
- guid: generateGuid(filename + "_texture_" + index.toString()),
170
- maxSize: _options.size[0],
171
- };
172
- logger.debug(`[${NAME}] No settings found for texture[${index}] - will use default settings (maxSize: ${settings.maxSize}, guid: ${settings.guid})`);
173
- }
174
- }
175
- else if (settings.generateLods === false) {
176
- logger.debug(`[${NAME}] Skipping texture[${textureIndex}]: progressive texture disabled`);
177
- continue;
178
- }
179
- if (settings.maxSize == undefined || settings.maxSize <= 0) {
180
- logger.warn(`[${NAME}] Skipping texture[${textureIndex}], settings are invalid (maxSize: ${settings.maxSize})`);
181
- continue;
182
- }
183
- const existingExtension = texture.getExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME);
184
- if (existingExtension !== undefined && existingExtension !== null) {
185
- logger.debug(`[${NAME}] Skipping, texture is already progressive ${textureIndex}`);
186
- continue;
187
- }
188
- const progressiveExt = doc.createExtension(NEEDLE_progressive);
189
- if (!progressiveExt || !progressiveExt.createTexture) {
190
- logger.debug(`[${NAME}] Skipping because progressive extension is not available. ${textureIndex}`);
191
- continue;
192
- }
193
- const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage());
194
- if (mimeTypeResult?.error) {
195
- logger.warn(`[${NAME}] Texture[${index}] has invalid mime type! Reported \"${texture.getMimeType()}\" but is actually \"${mimeTypeResult.mimeType}\": auto-fix`);
196
- texture.setMimeType(mimeTypeResult.mimeType);
197
- }
198
- /** This is the current texture's size */
199
- const currentSize = Math.min(...texture.getSize());
200
- if (currentSize <= settings.maxSize) {
201
- logger.debug(`[${NAME}] Skipping, texture[${textureIndex}] is already smaller than the requested size: ${currentSize}px <= ${settings.maxSize}px`);
202
- continue;
203
- }
204
- const textureGuid = settings.guid?.length ? settings.guid : generateGuid(filename + "_texture_" + index.toString());
205
- /** This is the size of the texture that is embedded in the glTF. No LOD should be smaller than this */
206
- const maxSize = settings.maxSize;
207
- const LOD_LEVEL = new Array();
208
- LOD_LEVEL.push({ size: currentSize });
209
- let generateLods = true; //opts.config?.usecase === "world";
210
- // If lods are enabled in the config
211
- // generateLods ||= opts.config?.textures?.lods === true;
212
- // // Ensure that texture lods are not disabled by { textures: { lods: false } }
213
- // generateLods &&= opts.config?.textures?.lods !== false;
214
- const _isLightmap = slots.includes("lightmapTexture") || isLightmap(texture);
215
- // Lightmaps don't have multiple levels right now
216
- if (generateLods && !_isLightmap) {
217
- const LEVEL = 6;
218
- let lastSize = currentSize;
219
- for (let i = 1; i < LEVEL; i++) {
220
- const size = lastSize * .5;
221
- if (size <= maxSize)
222
- break;
223
- lastSize = Math.round(size);
224
- LOD_LEVEL.push({ size: size });
225
- }
226
- }
227
- const results = new Array();
228
- for (let i = 0; i < LOD_LEVEL.length; i++) {
229
- const level = LOD_LEVEL[i];
230
- const lodResult = await createTextureLod({
231
- doc,
232
- inputFilepath: filePath,
233
- inputFilename: filename,
234
- basePath: textureLodBasePath,
235
- level: i,
236
- texture,
237
- slots,
238
- guid: textureGuid,
239
- textureIndex,
240
- maxSize: level.size
241
- }, opts);
242
- if (!lodResult) {
243
- continue;
244
- }
245
- const { 0: width, 1: height } = lodResult.texture.getSize();
246
- const outpath = lodResult?.outpath;
247
- const relName = path.relative(directory, outpath);
248
- results.push({
249
- path: relName,
250
- hash: lodResult.hash,
251
- width,
252
- height,
253
- });
254
- opts.results.push(outpath);
255
- }
256
- const cacheKey = getKey(texture);
257
- // TODO: this path should be added to the NEEDLE dependencies extension
258
- const model = {
259
- guid: textureGuid,
260
- lods: results,
261
- };
262
- texture.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, progressiveExt.createTexture(model));
263
- if (opts.useCache) {
264
- const cached = tryGetFromCache(cacheKey);
265
- if (cached) {
266
- logger.debug(`Progressive image ${index}: using cached preview version (size: ${options.size}))`);
267
- texture.setImage(cached);
268
- continue;
269
- }
270
- }
271
- options.size[0] = settings.maxSize;
272
- options.size[1] = settings.maxSize;
273
- logger.debug(`[${NAME}] Resizing embedded texture[${index}] to ${options.size[0]}x${options.size[1]}`);
274
- await resizeTexture(options, texture, logger, uri || name, slots);
275
- if (opts?.useCache === false) {
276
- }
277
- else
278
- addToCache(cacheKey, texture.getImage());
279
- }
280
- logger.debug(`Progressive transform finished in ${((Date.now() - startTime) / 1000).toFixed(1)} sec.`);
281
- });
282
- }
283
- async function createTextureLod(args, opts) {
284
- const { doc, inputFilepath: file, basePath, level, texture, guid, textureIndex } = args;
285
- let slots = args.slots;
286
- const logger = doc.getLogger();
287
- const newDoc = new Document();
288
- writeNeedleAsset(newDoc, {
289
- source: {
290
- filename: args.inputFilename
291
- }
292
- });
293
- const scene = newDoc.createScene();
294
- const buffer = newDoc.createBuffer();
295
- // add texture
296
- const newTexture = newDoc.createTexture();
297
- const img = texture.getImage();
298
- newTexture.setImage(img);
299
- newTexture.setURI(texture.getURI());
300
- newTexture.setMimeType(texture.getMimeType());
301
- newTexture.setName(texture.getName() || ("LOD_" + textureIndex));
302
- if (args.maxSize && args.maxSize >= 16) {
303
- const currentSize = newTexture.getSize();
304
- if (currentSize[0] > args.maxSize && currentSize[1] > args.maxSize) {
305
- await resizeTexture({ size: [args.maxSize, args.maxSize] }, newTexture, logger, texture.getURI(), slots);
306
- }
307
- }
308
- if (!slots?.length) {
309
- logger.warn(`WARN: No material slots found for texture at index ${textureIndex} (name: \"${texture.getName()}\") → will assume slot \"baseColorTexture\"`);
310
- slots = ["baseColorTexture"];
311
- }
312
- let slot_index = 0;
313
- // create material adding the texture to the same slot
314
- for (const slot of slots) {
315
- const newMaterial = newDoc.createMaterial(newTexture.getName());
316
- const channels = listTextureChannels(texture);
317
- // Ensure that we use the alpha channel if it is present
318
- if (channels.includes(TextureChannel.A)) {
319
- newMaterial.setAlphaMode("BLEND");
320
- }
321
- switch (slot) {
322
- case "baseColorTexture":
323
- newMaterial.setBaseColorTexture(newTexture);
324
- break;
325
- case "metallicRoughnessTexture":
326
- newMaterial.setMetallicRoughnessTexture(newTexture);
327
- break;
328
- case "normalTexture":
329
- newMaterial.setNormalTexture(newTexture);
330
- break;
331
- case "occlusionTexture":
332
- newMaterial.setOcclusionTexture(newTexture);
333
- break;
334
- case "emissiveTexture":
335
- newMaterial.setEmissiveTexture(newTexture);
336
- break;
337
- case "lightmapTexture":
338
- newMaterial.setBaseColorTexture(newTexture);
339
- setLightmapName(newTexture);
340
- break;
341
- default:
342
- if (slot.startsWith("/textures/")) {
343
- // Ensure the texture is using the alpha channel since we don't know what slots the original texture was used for
344
- // see https://linear.app/needle/issue/NE-4985
345
- newMaterial.setAlphaMode("BLEND");
346
- newMaterial.setBaseColorTexture(newTexture);
347
- continue;
348
- }
349
- logger.warn(`WARN: Unknown texture slot: ${slot} for texture ${textureIndex} → can not load progressively`);
350
- return null;
351
- }
352
- // Make sure the material is used
353
- createMeshForMaterial(newDoc, newMaterial, slot_index++);
354
- }
355
- // Pass compression settings to the new texture
356
- const compressionSettingsExt = texture.getExtension(NEEDLE_compression_texture.EXTENSION_NAME);
357
- if (compressionSettingsExt !== undefined && compressionSettingsExt !== null && compressionSettingsExt.getExtensionDefinition) {
358
- logger.info("> Pass compression extension to new texture");
359
- const newExt = newDoc.createExtension(NEEDLE_compression_texture);
360
- const compressionSettings = compressionSettingsExt.getExtensionDefinition();
361
- if (compressionSettings?.mode === "none") {
362
- // if the mode is set to None then we keep that setting for the new texture
363
- }
364
- else {
365
- switch (opts.config.usecase) {
366
- // For world scenarios we use ETC1S
367
- case "world":
368
- compressionSettings.mode = "ETC1S";
369
- break;
370
- case "product":
371
- compressionSettings.mode = detectTextureCompressionMode(slots);
372
- compressionSettings.quality = 100;
373
- break;
374
- default:
375
- compressionSettings.mode = detectTextureCompressionMode(slots);
376
- break;
377
- }
378
- }
379
- compressionSettings.slot = slots[0];
380
- const newCompressionSettings = newExt.create(compressionSettings);
381
- newTexture.setExtension(NEEDLE_compression_texture.EXTENSION_NAME, newCompressionSettings);
382
- }
383
- // add progressive texture extension
384
- const progressiveExt = newDoc.createExtension(NEEDLE_progressive);
385
- newTexture.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, progressiveExt.createTexture({
386
- guid: guid,
387
- }));
388
- const newIo = new NodeIO();
389
- registerExtensions(newIo);
390
- const ext = ".glb";
391
- const imageName = "image_" + level + "_" + guid;
392
- const filename = imageName + ext;
393
- const outName = path.join(basePath, filename);
394
- if (!existsSync(basePath)) {
395
- mkdirSync(basePath, { recursive: true });
396
- }
397
- logger.info(`[${NAME}] Write texture LOD ${level} to \"${filename}\"`);
398
- newIo.write(outName, newDoc);
399
- logger.info(`[${NAME}] Generate hash for texture LOD ${level}`);
400
- const hash = getHash(newTexture);
401
- return {
402
- outpath: outName,
403
- texture: newTexture,
404
- hash: hash,
405
- };
406
- }
407
- function createMeshForMaterial(doc, mat, i) {
408
- const buffer = doc.getRoot().listBuffers()[0];
409
- const scene = doc.getRoot().listScenes()[0];
410
- const node = doc.createNode();
411
- const mesh = doc.createMesh();
412
- const prim = doc.createPrimitive();
413
- const accessor = doc.createAccessor();
414
- accessor.setType('VEC3');
415
- // create quad mesh
416
- accessor.setArray(new Float32Array([
417
- -.5, -.5, 0,
418
- .5, -.5, 0,
419
- -.5, .5, 0,
420
- .5, .5, 0,
421
- ]));
422
- accessor.setBuffer(buffer);
423
- prim.setAttribute('POSITION', accessor);
424
- const indices = doc.createAccessor();
425
- indices.setType('SCALAR');
426
- indices.setArray(new Uint32Array([0, 1, 2, 2, 1, 3]));
427
- indices.setBuffer(buffer);
428
- prim.setIndices(indices);
429
- const uv = doc.createAccessor();
430
- uv.setType('VEC2');
431
- uv.setArray(new Float32Array([
432
- 0, 0,
433
- 1, 0,
434
- 0, 1,
435
- 1, 1,
436
- ]));
437
- uv.setBuffer(buffer);
438
- prim.setAttribute('TEXCOORD_0', uv);
439
- prim.setMaterial(mat);
440
- mesh.addPrimitive(prim);
441
- node.setMesh(mesh);
442
- scene.addChild(node);
443
- node.setName(`Quad_${i}`);
444
- const pos = [i, 0, 0];
445
- node.setTranslation(pos);
446
- return { node, mesh, prim };
447
- }
448
- /** Resampling filter methods. LANCZOS3 is sharper, LANCZOS2 is smoother. */
449
- export var TextureResizeFilter;
450
- (function (TextureResizeFilter) {
451
- /** Lanczos3 (sharp) */
452
- TextureResizeFilter["LANCZOS3"] = "lanczos3";
453
- /** Lanczos2 (smooth) */
454
- TextureResizeFilter["LANCZOS2"] = "lanczos2";
455
- })(TextureResizeFilter = TextureResizeFilter || (TextureResizeFilter = {}));
456
- export const TEXTURE_RESIZE_DEFAULTS = {
457
- size: [2048, 2048],
458
- filter: TextureResizeFilter.LANCZOS3,
459
- pattern: null,
460
- slots: null,
461
- };
462
- export async function resizeTextureToMax(texture, maxSize, logger) {
463
- const options = { ...TEXTURE_RESIZE_DEFAULTS };
464
- options.size = [maxSize, maxSize];
465
- await resizeTexture(options, texture, logger);
466
- }
467
- async function resizeTexture(options, texture, logger, uri, slots) {
468
- const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage());
469
- if (mimeTypeResult?.error) {
470
- logger.warn(`[${NAME}] Texture[${uri}] has invalid mime type: ${mimeTypeResult.error}: auto-fix`);
471
- texture.setMimeType(mimeTypeResult.mimeType);
472
- }
473
- const [maxWidth, maxHeight] = options.size;
474
- const [srcWidth, srcHeight] = texture.getSize();
475
- if (srcWidth <= maxWidth && srcHeight <= maxHeight) {
476
- logger.debug(`[${NAME}] Skipping, texture satisfies requested max size ${maxWidth}x${maxHeight}}`);
477
- return;
478
- }
479
- if (maxWidth <= 0 || maxHeight <= 0) {
480
- logger.debug(`${uri}: Skipping, invalid size range: ${maxWidth}x${maxHeight}`);
481
- return;
482
- }
483
- let dstWidth = srcWidth;
484
- let dstHeight = srcHeight;
485
- if (dstWidth > maxWidth) {
486
- dstHeight = Math.floor(dstHeight * (maxWidth / dstWidth));
487
- dstWidth = maxWidth;
488
- }
489
- if (dstHeight > maxHeight) {
490
- dstWidth = Math.floor(dstWidth * (maxHeight / dstHeight));
491
- dstHeight = maxHeight;
492
- }
493
- dstWidth = Math.floor(dstWidth);
494
- dstHeight = Math.floor(dstHeight);
495
- logger.info(`→ Resize texture (${uri || texture.getName() || slots.join(", ")}) from ${srcWidth}x${srcHeight} to ${dstWidth}x${dstHeight}px`);
496
- // https://sharp.pixelplumbing.com/api-resize
497
- const buffer = await sharp(texture.getImage())
498
- .resize(dstWidth, dstHeight, {
499
- fit: 'inside',
500
- kernel: "lanczos3",
501
- withoutEnlargement: true,
502
- })
503
- .toBuffer();
504
- texture.setImage(BufferUtils.toView(buffer));
505
- return {
506
- width: dstWidth,
507
- height: dstHeight,
508
- };
509
- }
510
- export function make_progressive_meshes(filePath, _options, opts) {
511
- let directory = path.dirname(filePath);
512
- if (opts.outpath) {
513
- directory = ensureIsDirectory(opts.outpath);
514
- }
515
- // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
516
- const outputDirectory = directory + "/";
517
- return createTransform("PROGRESSIVE_MESHES", async (doc, _context) => {
518
- const logger = doc.getLogger();
519
- // this is to ensure we're not creating progressive meshes for engine versions that don't support loading them yet
520
- // the generator should include the version or the app that produced the GLB too (needle engine repo: 2c00d0e304e205cf9d4ac0499faabb92a706a5c4)
521
- if (currentGenerator == "Needle Engine") {
522
- logger.warn("WARN: Skip creating mesh LODs - Unsupported generator found... Please update your editor integration");
523
- return;
524
- }
525
- if (isLOD(filePath)) {
526
- logger.info("Progressive Mesh Transform: skip lod at " + filePath);
527
- return Promise.resolve();
528
- }
529
- await MeshoptSimplifier.ready;
530
- const filename = path.basename(filePath);
531
- logger.info(`[${NAME}]: Begin generating progressive meshes for \"${filename}\"`);
532
- let index = 0;
533
- // Try settings here: https://meshoptimizer.org/demo/simplify.html
534
- // the lowest LOD (last entry) will be saved in the original file
535
- let MAX_LODS = 6;
536
- if (opts.config.usecase === "product") {
537
- MAX_LODS = 3;
538
- }
539
- const lod0_settings = {
540
- ratio: 1,
541
- error: 0,
542
- lockBorder: true,
543
- normalWeight: 2,
544
- colorWeight: 1,
545
- textureWeight: 1
546
- };
547
- const errorLevel = [0, .003, .005, .01, .02]; // 0.02 is roughly 10^-1.7 → disable scale error and set error https://meshoptimizer.org/demo/simplify.html
548
- const LODS = [lod0_settings];
549
- for (let l = 0; l < MAX_LODS; l++) {
550
- const t01 = l / (MAX_LODS - 1);
551
- const previous = LODS[LODS.length - 1];
552
- const ratio = previous.ratio * .5;
553
- let error = errorLevel[Math.min(errorLevel.length - 1, l)]; // example mesh: https://linear.app/needle/issue/NE-6653 or https://cloud-staging.needle.tools/edit/Z1S7sey8bb7061
554
- if (opts.config.usecase === "product") {
555
- error *= .5;
556
- }
557
- const normalWeight = lod0_settings.normalWeight; // keep normalWeight constantly high since it's so important
558
- const colorWeight = Math.max(.2, Math.min(1, lod0_settings.colorWeight * (1 - t01)));
559
- const textureWeight = Math.max(.2, Math.min(10, lod0_settings.textureWeight * (1 - t01 * 2)));
560
- const regularize = l > 1 && l < (MAX_LODS - 2); // only regularize the inner LODs
561
- LODS.push({
562
- ratio: ratio,
563
- error,
564
- lockBorder: true,
565
- regularize: regularize,
566
- normalWeight: normalWeight,
567
- colorWeight: colorWeight,
568
- textureWeight: textureWeight,
569
- });
570
- }
571
- logger.debug(`[${NAME}]: Generating mesh LODs:\n${JSON.stringify(LODS, undefined, 2)}`);
572
- /** Map of visited GUID -> extension */
573
- const visited = new Map();
574
- const root = doc.getRoot();
575
- const meshes = root.listMeshes();
576
- for (const mesh of meshes) {
577
- await processMesh(mesh, { filename });
578
- }
579
- logger.info(`[${NAME}]: Finished generating progressive meshes for \"${filename}\"`);
580
- async function processMesh(mesh, args) {
581
- /** The size and vertex count of the original mesh */
582
- const originalMeshInformation = getMeshInformation(mesh);
583
- const { totalVertexCount: originalTotalVertexCount, maxVertexCount: originalMaxVertexCount } = originalMeshInformation;
584
- let settings = mesh.getExtension(NEEDLE_progressive_mesh_settings.EXTENSION_NAME);
585
- if (!settings) {
586
- if (originalMaxVertexCount < 32) {
587
- logger.debug(`[${NAME}] Skipping, no settings found and no primitive with more than 32 vertices. mesh[${index}] has ${originalTotalVertexCount} vertices (max: ${originalMaxVertexCount})`);
588
- return;
589
- }
590
- else {
591
- settings = {
592
- guid: generateGuid(args.filename + "_mesh_" + index.toString()),
593
- generateLods: true,
594
- };
595
- logger.debug(`[${NAME}] No settings found for mesh[${index}] with ${originalTotalVertexCount} vertices (max: ${originalMaxVertexCount}) - will use default settings (guid: ${settings.guid})`);
596
- }
597
- }
598
- else if (settings.generateLods === false) {
599
- logger.debug(`[${NAME}] progressive mesh disabled for mesh at index ${index++}`);
600
- return;
601
- }
602
- const progressiveExt = doc.createExtension(NEEDLE_progressive);
603
- if (!progressiveExt || !progressiveExt.createMesh) {
604
- logger.debug(`[${NAME}] Skipping because progressive extension is not available. ${index++}`);
605
- return;
606
- }
607
- // check if we have already produced LODs for this mesh
608
- let needsToGenerateLODs = true;
609
- const generatedLODInfos = new Array();
610
- if (visited.has(settings.guid)) {
611
- // we can not just set the extension here...
612
- logger.debug(`[${NAME}] Mesh has already been processed ${index} - will use previously generated LODs`);
613
- needsToGenerateLODs = false;
614
- const existingPaths = visited.get(settings.guid);
615
- generatedLODInfos.push(...existingPaths.infos);
616
- }
617
- /** async simplification tasks... */
618
- const tasks = new Array();
619
- if (needsToGenerateLODs) {
620
- logger.info(`[${NAME}] Begin generating mesh LODs for \"${mesh.getName()}\"`);
621
- // produce LODS, skip the last level because it will be saved in the original file
622
- for (let level = 0; level < LODS.length - 1; level++) {
623
- const lod = LODS[level];
624
- const guid = settings.guid;
625
- // TODO: check if the LOD level did actually change the mesh - otherwise we can just skip generating lower resolutions
626
- const lodTask = createMeshLod(filename, doc, outputDirectory, level, mesh, originalMeshInformation, guid, lod, opts);
627
- lodTask.then(res => {
628
- if (res?.success) {
629
- generatedLODInfos[level] = res;
630
- }
631
- });
632
- tasks.push(lodTask);
633
- }
634
- // wait for all LODs to be generated
635
- await Promise.all(tasks);
636
- tasks.length = 0;
637
- }
638
- // compare LOD results and ignore if the mesh did not change
639
- // TODO: this should actually check mesh semantics (perhaps hash the mesh using the caching system?)
640
- let prevSize = -Infinity;
641
- for (let i = 0; i < generatedLODInfos.length; i++) {
642
- const lodInfo = generatedLODInfos[i];
643
- if (!lodInfo) {
644
- console.log(`[${NAME}] LOD ${i} doesnt exist`);
645
- continue;
646
- }
647
- const fullPath = outputDirectory + lodInfo.path;
648
- if (existsSync(fullPath)) {
649
- const lastSize = prevSize;
650
- const sizeOnDisc = statSync(fullPath).size;
651
- const diff = lastSize - sizeOnDisc;
652
- const level = lodInfo.level;
653
- let shouldOmitLevel = false;
654
- // if the mesh size on disc is identical to the last level, we can omit this level
655
- if (sizeOnDisc <= 0) {
656
- logger.info(`[${NAME}] Flag mesh LOD ${level}: size on disc is 0 → level will be omitted (if this mesh is is taken from cache it can mean that the LOD level was omitted before). \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
657
- shouldOmitLevel = true;
658
- }
659
- else if (diff == 0) {
660
- logger.info(`[${NAME}] Flag mesh LOD ${level}: size on disc did not change compared to previous level → level will be omitted. \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
661
- shouldOmitLevel = true;
662
- }
663
- else if (!shouldOmitLevel) {
664
- // if the mesh vertex count difference to the last valid level is less than 32 vertices or less than 5% of the last level, we can omit this level
665
- const meshInfo = lodInfo.lodMeshInfo;
666
- const lastMeshInfo = generatedLODInfos[i - 1]?.lodMeshInfo;
667
- if (meshInfo && lastMeshInfo) {
668
- const vertexDifference = lastMeshInfo.totalVertexCount - meshInfo.totalVertexCount;
669
- const diff01 = vertexDifference / lastMeshInfo.totalVertexCount;
670
- if (vertexDifference < 0) {
671
- logger.info(`[${NAME}] Flag mesh LOD ${level}: vertex count did increase (${-vertexDifference.toFixed(0)} vertices, ${(-diff01 * 100).toFixed(4)}%) → LOD ${level} will be omitted. \"${mesh.getName()}\" at \"${lodInfo.path}\" ${JSON.stringify(lastMeshInfo)} → ${JSON.stringify(meshInfo)}`);
672
- shouldOmitLevel = true;
673
- }
674
- else if (vertexDifference < 32 || diff01 < 0.05) {
675
- logger.info(`[${NAME}] Flag mesh LOD ${level}: vertex count difference below threshold (removed ${vertexDifference.toFixed(0)} vertices, ${(-diff01 * 100).toFixed(4)}%) → LOD ${level} will be omitted. \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
676
- shouldOmitLevel = true;
677
- }
678
- }
679
- if (shouldOmitLevel && opts.useCache != false && lodInfo.cacheKey != undefined) {
680
- // we invalidate the cached entry because the mesh change was too small
681
- addToCache(lodInfo.cacheKey, new Uint8Array(0));
682
- }
683
- }
684
- if (shouldOmitLevel) {
685
- logger.debug(`[${NAME}] Omitting LOD ${level} for \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
686
- generatedLODInfos.splice(i, 1);
687
- deleteFile(fullPath, opts);
688
- i--;
689
- }
690
- else {
691
- prevSize = sizeOnDisc;
692
- }
693
- }
694
- }
695
- let maxDensity = 0;
696
- let primitiveIndex = 0;
697
- const lodLevelInFile = generatedLODInfos.length;
698
- const clonedData = { primitives: [], weights: [...mesh.getWeights()] };
699
- // const primitiveDensities = new Array<number>(mesh.listPrimitives().length);
700
- mesh.listPrimitives().forEach((prim, index) => {
701
- const data = clonePrimitiveData(prim, logger);
702
- if (data != null) {
703
- clonedData.primitives.push(data);
704
- }
705
- logger.info(`[${NAME}] Simplify mesh[${index}] primitive[${primitiveIndex}] (embedded)`);
706
- const lastLod = LODS[lodLevelInFile];
707
- // TODO: this should be run after simplification but then we need to change the runtime to use the LOD 0 density
708
- const density = calculateMeshDensity(prim);
709
- // primitiveDensities[index] = density;
710
- maxDensity = Math.max(maxDensity, density);
711
- const task = simplifyMesh(doc, mesh, prim, primitiveIndex++, lastLod);
712
- tasks.push(task);
713
- });
714
- await Promise.all(tasks);
715
- tasks.length = 0;
716
- const { totalVertexCount: totalVertexCountAfter } = getMeshInformation(mesh);
717
- const difference = originalTotalVertexCount - totalVertexCountAfter;
718
- const diff01 = difference / originalTotalVertexCount;
719
- if (totalVertexCountAfter <= 0) {
720
- logger.error(`ERR: [${NAME}]: Mesh[${index}] has no vertices after simplification: ${mesh.getName()}`);
721
- }
722
- else if (difference < 32 || diff01 <= 0.05) {
723
- // the lowest LOD has the same vertex count as the original mesh → we don't need to write the extension and can also delete previously generated LODs
724
- logger.info(`[${NAME}]: Flag mesh LOD ${generatedLODInfos.length} (embedded): vertex count difference below threshold (removed ${difference} vertices, ${(-diff01 * 100).toFixed(2)} %) → will remove all LODs. \"${mesh.getName()}\"`);
725
- try {
726
- for (const lodInfo of generatedLODInfos) {
727
- deleteFile(outputDirectory + "/" + lodInfo.path, opts);
728
- }
729
- }
730
- catch (err) {
731
- logger.error(`ERR: [${NAME}] Failed to remove LOD files for \"${mesh.getName()}\"`);
732
- }
733
- // reset data (simplify might have changed the mesh data that we cloned before)
734
- const primitives = mesh.listPrimitives();
735
- if (clonedData.primitives.length == primitives.length) {
736
- if (clonedData.weights?.length > 0) {
737
- mesh.setWeights(clonedData.weights);
738
- }
739
- for (let i = 0; i < primitives.length; i++) {
740
- setPrimitiveData(primitives[i], clonedData.primitives[i]);
741
- }
742
- }
743
- return;
744
- }
745
- if (generatedLODInfos.length > 0) {
746
- const model = progressiveExt.createMesh({
747
- guid: settings.guid,
748
- lods: generatedLODInfos.map(l => {
749
- opts.results.push(resolve(outputDirectory, l.path));
750
- return {
751
- path: l.path,
752
- hash: l.content_hash.toString(),
753
- density: l.density,
754
- densities: l.densities,
755
- vertexCount: l.lodMeshInfo?.totalVertexCount,
756
- indexCount: l.lodMeshInfo?.totalIndexCount,
757
- };
758
- }),
759
- });
760
- mesh.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, model);
761
- index++;
762
- if (!visited.has(settings.guid)) {
763
- visited.set(settings.guid, {
764
- model: model,
765
- infos: generatedLODInfos,
766
- });
767
- }
768
- }
769
- }
770
- });
771
- }
772
- function deleteFile(filePath, _context) {
773
- rmSync(filePath);
774
- }
775
- function clonePrimitiveData(prim, logger) {
776
- const semantics = prim.listSemantics();
777
- if (semantics.some(s => s.includes("JOINTS"))) {
778
- return null;
779
- }
780
- const prevIndices = prim.getIndices();
781
- const indices = !prevIndices ? new Uint32Array() : new Uint32Array(prevIndices.getArray());
782
- const attributes = {};
783
- for (const attr of semantics) {
784
- logger.info(`[${NAME}] Clone attribute ${attr}`);
785
- const accessor = prim.getAttribute(attr);
786
- if (accessor) {
787
- attributes[attr] = new Float32Array(accessor.getArray());
788
- }
789
- }
790
- return { indices, attributes };
791
- }
792
- function setPrimitiveData(prim, data) {
793
- const indices = prim.getIndices();
794
- if (indices) {
795
- indices.setArray(data.indices);
796
- }
797
- for (const attr of prim.listSemantics()) {
798
- const accessor = prim.getAttribute(attr);
799
- if (accessor) {
800
- accessor.setArray(data.attributes[attr]);
801
- }
802
- }
803
- }
804
- /**
805
- * @param basePath the base path to save the lod files
806
- * @param level: 0 = original, 1 = first lod, 2 = second lod, ...
807
- * @param mesh the mesh
808
- * @param guid a unique identifier for the mesh
809
- * @param error the error to use for simplification. Higher means more simplification
810
- */
811
- async function createMeshLod(inputFileName, doc, basePath, level, mesh, meshInfo, guid, lodLevel, opts) {
812
- const logger = doc.getLogger();
813
- const fileExtension = ".glb";
814
- const fileName_withoutExtension = "mesh_lod_" + level + "_" + guid;
815
- const fileName = fileName_withoutExtension + fileExtension;
816
- const outPath = basePath + fileName;
817
- const primitives = mesh.listPrimitives();
818
- let cacheKey = undefined;
819
- // generate hash for mesh
820
- let hash = mesh["needle:mesh-hash"];
821
- if (hash === undefined) {
822
- logger.info(`[${NAME}] Generating hash for mesh \"${mesh.getName()}\"`);
823
- hash = getHash(mesh);
824
- mesh["needle:mesh-hash"] = hash;
825
- }
826
- hash += getHash({
827
- lodLevel,
828
- guid,
829
- config: opts.config,
830
- });
831
- if (opts.useCache !== false) {
832
- cacheKey = `mesh_lod_${level}_${hash}`;
833
- const cachedValue = tryGetFromCache(cacheKey);
834
- const meta = tryGetMetaFromCache(cacheKey);
835
- if (cachedValue && meta) {
836
- logger.info(`[${NAME}] LOD ${level} loaded from cache for \"${mesh.getName()}\"`);
837
- writeFileSync(outPath, cachedValue);
838
- return {
839
- level,
840
- path: fileName,
841
- success: true,
842
- cacheKey,
843
- content_hash: hash,
844
- lodMeshInfo: meta.meshInfo,
845
- density: meta.density,
846
- densities: meta.densities
847
- };
848
- }
849
- }
850
- const newDoc = new Document();
851
- writeNeedleAsset(newDoc, {
852
- "source": {
853
- filename: inputFileName,
854
- }
855
- });
856
- const newScene = newDoc.createScene();
857
- newDoc.createBuffer();
858
- const newNode = newDoc.createNode();
859
- newScene.addChild(newNode);
860
- newNode.setName("LOD " + level + "_" + guid);
861
- newNode.setTranslation([0, 0, 0]);
862
- const newMesh = newDoc.createMesh();
863
- newMesh.setName((mesh.getName() || "Unnamed") + " LOD " + level);
864
- newNode.setMesh(newMesh);
865
- const newMat = newDoc.createMaterial();
866
- const weights = mesh.getWeights();
867
- if (weights) {
868
- newMesh.setWeights(weights);
869
- }
870
- const tasks = new Array();
871
- // let didRunSimplification = false;
872
- const clonedAttributes = new Map();
873
- for (const prim of primitives) {
874
- const newPrim = newDoc.createPrimitive();
875
- newMesh.addPrimitive(newPrim);
876
- newPrim.setName(prim.getName());
877
- newPrim.setMode(prim.getMode());
878
- newPrim.setMaterial(newMat);
879
- const indices = prim.getIndices();
880
- if (indices) {
881
- const newIndices = newDoc.createAccessor();
882
- newIndices.setArray(indices.getArray());
883
- newIndices.setType(indices.getType());
884
- newIndices.setSparse(indices.getSparse());
885
- newIndices.setNormalized(indices.getNormalized());
886
- newPrim.setIndices(newIndices);
887
- }
888
- const semantics = prim.listSemantics();
889
- for (let i = 0; i < semantics.length; i++) {
890
- const attr = prim.getAttribute(semantics[i]);
891
- if (attr) {
892
- if (clonedAttributes.has(attr)) {
893
- const cloned = clonedAttributes.get(attr);
894
- newPrim.setAttribute(semantics[i], cloned);
895
- }
896
- else {
897
- const newAttr = newDoc.createAccessor();
898
- newAttr.setArray(attr.getArray());
899
- newAttr.setType(attr.getType());
900
- newAttr.setSparse(attr.getSparse());
901
- newAttr.setNormalized(attr.getNormalized());
902
- newPrim.setAttribute(semantics[i], newAttr);
903
- clonedAttributes.set(attr, newAttr);
904
- }
905
- }
906
- else {
907
- logger.warn(`[${NAME}] Missing attribute ${semantics[i]} in mesh LOD ${level}`);
908
- }
909
- }
910
- const targets = prim.listTargets();
911
- for (const target of targets) {
912
- const newTarget = newDoc.createPrimitiveTarget(target.getName());
913
- newPrim.addTarget(newTarget);
914
- // const attributes = target.listAttributes();
915
- const semantics = target.listSemantics();
916
- for (const attr of semantics) {
917
- const accessor = target.getAttribute(attr);
918
- if (accessor) {
919
- const newAccessor = newDoc.createAccessor();
920
- newAccessor.setArray(accessor.getArray());
921
- newAccessor.setType(accessor.getType());
922
- newAccessor.setSparse(accessor.getSparse());
923
- newAccessor.setNormalized(accessor.getNormalized());
924
- newTarget.setAttribute(attr, newAccessor);
925
- }
926
- else {
927
- console.error(`[${NAME}] Missing target attribute ${attr} in mesh LOD ${level}`);
928
- }
929
- }
930
- }
931
- ;
932
- const extensions = prim.listExtensions();
933
- for (const ext of extensions) {
934
- const PropertyClass = ext.constructor;
935
- const newProp = new PropertyClass(newDoc.getGraph());
936
- try {
937
- const copy = newProp.copy(ext);
938
- const name = ext.extensionName;
939
- newPrim.setExtension(name, copy);
940
- }
941
- catch (err) {
942
- logger.error(`[${NAME}] Failed to copy extension ${ext.extensionName} for mesh LOD ${level} → ${err}`);
943
- }
944
- }
945
- }
946
- let primitiveIndex = 0;
947
- if (lodLevel.error != undefined || lodLevel.ratio != undefined) {
948
- if (lodLevel.error > 0 || lodLevel.ratio < 1) {
949
- for (const newPrim of newMesh.listPrimitives()) {
950
- // didRunSimplification = true;
951
- const task = simplifyMesh(newDoc, newMesh, newPrim, primitiveIndex++, lodLevel);
952
- tasks.push(task);
953
- }
954
- }
955
- }
956
- await Promise.all(tasks);
957
- tasks.length = 0;
958
- const newMeshInformation = getMeshInformation(newMesh);
959
- if (newMeshInformation.totalVertexCount < 4) {
960
- logger.error(`[${NAME}] mesh LOD ${level} is has less than 4 vertices → skipping`);
961
- return {
962
- level,
963
- path: fileName,
964
- success: false,
965
- cacheKey: cacheKey,
966
- content_hash: hash,
967
- lodMeshInfo: null,
968
- density: null,
969
- densities: null
970
- };
971
- }
972
- let density = 0;
973
- const densities = new Array(newMesh.listPrimitives().length);
974
- newMesh.listPrimitives().forEach((prim, index) => {
975
- const d = calculateMeshDensity(prim);
976
- density = Math.max(density, d);
977
- densities[index] = d;
978
- });
979
- const progressiveExt = newDoc.createExtension(NEEDLE_progressive);
980
- if (!progressiveExt || !progressiveExt.createMesh) {
981
- logger.error("Failed to create progressive extension");
982
- return;
983
- }
984
- newMesh.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, progressiveExt.createMesh({
985
- guid: guid,
986
- }));
987
- // we need to compress the mesh with the same compression method as the original file
988
- const model = determineMeshCompression(doc, fileName);
989
- if (model) {
990
- const newExt = newDoc.createExtension(NEEDLE_mesh_compression);
991
- const newProp = newExt.create(model);
992
- logger.info(`[${NAME}] Pass compression format to mesh LOD ${level} → ${JSON.stringify(model)}`);
993
- newDoc.getRoot().setExtension(NEEDLE_mesh_compression.EXTENSION_NAME, newProp);
994
- }
995
- await newDoc.transform(sparse());
996
- const newIo = new NodeIO();
997
- registerExtensions(newIo);
998
- logger.info(`[${NAME}] Write mesh LOD ${level} with to \"${fileName}\"`);
999
- await newIo.write(outPath, newDoc);
1000
- // const hasChanged = meshInfo.maxVertexCount == newMeshInformation.maxVertexCount && meshInfo.totalVertexCount == newMeshInformation.totalVertexCount;
1001
- // add to cache
1002
- if (cacheKey != undefined) {
1003
- const bytes = readFileSync(outPath);
1004
- const sizeInMB = (bytes.byteLength / 1024 / 1024).toFixed(2);
1005
- logger.info(`[${NAME}] Add LOD ${level} to cache (${sizeInMB} MB) for \"${mesh.getName()}\" as ${cacheKey}`);
1006
- addToCache(cacheKey, bytes);
1007
- addMetaToCache(cacheKey, { meshInfo: newMeshInformation, density: density, densities: densities });
1008
- }
1009
- return {
1010
- level,
1011
- path: fileName,
1012
- success: true,
1013
- lodMeshInfo: newMeshInformation,
1014
- cacheKey: cacheKey,
1015
- content_hash: hash,
1016
- density: density,
1017
- densities: densities
1018
- };
1019
- }
1020
- async function simplifyMesh(doc, mesh, prim, index, lodinfo) {
1021
- const weldOptions = {
1022
- ...WELD_DEFAULTS,
1023
- overwrite: true,
1024
- cleanup: false,
1025
- tolerance: 0.00000000001,
1026
- ...lodinfo,
1027
- };
1028
- const simplifyOptions = {
1029
- ...SIMPLIFY_DEFAULTS,
1030
- ...lodinfo,
1031
- simplifier: MeshoptSimplifier,
1032
- };
1033
- return compressPrimitive(doc, mesh, prim, index, weldOptions, simplifyOptions);
1034
- }
1035
- export function isMeshLOD(filepath) {
1036
- return filepath.includes("mesh_lod_");
1037
- }
1038
- export function isImageLOD(filepath) {
1039
- return filepath.includes("image_");
1040
- }
1041
- export function isLOD(filepath) {
1042
- return isMeshLOD(filepath) || isImageLOD(filepath);
1043
- }
1
+ import { BufferUtils, Document, NodeIO, PropertyType, TextureChannel, Verbosity } from '@gltf-transform/core';
2
+ import { SIMPLIFY_DEFAULTS, WELD_DEFAULTS, listTextureChannels, listTextureSlots, sparse } from '@gltf-transform/functions';
3
+ import draco3d from 'draco3dgltf';
4
+ import { NEEDLE_PROGRESSIVE_EXTENSION_NAME, NEEDLE_progressive } from "../extensions/NEEDLE_progressive/index.js";
5
+ import { createOpaqueExtension, currentGenerator, NEEDLE_compression_texture, NEEDLE_mesh_compression, NEEDLE_progressive_mesh_settings, NEEDLE_progressive_texture_settings, registerExtensions as registerDefaultExtensions } from '../extensions/index.js';
6
+ import path, { basename, resolve } from 'path';
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
8
+ import { addMetaToCache, addToCache, getHash, getKey, tryGetFromCache, tryGetMetaFromCache } from '../cache/index.js';
9
+ import sharp from "sharp";
10
+ import { detectTextureCompressionMode, ensureIsDirectory, foreachGLTF, generateGuid, getOutputPath, isLightmap, setLightmapName, ioTryReadWithMissingResources, validateImageMimeType, writeNodeIO } from '../utils/index.js';
11
+ import { needle_asset, writeNeedleAsset } from './needle_asset.js';
12
+ import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
13
+ import { compressPrimitive, determineMeshCompression } from './needle_mesh_transform.js';
14
+ import { calculateMeshDensity, getMeshInformation } from "../utils/mesh.js";
15
+ export async function make_progressive(opts) {
16
+ const file = opts.path;
17
+ let success = true;
18
+ await foreachGLTF(file, async (f) => {
19
+ // Should we stop if one file fails?
20
+ if (!success) {
21
+ return;
22
+ }
23
+ // Don't re-process files that are already LODs
24
+ if (isMeshLOD(f) || isImageLOD(f)) {
25
+ opts.logger.info(`[Progressive Transform]: Ignore existing LOD file at ${f}`);
26
+ return;
27
+ }
28
+ const res = await onProcess(f, opts);
29
+ if (!res)
30
+ success = false;
31
+ });
32
+ if (success) {
33
+ // Make sure all results exist
34
+ // It's possible that files were deleted while omitting LODs - we want to remove them from the results list
35
+ const resultsCount = opts.results.length;
36
+ for (let i = opts.results.length - 1; i >= 0; i--) {
37
+ if (!existsSync(opts.results[i])) {
38
+ opts.results.splice(i, 1);
39
+ }
40
+ }
41
+ const resultsChanged = resultsCount !== opts.results.length;
42
+ if (opts.debug)
43
+ console.log(`[Progressive Transform]: ${opts.results.length} files processed. ${resultsChanged ? "Some results were removed" : ""}`);
44
+ }
45
+ return success;
46
+ }
47
+ function registerExtensions(io) {
48
+ registerDefaultExtensions(io);
49
+ io.registerExtensions([
50
+ NEEDLE_progressive,
51
+ NEEDLE_progressive_texture_settings,
52
+ NEEDLE_progressive_mesh_settings,
53
+ NEEDLE_mesh_compression,
54
+ //@ts-ignore
55
+ createOpaqueExtension(NEEDLE_compression_texture.EXTENSION_NAME, PropertyType.TEXTURE),
56
+ //@ts-ignore
57
+ // createOpaqueExtension(NEEDLE_mesh_compression.EXTENSION_NAME, [PropertyType.ROOT, PropertyType.MESH, PropertyType.PRIMITIVE]),
58
+ ]);
59
+ }
60
+ async function onProcess(file, opts) {
61
+ if (!file.endsWith(".glb") && !file.endsWith(".gltf") && !file.endsWith(".vrm")) {
62
+ console.log("ERR: file is not gltf/glb");
63
+ return false;
64
+ }
65
+ if (!existsSync(file)) {
66
+ console.log("ERR: file does not exist: " + file);
67
+ return false;
68
+ }
69
+ try {
70
+ const io = new NodeIO();
71
+ registerExtensions(io);
72
+ io.registerDependencies({
73
+ 'meshopt.decoder': MeshoptDecoder,
74
+ 'meshopt.encoder': MeshoptEncoder,
75
+ 'draco3d.decoder': await draco3d.createDecoderModule(),
76
+ 'draco3d.encoder': await draco3d.createEncoderModule(),
77
+ });
78
+ let document = await ioTryReadWithMissingResources(io, file); // await io.read(file);
79
+ if (document) {
80
+ const logger = document.getLogger();
81
+ //@ts-ignore
82
+ logger.verbosity = (opts.debug || opts.verbose) ? Verbosity.DEBUG : Verbosity.WARN;
83
+ let smallesTextureSize = 128;
84
+ if (opts.config.usecase === "product") {
85
+ smallesTextureSize = 512;
86
+ }
87
+ const transforms = [
88
+ needle_asset(),
89
+ ];
90
+ if (opts.config?.textures?.lods !== false) {
91
+ transforms.push(make_progressive_textures(file, { size: [smallesTextureSize, smallesTextureSize] }, opts));
92
+ }
93
+ // TODO: progressive meshes aren't supported yet for VRMs
94
+ const isVRM = file.toLowerCase().endsWith(".vrm");
95
+ if (!isVRM && opts.config?.meshes?.lods !== false) {
96
+ transforms.push(make_progressive_meshes(file, {}, opts));
97
+ }
98
+ else if (isVRM)
99
+ logger.warn("WARN: Progressive meshes are not supported for VRM files yet");
100
+ transforms.push(sparse());
101
+ logger.info("→ Transforming: " + file);
102
+ await document.transform(...transforms);
103
+ const filename = getOutputPath(file, opts.outpath);
104
+ ;
105
+ await writeNodeIO(io, filename, document);
106
+ opts.results.push(filename);
107
+ logger.info("← Transforming done: " + filename);
108
+ }
109
+ }
110
+ catch (err) {
111
+ console.log("ERR: failed to process file \"" + file + "\", Reason: " + err.message, "\nStack:\n" + err.stack);
112
+ return false;
113
+ }
114
+ return true;
115
+ }
116
+ // basic idea:
117
+ // 1. iterate all images that have an extension marking them to be progressively loaded
118
+ // 2. for each image, create a new image that is a downsampled version of the original and create a new gltf that contains the high res texture
119
+ // 3. add extension to low res image containing info about where to find the high res texture
120
+ export function createTransform(name, fn) {
121
+ Object.defineProperty(fn, 'name', { value: name });
122
+ return fn;
123
+ }
124
+ const NAME = 'NEEDLE_progressive';
125
+ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DEFAULTS, opts) {
126
+ const startTime = Date.now();
127
+ const filename = basename(filePath);
128
+ const options = { ...TEXTURE_RESIZE_DEFAULTS, ..._options };
129
+ let directory = path.dirname(filePath);
130
+ if (opts.outpath) {
131
+ directory = ensureIsDirectory(opts.outpath);
132
+ }
133
+ // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
134
+ const textureLodBasePath = directory + "/";
135
+ return createTransform(NAME, async (doc) => {
136
+ const logger = doc.getLogger();
137
+ if (isLOD(filePath)) {
138
+ logger.info("Progressive Image Transform: skip lod at " + filePath);
139
+ return Promise.resolve();
140
+ }
141
+ let index = 0;
142
+ for (const texture of doc.getRoot().listTextures()) {
143
+ const textureIndex = index++;
144
+ const name = texture.getName();
145
+ const uri = texture.getURI();
146
+ const match = !options.pattern || options.pattern.test(name) || options.pattern.test(uri);
147
+ if (!match) {
148
+ logger.debug(`[${NAME}] Skipping, excluded by "pattern" parameter. ${textureIndex}`);
149
+ continue;
150
+ }
151
+ if (texture.getMimeType() !== 'image/png' && texture.getMimeType() !== 'image/jpeg') {
152
+ logger.warn(`[${NAME}] Skipping, unsupported texture type "${texture.getMimeType()} at ${textureIndex}".`);
153
+ continue;
154
+ }
155
+ const slots = listTextureSlots(texture);
156
+ if (options.slots && !slots.some((slot) => options.slots?.test(slot))) {
157
+ logger.debug(`[${NAME}] Skipping, [${slots.join(', ')}] excluded by "slots" parameter. ${textureIndex}`);
158
+ continue;
159
+ }
160
+ let settings = texture.getExtension(NEEDLE_progressive_texture_settings.EXTENSION_NAME);
161
+ if (!settings) {
162
+ const size = texture.getSize();
163
+ if (size?.length >= 2 && size[0] <= options.size[0] && size[1] <= options.size[1]) {
164
+ logger.warn(`[${NAME}] Skipping texture[${textureIndex}], no settings found and the texture is already <= ${size[0]}x${size[1]}`);
165
+ continue;
166
+ }
167
+ else {
168
+ settings = {
169
+ guid: generateGuid(filename + "_texture_" + index.toString()),
170
+ maxSize: _options.size[0],
171
+ };
172
+ logger.debug(`[${NAME}] No settings found for texture[${index}] - will use default settings (maxSize: ${settings.maxSize}, guid: ${settings.guid})`);
173
+ }
174
+ }
175
+ else if (settings.generateLods === false) {
176
+ logger.debug(`[${NAME}] Skipping texture[${textureIndex}]: progressive texture disabled`);
177
+ continue;
178
+ }
179
+ if (settings.maxSize == undefined || settings.maxSize <= 0) {
180
+ logger.warn(`[${NAME}] Skipping texture[${textureIndex}], settings are invalid (maxSize: ${settings.maxSize})`);
181
+ continue;
182
+ }
183
+ const existingExtension = texture.getExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME);
184
+ if (existingExtension !== undefined && existingExtension !== null) {
185
+ logger.debug(`[${NAME}] Skipping, texture is already progressive ${textureIndex}`);
186
+ continue;
187
+ }
188
+ const progressiveExt = doc.createExtension(NEEDLE_progressive);
189
+ if (!progressiveExt || !progressiveExt.createTexture) {
190
+ logger.debug(`[${NAME}] Skipping because progressive extension is not available. ${textureIndex}`);
191
+ continue;
192
+ }
193
+ const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage());
194
+ if (mimeTypeResult?.error) {
195
+ logger.warn(`[${NAME}] Texture[${index}] has invalid mime type! Reported \"${texture.getMimeType()}\" but is actually \"${mimeTypeResult.mimeType}\": auto-fix`);
196
+ texture.setMimeType(mimeTypeResult.mimeType);
197
+ }
198
+ /** This is the current texture's size */
199
+ const currentSize = Math.min(...texture.getSize());
200
+ if (currentSize <= settings.maxSize) {
201
+ logger.debug(`[${NAME}] Skipping, texture[${textureIndex}] is already smaller than the requested size: ${currentSize}px <= ${settings.maxSize}px`);
202
+ continue;
203
+ }
204
+ const textureGuid = settings.guid?.length ? settings.guid : generateGuid(filename + "_texture_" + index.toString());
205
+ /** This is the size of the texture that is embedded in the glTF. No LOD should be smaller than this */
206
+ const maxSize = settings.maxSize;
207
+ const LOD_LEVEL = new Array();
208
+ LOD_LEVEL.push({ size: currentSize });
209
+ let generateLods = true; //opts.config?.usecase === "world";
210
+ // If lods are enabled in the config
211
+ // generateLods ||= opts.config?.textures?.lods === true;
212
+ // // Ensure that texture lods are not disabled by { textures: { lods: false } }
213
+ // generateLods &&= opts.config?.textures?.lods !== false;
214
+ const _isLightmap = slots.includes("lightmapTexture") || isLightmap(texture);
215
+ // Lightmaps don't have multiple levels right now
216
+ if (generateLods && !_isLightmap) {
217
+ const LEVEL = 6;
218
+ let lastSize = currentSize;
219
+ for (let i = 1; i < LEVEL; i++) {
220
+ const size = lastSize * .5;
221
+ if (size <= maxSize)
222
+ break;
223
+ lastSize = Math.round(size);
224
+ LOD_LEVEL.push({ size: size });
225
+ }
226
+ }
227
+ const results = new Array();
228
+ for (let i = 0; i < LOD_LEVEL.length; i++) {
229
+ const level = LOD_LEVEL[i];
230
+ const lodResult = await createTextureLod({
231
+ doc,
232
+ inputFilepath: filePath,
233
+ inputFilename: filename,
234
+ basePath: textureLodBasePath,
235
+ level: i,
236
+ texture,
237
+ slots,
238
+ guid: textureGuid,
239
+ textureIndex,
240
+ maxSize: level.size
241
+ }, opts);
242
+ if (!lodResult) {
243
+ continue;
244
+ }
245
+ const { 0: width, 1: height } = lodResult.texture.getSize();
246
+ const outpath = lodResult?.outpath;
247
+ const relName = path.relative(directory, outpath);
248
+ results.push({
249
+ path: relName,
250
+ hash: lodResult.hash,
251
+ width,
252
+ height,
253
+ });
254
+ opts.results.push(outpath);
255
+ }
256
+ const cacheKey = getKey(texture);
257
+ // TODO: this path should be added to the NEEDLE dependencies extension
258
+ const model = {
259
+ guid: textureGuid,
260
+ lods: results,
261
+ };
262
+ texture.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, progressiveExt.createTexture(model));
263
+ if (opts.useCache) {
264
+ const cached = tryGetFromCache(cacheKey);
265
+ if (cached) {
266
+ logger.debug(`Progressive image ${index}: using cached preview version (size: ${options.size}))`);
267
+ texture.setImage(cached);
268
+ continue;
269
+ }
270
+ }
271
+ options.size[0] = settings.maxSize;
272
+ options.size[1] = settings.maxSize;
273
+ logger.debug(`[${NAME}] Resizing embedded texture[${index}] to ${options.size[0]}x${options.size[1]}`);
274
+ await resizeTexture(options, texture, logger, uri || name, slots);
275
+ if (opts?.useCache === false) {
276
+ }
277
+ else
278
+ addToCache(cacheKey, texture.getImage());
279
+ }
280
+ logger.debug(`Progressive transform finished in ${((Date.now() - startTime) / 1000).toFixed(1)} sec.`);
281
+ });
282
+ }
283
+ async function createTextureLod(args, opts) {
284
+ const { doc, inputFilepath: file, basePath, level, texture, guid, textureIndex } = args;
285
+ let slots = args.slots;
286
+ const logger = doc.getLogger();
287
+ const newDoc = new Document();
288
+ writeNeedleAsset(newDoc, {
289
+ source: {
290
+ filename: args.inputFilename
291
+ }
292
+ });
293
+ const scene = newDoc.createScene();
294
+ const buffer = newDoc.createBuffer();
295
+ // add texture
296
+ const newTexture = newDoc.createTexture();
297
+ const img = texture.getImage();
298
+ newTexture.setImage(img);
299
+ newTexture.setURI(texture.getURI());
300
+ newTexture.setMimeType(texture.getMimeType());
301
+ newTexture.setName(texture.getName() || ("LOD_" + textureIndex));
302
+ if (args.maxSize && args.maxSize >= 16) {
303
+ const currentSize = newTexture.getSize();
304
+ if (currentSize[0] > args.maxSize && currentSize[1] > args.maxSize) {
305
+ await resizeTexture({ size: [args.maxSize, args.maxSize] }, newTexture, logger, texture.getURI(), slots);
306
+ }
307
+ }
308
+ if (!slots?.length) {
309
+ logger.warn(`WARN: No material slots found for texture at index ${textureIndex} (name: \"${texture.getName()}\") → will assume slot \"baseColorTexture\"`);
310
+ slots = ["baseColorTexture"];
311
+ }
312
+ let slot_index = 0;
313
+ // create material adding the texture to the same slot
314
+ for (const slot of slots) {
315
+ const newMaterial = newDoc.createMaterial(newTexture.getName());
316
+ const channels = listTextureChannels(texture);
317
+ // Ensure that we use the alpha channel if it is present
318
+ if (channels.includes(TextureChannel.A)) {
319
+ newMaterial.setAlphaMode("BLEND");
320
+ }
321
+ switch (slot) {
322
+ case "baseColorTexture":
323
+ newMaterial.setBaseColorTexture(newTexture);
324
+ break;
325
+ case "metallicRoughnessTexture":
326
+ newMaterial.setMetallicRoughnessTexture(newTexture);
327
+ break;
328
+ case "normalTexture":
329
+ newMaterial.setNormalTexture(newTexture);
330
+ break;
331
+ case "occlusionTexture":
332
+ newMaterial.setOcclusionTexture(newTexture);
333
+ break;
334
+ case "emissiveTexture":
335
+ newMaterial.setEmissiveTexture(newTexture);
336
+ break;
337
+ case "lightmapTexture":
338
+ newMaterial.setBaseColorTexture(newTexture);
339
+ setLightmapName(newTexture);
340
+ break;
341
+ default:
342
+ if (slot.startsWith("/textures/")) {
343
+ // Ensure the texture is using the alpha channel since we don't know what slots the original texture was used for
344
+ // see https://linear.app/needle/issue/NE-4985
345
+ newMaterial.setAlphaMode("BLEND");
346
+ newMaterial.setBaseColorTexture(newTexture);
347
+ continue;
348
+ }
349
+ logger.warn(`WARN: Unknown texture slot: ${slot} for texture ${textureIndex} → can not load progressively`);
350
+ return null;
351
+ }
352
+ // Make sure the material is used
353
+ createMeshForMaterial(newDoc, newMaterial, slot_index++);
354
+ }
355
+ // Pass compression settings to the new texture
356
+ const compressionSettingsExt = texture.getExtension(NEEDLE_compression_texture.EXTENSION_NAME);
357
+ if (compressionSettingsExt !== undefined && compressionSettingsExt !== null && compressionSettingsExt.getExtensionDefinition) {
358
+ logger.info("> Pass compression extension to new texture");
359
+ const newExt = newDoc.createExtension(NEEDLE_compression_texture);
360
+ const compressionSettings = compressionSettingsExt.getExtensionDefinition();
361
+ if (compressionSettings?.mode === "none") {
362
+ // if the mode is set to None then we keep that setting for the new texture
363
+ }
364
+ else {
365
+ switch (opts.config.usecase) {
366
+ // For world scenarios we use ETC1S
367
+ case "world":
368
+ compressionSettings.mode = "ETC1S";
369
+ break;
370
+ case "product":
371
+ compressionSettings.mode = detectTextureCompressionMode(slots);
372
+ compressionSettings.quality = 100;
373
+ break;
374
+ default:
375
+ compressionSettings.mode = detectTextureCompressionMode(slots);
376
+ break;
377
+ }
378
+ }
379
+ compressionSettings.slot = slots[0];
380
+ const newCompressionSettings = newExt.create(compressionSettings);
381
+ newTexture.setExtension(NEEDLE_compression_texture.EXTENSION_NAME, newCompressionSettings);
382
+ }
383
+ // add progressive texture extension
384
+ const progressiveExt = newDoc.createExtension(NEEDLE_progressive);
385
+ newTexture.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, progressiveExt.createTexture({
386
+ guid: guid,
387
+ }));
388
+ const newIo = new NodeIO();
389
+ registerExtensions(newIo);
390
+ const ext = ".glb";
391
+ const imageName = "image_" + level + "_" + guid;
392
+ const filename = imageName + ext;
393
+ const outName = path.join(basePath, filename);
394
+ if (!existsSync(basePath)) {
395
+ mkdirSync(basePath, { recursive: true });
396
+ }
397
+ logger.info(`[${NAME}] Write texture LOD ${level} to \"${filename}\"`);
398
+ newIo.write(outName, newDoc);
399
+ logger.info(`[${NAME}] Generate hash for texture LOD ${level}`);
400
+ const hash = getHash(newTexture);
401
+ return {
402
+ outpath: outName,
403
+ texture: newTexture,
404
+ hash: hash,
405
+ };
406
+ }
407
+ function createMeshForMaterial(doc, mat, i) {
408
+ const buffer = doc.getRoot().listBuffers()[0];
409
+ const scene = doc.getRoot().listScenes()[0];
410
+ const node = doc.createNode();
411
+ const mesh = doc.createMesh();
412
+ const prim = doc.createPrimitive();
413
+ const accessor = doc.createAccessor();
414
+ accessor.setType('VEC3');
415
+ // create quad mesh
416
+ accessor.setArray(new Float32Array([
417
+ -.5, -.5, 0,
418
+ .5, -.5, 0,
419
+ -.5, .5, 0,
420
+ .5, .5, 0,
421
+ ]));
422
+ accessor.setBuffer(buffer);
423
+ prim.setAttribute('POSITION', accessor);
424
+ const indices = doc.createAccessor();
425
+ indices.setType('SCALAR');
426
+ indices.setArray(new Uint32Array([0, 1, 2, 2, 1, 3]));
427
+ indices.setBuffer(buffer);
428
+ prim.setIndices(indices);
429
+ const uv = doc.createAccessor();
430
+ uv.setType('VEC2');
431
+ uv.setArray(new Float32Array([
432
+ 0, 0,
433
+ 1, 0,
434
+ 0, 1,
435
+ 1, 1,
436
+ ]));
437
+ uv.setBuffer(buffer);
438
+ prim.setAttribute('TEXCOORD_0', uv);
439
+ prim.setMaterial(mat);
440
+ mesh.addPrimitive(prim);
441
+ node.setMesh(mesh);
442
+ scene.addChild(node);
443
+ node.setName(`Quad_${i}`);
444
+ const pos = [i, 0, 0];
445
+ node.setTranslation(pos);
446
+ return { node, mesh, prim };
447
+ }
448
+ /** Resampling filter methods. LANCZOS3 is sharper, LANCZOS2 is smoother. */
449
+ export var TextureResizeFilter;
450
+ (function (TextureResizeFilter) {
451
+ /** Lanczos3 (sharp) */
452
+ TextureResizeFilter["LANCZOS3"] = "lanczos3";
453
+ /** Lanczos2 (smooth) */
454
+ TextureResizeFilter["LANCZOS2"] = "lanczos2";
455
+ })(TextureResizeFilter = TextureResizeFilter || (TextureResizeFilter = {}));
456
+ export const TEXTURE_RESIZE_DEFAULTS = {
457
+ size: [2048, 2048],
458
+ filter: TextureResizeFilter.LANCZOS3,
459
+ pattern: null,
460
+ slots: null,
461
+ };
462
+ export async function resizeTextureToMax(texture, maxSize, logger) {
463
+ const options = { ...TEXTURE_RESIZE_DEFAULTS };
464
+ options.size = [maxSize, maxSize];
465
+ await resizeTexture(options, texture, logger);
466
+ }
467
+ async function resizeTexture(options, texture, logger, uri, slots) {
468
+ const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage());
469
+ if (mimeTypeResult?.error) {
470
+ logger.warn(`[${NAME}] Texture[${uri}] has invalid mime type: ${mimeTypeResult.error}: auto-fix`);
471
+ texture.setMimeType(mimeTypeResult.mimeType);
472
+ }
473
+ const [maxWidth, maxHeight] = options.size;
474
+ const [srcWidth, srcHeight] = texture.getSize();
475
+ if (srcWidth <= maxWidth && srcHeight <= maxHeight) {
476
+ logger.debug(`[${NAME}] Skipping, texture satisfies requested max size ${maxWidth}x${maxHeight}}`);
477
+ return;
478
+ }
479
+ if (maxWidth <= 0 || maxHeight <= 0) {
480
+ logger.debug(`${uri}: Skipping, invalid size range: ${maxWidth}x${maxHeight}`);
481
+ return;
482
+ }
483
+ let dstWidth = srcWidth;
484
+ let dstHeight = srcHeight;
485
+ if (dstWidth > maxWidth) {
486
+ dstHeight = Math.floor(dstHeight * (maxWidth / dstWidth));
487
+ dstWidth = maxWidth;
488
+ }
489
+ if (dstHeight > maxHeight) {
490
+ dstWidth = Math.floor(dstWidth * (maxHeight / dstHeight));
491
+ dstHeight = maxHeight;
492
+ }
493
+ dstWidth = Math.floor(dstWidth);
494
+ dstHeight = Math.floor(dstHeight);
495
+ logger.info(`→ Resize texture (${uri || texture.getName() || slots.join(", ")}) from ${srcWidth}x${srcHeight} to ${dstWidth}x${dstHeight}px`);
496
+ // https://sharp.pixelplumbing.com/api-resize
497
+ const buffer = await sharp(texture.getImage())
498
+ .resize(dstWidth, dstHeight, {
499
+ fit: 'inside',
500
+ kernel: "lanczos3",
501
+ withoutEnlargement: true,
502
+ })
503
+ .toBuffer();
504
+ texture.setImage(BufferUtils.toView(buffer));
505
+ return {
506
+ width: dstWidth,
507
+ height: dstHeight,
508
+ };
509
+ }
510
+ export function make_progressive_meshes(filePath, _options, opts) {
511
+ let directory = path.dirname(filePath);
512
+ if (opts.outpath) {
513
+ directory = ensureIsDirectory(opts.outpath);
514
+ }
515
+ // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
516
+ const outputDirectory = directory + "/";
517
+ return createTransform("PROGRESSIVE_MESHES", async (doc, _context) => {
518
+ const logger = doc.getLogger();
519
+ // this is to ensure we're not creating progressive meshes for engine versions that don't support loading them yet
520
+ // the generator should include the version or the app that produced the GLB too (needle engine repo: 2c00d0e304e205cf9d4ac0499faabb92a706a5c4)
521
+ if (currentGenerator == "Needle Engine") {
522
+ logger.warn("WARN: Skip creating mesh LODs - Unsupported generator found... Please update your editor integration");
523
+ return;
524
+ }
525
+ if (isLOD(filePath)) {
526
+ logger.info("Progressive Mesh Transform: skip lod at " + filePath);
527
+ return Promise.resolve();
528
+ }
529
+ await MeshoptSimplifier.ready;
530
+ const filename = path.basename(filePath);
531
+ logger.info(`[${NAME}]: Begin generating progressive meshes for \"${filename}\"`);
532
+ let index = 0;
533
+ // Try settings here: https://meshoptimizer.org/demo/simplify.html
534
+ // the lowest LOD (last entry) will be saved in the original file
535
+ let MAX_LODS = 6;
536
+ if (opts.config.usecase === "product") {
537
+ MAX_LODS = 3;
538
+ }
539
+ const lod0_settings = {
540
+ ratio: 1,
541
+ error: 0,
542
+ lockBorder: true,
543
+ normalWeight: 2,
544
+ colorWeight: 1,
545
+ textureWeight: 1
546
+ };
547
+ const errorLevel = [0, .003, .005, .01, .02]; // 0.02 is roughly 10^-1.7 → disable scale error and set error https://meshoptimizer.org/demo/simplify.html
548
+ const LODS = [lod0_settings];
549
+ for (let l = 0; l < MAX_LODS; l++) {
550
+ const t01 = l / (MAX_LODS - 1);
551
+ const previous = LODS[LODS.length - 1];
552
+ const ratio = previous.ratio * .5;
553
+ let error = errorLevel[Math.min(errorLevel.length - 1, l)]; // example mesh: https://linear.app/needle/issue/NE-6653 or https://cloud-staging.needle.tools/edit/Z1S7sey8bb7061
554
+ if (opts.config.usecase === "product") {
555
+ error *= .5;
556
+ }
557
+ const normalWeight = lod0_settings.normalWeight; // keep normalWeight constantly high since it's so important
558
+ const colorWeight = Math.max(.2, Math.min(1, lod0_settings.colorWeight * (1 - t01)));
559
+ const textureWeight = Math.max(.2, Math.min(10, lod0_settings.textureWeight * (1 - t01 * 2)));
560
+ const regularize = l > 1 && l < (MAX_LODS - 2); // only regularize the inner LODs
561
+ LODS.push({
562
+ ratio: ratio,
563
+ error,
564
+ lockBorder: true,
565
+ regularize: regularize,
566
+ normalWeight: normalWeight,
567
+ colorWeight: colorWeight,
568
+ textureWeight: textureWeight,
569
+ });
570
+ }
571
+ logger.debug(`[${NAME}]: Generating mesh LODs:\n${JSON.stringify(LODS, undefined, 2)}`);
572
+ /** Map of visited GUID -> extension */
573
+ const visited = new Map();
574
+ const root = doc.getRoot();
575
+ const meshes = root.listMeshes();
576
+ for (const mesh of meshes) {
577
+ await processMesh(mesh, { filename });
578
+ }
579
+ logger.info(`[${NAME}]: Finished generating progressive meshes for \"${filename}\"`);
580
+ async function processMesh(mesh, args) {
581
+ /** The size and vertex count of the original mesh */
582
+ const originalMeshInformation = getMeshInformation(mesh);
583
+ const { totalVertexCount: originalTotalVertexCount, maxVertexCount: originalMaxVertexCount } = originalMeshInformation;
584
+ let settings = mesh.getExtension(NEEDLE_progressive_mesh_settings.EXTENSION_NAME);
585
+ if (!settings) {
586
+ if (originalMaxVertexCount < 32) {
587
+ logger.debug(`[${NAME}] Skipping, no settings found and no primitive with more than 32 vertices. mesh[${index}] has ${originalTotalVertexCount} vertices (max: ${originalMaxVertexCount})`);
588
+ return;
589
+ }
590
+ else {
591
+ settings = {
592
+ guid: generateGuid(args.filename + "_mesh_" + index.toString()),
593
+ generateLods: true,
594
+ };
595
+ logger.debug(`[${NAME}] No settings found for mesh[${index}] with ${originalTotalVertexCount} vertices (max: ${originalMaxVertexCount}) - will use default settings (guid: ${settings.guid})`);
596
+ }
597
+ }
598
+ else if (settings.generateLods === false) {
599
+ logger.debug(`[${NAME}] progressive mesh disabled for mesh at index ${index++}`);
600
+ return;
601
+ }
602
+ const progressiveExt = doc.createExtension(NEEDLE_progressive);
603
+ if (!progressiveExt || !progressiveExt.createMesh) {
604
+ logger.debug(`[${NAME}] Skipping because progressive extension is not available. ${index++}`);
605
+ return;
606
+ }
607
+ // check if we have already produced LODs for this mesh
608
+ let needsToGenerateLODs = true;
609
+ const generatedLODInfos = new Array();
610
+ if (visited.has(settings.guid)) {
611
+ // we can not just set the extension here...
612
+ logger.debug(`[${NAME}] Mesh has already been processed ${index} - will use previously generated LODs`);
613
+ needsToGenerateLODs = false;
614
+ const existingPaths = visited.get(settings.guid);
615
+ generatedLODInfos.push(...existingPaths.infos);
616
+ }
617
+ /** async simplification tasks... */
618
+ const tasks = new Array();
619
+ if (needsToGenerateLODs) {
620
+ logger.info(`[${NAME}] Begin generating mesh LODs for \"${mesh.getName()}\"`);
621
+ // produce LODS, skip the last level because it will be saved in the original file
622
+ for (let level = 0; level < LODS.length - 1; level++) {
623
+ const lod = LODS[level];
624
+ const guid = settings.guid;
625
+ // TODO: check if the LOD level did actually change the mesh - otherwise we can just skip generating lower resolutions
626
+ const lodTask = createMeshLod(filename, doc, outputDirectory, level, mesh, originalMeshInformation, guid, lod, opts);
627
+ lodTask.then(res => {
628
+ if (res?.success) {
629
+ generatedLODInfos[level] = res;
630
+ }
631
+ });
632
+ tasks.push(lodTask);
633
+ }
634
+ // wait for all LODs to be generated
635
+ await Promise.all(tasks);
636
+ tasks.length = 0;
637
+ }
638
+ // compare LOD results and ignore if the mesh did not change
639
+ // TODO: this should actually check mesh semantics (perhaps hash the mesh using the caching system?)
640
+ let prevSize = -Infinity;
641
+ for (let i = 0; i < generatedLODInfos.length; i++) {
642
+ const lodInfo = generatedLODInfos[i];
643
+ if (!lodInfo) {
644
+ console.log(`[${NAME}] LOD ${i} doesnt exist`);
645
+ continue;
646
+ }
647
+ const fullPath = outputDirectory + lodInfo.path;
648
+ if (existsSync(fullPath)) {
649
+ const lastSize = prevSize;
650
+ const sizeOnDisc = statSync(fullPath).size;
651
+ const diff = lastSize - sizeOnDisc;
652
+ const level = lodInfo.level;
653
+ let shouldOmitLevel = false;
654
+ // if the mesh size on disc is identical to the last level, we can omit this level
655
+ if (sizeOnDisc <= 0) {
656
+ logger.info(`[${NAME}] Flag mesh LOD ${level}: size on disc is 0 → level will be omitted (if this mesh is is taken from cache it can mean that the LOD level was omitted before). \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
657
+ shouldOmitLevel = true;
658
+ }
659
+ else if (diff == 0) {
660
+ logger.info(`[${NAME}] Flag mesh LOD ${level}: size on disc did not change compared to previous level → level will be omitted. \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
661
+ shouldOmitLevel = true;
662
+ }
663
+ else if (!shouldOmitLevel) {
664
+ // if the mesh vertex count difference to the last valid level is less than 32 vertices or less than 5% of the last level, we can omit this level
665
+ const meshInfo = lodInfo.lodMeshInfo;
666
+ const lastMeshInfo = generatedLODInfos[i - 1]?.lodMeshInfo;
667
+ if (meshInfo && lastMeshInfo) {
668
+ const vertexDifference = lastMeshInfo.totalVertexCount - meshInfo.totalVertexCount;
669
+ const diff01 = vertexDifference / lastMeshInfo.totalVertexCount;
670
+ if (vertexDifference < 0) {
671
+ logger.info(`[${NAME}] Flag mesh LOD ${level}: vertex count did increase (${-vertexDifference.toFixed(0)} vertices, ${(-diff01 * 100).toFixed(4)}%) → LOD ${level} will be omitted. \"${mesh.getName()}\" at \"${lodInfo.path}\" ${JSON.stringify(lastMeshInfo)} → ${JSON.stringify(meshInfo)}`);
672
+ shouldOmitLevel = true;
673
+ }
674
+ else if (vertexDifference < 32 || diff01 < 0.05) {
675
+ logger.info(`[${NAME}] Flag mesh LOD ${level}: vertex count difference below threshold (removed ${vertexDifference.toFixed(0)} vertices, ${(-diff01 * 100).toFixed(4)}%) → LOD ${level} will be omitted. \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
676
+ shouldOmitLevel = true;
677
+ }
678
+ }
679
+ if (shouldOmitLevel && opts.useCache != false && lodInfo.cacheKey != undefined) {
680
+ // we invalidate the cached entry because the mesh change was too small
681
+ addToCache(lodInfo.cacheKey, new Uint8Array(0));
682
+ }
683
+ }
684
+ if (shouldOmitLevel) {
685
+ logger.debug(`[${NAME}] Omitting LOD ${level} for \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
686
+ generatedLODInfos.splice(i, 1);
687
+ deleteFile(fullPath, opts);
688
+ i--;
689
+ }
690
+ else {
691
+ prevSize = sizeOnDisc;
692
+ }
693
+ }
694
+ }
695
+ let maxDensity = 0;
696
+ let primitiveIndex = 0;
697
+ const lodLevelInFile = generatedLODInfos.length;
698
+ const clonedData = { primitives: [], weights: [...mesh.getWeights()] };
699
+ // const primitiveDensities = new Array<number>(mesh.listPrimitives().length);
700
+ mesh.listPrimitives().forEach((prim, index) => {
701
+ const data = clonePrimitiveData(prim, logger);
702
+ if (data != null) {
703
+ clonedData.primitives.push(data);
704
+ }
705
+ logger.info(`[${NAME}] Simplify mesh[${index}] primitive[${primitiveIndex}] (embedded)`);
706
+ const lastLod = LODS[lodLevelInFile];
707
+ // TODO: this should be run after simplification but then we need to change the runtime to use the LOD 0 density
708
+ const density = calculateMeshDensity(prim);
709
+ // primitiveDensities[index] = density;
710
+ maxDensity = Math.max(maxDensity, density);
711
+ const task = simplifyMesh(doc, mesh, prim, primitiveIndex++, lastLod);
712
+ tasks.push(task);
713
+ });
714
+ await Promise.all(tasks);
715
+ tasks.length = 0;
716
+ const { totalVertexCount: totalVertexCountAfter } = getMeshInformation(mesh);
717
+ const difference = originalTotalVertexCount - totalVertexCountAfter;
718
+ const diff01 = difference / originalTotalVertexCount;
719
+ if (totalVertexCountAfter <= 0) {
720
+ logger.error(`ERR: [${NAME}]: Mesh[${index}] has no vertices after simplification: ${mesh.getName()}`);
721
+ }
722
+ else if (difference < 32 || diff01 <= 0.05) {
723
+ // the lowest LOD has the same vertex count as the original mesh → we don't need to write the extension and can also delete previously generated LODs
724
+ logger.info(`[${NAME}]: Flag mesh LOD ${generatedLODInfos.length} (embedded): vertex count difference below threshold (removed ${difference} vertices, ${(-diff01 * 100).toFixed(2)} %) → will remove all LODs. \"${mesh.getName()}\"`);
725
+ try {
726
+ for (const lodInfo of generatedLODInfos) {
727
+ deleteFile(outputDirectory + "/" + lodInfo.path, opts);
728
+ }
729
+ }
730
+ catch (err) {
731
+ logger.error(`ERR: [${NAME}] Failed to remove LOD files for \"${mesh.getName()}\"`);
732
+ }
733
+ // reset data (simplify might have changed the mesh data that we cloned before)
734
+ const primitives = mesh.listPrimitives();
735
+ if (clonedData.primitives.length == primitives.length) {
736
+ if (clonedData.weights?.length > 0) {
737
+ mesh.setWeights(clonedData.weights);
738
+ }
739
+ for (let i = 0; i < primitives.length; i++) {
740
+ setPrimitiveData(primitives[i], clonedData.primitives[i]);
741
+ }
742
+ }
743
+ return;
744
+ }
745
+ if (generatedLODInfos.length > 0) {
746
+ const model = progressiveExt.createMesh({
747
+ guid: settings.guid,
748
+ lods: generatedLODInfos.map(l => {
749
+ opts.results.push(resolve(outputDirectory, l.path));
750
+ return {
751
+ path: l.path,
752
+ hash: l.content_hash.toString(),
753
+ density: l.density,
754
+ densities: l.densities,
755
+ vertexCount: l.lodMeshInfo?.totalVertexCount,
756
+ indexCount: l.lodMeshInfo?.totalIndexCount,
757
+ };
758
+ }),
759
+ });
760
+ mesh.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, model);
761
+ index++;
762
+ if (!visited.has(settings.guid)) {
763
+ visited.set(settings.guid, {
764
+ model: model,
765
+ infos: generatedLODInfos,
766
+ });
767
+ }
768
+ }
769
+ }
770
+ });
771
+ }
772
+ function deleteFile(filePath, _context) {
773
+ rmSync(filePath);
774
+ }
775
+ function clonePrimitiveData(prim, logger) {
776
+ const semantics = prim.listSemantics();
777
+ if (semantics.some(s => s.includes("JOINTS"))) {
778
+ return null;
779
+ }
780
+ const prevIndices = prim.getIndices();
781
+ const indices = !prevIndices ? new Uint32Array() : new Uint32Array(prevIndices.getArray());
782
+ const attributes = {};
783
+ for (const attr of semantics) {
784
+ logger.info(`[${NAME}] Clone attribute ${attr}`);
785
+ const accessor = prim.getAttribute(attr);
786
+ if (accessor) {
787
+ attributes[attr] = new Float32Array(accessor.getArray());
788
+ }
789
+ }
790
+ return { indices, attributes };
791
+ }
792
+ function setPrimitiveData(prim, data) {
793
+ const indices = prim.getIndices();
794
+ if (indices) {
795
+ indices.setArray(data.indices);
796
+ }
797
+ for (const attr of prim.listSemantics()) {
798
+ const accessor = prim.getAttribute(attr);
799
+ if (accessor) {
800
+ accessor.setArray(data.attributes[attr]);
801
+ }
802
+ }
803
+ }
804
+ /**
805
+ * @param basePath the base path to save the lod files
806
+ * @param level: 0 = original, 1 = first lod, 2 = second lod, ...
807
+ * @param mesh the mesh
808
+ * @param guid a unique identifier for the mesh
809
+ * @param error the error to use for simplification. Higher means more simplification
810
+ */
811
+ async function createMeshLod(inputFileName, doc, basePath, level, mesh, meshInfo, guid, lodLevel, opts) {
812
+ const logger = doc.getLogger();
813
+ const fileExtension = ".glb";
814
+ const fileName_withoutExtension = "mesh_lod_" + level + "_" + guid;
815
+ const fileName = fileName_withoutExtension + fileExtension;
816
+ const outPath = basePath + fileName;
817
+ const primitives = mesh.listPrimitives();
818
+ let cacheKey = undefined;
819
+ // generate hash for mesh
820
+ let hash = mesh["needle:mesh-hash"];
821
+ if (hash === undefined) {
822
+ logger.info(`[${NAME}] Generating hash for mesh \"${mesh.getName()}\"`);
823
+ hash = getHash(mesh);
824
+ mesh["needle:mesh-hash"] = hash;
825
+ }
826
+ hash += getHash({
827
+ lodLevel,
828
+ guid,
829
+ config: opts.config,
830
+ });
831
+ if (opts.useCache !== false) {
832
+ cacheKey = `mesh_lod_${level}_${hash}`;
833
+ const cachedValue = tryGetFromCache(cacheKey);
834
+ const meta = tryGetMetaFromCache(cacheKey);
835
+ if (cachedValue && meta) {
836
+ logger.info(`[${NAME}] LOD ${level} loaded from cache for \"${mesh.getName()}\"`);
837
+ writeFileSync(outPath, cachedValue);
838
+ return {
839
+ level,
840
+ path: fileName,
841
+ success: true,
842
+ cacheKey,
843
+ content_hash: hash,
844
+ lodMeshInfo: meta.meshInfo,
845
+ density: meta.density,
846
+ densities: meta.densities
847
+ };
848
+ }
849
+ }
850
+ const newDoc = new Document();
851
+ writeNeedleAsset(newDoc, {
852
+ "source": {
853
+ filename: inputFileName,
854
+ }
855
+ });
856
+ const newScene = newDoc.createScene();
857
+ newDoc.createBuffer();
858
+ const newNode = newDoc.createNode();
859
+ newScene.addChild(newNode);
860
+ newNode.setName("LOD " + level + "_" + guid);
861
+ newNode.setTranslation([0, 0, 0]);
862
+ const newMesh = newDoc.createMesh();
863
+ newMesh.setName((mesh.getName() || "Unnamed") + " LOD " + level);
864
+ newNode.setMesh(newMesh);
865
+ const newMat = newDoc.createMaterial();
866
+ const weights = mesh.getWeights();
867
+ if (weights) {
868
+ newMesh.setWeights(weights);
869
+ }
870
+ const tasks = new Array();
871
+ // let didRunSimplification = false;
872
+ const clonedAttributes = new Map();
873
+ for (const prim of primitives) {
874
+ const newPrim = newDoc.createPrimitive();
875
+ newMesh.addPrimitive(newPrim);
876
+ newPrim.setName(prim.getName());
877
+ newPrim.setMode(prim.getMode());
878
+ newPrim.setMaterial(newMat);
879
+ const indices = prim.getIndices();
880
+ if (indices) {
881
+ const newIndices = newDoc.createAccessor();
882
+ newIndices.setArray(indices.getArray());
883
+ newIndices.setType(indices.getType());
884
+ newIndices.setSparse(indices.getSparse());
885
+ newIndices.setNormalized(indices.getNormalized());
886
+ newPrim.setIndices(newIndices);
887
+ }
888
+ const semantics = prim.listSemantics();
889
+ for (let i = 0; i < semantics.length; i++) {
890
+ const attr = prim.getAttribute(semantics[i]);
891
+ if (attr) {
892
+ if (clonedAttributes.has(attr)) {
893
+ const cloned = clonedAttributes.get(attr);
894
+ newPrim.setAttribute(semantics[i], cloned);
895
+ }
896
+ else {
897
+ const newAttr = newDoc.createAccessor();
898
+ newAttr.setArray(attr.getArray());
899
+ newAttr.setType(attr.getType());
900
+ newAttr.setSparse(attr.getSparse());
901
+ newAttr.setNormalized(attr.getNormalized());
902
+ newPrim.setAttribute(semantics[i], newAttr);
903
+ clonedAttributes.set(attr, newAttr);
904
+ }
905
+ }
906
+ else {
907
+ logger.warn(`[${NAME}] Missing attribute ${semantics[i]} in mesh LOD ${level}`);
908
+ }
909
+ }
910
+ const targets = prim.listTargets();
911
+ for (const target of targets) {
912
+ const newTarget = newDoc.createPrimitiveTarget(target.getName());
913
+ newPrim.addTarget(newTarget);
914
+ // const attributes = target.listAttributes();
915
+ const semantics = target.listSemantics();
916
+ for (const attr of semantics) {
917
+ const accessor = target.getAttribute(attr);
918
+ if (accessor) {
919
+ const newAccessor = newDoc.createAccessor();
920
+ newAccessor.setArray(accessor.getArray());
921
+ newAccessor.setType(accessor.getType());
922
+ newAccessor.setSparse(accessor.getSparse());
923
+ newAccessor.setNormalized(accessor.getNormalized());
924
+ newTarget.setAttribute(attr, newAccessor);
925
+ }
926
+ else {
927
+ console.error(`[${NAME}] Missing target attribute ${attr} in mesh LOD ${level}`);
928
+ }
929
+ }
930
+ }
931
+ ;
932
+ const extensions = prim.listExtensions();
933
+ for (const ext of extensions) {
934
+ const PropertyClass = ext.constructor;
935
+ const newProp = new PropertyClass(newDoc.getGraph());
936
+ try {
937
+ const copy = newProp.copy(ext);
938
+ const name = ext.extensionName;
939
+ newPrim.setExtension(name, copy);
940
+ }
941
+ catch (err) {
942
+ logger.error(`[${NAME}] Failed to copy extension ${ext.extensionName} for mesh LOD ${level} → ${err}`);
943
+ }
944
+ }
945
+ }
946
+ let primitiveIndex = 0;
947
+ if (lodLevel.error != undefined || lodLevel.ratio != undefined) {
948
+ if (lodLevel.error > 0 || lodLevel.ratio < 1) {
949
+ for (const newPrim of newMesh.listPrimitives()) {
950
+ // didRunSimplification = true;
951
+ const task = simplifyMesh(newDoc, newMesh, newPrim, primitiveIndex++, lodLevel);
952
+ tasks.push(task);
953
+ }
954
+ }
955
+ }
956
+ await Promise.all(tasks);
957
+ tasks.length = 0;
958
+ const newMeshInformation = getMeshInformation(newMesh);
959
+ if (newMeshInformation.totalVertexCount < 4) {
960
+ logger.error(`[${NAME}] mesh LOD ${level} is has less than 4 vertices → skipping`);
961
+ return {
962
+ level,
963
+ path: fileName,
964
+ success: false,
965
+ cacheKey: cacheKey,
966
+ content_hash: hash,
967
+ lodMeshInfo: null,
968
+ density: null,
969
+ densities: null
970
+ };
971
+ }
972
+ let density = 0;
973
+ const densities = new Array(newMesh.listPrimitives().length);
974
+ newMesh.listPrimitives().forEach((prim, index) => {
975
+ const d = calculateMeshDensity(prim);
976
+ density = Math.max(density, d);
977
+ densities[index] = d;
978
+ });
979
+ const progressiveExt = newDoc.createExtension(NEEDLE_progressive);
980
+ if (!progressiveExt || !progressiveExt.createMesh) {
981
+ logger.error("Failed to create progressive extension");
982
+ return;
983
+ }
984
+ newMesh.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, progressiveExt.createMesh({
985
+ guid: guid,
986
+ }));
987
+ // we need to compress the mesh with the same compression method as the original file
988
+ const model = determineMeshCompression(doc, fileName);
989
+ if (model) {
990
+ const newExt = newDoc.createExtension(NEEDLE_mesh_compression);
991
+ const newProp = newExt.create(model);
992
+ logger.info(`[${NAME}] Pass compression format to mesh LOD ${level} → ${JSON.stringify(model)}`);
993
+ newDoc.getRoot().setExtension(NEEDLE_mesh_compression.EXTENSION_NAME, newProp);
994
+ }
995
+ await newDoc.transform(sparse());
996
+ const newIo = new NodeIO();
997
+ registerExtensions(newIo);
998
+ logger.info(`[${NAME}] Write mesh LOD ${level} with to \"${fileName}\"`);
999
+ await newIo.write(outPath, newDoc);
1000
+ // const hasChanged = meshInfo.maxVertexCount == newMeshInformation.maxVertexCount && meshInfo.totalVertexCount == newMeshInformation.totalVertexCount;
1001
+ // add to cache
1002
+ if (cacheKey != undefined) {
1003
+ const bytes = readFileSync(outPath);
1004
+ const sizeInMB = (bytes.byteLength / 1024 / 1024).toFixed(2);
1005
+ logger.info(`[${NAME}] Add LOD ${level} to cache (${sizeInMB} MB) for \"${mesh.getName()}\" as ${cacheKey}`);
1006
+ addToCache(cacheKey, bytes);
1007
+ addMetaToCache(cacheKey, { meshInfo: newMeshInformation, density: density, densities: densities });
1008
+ }
1009
+ return {
1010
+ level,
1011
+ path: fileName,
1012
+ success: true,
1013
+ lodMeshInfo: newMeshInformation,
1014
+ cacheKey: cacheKey,
1015
+ content_hash: hash,
1016
+ density: density,
1017
+ densities: densities
1018
+ };
1019
+ }
1020
+ async function simplifyMesh(doc, mesh, prim, index, lodinfo) {
1021
+ const weldOptions = {
1022
+ ...WELD_DEFAULTS,
1023
+ overwrite: true,
1024
+ cleanup: false,
1025
+ tolerance: 0.00000000001,
1026
+ ...lodinfo,
1027
+ };
1028
+ const simplifyOptions = {
1029
+ ...SIMPLIFY_DEFAULTS,
1030
+ ...lodinfo,
1031
+ simplifier: MeshoptSimplifier,
1032
+ };
1033
+ return compressPrimitive(doc, mesh, prim, index, weldOptions, simplifyOptions);
1034
+ }
1035
+ export function isMeshLOD(filepath) {
1036
+ return filepath.includes("mesh_lod_");
1037
+ }
1038
+ export function isImageLOD(filepath) {
1039
+ return filepath.includes("image_");
1040
+ }
1041
+ export function isLOD(filepath) {
1042
+ return isMeshLOD(filepath) || isImageLOD(filepath);
1043
+ }