import { GltfBinInterface } from './GltfBin';
import { GltfJsonInterface } from './GltfJson';
import { EncodeArrayBufferToBase64 } from '@babylonjs/core/Misc/stringTools.js';
import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js';
import { Scene } from '@babylonjs/core/scene.js';
import { GLTFFileLoader } from '@babylonjs/loaders';
import '@babylonjs/loaders/glTF/2.0/glTFLoader.js';
import { buffer } from 'stream/consumers';
export interface GlbInterface {
arrayBuffer: ArrayBuffer;
bin: GltfBinInterface;
filename: string;
json: GltfJsonInterface;
loaded: boolean;
getBase64String(): string;
getBytes(): Uint8Array;
initFromGlbFile(file: File): void;
initFromGlbFilePath(filePath: string): void;
initFromGltfFiles(files: File[]): void;
initFromGltfFilePaths(filePaths: string[]): void;
}
// GLB is the binary version of glTF and this class is used to load and access that data
export default class Glb implements GlbInterface {
arrayBuffer = null as unknown as ArrayBuffer;
bin = null as unknown as GltfBinInterface;
filename = '';
json = null as unknown as GltfJsonInterface;
loaded = false;
// Returns the glb data as a data string for loading with Babylon.js
public getBase64String(): string {
return 'data:;base64,' + EncodeArrayBufferToBase64(this.arrayBuffer);
}
// Returns the generic ArrayBuffer as an unsigned byte array
public getBytes() {
return new Uint8Array(this.arrayBuffer);
}
// Loads a single .glb file that comes from the browser element
public async initFromGlbFile(file: File) {
if (!file.name.endsWith('.glb')) {
throw new Error('When only a single file is provided, it must be a .glb');
}
try {
this.arrayBuffer = await this.getBufferFromFileInput(file);
this.filename = file.name;
} catch (err) {
throw new Error('Unable to get buffer from file input');
}
await this.loadBinAndJson();
this.loaded = true;
}
// Loads a single .glb file that is on the filesystem (Node.js)
public async initFromGlbFilePath(filePath: string) {
if (!filePath.endsWith('.glb')) {
throw new Error('When only a single file is provided, it must be a .glb');
}
try {
// Need to import this way to compile webpack
// webpack.config.js also needs:
// config.resolve.fallback.fs = false
// config.resolve.fallback.path = false
const { promises } = await import('fs');
const { sep } = await import('path');
this.arrayBuffer = await promises.readFile(filePath);
this.filename = filePath.substring(filePath.lastIndexOf(sep) + 1);
} catch (err) {
throw new Error('Unable to get buffer from filepath');
}
await this.loadBinAndJson();
this.loaded = true;
}
// Loads a multi-file .gltf that comes from the browser element
public async initFromGltfFiles(files: File[]) {
let binAndImagesBufferSize = 0;
let binFile = null as unknown as File;
const bufferMap = new Map();
let gltfFile = null as unknown as File;
let imageFiles = [] as File[];
// Find files by extension
files.forEach(file => {
if (file.name.endsWith('.gltf')) {
this.filename = file.name;
gltfFile = file;
} else if (file.name.endsWith('.bin')) {
binFile = file;
} else {
imageFiles.push(file);
}
});
// Check that .gltf and .bin are provided
if (!binFile) {
throw new Error('No .bin file provided');
}
if (!gltfFile) {
throw new Error('No .gltf file provided');
}
// Load the json data from the .gltf
const gltfBuffer = new Uint8Array(await this.getBufferFromFileInput(gltfFile));
const dec = new TextDecoder();
const gltfJson = JSON.parse(dec.decode(gltfBuffer));
const originalBufferViewCount = gltfJson.bufferViews.length;
// Load the binary data from the .bin
const binBuffer = new Uint8Array(await this.getBufferFromFileInput(binFile));
binAndImagesBufferSize = this.alignedLength(binBuffer.byteLength);
// Load the binary data from all images and add to bufferView[]
let imageBuffers = [] as unknown as Uint8Array[];
for (let i = 0; i < imageFiles.length; i++) {
const imageFile = imageFiles[i];
// Note: this assumes that all files are in the same directory
imageBuffers.push(new Uint8Array(await this.getBufferFromFileInput(imageFile)));
// Map the bufferIndex to the uri, which is used to update gltfJson.images
bufferMap.set(imageFile.name, originalBufferViewCount + i);
gltfJson.bufferViews.push({
buffer: 0,
byteOffset: binAndImagesBufferSize,
byteLength: imageBuffers[i].byteLength,
});
binAndImagesBufferSize += this.alignedLength(imageBuffers[i].byteLength);
}
this.arrayBuffer = this.combineBuffersToGlb(binAndImagesBufferSize, bufferMap, binBuffer, gltfJson, imageBuffers);
this.loadBinAndJson();
this.loaded = true;
}
// Loads a multi-file .gltf that is on the filesystem (Node.js)
public async initFromGltfFilePaths(filePaths: string[]) {
// Need to import this way to compile webpack
// webpack.config.js also needs:
// config.resolve.fallback.fs = false
// config.resolve.fallback.path = false
const { promises } = await import('fs');
const { sep } = await import('path');
let binAndImagesBufferSize = 0;
let binFilePath = '';
const bufferMap = new Map();
let gltfFilePath = '';
let imageFilePaths = [] as string[];
// Find files by extension
filePaths.forEach(filePath => {
if (filePath.endsWith('.gltf')) {
gltfFilePath = filePath;
this.filename = filePath.substring(filePath.lastIndexOf(sep) + 1);
} else if (filePath.endsWith('.bin')) {
binFilePath = filePath;
} else {
imageFilePaths.push(filePath);
}
});
// Check that .gltf and .bin are provided
if (!binFilePath) {
throw new Error('No .bin file provided');
}
if (!gltfFilePath) {
throw new Error('No .gltf file provided');
}
// Load the json data from the .gltf
const gltfBuffer = await promises.readFile(gltfFilePath);
const gltfJson = JSON.parse(gltfBuffer.toString('utf-8'));
const originalBufferViewCount = gltfJson.bufferViews.length;
// Load the binary data from the .bin
const binBuffer = await promises.readFile(binFilePath);
binAndImagesBufferSize = this.alignedLength(binBuffer.length);
// Load the binary data from all images and add to bufferView[]
let imageBuffers = [] as unknown as Buffer[];
for (let i = 0; i < imageFilePaths.length; i++) {
const imageFilePath = imageFilePaths[i];
// Note: this assumes that all files are in the same directory
const imageFileName = imageFilePath.substring(imageFilePath.lastIndexOf(sep) + 1);
imageBuffers.push(await promises.readFile(imageFilePath));
// Map the bufferIndex to the uri, which is used to update gltfJson.images
bufferMap.set(imageFileName, originalBufferViewCount + i);
gltfJson.bufferViews.push({
buffer: 0,
byteOffset: binAndImagesBufferSize,
byteLength: imageBuffers[i].length,
});
binAndImagesBufferSize += this.alignedLength(imageBuffers[i].length);
}
this.arrayBuffer = this.combineBuffersToGlb(binAndImagesBufferSize, bufferMap, binBuffer, gltfJson, imageBuffers);
this.loadBinAndJson();
this.loaded = true;
}
///////////////////////
// PRIVATE FUNCTIONS //
///////////////////////
// Round the length up to the nearest 4 bytes (32 bits)
private alignedLength(initialLength: number): number {
if (initialLength == 0) {
return initialLength;
}
const alignValue = 4;
var modRemainder = initialLength % alignValue;
if (modRemainder === 0) {
return initialLength;
}
return initialLength + (alignValue - modRemainder);
}
// Get data loaded from a multi-file .gltf in the equivalent .glb format
private combineBuffersToGlb(
binAndImagesBufferSize: number,
bufferMap: Map,
binBuffer: Uint8Array,
gltfJson: GltfJsonInterface,
imageBuffers: Uint8Array[],
): ArrayBuffer {
/**
* Babylon.js does not have a way to load multiple files, so for
* multi-file .gltf + .bin + images, we'll convert to the .glb format
* The original .gltf has one buffer that references the external .bin
* We're going to remove the external uri reference and merge the
* binary data from the .bin and all the image files.
* The uris in the image data is replaced with a bufferView reference
* and the bufferViews need to be expanded to include the image data references
*/
// Note: new bufferViews for the images were added to the incoming gltfJson when loaded
// Update the buffer with the new size and remove the uri that was for the bin file
if (gltfJson.buffers.length !== 1) {
throw new Error('The gltf should have one buffer and it has ' + gltfJson.buffers.length);
}
gltfJson.buffers[0].byteLength = binAndImagesBufferSize;
delete gltfJson.buffers[0].uri;
// Replace the uri with a bufferView for all matched images
gltfJson.images.forEach(image => {
// Note: not checking if the uri is base64
const bufferIndex = bufferMap.get(image.uri);
if (bufferIndex) {
delete image.uri;
image.bufferView = bufferIndex;
// Note: mimeType should already be set
}
});
// reference: https://github.com/sbtron/makeglb/blob/master/index.html
const enc = new TextEncoder();
const jsonBuffer = enc.encode(JSON.stringify(gltfJson));
const jsonAlignedLength = this.alignedLength(jsonBuffer.length);
const totalSize =
12 + // file header: magic + version + length
8 + // json chunk header: json length + type
jsonAlignedLength +
8 + // bin chunk header: chunk length + type
binAndImagesBufferSize;
const arrayBuffer = new ArrayBuffer(totalSize);
const dataView = new DataView(arrayBuffer);
let bufferIndex = 0;
// Binary Magic
dataView.setUint32(bufferIndex, 0x46546c67, true);
bufferIndex += 4;
dataView.setUint32(bufferIndex, 2, true);
bufferIndex += 4;
dataView.setUint32(bufferIndex, totalSize, true);
bufferIndex += 4;
// JSON
dataView.setUint32(bufferIndex, jsonAlignedLength, true);
bufferIndex += 4;
dataView.setUint32(bufferIndex, 0x4e4f534a, true);
bufferIndex += 4;
for (let i = 0; i < jsonBuffer.length; i++) {
dataView.setUint8(bufferIndex, jsonBuffer[i]);
bufferIndex++;
}
let padding = jsonAlignedLength - jsonBuffer.length;
for (let i = 0; i < padding; i++) {
dataView.setUint8(bufferIndex, 0x20); // space
bufferIndex++;
}
// BIN (+images)
dataView.setUint32(bufferIndex, binAndImagesBufferSize, true);
bufferIndex += 4;
dataView.setUint32(bufferIndex, 0x004e4942, true);
bufferIndex += 4;
// .bin
for (let i = 0; i < binBuffer.length; i++) {
dataView.setUint8(bufferIndex, binBuffer[i]);
bufferIndex++;
}
// The bufferViews have byte offsets that are 32-bit aligned
// The bin and images write 8 bits at a time and may not take up
// all of the allocated space, so extra space at the end can be skipped
bufferIndex = this.alignedLength(bufferIndex);
// images
imageBuffers.forEach(imageBuffer => {
for (let i = 0; i < imageBuffer.length; i++) {
dataView.setUint32(bufferIndex, imageBuffer[i], true);
bufferIndex++;
}
bufferIndex = this.alignedLength(bufferIndex);
});
return arrayBuffer;
}
// Extract json and binary data from the arrayBuffer
private async loadBinAndJson(): Promise {
if (!this.arrayBuffer) {
throw new Error('The array buffer must be loaded before json and bin data can be extracted');
}
// Creating an empty scene for the purpose of this extraction
const engine = new NullEngine();
const scene = new Scene(engine);
return await new Promise((resolve, reject) => {
const fileLoader = new GLTFFileLoader();
fileLoader.loadFile(
scene,
this.getBase64String(),
data => {
this.json = data.json;
this.bin = data.bin;
resolve();
},
ev => {
// progress. nothing to do
},
true,
err => {
reject();
},
);
});
}
// Read a file from a web browser element
private async getBufferFromFileInput(file: File): Promise {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = function () {
if (reader.result) {
const buffer = reader.result as ArrayBuffer;
resolve(buffer);
} else {
reject();
}
};
reader.readAsArrayBuffer(file);
} catch (err) {
reject();
}
});
}
}