|
8 | 8 | let model; |
9 | 9 | let frameId; |
10 | 10 |
|
| 11 | + // Animation state |
| 12 | + let clock; |
| 13 | +
|
11 | 14 | let targetRotX = 0; |
12 | 15 | let targetRotY = 0; |
13 | 16 | let currentRotX = 0; |
14 | 17 | let currentRotY = 0; |
15 | 18 | let t = 0; |
16 | 19 |
|
| 20 | + // Procedural face controls |
| 21 | + export let speaking = false; |
| 22 | + export let autoBlink = true; |
| 23 | +
|
| 24 | + // Morph target tracking |
| 25 | + const morphMeshes = []; |
| 26 | + let idx = { |
| 27 | + EyeBlinkLeft: null, |
| 28 | + EyeBlinkRight: null, |
| 29 | + Blink: null, |
| 30 | + JawOpen: null, |
| 31 | + MouthOpen: null |
| 32 | + }; |
| 33 | +
|
| 34 | + // Blink state |
| 35 | + let nextBlinkIn = 0; // seconds |
| 36 | + let blinkTime = 0; // 0..1 progress of current blink |
| 37 | + let isBlinking = false; |
| 38 | +
|
17 | 39 | const DPR = Math.min(window.devicePixelRatio || 1, 2); |
18 | 40 | export let src = '/face.glb'; |
19 | 41 | export let removeHands = false; |
|
40 | 62 |
|
41 | 63 | function animate() { |
42 | 64 | frameId = requestAnimationFrame(animate); |
43 | | - t += 0.01; |
| 65 | + const dt = Math.min(0.05, clock?.getDelta?.() || 0.016); |
| 66 | + t += dt; |
44 | 67 |
|
45 | 68 | // smooth follow |
46 | 69 | currentRotX += (targetRotX - currentRotX) * 0.08; |
|
53 | 76 | model.position.y = Math.sin(t) * 0.03; |
54 | 77 | } |
55 | 78 |
|
| 79 | + // Procedural facial animation (morph targets only, if available) |
| 80 | + if (morphMeshes.length) { |
| 81 | + // Blink logic |
| 82 | + if (autoBlink) { |
| 83 | + nextBlinkIn -= dt; |
| 84 | + if (!isBlinking && nextBlinkIn <= 0) { |
| 85 | + isBlinking = true; |
| 86 | + blinkTime = 0; |
| 87 | + } |
| 88 | + if (isBlinking) { |
| 89 | + const blinkDuration = 0.18; // seconds |
| 90 | + blinkTime += dt / blinkDuration; |
| 91 | + const x = Math.min(1, blinkTime); |
| 92 | + // fast ease-in-out curve |
| 93 | + const ease = x < 0.5 ? (2 * x * x) : (1 - Math.pow(-2 * x + 2, 2) / 2); |
| 94 | + const blinkVal = 1.0 * ease; // 0..1 |
| 95 | + setMorphValue('Blink', blinkVal); |
| 96 | + setMorphValue('EyeBlinkLeft', blinkVal); |
| 97 | + setMorphValue('EyeBlinkRight', blinkVal); |
| 98 | + if (blinkTime >= 1) { |
| 99 | + isBlinking = false; |
| 100 | + // reset influences to open |
| 101 | + setMorphValue('Blink', 0); |
| 102 | + setMorphValue('EyeBlinkLeft', 0); |
| 103 | + setMorphValue('EyeBlinkRight', 0); |
| 104 | + // schedule next blink in 3-7s |
| 105 | + nextBlinkIn = 3 + Math.random() * 4; |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | +
|
| 110 | + // Speaking / mouth movement |
| 111 | + const mouthTarget = idx.JawOpen != null ? 'JawOpen' : (idx.MouthOpen != null ? 'MouthOpen' : null); |
| 112 | + if (mouthTarget) { |
| 113 | + const current = getMorphValue(mouthTarget); |
| 114 | + let target = 0; |
| 115 | + if (speaking) { |
| 116 | + // Layered sines pseudo-random mouth movement |
| 117 | + const n = Math.abs(Math.sin(t * 7) * 0.6 + Math.sin(t * 13.1 + 1.7) * 0.4); |
| 118 | + target = Math.min(1, Math.max(0, n)); |
| 119 | + target = target * 0.7; // limit openness |
| 120 | + } |
| 121 | + const newVal = current + (target - current) * 0.25; // smooth |
| 122 | + setMorphValue(mouthTarget, newVal); |
| 123 | + } |
| 124 | + } |
| 125 | +
|
56 | 126 | renderer.render(scene, camera); |
57 | 127 | } |
58 | 128 |
|
|
139 | 209 | camera.position.set(0, 0, dist); |
140 | 210 | camera.lookAt(0, 0, 0); |
141 | 211 | scene.add(model); |
| 212 | +
|
| 213 | + // Detect morph targets |
| 214 | + morphMeshes.length = 0; |
| 215 | + idx = { EyeBlinkLeft: null, EyeBlinkRight: null, Blink: null, JawOpen: null, MouthOpen: null }; |
| 216 | + model.traverse((o) => { |
| 217 | + if (o && o.isMesh && o.morphTargetDictionary && o.morphTargetInfluences) { |
| 218 | + const dict = o.morphTargetDictionary; |
| 219 | + const found = {}; |
| 220 | + for (const key in dict) { |
| 221 | + const k = key.toLowerCase(); |
| 222 | + if (k.includes('blink') && !('Blink' in found)) found['Blink'] = dict[key]; |
| 223 | + if (k === 'eyeblinkleft') found['EyeBlinkLeft'] = dict[key]; |
| 224 | + if (k === 'eyeblinkright') found['EyeBlinkRight'] = dict[key]; |
| 225 | + if ((k.includes('jawopen') || k === 'jawopen') && !('JawOpen' in found)) found['JawOpen'] = dict[key]; |
| 226 | + if ((k.includes('mouthopen') || k === 'mouthopen') && !('MouthOpen' in found)) found['MouthOpen'] = dict[key]; |
| 227 | + } |
| 228 | + // Record any indices we discovered |
| 229 | + for (const n in found) { |
| 230 | + if (idx[n] == null) idx[n] = found[n]; |
| 231 | + } |
| 232 | + // Only keep meshes that have any of our targets |
| 233 | + if (Object.keys(found).length) morphMeshes.push(o); |
| 234 | + } |
| 235 | + }); |
142 | 236 | }, |
143 | 237 | undefined, |
144 | 238 | (err) => { |
|
149 | 243 | resize(); |
150 | 244 | window.addEventListener('resize', resize); |
151 | 245 | container.addEventListener('pointermove', onPointerMove); |
| 246 | + clock = new THREE.Clock(); |
| 247 | + // schedule initial blink |
| 248 | + nextBlinkIn = 1.5 + Math.random() * 2.0; |
152 | 249 | animate(); |
153 | 250 | } |
154 | 251 |
|
|
163 | 260 | renderer?.dispose(); |
164 | 261 | pmrem?.dispose?.(); |
165 | 262 | }); |
| 263 | +
|
| 264 | + function setMorphValue(name, value) { |
| 265 | + if (!morphMeshes.length) return; |
| 266 | + for (const m of morphMeshes) { |
| 267 | + const dict = m.morphTargetDictionary; |
| 268 | + const infl = m.morphTargetInfluences; |
| 269 | + if (!dict || !infl) continue; |
| 270 | + const i = dict[name] ?? idx[name]; |
| 271 | + if (i != null && infl[i] != null) infl[i] = value; |
| 272 | + } |
| 273 | + } |
| 274 | +
|
| 275 | + function getMorphValue(name) { |
| 276 | + if (!morphMeshes.length) return 0; |
| 277 | + const m = morphMeshes[0]; |
| 278 | + const dict = m.morphTargetDictionary; |
| 279 | + const infl = m.morphTargetInfluences; |
| 280 | + const i = dict?.[name] ?? idx[name]; |
| 281 | + return i != null && infl?.[i] != null ? infl[i] : 0; |
| 282 | + } |
166 | 283 | </script> |
167 | 284 |
|
168 | 285 | <div class="viewer" bind:this={container}> |
|
0 commit comments