Améliore le visuel de l'avatar Eve — lissage géométrique et couvertures de jointures

- Fusion de vertices plus agressive (corps 0.08, bras 0.07, tête 0.08)
- Matériau laque blanche unifié : emissiveIntensity 0.16, clearcoat 1.0
- Matériau tête dédié : emissiveIntensity 0.28 pour effacer les ombres aux seams
- Collar élargi (tube 0.14), cylindre de cou ajouté, épaulières agrandies (scale Z 0.56)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-05-20 15:18:11 +02:00
commit d77ace03cd
169 files changed
+83033

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useFrame, useLoader } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as THREE from 'three';
import { EveModel } from './EveModel';
import { EveAvatar, EveEmotion, EmotionService } from '../services/emotionService';
interface AvatarModelLoaderProps {
avatarId: EveAvatar;
emotion: EveEmotion;
isSpeaking: boolean;
}
type AvatarManifest = Partial<Record<Exclude<EveAvatar, 'eve'>, string | null>>;
const AVATAR_MANIFEST_PATH = '/avatars/avatar-manifest.json';
const LoadedGlbAvatar: React.FC<{
path: string;
avatarId: EveAvatar;
emotion: EveEmotion;
isSpeaking: boolean;
}> = ({ path, avatarId, emotion, isSpeaking }) => {
const gltf = useLoader(GLTFLoader, path);
const scene = useMemo(() => gltf.scene.clone(true), [gltf.scene]);
useEffect(() => {
const style = EmotionService.getEmotionStyle(emotion, avatarId);
scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
child.castShadow = false;
child.receiveShadow = false;
const materialName = Array.isArray(child.material)
? child.material.map((material) => material.name).join(' ')
: child.material?.name || '';
if (/eye|led|visor|screen|emissive/i.test(`${child.name} ${materialName}`)) {
const eyeMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(style.eyeColor),
transparent: true,
opacity: 0.96
});
child.material = eyeMaterial;
}
});
}, [avatarId, emotion, scene]);
useFrame((state) => {
const t = state.clock.getElapsedTime();
scene.position.y = Math.sin(t * 1.4) * 0.05 - 0.28;
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
if (isSpeaking) {
scene.rotation.z = Math.sin(t * 8) * 0.025;
} else {
scene.rotation.z = THREE.MathUtils.lerp(scene.rotation.z, 0, 0.12);
}
});
return <primitive object={scene} scale={1} />;
};
export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId, emotion, isSpeaking }) => {
const [assetPath, setAssetPath] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setAssetPath(null);
if (avatarId === 'eve') return;
fetch(AVATAR_MANIFEST_PATH, { cache: 'no-store' })
.then((response) => {
if (!response.ok) return null;
return response.json() as Promise<AvatarManifest>;
})
.then((manifest) => {
if (cancelled || !manifest) return;
const nextAssetPath = manifest[avatarId];
setAssetPath(typeof nextAssetPath === 'string' && nextAssetPath.trim() ? nextAssetPath : null);
})
.catch(() => {
if (!cancelled) setAssetPath(null);
});
return () => {
cancelled = true;
};
}, [avatarId]);
if (avatarId !== 'eve' && assetPath) {
return <LoadedGlbAvatar path={assetPath} avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />;
}
return <EveModel emotion={emotion} isSpeaking={isSpeaking} avatarId="eve" />;
};
+639
View File
@@ -0,0 +1,639 @@
import React, { useRef, useEffect } from 'react';
import { useFrame, useLoader } from '@react-three/fiber';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js';
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import * as THREE from 'three';
import { EveEmotion, EveAvatar, EmotionService } from '../services/emotionService';
interface EveModelProps {
emotion: EveEmotion;
isSpeaking: boolean;
avatarId?: EveAvatar;
}
const createSmoothGeometry = (geometry: THREE.BufferGeometry, tolerance = 0.01) => {
const smoothGeometry = geometry.clone();
smoothGeometry.deleteAttribute('normal');
smoothGeometry.deleteAttribute('uv');
if (smoothGeometry.hasAttribute('color')) {
smoothGeometry.deleteAttribute('color');
}
if (smoothGeometry.hasAttribute('uv2')) {
smoothGeometry.deleteAttribute('uv2');
}
const mergedGeometry = mergeVertices(smoothGeometry, tolerance);
mergedGeometry.computeVertexNormals();
mergedGeometry.computeBoundingBox();
mergedGeometry.computeBoundingSphere();
return mergedGeometry;
};
const createVisorShaderMaterial = () => {
return new THREE.ShaderMaterial({
uniforms: {
eyeColor: { value: new THREE.Color('#00f2ff') },
eyeScaleY: { value: 0.75 },
eyeRotation: { value: 0 },
eyeOffsetX: { value: 0 },
eyeOffsetY: { value: 0 },
speakingPulse: { value: 0 },
thinkingVal: { value: 0 },
uTime: { value: 0 }
},
vertexShader: `
varying vec3 vLocalPosition;
varying vec3 vNormal;
void main() {
vLocalPosition = position;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 eyeColor;
uniform float eyeScaleY;
uniform float eyeRotation;
uniform float eyeOffsetX;
uniform float eyeOffsetY;
uniform float speakingPulse;
uniform float thinkingVal;
uniform float uTime;
varying vec3 vLocalPosition;
varying vec3 vNormal;
vec2 rotateAround(vec2 point, vec2 center, float angle) {
float c = cos(angle);
float s = sin(angle);
vec2 localPoint = point - center;
return vec2(
localPoint.x * c - localPoint.y * s,
localPoint.x * s + localPoint.y * c
) + center;
}
float eyeMask(vec2 point, vec2 center, float width, float height, float angle) {
vec2 rotatedPoint = rotateAround(point, center, angle);
vec2 normalizedPoint = (rotatedPoint - center) / vec2(width, height);
float distanceFromCenter = dot(normalizedPoint, normalizedPoint);
return 1.0 - smoothstep(0.64, 0.92, distanceFromCenter);
}
float eyeGlow(vec2 point, vec2 center, float width, float height, float angle) {
vec2 rotatedPoint = rotateAround(point, center, angle);
vec2 normalizedPoint = (rotatedPoint - center) / vec2(width, height);
float distanceFromCenter = dot(normalizedPoint, normalizedPoint);
return 1.0 - smoothstep(0.82, 1.85, distanceFromCenter);
}
void main() {
vec2 facePoint = vLocalPosition.xy;
float eyeHeight = max(0.11, 0.18 * eyeScaleY + speakingPulse);
vec2 leftCenter = vec2(-0.72 + eyeOffsetX, 8.56 + eyeOffsetY);
vec2 rightCenter = vec2(0.72 + eyeOffsetX, 8.56 + eyeOffsetY);
float leftEye = eyeMask(facePoint, leftCenter, 0.43, eyeHeight, eyeRotation);
float rightEye = eyeMask(facePoint, rightCenter, 0.43, eyeHeight, -eyeRotation);
float leftGlow = eyeGlow(facePoint, leftCenter, 0.49, eyeHeight * 1.35, eyeRotation);
float rightGlow = eyeGlow(facePoint, rightCenter, 0.49, eyeHeight * 1.35, -eyeRotation);
float eye = max(leftEye, rightEye);
float glow = max(leftGlow, rightGlow);
float core = eyeMask(facePoint, leftCenter + vec2(0.0, 0.01), 0.25, eyeHeight * 0.42, eyeRotation)
+ eyeMask(facePoint, rightCenter + vec2(0.0, 0.01), 0.25, eyeHeight * 0.42, -eyeRotation);
vec3 viewLight = normalize(vec3(-0.35, 0.42, 1.0));
float highlight = pow(max(dot(normalize(vNormal), viewLight), 0.0), 16.0) * 0.23;
float rim = pow(1.0 - abs(vNormal.z), 2.0) * 0.08;
// Lueur de calcul douce pendant la réflexion (remplace les bandes dures)
float scanline = 0.0;
if (thinkingVal > 0.05) {
// Bandes très espacées et très graduelles — effet data-pulse discret
float wave = sin(vLocalPosition.y * 2.5 - uTime * 4.0) * 0.5 + 0.5;
scanline = smoothstep(0.55, 0.95, wave) * thinkingVal * 0.45;
// Respiration lente (pas de scintillement haute fréquence agressif)
scanline *= (0.85 + 0.15 * sin(uTime * 2.2));
}
vec3 color = vec3(0.002, 0.004, 0.006);
color += vec3(highlight + rim);
color += eyeColor * glow * 0.13;
color += eyeColor * eye * 1.85;
color += vec3(0.88, 1.0, 1.0) * clamp(core, 0.0, 1.0) * 0.55;
color += eyeColor * scanline * 0.85;
gl_FragColor = vec4(color, 1.0);
}
`,
side: THREE.DoubleSide,
depthWrite: true,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1
});
};
export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
const modelRef = useRef<THREE.Group>(null);
const headRef = useRef<THREE.Object3D | null>(null);
const visorRef = useRef<THREE.Object3D | null>(null);
const eyesRef = useRef<THREE.Object3D | null>(null);
const visorMaterialRef = useRef<THREE.ShaderMaterial | null>(null);
const mainBodyMeshRef = useRef<THREE.Mesh | null>(null);
const mainBodyBasePositionsRef = useRef<Float32Array | null>(null);
const armsRef = useRef<THREE.Mesh[]>([]);
// Perfect pivot reference and center memory for neck-like head rotation
const headGroupRef = useRef<THREE.Group | null>(null);
const headCenterRef = useRef<THREE.Vector3>(new THREE.Vector3());
// Charger les matériaux et le modèle OBJ depuis le répertoire public
const materials = useLoader(MTLLoader, './Eve/EVE.mtl');
const obj = useLoader(OBJLoader, './Eve/EVE.obj', (loader) => {
materials.preload();
loader.setMaterials(materials);
});
// Cloner le modèle pour éviter les interférences si réutilisé
const clonedObj = React.useMemo(() => obj.clone(), [obj]);
useEffect(() => {
if (!clonedObj) return;
// Reset references to avoid old instances
const headMeshes: THREE.Mesh[] = [];
const visorMeshes: THREE.Mesh[] = [];
const eyesMeshes: THREE.Mesh[] = [];
const armsMeshes: THREE.Mesh[] = [];
const bodyMeshes: THREE.Mesh[] = [];
headRef.current = null;
visorRef.current = null;
eyesRef.current = null;
armsRef.current = [];
headGroupRef.current = null;
visorMaterialRef.current = null;
mainBodyMeshRef.current = null;
mainBodyBasePositionsRef.current = null;
// Remove helper covers from previous dev/StrictMode effect passes.
[
'eveBodyCollarSeamCover',
'eveNeckSeamCover',
'eveLeftShoulderSeamCover',
'eveRightShoulderSeamCover'
].forEach((helperName) => {
const helper = clonedObj.getObjectByName(helperName);
if (helper?.parent) {
helper.parent.remove(helper);
}
});
// 1. Classify meshes to assign control references and extract original textures
clonedObj.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
const lowerName = (child.name || '').toLowerCase();
// Hide weapons accessories completely (as requested: only the character)
if (lowerName.includes('gun')) {
child.visible = false;
return;
}
if (lowerName.includes('head')) {
headMeshes.push(child);
} else if (lowerName.includes('visor') || lowerName.includes('visiere') || lowerName.includes('screen') || lowerName.includes('ecran')) {
visorMeshes.push(child);
} else if (lowerName.includes('eye') || lowerName.includes('yeux')) {
eyesMeshes.push(child);
} else if (lowerName.includes('upperbody')) {
bodyMeshes.push(child);
} else if (lowerName.includes('arm')) {
armsMeshes.push(child);
} else {
bodyMeshes.push(child);
}
}
});
const resolvedHeadMesh = headMeshes[0] || null;
const resolvedVisorMesh = visorMeshes[0] || null;
const existingGroup = clonedObj.getObjectByName('headGroup');
// 2. Build High-Fidelity Retro-Futuristic space-age materials
// A. White lacquer glossy material for body parts, limbs and helmet shell.
// Specular dispersion values (higher roughness and low metalness) are optimized
// alongside a light emissive fill to diffuse self-shadows and mask assembly lines.
const whiteLacquerMaterial = new THREE.MeshPhysicalMaterial({
color: new THREE.Color('#ffffff'),
roughness: 0.14,
metalness: 0.14,
clearcoat: 1.0,
clearcoatRoughness: 0.04,
emissive: new THREE.Color('#ffffff'),
emissiveIntensity: 0.16,
reflectivity: 0.92,
depthWrite: true,
transparent: false
});
const seamCoverMaterial = new THREE.MeshPhysicalMaterial({
color: new THREE.Color('#ffffff'),
roughness: 0.14,
metalness: 0.14,
clearcoat: 1.0,
clearcoatRoughness: 0.04,
emissive: new THREE.Color('#ffffff'),
emissiveIntensity: 0.16,
reflectivity: 0.92,
depthWrite: true,
transparent: false
});
bodyMeshes.forEach((mesh) => {
// Weld nearby vertices to resolve seams perfectly
mesh.geometry = createSmoothGeometry(mesh.geometry, 0.08);
mesh.material = whiteLacquerMaterial;
mesh.castShadow = false;
mesh.receiveShadow = false;
if ((mesh.name || '').toLowerCase() === 'eve') {
mainBodyMeshRef.current = mesh;
mainBodyBasePositionsRef.current = new Float32Array(mesh.geometry.getAttribute('position').array);
}
});
if (resolvedHeadMesh) {
// Smooth out head geometry specifically to erase forehead seams and curves
resolvedHeadMesh.geometry = createSmoothGeometry(resolvedHeadMesh.geometry, 0.08);
// Dedicated head material: higher emissive to fill shadow gaps at seam edges
resolvedHeadMesh.material = new THREE.MeshPhysicalMaterial({
color: new THREE.Color('#ffffff'),
roughness: 0.12,
metalness: 0.12,
clearcoat: 1.0,
clearcoatRoughness: 0.03,
emissive: new THREE.Color('#ffffff'),
emissiveIntensity: 0.28,
reflectivity: 0.95,
depthWrite: true,
transparent: false,
});
resolvedHeadMesh.castShadow = false;
resolvedHeadMesh.receiveShadow = false;
}
armsRef.current = [];
armsMeshes.forEach((mesh) => {
// Apply smooth geometry and white lacquer material to the floating arms
mesh.geometry = createSmoothGeometry(mesh.geometry, 0.07);
mesh.material = whiteLacquerMaterial;
mesh.castShadow = false;
mesh.receiveShadow = false;
// Store base coordinates for dynamic relative offset animations
mesh.userData.initialPosition = mesh.position.clone();
mesh.userData.initialRotation = mesh.rotation.clone();
mesh.userData.basePositions = new Float32Array(mesh.geometry.getAttribute('position').array);
armsRef.current.push(mesh);
});
// B. Glossy black acrylic plastic cosmonaut visor (fully opaque black shield)
if (resolvedVisorMesh) {
resolvedVisorMesh.geometry.computeVertexNormals();
const visorMaterial = createVisorShaderMaterial();
visorMaterialRef.current = visorMaterial;
resolvedVisorMesh.material = visorMaterial;
resolvedVisorMesh.renderOrder = 2; // Render after the head shell
}
// C. Hide the low-resolution texture eyes. Clean Eve-style eyes are generated below.
eyesMeshes.forEach((mesh) => {
mesh.visible = false;
});
// 3. Compute local bounding box of the whole model to standardize height
const localBox = new THREE.Box3();
clonedObj.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
if (!child.geometry.boundingBox) {
child.geometry.computeBoundingBox();
}
if (child.geometry.boundingBox) {
localBox.union(child.geometry.boundingBox);
}
}
});
const size = new THREE.Vector3();
localBox.getSize(size);
const center = new THREE.Vector3();
localBox.getCenter(center);
// Normalize height to exactly 2.4 units in scene space
const targetHeight = 2.4;
const scaleFactor = targetHeight / (size.y || 1);
clonedObj.scale.setScalar(scaleFactor);
// Center model pivot perfectly at [0, 0, 0] in global workspace
clonedObj.position.x = -center.x * scaleFactor;
clonedObj.position.y = -center.y * scaleFactor;
clonedObj.position.z = -center.z * scaleFactor;
// Collar ring — wider tube to fully bridge body-to-neck gap
const bodyCollar = new THREE.Mesh(
new THREE.TorusGeometry(1.66, 0.14, 28, 160),
seamCoverMaterial
);
bodyCollar.name = 'eveBodyCollarSeamCover';
bodyCollar.position.set(0, 6.43, -0.04);
bodyCollar.rotation.x = Math.PI / 2;
bodyCollar.scale.z = 0.88;
bodyCollar.renderOrder = 1;
bodyCollar.castShadow = false;
bodyCollar.receiveShadow = false;
clonedObj.add(bodyCollar);
// Neck cylinder — fills the gap between collar ring and helmet base
const neckCover = new THREE.Mesh(
new THREE.CylinderGeometry(0.56, 0.70, 0.52, 36, 1, false),
seamCoverMaterial
);
neckCover.name = 'eveNeckSeamCover';
neckCover.position.set(0, 7.05, -0.04);
neckCover.renderOrder = 1;
neckCover.castShadow = false;
neckCover.receiveShadow = false;
clonedObj.add(neckCover);
// Shoulder caps — larger and deeper to fully cover arm-body junction
const shoulderGeometry = new THREE.SphereGeometry(0.42, 40, 24);
const leftShoulderCover = new THREE.Mesh(shoulderGeometry, seamCoverMaterial);
const rightShoulderCover = new THREE.Mesh(shoulderGeometry.clone(), seamCoverMaterial);
leftShoulderCover.name = 'eveLeftShoulderSeamCover';
rightShoulderCover.name = 'eveRightShoulderSeamCover';
leftShoulderCover.position.set(-2.58, 5.9, 0.03);
rightShoulderCover.position.set(2.58, 5.9, 0.03);
leftShoulderCover.scale.set(0.72, 0.88, 0.56);
rightShoulderCover.scale.set(0.72, 0.88, 0.56);
leftShoulderCover.renderOrder = 1;
rightShoulderCover.renderOrder = 1;
leftShoulderCover.castShadow = false;
rightShoulderCover.castShadow = false;
leftShoulderCover.receiveShadow = false;
rightShoulderCover.receiveShadow = false;
clonedObj.add(leftShoulderCover, rightShoulderCover);
// 4. Construct the synchronized Head Group with perfect pivots and local translations
if (resolvedHeadMesh) {
// A. Calculate local center of head geometry
const headGeom = resolvedHeadMesh.geometry;
if (!headGeom.boundingBox) {
headGeom.computeBoundingBox();
}
const headCenter = new THREE.Vector3();
if (headGeom.boundingBox) {
headGeom.boundingBox.getCenter(headCenter);
}
headCenterRef.current.copy(headCenter);
// B. Create head group and place it at headCenter (in clonedObj local space)
const headGroup = new THREE.Group();
headGroup.name = "headGroup";
headGroup.position.copy(headCenter);
clonedObj.add(headGroup);
headGroupRef.current = headGroup;
// C. Position Head: Keep geometry intact to maintain perfect Blender-defined coordinates, position relative to headGroup
headGroup.add(resolvedHeadMesh);
resolvedHeadMesh.position.set(-headCenter.x, -headCenter.y, -headCenter.z);
// D. Position Visor: Keep geometry intact for absolute precision, and position at headCenter
if (resolvedVisorMesh) {
headGroup.add(resolvedVisorMesh);
resolvedVisorMesh.position.set(-headCenter.x, -headCenter.y, -headCenter.z);
}
headRef.current = resolvedHeadMesh;
visorRef.current = resolvedVisorMesh;
eyesRef.current = null;
if (existingGroup?.parent && existingGroup !== headGroup) {
existingGroup.parent.remove(existingGroup);
}
}
}, [clonedObj, avatarId]);
// Boucle de rendu pour animer Eve en temps réel (Animations fluides R3F)
useFrame((state) => {
const t = state.clock.getElapsedTime();
// Style émotionnel actuel
const emoStyle = EmotionService.getEmotionStyle(emotion, avatarId);
// 1. Animation globale de flottaison (Lévitation magnétique)
if (modelRef.current) {
// Oscillation en hauteur stabilisée autour de y = -0.3
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 - 0.3;
// Légère rotation sur elle-même (mouvement de veille)
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
}
// 2. Animation de la tête flottante autonome (incluant la visière et les yeux)
if (headGroupRef.current && headCenterRef.current) {
const headCenter = headCenterRef.current;
// Flottaison déphasée par rapport au corps pour accentuer l'effet magnétique
let headY = Math.sin(t * 1.8) * 0.02 + 0.01;
// Orientation naturelle vers la souris pour donner vie au robot
const mouseX = state.pointer.x * 0.25;
const mouseY = state.pointer.y * 0.2;
const targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX, 0.1);
let targetRotX = THREE.MathUtils.lerp(headGroupRef.current.rotation.x, -mouseY + 0.05, 0.1);
let targetRotZ = 0;
// Inclinaisons émotionnelles
if (emotion === 'sad') {
// Tête baissée et légèrement inclinée
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.2, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0.05, 0.1);
} else if (emotion === 'thinking') {
// Tête inclinée sur le côté (curieuse) ET léger balancement pendulaire (swaying)
// L'inclinaison de base est à -0.15, et on fait osciller doucement autour de ça
const sway = Math.sin(t * 3.5) * 0.04;
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, -0.16 + sway, 0.1);
// Micro-mouvements de tête (hochement pensif vertical léger)
headY += Math.sin(t * 4.5) * 0.005;
} else if (emotion === 'angry') {
// Tête penchée en avant (boudeuse)
targetRotX = THREE.MathUtils.lerp(targetRotX, -0.1, 0.1);
} else {
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0, 0.1);
}
// Appliquer les transformations au Groupe Tête (tout bouge et pivote en parfait accord autour du pivot naturel)
headGroupRef.current.position.set(
headCenter.x,
headCenter.y + headY,
headCenter.z
);
headGroupRef.current.rotation.set(targetRotX, targetRotY, targetRotZ);
}
// 3. Animation des Yeux LED (Expressions d'émotion + Clignotement + Parole + Micro-saccades de veille)
if (visorMaterialRef.current) {
// Clignotement naturel toutes les 4.2 secondes
const isBlinking = t % 4.2 < 0.15;
// A. Taille verticale des yeux
let targetScaleY = isBlinking ? 0.02 : emoStyle.eyeScaleY;
// Respiration des yeux (micro-pulsation de veille) : oscillation subtile à ~0.38Hz
if (!isBlinking) {
targetScaleY += Math.sin(t * 2.4) * 0.018;
}
// Effet de parole : si Eve parle, ses yeux oscillent légèrement en hauteur au rythme de sa voix
if (isSpeaking && !isBlinking) {
targetScaleY += Math.sin(t * 18) * 0.08;
}
// B. Position des yeux (micro-saccades autonomes de veille + émotions)
let targetEyeOffsetY = 0;
let targetEyeOffsetX = 0;
if (emotion === 'surprised') {
targetEyeOffsetY = 0.05;
} else if (emotion === 'sad') {
targetEyeOffsetY = -0.04;
} else if (emotion === 'thinking') {
// Balayage lent et pensif
targetEyeOffsetY = Math.sin(t * 2.6) * 0.025;
targetEyeOffsetX = Math.cos(t * 1.8) * 0.04;
// Pulsation LED de calcul intense : les yeux vibrent légèrement en taille à haute fréquence (15 rad/s)
if (!isBlinking) {
targetScaleY += Math.sin(t * 15.0) * 0.05;
}
} else {
// Mode veille standard : génération de micro-saccades oculaires pseudo-aléatoires toutes les 2.8 secondes
const saccadeCycle = Math.floor(t * 0.35); // change toutes les ~2.8s
// Micro-saccades horizontales et verticales basées sur des fréquences non harmoniques
const rawSaccadeX = Math.sin(saccadeCycle * 17.31 + 2.5) * Math.cos(saccadeCycle * 7.89);
const rawSaccadeY = Math.cos(saccadeCycle * 13.17 - 1.2) * Math.sin(saccadeCycle * 5.43);
// Limitation des décalages pour garder un aspect centré et naturel
targetEyeOffsetX = rawSaccadeX * 0.045; // max ±0.045
targetEyeOffsetY = rawSaccadeY * 0.025; // max ±0.025
// Ajout d'une très légère dérive continue (bruit oculaire de veille)
targetEyeOffsetX += Math.sin(t * 0.8) * 0.008;
targetEyeOffsetY += Math.cos(t * 0.5) * 0.005;
}
visorMaterialRef.current.uniforms.eyeColor.value.set(emoStyle.eyeColor);
visorMaterialRef.current.uniforms.eyeScaleY.value = targetScaleY;
visorMaterialRef.current.uniforms.eyeRotation.value = emoStyle.eyeRotation;
visorMaterialRef.current.uniforms.eyeOffsetX.value = targetEyeOffsetX;
visorMaterialRef.current.uniforms.eyeOffsetY.value = targetEyeOffsetY;
visorMaterialRef.current.uniforms.speakingPulse.value = isSpeaking ? 0.025 : 0;
// Mise à jour des uniforms uTime et de la valeur de transition de réflexion
visorMaterialRef.current.uniforms.uTime.value = t;
let targetThinkingVal = emotion === 'thinking' ? 1.0 : 0.0;
visorMaterialRef.current.uniforms.thinkingVal.value = THREE.MathUtils.lerp(
visorMaterialRef.current.uniforms.thinkingVal.value,
targetThinkingVal,
0.08
);
}
// 4. Animation dynamique des bras (gesticulation en parlant, flottaison magnétique subtile au repos)
if (mainBodyMeshRef.current && mainBodyBasePositionsRef.current) {
const geometry = mainBodyMeshRef.current.geometry;
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
const base = mainBodyBasePositionsRef.current;
const talkWave = isSpeaking ? Math.sin(t * 10) * 0.18 : Math.sin(t * 1.4) * 0.035;
for (let i = 0; i < position.count; i += 1) {
const index = i * 3;
const baseX = base[index];
const baseY = base[index + 1];
const baseZ = base[index + 2];
const isArmRegion = Math.abs(baseX) > 2.05 && baseY > 1.0 && baseY < 6.25;
if (isArmRegion) {
const side = Math.sign(baseX);
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
position.setXYZ(
i,
baseX + side * talkWave * 0.18 * shoulderFalloff,
baseY + Math.abs(talkWave) * 0.1 * shoulderFalloff,
baseZ + Math.sin(t * 8 + side) * 0.045 * shoulderFalloff
);
} else {
position.setXYZ(i, baseX, baseY, baseZ);
}
}
position.needsUpdate = true;
geometry.computeVertexNormals();
}
if (armsRef.current.length > 0) {
armsRef.current.forEach((mesh) => {
// Neutralize global transformation to let vertex deformation do the work perfectly in the same coordinate space
mesh.position.set(0, 0, 0);
mesh.rotation.set(0, 0, 0);
const base = mesh.userData.basePositions as Float32Array;
if (!base) return;
const geometry = mesh.geometry;
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
const talkWave = isSpeaking ? Math.sin(t * 10) * 0.18 : Math.sin(t * 1.4) * 0.035;
for (let i = 0; i < position.count; i += 1) {
const index = i * 3;
const baseX = base[index];
const baseY = base[index + 1];
const baseZ = base[index + 2];
const isArmRegion = Math.abs(baseX) > 2.05 && baseY > 1.0 && baseY < 6.25;
if (isArmRegion) {
const side = Math.sign(baseX);
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
position.setXYZ(
i,
baseX + side * talkWave * 0.18 * shoulderFalloff,
baseY + Math.abs(talkWave) * 0.1 * shoulderFalloff,
baseZ + Math.sin(t * 8 + side) * 0.045 * shoulderFalloff
);
} else {
position.setXYZ(i, baseX, baseY, baseZ);
}
}
position.needsUpdate = true;
geometry.computeVertexNormals();
});
}
});
return (
<group ref={modelRef} scale={1.0} position={[0, -0.3, 0]}>
<primitive object={clonedObj} />
</group>
);
};
+59
View File
@@ -0,0 +1,59 @@
import React, { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { AvatarModelLoader } from './AvatarModelLoader';
import { EveEmotion, EveAvatar } from '../services/emotionService';
interface ThreeCanvasProps {
emotion: EveEmotion;
isSpeaking: boolean;
avatarId?: EveAvatar;
}
export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<Canvas
camera={{ position: [0, 0, 4.6], fov: 45 }}
gl={{ alpha: true, antialias: true }}
dpr={[1, 2]}
style={{ background: 'transparent' }}
>
{/* Soft ambient — dark space base */}
<ambientLight intensity={0.6} color="#b0c8e8" />
<hemisphereLight args={['#1a2a4a', '#050810', 0.8]} />
{/* Main key light — warm white rim from top-right */}
<directionalLight
position={[5, 8, 5]}
intensity={2.0}
color="#e8f4ff"
/>
{/* Cyan atmosphere rim — far enough to avoid harsh specular band on helmet */}
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
{/* Desaturated blue fill — subtle depth without coloring the white lacquer */}
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
{/* Soft white back fill to keep geometry readable */}
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
<Suspense fallback={null}>
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
</Suspense>
{/* OrbitControls calibrated to prevent clipping or losing focus */}
<OrbitControls
enableZoom={true}
minDistance={2.2}
maxDistance={5.5}
enablePan={false}
autoRotate={false}
maxPolarAngle={Math.PI / 1.7}
minPolarAngle={Math.PI / 2.8}
/>
</Canvas>
</div>
);
};