ThatOpen / engine_components

MIT License
321 stars 125 forks source link

Exporting with GLTFExporter produces invalid glTF #222

Closed wlinna closed 2 months ago

wlinna commented 9 months ago

Describe the bug šŸ“

I am trying to export the fragments to glTF, but the exported glb-file is invalid. glTF validator produces lots of errors and the file doesn't open in Blender

Whether it's because the fragments are somehow special or because the GLTFExporter of Three.js 152 specifically is broken I know not. However, in other contexts I have used GLTFExporter without problems for years. Either way, something should be done on the openbim-components side.

Reproduction ā–¶ļø

https://657dfca31ade9164da46e2de--bim-building-simplification.netlify.app/

Steps to reproduce šŸ”¢

~(Sorry for not being able to give a reproduction site at the moment. My repro site seems to break when built with Vite and deployed to Netlify. Maybe later)~

EDIT: Reproduction steps with the reproduction site

  1. Import IFC
  2. Export GLB
  3. Validate or try opening in Blender

Old steps (the reproduction steps explained in general)

  1. Follow this tutorial to generate fragments (except that I use the browser's file selector for IFC loading) https://docs.thatopen.com/Tutorials/FragmentIfcLoader
  2. Export with the following method
import { GLTFExporter, GLTFExporterOptions } from 'three/examples/jsm/exporters/GLTFExporter';

async function produceGlb() {
    if (!fragments.groups.length) return;

    const exporter = new GLTFExporter();
    const opts: GLTFExporterOptions = {
        trs: true,
        binary: true,
        onlyVisible: true,
    };

    const group = fragments.groups[0];
    const glb = await exporter.parseAsync(group, opts);
    return glb;
}

async function exportGlb() {
    if (!fragments.groups.length) return;

    const glb = (await produceGlb()) as ArrayBuffer;

    const blob = new Blob([glb]);
    const file = new File([blob], 'example.glb');

// I changed download to take name separately to make TypeScript happy
    download(file, 'example.glb');
}
  1. Validate or try opening in Blender

System Info šŸ’»

System:
    OS: Linux 6.5 Pop!_OS 22.04 LTS
    CPU: (20) x64 13th Gen Intel(R) Core(TM) i5-13600KF
    Memory: 13.81 GB / 31.18 GB
    Container: Yes
    Shell: 5.8.1 - /usr/bin/zsh
  Binaries:
    Node: 20.10.0 - /usr/bin/node
    npm: 10.2.3 - /usr/bin/npm
  Browsers:
    Chromium: 120.0.6099.71 (I'm actually using Firefox)
  npmPackages:
    openbim-components: ^1.2.0 => 1.2.0

Used Package Manager šŸ“¦

npm

Error Trace/Logs šŸ“ƒ

glTF validator output ```{ "uri": "example.glb", "mimeType": "model/gltf-binary", "validatorVersion": "2.0.0-dev.3.8", "validatedAt": "2023-12-16T16:13:37.458Z", "issues": { "numErrors": 630, "numWarnings": 0, "numInfos": 0, "numHints": 0, "messages": [ { "code": "VALUE_NOT_IN_RANGE", "message": "Value 2 is out of range.", "severity": 0, "pointer": "/bufferViews/2/byteStride" }, { "code": "VALUE_NOT_IN_RANGE", "message": "Value 2 is out of range.", "severity": 0, "pointer": "/bufferViews/6/byteStride" }, { "code": "VALUE_NOT_IN_RANGE", "message": "Value 2 is out of range.", "severity": 0, "pointer": "/bufferViews/10/byteStride" }, ... { "code": "MESH_PRIMITIVE_ACCESSOR_UNALIGNED", "message": "Vertex attribute data must be aligned to 4-byte boundaries.", "severity": 0, "pointer": "/meshes/0/primitives/0/attributes/_BLOCKID" }, { "code": "MESH_PRIMITIVE_ACCESSOR_UNALIGNED", "message": "Vertex attribute data must be aligned to 4-byte boundaries.", "severity": 0, "pointer": "/meshes/1/primitives/0/attributes/_BLOCKID" }, ... ], "truncated": false }, "info": { "version": "2.0", "generator": "THREE.GLTFExporter", "resources": [ { "pointer": "/buffers/0", "mimeType": "application/gltf-buffer", "storage": "glb", "byteLength": 2062856 } ], } } ```

Validations āœ…

wlinna commented 9 months ago

It seems that all the errors are related to the _BLOCKID attribute

agviegas commented 9 months ago

Hey @wlinna, the geometric representation we use is not necessarily glTF compatible due to the blockID attribute. If you remove that attribute from the geometry before exporting, would it work for you?

wlinna commented 9 months ago

Hello

If you remove that attribute from the geometry before exporting, would it work for you?

That's what I'm wondering myself. However, I don't know what blockID represents and thus I don't know what I'd be losing. However, that's what I'm going to try next.

Is blockID documented somewhere?

the geometric representation we use is not necessarily glTF compatible due to the blockID attribute.

But having a custom attribute shouldn't be a problem in itself. At my day job our meshes have custom attributes and GLTFExporter has no trouble dealing with them. Because of this, I'm inclined to think that something is wrong with the way blockID is stored / expressed.

agviegas commented 9 months ago

Hm, blockID is just another Three.js BufferAttribute, so I'm not sure what can be wrong with it. šŸ¤”

We use fragments to express geometry, which is a simple wrapper we created around Three.js instancedMeshes to work efficiently with BIM data. The concept is simple: use instancing for geometries that repeat, and merge geometries that are unique (like walls, slabs, etc) to reduce draw calls.

We use the blockID attribute to distinguish meshes with unique geometries that are merged to reduce the draw calls (e.g. walls or slabs) at the vertex level. It's not ideal having an int per vertex, but it's the easiest way to make this work with three-mesh-bvh, which necessary for things like real time clipping planes.

wlinna commented 9 months ago

Thanks for the response

We use the blockID attribute to distinguish meshes with unique geometries that are merged to reduce the draw calls (e.g. walls or slabs) at the vertex level. It's not ideal having an int per vertex, but it's the easiest way to make this work with three-mesh-bvh, which necessary for things like real time clipping planes.

Good to know. My application needs the meshes to be separated, so I need to use blockID to undo the batching.

Hm, blockID is just another Three.js BufferAttribute, so I'm not sure what can be wrong with it. šŸ¤”

The problem is related to the byteStride of bufferview. It has to be divisible by 4, but in the export it is 2. GLTFExporter computes it like this:

    case WEBGL_CONSTANTS.SHORT:
    case WEBGL_CONSTANTS.UNSIGNED_SHORT:

        componentSize = 2;
        break;

    ...

    bufferViewDef.byteStride = attribute.itemSize * componentSize;

Here's what the glTF spec says:

For performance and compatibility reasons, each element of a vertex attribute MUST be aligned to 4-byte boundaries inside a bufferView (i.e., accessor.byteOffset and bufferView.byteStride MUST be multiples of 4).

Perhaps it would be for the best to change blockID from Uint16 to Uint32 since apparently using Uint16 might have undesired performance/compatibility implications.

Whether you do it or not doesn't concern me anymore, however, because I need to undo the batching anyway and thus remove blockID anyway.

wlinna commented 9 months ago

Turns out that blockID isn't the only problem. I tried removing them and then exporting like before, but the results are not good

    group.traverse(obj => {

        console.log(obj.constructor.name);
        if (!(obj instanceof THREE.Mesh)) {
            return;   
        }

        const geom = obj.geometry as THREE.BufferGeometry;
        geom.deleteAttribute('blockID');

}

OutOfWhack

When I zoom out, it looks like the objects that are likely instanced are 1000 times larger and stacked together. I don't know how the instances are handled in fragments / components, so any pointers would be much appreciated

wlinna commented 9 months ago

It turns out that the GLTFExporter supplied by components does not include (GLTFMeshGpuInstancing)[ https://github.com/mrdoob/three.js/blob/d04539a76736ff500cae883d6a38b3dd8643c548/examples/jsm/exporters/GLTFExporter.js#L146C15-L146C36].

So part of the problem should be fixed by upgrading the Three.js dependency. I'm probably going to try make separate meshes though.

I ended up turning all the instances to separate Mesh objects (that just happen to share the geometry) to avoid any compatibility issues, and that works fine.

nicoarellano commented 7 months ago

Hi @wlinna I am curious to see how you solved this issue. I am also trying to export glbs or gltfs from certain IFC classes (walls, slabs, roofs, I want to create a lite or shell mesh that I can load on top of mapbox) but I am not being successful at doing so. @agviegas any advice would be greatly appreciated. Thanks!

wlinna commented 7 months ago

Hello, the process of exporting is surprisingly complicated, although I haven't tested with the latest version because I can't install it without errors. So I use "openbim-components": "1.2.0"

However, I managed to implement a my own solution that has served me well since. Feel free to modify my code. My code includes only uses two very basic materials (because my application doesn't need material information), so you'll likely need to write better material generation code (if you can share your improvement, I'd appreciate it)

Here's the relevant TypeScript code. Feel free to ask about the code if you have trouble using / understanding it.

Note that if you don't need to separate the components, you might be able to get away with something much simpler. In that case, removing blockID attribute from each geometry might be a sufficient alternative to the whole separateByBlockId operaiton.

async function exportGlb() {
    if (!fragments.groups.length) {
         return;
   }

    const group = fragments.groups[0];
    const clone = cloneAndUnravel(group);
   // You only need this if you use fragmentIfcLoader.settings.webIfc.COORDINATE_TO_ORIGIN = true;
    group.coordinationMatrix.invert().decompose(clone.position, clone.quaternion, clone.scale);

    const exporter = new GLTFExporter();
    // Options copied from examples. They don't have to be like this I think
    const opts = {
        trs: false,
        onlyVisible: false,
        truncateDrawRange: true,
        binary: true,
        maxTextureSize: 0
    };

    const glb = await exporter.parseAsync(clone, opts) as ArrayBuffer;

    const blob = new Blob([glb]);
    const file = new File([blob], name + '.glb');

    const objUrl = URL.createObjectURL(file);
    ...
}
class ItemData {
    position: number[] = [];
    index: number[] = [];
    oldToNew = new Map<number, number>();

    addNewPos(oldIdx: number, x: number, y: number, z: number) {
        this.position.push(x, y, z);
        this.oldToNew.set(oldIdx, (this.position.length / 3) - 1);
    }

    addIndex(oldIdx: number) {
        const newIdx = this.oldToNew.get(oldIdx);
        if (newIdx == null) {
            throw new Error('Index not found error');
        }

        this.index.push(newIdx);
    }
}

function cloneAndUnravel(group: FragmentsGroup) {
    const serializer = new Serializer();
    const exported = serializer.export(group);
    const clone = serializer.import(exported);

    const properties = group.properties!;
    const itemMap = new Map<string, ItemData>();

    clone.traverse(obj => {
        if (!(obj instanceof THREE.Mesh)) {
            return;
        }

        const geom = obj.geometry as THREE.BufferGeometry;

        if (obj instanceof FragmentMesh) {
            const matrix = new THREE.Matrix4;
            for (let instI = 0; instI < obj.count; ++instI) {
                obj.getMatrixAt(instI, matrix);

                separateByBlockId(obj.fragment, instI, geom, matrix, itemMap);
            }

            return;
        }

        throw new Error('This never happens');
    });

    clone.clear();

    const outGeoms = new Map<string, THREE.BufferGeometry>();

    for (const [key, itemData] of itemMap.entries()) {
        const newGeom = new THREE.BufferGeometry();
        newGeom.setIndex(itemData.index);
        newGeom.setAttribute('position', new THREE.Float32BufferAttribute(itemData.position, 3));

        const cleaned = BufferGeometryUtils.mergeVertices(newGeom);

        outGeoms.set(key, cleaned);
    }

    const myMat = new THREE.MeshStandardMaterial({ color: 0x666666 });
    const windowMat = new THREE.MeshStandardMaterial({ color: 0x0101FE, opacity: 0.7 });

    for (const [expressId, geom] of outGeoms.entries()) {
        const props = properties[Number(expressId)];
        const ifcType = props.type;
        let mat = myMat;
        if (ifcType === WEBIFC.IFCWINDOW || ifcType === WEBIFC.IFCWINDOWSTANDARDCASE) {
            mat = windowMat;
        }

        let newMesh = new THREE.Mesh(geom, mat);

        newMesh.name = props.GlobalId?.value ?? expressId;
        newMesh.userData.expressId = expressId;

        newMesh.updateMatrix();
        clone.add(newMesh);
    }

    return clone;
}
function separateByBlockId(fragment: Fragment, instanceId: number, bufGeom: THREE.BufferGeometry, matrix: THREE.Matrix4, itemMap: Map<string, ItemData>) {
    const origVerts = bufGeom.getAttribute('position');
    const blockIdBuf = bufGeom.getAttribute('blockID');
    const origIndex = bufGeom.index as THREE.Uint32BufferAttribute;

    const vec = new THREE.Vector3;

    for (let i = 0; i < origVerts.count; ++i) {
        let blockId = blockIdBuf.getX(i);
        let expressId = fragment.getItemID(instanceId, blockId);

        if (expressId.includes('.')) {
            // const test = fragment.getItemID(instanceId, blockId);
            console.warn("Investigate: weird expressId: " + expressId);
            expressId = expressId.substring(0, expressId.lastIndexOf('.'));
        }

        let blockData = itemMap.get(expressId) ?? new ItemData;
        itemMap.set(expressId, blockData);

        vec.set(origVerts.getX(i), origVerts.getY(i), origVerts.getZ(i)).applyMatrix4(matrix);

        blockData.addNewPos(i, vec.x, vec.y, vec.z);
    }

    let currentBlockId = -1;

    for (let iIndex = 0; iIndex < origIndex.count; ++iIndex) {
        let idx = origIndex.getX(iIndex);
        let blockId = blockIdBuf.getX(idx);

        if (iIndex % 3 === 0) {
            currentBlockId = blockId;
        } else if (blockId != currentBlockId) {
            throw new Error('The blockId should not change mid-triangle!');
        }

        let expressId = fragment.getItemID(instanceId, blockId);
        if (expressId.includes('.')) {
            expressId = expressId.substring(0, expressId.lastIndexOf('.'));
        }

        let blockData = itemMap.get(expressId) ?? new ItemData;
        itemMap.set(expressId, blockData);

        blockData.addIndex(idx);
    }
}
agviegas commented 7 months ago

Hi, this week we will release a new version of components/fragments, maybe it helps. We have gotten rid of the BlockID attribute, so now the mesh attribute of a component is a mere three.js InstancedMesh, so exporting it to GLTF should be a pure Three.js matter.

If after the update of components & fragments to 1.5.0 the issue remains, let me know and we'll see what we can do. Cheers!

wlinna commented 7 months ago

@agviegas Interesting! But how are the different meshes with identical materials batched then, if at all? Won't the additional draw calls become an issue? Or do I have to figure out a new way to split merged meshes (since I need to have them separated before processing)?

agviegas commented 7 months ago

Merged meshes don't exist anymore. Now, everything is instanced. So each unique element remains its own mesh. We have created a streaming system that can open multi gigabyte models on any devices in seconds at 60 fps. The draw calls are always under control because items that are not visible for the camera are removed from the scene, and later from memory (until seen again).

Needless to say, this system is also compatible with the rest of components and part of this library. This is what we are releasing this week.

Getting rid of merged meshes has made everything much cleaner and easier. Also, this year we are creating a BIM modeller, and this approach will make everything much easier to work with and to maintain.

nicoarellano commented 7 months ago

Thanks, @wlinna I'll give it a try. Looking forward to the new release.

wlinna commented 7 months ago

Very interesting. I have a couple of questions if you don't mind me asking:

Btw, I wonder how well will occlusion culling work when the model has a limited number of good occluders. Industrial facility models often seem to lack them. Not that it matters in my use case, just curious.

agviegas commented 7 months ago

Sure! This will be in the docs as a step-by-step example once we release it next week.

wlinna commented 7 months ago

Thanks for explaining!

What I actually need is to load the model to the scene, manipulate it a little bit such as by hiding certain components, and then convert it to GLB so that my WASM module can run some operations on it.

Thankfully 1.4 series still allows using IfcFragmentLoader. I really hope the ability to load the whole model to the scene will never be removed or deprecated.

1.4.11 has this rather nasty bug though :/ https://github.com/ThatOpen/engine_components/issues/314

agviegas commented 2 months ago

Hey @wlinna thanks for your patience. I think it's solved. Using the fragmentsManager tutorial as a starting point, and modifying the load function so that it exports the result with your config:

async function loadFragments() {
  if (fragments.groups.size) {
    return;
  }
  const file = await fetch(
    "https://thatopen.github.io/engine_components/resources/small.frag",
  );
  const data = await file.arrayBuffer();
  const buffer = new Uint8Array(data);
  const group = fragments.load(buffer);
  world.scene.three.add(group);
  uuid = group.uuid;

  const exporter = new GLTFExporter();
  const opts: GLTFExporterOptions = {
    trs: true,
    binary: true,
    onlyVisible: true,
  };

  const glb = (await exporter.parseAsync(group, opts)) as ArrayBuffer;

  const blob = new Blob([glb]);
  const exportedFile = new File([blob], "example.glb");

  // I changed download to take name separately to make TypeScript happy
  const a = document.createElement("a");
  a.download = "exported.glb";
  a.href = URL.createObjectURL(exportedFile);
  a.click();
  a.remove();
}

This is the result:

https://github.com/ThatOpen/engine_components/assets/56475338/3f4757f0-8af4-4ace-b8c4-adb1a046f7e5

Let me know if you have any issues. Cheers!