Skip to content

Commit 7b3b3f2

Browse files
committed
Mouth movement with question answers
1 parent 489cab9 commit 7b3b3f2

3 files changed

Lines changed: 134 additions & 4 deletions

File tree

src/App.svelte

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,24 @@
33
import QA from './lib/QA.svelte'
44
const title = 'danBot'
55
const subtitle = 'Ask me anything'
6+
let speaking = false
7+
let speakTimer
8+
function onAnswering(e) {
9+
const answer = e?.detail?.item?.answer || ''
10+
const dur = Math.min(6000, Math.max(1500, answer.length * 35))
11+
speaking = true
12+
clearTimeout(speakTimer)
13+
speakTimer = setTimeout(() => { speaking = false }, dur)
14+
}
615
</script>
716
817
<main class="wrap">
918
<header class="hero">
1019
<h1>{title}</h1>
1120
<p>{subtitle}</p>
1221
</header>
13-
<FaceViewer />
14-
<QA />
22+
<FaceViewer {speaking} />
23+
<QA on:answering={onAnswering} />
1524
<footer class="foot">
1625
<a href="https://github.com/danrose499/danBot" target="_blank" rel="noreferrer">GitHub</a>
1726
</footer>
@@ -43,6 +52,7 @@
4352
}
4453
.foot {
4554
margin-top: auto;
55+
margin-bottom: 5rem;
4656
opacity: 0.8;
4757
}
4858
.foot a { color: #7aa2ff; }

src/lib/FaceViewer.svelte

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,34 @@
88
let model;
99
let frameId;
1010
11+
// Animation state
12+
let clock;
13+
1114
let targetRotX = 0;
1215
let targetRotY = 0;
1316
let currentRotX = 0;
1417
let currentRotY = 0;
1518
let t = 0;
1619
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+
1739
const DPR = Math.min(window.devicePixelRatio || 1, 2);
1840
export let src = '/face.glb';
1941
export let removeHands = false;
@@ -40,7 +62,8 @@
4062
4163
function animate() {
4264
frameId = requestAnimationFrame(animate);
43-
t += 0.01;
65+
const dt = Math.min(0.05, clock?.getDelta?.() || 0.016);
66+
t += dt;
4467
4568
// smooth follow
4669
currentRotX += (targetRotX - currentRotX) * 0.08;
@@ -53,6 +76,53 @@
5376
model.position.y = Math.sin(t) * 0.03;
5477
}
5578
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+
56126
renderer.render(scene, camera);
57127
}
58128
@@ -139,6 +209,30 @@
139209
camera.position.set(0, 0, dist);
140210
camera.lookAt(0, 0, 0);
141211
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+
});
142236
},
143237
undefined,
144238
(err) => {
@@ -149,6 +243,9 @@
149243
resize();
150244
window.addEventListener('resize', resize);
151245
container.addEventListener('pointermove', onPointerMove);
246+
clock = new THREE.Clock();
247+
// schedule initial blink
248+
nextBlinkIn = 1.5 + Math.random() * 2.0;
152249
animate();
153250
}
154251
@@ -163,6 +260,26 @@
163260
renderer?.dispose();
164261
pmrem?.dispose?.();
165262
});
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+
}
166283
</script>
167284
168285
<div class="viewer" bind:this={container}>

src/lib/QA.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
<script>
22
import { qa } from './qa.js';
3+
import { createEventDispatcher } from 'svelte';
4+
const dispatch = createEventDispatcher();
35
let selected = qa[0];
46
function select(item) {
57
selected = item;
8+
dispatch('answering', { item });
69
}
710
</script>
811

@@ -23,7 +26,7 @@
2326

2427
<style>
2528
.qa {
26-
width: 100%;
29+
width: 75%;
2730
max-width: none;
2831
margin: 0.5rem auto;
2932
padding: 0 0 0.75rem;

0 commit comments

Comments
 (0)