Eventbuzz_Concierge/public/embed/avatar/avatar3d.js

646 lines
14 KiB
JavaScript

(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;
},
};
})();