@needle-tools/gltf-build-pipeline 1.4.1-beta.5 → 1.4.1-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,9 @@ All notable changes to this package will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.4.1-beta.6] - 2023-03-15
8
+ - add: caching support for mesh LODs
9
+
7
10
  ## [1.4.1-beta.5] - 2023-03-15
8
11
  - add: auto LODs now calculate mesh density
9
12
  - change: generate 6 LOD level
@@ -1,5 +1,10 @@
1
1
  import { ExtensibleProperty } from "@gltf-transform/core";
2
+ /**
3
+ * Get the available space on the cache directory in bytes.
4
+ */
2
5
  export declare function clearCache(): void;
6
+ export declare function ensureCacheSize(maxSizeMB: number): void;
3
7
  export declare function addToCache(key: string, bytes: Uint8Array): void;
4
8
  export declare function tryGetFromCache(key: string): Uint8Array | null;
5
9
  export declare function getKey(property: ExtensibleProperty): string;
10
+ export declare function getHash(property: ExtensibleProperty | object): number;
@@ -1,16 +1,59 @@
1
- import { Texture, Primitive } from "@gltf-transform/core";
1
+ import { ExtensibleProperty, Texture, Primitive, Accessor, Mesh } from "@gltf-transform/core";
2
2
  import tmp from 'tmp';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
+ // import * as CHECKDISCSPACE from 'check-disk-space'
6
+ /**
7
+ * Get the available space on the cache directory in bytes.
8
+ */
9
+ // export async function getCacheAvailableSpaceOnDisc(): Promise<number> {
10
+ // try {
11
+ // const dir = getCacheDirectory();
12
+ // const res = await CHECKDISCSPACE.default(dir).catch(() => { return { free: 0 } });
13
+ // return res.free;
14
+ // }
15
+ // catch (e) {
16
+ // console.error(`Error checking disk space: ${e}`);
17
+ // }
18
+ // return 0;
19
+ // }
5
20
  export function clearCache() {
6
21
  const dir = getCacheDirectory();
7
22
  fs.rmSync(dir, { recursive: true });
8
23
  console.log(`Cleared cache at ${dir}`);
9
24
  }
25
+ export function ensureCacheSize(maxSizeMB) {
26
+ const dir = getCacheDirectory();
27
+ let totalSize = 0;
28
+ const files = fs.readdirSync(dir).map(file => {
29
+ const path = `${dir}/${file}`;
30
+ const stats = fs.statSync(path);
31
+ totalSize += stats.size;
32
+ return { path, size: stats.size };
33
+ });
34
+ const maxSizeInBytes = maxSizeMB * 1024 * 1024;
35
+ if (totalSize < maxSizeInBytes)
36
+ return;
37
+ files.sort((a, b) => {
38
+ const mtimeA = fs.statSync(a.path).mtimeMs;
39
+ const mtimeB = fs.statSync(b.path).mtimeMs;
40
+ return mtimeA - mtimeB;
41
+ });
42
+ const targetSize = maxSizeInBytes * 0.9;
43
+ // remove oldest files first
44
+ while (totalSize > targetSize) {
45
+ const file = files.shift();
46
+ if (!file)
47
+ break;
48
+ fs.rmSync(file.path);
49
+ totalSize -= file.size;
50
+ }
51
+ console.log(`Cache size reduced to ${(totalSize / (1024 * 1024)).toFixed(1)} MB`);
52
+ }
10
53
  export function addToCache(key, bytes) {
11
54
  const dir = getCacheDirectory();
12
55
  const path = `${dir}/${key}.bin`;
13
- console.log(`Added ${(bytes.byteLength / 1024).toFixed(1)} KB to cache at ${path}`);
56
+ console.log(`Added ${(bytes.byteLength / 1024).toFixed(1)} KB to cache at \"${path}\"`);
14
57
  fs.writeFileSync(path, bytes);
15
58
  }
16
59
  export function tryGetFromCache(key) {
@@ -23,37 +66,54 @@ export function tryGetFromCache(key) {
23
66
  return null;
24
67
  }
25
68
  export function getKey(property) {
26
- let hash = 0;
27
69
  let prefix = "prop";
28
- const extensions = property.listExtensions();
29
- const extras = property.getExtras();
30
- // const slots = listTextureSlots(doc, texture);
31
- // const channels = getTextureChannelMask(doc, texture);
32
- hash += hashObject({
33
- extensions,
34
- extras,
35
- });
36
70
  if (property instanceof Texture) {
37
71
  prefix = "image";
38
- const image = property.getImage();
39
- const bytes = image?.buffer;
40
- if (bytes)
41
- hash += hashBuffer(bytes, image.byteOffset, image.byteLength);
42
72
  }
43
- else if (property instanceof Primitive) {
44
- prefix = "prim";
45
- const indices = property.getIndices();
46
- const attributes = property.listAttributes();
47
- const targets = property.listTargets();
73
+ let hash = getHash(property);
74
+ const key = `${prefix}-${hash}`;
75
+ return key;
76
+ }
77
+ export function getHash(property) {
78
+ let hash = 0;
79
+ if (property instanceof ExtensibleProperty) {
80
+ const extensions = property.listExtensions();
81
+ const extras = property.getExtras();
82
+ // const slots = listTextureSlots(doc, texture);
83
+ // const channels = getTextureChannelMask(doc, texture);
48
84
  hash += hashObject({
49
- indices,
50
- attributes,
51
- targets
85
+ extensions,
86
+ extras,
52
87
  });
88
+ if (property instanceof Texture) {
89
+ const image = property.getImage();
90
+ const bytes = image?.buffer;
91
+ if (bytes)
92
+ hash += hashBuffer(bytes, image.byteOffset, image.byteLength);
93
+ }
94
+ else if (property instanceof Mesh) {
95
+ const weights = property.getWeights();
96
+ hash += hashObject(weights);
97
+ const primitives = property.listPrimitives();
98
+ hash += hashObject(primitives);
99
+ }
100
+ else if (property instanceof Primitive) {
101
+ const indices = property.getIndices();
102
+ const attributes = property.listAttributes();
103
+ const targets = property.listTargets();
104
+ const semantics = property.listSemantics();
105
+ hash += hashObject({
106
+ indices,
107
+ attributes,
108
+ targets,
109
+ semantics
110
+ });
111
+ }
53
112
  }
54
- let key = hash.toString();
55
- key = `${prefix}-${key}`;
56
- return key;
113
+ else {
114
+ hash += hashObject(property);
115
+ }
116
+ return hash;
57
117
  }
58
118
  function getCacheDirectory() {
59
119
  const tmpDir = path.join(tmp.tmpdir, "Needle", "build-pipeline-cache");
@@ -63,6 +123,10 @@ function getCacheDirectory() {
63
123
  return tmpDir;
64
124
  }
65
125
  function hashObject(obj) {
126
+ if (obj instanceof Accessor) {
127
+ const buffer = obj.getArray();
128
+ return hashBuffer(buffer);
129
+ }
66
130
  const str = JSON.stringify(obj);
67
131
  let hash = 0;
68
132
  for (let i = 0; i < str.length; i++) {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./utils/index.js";
2
2
  export * from "./transforms/index.js";
3
3
  export * from "./extensions/index.js";
4
+ export * from "./cache/index.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./utils/index.js";
2
2
  export * from "./transforms/index.js";
3
3
  export * from "./extensions/index.js";
4
+ export * from "./cache/index.js";
@@ -5,6 +5,7 @@ import { NEEDLE_mesh_compression } from "../extensions/index.js";
5
5
  const NAME = 'needle_mesh_transform';
6
6
  export const needle_mesh_transform = function (_options) {
7
7
  return async (document, _context) => {
8
+ // TODO: needs to be moved into NEEDLE_progressive
8
9
  await simplifierTransform(document, _context, _options);
9
10
  await applyMeshCompression(document, _context);
10
11
  };
@@ -32,4 +32,7 @@ export declare enum TextureResizeFilter {
32
32
  export declare const TEXTURE_RESIZE_DEFAULTS: TextureResizeOptions;
33
33
  export declare function resizeTextureToMax(texture: Texture, maxSize: number, logger: ILogger): Promise<void>;
34
34
  export declare function make_progressive_meshes(filePath: string, _options: any, opts?: ProgressiveOptions): Transform;
35
+ export declare function isMeshLOD(filepath: string): boolean;
36
+ export declare function isImageLOD(filepath: string): boolean;
37
+ export declare function isLOD(filepath: string): boolean;
35
38
  export {};
@@ -3,8 +3,8 @@ import { SIMPLIFY_DEFAULTS, WELD_DEFAULTS, listTextureSlots, sparse } from '@glt
3
3
  import { NEEDLE_PROGRESSIVE_EXTENSION_NAME, NEEDLE_progressive, generateGuid } from "../extensions/NEEDLE_progressive/index.js";
4
4
  import { createOpaqueExtension, NEEDLE_compression_texture, NEEDLE_mesh_compression, NEEDLE_progressive_mesh_settings, NEEDLE_progressive_texture_settings, registerExtensions as registerDefaultExtensions } from '../extensions/index.js';
5
5
  import path from 'path';
6
- import { existsSync, rmSync, statSync } from 'fs';
7
- import { addToCache, getKey, tryGetFromCache } from '../cache/index.js';
6
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
7
+ import { addToCache, getHash, getKey, tryGetFromCache } from '../cache/index.js';
8
8
  import sharp from "sharp";
9
9
  import { foreachGLTF } from '../utils/index.js';
10
10
  import { needle_asset } from './needle_asset.js';
@@ -357,10 +357,9 @@ export function make_progressive_meshes(filePath, _options, opts) {
357
357
  const lod = LODS[level];
358
358
  const guid = settings.guid;
359
359
  // TODO: check if the LOD level did actually change the mesh - otherwise we can just skip generating lower resolutions
360
- const lodTask = createMeshLod(doc, outputDirectory, level, mesh, guid, lod);
360
+ const lodTask = createMeshLod(doc, outputDirectory, level, mesh, guid, lod, opts);
361
361
  lodTask.then(res => {
362
- console.log(`${NAME}: ${mesh.getName()} Register LOD ${level}`);
363
- if (res.success) {
362
+ if (res?.success) {
364
363
  generatedLODs[level] = res.path;
365
364
  }
366
365
  });
@@ -428,7 +427,29 @@ export function make_progressive_meshes(filePath, _options, opts) {
428
427
  * @param guid a unique identifier for the mesh
429
428
  * @param error the error to use for simplification. Higher means more simplification
430
429
  */
431
- async function createMeshLod(doc, basePath, level, mesh, guid, lodLevel) {
430
+ async function createMeshLod(doc, basePath, level, mesh, guid, lodLevel, opts) {
431
+ const fileExtension = ".glb";
432
+ const fileName_withoutExtension = "mesh_lod_" + level + "_" + guid;
433
+ const fileName = fileName_withoutExtension + fileExtension;
434
+ const outPath = basePath + fileName;
435
+ const primitives = mesh.listPrimitives();
436
+ let cacheKey = undefined;
437
+ if (opts.useCache !== false) {
438
+ // generate hash for mesh
439
+ let hash = mesh["needle:mesh-hash"];
440
+ if (hash === undefined) {
441
+ hash = getHash(mesh);
442
+ mesh["needle:mesh-hash"] = hash;
443
+ }
444
+ hash += getHash(lodLevel);
445
+ cacheKey = `mesh_lod_${level}_${hash}`;
446
+ const cachedValue = tryGetFromCache(cacheKey);
447
+ if (cachedValue) {
448
+ console.log(`${NAME}: ${mesh.getName()} LOD ${level} loaded ${(cachedValue.length / 1024).toFixed()} KB from cache`);
449
+ writeFileSync(outPath, cachedValue);
450
+ return { path: fileName, success: true };
451
+ }
452
+ }
432
453
  const newDoc = new Document();
433
454
  const newScene = newDoc.createScene();
434
455
  newDoc.createBuffer();
@@ -439,11 +460,10 @@ async function createMeshLod(doc, basePath, level, mesh, guid, lodLevel) {
439
460
  const newMesh = newDoc.createMesh();
440
461
  newMesh.setName(mesh.getName() + " LOD " + level);
441
462
  newNode.setMesh(newMesh);
442
- const primitives = mesh.listPrimitives();
463
+ const newMat = newDoc.createMaterial();
443
464
  const tasks = new Array();
444
465
  // let didRunSimplification = false;
445
466
  for (const prim of primitives) {
446
- const newMat = newDoc.createMaterial();
447
467
  const newPrim = newDoc.createPrimitive();
448
468
  newMesh.addPrimitive(newPrim);
449
469
  newPrim.setName(prim.getName());
@@ -475,6 +495,8 @@ async function createMeshLod(doc, basePath, level, mesh, guid, lodLevel) {
475
495
  console.log(`${NAME}: Copy primitive ${name} extension`);
476
496
  newPrim.setExtension(name, copy);
477
497
  }
498
+ }
499
+ for (const newPrim of newMesh.listPrimitives()) {
478
500
  if (lodLevel.error != undefined || lodLevel.ratio != undefined) {
479
501
  if (lodLevel.error > 0 || lodLevel.ratio < 1) {
480
502
  // didRunSimplification = true;
@@ -484,6 +506,7 @@ async function createMeshLod(doc, basePath, level, mesh, guid, lodLevel) {
484
506
  }
485
507
  }
486
508
  await Promise.all(tasks);
509
+ tasks.length = 0;
487
510
  // if we run simplify but the mesh didnt change
488
511
  // if (didRunSimplification) {
489
512
  // let anyPrimitiveDidChange = false;
@@ -511,13 +534,14 @@ async function createMeshLod(doc, basePath, level, mesh, guid, lodLevel) {
511
534
  }
512
535
  const newIo = new NodeIO();
513
536
  registerExtensions(newIo);
514
- const ext = ".glb";
515
- const name = "mesh_lod_" + level + "_" + guid;
516
- const filename = name + ext;
517
- const outName = basePath + filename;
518
- console.log(`${NAME}: Write mesh LOD ${level} to \"${filename}\" with: ${JSON.stringify(lodLevel)}`);
519
- await newIo.write(outName, newDoc);
520
- return { path: filename, success: true };
537
+ console.log(`${NAME}: Write mesh LOD ${level} to \"${fileName}\" with: ${JSON.stringify(lodLevel)}`);
538
+ await newIo.write(outPath, newDoc);
539
+ // add to cache
540
+ if (cacheKey != undefined) {
541
+ const bytes = readFileSync(outPath);
542
+ addToCache(cacheKey, bytes);
543
+ }
544
+ return { path: fileName, success: true };
521
545
  }
522
546
  function calculateMeshDensity(prim) {
523
547
  const bounds = {
@@ -612,9 +636,12 @@ async function simplifyMesh(doc, mesh, prim, lodinfo) {
612
636
  // simplifyOptions.lockBorder = lodinfo.lockBorder;
613
637
  return compressPrimitive(doc, mesh, prim, weldOptions, simplifyOptions);
614
638
  }
615
- function isMeshLOD(filepath) {
639
+ export function isMeshLOD(filepath) {
616
640
  return filepath.includes("mesh_lod_");
617
641
  }
618
- function isImageLOD(filepath) {
642
+ export function isImageLOD(filepath) {
619
643
  return filepath.includes("image_");
620
644
  }
645
+ export function isLOD(filepath) {
646
+ return isMeshLOD(filepath) || isImageLOD(filepath);
647
+ }
package/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
 
2
-
3
2
  import { spawnSync } from "child_process";
3
+ import { getVersion } from "./dist/index.js";
4
+
4
5
  const workingDir = process.cwd();
5
6
 
6
7
  // Original invoked dir set by npm
@@ -28,6 +29,15 @@ function runWithArgs(cmd) {
28
29
  spawnSync(cmd + " " + commandline_args, defaultArgs);
29
30
  }
30
31
 
31
-
32
- runWithArgs("node make-progressive.mjs");
33
- runWithArgs("node pack-gltf.mjs");
32
+ try {
33
+ const startTime = Date.now();
34
+ runWithArgs("node make-progressive.mjs");
35
+ runWithArgs("node pack-gltf.mjs");
36
+ const durationInMinutes = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
37
+ console.log(`[Needle Build Pipeline] v${getVersion()} — Finished progressive transform and optimization in ${durationInMinutes} minutes.`);
38
+ // small delay to not close the process immediately and give external processes some time to read the output
39
+ await new Promise(resolve => setTimeout(resolve, 100));
40
+ }
41
+ catch (err) {
42
+ console.error("Failed running commandline args:", err.message);
43
+ }
package/pack-gltf.mjs CHANGED
@@ -1,20 +1,18 @@
1
- import glob from 'glob';
2
1
  import path from 'path';
3
2
  import { NodeIO } from '@gltf-transform/core';
4
3
  import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
5
- import { draco } from '@gltf-transform/functions';
6
4
  import draco3d from 'draco3dgltf';
7
5
  import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS } from "./dist/extensions/NEEDLE_opaque/index.js";
8
6
  import commandLineArgs from "command-line-args";
9
7
  import { NEEDLE_mesh_compression, NEEDLE_compression_texture } from './dist/extensions/index.js';
10
- import { existsSync, statSync } from 'fs';
8
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
11
9
  import { needle_mesh_transform } from "./dist/transforms/needle_mesh_transform.js";
12
10
  import { needle_texture_transform } from "./dist/transforms/needle_texture_transform.js";
13
11
  import { needle_animation_transform } from "./dist/transforms/needle_animation_transform.js";
14
12
  import { foreachGLTF } from './fileutils.mjs';
15
13
  import { needle_asset } from './dist/transforms/needle_asset.js';
16
14
  import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
17
- import { getVersion } from './dist/index.js';
15
+ import { getVersion, getHash, addToCache, tryGetFromCache, isLOD, ensureCacheSize as ensureMaxCacheSize, isMeshLOD } from './dist/index.js';
18
16
 
19
17
  console.log(`[Needle Build Pipeline] pack glTF — version ${getVersion()}`)
20
18
 
@@ -77,6 +75,9 @@ let totalFilesProcessed = 0;
77
75
  let totalFileSizeInMB = 0;
78
76
  let totalFileSizeInMBBefore = 0;
79
77
 
78
+ /** Compress a glTF file
79
+ * @param {string} file
80
+ */
80
81
  async function packFile(file) {
81
82
  if (!existsSync(file)) {
82
83
  console.error("WARN: can not pack, file does not exist: " + file);
@@ -86,6 +87,22 @@ async function packFile(file) {
86
87
  return;
87
88
  }
88
89
 
90
+ // check if it's in the cache.
91
+ // We only need to check mesh LOD files because image LODs are cache via our toktx transform
92
+ // We can not cache the main glTF files because the hash always changes for them
93
+ let cacheKey = undefined;
94
+ if (allowCaching && isMeshLOD(file)) {
95
+ const existing = readFileSync(file);
96
+ const hash = getHash(existing);
97
+ cacheKey = "packed-" + hash;
98
+ const cached = tryGetFromCache(cacheKey);
99
+ if (cached) {
100
+ console.log("→ Skip packing (loaded from cache): " + path.basename(file));
101
+ writeFileSync(file, cached);
102
+ return;
103
+ }
104
+ }
105
+
89
106
  if (packedFiles.has(file)) return;
90
107
  packedFiles.add(file);
91
108
  console.log("→ Begin packing: " + file);
@@ -123,6 +140,10 @@ async function packFile(file) {
123
140
  );
124
141
  if (debug) file += ".debug.glb";
125
142
  await io.write(file, document);
143
+
144
+ if (allowCaching && cacheKey != undefined && existsSync(file))
145
+ addToCache(cacheKey, readFileSync(file));
146
+
126
147
  const duration = Date.now() - startTime;
127
148
  const sizeInMB = statSync(file).size / 1024 / 1024;
128
149
  const filename = path.basename(file);
@@ -148,8 +169,17 @@ let startTime = Date.now();
148
169
  const inputArgs = args.slice(2);
149
170
  foreachGLTF(inputArgs, async (file) => {
150
171
  await packFile(file);
151
- }).then(() => {
172
+ }).then(async () => {
152
173
  const duration = Date.now() - startTime;
153
174
  const durationInMinutes = duration / 1000 / 60;
175
+ if (allowCaching) {
176
+ // if the user's cache directory has more than 25GB free space, we allow a cache of 2 GB - otherwise 1 GB
177
+ // const freeSpaceInMB = (await getCacheAvailableSpaceOnDisc().catch(() => 0)) / 1024 / 1024;
178
+ // const maxCacheSize = freeSpaceInMB > 25000 ? 2000 : 1000;
179
+ ensureMaxCacheSize(1000);
180
+ }
154
181
  console.log(`[Needle Build Pipeline] v${getVersion()} — Finished packing ${totalFilesProcessed} files in ${durationInMinutes.toFixed(1)} minutes; total size ${totalFileSizeInMBBefore.toFixed(1)} MB → ${totalFileSizeInMB.toFixed(1)} MB, total GPU memory: ${totalGPUMemoryInMB.toFixed(1)} MB`);
155
- })
182
+ }).catch(err => {
183
+ console.error("ERR: packing failed: " + err.message);
184
+ if (debug) throw err;
185
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "1.4.1-beta.5",
3
+ "version": "1.4.1-beta.7",
4
4
  "description": "Pipeline and tools for optimizing gltf files using gltf-transform and compression settings within glTF extensions",
5
5
  "main": "index.mjs",
6
6
  "type": "module",