@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/README.md +82 -2
- package/dist/__tests__/mocks/EXRLoader.d.ts +3 -0
- package/dist/__tests__/mocks/HDRLoader.d.ts +3 -0
- package/dist/__tests__/mocks/KTX2Loader.d.ts +5 -0
- package/dist/__tests__/mocks/isows.d.ts +19 -0
- package/dist/__tests__/mocks/react-jsx-runtime.d.ts +9 -0
- package/dist/__tests__/mocks/react.d.ts +18 -0
- package/dist/__tests__/setup.d.ts +8 -8
- package/dist/avatar/VRMAvatar.d.ts +34 -11
- package/dist/avatar/compatibility.d.ts +12 -0
- package/dist/avatar/environment.d.ts +6 -0
- package/dist/avatar/index.d.ts +1 -1
- package/dist/avatar/postProcessing.d.ts +20 -0
- package/dist/react/index.d.ts +2 -1
- package/dist/react.esm.js +710 -69
- package/dist/react.js +710 -68
- package/package.json +26 -16
- package/dist/AnalyticsCollector.d.ts +0 -62
- package/dist/BillingMonitor.d.ts +0 -36
- package/dist/ConnectionStateManager.d.ts +0 -49
- package/dist/MediaManager.d.ts +0 -21
- package/dist/PerformanceMonitor.d.ts +0 -33
- package/dist/SecurityLogger.d.ts +0 -26
- package/dist/SessionManager.d.ts +0 -20
- package/dist/UsageTracker.d.ts +0 -21
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
|
1128
|
+
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
|
|
947
1129
|
scene.add(ambientLight);
|
|
948
|
-
const directionalLight = new THREE.DirectionalLight(0xffffff,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1223
|
+
const timer = new THREE.Timer();
|
|
1004
1224
|
const animate = () => {
|
|
1005
1225
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
1006
|
-
|
|
1226
|
+
timer.update();
|
|
1227
|
+
const delta = timer.getDelta();
|
|
1007
1228
|
animationTimeRef.current += delta;
|
|
1008
1229
|
if (vrmRef.current) {
|
|
1009
|
-
|
|
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
|
|
1013
|
-
const
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
if (
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
1025
|
-
if (
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
|
1030
|
-
if (
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
|
1035
|
-
if (
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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 (
|
|
1045
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
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,
|
|
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 };
|