import { open } from "node:fs/promises"; const GLB_MAGIC = 0x46546c67; const JSON_CHUNK_TYPE = 0x4e4f534a; const GPU_INSTANCING_EXTENSION = "EXT_mesh_gpu_instancing"; function primitiveCount(json, meshIndex) { return json.meshes?.[meshIndex]?.primitives?.length ?? 0; } function gpuInstanceCount(json, node) { const attributes = node.extensions?.[GPU_INSTANCING_EXTENSION]?.attributes; if (!attributes || typeof attributes !== "object") return 0; return Math.max( 0, ...Object.values(attributes).map((accessorIndex) => ( json.accessors?.[accessorIndex]?.count ?? 0 )), ); } export function analyzeGlbJson(json) { const repeatedMeshes = new Map(); let estimatedDrawCalls = 0; let expandedPrimitiveInstances = 0; let meshNodes = 0; let gpuInstanceBatches = 0; let gpuInstances = 0; for (const node of json.nodes ?? []) { if (!Number.isInteger(node.mesh)) continue; meshNodes += 1; const primitives = primitiveCount(json, node.mesh); const instances = gpuInstanceCount(json, node); estimatedDrawCalls += primitives; expandedPrimitiveInstances += primitives * Math.max(1, instances); if (instances > 0) { gpuInstanceBatches += 1; gpuInstances += instances; continue; } repeatedMeshes.set(node.mesh, (repeatedMeshes.get(node.mesh) ?? 0) + 1); } let eligibleRepeatedMeshes = 0; let eligibleRepeatedNodes = 0; let unbatchedDrawCallSavings = 0; for (const [meshIndex, count] of repeatedMeshes) { if (count < 3) continue; eligibleRepeatedMeshes += 1; eligibleRepeatedNodes += count; unbatchedDrawCallSavings += (count - 1) * primitiveCount(json, meshIndex); } const materials = json.materials ?? []; return { nodes: json.nodes?.length ?? 0, meshNodes, meshes: json.meshes?.length ?? 0, materials: materials.length, textures: json.textures?.length ?? 0, doubleSidedMaterials: materials.filter((material) => material.doubleSided === true).length, blendedMaterials: materials.filter((material) => material.alphaMode === "BLEND").length, estimatedDrawCalls, expandedPrimitiveInstances, gpuInstanceBatches, gpuInstances, eligibleRepeatedMeshes, eligibleRepeatedNodes, unbatchedDrawCallSavings, usesGpuInstancing: (json.extensionsUsed ?? []).includes(GPU_INSTANCING_EXTENSION), }; } export async function readGlbJson(file) { const handle = await open(file, "r"); try { const header = Buffer.alloc(20); const { bytesRead } = await handle.read(header, 0, header.length, 0); if (bytesRead !== header.length || header.readUInt32LE(0) !== GLB_MAGIC || header.readUInt32LE(4) !== 2 || header.readUInt32LE(16) !== JSON_CHUNK_TYPE) { throw new Error(`${file} is not a supported GLB 2.0 file.`); } const jsonLength = header.readUInt32LE(12); const jsonBuffer = Buffer.alloc(jsonLength); const jsonRead = await handle.read(jsonBuffer, 0, jsonLength, 20); if (jsonRead.bytesRead !== jsonLength) throw new Error(`${file} has a truncated JSON chunk.`); return JSON.parse(jsonBuffer.toString("utf8")); } finally { await handle.close(); } } export async function analyzeGlbFile(file) { return analyzeGlbJson(await readGlbJson(file)); }