aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/glbExport.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/glbExport.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/glbExport.js')
-rw-r--r--bluebey-studio/src/glbExport.js148
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;
+ }
+}