@usamanawaz/react-native-ai-avatar 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/assets/models/male-avatar-clothed.glb +0 -0
- package/assets/models/male-avatar.glb +0 -0
- package/dist/AIAvatar.d.ts +20 -0
- package/dist/AvatarView.d.ts +3 -0
- package/dist/animation/animator.d.ts +44 -0
- package/dist/animation/channels.d.ts +32 -0
- package/dist/animation/expressions.d.ts +4 -0
- package/dist/index.d.ts +13 -0
- package/dist/model/bakeSkinning.d.ts +8 -0
- package/dist/model/blendshapes.d.ts +12 -0
- package/dist/model/boneMap.d.ts +9 -0
- package/dist/model/catalog.d.ts +2 -0
- package/dist/model/cpuMorphs.d.ts +24 -0
- package/dist/model/cpuSkin.d.ts +19 -0
- package/dist/model/loadAvatar.d.ts +21 -0
- package/dist/model/skeleton.d.ts +17 -0
- package/dist/model/visemes.d.ts +19 -0
- package/dist/rendering/AvatarStage.d.ts +7 -0
- package/dist/rendering/applyPose.d.ts +3 -0
- package/dist/speech/ExpoSpeechProvider.d.ts +13 -0
- package/dist/speech/approximateMouth.d.ts +15 -0
- package/dist/types.d.ts +35 -0
- package/dist/voice/types.d.ts +18 -0
- package/package.json +72 -4
- package/src/avatar/AIAvatar.tsx +137 -0
- package/src/avatar/AvatarView.tsx +11 -0
- package/src/avatar/animation/animator.ts +344 -0
- package/src/avatar/animation/channels.ts +59 -0
- package/src/avatar/animation/expressions.ts +51 -0
- package/src/avatar/index.ts +23 -0
- package/src/avatar/model/bakeSkinning.ts +46 -0
- package/src/avatar/model/blendshapes.ts +70 -0
- package/src/avatar/model/boneMap.ts +17 -0
- package/src/avatar/model/catalog.ts +15 -0
- package/src/avatar/model/cpuMorphs.ts +124 -0
- package/src/avatar/model/cpuSkin.ts +105 -0
- package/src/avatar/model/jpeg-js.d.ts +6 -0
- package/src/avatar/model/loadAvatar.ts +497 -0
- package/src/avatar/model/skeleton.ts +21 -0
- package/src/avatar/model/visemes.ts +55 -0
- package/src/avatar/rendering/AvatarStage.tsx +119 -0
- package/src/avatar/rendering/applyPose.ts +59 -0
- package/src/avatar/speech/ExpoSpeechProvider.ts +42 -0
- package/src/avatar/speech/approximateMouth.ts +244 -0
- package/src/avatar/types.ts +45 -0
- package/src/avatar/voice/types.ts +20 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { Asset } from 'expo-asset';
|
|
2
|
+
import { File, Paths } from 'expo-file-system';
|
|
3
|
+
import { decode as decodeJpeg } from 'jpeg-js';
|
|
4
|
+
import { Platform } from 'react-native';
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
7
|
+
|
|
8
|
+
import { createCpuSkins, type CpuSkin } from './cpuSkin';
|
|
9
|
+
import { MPFB_BONE_SOURCES } from './boneMap';
|
|
10
|
+
import { extractCpuMorphs, type CpuMorphMesh } from './cpuMorphs';
|
|
11
|
+
import type { BoneName } from './skeleton';
|
|
12
|
+
|
|
13
|
+
export type BoundBone = {
|
|
14
|
+
bone: THREE.Bone;
|
|
15
|
+
rest: THREE.Quaternion;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type LoadedAvatar = {
|
|
19
|
+
scene: THREE.Group;
|
|
20
|
+
/** Rotated for subtle head-turn without GPU skinning. */
|
|
21
|
+
lookGroup: THREE.Group;
|
|
22
|
+
/** @deprecated Prefer cpuMorphs — kept for older call sites. */
|
|
23
|
+
morphMeshes: THREE.Mesh[];
|
|
24
|
+
cpuMorphs: CpuMorphMesh[];
|
|
25
|
+
cpuSkins: CpuSkin[];
|
|
26
|
+
bones: Partial<Record<BoneName, BoundBone[]>>;
|
|
27
|
+
frameCamera: (camera: THREE.PerspectiveCamera) => boolean;
|
|
28
|
+
dispose: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type GlbImage = {
|
|
32
|
+
bufferView?: number;
|
|
33
|
+
mimeType?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type GlbMaterial = {
|
|
37
|
+
name?: string;
|
|
38
|
+
pbrMetallicRoughness?: {
|
|
39
|
+
baseColorTexture?: { index?: number };
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type GlbJson = {
|
|
44
|
+
bufferViews?: { byteOffset?: number; byteLength: number }[];
|
|
45
|
+
images?: GlbImage[];
|
|
46
|
+
textures?: { source?: number }[];
|
|
47
|
+
materials?: GlbMaterial[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export async function loadAvatar(
|
|
51
|
+
moduleId: number,
|
|
52
|
+
onProgress?: (status: string) => void,
|
|
53
|
+
): Promise<LoadedAvatar> {
|
|
54
|
+
onProgress?.('Reading model…');
|
|
55
|
+
const buffer = await withTimeout(readModel(moduleId), 20000, 'Reading the model');
|
|
56
|
+
// GLTFLoader turns embedded PNGs into blob: URLs. Those requests hang on
|
|
57
|
+
// iOS, so the scene promise never resolves. Parse the rig without images,
|
|
58
|
+
// then attach the same PNGs through a local file Expo GL can upload.
|
|
59
|
+
onProgress?.('Parsing model…');
|
|
60
|
+
const gltf = await withTimeout(
|
|
61
|
+
parseGltf(Platform.OS === 'web' ? buffer : glbWithoutTextureRefs(buffer)),
|
|
62
|
+
20000,
|
|
63
|
+
'Parsing the model',
|
|
64
|
+
);
|
|
65
|
+
const loadedRoot = gltf.scene;
|
|
66
|
+
onProgress?.('Loading textures…');
|
|
67
|
+
await withTimeout(attachMissingTextures(loadedRoot, buffer), 20000, 'Loading textures');
|
|
68
|
+
|
|
69
|
+
// Bind bones before neutralizing skinning so camera framing still works.
|
|
70
|
+
const byName = new Map<string, THREE.Bone>();
|
|
71
|
+
loadedRoot.traverse((object) => {
|
|
72
|
+
if ((object as THREE.Bone).isBone) byName.set(object.name, object as THREE.Bone);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const bones: Partial<Record<BoneName, BoundBone[]>> = {};
|
|
76
|
+
for (const [contractName, sources] of Object.entries(MPFB_BONE_SOURCES) as [BoneName, readonly string[]][]) {
|
|
77
|
+
const bound: BoundBone[] = [];
|
|
78
|
+
for (const sourceName of sources) {
|
|
79
|
+
const bone = byName.get(sourceName);
|
|
80
|
+
if (!bone || bound.some((item) => item.bone === bone)) continue;
|
|
81
|
+
bound.push({ bone, rest: bone.quaternion.clone() });
|
|
82
|
+
}
|
|
83
|
+
if (bound.length > 0) bones[contractName] = bound;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Native Expo GL cannot draw SkinnedMesh (float bone textures). Pose on the CPU.
|
|
87
|
+
let cpuSkins: CpuSkin[] = [];
|
|
88
|
+
if (Platform.OS !== 'web') {
|
|
89
|
+
onProgress?.('Preparing mesh…');
|
|
90
|
+
cpuSkins = createCpuSkins(loadedRoot);
|
|
91
|
+
useBasicMaterials(loadedRoot);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const morphMeshes: THREE.Mesh[] = [];
|
|
95
|
+
const cpuMorphs: CpuMorphMesh[] = [];
|
|
96
|
+
loadedRoot.traverse((object) => {
|
|
97
|
+
const mesh = object as THREE.Mesh;
|
|
98
|
+
if (!mesh.isMesh || !mesh.morphTargetDictionary || !mesh.morphTargetInfluences) return;
|
|
99
|
+
morphMeshes.push(mesh);
|
|
100
|
+
const cpu = extractCpuMorphs(mesh);
|
|
101
|
+
if (cpu) cpuMorphs.push(cpu);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Use every face shape the file actually has. A partial set still drives
|
|
105
|
+
// Happy, Sad, and the other buttons; missing names stay at rest.
|
|
106
|
+
for (const morph of cpuMorphs) {
|
|
107
|
+
const skin = cpuSkins.find((item) => item.position === morph.position);
|
|
108
|
+
if (skin) morph.morphOut = skin.morph;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
loadedRoot.traverse((object) => {
|
|
112
|
+
const mesh = object as THREE.Mesh;
|
|
113
|
+
if (mesh.isMesh) mesh.frustumCulled = false;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const lookGroup = new THREE.Group();
|
|
117
|
+
lookGroup.name = 'AvatarLook';
|
|
118
|
+
lookGroup.add(loadedRoot);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
scene: lookGroup,
|
|
122
|
+
lookGroup,
|
|
123
|
+
morphMeshes,
|
|
124
|
+
cpuMorphs,
|
|
125
|
+
cpuSkins,
|
|
126
|
+
bones,
|
|
127
|
+
frameCamera: (camera) => framePortrait(loadedRoot, camera),
|
|
128
|
+
dispose: () => disposeScene(lookGroup),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readModel(moduleId: number): Promise<ArrayBuffer> {
|
|
133
|
+
const asset = Asset.fromModule(moduleId);
|
|
134
|
+
await asset.downloadAsync();
|
|
135
|
+
const uri = asset.localUri ?? asset.uri;
|
|
136
|
+
if (Platform.OS === 'web') {
|
|
137
|
+
const response = await fetch(uri);
|
|
138
|
+
if (!response.ok) throw new Error(`Could not fetch the avatar model (${response.status}).`);
|
|
139
|
+
return response.arrayBuffer();
|
|
140
|
+
}
|
|
141
|
+
const bytes = await new File(uri).bytes();
|
|
142
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseGltf(buffer: ArrayBuffer): Promise<{ scene: THREE.Group }> {
|
|
146
|
+
const loader = new GLTFLoader();
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
loader.parse(buffer, '', (gltf) => resolve(gltf), (error) => reject(error));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function attachMissingTextures(scene: THREE.Group, buffer: ArrayBuffer) {
|
|
153
|
+
const images = materialImages(buffer);
|
|
154
|
+
const textures = new Map<string, THREE.Texture>();
|
|
155
|
+
const pending: Promise<void>[] = [];
|
|
156
|
+
scene.traverse((object) => {
|
|
157
|
+
const mesh = object as THREE.Mesh;
|
|
158
|
+
if (!mesh.isMesh) return;
|
|
159
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
160
|
+
for (const material of materials) {
|
|
161
|
+
if (!isStandard(material) || material.map) continue;
|
|
162
|
+
const image = images.get(material.name);
|
|
163
|
+
if (!image || textures.has(material.name)) continue;
|
|
164
|
+
pending.push(
|
|
165
|
+
textureFromBytes(material.name, image.bytes, image.mime).then((texture) => {
|
|
166
|
+
textures.set(material.name, texture);
|
|
167
|
+
material.map = texture;
|
|
168
|
+
tuneMaterial(material);
|
|
169
|
+
material.needsUpdate = true;
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
await Promise.all(pending);
|
|
175
|
+
|
|
176
|
+
scene.traverse((object) => {
|
|
177
|
+
const mesh = object as THREE.Mesh;
|
|
178
|
+
if (!mesh.isMesh) return;
|
|
179
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
180
|
+
for (const material of materials) {
|
|
181
|
+
if (isStandard(material)) tuneMaterial(material);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function materialImages(buffer: ArrayBuffer): Map<string, { bytes: Uint8Array; mime: string }> {
|
|
187
|
+
const { json, bin } = readGlb(buffer);
|
|
188
|
+
const images = new Map<string, { bytes: Uint8Array; mime: string }>();
|
|
189
|
+
for (const material of json.materials ?? []) {
|
|
190
|
+
if (!material.name) continue;
|
|
191
|
+
const textureIndex = material.pbrMetallicRoughness?.baseColorTexture?.index;
|
|
192
|
+
if (textureIndex === undefined) continue;
|
|
193
|
+
const imageIndex = json.textures?.[textureIndex]?.source;
|
|
194
|
+
if (imageIndex === undefined) continue;
|
|
195
|
+
const image = json.images?.[imageIndex];
|
|
196
|
+
if (!image || image.bufferView === undefined) continue;
|
|
197
|
+
const view = json.bufferViews?.[image.bufferView];
|
|
198
|
+
if (!view) continue;
|
|
199
|
+
const start = view.byteOffset ?? 0;
|
|
200
|
+
images.set(material.name, {
|
|
201
|
+
bytes: bin.slice(start, start + view.byteLength),
|
|
202
|
+
mime: image.mimeType ?? 'image/png',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return images;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const TEXTURE_KEYS = [
|
|
209
|
+
'baseColorTexture',
|
|
210
|
+
'metallicRoughnessTexture',
|
|
211
|
+
'normalTexture',
|
|
212
|
+
'occlusionTexture',
|
|
213
|
+
'emissiveTexture',
|
|
214
|
+
] as const;
|
|
215
|
+
|
|
216
|
+
function glbWithoutTextureRefs(buffer: ArrayBuffer): ArrayBuffer {
|
|
217
|
+
const { json, bin } = readGlb(buffer);
|
|
218
|
+
const materials = (json.materials ?? []) as Array<Record<string, unknown>>;
|
|
219
|
+
for (const material of materials) {
|
|
220
|
+
for (const key of TEXTURE_KEYS) delete material[key];
|
|
221
|
+
const pbr = material.pbrMetallicRoughness;
|
|
222
|
+
if (pbr && typeof pbr === 'object') {
|
|
223
|
+
const surface = pbr as Record<string, unknown>;
|
|
224
|
+
for (const key of TEXTURE_KEYS) delete surface[key];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const jsonBytes = new TextEncoder().encode(JSON.stringify(json));
|
|
229
|
+
const jsonPadding = (4 - (jsonBytes.byteLength % 4)) % 4;
|
|
230
|
+
const binPadding = (4 - (bin.byteLength % 4)) % 4;
|
|
231
|
+
const total = 12 + 8 + jsonBytes.byteLength + jsonPadding + 8 + bin.byteLength + binPadding;
|
|
232
|
+
const out = new Uint8Array(total);
|
|
233
|
+
const view = new DataView(out.buffer);
|
|
234
|
+
view.setUint32(0, 0x46546c67, true);
|
|
235
|
+
view.setUint32(4, 2, true);
|
|
236
|
+
view.setUint32(8, total, true);
|
|
237
|
+
|
|
238
|
+
let offset = 12;
|
|
239
|
+
view.setUint32(offset, jsonBytes.byteLength + jsonPadding, true);
|
|
240
|
+
view.setUint32(offset + 4, 0x4e4f534a, true);
|
|
241
|
+
out.set(jsonBytes, offset + 8);
|
|
242
|
+
out.fill(0x20, offset + 8 + jsonBytes.byteLength, offset + 8 + jsonBytes.byteLength + jsonPadding);
|
|
243
|
+
offset += 8 + jsonBytes.byteLength + jsonPadding;
|
|
244
|
+
|
|
245
|
+
view.setUint32(offset, bin.byteLength + binPadding, true);
|
|
246
|
+
view.setUint32(offset + 4, 0x004e4942, true);
|
|
247
|
+
out.set(bin, offset + 8);
|
|
248
|
+
return out.buffer;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out.`)), ms);
|
|
254
|
+
promise.then(
|
|
255
|
+
(value) => {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
resolve(value);
|
|
258
|
+
},
|
|
259
|
+
(error: unknown) => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
reject(error);
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readGlb(buffer: ArrayBuffer): { json: GlbJson; bin: Uint8Array } {
|
|
268
|
+
const view = new DataView(buffer);
|
|
269
|
+
if (view.getUint32(0, true) !== 0x46546c67) throw new Error('Avatar file is not a GLB.');
|
|
270
|
+
let offset = 12;
|
|
271
|
+
let json: GlbJson | null = null;
|
|
272
|
+
let bin = new Uint8Array();
|
|
273
|
+
while (offset + 8 <= buffer.byteLength) {
|
|
274
|
+
const length = view.getUint32(offset, true);
|
|
275
|
+
const type = view.getUint32(offset + 4, true);
|
|
276
|
+
const start = offset + 8;
|
|
277
|
+
if (type === 0x4e4f534a) {
|
|
278
|
+
json = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, start, length))) as GlbJson;
|
|
279
|
+
} else if (type === 0x004e4942) {
|
|
280
|
+
bin = new Uint8Array(buffer, start, length);
|
|
281
|
+
}
|
|
282
|
+
offset = start + length;
|
|
283
|
+
}
|
|
284
|
+
if (!json) throw new Error('Avatar GLB is missing its JSON chunk.');
|
|
285
|
+
return { json, bin };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function textureFromBytes(name: string, bytes: Uint8Array, mime: string): Promise<THREE.Texture> {
|
|
289
|
+
if (mime.includes('jpeg') || mime.includes('jpg')) return textureFromJpeg(bytes);
|
|
290
|
+
if (Platform.OS === 'web') {
|
|
291
|
+
const blob = new Blob([Uint8Array.from(bytes)], { type: mime });
|
|
292
|
+
const url = URL.createObjectURL(blob);
|
|
293
|
+
const texture = await new Promise<THREE.Texture>((resolve, reject) => {
|
|
294
|
+
new THREE.TextureLoader().load(url, resolve, undefined, reject);
|
|
295
|
+
});
|
|
296
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
297
|
+
texture.flipY = false;
|
|
298
|
+
texture.needsUpdate = true;
|
|
299
|
+
return texture;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const extension = mime.includes('jpeg') ? 'jpg' : 'png';
|
|
303
|
+
const file = new File(Paths.cache, `avatar-${name.replace(/[^\w-]+/g, '_')}.${extension}`);
|
|
304
|
+
file.create({ overwrite: true, intermediates: true });
|
|
305
|
+
file.write(bytes);
|
|
306
|
+
const { width, height } = imageSize(bytes);
|
|
307
|
+
// Expo GL uploads via texImage2D(..., { localUri }). Three only takes that
|
|
308
|
+
// path when isDataTexture is set so image.data is passed as the pixels arg.
|
|
309
|
+
const texture = new THREE.Texture();
|
|
310
|
+
texture.image = { data: { localUri: fixFileUri(file.uri) }, width, height };
|
|
311
|
+
texture.flipY = false;
|
|
312
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
313
|
+
texture.generateMipmaps = false;
|
|
314
|
+
texture.minFilter = THREE.LinearFilter;
|
|
315
|
+
texture.magFilter = THREE.LinearFilter;
|
|
316
|
+
texture.needsUpdate = true;
|
|
317
|
+
(texture as THREE.Texture & { isDataTexture?: boolean }).isDataTexture = true;
|
|
318
|
+
return texture;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function textureFromJpeg(bytes: Uint8Array): THREE.DataTexture {
|
|
322
|
+
const decoded = decodeJpeg(Uint8Array.from(bytes), { useTArray: true, formatAsRGBA: true });
|
|
323
|
+
const texture = new THREE.DataTexture(decoded.data, decoded.width, decoded.height, THREE.RGBAFormat);
|
|
324
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
325
|
+
texture.flipY = false;
|
|
326
|
+
texture.generateMipmaps = false;
|
|
327
|
+
texture.minFilter = THREE.LinearFilter;
|
|
328
|
+
texture.magFilter = THREE.LinearFilter;
|
|
329
|
+
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
330
|
+
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
331
|
+
texture.needsUpdate = true;
|
|
332
|
+
return texture;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Expo GL expects `file://`, but some File APIs emit `file:/`. */
|
|
336
|
+
function fixFileUri(uri: string): string {
|
|
337
|
+
if (uri.startsWith('file:') && !uri.startsWith('file://')) {
|
|
338
|
+
return `file://${uri.slice('file:'.length)}`;
|
|
339
|
+
}
|
|
340
|
+
return uri;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function imageSize(bytes: Uint8Array): { width: number; height: number } {
|
|
344
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
345
|
+
const png = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47;
|
|
346
|
+
if (png) return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
|
|
347
|
+
throw new Error('Avatar texture is not a PNG.');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function tuneMaterial(material: THREE.MeshStandardMaterial) {
|
|
351
|
+
const name = material.name.toLowerCase();
|
|
352
|
+
material.envMapIntensity = 0.85;
|
|
353
|
+
if (name.includes('hair') || name.includes('eyebrow')) {
|
|
354
|
+
material.transparent = true;
|
|
355
|
+
material.depthWrite = false;
|
|
356
|
+
material.side = THREE.DoubleSide;
|
|
357
|
+
material.alphaTest = 0.05;
|
|
358
|
+
material.roughness = Math.min(material.roughness ?? 1, 0.72);
|
|
359
|
+
} else if (name.includes('eye')) {
|
|
360
|
+
material.transparent = true;
|
|
361
|
+
material.depthWrite = true;
|
|
362
|
+
material.side = THREE.DoubleSide;
|
|
363
|
+
material.roughness = 0.18;
|
|
364
|
+
material.metalness = 0.02;
|
|
365
|
+
} else if (name.includes('cloth') || name.includes('shirt') || name.includes('top')) {
|
|
366
|
+
material.transparent = false;
|
|
367
|
+
material.depthWrite = true;
|
|
368
|
+
material.side = THREE.DoubleSide;
|
|
369
|
+
material.roughness = 0.78;
|
|
370
|
+
material.metalness = 0.02;
|
|
371
|
+
} else {
|
|
372
|
+
// Skin reads more alive with a little warm albedo bias and soft specular.
|
|
373
|
+
material.transparent = false;
|
|
374
|
+
material.depthWrite = true;
|
|
375
|
+
material.side = THREE.FrontSide;
|
|
376
|
+
material.roughness = Math.min(material.roughness ?? 1, 0.55);
|
|
377
|
+
material.metalness = 0.02;
|
|
378
|
+
if (!material.map) material.color.set('#e8c4a8');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isStandard(material: THREE.Material): material is THREE.MeshStandardMaterial {
|
|
383
|
+
return 'map' in material;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function useBasicMaterials(scene: THREE.Object3D) {
|
|
387
|
+
scene.traverse((object) => {
|
|
388
|
+
const mesh = object as THREE.Mesh;
|
|
389
|
+
if (!mesh.isMesh) return;
|
|
390
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
391
|
+
const basic = materials.map((material) => {
|
|
392
|
+
if (!(material instanceof THREE.MeshStandardMaterial) && !(material instanceof THREE.MeshPhysicalMaterial)) {
|
|
393
|
+
material.side = THREE.DoubleSide;
|
|
394
|
+
return material;
|
|
395
|
+
}
|
|
396
|
+
const next = new THREE.MeshBasicMaterial({
|
|
397
|
+
name: material.name,
|
|
398
|
+
map: material.map,
|
|
399
|
+
color: material.map ? new THREE.Color('#ffffff') : fallbackColor(material),
|
|
400
|
+
side: THREE.DoubleSide,
|
|
401
|
+
depthWrite: true,
|
|
402
|
+
});
|
|
403
|
+
return next;
|
|
404
|
+
});
|
|
405
|
+
mesh.material = Array.isArray(mesh.material) ? basic : basic[0];
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function fallbackColor(material: THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial): THREE.Color {
|
|
410
|
+
const name = material.name.toLowerCase();
|
|
411
|
+
if (name.includes('hair')) return new THREE.Color('#2c2118');
|
|
412
|
+
if (name.includes('eyebrow')) return new THREE.Color('#241810');
|
|
413
|
+
if (name.includes('eye')) return new THREE.Color('#6e4b32');
|
|
414
|
+
if (name.includes('cloth') || name.includes('suit') || name.includes('shirt')) return material.color.clone();
|
|
415
|
+
if (material.color && material.map === null && material.color.getHex() !== 0xffffff) return material.color.clone();
|
|
416
|
+
return new THREE.Color('#d7a07a');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function framePortrait(root: THREE.Object3D, camera: THREE.PerspectiveCamera): boolean {
|
|
420
|
+
let headBone: THREE.Bone | null = null;
|
|
421
|
+
root.traverse((object) => {
|
|
422
|
+
const bone = object as THREE.Bone;
|
|
423
|
+
if (!headBone && bone.isBone && bone.name.toLowerCase() === 'head') headBone = bone;
|
|
424
|
+
});
|
|
425
|
+
if (!headBone) return frameFaceAndChest(root, camera);
|
|
426
|
+
|
|
427
|
+
const neck = headBone as THREE.Bone;
|
|
428
|
+
neck.updateWorldMatrix(true, false);
|
|
429
|
+
const head = new THREE.Vector3();
|
|
430
|
+
neck.getWorldPosition(head);
|
|
431
|
+
const lookY = head.y + 0.08;
|
|
432
|
+
camera.position.set(head.x, lookY, head.z + 0.82);
|
|
433
|
+
camera.near = 0.04;
|
|
434
|
+
camera.far = 20;
|
|
435
|
+
camera.fov = 26;
|
|
436
|
+
camera.lookAt(head.x, lookY + 0.02, head.z);
|
|
437
|
+
camera.updateProjectionMatrix();
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Face plus a short strip of chest, for a mesh that has no head bone. */
|
|
442
|
+
function frameFaceAndChest(root: THREE.Object3D, camera: THREE.PerspectiveCamera): boolean {
|
|
443
|
+
root.updateMatrixWorld(true);
|
|
444
|
+
const box = new THREE.Box3().setFromObject(root);
|
|
445
|
+
const size = box.getSize(new THREE.Vector3());
|
|
446
|
+
if (size.y < 1e-4) return false;
|
|
447
|
+
const centerX = (box.min.x + box.max.x) * 0.5;
|
|
448
|
+
const lookY = box.max.y - Math.min(0.2, size.y * 0.12);
|
|
449
|
+
camera.position.set(centerX, lookY, box.max.z + 0.82);
|
|
450
|
+
camera.near = 0.04;
|
|
451
|
+
camera.far = 20;
|
|
452
|
+
camera.fov = 26;
|
|
453
|
+
camera.lookAt(centerX, lookY + 0.04, box.max.z);
|
|
454
|
+
camera.updateProjectionMatrix();
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function frameOnFace(bones: Record<BoneName, BoundBone[]>, camera: THREE.PerspectiveCamera) {
|
|
459
|
+
const head = new THREE.Vector3();
|
|
460
|
+
const left = new THREE.Vector3();
|
|
461
|
+
const right = new THREE.Vector3();
|
|
462
|
+
bones.Head[0].bone.updateWorldMatrix(true, false);
|
|
463
|
+
bones.LeftEye[0].bone.updateWorldMatrix(true, false);
|
|
464
|
+
bones.RightEye[0].bone.updateWorldMatrix(true, false);
|
|
465
|
+
bones.Head[0].bone.getWorldPosition(head);
|
|
466
|
+
bones.LeftEye[0].bone.getWorldPosition(left);
|
|
467
|
+
bones.RightEye[0].bone.getWorldPosition(right);
|
|
468
|
+
const eyes = left.clone().add(right).multiplyScalar(0.5);
|
|
469
|
+
// Prefer the face forward (+Z for this MPFB export). Fall back to eye−head.
|
|
470
|
+
let forward = new THREE.Vector3(0, 0, 1);
|
|
471
|
+
const fromBones = eyes.clone().sub(head);
|
|
472
|
+
fromBones.y = 0;
|
|
473
|
+
if (fromBones.lengthSq() > 1e-6) forward = fromBones.normalize();
|
|
474
|
+
camera.position.copy(eyes).addScaledVector(forward, 0.72);
|
|
475
|
+
camera.position.y = eyes.y + 0.02;
|
|
476
|
+
camera.near = 0.05;
|
|
477
|
+
camera.far = 20;
|
|
478
|
+
camera.fov = 28;
|
|
479
|
+
// Aim slightly below the eyes so the whole face (and shirt collar) reads.
|
|
480
|
+
camera.lookAt(eyes.x, eyes.y - 0.04, eyes.z);
|
|
481
|
+
camera.updateProjectionMatrix();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function disposeScene(scene: THREE.Object3D) {
|
|
485
|
+
const textures = new Set<THREE.Texture>();
|
|
486
|
+
scene.traverse((object) => {
|
|
487
|
+
const mesh = object as THREE.Mesh;
|
|
488
|
+
if (!mesh.isMesh) return;
|
|
489
|
+
mesh.geometry.dispose();
|
|
490
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
491
|
+
for (const material of materials) {
|
|
492
|
+
if (isStandard(material) && material.map) textures.add(material.map);
|
|
493
|
+
material.dispose();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
for (const texture of textures) texture.dispose();
|
|
497
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bones a GLB must drive. Both sexes use these contract names.
|
|
3
|
+
* MPFB files keep their own bone names; the loader aliases them onto this list.
|
|
4
|
+
*/
|
|
5
|
+
export const REQUIRED_BONES = ['Neck', 'Head', 'LeftEye', 'RightEye'] as const;
|
|
6
|
+
|
|
7
|
+
/** Present when the mesh also drives the jaw with a bone. Lip-sync can use jawOpen alone. */
|
|
8
|
+
export const OPTIONAL_BONES = ['Jaw'] as const;
|
|
9
|
+
|
|
10
|
+
export type RequiredBoneName = (typeof REQUIRED_BONES)[number];
|
|
11
|
+
export type OptionalBoneName = (typeof OPTIONAL_BONES)[number];
|
|
12
|
+
export type BoneName = RequiredBoneName | OptionalBoneName;
|
|
13
|
+
|
|
14
|
+
/** Euler degrees, applied on top of the bind pose. */
|
|
15
|
+
export type BoneRotation = {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
z: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BonePose = Partial<Record<BoneName, BoneRotation>>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { BlendshapeWeights } from './blendshapes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Oculus viseme set (15). Speech text and audio both resolve to this list
|
|
5
|
+
* before they touch the mesh.
|
|
6
|
+
*/
|
|
7
|
+
export const VISEMES = [
|
|
8
|
+
'sil',
|
|
9
|
+
'PP',
|
|
10
|
+
'FF',
|
|
11
|
+
'TH',
|
|
12
|
+
'DD',
|
|
13
|
+
'kk',
|
|
14
|
+
'CH',
|
|
15
|
+
'SS',
|
|
16
|
+
'nn',
|
|
17
|
+
'RR',
|
|
18
|
+
'aa',
|
|
19
|
+
'E',
|
|
20
|
+
'ih',
|
|
21
|
+
'oh',
|
|
22
|
+
'ou',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type VisemeId = (typeof VISEMES)[number];
|
|
26
|
+
|
|
27
|
+
export type VisemeCue = {
|
|
28
|
+
/** Seconds from the start of the utterance. */
|
|
29
|
+
time: number;
|
|
30
|
+
viseme: VisemeId;
|
|
31
|
+
/** 0 to 1. Neighboring cues are crossfaded. */
|
|
32
|
+
weight: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Starting weights for each viseme. Tuned once a real mesh is loaded.
|
|
37
|
+
* `sil` is silence and leaves the mouth on the current expression.
|
|
38
|
+
*/
|
|
39
|
+
export const VISEME_BLENDSHAPES: Record<VisemeId, BlendshapeWeights> = {
|
|
40
|
+
sil: { jawOpen: 0.05, mouthClose: 0.08 },
|
|
41
|
+
PP: { jawOpen: 0, mouthClose: 0.9, mouthPressLeft: 0.45, mouthPressRight: 0.45 },
|
|
42
|
+
FF: { jawOpen: 0.07, mouthLowerDownLeft: 0.4, mouthLowerDownRight: 0.4, mouthUpperUpLeft: 0.18, mouthUpperUpRight: 0.18 },
|
|
43
|
+
TH: { jawOpen: 0.16, tongueOut: 0.28 },
|
|
44
|
+
DD: { jawOpen: 0.14 },
|
|
45
|
+
kk: { jawOpen: 0.2 },
|
|
46
|
+
CH: { jawOpen: 0.18, mouthFunnel: 0.32, mouthPucker: 0.16 },
|
|
47
|
+
SS: { jawOpen: 0.05, mouthStretchLeft: 0.32, mouthStretchRight: 0.32 },
|
|
48
|
+
nn: { jawOpen: 0.08, mouthClose: 0.18 },
|
|
49
|
+
RR: { jawOpen: 0.14, mouthPucker: 0.18, mouthFunnel: 0.12 },
|
|
50
|
+
aa: { jawOpen: 0.58, mouthLowerDownLeft: 0.32, mouthLowerDownRight: 0.32 },
|
|
51
|
+
E: { jawOpen: 0.3, mouthSmileLeft: 0.32, mouthSmileRight: 0.32, mouthStretchLeft: 0.24, mouthStretchRight: 0.24 },
|
|
52
|
+
ih: { jawOpen: 0.16, mouthSmileLeft: 0.14, mouthSmileRight: 0.14 },
|
|
53
|
+
oh: { jawOpen: 0.4, mouthFunnel: 0.48, mouthPucker: 0.12 },
|
|
54
|
+
ou: { jawOpen: 0.14, mouthPucker: 0.55, mouthFunnel: 0.24 },
|
|
55
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { Canvas, useFrame, useThree } from '@react-three/fiber/native';
|
|
4
|
+
import type { PerspectiveCamera } from 'three';
|
|
5
|
+
|
|
6
|
+
import type { AvatarAnimator } from '../animation/animator';
|
|
7
|
+
import { loadAvatar, type LoadedAvatar } from '../model/loadAvatar';
|
|
8
|
+
import { applyPose } from './applyPose';
|
|
9
|
+
|
|
10
|
+
type AvatarStageProps = {
|
|
11
|
+
source: number;
|
|
12
|
+
animator: AvatarAnimator;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function AvatarStage({ source, animator }: AvatarStageProps) {
|
|
16
|
+
const [avatar, setAvatar] = useState<LoadedAvatar | null>(null);
|
|
17
|
+
const [status, setStatus] = useState('Loading avatar…');
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let active = true;
|
|
21
|
+
let loaded: LoadedAvatar | null = null;
|
|
22
|
+
setAvatar(null);
|
|
23
|
+
setStatus('Loading avatar…');
|
|
24
|
+
loadAvatar(source, (progress) => {
|
|
25
|
+
if (active) setStatus(progress);
|
|
26
|
+
})
|
|
27
|
+
.then((next) => {
|
|
28
|
+
if (!active) {
|
|
29
|
+
next.dispose();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
loaded = next;
|
|
33
|
+
setAvatar(next);
|
|
34
|
+
setStatus('ready');
|
|
35
|
+
})
|
|
36
|
+
.catch((reason: unknown) => {
|
|
37
|
+
if (!active) return;
|
|
38
|
+
setStatus(reason instanceof Error ? reason.message : 'Could not load the avatar.');
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
active = false;
|
|
42
|
+
loaded?.dispose();
|
|
43
|
+
};
|
|
44
|
+
}, [source]);
|
|
45
|
+
|
|
46
|
+
const loading = status.endsWith('…');
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<View style={styles.frame}>
|
|
50
|
+
{avatar ? (
|
|
51
|
+
<Canvas
|
|
52
|
+
style={styles.canvas}
|
|
53
|
+
// MSAA is unreliable on the iOS Simulator's OpenGL stack.
|
|
54
|
+
gl={{ antialias: false }}
|
|
55
|
+
camera={{ fov: 32, near: 0.01, far: 40, position: [0, 0.85, 3.2] }}
|
|
56
|
+
onCreated={({ gl }) => {
|
|
57
|
+
const context = gl.getContext();
|
|
58
|
+
console.log(
|
|
59
|
+
`avatar gl ${context.drawingBufferWidth}x${context.drawingBufferHeight}`,
|
|
60
|
+
);
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<color attach="background" args={['#dce6f2']} />
|
|
64
|
+
<hemisphereLight args={['#f7f1ea', '#9eb0c8', 0.7]} />
|
|
65
|
+
<ambientLight intensity={0.35} color="#f3eee8" />
|
|
66
|
+
<directionalLight position={[0.45, 2.2, 1.6]} intensity={1.65} color="#fff4ea" />
|
|
67
|
+
<directionalLight position={[-1.1, 1.4, 0.9]} intensity={0.5} color="#d5e3f6" />
|
|
68
|
+
<directionalLight position={[0.15, 1.7, -1.4]} intensity={0.55} color="#ffe8d6" />
|
|
69
|
+
<AvatarModel avatar={avatar} animator={animator} />
|
|
70
|
+
</Canvas>
|
|
71
|
+
) : (
|
|
72
|
+
<View style={styles.status}>
|
|
73
|
+
{loading ? <ActivityIndicator color="#243044" /> : null}
|
|
74
|
+
<Text style={styles.statusText}>{status}</Text>
|
|
75
|
+
</View>
|
|
76
|
+
)}
|
|
77
|
+
</View>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function AvatarModel({ avatar, animator }: { avatar: LoadedAvatar; animator: AvatarAnimator }) {
|
|
82
|
+
const camera = useThree((state) => state.camera);
|
|
83
|
+
const framed = useRef(false);
|
|
84
|
+
|
|
85
|
+
useFrame((state) => {
|
|
86
|
+
applyPose(avatar, animator.sample(state.clock.elapsedTime));
|
|
87
|
+
if (!framed.current) {
|
|
88
|
+
framed.current = avatar.frameCamera(camera as PerspectiveCamera);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return <primitive object={avatar.scene} />;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const styles = StyleSheet.create({
|
|
96
|
+
frame: {
|
|
97
|
+
flex: 1,
|
|
98
|
+
minHeight: 320,
|
|
99
|
+
borderRadius: 16,
|
|
100
|
+
// overflow:hidden on an iOS parent clips the OpenGL layer, so the view
|
|
101
|
+
// stays the background color even while the GL context is rendering.
|
|
102
|
+
backgroundColor: '#dce6f2',
|
|
103
|
+
},
|
|
104
|
+
canvas: {
|
|
105
|
+
flex: 1,
|
|
106
|
+
},
|
|
107
|
+
status: {
|
|
108
|
+
flex: 1,
|
|
109
|
+
alignItems: 'center',
|
|
110
|
+
justifyContent: 'center',
|
|
111
|
+
paddingHorizontal: 24,
|
|
112
|
+
gap: 8,
|
|
113
|
+
},
|
|
114
|
+
statusText: {
|
|
115
|
+
color: '#243044',
|
|
116
|
+
fontSize: 15,
|
|
117
|
+
textAlign: 'center',
|
|
118
|
+
},
|
|
119
|
+
});
|