diff --git a/specs/base/binary/BinaryBufferDataResolverSpec.ts b/specs/base/binary/BinaryBufferDataResolverSpec.ts new file mode 100644 index 00000000..ca406d7f --- /dev/null +++ b/specs/base/binary/BinaryBufferDataResolverSpec.ts @@ -0,0 +1,143 @@ +import fs from "fs"; +import path from "path"; + +import { BinaryBufferDataResolver } from "../../../src/base"; +import { ResourceResolvers } from "../../../src/base"; + +import { SpecHelpers } from "../../SpecHelpers"; + +const SPECS_DATA_BASE_DIRECTORY = SpecHelpers.getSpecsDataBaseDirectory(); + +describe("BinaryBufferDataResolver", function () { + it("resolves the data from the buffer structure of a glTF", async function () { + const p = SPECS_DATA_BASE_DIRECTORY + "/Triangle/Triangle.gltf"; + const directory = path.dirname(p); + const resourceResolver = + ResourceResolvers.createFileResourceResolver(directory); + + const gltfBuffer = fs.readFileSync(p); + const gltf = JSON.parse(gltfBuffer.toString()); + const binaryBufferData = await BinaryBufferDataResolver.resolve( + gltf, + undefined, + resourceResolver + ); + + // The indices are unsigned short (16 bit) integer values + const indicesBufferView = binaryBufferData.bufferViewsData[0]; + const indicesArray = new Uint16Array( + indicesBufferView.buffer, + indicesBufferView.byteOffset, + indicesBufferView.byteLength / Uint16Array.BYTES_PER_ELEMENT + ); + const actualIndices = [...indicesArray]; + const expectedIndices = [0, 1, 2]; + + // The positions are float values + const positionsBufferView = binaryBufferData.bufferViewsData[1]; + const positionsArray = new Float32Array( + positionsBufferView.buffer, + positionsBufferView.byteOffset, + positionsBufferView.byteLength / Float32Array.BYTES_PER_ELEMENT + ); + const actualPositions = [...positionsArray]; + // prettier-ignore + const expectedPositions = [ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 0.0, 1.0, 0.0 + ]; + + expect(actualIndices).toEqual(expectedIndices); + expect(actualPositions).toEqual(expectedPositions); + }); + + it("resolves the data from the buffer structure of a glTF with meshopt compression", async function () { + const p = SPECS_DATA_BASE_DIRECTORY + "/Triangle/TriangleMeshopt.gltf"; + const directory = path.dirname(p); + const resourceResolver = + ResourceResolvers.createFileResourceResolver(directory); + + const gltfBuffer = fs.readFileSync(p); + const gltf = JSON.parse(gltfBuffer.toString()); + const binaryBufferData = await BinaryBufferDataResolver.resolve( + gltf, + undefined, + resourceResolver + ); + + // The indices are unsigned short (16 bit) integer values + const indicesBufferView = binaryBufferData.bufferViewsData[0]; + const indicesArray = new Uint16Array( + indicesBufferView.buffer, + indicesBufferView.byteOffset, + indicesBufferView.byteLength / Uint16Array.BYTES_PER_ELEMENT + ); + const actualIndices = [...indicesArray]; + const expectedIndices = [0, 1, 2]; + + // The positions are ALSO unsigned 16-bit integer values (normalized) + const positionsBufferView = binaryBufferData.bufferViewsData[1]; + const positionsArray = new Uint16Array( + positionsBufferView.buffer, + positionsBufferView.byteOffset, + positionsBufferView.byteLength / Uint16Array.BYTES_PER_ELEMENT + ); + const actualPositions = [...positionsArray]; + // prettier-ignore + const expectedPositions = [ + 32769, 32769, 0, + 0, 32767, 32769, + 0, 0, 32769, + 32767, 0, 0 + ] + + expect(actualIndices).toEqual(expectedIndices); + expect(actualPositions).toEqual(expectedPositions); + }); + + it("resolves the data from the buffer structure of a glTF without default byte offsets with meshopt compression", async function () { + const p = + SPECS_DATA_BASE_DIRECTORY + "/Triangle/TriangleMeshoptNoByteOffset.gltf"; + const directory = path.dirname(p); + const resourceResolver = + ResourceResolvers.createFileResourceResolver(directory); + + const gltfBuffer = fs.readFileSync(p); + const gltf = JSON.parse(gltfBuffer.toString()); + const binaryBufferData = await BinaryBufferDataResolver.resolve( + gltf, + undefined, + resourceResolver + ); + + // The indices are unsigned short (16 bit) integer values + const indicesBufferView = binaryBufferData.bufferViewsData[0]; + const indicesArray = new Uint16Array( + indicesBufferView.buffer, + indicesBufferView.byteOffset, + indicesBufferView.byteLength / Uint16Array.BYTES_PER_ELEMENT + ); + const actualIndices = [...indicesArray]; + const expectedIndices = [0, 1, 2]; + + // The positions are ALSO unsigned 16-bit integer values (normalized) + const positionsBufferView = binaryBufferData.bufferViewsData[1]; + const positionsArray = new Uint16Array( + positionsBufferView.buffer, + positionsBufferView.byteOffset, + positionsBufferView.byteLength / Uint16Array.BYTES_PER_ELEMENT + ); + const actualPositions = [...positionsArray]; + // prettier-ignore + const expectedPositions = [ + 32769, 32769, 0, + 0, 32767, 32769, + 0, 0, 32769, + 32767, 0, 0 + ] + + expect(actualIndices).toEqual(expectedIndices); + expect(actualPositions).toEqual(expectedPositions); + }); +}); diff --git a/specs/data/Triangle/TriangleMeshopt.bin b/specs/data/Triangle/TriangleMeshopt.bin new file mode 100644 index 00000000..f8f553f7 Binary files /dev/null and b/specs/data/Triangle/TriangleMeshopt.bin differ diff --git a/specs/data/Triangle/TriangleMeshopt.gltf b/specs/data/Triangle/TriangleMeshopt.gltf new file mode 100644 index 00000000..92e71fb0 --- /dev/null +++ b/specs/data/Triangle/TriangleMeshopt.gltf @@ -0,0 +1,127 @@ +{ + "asset": { + "generator": "glTF-Transform v3.10.1", + "version": "2.0" + }, + "accessors": [ + { + "type": "SCALAR", + "componentType": 5123, + "count": 3, + "normalized": false, + "byteOffset": 0, + "bufferView": 0 + }, + { + "type": "VEC3", + "componentType": 5122, + "count": 3, + "max": [ + 32767, + 32767, + 0 + ], + "min": [ + -32767, + -32767, + 0 + ], + "normalized": true, + "byteOffset": 0, + "bufferView": 1 + } + ], + "bufferViews": [ + { + "buffer": 1, + "byteOffset": 0, + "byteLength": 6, + "target": 34963, + "extensions": { + "EXT_meshopt_compression": { + "buffer": 0, + "byteOffset": 0, + "byteLength": 18, + "mode": "TRIANGLES", + "byteStride": 2, + "count": 3 + } + } + }, + { + "buffer": 1, + "byteOffset": 8, + "byteLength": 24, + "target": 34962, + "byteStride": 8, + "extensions": { + "EXT_meshopt_compression": { + "buffer": 0, + "byteOffset": 20, + "byteLength": 60, + "mode": "ATTRIBUTES", + "byteStride": 8, + "count": 3 + } + } + } + ], + "buffers": [ + { + "uri": "TriangleMeshopt.bin", + "byteLength": 80 + }, + { + "byteLength": 32, + "extensions": { + "EXT_meshopt_compression": { + "fallback": true + } + } + } + ], + "meshes": [ + { + "primitives": [ + { + "attributes": { + "POSITION": 1 + }, + "mode": 4, + "indices": 0 + } + ] + } + ], + "nodes": [ + { + "translation": [ + 0.5, + 0.5, + 0 + ], + "scale": [ + 0.5, + 0.5, + 0.5 + ], + "mesh": 0 + } + ], + "scenes": [ + { + "nodes": [ + 0 + ] + } + ], + "scene": 0, + "extensionsUsed": [ + "KHR_mesh_quantization", + "EXT_meshopt_compression" + ], + "extensionsRequired": [ + "KHR_mesh_quantization", + "EXT_meshopt_compression" + ] +} \ No newline at end of file diff --git a/specs/data/Triangle/TriangleMeshoptNoByteOffset.gltf b/specs/data/Triangle/TriangleMeshoptNoByteOffset.gltf new file mode 100644 index 00000000..b043dc4d --- /dev/null +++ b/specs/data/Triangle/TriangleMeshoptNoByteOffset.gltf @@ -0,0 +1,123 @@ +{ + "asset": { + "generator": "glTF-Transform v3.10.1", + "version": "2.0" + }, + "accessors": [ + { + "type": "SCALAR", + "componentType": 5123, + "count": 3, + "normalized": false, + "bufferView": 0 + }, + { + "type": "VEC3", + "componentType": 5122, + "count": 3, + "max": [ + 32767, + 32767, + 0 + ], + "min": [ + -32767, + -32767, + 0 + ], + "normalized": true, + "bufferView": 1 + } + ], + "bufferViews": [ + { + "buffer": 1, + "byteLength": 6, + "target": 34963, + "extensions": { + "EXT_meshopt_compression": { + "buffer": 0, + "byteLength": 18, + "mode": "TRIANGLES", + "byteStride": 2, + "count": 3 + } + } + }, + { + "buffer": 1, + "byteOffset": 8, + "byteLength": 24, + "target": 34962, + "byteStride": 8, + "extensions": { + "EXT_meshopt_compression": { + "buffer": 0, + "byteOffset": 20, + "byteLength": 60, + "mode": "ATTRIBUTES", + "byteStride": 8, + "count": 3 + } + } + } + ], + "buffers": [ + { + "uri": "TriangleMeshopt.bin", + "byteLength": 80 + }, + { + "byteLength": 32, + "extensions": { + "EXT_meshopt_compression": { + "fallback": true + } + } + } + ], + "meshes": [ + { + "primitives": [ + { + "attributes": { + "POSITION": 1 + }, + "mode": 4, + "indices": 0 + } + ] + } + ], + "nodes": [ + { + "translation": [ + 0.5, + 0.5, + 0 + ], + "scale": [ + 0.5, + 0.5, + 0.5 + ], + "mesh": 0 + } + ], + "scenes": [ + { + "nodes": [ + 0 + ] + } + ], + "scene": 0, + "extensionsUsed": [ + "KHR_mesh_quantization", + "EXT_meshopt_compression" + ], + "extensionsRequired": [ + "KHR_mesh_quantization", + "EXT_meshopt_compression" + ] +} \ No newline at end of file diff --git a/src/base/binary/BinaryBufferDataResolver.ts b/src/base/binary/BinaryBufferDataResolver.ts index 0ac2e6aa..92dd6758 100644 --- a/src/base/binary/BinaryBufferDataResolver.ts +++ b/src/base/binary/BinaryBufferDataResolver.ts @@ -6,6 +6,10 @@ import { BinaryBufferData } from "./BinaryBufferData"; import { BinaryBufferStructure } from "./BinaryBufferStructure"; import { BinaryDataError } from "./BinaryDataError"; +import { BufferView } from "../../structure"; + +import { MeshoptDecoder } from "meshoptimizer"; + /** * A class for resolving binary buffer data. * @@ -21,9 +25,12 @@ export class BinaryBufferDataResolver { * and returns a `BinaryBufferData` that contains the actual * binary buffer data. * + * If any of the given buffer views uses the `EXT_meshopt_compression` + * glTF extension, then this data will be decompressed. + * * The given `binaryBuffer` will be used as the buffer data - * for any buffer that does not have a URI (intended for - * binary subtree files)) + * for any buffer that does not have a URI and that is not + * a meshopt fallback buffer (intended for binary subtree files) * * @param binaryBufferStructure - The `BinaryBufferStructure` * @param binaryBuffer - The optional binary buffer @@ -41,23 +48,14 @@ export class BinaryBufferDataResolver { const buffersData: Buffer[] = []; const buffers = binaryBufferStructure.buffers; if (buffers) { - for (const buffer of buffers) { - if (!defined(buffer.uri)) { - if (!binaryBuffer) { - throw new BinaryDataError( - "Expected a binary buffer, but got undefined" - ); - } - buffersData.push(binaryBuffer); - } else { - //console.log("Obtaining buffer data from " + buffer.uri); - const bufferData = await resourceResolver.resolveData(buffer.uri); - if (!bufferData) { - const message = `Could not resolve buffer ${buffer.uri}`; - throw new BinaryDataError(message); - } - buffersData.push(bufferData); - } + for (let b = 0; b < buffers.length; b++) { + const bufferData = await BinaryBufferDataResolver.resolveBufferData( + binaryBufferStructure, + binaryBuffer, + b, + resourceResolver + ); + buffersData.push(bufferData); } } @@ -67,18 +65,227 @@ export class BinaryBufferDataResolver { const bufferViews = binaryBufferStructure.bufferViews; if (bufferViews) { for (const bufferView of bufferViews) { - const bufferData = buffersData[bufferView.buffer]; - const start = bufferView.byteOffset; - const end = start + bufferView.byteLength; - const bufferViewData = bufferData.subarray(start, end); + const bufferViewData = + await BinaryBufferDataResolver.resolveBufferViewData( + buffersData, + bufferView + ); bufferViewsData.push(bufferViewData); } } - const binarybBufferData: BinaryBufferData = { + const binaryBufferData: BinaryBufferData = { buffersData: buffersData, bufferViewsData: bufferViewsData, }; - return binarybBufferData; + return binaryBufferData; + } + + /** + * Resolve the data for the specified buffer from the given structure. + * + * This will... + * - resolve the buffer URI if it is present + * - return a zero-filled buffer if the specified buffer is a + * meshopt fallback buffer + * - return the given binary buffer otherwise + * + * @param binaryBufferStructure - Tbe `BinaryBufferStructure` + * @param binaryBuffer - The optional binary buffer + * @param bufferIndex - The index of the buffer to resolve + * @param resourceResolver - The `ResourceResolver` that will be + * used for resolving buffer URIs + * @returns The `Buffer` containing the data for the specified + * buffer. + * @throws BinaryDataError If the buffer data cannot be resolved + */ + private static async resolveBufferData( + binaryBufferStructure: BinaryBufferStructure, + binaryBuffer: Buffer | undefined, + bufferIndex: number, + resourceResolver: ResourceResolver + ): Promise { + const buffer = binaryBufferStructure.buffers[bufferIndex]; + + // If the buffer defines a URI, it will be resolved + if (defined(buffer.uri)) { + //console.log("Obtaining buffer data from " + buffer.uri); + const bufferData = await resourceResolver.resolveData(buffer.uri); + if (!bufferData) { + const message = `Could not resolve buffer ${buffer.uri}`; + throw new BinaryDataError(message); + } + return bufferData; + } + + // If the buffer does not define a URI, then it might + // be a "fallback" buffer from `EXT_meshopt_compression`. + // In this case, a dummy buffer with the appropriate + // size will be returned. Later, when the buffer views + // are processed, slices of this buffer will be filled + // with the data that results from uncompressing the + // meshopt-compressed data + const isFallbackBuffer = BinaryBufferDataResolver.isMeshoptFallbackBuffer( + binaryBufferStructure, + bufferIndex + ); + if (isFallbackBuffer) { + const fallbackBuffer = Buffer.alloc(buffer.byteLength); + return fallbackBuffer; + } + + // When the buffer does not have a URI and is not a fallback + // buffer, then it must be the binary buffer + if (!binaryBuffer) { + throw new BinaryDataError("Expected a binary buffer, but got undefined"); + } + return binaryBuffer; + } + + /** + * Resolve the data for the given buffer view against the given + * buffers data. + * + * This will slice out the data for the buffer view from the + * data that corresponds to its `buffer`. + * + * If the given buffer view uses the `EXT_meshopt_compression` + * glTF extension, then its data will be uncompressed into + * the appropriate slice of the uncompressed buffer, and this + * slice will be returned. + * + * @param buffersData - The buffers data + * @param bufferView - The `BufferView` object + * @returns The `Buffer` for the buffer view + */ + private static async resolveBufferViewData( + buffersData: Buffer[], + bufferView: BufferView + ): Promise { + // If the buffer view defines the `EXT_meshopt_compression` + // extension, then decode the meshopt buffer + const extensions = bufferView.extensions ?? {}; + const meshopt = extensions["EXT_meshopt_compression"]; + if (meshopt) { + const compressedBufferData = buffersData[meshopt.buffer]; + const uncompressedBufferData = buffersData[bufferView.buffer]; + const bufferViewData = await BinaryBufferDataResolver.decodeMeshopt( + compressedBufferData, + uncompressedBufferData, + bufferView, + meshopt + ); + return bufferViewData; + } + + // Otherwise, just slice out the required part of + // the buffer that the buffer view refers to + const bufferData = buffersData[bufferView.buffer]; + const start = bufferView.byteOffset ?? 0; + const end = start + bufferView.byteLength; + const bufferViewData = bufferData.subarray(start, end); + return bufferViewData; + } + + /** + * Decode the meshopt-compressed data for a buffer view that contained + * the given `EXT_meshopt_compression` extension object. + * + * The returned uncompressed buffer view will be a slice/subarray + * of the given uncompressed buffer. + * + * @param compressedBufferData - The buffer containing the compressed + * data that the extension object refers to + * @param uncompressedBufferData - The buffer for the uncompressed + * data. This may be a "fallback" buffer that was created when + * initializing the `buffersData` array. + * @param bufferView - The buffer view that contained the extension + * @param meshopt - The extension object + * @returns The decoded buffer (view) data + */ + private static async decodeMeshopt( + compressedBufferData: Buffer, + uncompressedBufferData: Buffer, + bufferView: BufferView, + meshopt: any + ): Promise { + // Slice out the compressed buffer view data from the compressed + // buffer, based on the meshopt byte offset and length + const meshoptByteOffset = meshopt.byteOffset ?? 0; + const meshoptByteLength = meshopt.byteLength; + const compressedBufferViewData = compressedBufferData.subarray( + meshoptByteOffset, + meshoptByteOffset + meshoptByteLength + ); + + // Slice out the data for the uncompressed buffer view from the + // uncompressed buffer, based on the buffer view offset and length, + // The buffer is a "fallback buffer" that is initially zero-filled. + const meshoptCount = meshopt.count; + const meshoptByteStride = meshopt.byteStride; + const uncompressedByteLength = meshoptByteStride * meshoptCount; + const bufferViewByteOffset = bufferView.byteOffset ?? 0; + const uncompressedBufferViewData = uncompressedBufferData.subarray( + bufferViewByteOffset, + bufferViewByteOffset + uncompressedByteLength + ); + + // Use the meshopt decoder to fill the uncompressed buffer view + // data from the compressed buffer view data + await MeshoptDecoder.ready; + const meshoptMode = meshopt.mode; + const meshoptFilter = meshopt.filter ?? "NONE"; + MeshoptDecoder.decodeGltfBuffer( + uncompressedBufferViewData, + meshoptCount, + meshoptByteStride, + compressedBufferViewData, + meshoptMode, + meshoptFilter + ); + + return Buffer.from(uncompressedBufferViewData); + } + + /** + * Returns whether the given buffer is a "fallback" buffer for the + * `EXT_meshopt_compression` extension. + * + * This is the case when it is either itself marked with + * an `EXT_meshopt_compression` extension object that + * defines `fallback: true`, or when it is referred to by + * a buffer view that defines the `EXT_meshopt_compression` + * extension. + * + * @param binaryBufferStructure - The BinaryBufferStructure + * @param bufferIndex - The buffer index + * @returns Whether the given buffer is a fallback buffer + */ + private static isMeshoptFallbackBuffer( + binaryBufferStructure: BinaryBufferStructure, + bufferIndex: number + ) { + const buffers = binaryBufferStructure.buffers; + const buffer = buffers[bufferIndex]; + if (buffer.extensions) { + const meshopt = buffer.extensions["EXT_meshopt_compression"]; + if (meshopt) { + if (meshopt["fallback"] === true) { + return true; + } + } + } + const bufferViews = binaryBufferStructure.bufferViews; + for (const bufferView of bufferViews) { + if (bufferView.extensions) { + const meshopt = bufferView.extensions["EXT_meshopt_compression"]; + if (meshopt) { + if (bufferView.buffer === bufferIndex) { + return true; + } + } + } + } + return false; } }