save code
This commit is contained in:
+84
-33
@@ -70,10 +70,28 @@ let pageGestureBootstrapArmed = false;
|
||||
|
||||
const AUTO_RECONNECT_KEY = "visual_chat_auto_reconnect";
|
||||
const ANIMATION_BUFFER_MS = 120;
|
||||
const AUDIO_PLAYBACK_BUFFER_MS = 25;
|
||||
const AV_SYNC_AUDIO_LEAD_MS = 60;
|
||||
const MOUTH_DRIVE_LIMITS = {
|
||||
expression: {
|
||||
aa: 0.58,
|
||||
ee: 0.40,
|
||||
oh: 0.48,
|
||||
ouFromOh: 0.16,
|
||||
pucker: 0.26,
|
||||
},
|
||||
morph: {
|
||||
jawOpen: 0.42,
|
||||
visemeAa: 0.36,
|
||||
visemeEe: 0.34,
|
||||
visemeOh: 0.38,
|
||||
mouthPucker: 0.22,
|
||||
},
|
||||
debugJaw: 0.52,
|
||||
};
|
||||
const DEFAULT_BODY_MODEL_URL =
|
||||
(modelUrl?.value || "").trim() ||
|
||||
"/web/models/VRM1_Constraint_Twist_Sample.vrm";
|
||||
const DEFAULT_VRM_FALLBACK_URL = "https://raw.githubusercontent.com/pixiv/three-vrm/dev/packages/three-vrm/examples/models/VRM1_Constraint_Twist_Sample.vrm";
|
||||
const CLOCK = new THREE.Clock();
|
||||
|
||||
const stateMap = {
|
||||
@@ -325,14 +343,14 @@ async function ensureMediaAudioContext() {
|
||||
await mediaAudioContext.resume();
|
||||
}
|
||||
if (!Number.isFinite(audioPlaybackCursor) || audioPlaybackCursor <= 0) {
|
||||
audioPlaybackCursor = mediaAudioContext.currentTime + 0.04;
|
||||
audioPlaybackCursor = mediaAudioContext.currentTime + getAudioPlaybackBufferSeconds();
|
||||
}
|
||||
return mediaAudioContext;
|
||||
}
|
||||
|
||||
function resetAudioPlayback() {
|
||||
if (mediaAudioContext) {
|
||||
audioPlaybackCursor = mediaAudioContext.currentTime + 0.04;
|
||||
audioPlaybackCursor = mediaAudioContext.currentTime + getAudioPlaybackBufferSeconds();
|
||||
}
|
||||
Array.from(audioPlaybackSources).forEach((source) => {
|
||||
try {
|
||||
@@ -350,7 +368,7 @@ async function queueServerAudio(arrayBuffer) {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = decoded;
|
||||
source.connect(context.destination);
|
||||
const startAt = Math.max(context.currentTime + 0.04, audioPlaybackCursor);
|
||||
const startAt = Math.max(context.currentTime + getAudioPlaybackBufferSeconds(), audioPlaybackCursor);
|
||||
source.start(startAt);
|
||||
audioPlaybackCursor = startAt + decoded.duration;
|
||||
audioPlaybackSources.add(source);
|
||||
@@ -578,10 +596,11 @@ function queueAnimationFrames(payload) {
|
||||
const frames = Array.isArray(payload.frames) ? payload.frames : [];
|
||||
const now = performance.now();
|
||||
const frameWindowMs = (1000 / Math.max(1, payload.fps || 25));
|
||||
const animationLeadMs = getAnimationLeadMs();
|
||||
|
||||
if (payload.type === "animation_reset" || payload.type === "animation_state") {
|
||||
avatarState.pendingFrames = [];
|
||||
avatarState.scheduleCursorMs = now + ANIMATION_BUFFER_MS;
|
||||
avatarState.scheduleCursorMs = now + animationLeadMs;
|
||||
avatarState.activeControls = {
|
||||
jawOpen: 0,
|
||||
viseme_aa: 0,
|
||||
@@ -594,7 +613,7 @@ function queueAnimationFrames(payload) {
|
||||
};
|
||||
}
|
||||
|
||||
const baseTime = Math.max(now + ANIMATION_BUFFER_MS, avatarState.scheduleCursorMs);
|
||||
const baseTime = Math.max(now + animationLeadMs, avatarState.scheduleCursorMs);
|
||||
frames.forEach((frame, index) => {
|
||||
const fallbackTime = index * frameWindowMs;
|
||||
avatarState.pendingFrames.push({
|
||||
@@ -637,22 +656,25 @@ function applyAnimationControls(controls) {
|
||||
const headYaw = clampSigned(safeControls.headYaw, 0.35);
|
||||
const headPitch = clampSigned(safeControls.headPitch, 0.3);
|
||||
const headRoll = clampSigned(safeControls.headRoll, 0.24);
|
||||
const hasFacialRig = Boolean(avatarState.currentVrm?.expressionManager) || avatarState.morphBindings.size > 0;
|
||||
const useExpressions = Boolean(avatarState.currentVrm?.expressionManager);
|
||||
const useMorphTargets = !useExpressions && avatarState.morphBindings.size > 0;
|
||||
const hasFacialRig = useExpressions || useMorphTargets;
|
||||
const mouthDrive = buildMouthDrive(jawOpen, visemeAa, visemeEe, visemeOh, mouthPucker);
|
||||
const t = performance.now() * 0.001;
|
||||
|
||||
if (avatarState.currentVrm?.expressionManager) {
|
||||
setExpressionValue("aa", visemeAa);
|
||||
setExpressionValue("ee", visemeEe);
|
||||
setExpressionValue("oh", visemeOh);
|
||||
setExpressionValue("ou", Math.max(visemeOh * 0.35, mouthPucker));
|
||||
if (useExpressions) {
|
||||
setExpressionValue("aa", mouthDrive.expression.aa);
|
||||
setExpressionValue("ee", mouthDrive.expression.ee);
|
||||
setExpressionValue("oh", mouthDrive.expression.oh);
|
||||
setExpressionValue("ou", mouthDrive.expression.ou);
|
||||
}
|
||||
|
||||
if (avatarState.morphBindings.size > 0) {
|
||||
setMorphValue("jawOpen", jawOpen);
|
||||
setMorphValue("viseme_aa", visemeAa);
|
||||
setMorphValue("viseme_ee", visemeEe);
|
||||
setMorphValue("viseme_oh", visemeOh);
|
||||
setMorphValue("mouthPucker", mouthPucker);
|
||||
if (useMorphTargets) {
|
||||
setMorphValue("jawOpen", mouthDrive.morph.jawOpen);
|
||||
setMorphValue("viseme_aa", mouthDrive.morph.viseme_aa);
|
||||
setMorphValue("viseme_ee", mouthDrive.morph.viseme_ee);
|
||||
setMorphValue("viseme_oh", mouthDrive.morph.viseme_oh);
|
||||
setMorphValue("mouthPucker", mouthDrive.morph.mouthPucker);
|
||||
}
|
||||
|
||||
applyBlinkControls(avatarState.blink?.value || 0);
|
||||
@@ -669,18 +691,18 @@ function applyAnimationControls(controls) {
|
||||
}
|
||||
|
||||
if (!hasFacialRig && avatarState.currentRoot && avatarState.currentRoot !== avatarState.debugRig) {
|
||||
const baseScale = 1.0 + jawOpen * 0.03;
|
||||
const baseScale = 1.0 + mouthDrive.debugJaw * 0.03;
|
||||
avatarState.currentRoot.scale.set(baseScale, baseScale, baseScale);
|
||||
}
|
||||
|
||||
if (avatarState.debugJaw) {
|
||||
avatarState.debugJaw.rotation.x = jawOpen * 0.5;
|
||||
avatarState.debugJaw.position.y = -0.06 - jawOpen * 0.015;
|
||||
avatarState.debugJaw.rotation.x = mouthDrive.debugJaw * 0.5;
|
||||
avatarState.debugJaw.position.y = -0.06 - mouthDrive.debugJaw * 0.015;
|
||||
}
|
||||
if (avatarState.debugMouth) {
|
||||
avatarState.debugMouth.scale.x = 1.0 + mouthPucker * 0.45;
|
||||
avatarState.debugMouth.scale.y = 1.0 + jawOpen * 3.8;
|
||||
avatarState.debugMouth.position.z = 0.31 + mouthPucker * 0.01;
|
||||
avatarState.debugMouth.scale.x = 1.0 + mouthDrive.morph.mouthPucker * 0.45;
|
||||
avatarState.debugMouth.scale.y = 1.0 + mouthDrive.debugJaw * 2.6;
|
||||
avatarState.debugMouth.position.z = 0.31 + mouthDrive.morph.mouthPucker * 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,10 +721,43 @@ function setMorphValue(controlName, value) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildMouthDrive(jawOpen, visemeAa, visemeEe, visemeOh, mouthPucker) {
|
||||
const mouthOpen = Math.max(jawOpen * 0.9, visemeAa);
|
||||
return {
|
||||
expression: {
|
||||
aa: clampControl(mouthOpen * MOUTH_DRIVE_LIMITS.expression.aa),
|
||||
ee: clampControl(visemeEe * MOUTH_DRIVE_LIMITS.expression.ee),
|
||||
oh: clampControl(visemeOh * MOUTH_DRIVE_LIMITS.expression.oh),
|
||||
ou: clampControl(
|
||||
Math.max(
|
||||
visemeOh * MOUTH_DRIVE_LIMITS.expression.ouFromOh,
|
||||
mouthPucker * MOUTH_DRIVE_LIMITS.expression.pucker
|
||||
)
|
||||
),
|
||||
},
|
||||
morph: {
|
||||
jawOpen: clampControl(jawOpen * MOUTH_DRIVE_LIMITS.morph.jawOpen),
|
||||
viseme_aa: clampControl(mouthOpen * MOUTH_DRIVE_LIMITS.morph.visemeAa),
|
||||
viseme_ee: clampControl(visemeEe * MOUTH_DRIVE_LIMITS.morph.visemeEe),
|
||||
viseme_oh: clampControl(visemeOh * MOUTH_DRIVE_LIMITS.morph.visemeOh),
|
||||
mouthPucker: clampControl(mouthPucker * MOUTH_DRIVE_LIMITS.morph.mouthPucker),
|
||||
},
|
||||
debugJaw: clampControl(mouthOpen * MOUTH_DRIVE_LIMITS.debugJaw),
|
||||
};
|
||||
}
|
||||
|
||||
function clampControl(value) {
|
||||
return Math.min(1, Math.max(0, Number(value) || 0));
|
||||
}
|
||||
|
||||
function getAudioPlaybackBufferSeconds() {
|
||||
return AUDIO_PLAYBACK_BUFFER_MS / 1000;
|
||||
}
|
||||
|
||||
function getAnimationLeadMs() {
|
||||
return ANIMATION_BUFFER_MS + AV_SYNC_AUDIO_LEAD_MS;
|
||||
}
|
||||
|
||||
function clampSigned(value, limit) {
|
||||
return Math.min(limit, Math.max(-limit, Number(value) || 0));
|
||||
}
|
||||
@@ -732,7 +787,7 @@ function detachCurrentAvatar() {
|
||||
scene.remove(avatarState.currentRoot);
|
||||
}
|
||||
avatarState.pendingFrames = [];
|
||||
avatarState.scheduleCursorMs = performance.now() + ANIMATION_BUFFER_MS;
|
||||
avatarState.scheduleCursorMs = performance.now() + getAnimationLeadMs();
|
||||
avatarState.lastAppliedSeq = -1;
|
||||
avatarState.idleRig = null;
|
||||
avatarState.currentRoot = avatarState.debugRig;
|
||||
@@ -765,11 +820,7 @@ async function loadModelFromUrl(url) {
|
||||
|
||||
async function loadDefaultAvatarModel() {
|
||||
modelUrl.value = DEFAULT_BODY_MODEL_URL;
|
||||
const ok = await loadModelFromUrl(DEFAULT_BODY_MODEL_URL);
|
||||
if (!ok) {
|
||||
modelUrl.value = DEFAULT_VRM_FALLBACK_URL;
|
||||
await loadModelFromUrl(DEFAULT_VRM_FALLBACK_URL);
|
||||
}
|
||||
await loadModelFromUrl(DEFAULT_BODY_MODEL_URL);
|
||||
}
|
||||
|
||||
async function loadModelFromFile(file) {
|
||||
@@ -823,7 +874,7 @@ function attachLoadedAsset(gltf, label) {
|
||||
avatarState.neckBone = resolveNeckBone(vrm, root) || avatarState.headBone;
|
||||
avatarState.idleRig = buildIdleRig(vrm, root);
|
||||
avatarState.pendingFrames = [];
|
||||
avatarState.scheduleCursorMs = performance.now() + ANIMATION_BUFFER_MS;
|
||||
avatarState.scheduleCursorMs = performance.now() + getAnimationLeadMs();
|
||||
avatarState.lastAppliedSeq = -1;
|
||||
|
||||
fitCameraToObject(root);
|
||||
@@ -867,7 +918,7 @@ function resolveNeckBone(vrm, root) {
|
||||
function collectMorphBindings(root) {
|
||||
const bindings = new Map();
|
||||
const aliases = {
|
||||
jawOpen: ["jawopen", "jaw_open", "mouthopen", "mouth_open", "aa", "viseme_aa"],
|
||||
jawOpen: ["jawopen", "jaw_open", "mouthopen", "mouth_open"],
|
||||
viseme_aa: ["viseme_aa", "aa", "a", "moutha"],
|
||||
viseme_ee: ["viseme_ee", "ee", "ih", "i", "mouthee"],
|
||||
viseme_oh: ["viseme_oh", "oh", "ou", "o", "mouthoh"],
|
||||
@@ -1321,7 +1372,7 @@ async function resetChat() {
|
||||
await fetch("/chat/reset", { method: "POST" });
|
||||
messagesEl.innerHTML = "";
|
||||
avatarState.pendingFrames = [];
|
||||
avatarState.scheduleCursorMs = performance.now() + ANIMATION_BUFFER_MS;
|
||||
avatarState.scheduleCursorMs = performance.now() + getAnimationLeadMs();
|
||||
resetAudioPlayback();
|
||||
resetAvatarPose();
|
||||
addMessage("system", "会话已重置。");
|
||||
|
||||
Reference in New Issue
Block a user