@needle-tools/gltf-build-pipeline 2.2.1-alpha.2 → 2.2.1-alpha.3

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