@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.
Files changed (66) hide show
  1. package/README.md +6 -36
  2. package/dist/gsplat-flame-avatar-renderer.cjs.js +12875 -0
  3. package/dist/{gsplat-flame-avatar-renderer.umd.js.map → gsplat-flame-avatar-renderer.cjs.js.map} +1 -1
  4. package/dist/gsplat-flame-avatar-renderer.esm.js +1 -1
  5. package/package.json +6 -11
  6. package/src/api/index.js +7 -0
  7. package/src/buffers/SplatBuffer.js +1394 -0
  8. package/src/buffers/SplatBufferGenerator.js +41 -0
  9. package/src/buffers/SplatPartitioner.js +110 -0
  10. package/src/buffers/UncompressedSplatArray.js +106 -0
  11. package/src/buffers/index.js +11 -0
  12. package/src/core/SplatGeometry.js +48 -0
  13. package/src/core/SplatMesh.js +2620 -0
  14. package/src/core/SplatScene.js +43 -0
  15. package/src/core/SplatTree.js +200 -0
  16. package/src/core/Viewer.js +2895 -0
  17. package/src/core/index.js +13 -0
  18. package/src/enums/EngineConstants.js +58 -0
  19. package/src/enums/LogLevel.js +13 -0
  20. package/src/enums/RenderMode.js +11 -0
  21. package/src/enums/SceneFormat.js +21 -0
  22. package/src/enums/SceneRevealMode.js +11 -0
  23. package/src/enums/SplatRenderMode.js +10 -0
  24. package/src/enums/index.js +13 -0
  25. package/src/flame/FlameAnimator.js +271 -0
  26. package/src/flame/FlameConstants.js +21 -0
  27. package/src/flame/FlameTextureManager.js +293 -0
  28. package/src/flame/index.js +22 -0
  29. package/src/flame/utils.js +50 -0
  30. package/src/index.js +39 -0
  31. package/src/loaders/DirectLoadError.js +14 -0
  32. package/src/loaders/INRIAV1PlyParser.js +223 -0
  33. package/src/loaders/PlyLoader.js +261 -0
  34. package/src/loaders/PlyParser.js +19 -0
  35. package/src/loaders/PlyParserUtils.js +311 -0
  36. package/src/loaders/index.js +13 -0
  37. package/src/materials/SplatMaterial.js +1065 -0
  38. package/src/materials/SplatMaterial2D.js +358 -0
  39. package/src/materials/SplatMaterial3D.js +278 -0
  40. package/src/materials/index.js +11 -0
  41. package/src/raycaster/Hit.js +37 -0
  42. package/src/raycaster/Ray.js +123 -0
  43. package/src/raycaster/Raycaster.js +175 -0
  44. package/src/raycaster/index.js +10 -0
  45. package/src/renderer/AnimationManager.js +574 -0
  46. package/src/renderer/AppConstants.js +101 -0
  47. package/src/renderer/GaussianSplatRenderer.js +695 -0
  48. package/src/renderer/index.js +24 -0
  49. package/src/utils/LoaderUtils.js +65 -0
  50. package/src/utils/Util.js +375 -0
  51. package/src/utils/index.js +9 -0
  52. package/src/worker/SortWorker.js +284 -0
  53. package/src/worker/index.js +8 -0
  54. package/dist/gsplat-flame-avatar-renderer.esm.min.js +0 -2
  55. package/dist/gsplat-flame-avatar-renderer.esm.min.js.map +0 -1
  56. package/dist/gsplat-flame-avatar-renderer.umd.js +0 -12876
  57. package/dist/gsplat-flame-avatar-renderer.umd.min.js +0 -2
  58. package/dist/gsplat-flame-avatar-renderer.umd.min.js.map +0 -1
  59. package/dist/gsplat-flame-avatar.esm.js +0 -12755
  60. package/dist/gsplat-flame-avatar.esm.js.map +0 -1
  61. package/dist/gsplat-flame-avatar.esm.min.js +0 -2
  62. package/dist/gsplat-flame-avatar.esm.min.js.map +0 -1
  63. package/dist/gsplat-flame-avatar.umd.js +0 -12876
  64. package/dist/gsplat-flame-avatar.umd.js.map +0 -1
  65. package/dist/gsplat-flame-avatar.umd.min.js +0 -2
  66. 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;