import fs from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {NodeIO, Document, getBounds} from '@gltf-transform/core'; import {ALL_EXTENSIONS} from '@gltf-transform/extensions'; import {prune, dedup, flatten, transformMesh, cloneDocument, mergeDocuments, simplify, compactPrimitive, unpartition} from '@gltf-transform/functions'; import {MeshoptSimplifier} from 'meshoptimizer'; import sharp from 'sharp'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../Content/Vegitations'); const manifest=JSON.parse(await fs.readFile(path.join(root,'sources.json'),'utf8')); const ids=[...new Set(manifest.files.filter(f=>f.path.endsWith('/source/model.gltf')).map(f=>f.path.split('/')[0]))]; const io=new NodeIO().registerExtensions(ALL_EXTENSIONS); await MeshoptSimplifier.ready; for(const id of ids) { const selected=process.argv.slice(2).filter(a=>!a.startsWith('--')); if(selected.length&&!selected.includes(id))continue; const out=path.join(root,id,'prepared');await fs.mkdir(out,{recursive:true}); const cards=JSON.parse(await fs.readFile(path.join(out,'cards.json'),'utf8').catch(()=>'null')); const sourceOnly=process.argv.includes('--source-only'); const stamp=JSON.stringify({version:5,cards:!!cards,files:manifest.files.filter(f=>f.path.startsWith(id+'/')).map(f=>f.md5)}); if(sourceOnly&&await fs.stat(path.join(root,id,'source/bake-source.glb')).catch(()=>null))continue; if(!sourceOnly&&await fs.readFile(path.join(out,'stamp.json'),'utf8').catch(()=>null)===stamp){console.log('Cached',id);continue;} console.log('Preparing',id); const doc=await io.read(path.join(root,id,'source/model.gltf')); await doc.transform(flatten()); const scene=doc.getRoot().getDefaultScene()??doc.getRoot().listScenes()[0]; const all=scene.listChildren(); const chosen=all.length<=3?all:[all[0],all[Math.floor(all.length/3)],all[Math.floor(all.length*2/3)]]; for(const node of all)if(!chosen.includes(node))node.dispose(); // Apply source transforms and give each variant a gravity-aligned, bottom-centred origin. for(let i=0;iv+(card.Right[k]*x+card.Up[k]*y)*card.Size)); norm.push(...card.Normal); uv.push(card.Uv[0]+(x+.5)*card.Uv[2],card.Uv[1]+(.5-y)*card.Uv[3]); } indices.push(index,index+1,index+2,index,index+2,index+3); } const buffer=doc.getRoot().listBuffers()[0]; const accessor=(type,array)=>doc.createAccessor().setType(type).setArray(array).setBuffer(buffer); mesh.addPrimitive(doc.createPrimitive().setAttribute('POSITION',accessor('VEC3',new Float32Array(pos))) .setAttribute('NORMAL',accessor('VEC3',new Float32Array(norm))).setAttribute('TEXCOORD_0',accessor('VEC2',new Float32Array(uv))) .setIndices(accessor('SCALAR',new Uint32Array(indices))).setMaterial(material)); } await doc.transform(prune()); const masks=manifest.files.filter(f=>f.path.startsWith(id+'/source/masks/')&&f.path.toLowerCase().includes('alpha')); for(const material of doc.getRoot().listMaterials()) { const name=material.getName().toLowerCase(); if(name==='baked_canopy')continue; const alpha=masks.find(f=>path.basename(f.path).toLowerCase()==='alpha.png'||name.includes(path.basename(f.path).toLowerCase().replace('_alpha.png',''))); const foliage=material.getAlphaMode()!=='OPAQUE'||/twig|leaves|grass|fern|shrub|weed/.test(name); material.setMetallicFactor(0).setDoubleSided(foliage); if(foliage&&alpha&&material.getBaseColorTexture()) { const texture=material.getBaseColorTexture(); const info=await sharp(texture.getImage()).metadata(); const a=await sharp(await fs.readFile(path.join(root,alpha.path))).resize(info.width,info.height).extractChannel(0).toBuffer(); const rgba=await sharp(texture.getImage()).removeAlpha().joinChannel(a).png().toBuffer(); const replacement=doc.createTexture(name+'_alpha').setImage(rgba).setMimeType('image/png'); material.setBaseColorTexture(replacement).setAlphaMode('MASK').setAlphaCutoff(.4); } } const tree=/tree|sapling/.test(id),budgets=tree?[12000,2000,350]:/shrub/.test(id)?[12000,3000,600]:[2500,800,200]; const merged=new Document();const stats=[]; for(let lod=0;lod<3;lod++) { const copy=cloneDocument(doc),sc=copy.getRoot().listScenes()[0];sc.setName('LOD'+lod); for(const node of sc.listChildren()) { const primitives=node.getMesh().listPrimitives(); const total=primitives.reduce((n,p)=>n+(p.getIndices()?.getCount()??p.getAttribute('POSITION').getCount())/3,0); const canopy=primitives.filter(p=>p.getMaterial().getName()==='baked_canopy').reduce((n,p)=>n+p.getIndices().getCount()/3,0); for(const p of primitives) { const count=p.getIndices()?.getCount()??p.getAttribute('POSITION').getCount(); const target=p.getMaterial().getName()==='baked_canopy'?count:Math.max(6,Math.floor((budgets[lod]-canopy)*count/Math.max(1,total-canopy)/3)*3); if(count<=target)continue; // The library performs topology/attribute-preserving reduction first. // simplify() acts on the whole document below; retain the target per primitive for a bounded fallback. p.setExtras({...p.getExtras(),target}); } } let original=0;for(const m of copy.getRoot().listMeshes())for(const p of m.listPrimitives())original+=p.getIndices().getCount()/3; const retained=[]; for(const mesh of copy.getRoot().listMeshes())for(const p of [...mesh.listPrimitives()]) if(p.getMaterial().getName()==='baked_canopy'){mesh.removePrimitive(p);retained.push([mesh,p]);} await copy.transform(simplify({simplifier:MeshoptSimplifier,ratio:Math.min(1,budgets[lod]*chosen.length/original),error:.01*(lod+1),lockBorder:false})); for(const [mesh,p] of retained)mesh.addPrimitive(p); for(const m of copy.getRoot().listMeshes())for(const p of m.listPrimitives()) { const indices=new Uint32Array(p.getIndices().getArray()),positions=new Float32Array(p.getAttribute('POSITION').getArray()); const target=p.getExtras().target; if(target&&indices.length>target*1.1) { let [reduced]=MeshoptSimplifier.simplify(indices,positions,3,target,1,['Permissive','Prune']); if(reduced.length>target*1.1)[reduced]=MeshoptSimplifier.simplifySloppy(indices,positions,3,null,target,1); p.setIndices(copy.createAccessor().setType('SCALAR').setArray(reduced).setBuffer(copy.getRoot().listBuffers()[0])); compactPrimitive(p); } p.setExtras({}); } await copy.transform(prune()); let triangles=0;for(const m of copy.getRoot().listMeshes())for(const p of m.listPrimitives())triangles+=p.getIndices().getCount()/3; stats.push(triangles);mergeDocuments(merged,copy); } merged.getRoot().setDefaultScene(merged.getRoot().listScenes()[0]); await merged.transform(dedup(),unpartition(),prune()); for(const ext of merged.getRoot().listExtensionsUsed())ext.dispose(); await io.write(path.join(out,'model.glb'),merged); const dryFile=manifest.files.find(f=>f.path.startsWith(id+'/source/masks/dry_diff')); if(dryFile) { const dry=cloneDocument(merged); const mask=masks[0]; const image=await sharp(await fs.readFile(path.join(root,dryFile.path))).metadata(); const a=await sharp(await fs.readFile(path.join(root,mask.path))).resize(image.width,image.height).extractChannel(0).toBuffer(); const rgba=await sharp(await fs.readFile(path.join(root,dryFile.path))).removeAlpha().joinChannel(a).png().toBuffer(); const texture=dry.createTexture('dry_grass').setImage(rgba).setMimeType('image/png'); for(const m of dry.getRoot().listMaterials())m.setBaseColorTexture(texture); await dry.transform(prune());await io.write(path.join(out,'dry.glb'),dry); } const variantBounds=chosen.map(n=>getBounds(n)); await fs.writeFile(path.join(out,'preparation.json'),JSON.stringify({source:'https://polyhaven.com/a/'+id,license:'CC0',budgets,triangles:stats,variants:variantBounds},null,2)); await fs.writeFile(path.join(out,'stamp.json'),stamp); console.log('Prepared',id,stats); }