646 lines
14 KiB
JavaScript
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;
|
|
},
|
|
};
|
|
})(); |