// Lossless grouping: only meshes with byte-identical primitives, attributes and material // dependencies become instances. Geometry is neither rescaled nor decimated here. import assert from 'node:assert/strict' import { createHash } from 'node:crypto' export function parseGlb(bytes) { assert.equal(bytes.toString('ascii', 0, 4), 'glTF') assert.equal(bytes.readUInt32LE(4), 2) assert.equal(bytes.readUInt32LE(8), bytes.length) const size = bytes.readUInt32LE(12), offset = 20 + size assert.equal(bytes.toString('ascii', 16, 20), 'JSON') assert.equal(bytes.toString('ascii', offset + 4, offset + 8), 'BIN\0') return { json: JSON.parse(bytes.toString('utf8', 20, offset)), bin: bytes.subarray(offset + 8, offset + 8 + bytes.readUInt32LE(offset)) } } export function packGlb(json, bin) { let text = Buffer.from(JSON.stringify(json)) text = Buffer.concat([text, Buffer.alloc((-text.length) & 3, 0x20)]) bin = Buffer.concat([bin, Buffer.alloc((-bin.length) & 3)]) const header = Buffer.alloc(20), tail = Buffer.alloc(8) header.write('glTF'); header.writeUInt32LE(2, 4); header.writeUInt32LE(28 + text.length + bin.length, 8) header.writeUInt32LE(text.length, 12); header.write('JSON', 16) tail.writeUInt32LE(bin.length); tail.write('BIN\0', 4) return Buffer.concat([header, text, tail, bin]) } export function accessorValues(json, bin, index) { const a = json.accessors[index], view = json.bufferViews[a.bufferView] assert.ok(!a.sparse && !a.normalized) const components = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }[a.type] assert.equal(a.componentType, 5126) const start = (view.byteOffset || 0) + (a.byteOffset || 0), stride = view.byteStride || components * 4 return Array.from({ length: a.count }, (_, i) => Array.from({ length: components }, (_, c) => bin.readFloatLE(start + i * stride + c * 4))) } export function meshSignature(json, bin, mesh) { function viewData(index) { const view = json.bufferViews[index], bytes = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength) return createHash('sha256').update(bytes).digest('hex') } function attribute(index) { const a = structuredClone(json.accessors[index]); delete a.name if (a.bufferView !== undefined) { a.bufferView = viewData(a.bufferView) } return a } return JSON.stringify(mesh.primitives.map(p => ({ mode: p.mode ?? 4, material: json.materials[p.material], attributes: Object.fromEntries(Object.entries(p.attributes).map(([k, i]) => [k, attribute(i)])), indices: attribute(p.indices), extensions: p.extensions?.KHR_draco_mesh_compression ? { attributes: p.extensions.KHR_draco_mesh_compression.attributes, payload: viewData(p.extensions.KHR_draco_mesh_compression.bufferView), } : undefined, }))) } export function instanceRepeatedMeshes(bytes) { const { json, bin } = parseGlb(bytes) assert.ok(!json.skins?.length && !json.animations?.length) assert.equal(json.scenes.length,1,'only one static scene supported') assert.ok(json.accessors.every(a=>!a.sparse),'sparse attributes unsupported') assert.ok((json.extensionsUsed||[]).every(e=>e==='KHR_draco_mesh_compression'),'unsupported extension') for(const mesh of json.meshes){ assert.ok(!mesh.weights,'static morph weights unsupported') for(const p of mesh.primitives){ assert.ok(!p.targets,'morph targets unsupported') assert.ok(p.indices!==undefined,'indexed meshes required') assert.ok(Object.keys(p.extensions||{}).every(e=>e==='KHR_draco_mesh_compression')) } } const roots = json.scenes[json.scene || 0].nodes assert.deepEqual([...roots].sort((a,b) => a-b), json.nodes.map((_, i) => i), 'all source nodes must be scene roots') const groups = new Map(), originalNodes = json.nodes.length for (const node of json.nodes) { assert.ok(node.mesh !== undefined && !node.children?.length && !node.matrix && !node.extensions && !node.weights) const key = meshSignature(json, bin, json.meshes[node.mesh]) if (!groups.has(key)) groups.set(key, []) groups.get(key).push(node) } const chunks = [bin], nodes = [], meshes = [], records = [] let length = bin.length function append(values, components) { const padding = (-length) & 3 if (padding) { chunks.push(Buffer.alloc(padding)); length += padding } const data = Buffer.alloc(values.length * 4) values.forEach((v, i) => { assert.ok(Number.isFinite(v)); data.writeFloatLE(v, i * 4) }) const view = json.bufferViews.length json.bufferViews.push({ buffer: 0, byteOffset: length, byteLength: data.length }) chunks.push(data); length += data.length const accessor = json.accessors.length json.accessors.push({ bufferView: view, componentType: 5126, type: components === 4 ? 'VEC4' : 'VEC3', count: values.length / components, ...(components === 3 ? { min: [0,1,2].map(a => Math.min(...values.filter((_,i)=>i%3===a))), max: [0,1,2].map(a=>Math.max(...values.filter((_,i)=>i%3===a))) } : {}) }) return accessor } for (const group of groups.values()) { const meshIndex = meshes.length meshes.push(json.meshes[group[0].mesh]) if (group.length === 1) { nodes.push({ ...group[0], mesh: meshIndex }); continue } const trs = { TRANSLATION: [], ROTATION: [], SCALE: [] } for (const node of group) { const t = node.translation || [0,0,0], r = node.rotation || [0,0,0,1], s = node.scale || [1,1,1] assert.ok(s.every(v => Math.abs(v-1)<1e-7), 'bake nonuniform scale before instancing') assert.ok(r.slice(0,3).every(v=>Math.abs(v)<1e-7)&&Math.abs(r[3]-1)<1e-7, 'bake rotation before instancing') trs.TRANSLATION.push(...t); trs.ROTATION.push(...r); trs.SCALE.push(...s) } const attributes = { TRANSLATION: append(trs.TRANSLATION, 3) } nodes.push({ mesh: meshIndex, name: 'Instances_' + meshIndex, extensions: { EXT_mesh_gpu_instancing: { attributes } }, extras: { instanceNames: group.map(n => n.name), instanceExtras: group.map(n => n.extras || {}) } }) records.push({ node: nodes.length - 1, count: group.length, names: group.map(n => n.name) }) } json.nodes = nodes; json.meshes = meshes; json.scenes[json.scene || 0].nodes = nodes.map((_,i)=>i) if (records.length) { json.extensionsUsed = [...new Set([...(json.extensionsUsed || []), 'EXT_mesh_gpu_instancing'])] json.extensionsRequired = [...new Set([...(json.extensionsRequired || []), 'EXT_mesh_gpu_instancing'])] } // Prune mesh/accessor/view closure, including instance attributes; discard duplicate compressed payloads. const accessorIds = new Set(), viewIds = new Set(), materialIds = new Set() for (const mesh of meshes) for (const p of mesh.primitives) { Object.values(p.attributes).forEach(i => accessorIds.add(i)); accessorIds.add(p.indices); materialIds.add(p.material) if (p.extensions?.KHR_draco_mesh_compression) viewIds.add(p.extensions.KHR_draco_mesh_compression.bufferView) } for (const node of nodes) Object.values(node.extensions?.EXT_mesh_gpu_instancing?.attributes || {}).forEach(i => accessorIds.add(i)) for (const i of accessorIds) if (json.accessors[i].bufferView !== undefined) viewIds.add(json.accessors[i].bufferView) assert.ok(!json.images?.length && !json.textures?.length, 'city asset conversion only supports untextured palette') const accessors = [...accessorIds].map(i => json.accessors[i]), accessorMap = new Map([...accessorIds].map((i,n)=>[i,n])) const all = Buffer.concat(chunks), views = [], payloads = []; let offset = 0 const viewMap = new Map() for (const i of viewIds) { const v = json.bufferViews[i], padding = (-offset)&3 if (padding) { payloads.push(Buffer.alloc(padding)); offset += padding } viewMap.set(i, views.length) views.push({ ...v, byteOffset: offset }); payloads.push(all.subarray(v.byteOffset || 0, (v.byteOffset || 0)+v.byteLength)); offset += v.byteLength } for (const a of accessors) if (a.bufferView !== undefined) a.bufferView = viewMap.get(a.bufferView) const materials = [...materialIds].map(i => json.materials[i]), materialMap = new Map([...materialIds].map((i,n)=>[i,n])) for (const mesh of meshes) for (const p of mesh.primitives) { p.attributes = Object.fromEntries(Object.entries(p.attributes).map(([k,i])=>[k,accessorMap.get(i)])) p.indices = accessorMap.get(p.indices); p.material = materialMap.get(p.material) if (p.extensions?.KHR_draco_mesh_compression) p.extensions.KHR_draco_mesh_compression.bufferView = viewMap.get(p.extensions.KHR_draco_mesh_compression.bufferView) } for (const node of nodes) if (node.extensions?.EXT_mesh_gpu_instancing) node.extensions.EXT_mesh_gpu_instancing.attributes = Object.fromEntries(Object.entries(node.extensions.EXT_mesh_gpu_instancing.attributes).map(([k,i])=>[k,accessorMap.get(i)])) json.accessors = accessors; json.bufferViews = views; json.materials = materials; json.buffers = [{byteLength:offset}] return { bytes: packGlb(json, Buffer.concat(payloads)), report: { originalNodes, outputNodes: nodes.length, instanceGroups: records.length, instancedPlacements: records.reduce((s,g)=>s+g.count,0), records } } }