import * as THREE from 'three'; import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js'; /** * Hand the posed character back as a 3D file. * * WHY: a PNG or a WebM only travels as pixels. Being able to take the pose, the * expression and the props into Blender (or embed them elsewhere as a model) * turns the studio into a front end for the character instead of a picture * maker, and it costs nothing: what is on screen already *is* a glTF scene, so * the only job here is to hand the live objects to three's exporter with the * right options and a Blob around the result. * * The caller passes exactly what it wants in the file - the character root and * any props - so no lights, helpers or ground ever enter it. The inverted-hull * outlines are the one wrinkle: they are visible (they are part of the look) * but they are duplicates of the body geometry, so exporting them would double * the file and leave a black shell around the character. `needsTemporaryHide` * decides what to hide for the duration, and `exportGLB` restores it after. */ const GLTF_BINARY_TYPE = 'model/gltf-binary'; const GLTF_JSON_TYPE = 'model/gltf+json'; /** The largest texture a GPU can be assumed to handle everywhere. */ const MAX_TEXTURE_SIZE = 4096; /** * True for objects the exporter must skip: the inverted-hull outlines (named * `...:outline`), anything the app tagged as a helper or as excluded from the * export, and anything already hidden. The name check is the important one: an * outline is visible on screen, so `onlyVisible` alone would not drop it. * * @param {THREE.Object3D} object * @returns {boolean} */ export function needsTemporaryHide(object) { if (!object) return false; if (typeof object.name === 'string' && object.name.endsWith(':outline')) return true; if (object.userData?.isHelper === true) return true; if (object.userData?.excludeFromExport === true) return true; return object.visible === false; } /** Triangles in a geometry, ignoring draw ranges (the export does too). */ function triangleCount(geometry) { if (!geometry) return 0; const index = geometry.index; const count = index ? index.count : geometry.attributes?.position?.count ?? 0; return Math.floor(count / 3); } /** * Count what `exportGLB` will write, for the summary the app shows first. * * Walks the same pruned tree the exporter sees, so hiding an outline or a * helper is reflected in the numbers. A `SkinnedMesh` is also a `Mesh`, so it * counts in both `meshes` and `skinnedMeshes`; a material shared by several * meshes counts once, and every texture a counted material refers to counts * once. * * @param {THREE.Object3D[]} objects * @returns {{ meshes: number, skinnedMeshes: number, materials: number, textures: number, triangles: number }} */ export function describeScene(objects) { const materials = new Set(); const textures = new Set(); let meshes = 0; let skinnedMeshes = 0; let triangles = 0; const visit = (object) => { if (!object || needsTemporaryHide(object)) return; if (object.isMesh) { meshes += 1; if (object.isSkinnedMesh) skinnedMeshes += 1; const list = Array.isArray(object.material) ? object.material : [object.material]; for (const material of list) { if (!material) continue; materials.add(material); for (const value of Object.values(material)) { if (value && value.isTexture) textures.add(value); } } triangles += triangleCount(object.geometry); } for (const child of object.children ?? []) visit(child); }; for (const root of Array.isArray(objects) ? objects : []) visit(root); return { meshes, skinnedMeshes, materials: materials.size, textures: textures.size, triangles }; } /** * Hide every object the file must not contain, remembering what to restore. * Stops at the first excluded ancestor: the exporter skips a hidden subtree, so * there is no need to walk inside one. */ function hideExcluded(object, hidden) { if (needsTemporaryHide(object)) { hidden.push({ object, visible: object.visible }); object.visible = false; return; } for (const child of object.children ?? []) hideExcluded(child, hidden); } /** * Export `objects` as a GLB (or a `.gltf` JSON) Blob. * * @param {THREE.Object3D[]} objects the character root and any props * @param {{ binary?: boolean, name?: string }} [options] * @returns {Promise} `model/gltf-binary`, or `model/gltf+json` when not binary */ export async function exportGLB(objects, { binary = true, name = 'bluebey' } = {}) { const roots = (Array.isArray(objects) ? objects : []).filter(Boolean); if (roots.length === 0) { throw new Error('exportGLB: objects is empty, there is nothing to export'); } if (describeScene(roots).meshes === 0) { throw new Error('exportGLB: objects contain no visible meshes to export'); } const hidden = []; try { for (const root of roots) hideExcluded(root, hidden); // The exporter names a bare array of objects "AuxScene". Wrapping them in a // named Scene keeps the caller's objects where they are (we push straight // into `children`, exactly as the exporter does) and gives the file a real // scene name. const scene = new THREE.Scene(); scene.name = name; for (const root of roots) scene.children.push(root); const exporter = new GLTFExporter(); const result = await exporter.parseAsync(scene, { binary, onlyVisible: true, truncateDrawRange: false, maxTextureSize: MAX_TEXTURE_SIZE, }); if (binary) return new Blob([result], { type: GLTF_BINARY_TYPE }); return new Blob([JSON.stringify(result)], { type: GLTF_JSON_TYPE }); } finally { for (const { object, visible } of hidden) object.visible = visible; } }