Files

470 lines
16 KiB
JavaScript
Raw Permalink Normal View History

2026-04-11 22:23:37 +08:00
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { VRMLoaderPlugin } from '@pixiv/three-vrm';
function initChatAvatarWidget() {
const widget = document.getElementById('chat-avatar-widget');
const stage = document.getElementById('chat-avatar-stage');
const canvas = document.getElementById('chat-avatar-canvas');
const speech = document.getElementById('chat-avatar-speech');
const status = document.getElementById('chat-avatar-status');
if (!widget || !stage || !canvas || !speech || !status || window.chatAvatarWidgetInitialized) {
return;
}
const avatarPresets = [
{
label: '暗夜系',
speech: '这套暗系角色会更接近你刚才给我的参考方向。',
url: 'https://cdn.jsdelivr.net/gh/madjin/vrm-samples@master/vroid/beta/Darkness_Shibu.vrm',
twist: -0.2,
},
{
label: '冷灰系',
speech: '这一套更偏高冷一些,但还是偏可爱,不如暗夜系成熟。',
url: 'https://cdn.jsdelivr.net/gh/madjin/vrm-samples@master/vroid/beta/Sendagaya_Shino.vrm',
twist: -0.08,
},
{
label: '甜酷系',
speech: '这套会更明艳一些,适合后面继续往甜辣路线改。',
url: 'https://cdn.jsdelivr.net/gh/madjin/vrm-samples@master/vroid/beta/Victoria_Rubin.vrm',
twist: -0.12,
},
];
const expressions = [
{ label: '冷淡', preset: 'neutral', speech: '现在先用冷感表情看整体质感。' },
{ label: '浅笑', preset: 'happy', speech: '带一点笑意以后,气质会柔一点。' },
{ label: '慵懒', preset: 'relaxed', speech: '这种神态更适合成熟一点的虚拟人物。' },
{ label: '惊讶', preset: 'surprised', speech: '表情系统后面还能接聊天互动和动作触发。' },
];
const state = {
collapsed: false,
dragging: false,
dragStartX: 0,
manualRotateY: -0.18,
hoverYaw: 0,
hoverPitch: 0,
waveUntil: 0,
modelIndex: 0,
expressionIndex: 0,
};
let scene = null;
let camera = null;
let renderer = null;
let clock = null;
let mixerRoot = null;
let avatarSlot = null;
let loader = null;
let currentVrm = null;
let currentArmBones = null;
let resizeObserver = null;
let speechTimer = null;
const sparkles = [];
function setSpeech(text) {
speech.textContent = text;
}
function setStatus(text, isError = false) {
status.textContent = text;
status.style.display = text ? 'block' : 'none';
status.style.background = isError ? 'rgba(153, 27, 27, 0.84)' : 'rgba(15, 23, 42, 0.55)';
}
function getHumanoidBone(humanoid, name) {
return humanoid.getRawBoneNode?.(name) ?? humanoid.getNormalizedBoneNode?.(name) ?? null;
}
function fitAvatarToStage(vrmScene) {
const box = new THREE.Box3().setFromObject(vrmScene);
const size = box.getSize(new THREE.Vector3());
const targetHeight = 1.96;
const targetWidth = 1.22;
const scale = Math.min(
targetHeight / Math.max(size.y, 0.001),
targetWidth / Math.max(size.x, 0.001)
);
vrmScene.scale.setScalar(scale);
vrmScene.updateMatrixWorld(true);
const scaledBox = new THREE.Box3().setFromObject(vrmScene);
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
const floorY = -1.02;
vrmScene.position.set(
-scaledCenter.x,
floorY - scaledBox.min.y - 0.03,
-scaledCenter.z
);
}
function collectArmBones(vrm) {
const humanoid = vrm.humanoid;
if (!humanoid) {
return null;
}
return {
leftUpperArm: getHumanoidBone(humanoid, 'leftUpperArm'),
leftLowerArm: getHumanoidBone(humanoid, 'leftLowerArm'),
rightUpperArm: getHumanoidBone(humanoid, 'rightUpperArm'),
rightLowerArm: getHumanoidBone(humanoid, 'rightLowerArm'),
neck: getHumanoidBone(humanoid, 'neck'),
spine: getHumanoidBone(humanoid, 'spine'),
};
}
function resetExpressions() {
if (!currentVrm?.expressionManager) {
return;
}
['neutral', 'happy', 'relaxed', 'surprised', 'angry', 'sad'].forEach((name) => {
currentVrm.expressionManager.setValue(name, 0);
});
}
function applyExpression() {
const expression = expressions[state.expressionIndex];
if (!expression) {
return;
}
resetExpressions();
if (currentVrm?.expressionManager && expression.preset !== 'neutral') {
currentVrm.expressionManager.setValue(expression.preset, 1);
}
setSpeech(`${avatarPresets[state.modelIndex].label} ${expression.label}${expression.speech}`);
}
function loadAvatar(index) {
const preset = avatarPresets[index];
if (!preset || !loader || !avatarSlot) {
return;
}
setStatus(`正在加载 ${preset.label}...`);
setSpeech(preset.speech);
if (currentVrm?.scene) {
avatarSlot.remove(currentVrm.scene);
}
currentVrm = null;
currentArmBones = null;
loader.load(
preset.url,
(gltf) => {
const vrm = gltf.userData.vrm;
if (!vrm) {
setStatus('模型加载成功,但未识别到 VRM 数据。', true);
return;
}
currentVrm = vrm;
currentVrm.scene.rotation.y = Math.PI + preset.twist;
fitAvatarToStage(currentVrm.scene);
avatarSlot.add(currentVrm.scene);
currentArmBones = collectArmBones(currentVrm);
state.expressionIndex = 0;
applyExpression();
setStatus('');
},
undefined,
(error) => {
console.error('VRM 角色加载失败', error);
setStatus('二次元角色加载失败,可能是外部模型资源被拦截了。', true);
}
);
}
function buildScene() {
scene = new THREE.Scene();
clock = new THREE.Clock();
camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100);
camera.position.set(0, 0.4, 4.35);
camera.lookAt(0, -0.02, 0);
renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
powerPreference: 'high-performance',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.88;
scene.add(new THREE.HemisphereLight('#f5ecf7', '#8098cb', 1.15));
const keyLight = new THREE.DirectionalLight('#fff6fb', 1.45);
keyLight.position.set(2.4, 4.2, 4.2);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight('#f0d6ff', 0.72);
fillLight.position.set(-2.8, 1.6, 3.1);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight('#c4cfff', 0.56);
rimLight.position.set(-2.4, 2.2, -2.4);
scene.add(rimLight);
mixerRoot = new THREE.Group();
scene.add(mixerRoot);
const pedestal = new THREE.Mesh(
new THREE.CylinderGeometry(0.94, 1.1, 0.22, 36),
new THREE.MeshToonMaterial({ color: '#eadce8' })
);
pedestal.position.set(0, -1.12, 0);
mixerRoot.add(pedestal);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(0.78, 0.04, 12, 38),
new THREE.MeshToonMaterial({ color: '#d7b2d3' })
);
ring.position.set(0, -0.98, 0);
ring.rotation.x = Math.PI / 2;
mixerRoot.add(ring);
avatarSlot = new THREE.Group();
mixerRoot.add(avatarSlot);
const sparkleMaterialA = new THREE.MeshToonMaterial({ color: '#fff4b0' });
const sparkleMaterialB = new THREE.MeshToonMaterial({ color: '#ffffff' });
for (let i = 0; i < 18; i++) {
const sparkle = new THREE.Mesh(
new THREE.OctahedronGeometry(0.045, 0),
i % 2 === 0 ? sparkleMaterialA : sparkleMaterialB
);
const angle = (Math.PI * 2 * i) / 18;
const radius = 1.08 + (i % 3) * 0.16;
sparkle.position.set(Math.cos(angle) * radius, -0.12 + (i % 5) * 0.32, Math.sin(angle) * radius * 0.5);
sparkle.userData.baseY = sparkle.position.y;
sparkle.userData.speed = 0.9 + (i % 4) * 0.18;
mixerRoot.add(sparkle);
sparkles.push(sparkle);
}
}
function resizeStage() {
if (!renderer || !camera) {
return;
}
const width = Math.max(stage.clientWidth, 1);
const height = Math.max(stage.clientHeight, 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function updatePose(elapsed) {
if (!currentArmBones) {
return;
}
const spineLean = Math.sin(elapsed * 1.1) * 0.012;
const neckTurn = state.hoverYaw * 0.18 + Math.sin(elapsed * 0.7) * 0.05;
const neckNod = state.hoverPitch * 0.08;
if (currentArmBones.spine) {
currentArmBones.spine.rotation.z = spineLean;
currentArmBones.spine.rotation.x = 0.04;
}
if (currentArmBones.neck) {
currentArmBones.neck.rotation.y = neckTurn;
currentArmBones.neck.rotation.x = neckNod;
}
const baseLeftArmZ = 1.22;
const baseRightArmZ = -1.22;
const baseUpperArmX = 0.12;
const baseLowerArmZ = 0.08;
if (currentArmBones.leftUpperArm) {
currentArmBones.leftUpperArm.rotation.z = baseLeftArmZ + Math.sin(elapsed * 1.2 + 0.3) * 0.02;
currentArmBones.leftUpperArm.rotation.x = baseUpperArmX;
currentArmBones.leftUpperArm.rotation.y = -0.1;
}
if (currentArmBones.leftLowerArm) {
currentArmBones.leftLowerArm.rotation.z = baseLowerArmZ;
currentArmBones.leftLowerArm.rotation.x = -0.06;
}
const isWaving = elapsed < state.waveUntil;
if (currentArmBones.rightUpperArm) {
currentArmBones.rightUpperArm.rotation.z = isWaving
? -0.54 - Math.sin(elapsed * 10.5) * 0.22
: baseRightArmZ - Math.sin(elapsed * 1.15) * 0.02;
currentArmBones.rightUpperArm.rotation.x = isWaving ? 0.32 : baseUpperArmX;
currentArmBones.rightUpperArm.rotation.y = isWaving ? 0.22 : 0.1;
}
if (currentArmBones.rightLowerArm) {
currentArmBones.rightLowerArm.rotation.z = isWaving
? -0.58 - Math.cos(elapsed * 10.5) * 0.18
: -baseLowerArmZ;
currentArmBones.rightLowerArm.rotation.x = isWaving ? -0.18 : -0.06;
}
}
function animate() {
const elapsed = clock.getElapsedTime();
clock.getDelta();
mixerRoot.rotation.y = state.manualRotateY + Math.sin(elapsed * 0.42) * 0.08;
mixerRoot.position.y = Math.sin(elapsed * 1.3) * 0.03;
sparkles.forEach((sparkle, index) => {
sparkle.rotation.x += 0.016 + index * 0.0005;
sparkle.rotation.y += 0.024 + index * 0.0007;
sparkle.position.y = sparkle.userData.baseY + Math.sin(elapsed * sparkle.userData.speed + index) * 0.05;
});
if (currentVrm) {
updatePose(elapsed);
}
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
function bindPointer() {
const updateHover = (event) => {
const rect = stage.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
state.hoverYaw = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
state.hoverPitch = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
};
stage.addEventListener('pointerdown', (event) => {
state.dragging = true;
state.dragStartX = event.clientX;
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener('pointermove', (event) => {
updateHover(event);
if (!state.dragging) {
return;
}
const deltaX = event.clientX - state.dragStartX;
state.dragStartX = event.clientX;
state.manualRotateY += deltaX * 0.011;
});
const releasePointer = () => {
state.dragging = false;
};
stage.addEventListener('pointerup', releasePointer);
stage.addEventListener('pointercancel', releasePointer);
stage.addEventListener('pointerleave', () => {
state.hoverYaw *= 0.7;
state.hoverPitch *= 0.7;
});
}
function scheduleSpeech() {
const idleLines = [
'现在这版已经是实打实的二次元角色资源,不再是代码拼图。',
'如果这条路对味,下一步就能做成“每个用户自己的形象”。',
'后面还可以接换装、亲密度、称号和专属动作。',
];
let idleIndex = 0;
clearInterval(speechTimer);
speechTimer = window.setInterval(() => {
if (state.dragging || state.collapsed) {
return;
}
idleIndex = (idleIndex + 1) % idleLines.length;
setSpeech(idleLines[idleIndex]);
}, 6400);
}
function init() {
buildScene();
resizeStage();
bindPointer();
scheduleSpeech();
loader = new GLTFLoader();
loader.crossOrigin = 'anonymous';
loader.register((parser) => new VRMLoaderPlugin(parser));
loadAvatar(state.modelIndex);
animate();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => resizeStage());
resizeObserver.observe(stage);
} else {
window.addEventListener('resize', resizeStage);
}
window.chatAvatarWidgetInitialized = true;
}
window.chatAvatarWidget = {
cycleModel: () => {
state.modelIndex = (state.modelIndex + 1) % avatarPresets.length;
loadAvatar(state.modelIndex);
},
cycleExpression: () => {
state.expressionIndex = (state.expressionIndex + 1) % expressions.length;
applyExpression();
},
triggerWave: () => {
if (!clock) {
return;
}
state.waveUntil = clock.getElapsedTime() + 2.2;
setSpeech('现在这个动作只是演示,后面可以接聊天触发和专属姿态。');
},
toggleCollapsed: () => {
state.collapsed = !state.collapsed;
widget.classList.toggle('is-collapsed', state.collapsed);
},
};
window.toggleChatAvatarWidgetPanel = () => {
window.chatAvatarWidget.toggleCollapsed();
};
try {
init();
} catch (error) {
console.error('聊天虚拟形象初始化失败', error);
setStatus('二次元角色初始化失败,可能是本地依赖或外部模型资源加载失败。', true);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChatAvatarWidget, { once: true });
} else {
initChatAvatarWidget();
}