mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-16 11:47:55 +02:00
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:
commit
d77ace03cd
169 files changed
+83033
No files matched your search
@@ -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" />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user