UNPKG

@xrengine/server-core

Version:

Shared components for XREngine server

329 lines (305 loc) 11.1 kB
import { Application } from '@feathersjs/express/lib' import { BufferUtils, Document, Format, Buffer as glBuffer, Material, Primitive, Property, Texture } from '@gltf-transform/core' import { MeshoptCompression, MeshQuantization, TextureBasisu } from '@gltf-transform/extensions' import { dedup, draco, partition, prune, quantize, reorder } from '@gltf-transform/functions' import appRootPath from 'app-root-path' import { exec } from 'child_process' import fs from 'fs' import { MeshoptEncoder } from 'meshoptimizer' import path from 'path' import sharp from 'sharp' import { MathUtils } from 'three' import util from 'util' import { ModelTransformParameters } from '@xrengine/engine/src/assets/classes/ModelTransform' import { getContentType } from '../../util/fileUtils' import { EEMaterial } from '../extensions/EE_MaterialTransformer' import ModelTransformLoader from '../ModelTransformLoader' export type ModelTransformArguments = { src: string dst: string parms: ModelTransformParameters } export async function combineMaterials(document: Document) { const root = document.getRoot() const cache: Material[] = [] console.log('combining materials...') root.listMaterials().map((material) => { const eeMat = material.getExtension<EEMaterial>('EE_material') const dupe = cache.find((cachedMaterial) => { const cachedEEMat = cachedMaterial.getExtension<EEMaterial>('EE_material') if (eeMat !== null && cachedEEMat !== null) { return ( eeMat.prototype === cachedEEMat.prototype && ((eeMat.args === cachedEEMat.args) === null || (cachedEEMat.args && eeMat.args?.equals(cachedEEMat.args))) ) } else return material.equals(cachedMaterial) }) if (dupe !== undefined) { console.log('found duplicate material...') let dupeCount = 0 root .listMeshes() .flatMap((mesh) => mesh.listPrimitives()) .map((prim) => { if (prim.getMaterial() === material) { prim.setMaterial(dupe) dupeCount++ } }) console.log('replaced ' + dupeCount + ' materials') } else { cache.push(material) } }) } export async function combineMeshes(document: Document) { const root = document.getRoot() const prims = root.listMeshes().flatMap((mesh) => mesh.listPrimitives()) const matMap = new Map<Material, Primitive[]>() for (const prim of prims) { const material = prim.getMaterial() if (material) { if (!matMap.has(material)) { matMap.set(material, []) } const matPrims = matMap.get(material) matPrims?.push(prim) } } ;[...matMap.entries()].map(([material, prims]) => { const nuPrim = document.createPrimitive() nuPrim.setMaterial(material) prims.map((prim) => { prim.listAttributes().map((accessor) => { let nuAttrib = nuPrim.getAttribute(accessor.getName()) if (!nuAttrib) { nuPrim.setAttribute(accessor.getName(), accessor) nuAttrib = accessor } else { nuAttrib.setArray( BufferUtils.concat([Uint8Array.from(nuAttrib.getArray()!), Uint8Array.from(accessor.getArray()!)]) ) } }) }) }) } export async function transformModel(app: Application, args: ModelTransformArguments) { const parms = args.parms const promiseExec = util.promisify(exec) const serverDir = path.join(appRootPath.path, 'packages/server') const tmpDir = path.join(serverDir, 'tmp') const BASIS_U = path.join(appRootPath.path, 'packages/server/public/loader_decoders/basisu') const GLTF_PACK = path.join(appRootPath.path, 'packages/server/public/loader_decoders/gltfpack') const toTmp = (fileName) => { return `${tmpDir}/${fileName}` } /** * * @param {string} mimeType * @returns */ const mimeToFileType = (mimeType) => { switch (mimeType) { case 'image/jpg': case 'image/jpeg': return 'jpg' case 'image/png': return 'png' case 'image/ktx2': return 'ktx2' default: return null } } const fileTypeToMime = (fileType) => { switch (fileType) { case 'jpg': return 'image/jpg' case 'png': return 'image/png' case 'ktx2': return 'image/ktx2' default: return null } } const resourceName = /*'model-resources'*/ path.basename(args.src).slice(0, path.basename(args.src).lastIndexOf('.')) const resourcePath = path.join(path.dirname(args.src), resourceName) const projectRoot = path.join(appRootPath.path, 'packages/projects') const toValidFilename = (name: string) => { let result = name.replace(/[\s]/, '-') return result } const toPath = (element: Texture | glBuffer, index?: number) => { if (element instanceof Texture) { if (element.getURI()) { return path.basename(element.getURI()) } else return `${toValidFilename(element.getName())}-${index}-${Date.now()}.${mimeToFileType(element.getMimeType())}` } else if (element instanceof glBuffer) return `buffer-${index}-${Date.now()}.bin` else throw new Error('invalid element to find path') } const fileUploadPath = (fUploadPath: string) => { const pathCheck = /.*\/packages\/projects\/(.*)\/([\w\d\s\-_\.]*)$/ const [_, savePath, fileName] = pathCheck.exec(fUploadPath) ?? pathCheck.exec(path.join(path.dirname(args.src), fUploadPath))! return [savePath, fileName] } const initializeResourceDir = async () => { if (fs.existsSync(resourcePath)) { //fs.rmSync(resourcePath, { recursive: true, force: true }) await app.service('file-browser').remove(resourcePath.replace(projectRoot, '')) } //fs.mkdirSync(resourcePath) if (!fs.existsSync(resourcePath)) await app.service('file-browser').create(resourcePath.replace(projectRoot, '') as any) } const { io } = await ModelTransformLoader() const document = await io.read(args.src) const root = document.getRoot() /* ID unnamed resources */ await combineMaterials(document) if (args.parms.dedup) { await document.transform(dedup()) } if (args.parms.prune) { await document.transform(prune()) } /* PROCESS MESHES */ if (args.parms.meshQuantization.enabled) { document.createExtension(MeshQuantization).setRequired(true) await document.transform(quantize(args.parms.meshQuantization.options)) } if (args.parms.dracoCompression.enabled) { await document.transform(draco(args.parms.dracoCompression.options)) } /* /PROCESS MESHES */ /* PROCESS TEXTURES */ //resize textures const handleImage = async (inPath, outPath, dstImgFormat) => { try { if (path.extname(inPath) === '.ktx2') { console.warn('cannot resize ktx2 compressed image at ' + inPath) return } const img = await sharp(inPath) const metadata = await img.metadata() await img .resize(Math.min(parms.maxTextureSize, metadata.width), Math.min(parms.maxTextureSize, metadata.height), { fit: 'contain' }) .toFormat(dstImgFormat) .toFile(outPath.replace(/\.[\w\d]+$/, `.${dstImgFormat}`)) console.log('handled image file ' + inPath) } catch (e) { console.error('error while handling image ' + inPath) console.error(e) } } const textures = root .listTextures() .filter( (texture) => (mimeToFileType(texture.getMimeType()) !== parms.textureFormat && !!texture.getSize()) || texture.getSize()?.reduce((x, y) => Math.max(x, y))! > parms.maxTextureSize ) for (const texture of textures) { const oldImg = texture.getImage() const fileName = toPath(texture) const oldPath = toTmp(fileName) const resizeExtension = parms.textureFormat === 'ktx2' ? 'png' : parms.textureFormat const resizedPath = oldPath.replace( new RegExp(`\\.${mimeToFileType(texture.getMimeType())}$`), `-resized.${resizeExtension}` ) if (!fs.existsSync(tmpDir)) { fs.mkdirSync(tmpDir) } fs.writeFileSync(oldPath, oldImg!) const xResizedName = fileName.replace( new RegExp(`\\.${mimeToFileType(texture.getMimeType())}$`), `-resized.${parms.textureFormat}` ) const nuFileName = fileName.replace( new RegExp(`\\.${mimeToFileType(texture.getMimeType())}$`), `-transformed.${parms.textureFormat}` ) const nuPath = `${tmpDir}/${nuFileName}` await handleImage(oldPath, resizedPath, resizeExtension) if (parms.textureFormat === 'ktx2') { //KTX2 Basisu Compression document.createExtension(TextureBasisu).setRequired(true) await promiseExec(`${BASIS_U} -ktx2 ${resizedPath}`) await promiseExec(`mv ${serverDir}/${xResizedName} ${nuPath}`) console.log('loaded ktx2 image ' + nuPath) } else { await promiseExec(`mv ${resizedPath} ${nuPath}`) } texture.setImage(fs.readFileSync(nuPath)) texture.setMimeType(fileTypeToMime(parms.textureFormat)!) } let result switch (parms.modelFormat) { case 'glb': const data = await io.writeBinary(document) const [savePath, fileName] = fileUploadPath(args.dst) result = await app.service('file-browser').patch(null, { path: savePath, fileName, body: data, contentType: getContentType(args.dst) }) console.log('Handled glb file') break case 'gltf': const idResources = (elements) => elements.filter((mesh) => !mesh.getName()).map((mesh) => mesh.setName(MathUtils.generateUUID())) idResources(root.listBuffers()) idResources(root.listMeshes()) idResources(root.listTextures()) document.transform( partition({ animations: true, meshes: root.listMeshes().map((mesh) => mesh.getName()) }) ) const { json, resources } = await io.writeJSON(document, { format: Format.GLTF, basename: resourceName }) await initializeResourceDir() json.images?.map((image) => { image.uri = path.join(resourceName, path.basename(image.uri!)) }) const defaultBufURI = MathUtils.generateUUID() + '.bin' json.buffers?.map((buffer) => { buffer.uri = path.join(resourceName, path.basename(buffer.uri ?? defaultBufURI)) }) Object.keys(resources).map((uri) => { resources[path.join(resourceName, path.basename(uri))] = resources[uri] delete resources[uri] }) const doUpload = (uri, data) => { const [savePath, fileName] = fileUploadPath(uri) return app.service('file-browser').patch(null, { path: savePath, fileName, body: data, contentType: getContentType(uri) }) } await Promise.all(Object.entries(resources).map(([uri, data]) => doUpload(uri, data))) result = await doUpload(args.dst.replace(/\.glb$/, '.gltf'), Buffer.from(JSON.stringify(json))) console.log('Handled gltf file') break } if (fs.existsSync(tmpDir)) await promiseExec(`rm -R ${tmpDir}`) return result }