@needle-tools/gltf-build-pipeline 2.0.0-alpha → 2.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ 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.1.0-alpha] - 2023-06-03
8
+ - add: compression config support for high-level usecases that can be passed via commandline arguments or via config. Available options are currently `default`, `world` and `product`.
9
+ - add: texture LODs can now be generated by passing in the path to a compression config. `--config <path/to/config.json>`
10
+ - change: VRMs now use draco mesh compression by default
11
+
12
+ ### Config Examples
13
+ - `default` - use default compression. When not explictly configured textures will be compressed with ETC1S
14
+ - `product` - when not explictly configured textures will use UASTC for `metallicRoughnessTexture` and `normalTexture`
15
+ - `world` - generate texture LODs when enabled (and not explictly disabled by `textures: { lods: false }`)
16
+ ```json
17
+ {
18
+ "usecase": "default" // optional
19
+ "textures" : {
20
+ "lods": true
21
+ }
22
+ }
23
+ ```
24
+ The config can also be part of a `needle.config.json`:
25
+ ```json
26
+ {
27
+ ... regular needle.config
28
+ "gltf": {
29
+ "usecase": "default" // optional
30
+ "textures": {
31
+ "lods": true
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
7
37
  ## [2.0.0-alpha] - 2023-06-01
8
38
  - update gltf-transform dependency to 4.x
9
39
  - change: ensure ETC1S and UASTC textures are power of 2
@@ -0,0 +1,3 @@
1
+ {
2
+ "usecase": "test"
3
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.0.0-alpha",
3
+ "version": "2.1.0-alpha.1",
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",
@@ -48,6 +48,7 @@ export declare function getKeyWithHash(property: ExtensibleProperty): {
48
48
  /**
49
49
  * Get the hash for a property or an object.
50
50
  * @param property The property to get the hash for.
51
+ * @param level The current level of recursion.
51
52
  * @returns The hash as a number.
52
53
  */
53
54
  export declare function getHash(property: ExtensibleProperty | object, level?: number): number;
@@ -167,6 +167,7 @@ export function getKeyWithHash(property) {
167
167
  /**
168
168
  * Get the hash for a property or an object.
169
169
  * @param property The property to get the hash for.
170
+ * @param level The current level of recursion.
170
171
  * @returns The hash as a number.
171
172
  */
172
173
  export function getHash(property, level = 0) {
@@ -45,11 +45,12 @@ Compress a single glTF file. The input file is compressed and written to the out
45
45
  totalFileSizeInMB: 0,
46
46
  totalFileSizeInMBBefore: 0,
47
47
  };
48
- let config = getConfig(options.config?.toString());
48
+ const file = args.input.toString();
49
+ let config = getConfig(options.config?.toString(), file);
49
50
  if (options.usecase) {
50
51
  logger.info("Using usecase: " + options.usecase);
51
52
  config = { ...createConfig(options.usecase.toString()), ...config, };
52
- if (config.usecase != "unknown" && config.usecase != options.usecase.toString()) {
53
+ if (config.usecase != "default" && config.usecase != options.usecase.toString()) {
53
54
  logger.warn(`Conflicting usecase. Your specific config at \"${options.config}\" has a different usecase than the one provided by the CLI: \"${options.usecase}\". Using the usecase from the config file.`);
54
55
  }
55
56
  else {
@@ -57,7 +58,6 @@ Compress a single glTF file. The input file is compressed and written to the out
57
58
  }
58
59
  }
59
60
  const debug = options.debug === true;
60
- const file = args.input.toString();
61
61
  const isDirectory = statSync(file).isDirectory();
62
62
  const outDir = isDirectory ? file : dirname(file);
63
63
  let outputIsDirectory = false;
@@ -98,11 +98,11 @@ Compress a single glTF file. The input file is compressed and written to the out
98
98
  process.exit(ERROR_CODES.PACKING_FAILED);
99
99
  }
100
100
  });
101
- printStats(stats);
101
+ printStats(stats, logger, config);
102
102
  console.log("Stats: ", options);
103
103
  if (options.stats) {
104
104
  const statsFile = (isDirectory ? (path.join(file, getDirName(file))) : file) + ".needle.stats.json";
105
- writeFileStatsToFile(file_stats, statsFile);
105
+ writeFileStatsToFile(config, file_stats, statsFile);
106
106
  }
107
107
  })
108
108
  .command("transform", "Transform (progressive loading) and compress glTF, GLB or VRM files")
@@ -126,18 +126,18 @@ Each version will be compressed and written to the output directory.
126
126
  totalFileSizeInMB: 0,
127
127
  totalFileSizeInMBBefore: 0,
128
128
  };
129
- let config = getConfig(options.config?.toString());
129
+ const file = args.input.toString();
130
+ let config = getConfig(options.config?.toString(), file);
130
131
  if (options.usecase) {
131
132
  logger.info("Using usecase: " + options.usecase);
132
133
  config = { ...createConfig(options.usecase.toString()), ...config, };
133
- if (config.usecase != "unknown" && config.usecase != options.usecase.toString()) {
134
+ if (config.usecase != "default" && config.usecase != options.usecase.toString()) {
134
135
  logger.warn(`Conflicting usecase. Your specific config at \"${options.config}\" has a different usecase than the one provided by the CLI: \"${options.usecase}\". Using the usecase from the config file.`);
135
136
  }
136
137
  else {
137
138
  config.usecase = options.usecase.toString();
138
139
  }
139
140
  }
140
- let file = args.input.toString();
141
141
  const isDirectory = statSync(file).isDirectory();
142
142
  const outDir = isDirectory ? file : dirname(file);
143
143
  const file_stats = [];
@@ -175,10 +175,10 @@ Each version will be compressed and written to the output directory.
175
175
  process.exit(ERROR_CODES.PACKING_FAILED);
176
176
  }
177
177
  });
178
- printStats(stats);
178
+ printStats(stats, logger, config);
179
179
  if (options.stats) {
180
180
  const statsFile = (isDirectory ? (path.join(file, getDirName(file))) : file) + ".needle.stats.json";
181
- writeFileStatsToFile(file_stats, statsFile);
181
+ writeFileStatsToFile(config, file_stats, statsFile);
182
182
  }
183
183
  });
184
184
  program.run();
@@ -1,4 +1,4 @@
1
- declare type Usecase = "unknown" | "product" | "world";
1
+ declare type Usecase = "default" | "product" | "world";
2
2
  export declare const UsecaseOptions: string[];
3
3
  /**
4
4
  * The config object contains information about how a file should be processed.
@@ -6,6 +6,9 @@ export declare const UsecaseOptions: string[];
6
6
  export type Config = {
7
7
  /** High-level configuration */
8
8
  usecase: Usecase;
9
+ textures?: {
10
+ lods: boolean;
11
+ };
9
12
  };
10
13
  /**
11
14
  * Create a config object with the given usecase.
@@ -39,5 +42,6 @@ export declare function createConfig(usecase: string): {
39
42
  *
40
43
  *
41
44
  */
42
- export declare function getConfig(path?: string): Config;
45
+ export declare function getConfig(configPath?: string, fallbackSearchDirectory?: string): Config;
46
+ export declare function searchConfig(directory: string): Config | null;
43
47
  export {};
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync } from "fs";
2
- export const UsecaseOptions = ["product"];
1
+ import { existsSync, readFileSync, statSync } from "fs";
2
+ import path, { dirname } from "path";
3
+ export const UsecaseOptions = ["product", "world"];
3
4
  const defaultConfig = {
4
- usecase: "unknown",
5
+ usecase: "default",
5
6
  };
6
7
  /**
7
8
  * Create a config object with the given usecase.
@@ -40,49 +41,46 @@ export function createConfig(usecase) {
40
41
  *
41
42
  *
42
43
  */
43
- export function getConfig(path) {
44
- if (path != null) {
45
- if (!existsSync(path)) {
46
- throw new Error(`No config found at \"${path}\"`);
44
+ export function getConfig(configPath, fallbackSearchDirectory) {
45
+ if (configPath != null) {
46
+ if (!existsSync(configPath)) {
47
+ throw new Error(`No config found at \"${configPath}\"`);
48
+ }
49
+ const res = resolveConfig(configPath, readFileSync(configPath, "utf8"));
50
+ if (res) {
51
+ return res;
52
+ }
53
+ else {
54
+ throw new Error(`Failed parsing config at \"${configPath}\"`);
47
55
  }
48
- return resolveConfig(path, readFileSync(path, "utf8"));
49
56
  }
50
57
  if (_cachedConfig !== undefined) {
51
58
  return _cachedConfig;
52
59
  }
53
60
  // Resolve the config once and cache it
54
- const result = internalResolveConfig();
55
- if (result) {
56
- console.log("INFO: Using optimization config with usecase: " + result.usecase);
57
- _cachedConfig = result;
61
+ // Check if a needle.config.json exists in the directory from which the script is run (or any parent directory)
62
+ if (fallbackSearchDirectory) {
63
+ const result = searchConfig(fallbackSearchDirectory);
64
+ if (result) {
65
+ _cachedConfig = { ...defaultConfig, ...result };
66
+ console.log("INFO: Using config with usecase: " + _cachedConfig.usecase);
67
+ }
58
68
  }
59
- else {
60
- console.log("INFO: No optimization config found. Using default config.");
69
+ // If no config was found, use the default config
70
+ if (!_cachedConfig) {
71
+ console.log("INFO: No config found. Using default config.");
61
72
  _cachedConfig = defaultConfig;
62
73
  }
63
74
  return _cachedConfig;
64
75
  }
65
76
  let _cachedConfig = undefined;
66
- function internalResolveConfig() {
67
- // Check if a config path is passed as an argument
68
- const args = process.argv.slice(2);
69
- for (let i = 0; i < args.length; i++) {
70
- if (args[i] === "--config") {
71
- if (i + 1 < args.length) {
72
- const configPath = args[i + 1];
73
- if (existsSync(configPath)) {
74
- const content = readFileSync(configPath, "utf8");
75
- return resolveConfig(configPath, content);
76
- }
77
- throw new Error(`Config file not found at path configured by CLI --config argument: \"${configPath}\"`);
78
- }
79
- }
77
+ export function searchConfig(directory) {
78
+ if (statSync(directory).isFile()) {
79
+ directory = dirname(directory);
80
80
  }
81
- // Check if a needle.config.json exists in the directory from which the script is run (or any parent directory)
82
- const workingDir = process.cwd();
83
- let directory = workingDir.replaceAll("\\", "/");
81
+ directory = directory.replaceAll("\\", "/");
84
82
  while (directory.length > 0) {
85
- const configPath = `${directory}/needle.config.json`;
83
+ const configPath = path.join(directory, "needle.config.json");
86
84
  if (existsSync(configPath)) {
87
85
  const content = readFileSync(configPath, "utf8");
88
86
  const config = resolveConfig(configPath, content);
@@ -103,16 +101,16 @@ function resolveConfig(filename, content) {
103
101
  if (filename.endsWith("needle.config.json")) {
104
102
  try {
105
103
  const config = JSON.parse(content);
106
- if (config.optimization) {
107
- console.log("INFO: Found optimization config at \"" + filename + "\".");
104
+ if (config.gltf) {
105
+ console.log("INFO: Found \"gltf\" config at \"" + filename + "\".");
108
106
  // If the optimization config is a string, it's the usecase
109
- if (typeof config.optimization === "string") {
110
- return createConfig(config.optimization);
107
+ if (typeof config.gltf === "string") {
108
+ return createConfig(config.gltf);
111
109
  }
112
- return config.optimization;
110
+ return config.gltf;
113
111
  }
114
112
  else {
115
- console.log("INFO: No optimization config found in needle.config.json. Using default config.");
113
+ console.log("INFO: No \"gltf\" config found in needle.config.json. Using default config.");
116
114
  return null;
117
115
  }
118
116
  }
@@ -624,6 +624,27 @@ class JsonPointerHandler {
624
624
  pointer?.register(obj, key, fullPath);
625
625
  }
626
626
  }
627
+ if (value.startsWith("/animations/")) {
628
+ if (this.debug)
629
+ console.log("Read pointer", value);
630
+ const index = parseInt(value.substring("/animations/".length));
631
+ const object = context.animations[index];
632
+ if (object) {
633
+ let pointer = this.map.get(value);
634
+ if (!pointer) {
635
+ // TODO: resolve animations + we need to set the reference properly so we can use prune. See https://linear.app/needle/issue/NE-4982
636
+ // const definition = context.jsonDoc.json.animations![index];
637
+ // const res = new AnimationIndexResolver(this.document, object, definition);
638
+ // res.debug = this.debug;
639
+ // res.init(context);
640
+ // pointer = new JsonPointer("/animations/", res, value);
641
+ // pointer.debug = this.debug;
642
+ // this.map.set(value, pointer);
643
+ prop.setReference(value, object);
644
+ }
645
+ pointer?.register(obj, key, fullPath);
646
+ }
647
+ }
627
648
  }
628
649
  break;
629
650
  case 'number':
@@ -682,7 +703,7 @@ class JsonPointerHandler {
682
703
  pointer.debug = this.debug;
683
704
  pointer.register(obj, key, fullPath);
684
705
  this.map.set(dictKey, pointer);
685
- prop.setReference(dictKey, object);
706
+ prop.setReference(dictKey, object, { channels: TextureChannel.R | TextureChannel.G | TextureChannel.B | TextureChannel.A });
686
707
  }
687
708
  }
688
709
  break;
@@ -12,8 +12,12 @@ type LodInfo = {
12
12
  /** Hash of the LOD file. It has been generated using the file content and settings */
13
13
  hash: string;
14
14
  };
15
+ export type ProgressiveTextureLodEntry = LodInfo & {
16
+ width: number;
17
+ height: number;
18
+ };
15
19
  export type ProgressiveTextureModel = ProgressiveLoadingInfo & {
16
- lods?: Array<LodInfo & {}>;
20
+ lods?: Array<ProgressiveTextureLodEntry>;
17
21
  };
18
22
  export type ProgressiveMeshModel = ProgressiveLoadingInfo & {
19
23
  /** LOD0 vertex density. TODO: remove this since we now have a LODs array. This is just for supporting older engine versions */
@@ -9,7 +9,6 @@ import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS, NEEDLE_compression_texture, NEEDLE
9
9
  import { isLOD, isMeshLOD, needle_animation_transform, needle_asset, needle_mesh_transform, needle_texture_transform, } from '../transforms/index.js';
10
10
  import { createNewFilestats, writeNodeIO } from "../utils/index.js";
11
11
  import { addToCache, getHash, tryGetFromCache, } from "../cache/index.js";
12
- import { getConfig } from '../config/index.js';
13
12
  /** Compress a glTF file
14
13
  */
15
14
  export async function packGLTF(inputFile, outputFile, options) {
@@ -42,7 +41,7 @@ export async function packGLTF(inputFile, outputFile, options) {
42
41
  packedFiles.add(inputFile);
43
42
  }
44
43
  try {
45
- const config = options.config || getConfig();
44
+ const config = options.config;
46
45
  const prevSizeInMB = statSync(inputFile).size / 1024 / 1024;
47
46
  logger.info(`→ Compressing ${prevSizeInMB.toFixed(1)} MB: ${inputFile}`);
48
47
  const startTime = Date.now();
@@ -85,7 +84,10 @@ export async function packGLTF(inputFile, outputFile, options) {
85
84
  const isProgressiveAsset = isLOD(inputFile);
86
85
  // TODO: progressive mesh LODs should not be pruned right now because it will remove e.g. UVs from meshes (and probably blend shapes too) (so we just not do it for any progressive asset)
87
86
  if (!isProgressiveAsset) {
88
- transforms.push(prune());
87
+ transforms.push(prune({
88
+ // Pruning animations is not supported yet (e.g. if the animation is only referenced by a component)
89
+ propertyTypes: ['MESH', 'MATERIAL', 'TEXTURE']
90
+ }));
89
91
  }
90
92
  await document.transform(...transforms);
91
93
  if (!outputFile)
@@ -16,10 +16,10 @@ async function applyMeshCompression(document, transform_context, context) {
16
16
  const rootModel = rootExtension.model;
17
17
  if (rootModel.compression) {
18
18
  if (rootModel.compression === "none") {
19
- console.log(`[${NAME}]: NONE → will not apply mesh compression`);
19
+ document.getLogger().info(`[${NAME}]: NONE → will not apply mesh compression`);
20
20
  return;
21
21
  }
22
- console.log(`[${NAME}]: ${rootModel.compression.toUpperCase()} → will apply ${rootModel.compression} mesh compression`);
22
+ document.getLogger().info(`[${NAME}]: ${rootModel.compression.toUpperCase()} → will apply ${rootModel.compression} mesh compression`);
23
23
  if (rootModel.compression === "draco") {
24
24
  const transform = draco();
25
25
  return transform(document, transform_context);
@@ -31,13 +31,13 @@ async function applyMeshCompression(document, transform_context, context) {
31
31
  }
32
32
  }
33
33
  // If nothing is configured apply meshopt compression as long as we're running gltf-transform 3.x
34
- if (context.file.endsWith(".vrm")) {
35
- console.log(`[${NAME}]: VRM → will apply meshopt mesh compression`);
36
- const transform = meshopt({ encoder: MeshoptEncoder });
37
- return transform(document, transform_context);
38
- }
34
+ // if (context.file.endsWith(".vrm")) {
35
+ // document.getLogger().info(`[${NAME}]: VRM → will apply meshopt mesh compression`);
36
+ // const transform = meshopt({ encoder: MeshoptEncoder });
37
+ // return transform(document, transform_context);
38
+ // }
39
39
  // by default we still want to run draco if the root doesnt have a compression model configured
40
- console.log(`[${NAME}]: No compression model configured → will apply draco mesh compression`);
40
+ console.log(`[${NAME}]: → will apply draco mesh compression (default)`);
41
41
  return draco()(document, transform_context);
42
42
  }
43
43
  async function simplifierTransform(document, _context, _options) {
@@ -1,11 +1,11 @@
1
- import { BufferUtils, Document, NodeIO, PropertyType } from '@gltf-transform/core';
2
- import { SIMPLIFY_DEFAULTS, WELD_DEFAULTS, listTextureSlots, sparse } from '@gltf-transform/functions';
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
3
  import draco3d from 'draco3dgltf';
4
4
  import { NEEDLE_PROGRESSIVE_EXTENSION_NAME, NEEDLE_progressive } from "../extensions/NEEDLE_progressive/index.js";
5
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
6
  import path, { basename, resolve } from 'path';
7
7
  import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
8
- import { addMetaToCache, addToCache, getHash, getKeyWithHash, tryGetFromCache, tryGetMetaFromCache } from '../cache/index.js';
8
+ import { addMetaToCache, addToCache, getHash, getKey, tryGetFromCache, tryGetMetaFromCache } from '../cache/index.js';
9
9
  import sharp from "sharp";
10
10
  import { detectTextureCompressionMode, foreachGLTF, generateGuid, writeNodeIO } from '../utils/index.js';
11
11
  import { needle_asset, writeNeedleAsset } from './needle_asset.js';
@@ -55,18 +55,13 @@ async function onProcess(file, opts) {
55
55
  });
56
56
  let document = await io.read(file);
57
57
  if (document) {
58
- if (opts.debug === true) {
59
- if (file.includes(".debug.")) {
60
- console.log("Skip debug file: " + file);
61
- return;
62
- }
63
- }
58
+ const logger = document.getLogger();
59
+ //@ts-ignore
60
+ logger.verbosity = opts.debug ? Verbosity.DEBUG : Verbosity.WARN;
64
61
  let smallesTextureSize = 128;
65
62
  if (opts.config.usecase === "product") {
66
63
  smallesTextureSize = 512;
67
64
  }
68
- //@ts-ignore
69
- document.getLogger().verbosity = 0;
70
65
  const transforms = [
71
66
  needle_asset(),
72
67
  make_progressive_textures(file, { size: [smallesTextureSize, smallesTextureSize] }, opts),
@@ -77,8 +72,8 @@ async function onProcess(file, opts) {
77
72
  transforms.push(make_progressive_meshes(file, {}, opts));
78
73
  }
79
74
  else
80
- console.warn("WARN: Progressive meshes are not supported for VRM files yet");
81
- console.log("→ Transforming: " + file);
75
+ logger.warn("WARN: Progressive meshes are not supported for VRM files yet");
76
+ logger.info("→ Transforming: " + file);
82
77
  await document.transform(...transforms);
83
78
  let filename = file;
84
79
  if (opts.debug === true) {
@@ -91,7 +86,7 @@ async function onProcess(file, opts) {
91
86
  }
92
87
  await writeNodeIO(io, filename, document);
93
88
  opts.results.push(filename);
94
- console.log("← Transforming done: " + filename);
89
+ logger.info("← Transforming done: " + filename);
95
90
  }
96
91
  }
97
92
  catch (err) {
@@ -108,7 +103,6 @@ export function createTransform(name, fn) {
108
103
  }
109
104
  const NAME = 'NEEDLE_progressive';
110
105
  export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DEFAULTS, opts) {
111
- console.log("Make progressive: " + filePath);
112
106
  const startTime = Date.now();
113
107
  const filename = basename(filePath);
114
108
  const options = { ...TEXTURE_RESIZE_DEFAULTS, ..._options };
@@ -117,6 +111,10 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
117
111
  const textureLodBasePath = directory + "/";
118
112
  return createTransform(NAME, async (doc) => {
119
113
  const logger = doc.getLogger();
114
+ if (isLOD(filePath)) {
115
+ logger.info("Progressive Image Transform: skip lod at " + filePath);
116
+ return Promise.resolve();
117
+ }
120
118
  let index = 0;
121
119
  for (const texture of doc.getRoot().listTextures()) {
122
120
  const textureIndex = index++;
@@ -159,8 +157,6 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
159
157
  logger.warn(`[${NAME}] Skipping texture[${textureIndex}], settings are invalid (maxSize: ${settings.maxSize})`);
160
158
  continue;
161
159
  }
162
- options.size[0] = settings.maxSize;
163
- options.size[1] = settings.maxSize;
164
160
  const existingExtension = texture.getExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME);
165
161
  if (existingExtension !== undefined && existingExtension !== null) {
166
162
  logger.debug(`[${NAME}] Skipping, texture is already progressive ${textureIndex}`);
@@ -172,25 +168,62 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
172
168
  continue;
173
169
  }
174
170
  const textureGuid = settings.guid?.length ? settings.guid : generateGuid(filename + "_texture_" + index.toString());
175
- const outPath = createTextureLod(doc, filePath, textureLodBasePath, 0, texture, slots, textureGuid, textureIndex);
176
- if (!outPath) {
177
- continue;
171
+ /** This is the size of the texture that is embedded in the glTF. No LOD should be smaller than this */
172
+ const maxSize = settings.maxSize;
173
+ /** This is the current texture's size */
174
+ const currentSize = Math.min(...texture.getSize());
175
+ const LOD_LEVEL = new Array();
176
+ LOD_LEVEL.push({ size: currentSize });
177
+ let generateLods = opts.config?.usecase === "world";
178
+ // If lods are enabled in the config
179
+ generateLods ||= opts.config?.textures?.lods === true;
180
+ // Ensure that texture lods are not disabled by { textures: { lods: false } }
181
+ generateLods &&= opts.config?.textures?.lods !== false;
182
+ if (generateLods) {
183
+ const LEVEL = 6;
184
+ let lastSize = currentSize;
185
+ for (let i = 1; i < LEVEL; i++) {
186
+ const size = lastSize * .5;
187
+ if (size <= maxSize)
188
+ break;
189
+ lastSize = size;
190
+ LOD_LEVEL.push({ size: size });
191
+ }
192
+ }
193
+ const results = new Array();
194
+ for (let i = 0; i < LOD_LEVEL.length; i++) {
195
+ const level = LOD_LEVEL[i];
196
+ const lodResult = await createTextureLod({
197
+ doc,
198
+ file: filePath,
199
+ basePath: textureLodBasePath,
200
+ level: i,
201
+ texture,
202
+ slots,
203
+ guid: textureGuid,
204
+ textureIndex,
205
+ maxSize: level.size
206
+ }, opts);
207
+ if (!lodResult) {
208
+ continue;
209
+ }
210
+ const { 0: width, 1: height } = lodResult.texture.getSize();
211
+ const outpath = lodResult?.outpath;
212
+ const relName = path.relative(directory, outpath);
213
+ results.push({
214
+ path: relName,
215
+ hash: lodResult.hash,
216
+ width,
217
+ height,
218
+ });
219
+ opts.results.push(outpath);
178
220
  }
179
- opts.results.push(outPath);
180
- const { key, hash } = getKeyWithHash(texture);
181
- const relName = path.relative(directory, outPath);
221
+ const key = getKey(texture);
182
222
  // TODO: this path should be added to the NEEDLE dependencies extension
183
223
  const model = {
184
224
  guid: textureGuid,
185
- lods: [
186
- {
187
- hash: hash.toString(),
188
- path: relName,
189
- }
190
- ],
225
+ lods: results,
191
226
  };
192
- // for backwards compatibility - this should be removed in the future
193
- model["uri"] = relName;
194
227
  texture.setExtension(NEEDLE_PROGRESSIVE_EXTENSION_NAME, progressiveExt.createTexture(model));
195
228
  if (opts.useCache) {
196
229
  const cached = tryGetFromCache(key);
@@ -200,6 +233,9 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
200
233
  continue;
201
234
  }
202
235
  }
236
+ options.size[0] = settings.maxSize;
237
+ options.size[1] = settings.maxSize;
238
+ logger.debug(`[${NAME}] Resizing embedded texture[${index}] to ${options.size[0]}x${options.size[1]}`);
203
239
  await resizeTexture(options, texture, logger, uri || name, slots);
204
240
  if (opts?.useCache === false) {
205
241
  }
@@ -209,14 +245,10 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
209
245
  logger.debug(`Progressive transform finished in ${((Date.now() - startTime) / 1000).toFixed(1)} sec.`);
210
246
  });
211
247
  }
212
- function createTextureLod(doc, file, basePath, level, texture, slots, guid, textureIndex) {
213
- // const imageBytes = texture.getImage();
214
- // if (imageBytes) {
215
- // // write bytes
216
- // const outPath = basePath + guid + "_" + level + ".png";
217
- // writeFileSync(outPath, imageBytes);
218
- // return outPath;
219
- // }
248
+ async function createTextureLod(args, opts) {
249
+ const { doc, file, basePath, level, texture, guid, textureIndex } = args;
250
+ let slots = args.slots;
251
+ const logger = doc.getLogger();
220
252
  const newDoc = new Document();
221
253
  writeNeedleAsset(newDoc);
222
254
  const scene = newDoc.createScene();
@@ -227,14 +259,25 @@ function createTextureLod(doc, file, basePath, level, texture, slots, guid, text
227
259
  newTexture.setImage(img);
228
260
  newTexture.setURI(texture.getURI());
229
261
  newTexture.setMimeType(texture.getMimeType());
262
+ if (args.maxSize && args.maxSize >= 16) {
263
+ const currentSize = newTexture.getSize();
264
+ if (currentSize[0] > args.maxSize && currentSize[1] > args.maxSize) {
265
+ await resizeTexture({ size: [args.maxSize, args.maxSize] }, newTexture, logger, texture.getURI(), slots);
266
+ }
267
+ }
230
268
  if (!slots?.length) {
231
- console.warn(`WARN: No material slots found for texture at index ${textureIndex} (name: \"${texture.getName()}\") → will assume slot \"baseColorTexture\"`);
269
+ logger.warn(`WARN: No material slots found for texture at index ${textureIndex} (name: \"${texture.getName()}\") → will assume slot \"baseColorTexture\"`);
232
270
  slots = ["baseColorTexture"];
233
271
  }
234
272
  let slot_index = 0;
235
273
  // create material adding the texture to the same slot
236
274
  for (const slot of slots) {
237
- const newMaterial = newDoc.createMaterial();
275
+ const newMaterial = newDoc.createMaterial(newTexture.getName());
276
+ const channels = listTextureChannels(texture);
277
+ // Ensure that we use the alpha channel if it is present
278
+ if (channels.includes(TextureChannel.A)) {
279
+ newMaterial.setAlphaMode("BLEND");
280
+ }
238
281
  switch (slot) {
239
282
  case "baseColorTexture":
240
283
  newMaterial.setBaseColorTexture(newTexture);
@@ -252,7 +295,14 @@ function createTextureLod(doc, file, basePath, level, texture, slots, guid, text
252
295
  newMaterial.setEmissiveTexture(newTexture);
253
296
  break;
254
297
  default:
255
- console.warn("WARN: Unknown texture slot: " + slot + " for texture " + textureIndex + " → can not load progressively");
298
+ if (slot.startsWith("/textures/")) {
299
+ // Ensure the texture is using the alpha channel since we don't know what slots the original texture was used for
300
+ // see https://linear.app/needle/issue/NE-4985
301
+ newMaterial.setAlphaMode("BLEND");
302
+ newMaterial.setBaseColorTexture(newTexture);
303
+ continue;
304
+ }
305
+ logger.warn(`WARN: Unknown texture slot: ${slot} for texture ${textureIndex} → can not load progressively`);
256
306
  return null;
257
307
  }
258
308
  // Make sure the material is used
@@ -260,16 +310,23 @@ function createTextureLod(doc, file, basePath, level, texture, slots, guid, text
260
310
  }
261
311
  const compressionSettings = texture.getExtension(NEEDLE_compression_texture.EXTENSION_NAME);
262
312
  if (compressionSettings !== undefined && compressionSettings !== null && compressionSettings.getExtensionDefinition) {
263
- console.log("> Pass compression extension to new texture");
313
+ logger.info("> Pass compression extension to new texture");
264
314
  const newExt = newDoc.createExtension(NEEDLE_compression_texture);
265
315
  const def = compressionSettings.getExtensionDefinition();
266
- def.mode = detectTextureCompressionMode(slots);
316
+ switch (opts.config.usecase) {
317
+ // For world scenarios we use ETC1S
318
+ case "world":
319
+ def.mode = "ETC1S";
320
+ break;
321
+ case "product":
322
+ def.mode = detectTextureCompressionMode(slots);
323
+ def.quality = 100;
324
+ break;
325
+ default:
326
+ def.mode = detectTextureCompressionMode(slots);
327
+ break;
328
+ }
267
329
  def.slot = slots[0];
268
- // for VRMS we just use webp
269
- // if (file?.toLocaleLowerCase().endsWith(".vrm")) {
270
- // def.mode = "webp";
271
- // def.quality = 70;
272
- // }
273
330
  const newProp = newExt.create(def);
274
331
  newTexture.setExtension(NEEDLE_compression_texture.EXTENSION_NAME, newProp);
275
332
  }
@@ -284,9 +341,13 @@ function createTextureLod(doc, file, basePath, level, texture, slots, guid, text
284
341
  const imageName = "image_" + level + "_" + guid;
285
342
  const filename = imageName + ext;
286
343
  const outName = basePath + filename;
287
- console.log(`INFO: Write texture LOD ${level} to \"${filename}\"`);
344
+ logger.info(`Write texture LOD ${level} to \"${filename}\"`);
288
345
  newIo.write(outName, newDoc);
289
- return outName;
346
+ return {
347
+ outpath: outName,
348
+ texture: newTexture,
349
+ hash: getHash(newTexture).toString(),
350
+ };
290
351
  }
291
352
  function createMeshForMaterial(doc, mat, i) {
292
353
  const buffer = doc.getRoot().listBuffers()[0];
@@ -369,6 +430,7 @@ async function resizeTexture(options, texture, logger, uri, slots) {
369
430
  dstWidth = Math.floor(dstWidth * (maxHeight / dstHeight));
370
431
  dstHeight = maxHeight;
371
432
  }
433
+ logger.info("> Resize texture to " + dstWidth + "x" + dstHeight + " (from " + srcWidth + "x" + srcHeight + ")");
372
434
  // https://sharp.pixelplumbing.com/api-resize
373
435
  const buffer = await sharp(texture.getImage())
374
436
  .resize(dstWidth, dstHeight, {
@@ -378,26 +440,30 @@ async function resizeTexture(options, texture, logger, uri, slots) {
378
440
  })
379
441
  .toBuffer();
380
442
  texture.setImage(BufferUtils.toView(buffer));
443
+ return {
444
+ width: dstWidth,
445
+ height: dstHeight,
446
+ };
381
447
  }
382
448
  export function make_progressive_meshes(filePath, _options, opts) {
383
449
  const directory = path.dirname(filePath);
384
450
  // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
385
451
  const outputDirectory = directory + "/";
386
452
  return createTransform("PROGRESSIVE_MESHES", async (doc, _context) => {
453
+ const logger = doc.getLogger();
387
454
  // this is to ensure we're not creating progressive meshes for engine versions that don't support loading them yet
388
455
  // the generator should include the version or the app that produced the GLB too (needle engine repo: 2c00d0e304e205cf9d4ac0499faabb92a706a5c4)
389
456
  if (currentGenerator == "Needle Engine") {
390
- console.log("WARN: Skip creating mesh LODs - Unsupported generator found... Please update your editor integration");
457
+ logger.warn("WARN: Skip creating mesh LODs - Unsupported generator found... Please update your editor integration");
391
458
  return;
392
459
  }
393
- if (isMeshLOD(filePath)) {
394
- console.log("Progressive Mesh Transform: skip lod at " + filePath);
460
+ if (isLOD(filePath)) {
461
+ logger.info("Progressive Mesh Transform: skip lod at " + filePath);
395
462
  return Promise.resolve();
396
463
  }
397
464
  await MeshoptSimplifier.ready;
398
- const logger = doc.getLogger();
399
465
  const filename = path.basename(filePath);
400
- console.log(`[${NAME}]: Begin generating progressive meshes for \"${filename}\"`);
466
+ logger.info(`[${NAME}]: Begin generating progressive meshes for \"${filename}\"`);
401
467
  let index = 0;
402
468
  // the lowest LOD (last entry) will be saved in the original file
403
469
  let MAX_LODS = 6;
@@ -433,7 +499,7 @@ export function make_progressive_meshes(filePath, _options, opts) {
433
499
  for (const mesh of meshes) {
434
500
  await processMesh(mesh, { filename });
435
501
  }
436
- console.log(`[${NAME}]: Finished generating progressive meshes for \"${filename}\"`);
502
+ logger.info(`[${NAME}]: Finished generating progressive meshes for \"${filename}\"`);
437
503
  async function processMesh(mesh, args) {
438
504
  /** The size and vertex count of the original mesh */
439
505
  const originalMeshInformation = getMeshInformation(mesh);
@@ -474,7 +540,7 @@ export function make_progressive_meshes(filePath, _options, opts) {
474
540
  /** async simplification tasks... */
475
541
  const tasks = new Array();
476
542
  if (needsToGenerateLODs) {
477
- console.log(`[${NAME}] Begin generating mesh LODs for \"${mesh.getName()}\"`);
543
+ logger.info(`[${NAME}] Begin generating mesh LODs for \"${mesh.getName()}\"`);
478
544
  // produce LODS, skip the last level because it will be saved in the original file
479
545
  for (let level = 0; level < LODS.length - 1; level++) {
480
546
  const lod = LODS[level];
@@ -510,11 +576,11 @@ export function make_progressive_meshes(filePath, _options, opts) {
510
576
  let shouldOmitLevel = false;
511
577
  // if the mesh size on disc is identical to the last level, we can omit this level
512
578
  if (sizeOnDisc <= 0) {
513
- console.log(`[${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}\"`);
579
+ 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}\"`);
514
580
  shouldOmitLevel = true;
515
581
  }
516
582
  else if (diff == 0) {
517
- console.log(`[${NAME}] Flag mesh LOD ${level}: size on disc did not change compared to previous level → level will be omitted. \"${mesh.getName()}\" at \"${lodInfo.path}\"`);
583
+ 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}\"`);
518
584
  shouldOmitLevel = true;
519
585
  }
520
586
  else if (!shouldOmitLevel) {
@@ -557,7 +623,7 @@ export function make_progressive_meshes(filePath, _options, opts) {
557
623
  if (data != null) {
558
624
  clonedData.primitives.push(data);
559
625
  }
560
- console.log(`[${NAME}] Simplify mesh[${index}] primitive[${primitiveIndex}] (embedded)`);
626
+ logger.info(`[${NAME}] Simplify mesh[${index}] primitive[${primitiveIndex}] (embedded)`);
561
627
  const lastLod = LODS[lodLevelInFile];
562
628
  // TODO: this should be run after simplification but then we need to change the runtime to use the LOD 0 density
563
629
  const density = calculateMeshDensity(prim);
@@ -575,7 +641,7 @@ export function make_progressive_meshes(filePath, _options, opts) {
575
641
  }
576
642
  else if (difference < 32 || diff01 <= 0.05) {
577
643
  // 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
578
- console.log(`[${NAME}]: Flag mesh LOD ${generatedLODInfos.length} (embedded): vertex count difference below threshold (removed ${difference} vertices, ${(-diff01 * 100).toFixed(2)} %) → will remove LOD. \"${mesh.getName()}\"`);
644
+ logger.info(`[${NAME}]: Flag mesh LOD ${generatedLODInfos.length} (embedded): vertex count difference below threshold (removed ${difference} vertices, ${(-diff01 * 100).toFixed(2)} %) → will remove LOD. \"${mesh.getName()}\"`);
579
645
  try {
580
646
  for (const lodInfo of generatedLODInfos) {
581
647
  rmSync(outputDirectory + "/" + lodInfo.path);
@@ -96,7 +96,7 @@ export class NeedleToktxContext {
96
96
  const doc = this.document;
97
97
  const logger = doc.getLogger();
98
98
  const slots = listTextureSlots(texture);
99
- const channels = getTextureChannelMask(texture);
99
+ let channels = getTextureChannelMask(texture);
100
100
  // const textureLabel =
101
101
  // texture.getURI() ||
102
102
  // texture.getName() ||
@@ -175,6 +175,10 @@ export class NeedleToktxContext {
175
175
  }
176
176
  }
177
177
  const defaults = mode === Mode.ETC1S ? ETC1S_DEFAULTS : UASTC_DEFAULTS;
178
+ // TODO: should we ensure that the texture is using all channels? Unused channels are discarded anyways. The issue we have here is that textures that are solely used from pointers have unknown channels. We register them with RGBA when generating progressive assets as well as assign texture json pointers /textures/ to
179
+ // Alpha blended materials to make sure RGBA channels are used when progressive GLBs are compressed (in needle_progressive 377)
180
+ // https://linear.app/needle/issue/NE-4985
181
+ // channels = TextureChannel.R | TextureChannel.G | TextureChannel.B | TextureChannel.A;
178
182
  //@ts-ignore
179
183
  const toktxParams = createParams(texture, slots, channels, srcSize, logger, this.numTextures, { ...defaults, mode, quality, compression });
180
184
  await fs.writeFile(inPath, Buffer.from(srcImage));
@@ -57,7 +57,7 @@ export async function foreachGLTF(args, callback) {
57
57
  }
58
58
  }
59
59
  if (context.files.length <= 0) {
60
- console.log("WARN: No glTF files found. Make sure to invoke with a valid path to a glTF file or directory. This path may be relative to " + initDir);
60
+ console.log("WARN: No glTF files found. Make sure to invoke with a valid path to a glTF file or directory. This path may be relative to \"" + initDir + "\"");
61
61
  }
62
62
  else {
63
63
  for (const file of context.files) {
@@ -1,5 +1,6 @@
1
1
  import { Logger } from "@donmccurdy/caporal";
2
2
  import { Document } from "@gltf-transform/core";
3
+ import { Config } from "../config/index.js";
3
4
  export type Stats = {
4
5
  startTime: number;
5
6
  totalGPUMemoryInMB: number;
@@ -7,7 +8,7 @@ export type Stats = {
7
8
  totalFileSizeInMB: number;
8
9
  totalFileSizeInMBBefore: number;
9
10
  };
10
- export declare function printStats(stats: Stats, logger?: Logger): void;
11
+ export declare function printStats(stats: Stats, logger: Logger, config: Config): void;
11
12
  export type FileStats = {
12
13
  filename: string;
13
14
  /** Size in bytes */
@@ -25,4 +26,4 @@ export type FileStats = {
25
26
  * Create a new filestats object from a file and a document
26
27
  */
27
28
  export declare function createNewFilestats(file: string, document: Document, stats: Pick<FileStats, "gpu_memory">): FileStats;
28
- export declare function writeFileStatsToFile(filestats: FileStats[], output_path: string): void;
29
+ export declare function writeFileStatsToFile(config: Config, filestats: FileStats[], output_path: string): void;
@@ -1,10 +1,10 @@
1
1
  import { getVersion } from "./version.js";
2
2
  import { getFileName } from "./fileutils.js";
3
3
  import { statSync, writeFileSync } from "fs";
4
- export function printStats(stats, logger) {
4
+ export function printStats(stats, logger, config) {
5
5
  const duration = Date.now() - stats.startTime;
6
6
  const durationInMinutes = duration / 1000 / 60;
7
- const msg = `[Needle Build Pipeline] v${getVersion()} — Finished packing ${stats.totalFilesProcessed} files in ${durationInMinutes.toFixed(1)} minutes; total size ${stats.totalFileSizeInMBBefore.toFixed(1)} MB → ${stats.totalFileSizeInMB.toFixed(1)} MB, total GPU memory: ${stats.totalGPUMemoryInMB.toFixed(1)} MB`;
7
+ const msg = `[Needle Build Pipeline] v${getVersion()} — Finished ${config?.usecase} compression ${stats.totalFilesProcessed} files in ${durationInMinutes.toFixed(1)} minutes; total size ${stats.totalFileSizeInMBBefore.toFixed(1)} MB → ${stats.totalFileSizeInMB.toFixed(1)} MB, total GPU memory: ${stats.totalGPUMemoryInMB.toFixed(1)} MB`;
8
8
  if (logger)
9
9
  logger.info(msg);
10
10
  else
@@ -34,7 +34,7 @@ export function createNewFilestats(file, document, stats) {
34
34
  filestats.triangle_count = filestats.vertex_count / 3;
35
35
  return filestats;
36
36
  }
37
- export function writeFileStatsToFile(filestats, output_path) {
37
+ export function writeFileStatsToFile(config, filestats, output_path) {
38
38
  if (output_path == undefined) {
39
39
  console.error("No stats output path provided");
40
40
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.0.0-alpha",
3
+ "version": "2.1.0-alpha.1",
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",
@@ -59,7 +59,7 @@ if (noToktx)
59
59
  const packedFiles = new Set();
60
60
 
61
61
  /**
62
- * @type {import("../dist/index.js").Stats}
62
+ * @type {import("../dist/src/index.js").Stats}
63
63
  */
64
64
  const stats = {
65
65
  startTime: Date.now(),