CesiumGS / 3d-tiles-tools

Apache License 2.0
281 stars 41 forks source link

`3d-tiles-tools upgrade` --options flag not effective for `gltf-pipeline gltfUpgradeOptions` #144

Closed jo-chemla closed 1 month ago

jo-chemla commented 1 month ago

Hi there,

I'm trying to parse through every glb tile of a large tileset, trying to decompress textures which are compressed with KTX2. Note KTX2 decompression is not yet available within gltf-pipeline - see probable blockers in this thread for gltf-transform, and linked root issue thread to enable support within KhronosGroup/Basis-Universal-Transcoders - so I'll do this by myself, but would need to explode every glb tile to gltf + bin geometry + ktx2 texture.

I've been trying several iterations of the upgrade command with the --options flag passed to either --separateTextures, --separate to explode geometry and textures, or --json to convert gltf to glb but they all resulted in glb tiles - with packed geometry and textures. By reading through the code, it seems that the targetVersion has to be 1.0 for these gltfUpgradeOptions to be passed, but I feel like I'm misunderstanding how to process every tile with gltf-pipeline.

Here are some example commands I've tested. Any help would be appreciated! Best regards,

npx 3d-tiles-tools upgrade -i .\tilesetRoot.json -o .\separate-tilesetRoot.json --targetVersion 1.0 --options --separate
npx 3d-tiles-tools upgrade -i .\tilesetRoot.json -o .\separate-tilesetRoot.json --targetVersion 1.0 --options --separateTexture
npx 3d-tiles-tools upgrade -i .\tilesetRoot.json -o .\json-tilesetRoot.json --targetVersion 1.0 --options --json
javagl commented 1 month ago

These --options are intended for a very narrow use-case. (And maybe that could be made clearer in the description). They are only passed to the gltf-pipeline for the case where the glTF that is contained in B3DM- or I3DM files is supposed to be upgraded from glTF 1.0 to glTF 2.0. (This in itself is only possible for very few cases - basically, cases where ~"the GLSL shader code (that was still stored in glTF 1.0!) has a certain structure"). So when you have a veeery old tileset, where the B3DMs still contain glTF 1.0 data, then the upgrade (with --targetVersion 1.0, the default) will try to convert this glTF 1.0 into glTF 2.0. And for rare, special cases, it can be useful to pass in additional --options for the conversion.

Or more focussed on your question: There is no built-in mechanism for doing arbitrary invovations of gltf-pipeline (and consequently, no mechanism to pass --options to such an invocation).

But... in a more abstract form, this is a common use-case: "Perform operation X on every tile content". This can often be done with a few lines of custom code. And an approach for generalizing this was ... actually drafted even in one of the first versions of the 3D Tiles Tools. Namely, the concept of a "pipeline". Unfortunately, this was never really settled/solidified (even though it could be remarkably powerful). But fortunately, one of the "initial draft" functionalities that still does work could be helpful here:

Put this into some pipeline.json:

{
  "input": "C:/data/glb/tileset.json",
  "output": "C:/data/gltf/tileset.json",
  "tilesetStages": [
    {
      "name": "ConvertGlbToGltf",
      "contentStages": [
        {
          "name": "separateGltf"
        }
      ]
    }
  ]
}

and run this with npx 3d-tiles-tools pipeline -i .\pipeline.json

It will read the input, and convert each .glb into a .gltf+resources (updating the content.uris accordingly).

(The "magic" here is that "name": "separateGltf", which is sort of a "predefined command". Generalizing this properly, and defining a real "file format" for the pipelines, including a JSON schema and such, is ... a long-term-maybe 'TODO'...)


I'm still curious about how you intend to do the next step. I don't know how much tooling exists, in NodeJS or in general, for the required steps:

The easiest approach would be - iff glTF-Transform supported decompressing KTX2 - to just use the following skeleton for "generic tile content processing". In that callback that currently contains the log message, you could ...

import { TilesetEntry } from "./src/tilesets";
import { BasicTilesetProcessor } from "./src/tools";

async function example() {

  const source = "./input/tileset.json";
  const target = "./output/tileset.json";
  const overwrite = true;

  const tilesetProcessor = new BasicTilesetProcessor();
  await tilesetProcessor.begin(source, target, overwrite);

  // Process all entries that are tile content
  await tilesetProcessor.processTileContentEntries(
    (uri: string) => uri,
    async (
      sourceEntry: TilesetEntry,
      type: string | undefined
    ): Promise<TilesetEntry> => {
      console.log("In processTileContentEntries");
      console.log("  name:" + sourceEntry.key);
      console.log("  type: " + type);
      const targetEntry : TilesetEntry = {
        key: sourceEntry.key,
        value: sourceEntry.value
      }
      return targetEntry;
    }
  );
  await tilesetProcessor.end();
}

example();

It might even be possible to come up with some hacky solution here, like an fs.writeFileSync(..."tempInput"), followed by a (CLI!) call to ktx transcode, and then some fs.readFileSync("tempOutput") to read the RGBA pixels, and do some sharp call to convert these pixels to PNG and sneak the result back into the glTF-Transform Texture - but this could only be remotely reasonable iff (if and only if) all this was supposed to be a one-shot operation for one specific tileset...

jo-chemla commented 1 month ago

Once again, thank you so much for all these details, really full of insights. This is indeed a once-in-a-lifetime workflow that shall not be applied in production, but only to one tileset we did receive that do not match our current expectations.

Combining 3d-tiles-tools tilesetProcessor.processTileContentEntries with gltf-transform document.transform (with a custom transform) is indeed exactly the way to go. I gave it a shot, and avoided CLI calls as much as possible - but still relied quite heavily on node synchronous operations.

Note the ktx pipeline can directly export a ktx file to image file via the extract CLI method. There also seem to be js bindings to libktx, see here and there as wasm, but seem not yet very straightforward.

The pipeline now looks like the following:

I'm ~mostly~ done with the below code in next post - fighting with sharp when trying to use cloneDocument, seems to be due to an issue that was fixed by a release of gltf-transform that is more recent that the one used in 3d-tiles-tools repo. Again, thanks a lot for your help!

javagl commented 1 month ago

OK, that looks like a fleshed-out version of that "hacky" solution that I mentioned, with that "hacky" part mainly being the necessity to call the KTX CLI tool.

I only have a rough understanding/memory of the current pain points:

About sharp: I remember that I once stumbled over some obscure error messages related to sharp. It was something that involved "Could not load the "sharp" module..." messages. And it was related to some version incompatibilty, caused by some native library that sharp is using under the hood, and that somehow ~"can only be loaded in one version", meaning that it shows up when there are two different sharp versions in the dependency tree. IIRC, I stumbled over https://github.com/lovell/sharp/issues/3870#issuecomment-1833121113 and (even though I'm on Windows) helplessly downgraded some version of sharp somewhere, and it worked...

About the CLI call: There once had been JS bindings for KTX. And technically, they are still there. But the documentation at https://github.khronos.org/KTX-Software/ktxjswrappers/index.html says that they are deprectated. But I know that they recently have been extended, somewhere in the context of https://github.com/KhronosGroup/glTF-Compressor/tree/main/source/libs . But I think that these changes did not yet make their way back into the main repo. So ... the tl;dr of all these 'but's': The KTX-Software repo (and mainly the bindings) may ... require some work. (I started cleaning up the Java bindings a while ago, at https://github.com/KhronosGroup/KTX-Software/pull/886 , but ... have to juggle with priorities here...)

The 3D Tiles Tools actually contain a KtxUtility class (used in some of the GltfTransform.... utility classes). But being based on the BinomialLLC encoder, this currently only offers encoding into KTX, and not decoding. Maybe it will be extended in the future....

javagl commented 1 month ago

(A small aside: It's not entirely clear why you are writing the glTF 2.0 data into files. It should be possible to just read the GLB data with await io.readBinary(someBuffer);. But I'd have to read the code more thoroughly...)

jo-chemla commented 1 month ago

Thanks again for getting back.

I just edited the above code snippet to fix minor errors. Last thing I needed to be doing is that the document still had the KHR_texture_basisu listed as extensionsRequired and extensionsUsed although the texture is now png. The way to do it via gltf-transform utility methods was to use extension.dispose() - just spawned a discussion here. Consider this thread done, thanks again!

Complete code ```js import path from "path"; import fs from 'fs'; import { spawnSync } from 'child_process'; // cesium 3d-tiles-tools import { TilesetEntry, BasicTilesetProcessor, TilesetUpgrader, GltfUtilities } from "3d-tiles-tools"; // import GltfPipeline from "gltf-pipeline"; // donmccurdy gltf-transform import { Document, NodeIO, Property, Node } from '@gltf-transform/core'; import { ALL_EXTENSIONS, KHRTextureBasisu } from '@gltf-transform/extensions'; import draco3d from 'draco3dgltf'; import { cloneDocument } from '@gltf-transform/functions'; // import { read as ktxread, write as ktxwrite } from 'ktx-parse'; // Configure I/O. const io = new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ 'draco3d.decoder': await draco3d.createDecoderModule(), // Optional. 'draco3d.encoder': await draco3d.createEncoderModule(), // Optional. }); const TEMP_KTX2_TEX = 'temp_tile_20_fp.ktx2' const TEMP_PNG_TEX = 'temp_tile_20_fp.png' async function main() { const source_ = "L:/JOBS/tilesetRoot.json"; const target = "./output/tileset.json"; const overwrite = true; const source = path.normalize(source_).replaceAll('\\', '/') console.log('source_ vs source', source_, source) const tilesetProcessor = new BasicTilesetProcessor(); await tilesetProcessor.begin(source, target, overwrite); const sourceDir = determineTilesetDirectoryName(source) const targetDir = determineTilesetDirectoryName(target) // Using 3d-tiles-tools tilesetProcessor.processTileContentEntries // Process all entries that are tile content await tilesetProcessor.processTileContentEntries( (uri: string) => uri, async ( sourceEntry: TilesetEntry, type: string | undefined ): Promise => { console.log(`name: ${sourceEntry.key} in processTileContentEntries`); console.log(" type: " + type); if (type == 'CONTENT_TYPE_GLB') { // Can use gltf-transform here to glb to gltf and process the gltf const tile_fp = path.join(sourceDir, sourceEntry.key); const glbBuffer = sourceEntry.value; const upgradedOutputBuffer = await GltfUtilities.upgradeGlb(glbBuffer, { targetVersion: "2.0" }) // const targetTile_dir = path.join(targetDir, path.parse(sourceEntry.key).dir) // fs.mkdirSync(targetTile_dir, { recursive: true }) // const tile_20_fp = path.format({ // dir: targetTile_dir, // name: path.parse(sourceEntry.key).name + '-2.0', // ext: '.glb', // }).replaceAll('\\', '/'); // console.log(` - writing upgraded 2.0 gltf to ${tile_20_fp}`) // fs.writeFileSync(tile_20_fp, upgradedOutputBuffer); console.log(' done upgrading glb to 2.0 so gltf-transform can process') const sourceDocument = await io.readBinary(upgradedOutputBuffer); // const sourceDocument = await io.read(tile_20_fp); // const targetDocument = cloneDocument(sourceDocument); const targetDocument = sourceDocument; await targetDocument.transform( customTransform({}), ) // Other transformations available: // prune(), // Remove unused nodes, textures, or other data. // dedup(), // Remove duplicate vertex or texture data, if any. // draco(), // Compress mesh geometry with Draco. // // Convert textures to WebP (Requires glTF Transform v3 and Node.js). // textureCompress({ // encoder: sharp, // targetFormat: 'webp', // resize: [1024, 2024], // }), const outputGlb = await io.writeBinary(targetDocument); // console.log('outGlb', outputGlb) // console.log('sourceEntry.value', sourceEntry.value) // fs.unlink( // tile_20_fp, // function (err) { // if (err) return console.log(err); // console.log(` file ${tile_20_fp} deleted successfully`); // } // ) const targetEntry: TilesetEntry = { key: sourceEntry.key, value: Buffer.from(outputGlb) } return targetEntry; } const targetEntry: TilesetEntry = { key: sourceEntry.key, value: sourceEntry.value } return targetEntry; } ); await tilesetProcessor.end(); // Delete temporary files [TEMP_KTX2_TEX, TEMP_PNG_TEX].forEach( fp => fs.unlink( fp, function (err) { if (err) return console.log(err); console.log(`file ${fp} deleted successfully`); } ) ) console.log(`Finished processing tileset ${source} to ${target}`) } // Custom transform: enable/disable backface culling. function customTransform(options: any) { return (document: Document) => { // TODO: Remove extension from document KHR_texture_basisu // document. for (const texture of document.getRoot().listTextures()) { // material.setDoubleSided(!options.cull); console.log(' - texture mimeType', texture.getMimeType()) if (texture.getMimeType() == 'image/ktx2') { // Explore js bindings for libktx by khronos group to avoid CLI call - requires wasm build // https://github.khronos.org/KTX-Software/ktxjswrappers/libktx_js.html // https://github.com/KhronosGroup/KTX-Software/discussions/782 const image = texture.getImage(); if (image) { // const ktx_input = ktxread(image); // const name = texture.getURI() || texture.getName(); // const ktx_output = ktxwrite(ktx_input) fs.writeFileSync(TEMP_KTX2_TEX, image); console.log(' - ktx texture written to disk so ktx CLI can process to png', TEMP_KTX2_TEX) // texture.setImage(ktx_output); const ktxCmd = spawnSync('ktx', ['extract', TEMP_KTX2_TEX, TEMP_PNG_TEX]); // console.log(`stderr: ${ktxCmd.stderr.toString()} \nstdout: ${ktxCmd.stdout.toString()}`); console.log(' ktx texture converted to png to ', TEMP_PNG_TEX) const pngImg = fs.readFileSync(TEMP_PNG_TEX) texture.setImage(pngImg); texture.setMimeType('image/png'); console.log('texture.getExtension(KHR_texture_basisu) ', texture.getExtension('KHR_texture_basisu')) texture.setExtension('yo', null) console.log('texture.getExtension(KHR_texture_basisu) ', texture.getExtension('yo')) console.log(' glb tile ktx2 texture replaced with png') } } } // Remove extension from document KHR_texture_basisu // document.createExtension(KHRTextureBasisu).dispose(); also works document.getRoot() .listExtensionsUsed() .find((e) => e.extensionName === 'KHR_texture_basisu') ?.dispose(); }; } function determineTilesetDirectoryName(tilesetName: string) { const n = tilesetName.toLowerCase(); if (n.endsWith(".json") || n.endsWith(".3tz") || n.endsWith(".3dtiles")) { return path.dirname(tilesetName); } return tilesetName; } main(); // Could also choose upgrade whole tileset to 2.0 (so gltf-transform can parse it) // As well as explode glb to gltf + ktx2 + draco bin geometry for easier processing // But these are done via the script rather than CLIs // const gltfUpgradeOptions = undefined; // const targetVersion = "1.1"; // const tilesetUpgrader = new TilesetUpgrader( // targetVersion, // gltfUpgradeOptions // ); // await tilesetUpgrader.upgrade(currentInput, currentOutput, overwrite); ```