216 lines
10 KiB
JavaScript
216 lines
10 KiB
JavaScript
import * as THREE from 'three';
|
|
import { GLTFLoader } from './vendor/GLTFLoader.js';
|
|
|
|
const COLORS = { LISTENING: '#00ff88', THINKING: '#ffcc00', PROCESSING: '#ffcc00', SPEAKING: '#ff6b00', SLEEPING: '#3a8a9a', MUTED: '#ff3366' };
|
|
const canvas = document.getElementById('scene');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color('#00060a');
|
|
const camera = new THREE.PerspectiveCamera(28, 1, 0.05, 50);
|
|
|
|
const hemi = new THREE.HemisphereLight('#bfe8ff', '#0a1a2a', 1.2);
|
|
const key = new THREE.DirectionalLight('#ffffff', 2.2); key.position.set(1.2, 2.4, 2.0);
|
|
const fill = new THREE.DirectionalLight('#9fd8ff', 0.8); fill.position.set(-1.5, 1.0, 1.5);
|
|
const rim = new THREE.DirectionalLight('#00d4ff', 1.6); rim.position.set(-2, 1.5, -2);
|
|
scene.add(hemi, key, fill, rim);
|
|
|
|
const state = { name: 'LISTENING', level: 0, open: 0, width: 0, framing: 'bust' };
|
|
const smooth = { open: 0, width: 0, nod: 0, yaw: 0, pitch: 0, glanceX: 0, glanceY: 0, glanceUntil: 0, light: 1 };
|
|
let model = null, mixer = null, head = null;
|
|
const eyes = [];
|
|
const restQ = new Map(); // orientamento nello spazio della posa neutra, per testa e occhi
|
|
const headPos = new THREE.Vector3(0, 1.6, 0);
|
|
const morphMeshes = [];
|
|
const mouth = { hasShapes: false, map: {} };
|
|
const blink = { next: 2, until: 0 };
|
|
const levels = new Array(36).fill(0);
|
|
const clock = new THREE.Clock();
|
|
let modelHeight = 1.7;
|
|
const modelCenter = new THREE.Vector3();
|
|
|
|
function resize() {
|
|
const w = window.innerWidth || canvas.clientWidth, h = window.innerHeight || canvas.clientHeight;
|
|
renderer.setSize(w, h, false);
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
}
|
|
window.addEventListener('resize', resize);
|
|
resize();
|
|
|
|
function findMorphs(root) {
|
|
root.traverse((o) => {
|
|
if (o.isMesh && o.morphTargetDictionary && o.morphTargetInfluences) morphMeshes.push(o);
|
|
});
|
|
const names = new Set();
|
|
for (const m of morphMeshes) for (const k of Object.keys(m.morphTargetDictionary)) names.add(k);
|
|
const pick = (...cands) => cands.find((c) => names.has(c));
|
|
mouth.map = {
|
|
open: pick('jawOpen', 'JawOpen', 'mouthOpen', 'MouthOpen', 'viseme_aa', 'mouth_open', 'A'),
|
|
aa: pick('viseme_aa'),
|
|
smile: pick('mouthSmileLeft', 'mouthSmile', 'mouthSmile_L'),
|
|
smileR: pick('mouthSmileRight', 'mouthSmile_R'),
|
|
wide: pick('viseme_I', 'viseme_E'),
|
|
round: pick('viseme_O', 'mouthFunnel', 'mouthPucker', 'O'),
|
|
pucker: pick('mouthPucker'),
|
|
blinkL: pick('eyeBlinkLeft', 'eyesClosed'),
|
|
blinkR: pick('eyeBlinkRight'),
|
|
browUp: pick('browInnerUp'),
|
|
close: pick('mouthClose'),
|
|
};
|
|
mouth.hasShapes = Boolean(mouth.map.open);
|
|
document.getElementById('hint').textContent = (mouth.hasShapes ? 'labiale: attiva' : 'labiale: modello senza blend shape') + ' · clic: busto / figura intera';
|
|
}
|
|
|
|
function setMorph(name, value) {
|
|
if (!name) return;
|
|
for (const m of morphMeshes) {
|
|
const i = m.morphTargetDictionary[name];
|
|
if (i !== undefined) m.morphTargetInfluences[i] = value;
|
|
}
|
|
}
|
|
|
|
function measure() {
|
|
const box = new THREE.Box3().setFromObject(model);
|
|
modelHeight = Math.max(0.1, box.max.y - box.min.y);
|
|
box.getCenter(modelCenter);
|
|
}
|
|
|
|
function frame() {
|
|
if (!model) return;
|
|
if (head) head.getWorldPosition(headPos);
|
|
const h = modelHeight;
|
|
if (state.framing === 'bust') {
|
|
camera.position.set(headPos.x, headPos.y + h * 0.02, headPos.z + h * 0.56);
|
|
camera.lookAt(headPos.x, headPos.y - h * 0.015, headPos.z);
|
|
} else {
|
|
camera.position.set(modelCenter.x, modelCenter.y + h * 0.05, modelCenter.z + h * 1.9);
|
|
camera.lookAt(modelCenter.x, modelCenter.y, modelCenter.z);
|
|
}
|
|
}
|
|
canvas.addEventListener('click', () => { state.framing = state.framing === 'bust' ? 'full' : 'bust'; frame(); });
|
|
|
|
const MODEL = new URLSearchParams(location.search).get('model') || 'allegra_2';
|
|
new GLTFLoader().load(`./models/${encodeURIComponent(MODEL)}.glb`, (gltf) => {
|
|
model = gltf.scene;
|
|
scene.add(model);
|
|
model.traverse((o) => {
|
|
if (o.isBone) {
|
|
if (o.name === 'Head') head = o;
|
|
else if (o.name === 'LeftEye' || o.name === 'RightEye') eyes.push(o);
|
|
}
|
|
if (o.isMesh) o.frustumCulled = false;
|
|
});
|
|
findMorphs(model);
|
|
model.updateMatrixWorld(true);
|
|
for (const b of [head, ...eyes]) if (b) restQ.set(b, b.getWorldQuaternion(new THREE.Quaternion()));
|
|
let clipTracks = 0;
|
|
if (gltf.animations.length) {
|
|
mixer = new THREE.AnimationMixer(model);
|
|
const clip = gltf.animations[0].clone();
|
|
// Testa e occhi li orientiamo noi; il bacino resta fermo così il corpo guarda avanti.
|
|
clip.tracks = clip.tracks.filter((tr) => !/^(Head|Neck|Hips|LeftEye|RightEye)\./.test(tr.name));
|
|
clipTracks = clip.tracks.length;
|
|
const action = mixer.clipAction(clip);
|
|
action.setLoop(THREE.LoopRepeat); action.play();
|
|
}
|
|
document.getElementById('msg').remove();
|
|
measure();
|
|
if (head) head.getWorldPosition(headPos);
|
|
console.log('modello', MODEL, ': altezza', modelHeight.toFixed(3), 'testa', headPos.toArray().map((v) => v.toFixed(2)).join(','), 'blend shape', mouth.hasShapes, 'occhi', eyes.length, 'tracce', clipTracks);
|
|
frame();
|
|
}, undefined, (err) => {
|
|
document.getElementById('msg').textContent = 'Modello non caricato: ' + (err.message || err);
|
|
});
|
|
|
|
const qOffset = new THREE.Quaternion(), qParent = new THREE.Quaternion(), euler = new THREE.Euler();
|
|
/** Orienta un osso nello spazio: posa neutra + rotazione (pitch, yaw) in assi mondo. */
|
|
function aimBone(bone, pitch, yaw, roll) {
|
|
const rest = restQ.get(bone);
|
|
if (!rest || !bone.parent) return;
|
|
bone.parent.updateWorldMatrix(true, false);
|
|
bone.parent.getWorldQuaternion(qParent).invert();
|
|
euler.set(pitch, yaw, roll || 0, 'YXZ');
|
|
qOffset.setFromEuler(euler);
|
|
bone.quaternion.copy(qParent).multiply(qOffset).multiply(rest);
|
|
}
|
|
|
|
function render() {
|
|
requestAnimationFrame(render);
|
|
const dt = Math.min(0.05, clock.getDelta());
|
|
const t = clock.elapsedTime;
|
|
if (mixer) mixer.update(dt);
|
|
|
|
// Posa per stato (pitch positivo = guarda in basso)
|
|
let targetPitch = 0, targetYaw = 0, light = 1;
|
|
if (state.name === 'THINKING' || state.name === 'PROCESSING') { targetPitch = -0.14; targetYaw = 0.28 + Math.sin(t * 0.7) * 0.05; }
|
|
else if (state.name === 'SLEEPING') { targetPitch = 0.32; light = 0.45; }
|
|
else if (state.name === 'LISTENING') { targetYaw = Math.sin(t * 0.5) * 0.05; targetPitch = Math.sin(t * 0.8) * 0.02; }
|
|
else if (state.name === 'SPEAKING') { targetYaw = Math.sin(t * 0.9) * 0.04; }
|
|
if (t < smooth.glanceUntil) { targetYaw += smooth.glanceX; targetPitch += smooth.glanceY; }
|
|
const k = Math.min(1, dt * 4);
|
|
smooth.yaw += (targetYaw - smooth.yaw) * k;
|
|
smooth.pitch += (targetPitch - smooth.pitch) * k;
|
|
smooth.light += (light - smooth.light) * k;
|
|
hemi.intensity = 1.2 * smooth.light; key.intensity = 2.2 * smooth.light; fill.intensity = 0.8 * smooth.light;
|
|
|
|
const nodTarget = state.name === 'SPEAKING' ? state.level * 0.07 : 0;
|
|
smooth.nod += (nodTarget - smooth.nod) * Math.min(1, dt * 12);
|
|
|
|
if (head) aimBone(head, smooth.pitch + smooth.nod, smooth.yaw, Math.sin(t * 0.9) * 0.012);
|
|
for (const e of eyes) aimBone(e, smooth.pitch * 0.5 + Math.sin(t * 0.37) * 0.03, smooth.yaw * 0.6 + Math.sin(t * 0.23) * 0.04, 0);
|
|
|
|
// Bocca e viso
|
|
const openTarget = state.name === 'SPEAKING' ? state.open : 0;
|
|
smooth.open += (openTarget - smooth.open) * Math.min(1, dt * 18);
|
|
smooth.width += ((state.name === 'SPEAKING' ? state.width : 0) - smooth.width) * Math.min(1, dt * 12);
|
|
if (mouth.hasShapes) {
|
|
const o = Math.min(1, smooth.open), w = smooth.width;
|
|
setMorph(mouth.map.open, o * 0.45);
|
|
setMorph(mouth.map.aa, o * 0.55 * (1 - Math.abs(w) * 0.5));
|
|
setMorph(mouth.map.wide, Math.max(0, w) * o * 0.6);
|
|
setMorph(mouth.map.round, Math.max(0, -w) * o * 0.7);
|
|
setMorph(mouth.map.pucker, Math.max(0, -w) * o * 0.2);
|
|
const smileBase = state.name === 'LISTENING' ? 0.07 : 0.03;
|
|
setMorph(mouth.map.smile, smileBase + Math.max(0, w) * 0.2);
|
|
setMorph(mouth.map.smileR, smileBase + Math.max(0, w) * 0.2);
|
|
setMorph(mouth.map.close, 0); // usato da solo deforma le labbra: mai a riposo
|
|
setMorph(mouth.map.browUp, state.name === 'THINKING' || state.name === 'PROCESSING' ? 0.5 : (state.name === 'LISTENING' ? 0.15 : 0));
|
|
if (t > blink.next) { blink.until = t + 0.13; blink.next = t + 2 + Math.random() * 4; }
|
|
const closed = state.name === 'SLEEPING' ? 1 : (t < blink.until ? 1 : 0);
|
|
setMorph(mouth.map.blinkL, closed); setMorph(mouth.map.blinkR, closed);
|
|
}
|
|
if (state.framing === 'bust' && head) frame();
|
|
renderer.render(scene, camera);
|
|
drawOverlay();
|
|
}
|
|
|
|
const wave = document.getElementById('wave'), wctx = wave.getContext('2d'), stateEl = document.getElementById('state');
|
|
let dispLevel = 0;
|
|
function drawOverlay() {
|
|
dispLevel += (state.level - dispLevel) * 0.3;
|
|
levels.push(dispLevel); levels.shift();
|
|
const col = COLORS[state.name] || '#00d4ff';
|
|
stateEl.style.color = col; stateEl.style.textShadow = `0 0 8px ${col}88`;
|
|
stateEl.textContent = '● ' + (state.label || state.name);
|
|
wctx.clearRect(0, 0, wave.width, wave.height);
|
|
const n = levels.length, bw = wave.width / n;
|
|
for (let i = 0; i < n; i++) {
|
|
const h = 3 + levels[i] * (wave.height - 4) + Math.sin(i * 0.7 + performance.now() / 600) * 1.5;
|
|
wctx.fillStyle = col; wctx.globalAlpha = 0.35 + 0.65 * (i / n);
|
|
wctx.fillRect(i * bw + 1, (wave.height - h) / 2, bw - 3, h);
|
|
}
|
|
wctx.globalAlpha = 1;
|
|
}
|
|
|
|
window.avatar3d = {
|
|
update(level, open, width, name) { state.level = level; state.open = open; state.width = width; if (name) { state.label = name; state.name = name.split(' ')[0]; } },
|
|
setState(name) { state.label = name; state.name = name.split(' ')[0]; },
|
|
glance(dx, dy, hold) { smooth.glanceX = dx * 0.3; smooth.glanceY = -dy * 0.2; smooth.glanceUntil = clock.elapsedTime + (hold || 1.1); },
|
|
setFraming(f) { state.framing = f; frame(); },
|
|
status() { return { loaded: Boolean(model), animation: Boolean(mixer), head: Boolean(head), blendShapes: mouth.hasShapes, morphs: morphMeshes.length, eyes: eyes.length }; },
|
|
};
|
|
render();
|