| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141 |
- // 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 } }
- }
|