diff options
Diffstat (limited to 'bluebey-studio/src/glbExport.js')
| -rw-r--r-- | bluebey-studio/src/glbExport.js | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/bluebey-studio/src/glbExport.js b/bluebey-studio/src/glbExport.js new file mode 100644 index 0000000..6e3e997 --- /dev/null +++ b/bluebey-studio/src/glbExport.js @@ -0,0 +1,148 @@ +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<Blob>} `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; + } +} |
