instance-safe-primitives.mjs 9.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. // Instance only genuinely shared primitives whose normals are invariant under every
  2. // instance's scale (within 0.08 degrees). Sloped/bevel surfaces keep ordinary node TRS.
  3. // Retained unused POSITION vertices conservatively enclose all rotated/scaled copies
  4. // in Cesium's translation-only command bound; they are NEVER referenced by triangles.
  5. import assert from 'node:assert/strict'
  6. import draco3d from 'draco3d'
  7. import {parseGlb,packGlb,meshSignature} from './instance-repeated-meshes.mjs'
  8. const decoder=await draco3d.createDecoderModule({})
  9. export function decodePrimitive(json,bin,p){
  10. const e=p.extensions?.KHR_draco_mesh_compression
  11. if(!e){
  12. function values(index){const a=json.accessors[index],v=json.bufferViews[a.bufferView],n={VEC2:2,VEC3:3,SCALAR:1}[a.type],size={5126:4,5125:4,5123:2}[a.componentType],start=(v.byteOffset||0)+(a.byteOffset||0),stride=v.byteStride||size*n
  13. const read=a.componentType===5126?'readFloatLE':a.componentType===5125?'readUInt32LE':'readUInt16LE'
  14. return Array.from({length:a.count*n},(_,i)=>bin[read](start+Math.floor(i/n)*stride+(i%n)*size))}
  15. return{positions:values(p.attributes.POSITION),normals:values(p.attributes.NORMAL),indices:values(p.indices)}
  16. }
  17. const v=json.bufferViews[e.bufferView],b=bin.subarray(v.byteOffset||0,(v.byteOffset||0)+v.byteLength)
  18. const dec=new decoder.Decoder(),db=new decoder.DecoderBuffer(),m=new decoder.Mesh();let status
  19. try{db.Init(new Int8Array(b.buffer,b.byteOffset,b.byteLength),b.byteLength);status=dec.DecodeBufferToMesh(db,m);assert.ok(status.ok(),status.error_msg())
  20. function attr(semantic){const a=dec.GetAttributeByUniqueId(m,e.attributes[semantic]),size=m.num_points()*12,ptr=decoder._malloc(size)
  21. try{assert.ok(dec.GetAttributeDataArrayForAllPoints(m,a,decoder.DT_FLOAT32,size,ptr));return [...new Float32Array(decoder.HEAPF32.buffer,ptr,m.num_points()*3)]}finally{decoder._free(ptr)}}
  22. const size=m.num_faces()*12,ptr=decoder._malloc(size);let indices
  23. try{assert.ok(dec.GetTrianglesUInt32Array(m,size,ptr));indices=[...new Uint32Array(decoder.HEAPU32.buffer,ptr,m.num_faces()*3)]}finally{decoder._free(ptr)}
  24. return{positions:attr('POSITION'),normals:attr('NORMAL'),indices}
  25. }finally{if(status)decoder.destroy(status);decoder.destroy(m);decoder.destroy(db);decoder.destroy(dec)}
  26. }
  27. export function rotate(v,q){const [x,y,z,w]=q,u=[y*v[2]-z*v[1],z*v[0]-x*v[2],x*v[1]-y*v[0]],uu=[y*u[2]-z*u[1],z*u[0]-x*u[2],x*u[1]-y*u[0]];return v.map((a,i)=>a+2*(w*u[i]+uu[i]))}
  28. export function safeNormalDot(normals,scale){let min=1;for(let i=0;i<normals.length;i+=3){const n=normals.slice(i,i+3),a=n.map((v,j)=>v*scale[j]),b=n.map((v,j)=>v/scale[j]);const dot=a.reduce((s,v,j)=>s+v*b[j],0)/Math.hypot(...a)/Math.hypot(...b);min=Math.min(min,dot)}return min}
  29. export function instanceSafePrimitives(bytes){
  30. const {json,bin}=parseGlb(bytes)
  31. assert.equal(json.scenes.length,1);assert.ok(!json.animations?.length&&!json.skins?.length&&!json.images?.length)
  32. assert.deepEqual([...json.scenes[json.scene||0].nodes].sort((a,b)=>a-b),json.nodes.map((_,i)=>i))
  33. assert.ok(json.accessors.every(a=>!a.sparse));assert.ok((json.extensionsUsed||[]).every(e=>e==='KHR_draco_mesh_compression'))
  34. const sourceNodes=json.nodes,groups=new Map(),remaining=new Map(sourceNodes.map(n=>[n,[]]))
  35. let oldPrimitives=0,minNormalDot=1
  36. for(const node of sourceNodes){assert.ok(node.mesh!==undefined&&!node.matrix&&!node.children&&!node.weights&&!node.extensions)
  37. for(const p of json.meshes[node.mesh].primitives){assert.ok(!p.targets&&(p.mode??4)===4);assert.ok(Object.keys(p.attributes).every(k=>['POSITION','NORMAL'].includes(k)));const key=meshSignature(json,bin,{primitives:[p]});if(!groups.has(key))groups.set(key,[]);groups.get(key).push({node,p});oldPrimitives++}}
  38. const chunks=[bin],nodes=[],meshes=[],records=[];let length=bin.length
  39. function accessor(values,type,integer=false){const comp={SCALAR:1,VEC3:3,VEC4:4}[type],pad=(-length)&3;if(pad){chunks.push(Buffer.alloc(pad));length+=pad}const b=Buffer.alloc(values.length*4);values.forEach((v,i)=>{assert.ok(Number.isFinite(v));integer?b.writeUInt32LE(v,i*4):b.writeFloatLE(v,i*4)})
  40. const vi=json.bufferViews.length;json.bufferViews.push({buffer:0,byteOffset:length,byteLength:b.length});chunks.push(b);length+=b.length
  41. const i=json.accessors.length,a={bufferView:vi,componentType:integer?5125:5126,type,count:values.length/comp};if(type==='VEC3'){a.min=[0,1,2].map(k=>Math.min(...values.filter((_,i)=>i%3===k)));a.max=[0,1,2].map(k=>Math.max(...values.filter((_,i)=>i%3===k)))}json.accessors.push(a);return i}
  42. for(const group of groups.values()){
  43. const geom=group.length>=2?decodePrimitive(json,bin,group[0].p):null
  44. const eligible=geom&&group.every(({node})=>{const s=node.scale||[1,1,1],q=node.rotation||[0,0,0,1];assert.ok(s.every(v=>v>0)&&Math.abs(Math.hypot(...q)-1)<1e-5);return safeNormalDot(geom.normals,s)>=0.999999})
  45. if(!eligible){for(const {node,p} of group)remaining.get(node).push(structuredClone(p));continue}
  46. let radius=0;for(const {node}of group){const s=node.scale||[1,1,1];for(let i=0;i<geom.positions.length;i+=3)radius=Math.max(radius,Math.hypot(...geom.positions.slice(i,i+3).map((v,a)=>v*s[a])))}radius+=0.005
  47. const originalCount=geom.positions.length/3,positions=[...geom.positions],normals=[...geom.normals]
  48. for(const x of [-radius,radius])for(const y of [-radius,radius])for(const z of [-radius,radius]){positions.push(x,y,z);normals.push(0,1,0)}
  49. const p={attributes:{POSITION:accessor(positions,'VEC3'),NORMAL:accessor(normals,'VEC3')},indices:accessor(geom.indices,'SCALAR',true),material:group[0].p.material}
  50. meshes.push({primitives:[p]})
  51. const translations=group.flatMap(g=>g.node.translation||[0,0,0]),rotations=group.flatMap(g=>g.node.rotation||[0,0,0,1]),scales=group.flatMap(g=>g.node.scale||[1,1,1])
  52. nodes.push({mesh:meshes.length-1,extensions:{EXT_mesh_gpu_instancing:{attributes:{TRANSLATION:accessor(translations,'VEC3'),ROTATION:accessor(rotations,'VEC4'),SCALE:accessor(scales,'VEC3')}}},extras:{instanceNames:group.map(g=>g.node.name),sourceVertexCount:originalCount,boundPaddingVertices:8}})
  53. const dot=Math.min(...group.map(g=>safeNormalDot(geom.normals,g.node.scale||[1,1,1])));minNormalDot=Math.min(minNormalDot,dot)
  54. records.push({node:nodes.length-1,count:group.length,names:group.map(g=>g.node.name),triangles:geom.indices.length/3,minNormalDot:dot})
  55. }
  56. for(const node of sourceNodes){const ps=remaining.get(node);if(!ps.length)continue;meshes.push({primitives:ps});nodes.push({...node,mesh:meshes.length-1})}
  57. json.nodes=nodes;json.meshes=meshes;json.scenes[json.scene||0].nodes=nodes.map((_,i)=>i)
  58. const aid=new Set(),vid=new Set(),mid=new Set()
  59. for(const mesh of meshes)for(const p of mesh.primitives){Object.values(p.attributes).forEach(i=>aid.add(i));aid.add(p.indices);mid.add(p.material);if(p.extensions?.KHR_draco_mesh_compression)vid.add(p.extensions.KHR_draco_mesh_compression.bufferView)}
  60. for(const n of nodes)Object.values(n.extensions?.EXT_mesh_gpu_instancing?.attributes||{}).forEach(i=>aid.add(i))
  61. for(const i of aid)if(json.accessors[i].bufferView!==undefined)vid.add(json.accessors[i].bufferView)
  62. const aa=[...aid],am=new Map(aa.map((v,i)=>[v,i])),vv=[...vid],vm=new Map(),all=Buffer.concat(chunks),views=[],payload=[];let off=0
  63. for(const i of vv){const v=json.bufferViews[i],pad=(-off)&3;if(pad){payload.push(Buffer.alloc(pad));off+=pad}vm.set(i,views.length);views.push({...v,byteOffset:off});payload.push(all.subarray(v.byteOffset||0,(v.byteOffset||0)+v.byteLength));off+=v.byteLength}
  64. const mm=[...mid],matm=new Map(mm.map((v,i)=>[v,i]));json.materials=mm.map(i=>json.materials[i]);json.accessors=aa.map(i=>json.accessors[i]);json.accessors.forEach(a=>{if(a.bufferView!==undefined)a.bufferView=vm.get(a.bufferView)})
  65. for(const mesh of meshes)for(const p of mesh.primitives){p.attributes=Object.fromEntries(Object.entries(p.attributes).map(([k,i])=>[k,am.get(i)]));p.indices=am.get(p.indices);p.material=matm.get(p.material);if(p.extensions?.KHR_draco_mesh_compression)p.extensions.KHR_draco_mesh_compression.bufferView=vm.get(p.extensions.KHR_draco_mesh_compression.bufferView)}
  66. for(const n of nodes)if(n.extensions?.EXT_mesh_gpu_instancing)n.extensions.EXT_mesh_gpu_instancing.attributes=Object.fromEntries(Object.entries(n.extensions.EXT_mesh_gpu_instancing.attributes).map(([k,i])=>[k,am.get(i)]))
  67. json.bufferViews=views;json.buffers=[{byteLength:off}]
  68. if(records.length){json.extensionsUsed=[...new Set([...(json.extensionsUsed||[]),'EXT_mesh_gpu_instancing'])];json.extensionsRequired=[...new Set([...(json.extensionsRequired||[]),'EXT_mesh_gpu_instancing'])]}
  69. return{bytes:packGlb(json,Buffer.concat(payload)),report:{originalNodes:sourceNodes.length,outputNodes:nodes.length,oldPrimitives,outputPrimitives:nodes.reduce((s,n)=>s+meshes[n.mesh].primitives.length,0),instanceGroups:records.length,instancedPlacements:records.reduce((s,r)=>s+r.count,0),records,minNormalDot}}
  70. }