(function () { 'use strict'; let THREE = null; let GLTFLoader = null; let scene = null; let camera = null; let renderer = null; let model = null; let container = null; let animationFrame = 0; let analyser = null; let frequencyData = null; const morphs = { jawOpen: [], mouthClose: [], sil: [], }; let initialized = false; // -------------------------------------------------- // Load Three.js + GLTFLoader // -------------------------------------------------- async function loadThree() { if (THREE && GLTFLoader) { return; } const threeModule = await import( 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js' ); THREE = threeModule; const loaderModule = await import( 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js' ); GLTFLoader = loaderModule.GLTFLoader; } // -------------------------------------------------- // Initialize // -------------------------------------------------- async function init(options = {}) { if (initialized) { return window.EBAvatar3D; } container = options.container || document.getElementById('eba-avatar-stage'); if (!container) { throw new Error( '[avatar3d] #eba-avatar-stage not found' ); } await loadThree(); createRenderer(); createScene(); createCamera(); createLights(); await loadModel( options.modelUrl || '/embed/avatar/model.glb' ); setupResize(); initialized = true; startRenderLoop(); console.info('[avatar3d] initialized'); return window.EBAvatar3D; } // -------------------------------------------------- // Renderer // -------------------------------------------------- function createRenderer() { renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance', }); renderer.setPixelRatio( Math.min(window.devicePixelRatio || 1, 2) ); renderer.setSize( container.clientWidth || 640, container.clientHeight || 640, false ); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.domElement.id = 'eba-avatar-canvas'; renderer.domElement.style.display = 'block'; renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%'; container.appendChild(renderer.domElement); } // -------------------------------------------------- // Scene // -------------------------------------------------- function createScene() { scene = new THREE.Scene(); scene.background = new THREE.Color(0x020617); } // -------------------------------------------------- // Camera // -------------------------------------------------- function createCamera() { const width = container.clientWidth || 640; const height = container.clientHeight || 640; camera = new THREE.PerspectiveCamera( 30, width / height, 0.01, 100 ); camera.position.set( 0, 1.55, 2.7 ); camera.lookAt( 0, 1.45, 0 ); } // -------------------------------------------------- // Lights // -------------------------------------------------- function createLights() { const ambient = new THREE.HemisphereLight( 0xffffff, 0x223344, 2 ); scene.add(ambient); const key = new THREE.DirectionalLight( 0xffffff, 2.5 ); key.position.set(2, 3, 4); scene.add(key); const fill = new THREE.DirectionalLight( 0xaac8ff, 1 ); fill.position.set(-3, 2, 2); scene.add(fill); } // -------------------------------------------------- // Load GLB // -------------------------------------------------- async function loadModel(url) { return new Promise((resolve, reject) => { const loader = new GLTFLoader(); console.info( '[avatar3d] loading model:', url ); loader.load( url, (gltf) => { model = gltf.scene; scene.add(model); inspectModel(model); fitModel(); console.info( '[avatar3d] model loaded' ); resolve(model); }, (progress) => { if (progress.total) { const percent = (progress.loaded / progress.total) * 100; console.info( `[avatar3d] loading ${percent.toFixed(0)}%` ); } }, (error) => { console.error( '[avatar3d] model loading failed', error ); reject(error); } ); }); } // -------------------------------------------------- // Find morph targets // -------------------------------------------------- function inspectModel(root) { Object.keys(morphs).forEach((key) => { morphs[key].length = 0; }); root.traverse((object) => { if (!object.isMesh) { return; } if ( !object.morphTargetDictionary || !object.morphTargetInfluences ) { return; } console.info( '[avatar3d] morph mesh:', object.name ); registerMorph( object, 'jawOpen' ); registerMorph( object, 'mouthClose' ); registerMorph( object, 'sil' ); }); console.info( '[avatar3d] jawOpen targets:', morphs.jawOpen.length ); } function registerMorph(mesh, name) { const dictionary = mesh.morphTargetDictionary; if ( dictionary && Object.prototype.hasOwnProperty.call( dictionary, name ) ) { morphs[name].push({ mesh, index: dictionary[name], }); } } // -------------------------------------------------- // Fit model // -------------------------------------------------- function fitModel() { if (!model) { return; } const box = new THREE.Box3().setFromObject(model); const size = new THREE.Vector3(); const center = new THREE.Vector3(); box.getSize(size); box.getCenter(center); console.info( '[avatar3d] model size:', size ); model.position.x -= center.x; model.position.y -= box.min.y; model.position.z -= center.z; const maxDimension = Math.max( size.x, size.y, size.z ); if (maxDimension > 5) { const scale = 2 / maxDimension; model.scale.setScalar(scale); } } // -------------------------------------------------- // Audio analyser // -------------------------------------------------- function setAudioAnalyser(nextAnalyser) { analyser = nextAnalyser; if (analyser) { frequencyData = new Uint8Array( analyser.frequencyBinCount ); console.info( '[avatar3d] audio analyser connected' ); } else { frequencyData = null; console.info( '[avatar3d] audio analyser disconnected' ); } } function getAudioLevel() { if (!analyser || !frequencyData) { return 0; } analyser.getByteFrequencyData( frequencyData ); let sum = 0; for ( let i = 0; i < frequencyData.length; i++ ) { const value = frequencyData[i] / 255; sum += value * value; } const rms = Math.sqrt( sum / frequencyData.length ); return Math.min( 1, rms * 2.5 ); } // -------------------------------------------------- // Lip movement // -------------------------------------------------- function updateMouth() { const level = getAudioLevel(); const target = Math.min( 1, Math.max( 0, level * 2.2 ) ); // Jaw opens when agent speaks. morphs.jawOpen.forEach( (targetInfo) => { const current = targetInfo.mesh .morphTargetInfluences[ targetInfo.index ] || 0; targetInfo.mesh .morphTargetInfluences[ targetInfo.index ] = THREE.MathUtils.lerp( current, target, 0.35 ); } ); // Mouth close when silent. morphs.mouthClose.forEach( (targetInfo) => { const current = targetInfo.mesh .morphTargetInfluences[ targetInfo.index ] || 0; targetInfo.mesh .morphTargetInfluences[ targetInfo.index ] = THREE.MathUtils.lerp( current, Math.max( 0, 1 - target ), 0.15 ); } ); // Silence target. morphs.sil.forEach( (targetInfo) => { const current = targetInfo.mesh .morphTargetInfluences[ targetInfo.index ] || 0; const silence = target < 0.03 ? 1 : 0; targetInfo.mesh .morphTargetInfluences[ targetInfo.index ] = THREE.MathUtils.lerp( current, silence, 0.15 ); } ); } // -------------------------------------------------- // Render loop // -------------------------------------------------- function startRenderLoop() { if (animationFrame) { return; } const render = () => { animationFrame = requestAnimationFrame( render ); updateMouth(); if ( renderer && scene && camera ) { renderer.render( scene, camera ); } }; render(); } // -------------------------------------------------- // Resize // -------------------------------------------------- function setupResize() { window.addEventListener( 'resize', resize, { passive: true } ); resize(); } function resize() { if ( !renderer || !camera || !container ) { return; } const width = container.clientWidth || 640; const height = container.clientHeight || 640; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize( width, height, false ); } // -------------------------------------------------- // Destroy // -------------------------------------------------- function destroy() { if (animationFrame) { cancelAnimationFrame( animationFrame ); animationFrame = 0; } if (renderer) { renderer.dispose(); if ( renderer.domElement && renderer.domElement.parentNode ) { renderer.domElement.parentNode.removeChild( renderer.domElement ); } } scene = null; camera = null; renderer = null; model = null; analyser = null; frequencyData = null; Object.keys(morphs).forEach( (key) => { morphs[key].length = 0; } ); initialized = false; } // -------------------------------------------------- // Public API // -------------------------------------------------- window.EBAvatar3D = { init, setAudioAnalyser, destroy, get initialized() { return initialized; }, get model() { return model; }, }; })();