@aether-stack-dev/client-sdk 1.2.5 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.esm.js CHANGED
@@ -5,6 +5,16 @@ import { jsxs, jsx } from 'react/jsx-runtime';
5
5
  import * as THREE from 'three';
6
6
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
7
7
  import { VRMLoaderPlugin, VRMHumanBoneName } from '@pixiv/three-vrm';
8
+ import { VRMLookAtQuaternionProxy, VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation';
9
+ import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
10
+ import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
11
+ import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
12
+ import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
13
+ import { BokehPass } from 'three/examples/jsm/postprocessing/BokehPass.js';
14
+ import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
15
+ import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js';
16
+ import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
17
+ import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
8
18
 
9
19
  const ARKIT_BLENDSHAPES = [
10
20
  'jawOpen', 'jawForward', 'jawLeft', 'jawRight',
@@ -28,8 +38,18 @@ const CRITICAL_BLENDSHAPES = [
28
38
  'jawOpen', 'eyeBlinkLeft', 'eyeBlinkRight',
29
39
  ];
30
40
  const DEFAULT_BLENDSHAPE_MAP = Object.fromEntries(ARKIT_BLENDSHAPES.map(name => [name, name]));
31
- ({
32
- ...DEFAULT_BLENDSHAPE_MAP});
41
+ const VROID_BLENDSHAPE_MAP = {
42
+ ...DEFAULT_BLENDSHAPE_MAP,
43
+ jawOpen: 'Fcl_MTH_A',
44
+ mouthFunnel: 'Fcl_MTH_U',
45
+ mouthSmileLeft: 'Fcl_MTH_Fun',
46
+ mouthSmileRight: 'Fcl_MTH_Fun',
47
+ eyeBlinkLeft: 'Fcl_EYE_Close_L',
48
+ eyeBlinkRight: 'Fcl_EYE_Close_R',
49
+ browDownLeft: 'Fcl_BRW_Angry_L',
50
+ browDownRight: 'Fcl_BRW_Angry_R',
51
+ browInnerUp: 'Fcl_BRW_Surprised',
52
+ };
33
53
 
34
54
  const IDX_JAW_OPEN = ARKIT_BLENDSHAPES.indexOf('jawOpen');
35
55
  const IDX_MOUTH_FUNNEL = ARKIT_BLENDSHAPES.indexOf('mouthFunnel');
@@ -797,7 +817,6 @@ function useAStackCSR(options) {
797
817
  };
798
818
  }
799
819
 
800
- const DEFAULT_MAX_MODEL_SIZE = 30 * 1024 * 1024; // 30MB
801
820
  const HIGH_VERTEX_THRESHOLD = 100000;
802
821
  function buildCompatibilityReport(vrm) {
803
822
  const foundNames = new Set();
@@ -834,7 +853,9 @@ function buildCompatibilityReport(vrm) {
834
853
  const supported = [];
835
854
  const missing = [];
836
855
  for (const name of ARKIT_BLENDSHAPES) {
837
- if (foundNames.has(name) || foundNames.has(name.toLowerCase())) {
856
+ const vroidMapped = VROID_BLENDSHAPE_MAP[name];
857
+ if (foundNames.has(name) || foundNames.has(name.toLowerCase())
858
+ || (vroidMapped && (foundNames.has(vroidMapped) || foundNames.has(vroidMapped.toLowerCase())))) {
838
859
  supported.push(name);
839
860
  }
840
861
  else {
@@ -857,45 +878,205 @@ function buildCompatibilityReport(vrm) {
857
878
  modelStats: { vertexCount, textureCount, morphTargetCount },
858
879
  };
859
880
  }
860
- function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models/avatar.vrm', backgroundColor = 0x1a1a2e, blendshapeMap, onModelLoad, maxModelSize = DEFAULT_MAX_MODEL_SIZE, }) {
881
+
882
+ function createPostProcessing(renderer, scene, camera, config, width, height) {
883
+ const composer = new EffectComposer(renderer);
884
+ composer.addPass(new RenderPass(scene, camera));
885
+ if (config.bloom !== undefined && config.bloom > 0) {
886
+ composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), config.bloom * 0.8, 0.4, 0.85));
887
+ }
888
+ if (config.ao !== undefined && config.ao > 0) {
889
+ const ssao = new SSAOPass(scene, camera, width, height);
890
+ ssao.kernelRadius = 8 * config.ao;
891
+ ssao.minDistance = 0.005;
892
+ ssao.maxDistance = 0.1;
893
+ composer.addPass(ssao);
894
+ }
895
+ if (config.dof) {
896
+ composer.addPass(new BokehPass(scene, camera, { focus: 1.4, aperture: 0.002, maxblur: 0.005 }));
897
+ }
898
+ composer.addPass(new OutputPass());
899
+ return {
900
+ composer,
901
+ dispose: () => { composer.renderTarget1.dispose(); composer.renderTarget2.dispose(); },
902
+ setSize: (w, h) => composer.setSize(w, h),
903
+ };
904
+ }
905
+ const FPS_SAMPLE_SIZE = 60;
906
+ const FPS_THRESHOLD = 30;
907
+ class FpsMonitor {
908
+ constructor() {
909
+ this.samples = [];
910
+ this.lastTime = 0;
911
+ this.disabled = false;
912
+ }
913
+ tick(time) {
914
+ if (this.lastTime > 0) {
915
+ const delta = time - this.lastTime;
916
+ if (delta > 0)
917
+ this.samples.push(1000 / delta);
918
+ if (this.samples.length > FPS_SAMPLE_SIZE)
919
+ this.samples.shift();
920
+ }
921
+ this.lastTime = time;
922
+ if (this.samples.length >= FPS_SAMPLE_SIZE) {
923
+ const avg = this.samples.reduce((a, b) => a + b, 0) / this.samples.length;
924
+ if (avg < FPS_THRESHOLD) {
925
+ this.disabled = true;
926
+ console.warn(`[VRM] Post-processing auto-disabled (avg FPS: ${avg.toFixed(0)})`);
927
+ }
928
+ }
929
+ }
930
+ reset() {
931
+ this.samples = [];
932
+ this.lastTime = 0;
933
+ this.disabled = false;
934
+ }
935
+ }
936
+
937
+ let ktx2Loader = null;
938
+ function getKTX2Loader(renderer) {
939
+ if (!ktx2Loader) {
940
+ ktx2Loader = new KTX2Loader();
941
+ ktx2Loader.setTranscoderPath('/basis/');
942
+ ktx2Loader.detectSupport(renderer);
943
+ }
944
+ return ktx2Loader;
945
+ }
946
+ function loadEnvironment(renderer, url) {
947
+ const cleanUrl = url.split('?')[0].split('#')[0].toLowerCase();
948
+ // For blob URLs, the filename is passed as a hash fragment (e.g. blob:...#file.ktx2)
949
+ const hash = (url.split('#')[1] || '').toLowerCase();
950
+ const hashExt = hash.includes('.') ? '.' + hash.split('.').pop() : '';
951
+ const pmrem = new THREE.PMREMGenerator(renderer);
952
+ pmrem.compileEquirectangularShader();
953
+ const finalize = (texture) => {
954
+ const envMap = pmrem.fromEquirectangular(texture).texture;
955
+ texture.dispose();
956
+ pmrem.dispose();
957
+ return { envMap, dispose: () => envMap.dispose() };
958
+ };
959
+ if (cleanUrl.endsWith('.ktx2') || hashExt === '.ktx2') {
960
+ return new Promise((resolve, reject) => {
961
+ getKTX2Loader(renderer).load(url, (compressedTex) => {
962
+ // Compressed textures can't be flipped by WebGL on upload, so the
963
+ // top-down KTX2 data is inverted vs what PMREM expects. Render to
964
+ // an intermediate target with flipped v to decompress + correct.
965
+ const w = compressedTex.image.width, h = compressedTex.image.height;
966
+ const rt = new THREE.WebGLRenderTarget(w, h, { type: THREE.HalfFloatType });
967
+ const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
968
+ const mat = new THREE.ShaderMaterial({
969
+ uniforms: { tMap: { value: compressedTex } },
970
+ vertexShader: 'varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position,1.); }',
971
+ fragmentShader: 'uniform sampler2D tMap; varying vec2 vUv; void main(){ gl_FragColor=texture2D(tMap,vec2(vUv.x,1.-vUv.y)); }',
972
+ });
973
+ const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
974
+ const sc = new THREE.Scene();
975
+ sc.add(quad);
976
+ const prevRT = renderer.getRenderTarget();
977
+ renderer.setRenderTarget(rt);
978
+ renderer.render(sc, cam);
979
+ renderer.setRenderTarget(prevRT);
980
+ mat.dispose();
981
+ quad.geometry.dispose();
982
+ compressedTex.dispose();
983
+ rt.texture.mapping = THREE.EquirectangularReflectionMapping;
984
+ const envMap = pmrem.fromEquirectangular(rt.texture).texture;
985
+ rt.dispose();
986
+ pmrem.dispose();
987
+ resolve({ envMap, dispose: () => envMap.dispose() });
988
+ }, undefined, reject);
989
+ });
990
+ }
991
+ return new Promise((resolve, reject) => {
992
+ const onLoad = (texture) => resolve(finalize(texture));
993
+ if (cleanUrl.endsWith('.exr') || hashExt === '.exr') {
994
+ new EXRLoader().load(url, onLoad, undefined, reject);
995
+ }
996
+ else {
997
+ new HDRLoader().load(url, onLoad, undefined, reject);
998
+ }
999
+ });
1000
+ }
1001
+
1002
+ const DEFAULT_MAX_MODEL_SIZE = 30 * 1024 * 1024; // 30MB
1003
+ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = 'https://xwqtyabmavpacejuypyp.supabase.co/storage/v1/object/public/vrm-templates/5705015963733407866.vrm', backgroundColor = 0xffffff, blendshapeMap, expressionOverrides, onModelLoad, maxModelSize = DEFAULT_MAX_MODEL_SIZE, cameraPosition, cameraTarget, cameraFov, lightIntensity, idleAnimationUrl, animationSpeed = 1, animationWeight = 1, animationCrossfade = 0.5, postProcessing, environmentUrl, environmentIntensity, environmentBlur, environmentZoom, onEnvironmentLoad, features, orbitAngle, orbitElevation, avatarRotation, onOrbitChange, cursorFollow = true, preserveDrawingBuffer = false, }) {
861
1004
  const containerRef = useRef(null);
862
1005
  const rendererRef = useRef(null);
863
1006
  const sceneRef = useRef(null);
864
1007
  const cameraRef = useRef(null);
865
1008
  const vrmRef = useRef(null);
1009
+ const lightsRef = useRef(null);
1010
+ const onModelLoadRef = useRef(onModelLoad);
1011
+ onModelLoadRef.current = onModelLoad;
1012
+ const animSpeedRef = useRef(animationSpeed);
1013
+ animSpeedRef.current = animationSpeed;
1014
+ const animWeightRef = useRef(animationWeight);
1015
+ animWeightRef.current = animationWeight;
1016
+ const animCrossfadeRef = useRef(animationCrossfade);
1017
+ animCrossfadeRef.current = animationCrossfade;
1018
+ const exprOverridesRef = useRef(expressionOverrides);
1019
+ exprOverridesRef.current = expressionOverrides;
1020
+ const featuresRef = useRef(features);
1021
+ featuresRef.current = features;
1022
+ const envZoomRef = useRef(environmentZoom);
1023
+ envZoomRef.current = environmentZoom;
1024
+ const onEnvLoadRef = useRef(onEnvironmentLoad);
1025
+ onEnvLoadRef.current = onEnvironmentLoad;
866
1026
  const [loading, setLoading] = useState(true);
867
1027
  const [error, setError] = useState(null);
868
1028
  const animationFrameRef = useRef(0);
869
1029
  const animationTimeRef = useRef(0);
1030
+ const mixerRef = useRef(null);
1031
+ const clipActionRef = useRef(null);
1032
+ const composerRef = useRef(null);
1033
+ const envMapRef = useRef(null);
1034
+ const bgSceneRef = useRef(new THREE.Scene());
1035
+ useRef(null);
1036
+ const fpsMonitorRef = useRef(new FpsMonitor());
1037
+ const orbitRef = useRef({ angle: 0, targetAngle: 0, elevation: 0, targetElevation: 0, zoomOffset: 0, dragging: false, lastX: 0, lastY: 0 });
1038
+ const orbitAnglePropRef = useRef(orbitAngle);
1039
+ orbitAnglePropRef.current = orbitAngle;
1040
+ const onOrbitChangeRef = useRef(onOrbitChange);
1041
+ onOrbitChangeRef.current = onOrbitChange;
1042
+ const baseCamRef = useRef({ distance: 1.2, height: 1.4, targetY: 1.3 });
1043
+ const idleRef = useRef({ blinkNext: 2 + Math.random() * 4, blinkPhase: 0, blinkStart: 0,
1044
+ happy: 0, happyTarget: 0, browInner: 0, browTarget: 0, cheek: 0, cheekTarget: 0, microNext: 3,
1045
+ eyeX: 0, eyeY: 0, eyeTargetX: 0, eyeTargetY: 0, eyeNext: 1 });
1046
+ const isA2FActiveRef = useRef(false);
1047
+ const idleMorphsRef = useRef([]);
1048
+ const springSettleFramesRef = useRef(0);
1049
+ const cursorFollowRef = useRef(cursorFollow);
1050
+ cursorFollowRef.current = cursorFollow;
1051
+ const cursorRef = useRef({ x: 0, y: 0, active: false });
870
1052
  const applyBlendshapes = useCallback((vrm, shapes) => {
871
1053
  const arkitMap = {};
872
1054
  ARKIT_BLENDSHAPES.forEach((name, i) => {
873
1055
  arkitMap[name] = shapes[i] || 0;
874
1056
  });
875
- const mouthOpen = arkitMap.jawOpen || 0;
1057
+ const mouthOpen = arkitMap.jawOpen || 0, funnel = arkitMap.mouthFunnel || 0;
876
1058
  const smile = ((arkitMap.mouthSmileLeft || 0) + (arkitMap.mouthSmileRight || 0)) / 2;
877
- const funnel = arkitMap.mouthFunnel || 0;
878
- const blinkLeft = arkitMap.eyeBlinkLeft || 0;
879
- const blinkRight = arkitMap.eyeBlinkRight || 0;
1059
+ const blinkLeft = arkitMap.eyeBlinkLeft || 0, blinkRight = arkitMap.eyeBlinkRight || 0;
880
1060
  if (vrm.expressionManager) {
881
1061
  vrm.expressionManager.expressions.forEach(exp => {
882
1062
  vrm.expressionManager?.setValue(exp.expressionName, 0);
883
1063
  });
884
- const trySetExpression = (name, value) => {
1064
+ const trySet = (name, value) => {
885
1065
  try {
886
1066
  vrm.expressionManager?.setValue(name, Math.min(Math.max(value, 0), 1));
887
1067
  }
888
- catch {
889
- // Expression doesn't exist
890
- }
1068
+ catch { }
891
1069
  };
892
- trySetExpression('aa', mouthOpen * 1.5);
893
- trySetExpression('happy', smile * 2);
894
- trySetExpression('blink', (blinkLeft + blinkRight) / 2);
895
- trySetExpression('blinkLeft', blinkLeft);
896
- trySetExpression('blinkRight', blinkRight);
897
- trySetExpression('ou', funnel);
898
- vrm.expressionManager.update();
1070
+ trySet('aa', mouthOpen * 1.5);
1071
+ trySet('happy', smile * 2);
1072
+ trySet('blink', (blinkLeft + blinkRight) / 2);
1073
+ trySet('blinkLeft', blinkLeft);
1074
+ trySet('blinkRight', blinkRight);
1075
+ trySet('ou', funnel);
1076
+ try {
1077
+ vrm.expressionManager.update();
1078
+ }
1079
+ catch { }
899
1080
  }
900
1081
  vrm.scene.traverse((obj) => {
901
1082
  if (obj.isMesh) {
@@ -923,7 +1104,8 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
923
1104
  renderer = new THREE.WebGLRenderer({
924
1105
  antialias: true,
925
1106
  alpha: true,
926
- powerPreference: 'high-performance'
1107
+ powerPreference: 'high-performance',
1108
+ preserveDrawingBuffer,
927
1109
  });
928
1110
  }
929
1111
  catch {
@@ -937,20 +1119,21 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
937
1119
  container.appendChild(renderer.domElement);
938
1120
  rendererRef.current = renderer;
939
1121
  const scene = new THREE.Scene();
940
- scene.background = new THREE.Color(backgroundColor);
1122
+ scene.background = backgroundColor === null ? null : new THREE.Color(backgroundColor ?? 0xffffff);
941
1123
  sceneRef.current = scene;
942
1124
  const camera = new THREE.PerspectiveCamera(30, width / height, 0.1, 20);
943
1125
  camera.position.set(0, 1.4, 1.2);
944
1126
  camera.lookAt(0, 1.3, 0);
945
1127
  cameraRef.current = camera;
946
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
1128
+ const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
947
1129
  scene.add(ambientLight);
948
- const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
1130
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
949
1131
  directionalLight.position.set(1, 1, 1);
950
1132
  scene.add(directionalLight);
951
- const backLight = new THREE.DirectionalLight(0x4a9eff, 0.3);
1133
+ const backLight = new THREE.DirectionalLight(0x4a9eff, 0.4);
952
1134
  backLight.position.set(-1, 1, -1);
953
1135
  scene.add(backLight);
1136
+ lightsRef.current = { ambient: ambientLight, directional: directionalLight, back: backLight };
954
1137
  let cancelled = false;
955
1138
  // Size guard: fire HEAD check in parallel — if model is too large, cancel before render
956
1139
  if (maxModelSize > 0) {
@@ -965,7 +1148,7 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
965
1148
  const errMsg = `Model too large (${sizeMB}MB, limit ${limitMB}MB)`;
966
1149
  setError(errMsg);
967
1150
  setLoading(false);
968
- onModelLoad?.({
1151
+ onModelLoadRef.current?.({
969
1152
  supported: 0, missing: [...ARKIT_BLENDSHAPES], warnings: [errMsg],
970
1153
  modelStats: { vertexCount: 0, textureCount: 0, morphTargetCount: 0 },
971
1154
  });
@@ -981,12 +1164,49 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
981
1164
  if (vrm) {
982
1165
  scene.add(vrm.scene);
983
1166
  vrmRef.current = vrm;
1167
+ const mc = [];
1168
+ vrm.scene.traverse((o) => {
1169
+ const mesh = o;
1170
+ if (!mesh.isMesh)
1171
+ return;
1172
+ const d = mesh.morphTargetDictionary, inf = mesh.morphTargetInfluences;
1173
+ if (!d || !inf)
1174
+ return;
1175
+ const e = { inf };
1176
+ if (d['browInnerUp'] !== undefined)
1177
+ e.brow = d['browInnerUp'];
1178
+ if (d['cheekSquintLeft'] !== undefined)
1179
+ e.cheekL = d['cheekSquintLeft'];
1180
+ if (d['cheekSquintRight'] !== undefined)
1181
+ e.cheekR = d['cheekSquintRight'];
1182
+ if (e.brow !== undefined || e.cheekL !== undefined)
1183
+ mc.push(e);
1184
+ });
1185
+ idleMorphsRef.current = mc;
1186
+ // Apply idle pose immediately so the model never renders in T-pose
1187
+ const humanoid = vrm.humanoid;
1188
+ if (humanoid) {
1189
+ const b = (n) => humanoid.getNormalizedBoneNode(n);
1190
+ const lArm = b(VRMHumanBoneName.LeftUpperArm), rArm = b(VRMHumanBoneName.RightUpperArm);
1191
+ if (lArm)
1192
+ lArm.rotation.z = -1;
1193
+ if (rArm)
1194
+ rArm.rotation.z = 1.0;
1195
+ }
1196
+ if (vrm.lookAt) {
1197
+ const proxy = new VRMLookAtQuaternionProxy(vrm.lookAt);
1198
+ proxy.name = 'VRMLookAtQuaternionProxy';
1199
+ vrm.scene.add(proxy);
1200
+ }
1201
+ vrm.update(0);
1202
+ vrm.springBoneManager?.reset();
1203
+ springSettleFramesRef.current = 30;
984
1204
  setLoading(false);
985
1205
  const report = buildCompatibilityReport(vrm);
986
1206
  if (report.warnings.length > 0) {
987
1207
  console.warn('[VRM] Compatibility warnings:', report.warnings);
988
1208
  }
989
- onModelLoad?.(report);
1209
+ onModelLoadRef.current?.(report);
990
1210
  }
991
1211
  }, () => { }, (err) => {
992
1212
  if (cancelled)
@@ -1000,78 +1220,499 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
1000
1220
  setError(detail);
1001
1221
  setLoading(false);
1002
1222
  });
1003
- const clock = new THREE.Clock();
1223
+ const timer = new THREE.Timer();
1004
1224
  const animate = () => {
1005
1225
  animationFrameRef.current = requestAnimationFrame(animate);
1006
- const delta = clock.getDelta();
1226
+ timer.update();
1227
+ const delta = timer.getDelta();
1007
1228
  animationTimeRef.current += delta;
1008
1229
  if (vrmRef.current) {
1009
- vrmRef.current.update(delta);
1230
+ try {
1231
+ vrmRef.current.update(delta);
1232
+ }
1233
+ catch { }
1234
+ if (featuresRef.current?.springBones === false || springSettleFramesRef.current > 0) {
1235
+ vrmRef.current.springBoneManager?.reset();
1236
+ if (springSettleFramesRef.current > 0)
1237
+ springSettleFramesRef.current--;
1238
+ }
1239
+ if (mixerRef.current)
1240
+ mixerRef.current.update(delta);
1010
1241
  const humanoid = vrmRef.current.humanoid;
1011
- if (humanoid) {
1012
- const time = animationTimeRef.current;
1013
- const breathCycle = time * 1.2;
1014
- const breathIntensity = 0.03;
1015
- const idleSway = time * 0.5;
1016
- const swayIntensity = 0.02;
1017
- const headBob = time * 0.7;
1018
- const headBobIntensity = 0.015;
1019
- const spineBone = humanoid.getNormalizedBoneNode(VRMHumanBoneName.Spine);
1020
- if (spineBone) {
1021
- spineBone.rotation.x = Math.sin(breathCycle) * breathIntensity;
1022
- spineBone.rotation.z = Math.sin(idleSway) * swayIntensity * 0.3;
1242
+ if (featuresRef.current?.idleAnimation !== false && humanoid && !clipActionRef.current) {
1243
+ const t = animationTimeRef.current, br = t * 1.2, sw = t * 0.5, hb = t * 0.7;
1244
+ const b = (n) => humanoid.getNormalizedBoneNode(n);
1245
+ const spine = b(VRMHumanBoneName.Spine);
1246
+ if (spine) {
1247
+ spine.rotation.x = Math.sin(br) * 0.03;
1248
+ spine.rotation.z = Math.sin(sw) * 0.006;
1249
+ }
1250
+ const chest = b(VRMHumanBoneName.Chest);
1251
+ if (chest) {
1252
+ chest.rotation.x = Math.sin(br + 0.3) * 0.015;
1253
+ chest.rotation.z = Math.sin(sw + 0.5) * 0.004;
1023
1254
  }
1024
- const chestBone = humanoid.getNormalizedBoneNode(VRMHumanBoneName.Chest);
1025
- if (chestBone) {
1026
- chestBone.rotation.x = Math.sin(breathCycle + 0.3) * breathIntensity * 0.5;
1027
- chestBone.rotation.z = Math.sin(idleSway + 0.5) * swayIntensity * 0.2;
1255
+ const neck = b(VRMHumanBoneName.Neck);
1256
+ if (neck) {
1257
+ neck.rotation.x = Math.sin(hb) * 0.015;
1258
+ neck.rotation.y = Math.sin(sw * 0.7) * 0.01;
1028
1259
  }
1029
- const neckBone = humanoid.getNormalizedBoneNode(VRMHumanBoneName.Neck);
1030
- if (neckBone) {
1031
- neckBone.rotation.x = Math.sin(headBob) * headBobIntensity;
1032
- neckBone.rotation.y = Math.sin(idleSway * 0.7) * swayIntensity * 0.5;
1260
+ const head = b(VRMHumanBoneName.Head);
1261
+ if (head) {
1262
+ head.rotation.x = Math.sin(hb + 0.5) * 0.006;
1263
+ head.rotation.y = Math.sin(sw * 0.6 + 0.8) * 0.006;
1033
1264
  }
1034
- const headBone = humanoid.getNormalizedBoneNode(VRMHumanBoneName.Head);
1035
- if (headBone) {
1036
- headBone.rotation.x = Math.sin(headBob + 0.5) * headBobIntensity * 0.4;
1037
- headBone.rotation.y = Math.sin(idleSway * 0.6 + 0.8) * swayIntensity * 0.3;
1265
+ const lSh = b(VRMHumanBoneName.LeftShoulder), rSh = b(VRMHumanBoneName.RightShoulder);
1266
+ if (lSh)
1267
+ lSh.rotation.z = Math.sin(br) * 0.006;
1268
+ if (rSh)
1269
+ rSh.rotation.z = -Math.sin(br) * 0.006;
1270
+ const lArm = b(VRMHumanBoneName.LeftUpperArm), rArm = b(VRMHumanBoneName.RightUpperArm);
1271
+ if (lArm)
1272
+ lArm.rotation.z = -1 + Math.sin(sw * 0.4) * 0.02;
1273
+ if (rArm)
1274
+ rArm.rotation.z = 1.0 + Math.sin(sw * 0.4 + 0.5) * 0.02;
1275
+ }
1276
+ if (featuresRef.current?.microExpressions !== false && vrmRef.current.expressionManager && !isA2FActiveRef.current &&
1277
+ !Object.keys(exprOverridesRef.current ?? {}).length) {
1278
+ const idle = idleRef.current, t = animationTimeRef.current;
1279
+ let bv = 0;
1280
+ if (idle.blinkPhase === 0 && t >= idle.blinkNext) {
1281
+ idle.blinkPhase = 1;
1282
+ idle.blinkStart = t;
1038
1283
  }
1039
- const leftShoulder = humanoid.getNormalizedBoneNode(VRMHumanBoneName.LeftShoulder);
1040
- const rightShoulder = humanoid.getNormalizedBoneNode(VRMHumanBoneName.RightShoulder);
1041
- if (leftShoulder) {
1042
- leftShoulder.rotation.z = Math.sin(breathCycle) * breathIntensity * 0.2;
1284
+ if (idle.blinkPhase === 1) {
1285
+ bv = Math.min((t - idle.blinkStart) / 0.15, 1);
1286
+ if (bv >= 1) {
1287
+ idle.blinkPhase = 2;
1288
+ idle.blinkStart = t;
1289
+ }
1043
1290
  }
1044
- if (rightShoulder) {
1045
- rightShoulder.rotation.z = -Math.sin(breathCycle) * breathIntensity * 0.2;
1291
+ else if (idle.blinkPhase === 2) {
1292
+ bv = 1 - Math.min((t - idle.blinkStart) / 0.15, 1);
1293
+ if (bv <= 0) {
1294
+ idle.blinkPhase = 0;
1295
+ idle.blinkNext = t + 2 + Math.random() * 4;
1296
+ }
1046
1297
  }
1047
- const leftUpperArm = humanoid.getNormalizedBoneNode(VRMHumanBoneName.LeftUpperArm);
1048
- const rightUpperArm = humanoid.getNormalizedBoneNode(VRMHumanBoneName.RightUpperArm);
1049
- if (leftUpperArm) {
1050
- leftUpperArm.rotation.z = -1 + Math.sin(idleSway * 0.4) * 0.02;
1298
+ if (t >= idle.microNext) {
1299
+ idle.happyTarget = Math.random() * 0.1;
1300
+ idle.browTarget = Math.random() * 0.08;
1301
+ idle.cheekTarget = Math.random() * 0.05;
1302
+ idle.microNext = t + 3 + Math.random() * 5;
1051
1303
  }
1052
- if (rightUpperArm) {
1053
- rightUpperArm.rotation.z = 1.0 + Math.sin(idleSway * 0.4 + 0.5) * 0.02;
1304
+ idle.happy += (idle.happyTarget - idle.happy) * 0.02;
1305
+ idle.browInner += (idle.browTarget - idle.browInner) * 0.02;
1306
+ idle.cheek += (idle.cheekTarget - idle.cheek) * 0.02;
1307
+ const cursor = cursorRef.current;
1308
+ if (cursorFollowRef.current && cursor.active) {
1309
+ idle.eyeTargetX = cursor.x * 0.26; // ±15° horizontal (0.26 rad ≈ 15°)
1310
+ idle.eyeTargetY = cursor.y * 0.17; // ±10° vertical (0.17 rad ≈ 10°)
1311
+ }
1312
+ else if (t >= idle.eyeNext) {
1313
+ idle.eyeTargetX = (Math.random() - 0.5) * 0.06;
1314
+ idle.eyeTargetY = (Math.random() - 0.5) * 0.04;
1315
+ idle.eyeNext = t + 1.5 + Math.random() * 3;
1316
+ }
1317
+ idle.eyeX += (idle.eyeTargetX - idle.eyeX) * 0.06;
1318
+ idle.eyeY += (idle.eyeTargetY - idle.eyeY) * 0.06;
1319
+ const em = vrmRef.current.expressionManager;
1320
+ try {
1321
+ em.setValue('blink', Math.max(bv, 0));
1322
+ }
1323
+ catch { }
1324
+ try {
1325
+ em.setValue('happy', Math.max(idle.happy, 0));
1326
+ }
1327
+ catch { }
1328
+ try {
1329
+ em.update();
1330
+ }
1331
+ catch { }
1332
+ for (const m of idleMorphsRef.current) {
1333
+ if (m.brow !== undefined)
1334
+ m.inf[m.brow] = idle.browInner;
1335
+ if (m.cheekL !== undefined)
1336
+ m.inf[m.cheekL] = idle.cheek;
1337
+ if (m.cheekR !== undefined)
1338
+ m.inf[m.cheekR] = idle.cheek;
1339
+ }
1340
+ if (humanoid) {
1341
+ for (const bn of [VRMHumanBoneName.LeftEye, VRMHumanBoneName.RightEye]) {
1342
+ const eye = humanoid.getNormalizedBoneNode(bn);
1343
+ if (eye) {
1344
+ eye.rotation.x = idle.eyeY;
1345
+ eye.rotation.y = idle.eyeX;
1346
+ }
1347
+ }
1054
1348
  }
1055
1349
  }
1056
1350
  }
1057
- renderer.render(scene, camera);
1351
+ // Smooth lerp for spherical orbit (azimuth + elevation)
1352
+ const orbit = orbitRef.current;
1353
+ const elevDiff = orbit.targetElevation - orbit.elevation;
1354
+ const angleDiff = orbit.targetAngle - orbit.angle;
1355
+ if (Math.abs(elevDiff) > 0.0005 || Math.abs(angleDiff) > 0.0005) {
1356
+ orbit.elevation += elevDiff * 0.1;
1357
+ orbit.angle += angleDiff * 0.1;
1358
+ const base = baseCamRef.current;
1359
+ const d = Math.max(0.3, base.distance + orbit.zoomOffset);
1360
+ const cosEl = Math.cos(orbit.elevation);
1361
+ const sinEl = Math.sin(orbit.elevation);
1362
+ camera.position.set(Math.sin(orbit.angle) * cosEl * d, base.height + sinEl * d, Math.cos(orbit.angle) * cosEl * d);
1363
+ camera.lookAt(0, base.targetY, 0);
1364
+ }
1365
+ const pipeline = composerRef.current;
1366
+ const ez = envZoomRef.current;
1367
+ const hasBgZoom = ez && ez !== 1 && scene.background && envMapRef.current;
1368
+ if (hasBgZoom && !pipeline) {
1369
+ const baseFov = camera.fov;
1370
+ const bgScene = bgSceneRef.current;
1371
+ bgScene.backgroundBlurriness = scene.backgroundBlurriness;
1372
+ bgScene.backgroundIntensity = scene.backgroundIntensity ?? 1;
1373
+ camera.fov = baseFov / ez;
1374
+ camera.updateProjectionMatrix();
1375
+ renderer.render(bgScene, camera);
1376
+ camera.fov = baseFov;
1377
+ camera.updateProjectionMatrix();
1378
+ const savedBg = scene.background;
1379
+ scene.background = null;
1380
+ renderer.autoClear = false;
1381
+ renderer.clearDepth();
1382
+ renderer.render(scene, camera);
1383
+ renderer.autoClear = true;
1384
+ scene.background = savedBg;
1385
+ }
1386
+ else if (pipeline && !fpsMonitorRef.current.disabled) {
1387
+ pipeline.composer.render();
1388
+ fpsMonitorRef.current.tick(performance.now());
1389
+ }
1390
+ else {
1391
+ renderer.render(scene, camera);
1392
+ }
1058
1393
  };
1059
1394
  animate();
1060
1395
  return () => {
1061
1396
  cancelled = true;
1062
1397
  cancelAnimationFrame(animationFrameRef.current);
1398
+ clipActionRef.current = null;
1399
+ mixerRef.current = null;
1400
+ composerRef.current?.dispose();
1401
+ composerRef.current = null;
1402
+ envMapRef.current?.dispose();
1403
+ envMapRef.current = null;
1063
1404
  renderer.dispose();
1064
1405
  if (container && renderer.domElement) {
1065
1406
  container.removeChild(renderer.domElement);
1066
1407
  }
1067
1408
  };
1068
- }, [width, height, modelUrl, backgroundColor, maxModelSize, onModelLoad]);
1409
+ }, [width, height, modelUrl, maxModelSize, preserveDrawingBuffer]);
1410
+ useEffect(() => {
1411
+ composerRef.current?.dispose();
1412
+ composerRef.current = null;
1413
+ fpsMonitorRef.current.reset();
1414
+ const renderer = rendererRef.current, scene = sceneRef.current, camera = cameraRef.current;
1415
+ const pp = features?.postProcessing ?? postProcessing;
1416
+ if (!renderer || !scene || !camera || !pp)
1417
+ return;
1418
+ const hasAnyEffect = (pp.bloom && pp.bloom > 0) || (pp.ao && pp.ao > 0) || pp.dof;
1419
+ if (!hasAnyEffect)
1420
+ return;
1421
+ composerRef.current = createPostProcessing(renderer, scene, camera, pp, width, height);
1422
+ return () => { composerRef.current?.dispose(); composerRef.current = null; };
1423
+ }, [postProcessing, features, width, height]);
1424
+ // Load/unload .vrma animation clip — crossfade between clips
1425
+ useEffect(() => {
1426
+ const vrm = vrmRef.current;
1427
+ const fade = animCrossfadeRef.current;
1428
+ if (!idleAnimationUrl) {
1429
+ const old = clipActionRef.current;
1430
+ if (old) {
1431
+ old.fadeOut(fade);
1432
+ setTimeout(() => {
1433
+ old.stop();
1434
+ if (clipActionRef.current === old)
1435
+ clipActionRef.current = null;
1436
+ if (mixerRef.current && !clipActionRef.current) {
1437
+ mixerRef.current.stopAllAction();
1438
+ mixerRef.current = null;
1439
+ }
1440
+ }, fade * 1000);
1441
+ }
1442
+ return;
1443
+ }
1444
+ if (!vrm)
1445
+ return;
1446
+ let cancelled = false;
1447
+ const loader = new GLTFLoader();
1448
+ loader.register((parser) => new VRMAnimationLoaderPlugin(parser));
1449
+ const origWarn = console.warn;
1450
+ console.warn = (...args) => {
1451
+ if (typeof args[0] === 'string' && args[0].includes('specVersion'))
1452
+ return;
1453
+ origWarn.apply(console, args);
1454
+ };
1455
+ loader.load(idleAnimationUrl, (gltf) => {
1456
+ console.warn = origWarn;
1457
+ if (cancelled || !vrmRef.current)
1458
+ return;
1459
+ const animations = gltf.userData.vrmAnimations;
1460
+ if (!animations || animations.length === 0)
1461
+ return;
1462
+ const mixer = mixerRef.current ?? new THREE.AnimationMixer(vrmRef.current.scene);
1463
+ mixerRef.current = mixer;
1464
+ const clip = createVRMAnimationClip(animations[0], vrmRef.current);
1465
+ const newAction = mixer.clipAction(clip);
1466
+ newAction.setLoop(THREE.LoopPingPong, Infinity);
1467
+ newAction.timeScale = animSpeedRef.current;
1468
+ newAction.setEffectiveWeight(animWeightRef.current);
1469
+ const oldAction = clipActionRef.current;
1470
+ const cf = animCrossfadeRef.current;
1471
+ if (oldAction) {
1472
+ newAction.reset().play();
1473
+ oldAction.crossFadeTo(newAction, cf, true);
1474
+ setTimeout(() => { oldAction.stop(); mixer.uncacheAction(oldAction.getClip()); }, cf * 1000);
1475
+ }
1476
+ else {
1477
+ newAction.reset().fadeIn(cf).play();
1478
+ }
1479
+ clipActionRef.current = newAction;
1480
+ }, () => { }, (err) => {
1481
+ console.warn = origWarn;
1482
+ if (cancelled)
1483
+ return;
1484
+ console.warn('[VRM] Failed to load animation clip:', err);
1485
+ });
1486
+ return () => { cancelled = true; };
1487
+ }, [idleAnimationUrl, loading]);
1488
+ // Update animation speed/weight in real time
1489
+ useEffect(() => {
1490
+ if (clipActionRef.current)
1491
+ clipActionRef.current.timeScale = animationSpeed;
1492
+ }, [animationSpeed]);
1493
+ useEffect(() => {
1494
+ if (clipActionRef.current)
1495
+ clipActionRef.current.setEffectiveWeight(animationWeight);
1496
+ }, [animationWeight]);
1497
+ // Update background in-place (skip when HDRI active)
1498
+ useEffect(() => {
1499
+ if (sceneRef.current && !environmentUrl) {
1500
+ sceneRef.current.background = backgroundColor === null ? null : new THREE.Color(backgroundColor ?? 0xffffff);
1501
+ }
1502
+ }, [backgroundColor, environmentUrl]);
1503
+ // HDRI environment map loading — keep old envMap visible until new one is ready
1504
+ useEffect(() => {
1505
+ const scene = sceneRef.current, renderer = rendererRef.current;
1506
+ if (!scene || !renderer || !environmentUrl) {
1507
+ envMapRef.current?.dispose();
1508
+ envMapRef.current = null;
1509
+ if (scene) {
1510
+ scene.environment = null;
1511
+ scene.background = null;
1512
+ scene.backgroundBlurriness = 0;
1513
+ scene.environmentIntensity = 1;
1514
+ }
1515
+ return;
1516
+ }
1517
+ let cancelled = false;
1518
+ const prev = envMapRef.current;
1519
+ loadEnvironment(renderer, environmentUrl).then(r => {
1520
+ if (cancelled) {
1521
+ r.dispose();
1522
+ return;
1523
+ }
1524
+ prev?.dispose();
1525
+ scene.environment = r.envMap;
1526
+ scene.background = r.envMap;
1527
+ scene.backgroundBlurriness = environmentBlur ?? 0;
1528
+ scene.environmentIntensity = environmentIntensity ?? 1;
1529
+ scene.backgroundIntensity = environmentIntensity ?? 1;
1530
+ bgSceneRef.current.background = r.envMap;
1531
+ envMapRef.current = { dispose: r.dispose, envMap: r.envMap };
1532
+ onEnvLoadRef.current?.();
1533
+ }).catch(e => console.warn('[VRM] HDRI load failed:', e));
1534
+ return () => { cancelled = true; };
1535
+ }, [environmentUrl]);
1536
+ // Update HDRI intensity/blur without reloading
1537
+ useEffect(() => {
1538
+ if (!sceneRef.current || !envMapRef.current)
1539
+ return;
1540
+ sceneRef.current.backgroundBlurriness = environmentBlur ?? 0;
1541
+ sceneRef.current.environmentIntensity = environmentIntensity ?? 1;
1542
+ sceneRef.current.backgroundIntensity = environmentIntensity ?? 1;
1543
+ }, [environmentIntensity, environmentBlur]);
1544
+ // Update camera base values and apply orbit
1545
+ useEffect(() => {
1546
+ const pos = cameraPosition ?? [0, 1.4, 1.2];
1547
+ const target = cameraTarget ?? [0, 1.3, 0];
1548
+ baseCamRef.current = { distance: pos[2], height: pos[1], targetY: target[1] };
1549
+ if (orbitAnglePropRef.current === undefined) {
1550
+ orbitRef.current.angle = 0;
1551
+ orbitRef.current.targetAngle = 0;
1552
+ }
1553
+ orbitRef.current.zoomOffset = 0;
1554
+ if (orbitElevation === undefined) {
1555
+ orbitRef.current.elevation = 0;
1556
+ orbitRef.current.targetElevation = 0;
1557
+ }
1558
+ const camera = cameraRef.current;
1559
+ if (!camera)
1560
+ return;
1561
+ const d = baseCamRef.current.distance;
1562
+ camera.position.set(0, baseCamRef.current.height, d);
1563
+ camera.lookAt(0, baseCamRef.current.targetY, 0);
1564
+ if (cameraFov !== undefined && camera.fov !== cameraFov) {
1565
+ camera.fov = cameraFov;
1566
+ camera.updateProjectionMatrix();
1567
+ }
1568
+ }, [cameraPosition, cameraTarget, cameraFov]);
1569
+ // Prop-driven orbit angle
1570
+ useEffect(() => {
1571
+ if (orbitAngle !== undefined) {
1572
+ orbitRef.current.targetAngle = orbitAngle;
1573
+ }
1574
+ }, [orbitAngle]);
1575
+ // Prop-driven orbit elevation
1576
+ useEffect(() => {
1577
+ if (orbitElevation !== undefined) {
1578
+ orbitRef.current.targetElevation = orbitElevation;
1579
+ }
1580
+ }, [orbitElevation]);
1581
+ // Prop-driven avatar rotation
1582
+ useEffect(() => {
1583
+ if (avatarRotation !== undefined && vrmRef.current) {
1584
+ vrmRef.current.scene.rotation.y = avatarRotation;
1585
+ }
1586
+ }, [avatarRotation, loading]);
1587
+ // Mouse orbit (horizontal only) + scroll zoom
1588
+ useEffect(() => {
1589
+ const container = containerRef.current;
1590
+ if (!container)
1591
+ return;
1592
+ const applyCameraOrbit = () => {
1593
+ const camera = cameraRef.current;
1594
+ if (!camera)
1595
+ return;
1596
+ const base = baseCamRef.current;
1597
+ const orbit = orbitRef.current;
1598
+ const d = Math.max(0.3, base.distance + orbit.zoomOffset);
1599
+ const cosEl = Math.cos(orbit.elevation);
1600
+ const sinEl = Math.sin(orbit.elevation);
1601
+ camera.position.set(Math.sin(orbit.angle) * cosEl * d, base.height + sinEl * d, Math.cos(orbit.angle) * cosEl * d);
1602
+ camera.lookAt(0, base.targetY, 0);
1603
+ };
1604
+ const onWheel = (e) => {
1605
+ e.preventDefault();
1606
+ orbitRef.current.zoomOffset += e.deltaY * 0.002;
1607
+ applyCameraOrbit();
1608
+ };
1609
+ const onMouseDown = (e) => {
1610
+ orbitRef.current.dragging = true;
1611
+ orbitRef.current.lastX = e.clientX;
1612
+ orbitRef.current.lastY = e.clientY;
1613
+ };
1614
+ const onMouseMove = (e) => {
1615
+ if (!orbitRef.current.dragging)
1616
+ return;
1617
+ const dx = e.clientX - orbitRef.current.lastX;
1618
+ const dy = e.clientY - orbitRef.current.lastY;
1619
+ orbitRef.current.lastX = e.clientX;
1620
+ orbitRef.current.lastY = e.clientY;
1621
+ orbitRef.current.angle -= dx * 0.005;
1622
+ orbitRef.current.targetAngle = orbitRef.current.angle;
1623
+ orbitRef.current.elevation = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, orbitRef.current.elevation + dy * 0.005));
1624
+ orbitRef.current.targetElevation = orbitRef.current.elevation;
1625
+ applyCameraOrbit();
1626
+ onOrbitChangeRef.current?.(orbitRef.current.angle, orbitRef.current.elevation);
1627
+ };
1628
+ const onMouseUp = () => {
1629
+ orbitRef.current.dragging = false;
1630
+ };
1631
+ const onKeyDown = (e) => {
1632
+ const o = orbitRef.current;
1633
+ if (e.key === 'ArrowUp') {
1634
+ e.preventDefault();
1635
+ o.targetElevation = Math.min(o.targetElevation + 0.1, Math.PI / 3);
1636
+ }
1637
+ else if (e.key === 'ArrowDown') {
1638
+ e.preventDefault();
1639
+ o.targetElevation = Math.max(o.targetElevation - 0.1, -Math.PI / 3);
1640
+ }
1641
+ else if (e.key === 'ArrowLeft') {
1642
+ e.preventDefault();
1643
+ o.targetAngle += 0.15;
1644
+ }
1645
+ else if (e.key === 'ArrowRight') {
1646
+ e.preventDefault();
1647
+ o.targetAngle -= 0.15;
1648
+ }
1649
+ onOrbitChangeRef.current?.(o.targetAngle, o.targetElevation);
1650
+ };
1651
+ const onCursorMove = (e) => {
1652
+ const rect = container.getBoundingClientRect();
1653
+ cursorRef.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
1654
+ cursorRef.current.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
1655
+ cursorRef.current.active = true;
1656
+ };
1657
+ const onCursorLeave = () => { cursorRef.current.active = false; };
1658
+ container.addEventListener('wheel', onWheel, { passive: false });
1659
+ container.addEventListener('mousedown', onMouseDown);
1660
+ container.addEventListener('mousemove', onCursorMove);
1661
+ container.addEventListener('mouseleave', onCursorLeave);
1662
+ window.addEventListener('mousemove', onMouseMove);
1663
+ window.addEventListener('mouseup', onMouseUp);
1664
+ window.addEventListener('keydown', onKeyDown);
1665
+ return () => {
1666
+ container.removeEventListener('wheel', onWheel);
1667
+ container.removeEventListener('mousedown', onMouseDown);
1668
+ container.removeEventListener('mousemove', onCursorMove);
1669
+ container.removeEventListener('mouseleave', onCursorLeave);
1670
+ window.removeEventListener('mousemove', onMouseMove);
1671
+ window.removeEventListener('mouseup', onMouseUp);
1672
+ window.removeEventListener('keydown', onKeyDown);
1673
+ };
1674
+ }, []);
1675
+ // Update lighting in-place
1676
+ useEffect(() => {
1677
+ if (!lightsRef.current)
1678
+ return;
1679
+ const intensity = lightIntensity ?? 1;
1680
+ lightsRef.current.ambient.intensity = 1.0 * intensity;
1681
+ lightsRef.current.directional.intensity = 1.2 * intensity;
1682
+ lightsRef.current.back.intensity = 0.4 * intensity;
1683
+ }, [lightIntensity]);
1069
1684
  useEffect(() => {
1685
+ isA2FActiveRef.current = blendshapes.some(v => v !== 0);
1070
1686
  if (vrmRef.current && blendshapes.length > 0) {
1071
1687
  applyBlendshapes(vrmRef.current, blendshapes);
1072
1688
  }
1073
1689
  }, [blendshapes, applyBlendshapes]);
1690
+ useEffect(() => {
1691
+ const vrm = vrmRef.current;
1692
+ if (!vrm?.expressionManager)
1693
+ return;
1694
+ const hasLiveData = blendshapes.some(v => v !== 0);
1695
+ if (hasLiveData)
1696
+ return;
1697
+ if (features?.expressionPresets === false)
1698
+ return;
1699
+ vrm.expressionManager.expressions.forEach(exp => {
1700
+ vrm.expressionManager.setValue(exp.expressionName, 0);
1701
+ });
1702
+ if (expressionOverrides) {
1703
+ for (const [name, value] of Object.entries(expressionOverrides)) {
1704
+ try {
1705
+ vrm.expressionManager.setValue(name, Math.min(Math.max(value, 0), 1));
1706
+ }
1707
+ catch { /* expression doesn't exist on this model */ }
1708
+ }
1709
+ }
1710
+ try {
1711
+ vrm.expressionManager.update();
1712
+ }
1713
+ catch { }
1714
+ }, [expressionOverrides, blendshapes]);
1074
1715
  return (jsxs("div", { className: "relative", style: { width, height }, children: [jsx("div", { ref: containerRef, className: "rounded-lg overflow-hidden" }), loading && (jsx("div", { className: "absolute inset-0 flex items-center justify-center bg-gray-800 rounded-lg", children: jsx("div", { className: "text-white text-sm", children: "Loading VRM avatar..." }) })), error && (jsx("div", { className: "absolute inset-0 flex items-center justify-center bg-red-900/50 rounded-lg", children: jsx("div", { className: "text-white text-sm", children: error }) }))] }));
1075
1716
  }
1076
1717
 
1077
- export { VRMAvatar, useAStackCSR };
1718
+ export { VRMAvatar, VROID_BLENDSHAPE_MAP, useAStackCSR };