1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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;
}
}
|