@myned-ai/gsplat-flame-avatar-renderer 1.0.2 → 1.0.5
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/README.md +6 -36
- package/dist/gsplat-flame-avatar-renderer.cjs.js +12875 -0
- package/dist/{gsplat-flame-avatar-renderer.umd.js.map → gsplat-flame-avatar-renderer.cjs.js.map} +1 -1
- package/dist/gsplat-flame-avatar-renderer.esm.js +1 -1
- package/package.json +6 -11
- package/src/api/index.js +7 -0
- package/src/buffers/SplatBuffer.js +1394 -0
- package/src/buffers/SplatBufferGenerator.js +41 -0
- package/src/buffers/SplatPartitioner.js +110 -0
- package/src/buffers/UncompressedSplatArray.js +106 -0
- package/src/buffers/index.js +11 -0
- package/src/core/SplatGeometry.js +48 -0
- package/src/core/SplatMesh.js +2620 -0
- package/src/core/SplatScene.js +43 -0
- package/src/core/SplatTree.js +200 -0
- package/src/core/Viewer.js +2895 -0
- package/src/core/index.js +13 -0
- package/src/enums/EngineConstants.js +58 -0
- package/src/enums/LogLevel.js +13 -0
- package/src/enums/RenderMode.js +11 -0
- package/src/enums/SceneFormat.js +21 -0
- package/src/enums/SceneRevealMode.js +11 -0
- package/src/enums/SplatRenderMode.js +10 -0
- package/src/enums/index.js +13 -0
- package/src/flame/FlameAnimator.js +271 -0
- package/src/flame/FlameConstants.js +21 -0
- package/src/flame/FlameTextureManager.js +293 -0
- package/src/flame/index.js +22 -0
- package/src/flame/utils.js +50 -0
- package/src/index.js +39 -0
- package/src/loaders/DirectLoadError.js +14 -0
- package/src/loaders/INRIAV1PlyParser.js +223 -0
- package/src/loaders/PlyLoader.js +261 -0
- package/src/loaders/PlyParser.js +19 -0
- package/src/loaders/PlyParserUtils.js +311 -0
- package/src/loaders/index.js +13 -0
- package/src/materials/SplatMaterial.js +1065 -0
- package/src/materials/SplatMaterial2D.js +358 -0
- package/src/materials/SplatMaterial3D.js +278 -0
- package/src/materials/index.js +11 -0
- package/src/raycaster/Hit.js +37 -0
- package/src/raycaster/Ray.js +123 -0
- package/src/raycaster/Raycaster.js +175 -0
- package/src/raycaster/index.js +10 -0
- package/src/renderer/AnimationManager.js +574 -0
- package/src/renderer/AppConstants.js +101 -0
- package/src/renderer/GaussianSplatRenderer.js +695 -0
- package/src/renderer/index.js +24 -0
- package/src/utils/LoaderUtils.js +65 -0
- package/src/utils/Util.js +375 -0
- package/src/utils/index.js +9 -0
- package/src/worker/SortWorker.js +284 -0
- package/src/worker/index.js +8 -0
- package/dist/gsplat-flame-avatar-renderer.esm.min.js +0 -2
- package/dist/gsplat-flame-avatar-renderer.esm.min.js.map +0 -1
- package/dist/gsplat-flame-avatar-renderer.umd.js +0 -12876
- package/dist/gsplat-flame-avatar-renderer.umd.min.js +0 -2
- package/dist/gsplat-flame-avatar-renderer.umd.min.js.map +0 -1
- package/dist/gsplat-flame-avatar.esm.js +0 -12755
- package/dist/gsplat-flame-avatar.esm.js.map +0 -1
- package/dist/gsplat-flame-avatar.esm.min.js +0 -2
- package/dist/gsplat-flame-avatar.esm.min.js.map +0 -1
- package/dist/gsplat-flame-avatar.umd.js +0 -12876
- package/dist/gsplat-flame-avatar.umd.js.map +0 -1
- package/dist/gsplat-flame-avatar.umd.min.js +0 -2
- package/dist/gsplat-flame-avatar.umd.min.js.map +0 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GaussianSplatRenderer
|
|
3
|
+
*
|
|
4
|
+
* Derived from gaussian-splat-renderer-for-lam
|
|
5
|
+
*
|
|
6
|
+
* High-level orchestration class that:
|
|
7
|
+
* - Loads ZIP assets via fetch
|
|
8
|
+
* - Unpacks with JSZip
|
|
9
|
+
* - Creates Viewer instance
|
|
10
|
+
* - Loads FLAME/skin models
|
|
11
|
+
* - Runs the render loop
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/* global NProgress */
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Vector3,
|
|
18
|
+
Bone,
|
|
19
|
+
Clock,
|
|
20
|
+
AnimationMixer
|
|
21
|
+
} from 'three';
|
|
22
|
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
23
|
+
import JSZip from 'jszip';
|
|
24
|
+
|
|
25
|
+
// Import internal modules
|
|
26
|
+
import { TYVoiceChatState } from './AppConstants.js';
|
|
27
|
+
import { AnimationManager } from './AnimationManager.js';
|
|
28
|
+
import { Viewer } from '../core/Viewer.js';
|
|
29
|
+
import { SceneFormat } from '../enums/SceneFormat.js';
|
|
30
|
+
|
|
31
|
+
// Configuration objects - these would normally be loaded from the ZIP
|
|
32
|
+
const charactorConfig = {
|
|
33
|
+
camPos: { x: 0, y: 1.8, z: 1 },
|
|
34
|
+
camRot: { x: -10, y: 0, z: 0 },
|
|
35
|
+
backgroundColor: 'ffffff',
|
|
36
|
+
useFlame: 'false' // Match compact file default - use non-FLAME mode
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const motionConfig = {
|
|
40
|
+
offset: {},
|
|
41
|
+
scale: {}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Animation configuration - defines how animation clips are distributed to states
|
|
45
|
+
// The animation.glb contains clips in order: hello(2), idle(1), listen(0), speak(6), think(3)
|
|
46
|
+
const animationConfig = {
|
|
47
|
+
hello: { size: 2, isGroup: false },
|
|
48
|
+
idle: { size: 1, isGroup: false },
|
|
49
|
+
listen: { size: 0, isGroup: false },
|
|
50
|
+
speak: { size: 6, isGroup: false },
|
|
51
|
+
think: { size: 3, isGroup: true },
|
|
52
|
+
other: []
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* GaussianSplatRenderer - Main rendering class
|
|
57
|
+
*/
|
|
58
|
+
export class GaussianSplatRenderer {
|
|
59
|
+
// Static canvas element shared across instances
|
|
60
|
+
static _canvas = (typeof document !== 'undefined') ? document.createElement('canvas') : null;
|
|
61
|
+
|
|
62
|
+
// Singleton instance
|
|
63
|
+
static instance = undefined;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Factory method to create/get renderer instance
|
|
67
|
+
* @param {HTMLElement} container - DOM container for canvas
|
|
68
|
+
* @param {string} assetPath - URL to character ZIP file
|
|
69
|
+
* @param {object} options - Configuration options
|
|
70
|
+
* @returns {Promise<GaussianSplatRenderer>}
|
|
71
|
+
*/
|
|
72
|
+
static async getInstance(container, assetPath, options = {}) {
|
|
73
|
+
if (this.instance !== undefined) {
|
|
74
|
+
return this.instance;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const characterPath = assetPath;
|
|
79
|
+
|
|
80
|
+
// Parse character name from path
|
|
81
|
+
const url = new URL(characterPath, window.location.href);
|
|
82
|
+
const pathname = url.pathname;
|
|
83
|
+
const matches = pathname.match(/\/([^/]+?)\.zip/);
|
|
84
|
+
const characterName = matches && matches[1];
|
|
85
|
+
|
|
86
|
+
if (!characterName) {
|
|
87
|
+
throw new Error('character model is not found');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Show progress
|
|
91
|
+
if (typeof NProgress !== 'undefined') {
|
|
92
|
+
NProgress.start();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Download ZIP file
|
|
96
|
+
const characterZipResponse = await fetch(characterPath);
|
|
97
|
+
if (!characterZipResponse.ok) {
|
|
98
|
+
throw new Error(`Failed to download: ${characterZipResponse.statusText}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Report download progress
|
|
102
|
+
if (options.downloadProgress) {
|
|
103
|
+
options.downloadProgress(1.0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (options.loadProgress) {
|
|
107
|
+
options.loadProgress(0.1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof NProgress !== 'undefined') {
|
|
111
|
+
NProgress.done();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const arrayBuffer = await characterZipResponse.arrayBuffer();
|
|
115
|
+
|
|
116
|
+
// Load ZIP with imported JSZip
|
|
117
|
+
const zipData = await JSZip.loadAsync(arrayBuffer);
|
|
118
|
+
|
|
119
|
+
// Find folder name in ZIP
|
|
120
|
+
let fileName = '';
|
|
121
|
+
Object.values(zipData.files).forEach(file => {
|
|
122
|
+
if (file.dir) {
|
|
123
|
+
fileName = file.name?.slice(0, file.name?.length - 1); // Remove trailing '/'
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!fileName) {
|
|
128
|
+
throw new Error('file folder is not found');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create renderer instance
|
|
132
|
+
const renderer = new GaussianSplatRenderer(container, zipData);
|
|
133
|
+
|
|
134
|
+
// Setup camera position
|
|
135
|
+
const cameraPos = new Vector3();
|
|
136
|
+
cameraPos.x = charactorConfig.camPos?.x || 0;
|
|
137
|
+
cameraPos.y = charactorConfig.camPos?.y || 0;
|
|
138
|
+
cameraPos.z = charactorConfig.camPos?.z || 1;
|
|
139
|
+
|
|
140
|
+
const cameraRotation = new Vector3();
|
|
141
|
+
cameraRotation.x = charactorConfig.camRot?.x || 0;
|
|
142
|
+
cameraRotation.y = charactorConfig.camRot?.y || 0;
|
|
143
|
+
cameraRotation.z = charactorConfig.camRot?.z || 0;
|
|
144
|
+
|
|
145
|
+
// Background color
|
|
146
|
+
let backgroundColor = 0xffffff;
|
|
147
|
+
if (charactorConfig.backgroundColor) {
|
|
148
|
+
backgroundColor = parseInt(charactorConfig.backgroundColor, 16);
|
|
149
|
+
}
|
|
150
|
+
if (options?.backgroundColor && renderer.isHexColorStrict(options.backgroundColor)) {
|
|
151
|
+
backgroundColor = parseInt(options.backgroundColor, 16);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Store callbacks
|
|
155
|
+
renderer.getChatState = options?.getChatState;
|
|
156
|
+
renderer.getExpressionData = options?.getExpressionData;
|
|
157
|
+
|
|
158
|
+
// FLAME mode flag
|
|
159
|
+
if (charactorConfig.useFlame) {
|
|
160
|
+
renderer.useFlame = (charactorConfig.useFlame === 'false') ? false : true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Create Viewer with imported class
|
|
164
|
+
renderer.viewer = new Viewer({
|
|
165
|
+
rootElement: container,
|
|
166
|
+
threejsCanvas: GaussianSplatRenderer._canvas,
|
|
167
|
+
cameraUp: [0, 1, 0],
|
|
168
|
+
initialCameraPosition: [cameraPos.x, cameraPos.y, cameraPos.z],
|
|
169
|
+
initialCameraRotation: [cameraRotation.x, cameraRotation.y, cameraRotation.z],
|
|
170
|
+
sphericalHarmonicsDegree: 0,
|
|
171
|
+
backgroundColor: backgroundColor
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
renderer.viewer.useFlame = renderer.useFlame;
|
|
175
|
+
|
|
176
|
+
// Load model based on mode
|
|
177
|
+
if (renderer.viewer.useFlame === true) {
|
|
178
|
+
await renderer.loadFlameModel(fileName, motionConfig);
|
|
179
|
+
} else {
|
|
180
|
+
await renderer.loadModel(fileName, animationConfig, motionConfig);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (options.loadProgress) {
|
|
184
|
+
options.loadProgress(0.2);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Load offset PLY
|
|
188
|
+
const offsetFileUrl = await renderer.unpackFileAsBlob(fileName + '/offset.ply');
|
|
189
|
+
|
|
190
|
+
if (options.loadProgress) {
|
|
191
|
+
options.loadProgress(0.3);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Add splat scene
|
|
195
|
+
await renderer.viewer.addSplatScene(offsetFileUrl, {
|
|
196
|
+
progressiveLoad: true,
|
|
197
|
+
sharedMemoryForWorkers: false,
|
|
198
|
+
showLoadingUI: false,
|
|
199
|
+
format: SceneFormat.Ply
|
|
200
|
+
});
|
|
201
|
+
renderer.render();
|
|
202
|
+
|
|
203
|
+
if (options.loadProgress) {
|
|
204
|
+
options.loadProgress(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.instance = renderer;
|
|
208
|
+
return renderer;
|
|
209
|
+
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('GaussianSplatRenderer.getInstance error:', error);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Constructor
|
|
218
|
+
* @param {HTMLElement} _container - DOM container
|
|
219
|
+
* @param {JSZip} zipData - Loaded ZIP data
|
|
220
|
+
*/
|
|
221
|
+
constructor(_container, zipData) {
|
|
222
|
+
// ZIP file cache
|
|
223
|
+
this.zipUrls = {
|
|
224
|
+
urls: new Map(),
|
|
225
|
+
zip: zipData
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// State
|
|
229
|
+
this.useFlame = false;
|
|
230
|
+
this.lastTime = 0;
|
|
231
|
+
this.startTime = 0;
|
|
232
|
+
this.expressionData = {};
|
|
233
|
+
this.chatState = TYVoiceChatState.Idle;
|
|
234
|
+
|
|
235
|
+
// Setup canvas
|
|
236
|
+
if (GaussianSplatRenderer._canvas && _container) {
|
|
237
|
+
const { width, height } = _container.getBoundingClientRect();
|
|
238
|
+
GaussianSplatRenderer._canvas.style.visibility = 'visible';
|
|
239
|
+
GaussianSplatRenderer._canvas.width = width;
|
|
240
|
+
GaussianSplatRenderer._canvas.height = height;
|
|
241
|
+
_container.appendChild(GaussianSplatRenderer._canvas);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Animation timing
|
|
245
|
+
this.clock = new Clock();
|
|
246
|
+
this.startTime = performance.now() / 1000.0;
|
|
247
|
+
|
|
248
|
+
// These will be set during loading
|
|
249
|
+
this.viewer = null;
|
|
250
|
+
this.mixer = null;
|
|
251
|
+
this.animManager = null;
|
|
252
|
+
this.model = null;
|
|
253
|
+
this.motioncfg = null;
|
|
254
|
+
this.getChatState = null;
|
|
255
|
+
this.getExpressionData = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Dispose renderer and free resources
|
|
260
|
+
*/
|
|
261
|
+
dispose() {
|
|
262
|
+
if (GaussianSplatRenderer._canvas) {
|
|
263
|
+
GaussianSplatRenderer._canvas.style.visibility = 'hidden';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.disposeModel();
|
|
267
|
+
|
|
268
|
+
// Revoke all blob URLs
|
|
269
|
+
this.zipUrls.urls.forEach((value) => {
|
|
270
|
+
URL.revokeObjectURL(value);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
GaussianSplatRenderer.instance = undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Dispose model-specific resources
|
|
278
|
+
*/
|
|
279
|
+
disposeModel() {
|
|
280
|
+
if (this.mixer) {
|
|
281
|
+
this.mixer.stopAllAction();
|
|
282
|
+
if (this.viewer?.avatarMesh) {
|
|
283
|
+
this.mixer.uncacheRoot(this.viewer.avatarMesh);
|
|
284
|
+
}
|
|
285
|
+
this.mixer = undefined;
|
|
286
|
+
this.animManager?.dispose();
|
|
287
|
+
}
|
|
288
|
+
this.viewer?.dispose();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get the Three.js camera
|
|
293
|
+
* @returns {THREE.Camera}
|
|
294
|
+
*/
|
|
295
|
+
getCamera() {
|
|
296
|
+
return this.viewer?.camera;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Update blendshape weights from action data
|
|
301
|
+
* @param {object} actionData - Blendshape weights
|
|
302
|
+
* @returns {object} Processed influence values
|
|
303
|
+
*/
|
|
304
|
+
updateBS(actionData) {
|
|
305
|
+
// Default influence values - all 52 ARKit blendshapes
|
|
306
|
+
let influence = {
|
|
307
|
+
browDownLeft: 0.0,
|
|
308
|
+
browDownRight: 0.0,
|
|
309
|
+
browInnerUp: 0.0,
|
|
310
|
+
browOuterUpLeft: 0.0,
|
|
311
|
+
browOuterUpRight: 0.0,
|
|
312
|
+
mouthCheekPuff: 0.0,
|
|
313
|
+
cheekSquintLeft: 0.0,
|
|
314
|
+
cheekSquintRight: 0.0,
|
|
315
|
+
eyeBlinkLeft: 0.0,
|
|
316
|
+
eyeBlinkRight: 0.0,
|
|
317
|
+
eyeLookDownLeft: 0.0,
|
|
318
|
+
eyeLookDownRight: 0.0,
|
|
319
|
+
eyeLookInLeft: 0.0,
|
|
320
|
+
eyeLookInRight: 0.0,
|
|
321
|
+
eyeLookOutLeft: 0.0,
|
|
322
|
+
eyeLookOutRight: 0.0,
|
|
323
|
+
eyeLookUpLeft: 0.0,
|
|
324
|
+
eyeLookUpRight: 0.0,
|
|
325
|
+
eyeSquintLeft: 0.0,
|
|
326
|
+
eyeSquintRight: 0.0,
|
|
327
|
+
eyeWideLeft: 0.0,
|
|
328
|
+
eyeWideRight: 0.0,
|
|
329
|
+
jawForward: 0.0,
|
|
330
|
+
jawLeft: 0.0,
|
|
331
|
+
jawOpen: 0.0,
|
|
332
|
+
jawRight: 0.0,
|
|
333
|
+
mouthClose: 0.0,
|
|
334
|
+
mouthDimpleLeft: 0.0,
|
|
335
|
+
mouthDimpleRight: 0.0,
|
|
336
|
+
mouthFrownLeft: 0.0,
|
|
337
|
+
mouthFrownRight: 0.0,
|
|
338
|
+
mouthFunnel: 0.0,
|
|
339
|
+
mouthLeft: 0.0,
|
|
340
|
+
mouthLowerDownLeft: 0.0,
|
|
341
|
+
mouthLowerDownRight: 0.0,
|
|
342
|
+
mouthPressLeft: 0.0,
|
|
343
|
+
mouthPressRight: 0.0,
|
|
344
|
+
mouthPucker: 0.0,
|
|
345
|
+
mouthRight: 0.0,
|
|
346
|
+
mouthRollLower: 0.0,
|
|
347
|
+
mouthRollUpper: 0.0,
|
|
348
|
+
mouthShrugLower: 0.0,
|
|
349
|
+
mouthShrugUpper: 0.0,
|
|
350
|
+
mouthSmileLeft: 0.0,
|
|
351
|
+
mouthSmileRight: 0.0,
|
|
352
|
+
mouthStretchLeft: 0.0,
|
|
353
|
+
mouthStretchRight: 0.0,
|
|
354
|
+
mouthUpperUpLeft: 0.0,
|
|
355
|
+
mouthUpperUpRight: 0.0,
|
|
356
|
+
noseSneerLeft: 0.0,
|
|
357
|
+
noseSneerRight: 0.0,
|
|
358
|
+
tongueOut: 0.0
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (actionData != null) {
|
|
362
|
+
influence = actionData;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return influence;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Main render loop
|
|
370
|
+
*/
|
|
371
|
+
render() {
|
|
372
|
+
if (this.viewer && this.viewer.selfDrivenMode) {
|
|
373
|
+
this.viewer.requestFrameId = requestAnimationFrame(() => this.render());
|
|
374
|
+
|
|
375
|
+
const frameInfoInternal = 1.0 / 30.0;
|
|
376
|
+
const currentTime = performance.now() / 1000;
|
|
377
|
+
|
|
378
|
+
// Prevent division by zero if totalFrames is 0 or not set
|
|
379
|
+
const totalFrames = this.viewer.totalFrames || 1;
|
|
380
|
+
const calcDelta = (currentTime - this.startTime) % (totalFrames * frameInfoInternal);
|
|
381
|
+
const frameIndex = Math.floor(calcDelta / frameInfoInternal);
|
|
382
|
+
this.viewer.frame = frameIndex;
|
|
383
|
+
|
|
384
|
+
// Update chat state
|
|
385
|
+
if (this.getChatState) {
|
|
386
|
+
this.chatState = this.getChatState();
|
|
387
|
+
// DEBUG: Log state transitions
|
|
388
|
+
if (!this._lastLoggedState || this._lastLoggedState !== this.chatState) {
|
|
389
|
+
console.log('[ANIM] Chat state changed to:', this.chatState, 'animManager exists:', !!this.animManager);
|
|
390
|
+
this._lastLoggedState = this.chatState;
|
|
391
|
+
}
|
|
392
|
+
this.animManager?.update(this.chatState);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Update expression data
|
|
396
|
+
if (this.getExpressionData) {
|
|
397
|
+
this.expressionData = this.updateBS(this.getExpressionData());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Non-FLAME mode: animation mixer
|
|
401
|
+
if (this.viewer.useFlame === false) {
|
|
402
|
+
if (!this.mixer || !this.animManager) {
|
|
403
|
+
if (!this._warnedOnce) {
|
|
404
|
+
console.warn('render: mixer or animManager not initialized, skipping animation update');
|
|
405
|
+
console.log('[ANIM] useFlame:', this.viewer.useFlame, 'mixer:', !!this.mixer, 'animManager:', !!this.animManager);
|
|
406
|
+
this._warnedOnce = true;
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
const mixerUpdateDelta = this.clock.getDelta();
|
|
410
|
+
this.mixer.update(mixerUpdateDelta);
|
|
411
|
+
|
|
412
|
+
// Apply motion config offsets/scales
|
|
413
|
+
if (this.motioncfg) {
|
|
414
|
+
for (const morphTarget in this.expressionData) {
|
|
415
|
+
const offset = this.motioncfg.offset?.[morphTarget];
|
|
416
|
+
const scale = this.motioncfg.scale?.[morphTarget];
|
|
417
|
+
if (offset !== undefined && scale !== undefined) {
|
|
418
|
+
this.expressionData[morphTarget] =
|
|
419
|
+
this.expressionData[morphTarget] * scale + offset;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this.setExpression();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Update viewer
|
|
429
|
+
this.viewer.update(this.viewer.renderer, this.viewer.camera);
|
|
430
|
+
|
|
431
|
+
// Render if needed
|
|
432
|
+
const shouldRender = this.viewer.shouldRender();
|
|
433
|
+
if (this._renderLogCount <= 3) {
|
|
434
|
+
console.log('[GS-DEBUG] shouldRender:', shouldRender);
|
|
435
|
+
}
|
|
436
|
+
if (shouldRender) {
|
|
437
|
+
this.viewer.render();
|
|
438
|
+
this.viewer.consecutiveRenderFrames++;
|
|
439
|
+
} else {
|
|
440
|
+
this.viewer.consecutiveRenderFrames = 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
this.viewer.renderNextFrame = false;
|
|
444
|
+
this.viewer.selfDrivenModeRunning = true;
|
|
445
|
+
} else {
|
|
446
|
+
throw new Error('Cannot start viewer unless it is in self driven mode.');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Validate hex color string
|
|
452
|
+
* @param {string} value - Color string to validate
|
|
453
|
+
* @returns {boolean}
|
|
454
|
+
*/
|
|
455
|
+
isHexColorStrict(value) {
|
|
456
|
+
if (typeof value !== 'string') return false;
|
|
457
|
+
const hexColorRegex = /^(#|0x)[0-9A-Fa-f]{6}$/i;
|
|
458
|
+
return hexColorRegex.test(value);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Apply expression data to mesh
|
|
463
|
+
*/
|
|
464
|
+
setExpression() {
|
|
465
|
+
// Update splat mesh blendshapes
|
|
466
|
+
if (this.viewer?.splatMesh) {
|
|
467
|
+
this.viewer.splatMesh.bsWeight = this.expressionData;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Update morph targets on avatar model
|
|
471
|
+
if (this.model) {
|
|
472
|
+
this.model.traverse((object) => {
|
|
473
|
+
if (object.isMesh || object.isSkinnedMesh) {
|
|
474
|
+
const morphAttributes = object.geometry?.morphAttributes;
|
|
475
|
+
const hasMorphTargets = morphAttributes && Object.keys(morphAttributes).length > 0;
|
|
476
|
+
|
|
477
|
+
if (hasMorphTargets) {
|
|
478
|
+
const morphTargetDictionary = object.morphTargetDictionary;
|
|
479
|
+
for (const morphTarget in morphTargetDictionary) {
|
|
480
|
+
const target = morphTargetDictionary[morphTarget];
|
|
481
|
+
const data = this.expressionData[morphTarget];
|
|
482
|
+
if (data !== undefined) {
|
|
483
|
+
object.morphTargetInfluences[target] = Math.max(0.0, Math.min(1.0, data));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Load FLAME model from ZIP
|
|
494
|
+
* @param {string} pathName - Path within ZIP
|
|
495
|
+
* @param {object} motionConfig - Motion configuration
|
|
496
|
+
*/
|
|
497
|
+
async loadFlameModel(pathName, motionConfig) {
|
|
498
|
+
// Load all required files in parallel
|
|
499
|
+
const [skinModel, lbs_weight_80k, flame_params, indexes, bone_tree] = await Promise.all([
|
|
500
|
+
this.unpackAndLoadGlb(pathName + '/skin.glb'),
|
|
501
|
+
this.unpackAndLoadJson(pathName + '/lbs_weight_20k.json'),
|
|
502
|
+
this.unpackAndLoadJson(pathName + '/flame_params.json'),
|
|
503
|
+
this.unpackAndLoadJson(pathName + '/vertex_order.json'),
|
|
504
|
+
this.unpackAndLoadJson(pathName + '/bone_tree.json')
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
if (!this.viewer) {
|
|
508
|
+
throw new Error('render viewer is not initialized');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Find skinned mesh and bone root
|
|
512
|
+
let skinModelSkinnedMesh;
|
|
513
|
+
let boneRoot;
|
|
514
|
+
|
|
515
|
+
skinModel.traverse((object) => {
|
|
516
|
+
if (object.isSkinnedMesh) {
|
|
517
|
+
skinModelSkinnedMesh = object;
|
|
518
|
+
}
|
|
519
|
+
if (object instanceof Bone && object.name === 'hip') {
|
|
520
|
+
boneRoot = object;
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Set viewer properties
|
|
525
|
+
this.viewer.sortedIndexes = indexes;
|
|
526
|
+
this.viewer.flame_params = flame_params;
|
|
527
|
+
this.viewer.lbs_weight_80k = lbs_weight_80k;
|
|
528
|
+
this.viewer.bone_tree = bone_tree;
|
|
529
|
+
this.viewer.totalFrames = flame_params['expr']?.length || 1;
|
|
530
|
+
|
|
531
|
+
if (skinModelSkinnedMesh) {
|
|
532
|
+
this.viewer.gaussianSplatCount = skinModelSkinnedMesh.geometry.attributes.position.count;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
this.viewer.avatarMesh = skinModel;
|
|
536
|
+
this.viewer.skinModel = skinModelSkinnedMesh;
|
|
537
|
+
this.viewer.boneRoot = boneRoot;
|
|
538
|
+
this.motioncfg = motionConfig;
|
|
539
|
+
|
|
540
|
+
// Update morph targets
|
|
541
|
+
if (skinModelSkinnedMesh) {
|
|
542
|
+
this.viewer.updateMorphTarget(skinModelSkinnedMesh);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Add to scene (hidden)
|
|
546
|
+
this.viewer.threeScene.add(skinModel);
|
|
547
|
+
skinModel.visible = false;
|
|
548
|
+
|
|
549
|
+
// Compute bone texture
|
|
550
|
+
if (skinModelSkinnedMesh) {
|
|
551
|
+
skinModelSkinnedMesh.skeleton.computeBoneTexture();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Load non-FLAME model with animation
|
|
557
|
+
* @param {string} pathName - Path within ZIP
|
|
558
|
+
* @param {object} animationConfig - Animation configuration
|
|
559
|
+
* @param {object} motionConfig - Motion configuration
|
|
560
|
+
*/
|
|
561
|
+
async loadModel(pathName, animationConfig, motionConfig) {
|
|
562
|
+
const [skinModel, aniclip, indexes] = await Promise.all([
|
|
563
|
+
this.unpackAndLoadGlb(pathName + '/skin.glb'),
|
|
564
|
+
this.unpackAndLoadGlb(pathName + '/animation.glb'),
|
|
565
|
+
this.unpackAndLoadJson(pathName + '/vertex_order.json')
|
|
566
|
+
]);
|
|
567
|
+
|
|
568
|
+
if (!this.viewer) {
|
|
569
|
+
throw new Error('render viewer is not initialized');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let skinModelSkinnedMesh;
|
|
573
|
+
let boneRoot;
|
|
574
|
+
|
|
575
|
+
skinModel.traverse((object) => {
|
|
576
|
+
if (object.isSkinnedMesh) {
|
|
577
|
+
skinModelSkinnedMesh = object;
|
|
578
|
+
}
|
|
579
|
+
if (object instanceof Bone && object.name === 'hip') {
|
|
580
|
+
boneRoot = object;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
this.viewer.sortedIndexes = indexes;
|
|
585
|
+
|
|
586
|
+
if (skinModelSkinnedMesh) {
|
|
587
|
+
this.viewer.gaussianSplatCount = skinModelSkinnedMesh.geometry.attributes.position.count;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
this.viewer.avatarMesh = skinModel;
|
|
591
|
+
this.viewer.skinModel = skinModelSkinnedMesh;
|
|
592
|
+
this.viewer.boneRoot = boneRoot;
|
|
593
|
+
|
|
594
|
+
// Setup animation
|
|
595
|
+
this.mixer = new AnimationMixer(skinModel);
|
|
596
|
+
this.animManager = new AnimationManager(this.mixer, aniclip, animationConfig);
|
|
597
|
+
this.motioncfg = motionConfig;
|
|
598
|
+
|
|
599
|
+
// Set totalFrames from animation clips or default to 1
|
|
600
|
+
if (Array.isArray(aniclip) && aniclip.length > 0 && aniclip[0].duration) {
|
|
601
|
+
this.viewer.totalFrames = Math.floor(aniclip[0].duration * 30); // 30 fps
|
|
602
|
+
} else {
|
|
603
|
+
this.viewer.totalFrames = 1;
|
|
604
|
+
}
|
|
605
|
+
console.log('loadModel: totalFrames set to', this.viewer.totalFrames);
|
|
606
|
+
|
|
607
|
+
if (skinModelSkinnedMesh) {
|
|
608
|
+
this.viewer.updateMorphTarget(skinModelSkinnedMesh);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.viewer.threeScene.add(skinModel);
|
|
612
|
+
skinModel.visible = false;
|
|
613
|
+
|
|
614
|
+
if (skinModelSkinnedMesh) {
|
|
615
|
+
skinModelSkinnedMesh.skeleton.computeBoneTexture();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Unpack file from ZIP as blob URL
|
|
621
|
+
* @param {string} path - Path within ZIP
|
|
622
|
+
* @returns {Promise<string>} Blob URL
|
|
623
|
+
*/
|
|
624
|
+
async unpackFileAsBlob(path) {
|
|
625
|
+
if (!this.zipUrls.urls.has(path)) {
|
|
626
|
+
const modelFile = await this.zipUrls.zip?.file(path)?.async('blob');
|
|
627
|
+
if (modelFile) {
|
|
628
|
+
const modelUrl = URL.createObjectURL(modelFile);
|
|
629
|
+
this.zipUrls.urls.set(path, modelUrl);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return this.zipUrls.urls.get(path);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Unpack and load GLB file
|
|
637
|
+
* @param {string} path - Path within ZIP
|
|
638
|
+
* @returns {Promise<THREE.Group|THREE.AnimationClip[]>}
|
|
639
|
+
*/
|
|
640
|
+
async unpackAndLoadGlb(path) {
|
|
641
|
+
if (!this.zipUrls.urls.has(path)) {
|
|
642
|
+
const modelFile = await this.zipUrls.zip?.file(path)?.async('arraybuffer');
|
|
643
|
+
if (modelFile) {
|
|
644
|
+
const blob = new Blob([modelFile], { type: 'model/gltf-binary' });
|
|
645
|
+
const modelUrl = URL.createObjectURL(blob);
|
|
646
|
+
this.zipUrls.urls.set(path, modelUrl);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return this.LoadGLTF(this.zipUrls.urls.get(path));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Unpack and parse JSON file
|
|
654
|
+
* @param {string} path - Path within ZIP
|
|
655
|
+
* @returns {Promise<object>}
|
|
656
|
+
*/
|
|
657
|
+
async unpackAndLoadJson(path) {
|
|
658
|
+
const file = this.zipUrls.zip?.file(path);
|
|
659
|
+
if (!file) {
|
|
660
|
+
throw new Error(`File not found in ZIP: ${path}`);
|
|
661
|
+
}
|
|
662
|
+
const jsonFile = await file.async('string');
|
|
663
|
+
if (!jsonFile) {
|
|
664
|
+
throw new Error(`Failed to read file from ZIP: ${path}`);
|
|
665
|
+
}
|
|
666
|
+
return JSON.parse(jsonFile);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Load GLTF file
|
|
671
|
+
* @param {string} url - URL to GLTF/GLB file
|
|
672
|
+
* @returns {Promise<THREE.Group|THREE.AnimationClip[]>}
|
|
673
|
+
*/
|
|
674
|
+
async LoadGLTF(url) {
|
|
675
|
+
return new Promise((resolve, reject) => {
|
|
676
|
+
const loader = new GLTFLoader();
|
|
677
|
+
loader.load(
|
|
678
|
+
url,
|
|
679
|
+
(gltf) => {
|
|
680
|
+
if (gltf.animations.length > 0) {
|
|
681
|
+
resolve(gltf.animations);
|
|
682
|
+
} else {
|
|
683
|
+
resolve(gltf.scene);
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
undefined,
|
|
687
|
+
(error) => {
|
|
688
|
+
reject(error);
|
|
689
|
+
}
|
|
690
|
+
);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export default GaussianSplatRenderer;
|