@needle-tools/gltf-build-pipeline 2.6.0-alpha.1 โ†’ 2.7.0-alpha

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,13 @@ 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
+ ## [2.7.0-alpha] - 2024-01-13
8
+ - add: VRM mesh simplification support
9
+ - fix: issue where the output directory was not created if it didn't exist yet
10
+ - fix: try resolve deduplicated meshes
11
+ - change: update gltf-transform to 4.1.1
12
+ - change: modify mesh LOD settings for more vertices
13
+
7
14
  ## [2.6.0-alpha] - 2024-12-16
8
15
  - add: create placeholder textures for missing jpg, png, webp, gif, tif on disc
9
16
  - fix: compressing a gltf file to a different output directory does not falsely delete old external textures on disc in the input directory
@@ -1,4 +1,4 @@
1
- import { Extension, ExtensionProperty, PropertyType, TextureChannel } from '@gltf-transform/core';
1
+ import { Extension, ExtensionProperty, PropertyType, Node, TextureChannel } from '@gltf-transform/core';
2
2
  import { getTexture, tryGetInArray, writeExtension } from '../utils.js';
3
3
  import { isLightmap } from '../../utils/texture.js';
4
4
  const ALL_PROPERTY_TYPES = [];
@@ -369,7 +369,7 @@ export function createOpaqueExtension(name, types, opts) {
369
369
  writeExtension(result, EXTENSION_NAME, this.ext);
370
370
  if (debugLog) {
371
371
  // console.log("WRITE", EXTENSION_NAME, "to", this.gltfField, "at index", index);
372
- console.log(result);
372
+ // console.log("WRITE RESULT", result);
373
373
  }
374
374
  }
375
375
  }
@@ -646,6 +646,26 @@ class JsonPointerHandler {
646
646
  pointer?.register(obj, key, fullPath);
647
647
  }
648
648
  }
649
+ if (value.startsWith("/meshes/")) {
650
+ if (this.debug)
651
+ console.log("Read pointer", value);
652
+ const index = parseInt(value.substring("/meshes/".length));
653
+ const object = context.meshes[index];
654
+ if (object) {
655
+ let pointer = this.map.get(value);
656
+ if (!pointer) {
657
+ const definition = context.jsonDoc.json.meshes[index];
658
+ const res = new MeshIndexResolver(this.document, object, definition, prop);
659
+ res.debug = this.debug;
660
+ res.init(context);
661
+ pointer = new JsonPointer("/meshes/", res, value);
662
+ pointer.debug = this.debug;
663
+ this.map.set(value, pointer);
664
+ prop.setReference(value, object);
665
+ }
666
+ pointer?.register(obj, key, fullPath);
667
+ }
668
+ }
649
669
  }
650
670
  break;
651
671
  case 'number':
@@ -770,14 +790,15 @@ class JsonPointer {
770
790
  else {
771
791
  this.newPointer = index;
772
792
  }
773
- if (this.debug)
774
- console.log(`RESOLVED pointer ${this.oldPointer} to โ†’ ${this.newPointer}`);
775
793
  return true;
776
794
  }
777
795
  else {
778
796
  // If the pointer could not be resolved during write we need to log an error
779
- if (step === "write")
780
- console.log("ERR: ๐Ÿงจ FAILED to resolve pointer", this.pointerBase, ` used by ${this._debugInfo.join(", ")}`);
797
+ if (step === "write") {
798
+ console.log(`ERR: ๐Ÿงจ FAILED to resolve pointer \"${this.oldPointer}\" used by ${this._debugInfo.join(", ")}`);
799
+ if (this.debug)
800
+ debugger;
801
+ }
781
802
  }
782
803
  return false;
783
804
  }
@@ -790,7 +811,7 @@ class JsonPointer {
790
811
  const obj = this._objects[i];
791
812
  const prop = this._props[i];
792
813
  if (this.debug) {
793
- console.log(`< Resolved pointer ${this._debugInfo[i]}: ${this.newPointer}`);
814
+ console.log(`< Resolved pointer ${this._debugInfo[i]}: ${this.oldPointer} โ†’ ${this.newPointer}`);
794
815
  }
795
816
  obj[prop] = this.newPointer;
796
817
  }
@@ -802,10 +823,13 @@ class PointerResolver {
802
823
  inputObject;
803
824
  /* the original json definition */
804
825
  inputDefinition;
805
- constructor(doc, obj, def) {
826
+ /** The graph property this pointer belongs to */
827
+ property;
828
+ constructor(doc, obj, def, prop) {
806
829
  this.document = doc;
807
830
  this.inputObject = obj;
808
831
  this.inputDefinition = def;
832
+ this.property = prop;
809
833
  }
810
834
  debug = false;
811
835
  }
@@ -913,3 +937,35 @@ class NodeIndexResolver extends PointerResolver {
913
937
  return context.nodeIndexMap.get(this.inputObject);
914
938
  }
915
939
  }
940
+ class MeshIndexResolver extends PointerResolver {
941
+ init(context) {
942
+ }
943
+ resolve(context) {
944
+ let index = context.meshIndexMap.get(this.inputObject);
945
+ if (index >= 0) {
946
+ return index;
947
+ }
948
+ let obj = null;
949
+ if (this.inputObject.isDisposed()) {
950
+ // If the mesh was deduplicated we try to get the mesh from a parent node
951
+ // This is a HACK, we need to get the reference to the mesh this one was deduplicated with since the parent node potentially has many children
952
+ const parents = this.property?.listParents();
953
+ for (const parent of parents) {
954
+ if (parent instanceof Node) {
955
+ obj = parent.getMesh();
956
+ if (obj) {
957
+ console.log(`INFO: resolved mesh \"${this.inputObject.getName()}\" from parent node \"${parent.getName()}\"`);
958
+ break;
959
+ }
960
+ }
961
+ }
962
+ if (obj.isDisposed()) {
963
+ console.warn("WARN: mesh is disposed", this.inputObject.getName());
964
+ }
965
+ }
966
+ if (!obj) {
967
+ return undefined;
968
+ }
969
+ return context.meshIndexMap.get(obj);
970
+ }
971
+ }
@@ -81,10 +81,11 @@ export async function packGLTF(inputFile, outputFile, options) {
81
81
  },
82
82
  config,
83
83
  }),
84
- needle_mesh_transform({ file: inputFile, config }, { ratio: 0.75, error: 0.0005 }),
84
+ needle_mesh_transform({ file: inputFile, config }),
85
85
  needle_animation_transform({}),
86
86
  resample(),
87
87
  // Can not dedup "materials" because progressive textures will then assigned to the wrong material instance
88
+ // HACK: keepUniqueNames is enabled to fix https://linear.app/needle/issue/NE-6288
88
89
  dedup({ propertyTypes: [PropertyType.MESH, PropertyType.MATERIAL] }),
89
90
  // prune(),
90
91
  ];
@@ -102,7 +103,7 @@ export async function packGLTF(inputFile, outputFile, options) {
102
103
  transforms.push(join({ keepNamed: false }));
103
104
  }
104
105
  await document.transform(...transforms);
105
- logger.info(`โ‡„ Writing to ${outputFile}`);
106
+ logger.info(`โ† Writing to ${outputFile}`);
106
107
  await writeNodeIO(io, outputFile, document);
107
108
  if (useCache && cacheKey != undefined && existsSync(outputFile))
108
109
  addToCache(cacheKey, readFileSync(outputFile));
@@ -10,6 +10,6 @@ declare type MeshTransformContext = {
10
10
  * Which compression type for a model should be used. This should be respected when creating LOD meshes and the appropriate extension should be added to the LOD mesh so it receives the same mesh compression as the parent mesh
11
11
  */
12
12
  export declare function determineMeshCompression(document: Document, filePath: string): NEEDLE_mesh_compression_model_root;
13
- export declare const needle_mesh_transform: (context: MeshTransformContext, simplify: Omit<SimplifyOptions, "simplifier">) => Transform;
13
+ export declare const needle_mesh_transform: (context: MeshTransformContext) => Transform;
14
14
  export declare function compressPrimitive(document: Document, mesh: Mesh, prim: Primitive, index: number, weldOptions: Required<WeldOptions>, meshOptions: Required<SimplifyOptions>): Promise<Primitive>;
15
15
  export {};
@@ -3,6 +3,7 @@ import { meshopt, simplifyPrimitive, SIMPLIFY_DEFAULTS, WELD_DEFAULTS, draco } f
3
3
  import { MeshoptEncoder, MeshoptSimplifier } from "meshoptimizer";
4
4
  import { NEEDLE_mesh_compression, NEEDLE_mesh_compression_model_root } from "../extensions/index.js";
5
5
  import { weldPrimitive } from "../functions/weld.3.js";
6
+ import { calculateMeshDensity } from "../utils/mesh.js";
6
7
  const NAME = 'needle_mesh_transform';
7
8
  /**
8
9
  * Which compression type for a model should be used. This should be respected when creating LOD meshes and the appropriate extension should be added to the LOD mesh so it receives the same mesh compression as the parent mesh
@@ -18,7 +19,7 @@ export function determineMeshCompression(document, filePath) {
18
19
  return rootModel;
19
20
  }
20
21
  }
21
- if (filePath.endsWith(".vrm")) {
22
+ if (filePath?.toLowerCase().endsWith(".vrm")) {
22
23
  const hasBlendshapes = document.getRoot().listMeshes().some((mesh) => mesh.listPrimitives().some((prim) => prim.listTargets().length > 0));
23
24
  if (hasBlendshapes) {
24
25
  return new NEEDLE_mesh_compression_model_root("meshopt");
@@ -57,10 +58,10 @@ export function determineMeshCompression(document, filePath) {
57
58
  // }
58
59
  return new NEEDLE_mesh_compression_model_root("draco");
59
60
  }
60
- export const needle_mesh_transform = function (context, simplify) {
61
+ export const needle_mesh_transform = function (context) {
61
62
  return async (document, _context) => {
62
63
  // TODO: needs to be moved into NEEDLE_progressive
63
- await simplifierTransform(document, _context, simplify);
64
+ await simplifierTransform(document, _context, context);
64
65
  await applyMeshCompression(document, _context, context);
65
66
  };
66
67
  };
@@ -88,21 +89,28 @@ async function applyMeshCompression(document, transform_context, context) {
88
89
  document.getLogger().info(`[${NAME}]: โ†’ will apply draco mesh compression (default)`);
89
90
  return draco()(document, transform_context);
90
91
  }
91
- async function simplifierTransform(document, _context, _options) {
92
+ async function simplifierTransform(document, _context, meshContext) {
92
93
  const simplifier = MeshoptSimplifier;
93
94
  if (!simplifier) {
94
95
  console.log(`WARN: [${NAME}] simplifier dependency required โ€” install "meshoptimizer".`);
95
96
  return false;
96
97
  }
97
98
  const logger = document.getLogger();
98
- const options = { ...SIMPLIFY_DEFAULTS, ..._options };
99
- if (!options.simplifier)
100
- options.simplifier = simplifier;
99
+ const isVRM = meshContext.file?.toLowerCase().endsWith(".vrm");
100
+ const options = {
101
+ ...SIMPLIFY_DEFAULTS,
102
+ simplifier: simplifier,
103
+ ratio: 0.75,
104
+ error: 0.0005,
105
+ };
101
106
  const weldOptions = {
102
107
  ...WELD_DEFAULTS,
103
- tolerance: 0.00000000001,
104
108
  overwrite: true,
105
109
  };
110
+ // let meshInformation: MeshInformation | null = null;
111
+ // if (isVRM) {
112
+ // meshInformation = getMeshInformation(document);
113
+ // }
106
114
  for (const mesh of document.getRoot().listMeshes()) {
107
115
  let i = 0;
108
116
  for (const prim of mesh.listPrimitives()) {
@@ -119,9 +127,25 @@ async function simplifierTransform(document, _context, _options) {
119
127
  const ext = prim.getExtension(NEEDLE_mesh_compression.EXTENSION_NAME);
120
128
  const mesh_compression = ext?.model;
121
129
  const simplifyOpts = { ...options };
122
- if (!ext) {
130
+ if (isVRM) {
131
+ const density = calculateMeshDensity(prim);
132
+ if (density > 0) {
133
+ const targetDensity = 2_000;
134
+ const t = (1 / density) * targetDensity;
135
+ simplifyOpts.ratio = Math.min(Math.max(.1, t), .95);
136
+ simplifyOpts.error = 15 / density;
137
+ simplifyOpts.lockBorder = true;
138
+ }
139
+ else {
140
+ simplifyOpts.ratio = 0.8;
141
+ simplifyOpts.error = 0.001;
142
+ simplifyOpts.lockBorder = true;
143
+ }
144
+ logger.info(`${mesh.getName()}: Density: ${density.toFixed(2)}, Ratio: ${simplifyOpts.ratio.toFixed(2)}, Error: ${simplifyOpts.error}`);
145
+ }
146
+ if (!ext && !isVRM) {
123
147
  // If no extension exists on the primitive dont apply any simplification
124
- // console.log(`[${NAME}]: Skipping primitive simplification of mesh "${mesh.getName()}": No mesh compression extension found.`);
148
+ // AND if the file is not a VRM file
125
149
  continue;
126
150
  }
127
151
  if (mesh_compression) {
@@ -9,7 +9,7 @@ import { addMetaToCache, addToCache, getHash, getKey, tryGetFromCache, tryGetMet
9
9
  import sharp from "sharp";
10
10
  import { detectTextureCompressionMode, ensureIsDirectory, foreachGLTF, generateGuid, getOutputPath, isLightmap, setLightmapName, ioTryReadWithMissingResources, validateImageMimeType, writeNodeIO } from '../utils/index.js';
11
11
  import { needle_asset, writeNeedleAsset } from './needle_asset.js';
12
- import { MeshoptDecoder, MeshoptSimplifier } from 'meshoptimizer';
12
+ import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
13
13
  import { compressPrimitive, determineMeshCompression } from './needle_mesh_transform.js';
14
14
  import { calculateMeshDensity, getMeshInformation } from "../utils/mesh.js";
15
15
  export async function make_progressive(opts) {
@@ -58,6 +58,7 @@ async function onProcess(file, opts) {
58
58
  registerExtensions(io);
59
59
  io.registerDependencies({
60
60
  'meshopt.decoder': MeshoptDecoder,
61
+ 'meshopt.encoder': MeshoptEncoder,
61
62
  'draco3d.decoder': await draco3d.createDecoderModule(),
62
63
  'draco3d.encoder': await draco3d.createEncoderModule(),
63
64
  });
@@ -533,7 +534,7 @@ export function make_progressive_meshes(filePath, _options, opts) {
533
534
  // const t = l / MAX_LODS;
534
535
  const latest = LODS[LODS.length - 1];
535
536
  const ratio = latest.ratio * .5;
536
- let error = Math.min(.013 * (l + 1), 0.05);
537
+ let error = Math.min(.013 * (l + 1), 0.003);
537
538
  if (opts.config.usecase === "product") {
538
539
  error *= .5;
539
540
  }
@@ -544,6 +545,7 @@ export function make_progressive_meshes(filePath, _options, opts) {
544
545
  // toleranceNormal: 0.1 + t * 0.1,
545
546
  });
546
547
  }
548
+ logger.debug(`[${NAME}]: Generating mesh LODs:\n${JSON.stringify(LODS, undefined, 2)}`);
547
549
  /** Map of visited GUID -> extension */
548
550
  const visited = new Map();
549
551
  const root = doc.getRoot();
@@ -842,6 +844,8 @@ async function createMeshLod(inputFileName, doc, basePath, level, mesh, meshInfo
842
844
  const newAttr = newDoc.createAccessor();
843
845
  newAttr.setArray(attr.getArray());
844
846
  newAttr.setType(attr.getType());
847
+ newAttr.setSparse(attr.getSparse());
848
+ newAttr.setNormalized(attr.getNormalized());
845
849
  newPrim.setAttribute(semantics[i], newAttr);
846
850
  }
847
851
  else {
@@ -853,20 +857,29 @@ async function createMeshLod(inputFileName, doc, basePath, level, mesh, meshInfo
853
857
  const newIndices = newDoc.createAccessor();
854
858
  newIndices.setArray(indices.getArray());
855
859
  newIndices.setType(indices.getType());
860
+ newIndices.setSparse(indices.getSparse());
861
+ newIndices.setNormalized(indices.getNormalized());
856
862
  newPrim.setIndices(newIndices);
857
863
  }
858
864
  const targets = prim.listTargets();
859
865
  for (const target of targets) {
860
866
  const newTarget = newDoc.createPrimitiveTarget(target.getName());
861
867
  newPrim.addTarget(newTarget);
862
- for (const attr of target.listSemantics()) {
868
+ // const attributes = target.listAttributes();
869
+ const semantics = target.listSemantics();
870
+ for (const attr of semantics) {
863
871
  const accessor = target.getAttribute(attr);
864
872
  if (accessor) {
865
873
  const newAccessor = newDoc.createAccessor();
866
874
  newAccessor.setArray(accessor.getArray());
867
875
  newAccessor.setType(accessor.getType());
876
+ newAccessor.setSparse(accessor.getSparse());
877
+ newAccessor.setNormalized(accessor.getNormalized());
868
878
  newTarget.setAttribute(attr, newAccessor);
869
879
  }
880
+ else {
881
+ console.error(`[${NAME}] Missing target attribute ${attr} in mesh LOD ${level}`);
882
+ }
870
883
  }
871
884
  }
872
885
  ;
@@ -1,10 +1,10 @@
1
- import { Mesh, Primitive } from "@gltf-transform/core";
1
+ import { Document, Mesh, Primitive } from "@gltf-transform/core";
2
2
  export declare type MeshInformation = {
3
3
  readonly maxVertexCount: number;
4
4
  readonly totalVertexCount: number;
5
5
  readonly totalIndexCount: number;
6
6
  };
7
- export declare function getMeshInformation(mesh: Mesh): MeshInformation;
7
+ export declare function getMeshInformation(mesh: Mesh | Document): MeshInformation;
8
8
  /**
9
9
  * Calculate the density of a mesh.
10
10
  * @param prim - The primitive to calculate the density for.
@@ -1,18 +1,35 @@
1
+ import { Mesh } from "@gltf-transform/core";
1
2
  export function getMeshInformation(mesh) {
2
3
  let maxVertexCount = 0;
3
4
  let totalIndexCount = 0;
4
- const totalVertexCount = mesh.listPrimitives().reduce((acc, prim) => {
5
- const indices = prim.getIndices()?.getCount() || 0;
6
- totalIndexCount += indices;
7
- const vertices = prim.getAttribute('POSITION')?.getCount() || 0;
8
- maxVertexCount = Math.max(maxVertexCount, vertices);
9
- return acc + vertices;
10
- }, 0);
11
- return {
12
- maxVertexCount,
13
- totalVertexCount,
14
- totalIndexCount,
15
- };
5
+ let totalVertexCount = 0;
6
+ if (mesh instanceof Mesh) {
7
+ totalVertexCount = mesh.listPrimitives().reduce((acc, prim) => {
8
+ const indices = prim.getIndices()?.getCount() || 0;
9
+ totalIndexCount += indices;
10
+ const vertices = prim.getAttribute('POSITION')?.getCount() || 0;
11
+ maxVertexCount = Math.max(maxVertexCount, vertices);
12
+ return acc + vertices;
13
+ }, 0);
14
+ return {
15
+ maxVertexCount,
16
+ totalVertexCount,
17
+ totalIndexCount,
18
+ };
19
+ }
20
+ else {
21
+ mesh.getRoot().listMeshes().forEach(mesh => {
22
+ const info = getMeshInformation(mesh);
23
+ maxVertexCount = Math.max(maxVertexCount, info.maxVertexCount);
24
+ totalVertexCount += info.totalVertexCount;
25
+ totalIndexCount += info.totalIndexCount;
26
+ });
27
+ return {
28
+ maxVertexCount,
29
+ totalVertexCount,
30
+ totalIndexCount,
31
+ };
32
+ }
16
33
  }
17
34
  /**
18
35
  * Calculate the density of a mesh.
@@ -1,9 +1,15 @@
1
+ import { existsSync, mkdirSync } from "fs";
1
2
  import { writeFile } from "fs/promises";
3
+ import path from "path";
2
4
  import sharp from "sharp";
3
5
  /**
4
6
  * Handle writing glTf, GLB and VRM files
5
7
  */
6
8
  export async function writeNodeIO(nodeio, filename, document) {
9
+ const directoryPath = path.dirname(filename);
10
+ if (!existsSync(directoryPath)) {
11
+ mkdirSync(directoryPath);
12
+ }
7
13
  // see https://github.com/donmccurdy/glTF-Transform/issues/1390
8
14
  if (filename.endsWith(".vrm")) {
9
15
  const bin = await nodeio.writeBinary(document);
@@ -0,0 +1 @@
1
+ export declare const version = "2.7.0-alpha";
@@ -0,0 +1 @@
1
+ export const version = "2.7.0-alpha";
@@ -1,5 +1,4 @@
1
- import packageJson from "#package.json" assert { type: "json" };
1
+ import { version } from './version.gen.js';
2
2
  export function getVersion(_marklocal = true) {
3
- let res = packageJson.version;
4
- return res;
3
+ return version;
5
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.6.0-alpha.1",
3
+ "version": "2.7.0-alpha",
4
4
  "description": "Pipeline and tools for optimizing gltf files using gltf-transform and compression settings within glTF extensions",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -47,9 +47,9 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@donmccurdy/caporal": "~0.0.10",
50
- "@gltf-transform/core": "^4.0.8",
51
- "@gltf-transform/extensions": "^4.0.8",
52
- "@gltf-transform/functions": "^4.0.8",
50
+ "@gltf-transform/core": "^4.1.1",
51
+ "@gltf-transform/extensions": "^4.1.1",
52
+ "@gltf-transform/functions": "^4.1.1",
53
53
  "command-exists": "^1.2.9",
54
54
  "command-line-args": "^5.2.1",
55
55
  "draco3dgltf": "^1.5.3",