# 아바타 블렌드셰이프 캘리브레이션 시스템

## 1. 문제

V2 모델은 52-dim ARKit 블렌드셰이프를 출력한다. 그런데 아바타마다 같은 값이 다르게 보인다.

예를 들어 `jawOpen: 0.8`을 보내면:
- 아바타 A: 자연스럽게 입이 벌어짐
- 아바타 B: 턱이 과하게 벌어져서 부자연스러움
- 아바타 C: 거의 변화 없음

이유는 아바타 제작자마다 morph target(블렌드셰이프)의 메시 변형량을 다르게 디자인하기 때문이다. 같은 weight=1.0이라도 실제 정점(vertex) 이동량이 아바타마다 다르다.

---

## 2. 현재 상태 (calibration-demo)

`lipsync-wasm-se/examples/calibration-demo/`에 프로토타입이 존재한다.

### 현재 방식: 단순 비율 곱셈

```
gain = reference_RMS / user_RMS
calibrated_output = model_output × gain
```

1. 레퍼런스 아바타(brunette.glb)의 각 채널별 RMS 변위량이 하드코딩되어 있음
2. 유저 아바타를 로드하면 morph target geometry를 순회하며 채널별 RMS 변위량을 측정함
3. 레퍼런스 대비 비율을 gain으로 사용 (채널별 clamp 적용)
4. 매 프레임 `output × gain` → clamp [0, 1]

### 현재 방식의 한계

단순 곱셈은 **전 범위를 균일하게 스케일링**한다. 이것이 왜 문제인지:

```
예: 레퍼런스 jawOpen RMS = 0.017, 유저 아바타 = 0.034 (2배 큰 변형)
→ gain = 0.017 / 0.034 = 0.5

모델 출력 0.8 (크게 벌림) → 0.8 × 0.5 = 0.4  ✓ 적절히 축소됨
모델 출력 0.1 (살짝 벌림) → 0.1 × 0.5 = 0.05  ✗ 미세한 움직임까지 반으로 줄어 거의 안 보임
모델 출력 0.03 (아주 미세) → 0.03 × 0.5 = 0.015 ✗ 사실상 사라짐
```

문제: **작은 움직임은 보존하면서 큰 움직임만 조절해야** 하는데, 단순 곱셈은 둘 다 똑같이 줄인다.

또 다른 문제:
- **Dead zone**: 일부 아바타는 weight < 0.05 구간에서 아예 반응이 없음 → 여기에 값을 보내면 낭비
- **Saturation**: 일부 아바타는 weight 0.7에서 이미 메시 자체교차(self-intersection) 발생 → 0.7 이상 보내면 깨짐
- **비선형 응답**: morph target이 선형이 아닌 경우 중간값의 비율이 달라짐

---

## 3. 개선된 캘리브레이션 시스템

### 3.1 핵심 아이디어: Range Matching

단순 곱셈이 아니라, **레퍼런스 아바타의 응답 범위를 유저 아바타의 응답 범위에 매핑**한다.

```
기존: output × (ref / user)                    ← 균일 스케일링
개선: adaptive_remap(output, ref_curve, user_curve)  ← 범위 매칭
```

### 3.2 Multi-Point Probing (다중 지점 측정)

현재는 weight=1.0에서만 RMS를 측정한다. 이를 **여러 지점에서 측정**하여 응답 곡선을 구축한다.

```javascript
// 측정 지점
const PROBE_WEIGHTS = [0.05, 0.25, 0.5, 0.75, 1.0];

function probeChannel(meshes, channelName) {
  const curve = [];
  
  for (const w of PROBE_WEIGHTS) {
    // 1. 해당 채널의 morphTargetInfluence를 w로 설정
    setMorphWeight(meshes, channelName, w);
    
    // 2. 변위된 정점 위치에서 RMS 계산
    const rms = measureDisplacement(meshes, channelName, w);
    curve.push({ weight: w, rms });
    
    // 3. 리셋
    setMorphWeight(meshes, channelName, 0);
  }
  
  return curve;
  // 예시 결과: [
  //   { weight: 0.05, rms: 0.0002 },  // dead zone: 거의 안 움직임
  //   { weight: 0.25, rms: 0.0045 },
  //   { weight: 0.50, rms: 0.0089 },
  //   { weight: 0.75, rms: 0.0131 },
  //   { weight: 1.00, rms: 0.0170 },
  // ]
}
```

**참고:** Three.js에서 morph target의 정점 변위는 `geometry.morphAttributes.position[morphIdx]`에 저장되어 있고, weight에 비례하여 선형 보간된다. 따라서 실제로는 weight=1.0에서 한 번만 측정하고 나머지는 `rms_at_w = rms_at_1 × w`로 계산 가능하다. 하지만 일부 아바타는 corrective morph target이나 multiple mesh 합산 시 비선형성이 나타날 수 있으므로, multi-point 측정이 안전하다.

### 3.3 응답 곡선에서 파라미터 추출

각 채널의 probing 결과에서 3가지 핵심 파라미터를 추출한다:

```javascript
function extractChannelParams(probeCurve) {
  // 1. Dead zone 탐지: 의미 있는 움직임이 시작되는 지점
  //    RMS가 전체 max의 1% 미만인 구간은 dead zone
  const maxRms = probeCurve[probeCurve.length - 1].rms;
  const deadThreshold = maxRms * 0.01;
  let deadZone = 0;
  for (const p of probeCurve) {
    if (p.rms > deadThreshold) break;
    deadZone = p.weight;
  }

  // 2. Saturation 탐지: 응답이 포화되는 지점
  //    연속 두 지점에서 RMS 증가율이 전체 평균의 20% 미만이면 포화
  let saturation = 1.0;
  const avgRate = maxRms / 1.0;
  for (let i = 1; i < probeCurve.length; i++) {
    const rate = (probeCurve[i].rms - probeCurve[i-1].rms) 
                / (probeCurve[i].weight - probeCurve[i-1].weight);
    if (rate < avgRate * 0.20) {
      saturation = probeCurve[i-1].weight;
      break;
    }
  }

  // 3. 유효 범위 (effective range)
  const effectiveRange = [deadZone, saturation];

  return { deadZone, saturation, effectiveRange, maxRms };
}
```

### 3.4 Adaptive Remap 함수 (핵심 수식)

단순 곱셈 대신, **입력 범위를 아바타의 유효 범위에 맞게 재매핑**한다.

```javascript
/**
 * 모델 출력을 아바타의 유효 범위에 적응적으로 매핑.
 *
 * @param {number} x         - 모델 출력 (0~1)
 * @param {object} refParams - 레퍼런스 아바타 파라미터
 * @param {object} userParams - 유저 아바타 파라미터
 * @returns {number}          - 캘리브레이션된 출력 (0~1)
 */
function adaptiveRemap(x, refParams, userParams) {
  if (x < 0.001) return 0;  // 제로는 제로

  // Step 1: 변위 비율 계산
  const ratio = refParams.maxRms / userParams.maxRms;
  // ratio > 1: 유저 아바타가 덜 움직임 → 값을 키워야 함
  // ratio < 1: 유저 아바타가 더 움직임 → 값을 줄여야 함

  // Step 2: Power curve gamma 계산
  //   - ratio > 1 (보강 필요): gamma < 1 → 작은 값을 상대적으로 더 키움
  //   - ratio < 1 (억제 필요): gamma > 1 → 큰 값을 상대적으로 더 줄임
  //   - ratio = 1: gamma = 1 → 변화 없음
  const gamma = 1.0 / Math.pow(ratio, 0.4);

  // Step 3: Power curve 적용
  //   x^gamma는 비균일 스케일링:
  //   gamma < 1이면 곡선이 위로 볼록 → 작은 값 보존, 큰 값 적당히 증가
  //   gamma > 1이면 곡선이 아래로 볼록 → 작은 값 보존, 큰 값 더 많이 감소
  let y = Math.pow(x, gamma);

  // Step 4: 유효 범위 매핑
  //   [0, 1] → [deadZone, saturation]
  const outMin = userParams.deadZone;
  const outMax = userParams.saturation;
  y = outMin + y * (outMax - outMin);

  // Step 5: 소프트 클램프 (hard clip 대신 부드럽게 포화)
  //   saturation 근처에서 급격히 꺾이지 않고 부드럽게 수렴
  if (y > outMax * 0.9) {
    const excess = (y - outMax * 0.9) / (outMax * 0.1);
    y = outMax * 0.9 + outMax * 0.1 * (1 - Math.exp(-excess));
  }

  return Math.max(0, Math.min(1.0, y));
}
```

### 3.5 단순 곱셈 vs Adaptive Remap 비교

유저 아바타가 레퍼런스보다 2배 큰 변형을 가진 jawOpen 채널의 예:

```
ratio = 0.5 (유저가 더 많이 움직임)
gamma = 1 / 0.5^0.4 = 1 / 0.758 = 1.319

모델 출력  │ 단순 곱셈 (×0.5) │ Adaptive Remap │ 차이
──────────┼─────────────────┼───────────────┼──────────────
0.03      │ 0.015 (거의 소멸) │ 0.021          │ 미세 움직임 40% 더 보존
0.10      │ 0.050            │ 0.064          │ 작은 움직임 28% 더 보존
0.30      │ 0.150            │ 0.178          │ 중간 움직임 적절 조정
0.60      │ 0.300            │ 0.312          │ 큰 움직임은 비슷
0.80      │ 0.400            │ 0.388          │ 큰 움직임은 오히려 약간 더 억제
1.00      │ 0.500            │ 0.456          │ 최대값은 더 보수적으로 제한
```

**효과: 작은 움직임은 더 살리고, 큰 움직임은 비슷하거나 더 보수적으로 제한한다.**

반대로 유저 아바타가 레퍼런스보다 0.5배 작은 변형을 가진 경우:

```
ratio = 2.0 (유저가 덜 움직임 → 값을 키워야 함)
gamma = 1 / 2.0^0.4 = 1 / 1.320 = 0.758

모델 출력  │ 단순 곱셈 (×2.0) │ Adaptive Remap │ 차이
──────────┼─────────────────┼───────────────┼──────────────
0.03      │ 0.060            │ 0.047          │ 과도한 증폭 방지
0.10      │ 0.200            │ 0.160          │ 과증폭 억제
0.30      │ 0.600            │ 0.436          │ 중간값 적절 조정
0.60      │ 1.000 (클립!)    │ 0.728          │ 클리핑 방지
0.80      │ 1.000 (클립!)    │ 0.860          │ 클리핑 방지
1.00      │ 1.000 (클립!)    │ 0.966          │ 범위 내 유지
```

**효과: 단순 곱셈은 중간값 이상에서 모두 1.0에 클리핑되어 디테일이 사라지지만, Adaptive Remap은 전 범위를 보존한다.**

---

## 4. 유저 채널 조절 UI

시니어 요청: 유저가 52채널 각각의 강도를 직접 조절할 수 있어야 한다.

### 4.1 흐름

```
아바타 로드
    ↓
자동 캘리브레이션 (Multi-Point Probing → Adaptive Remap 파라미터 계산)
    ↓
52채널 캘리브레이션 패널 표시 (자동 계산된 초기값 세팅)
    ↓
유저가 슬라이더로 조정 (실시간 프리뷰)
    ↓
프로파일 저장/내보내기 (JSON)
```

### 4.2 캘리브레이션 패널

```javascript
// 패널 UI 구조
// 각 채널: 이름 + 자동 계산된 gain + 유저 오버라이드 슬라이더 + 리셋 버튼

const calibrationPanel = {
  channels: ARKIT_52.map((name, i) => ({
    name,                           // 'jawOpen'
    autoGain: calibrationGains[i],  // 자동 계산값 (예: 0.62)
    userGain: calibrationGains[i],  // 유저 조정값 (초기값 = 자동값)
    locked: false,                  // 유저가 고정하면 자동 재계산에서 제외
  })),
  
  // 채널 그룹별 묶음 표시
  groups: {
    'Brow':   [0, 1, 2, 3, 4],                     // browDown L/R, browInnerUp, browOuterUp L/R
    'Eye':    [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21],
    'Jaw':    [22, 23, 24, 25],                     // jawForward, jawLeft, jawOpen, jawRight
    'Mouth':  [26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48],
    'Cheek':  [5, 6, 7],
    'Nose':   [49, 50],
    'Tongue': [51],
  },
};
```

### 4.3 프리뷰 모드

유저가 슬라이더를 조정할 때 **해당 채널의 효과를 실시간으로 확인**할 수 있어야 한다.

```javascript
// 프리뷰: 채널 하나를 선택하면 해당 채널만 0 → 1 → 0 사이클 재생
function previewChannel(channelIndex) {
  const duration = 1.5;  // 1.5초 사이클
  const startTime = performance.now();
  
  function animate() {
    const t = ((performance.now() - startTime) / 1000) % duration;
    const value = Math.sin(t / duration * Math.PI);  // 0 → 1 → 0
    
    const calibratedValue = adaptiveRemap(
      value,
      refParams[channelIndex],
      userParams[channelIndex]
    ) * panel.channels[channelIndex].userGain;
    
    applyBlendshape(channelIndex, calibratedValue);
    
    if (previewing) requestAnimationFrame(animate);
  }
  animate();
}
```

### 4.4 프로파일 저장 형식

```json
{
  "version": 1,
  "avatarName": "my-vroid-avatar",
  "platform": "vroid",
  "created": "2026-04-03T12:00:00Z",
  "calibration": {
    "method": "adaptive_remap",
    "channels": {
      "browDownLeft":   { "gamma": 1.02, "deadZone": 0.00, "saturation": 1.00, "userGain": 1.0 },
      "jawOpen":        { "gamma": 1.32, "deadZone": 0.02, "saturation": 0.85, "userGain": 0.9 },
      "mouthSmileLeft": { "gamma": 0.88, "deadZone": 0.00, "saturation": 1.00, "userGain": 1.1 },
      ...
    }
  }
}
```

---

## 5. 전체 캘리브레이션 파이프라인

```
┌──────────────────────────────────────────────────────┐
│ 1. 아바타 로드                                         │
│    - VRM/GLB 파일 로드                                 │
│    - morph target이 있는 메시 수집                      │
└──────────────────┬───────────────────────────────────┘
                   ↓
┌──────────────────┴───────────────────────────────────┐
│ 2. Multi-Point Probing                                │
│    - 52채널 × 5 weight 지점에서 RMS 변위 측정           │
│    - 채널별 응답 곡선 구축                               │
│    - Dead zone, saturation 지점 탐지                    │
│    - 소요: ~50ms (geometry 접근만, 렌더링 불필요)        │
└──────────────────┬───────────────────────────────────┘
                   ↓
┌──────────────────┴───────────────────────────────────┐
│ 3. Adaptive Remap 파라미터 계산                         │
│    - 레퍼런스 프로파일과 비교                             │
│    - 채널별 gamma, deadZone, saturation 도출            │
│    - 양측 대칭 보정                                      │
└──────────────────┬───────────────────────────────────┘
                   ↓
┌──────────────────┴───────────────────────────────────┐
│ 4. 유저 조정 (선택)                                     │
│    - 52채널 슬라이더 패널 표시                            │
│    - 채널별 프리뷰 (0→1→0 사이클)                        │
│    - 그룹별 일괄 조정 가능 (예: Mouth 전체 ×0.8)         │
│    - 유저가 조정한 값은 userGain으로 저장                  │
└──────────────────┬───────────────────────────────────┘
                   ↓
┌──────────────────┴───────────────────────────────────┐
│ 5. 프레임별 적용                                        │
│                                                        │
│  for each frame:                                       │
│    for ch in 0..52:                                    │
│      raw = model_output[ch]                            │
│      remapped = adaptiveRemap(raw, ref[ch], user[ch])  │
│      final = remapped × userGain[ch]                   │
│      output[ch] = clamp(final, 0, 1)                   │
│                                                        │
│  비용: 52채널 × (pow + 곱셈 + clamp) ≈ 무시 가능        │
└──────────────────────────────────────────────────────┘
```

---

## 6. 구현 위치

| 컴포넌트 | 위치 | 역할 |
|---------|------|------|
| Probing | JS (아바타 로드 시) | Three.js morphAttributes 접근 |
| Remap 계산 | JS | gamma, deadZone, saturation 도출 |
| 프레임별 적용 | WASM 또는 JS | 매 프레임 52채널 remap (성능상 WASM 권장) |
| UI 패널 | JS (프론트엔드) | 52채널 슬라이더 + 프리뷰 |
| 프로파일 저장 | JS → JSON | localStorage 또는 파일 export |

### WASM에 넣을 경우

```rust
// shared/src/calibration.rs

pub struct ChannelCalibration {
    pub gamma: f32,
    pub dead_zone: f32,
    pub saturation: f32,
    pub user_gain: f32,
}

pub fn apply_calibration(
    frame: &mut [f32; 52],
    cal: &[ChannelCalibration; 52],
) {
    for (i, ch) in frame.iter_mut().enumerate() {
        let x = *ch;
        if x < 0.001 { *ch = 0.0; continue; }
        
        let c = &cal[i];
        let y = x.powf(c.gamma);
        let mapped = c.dead_zone + y * (c.saturation - c.dead_zone);
        *ch = (mapped * c.user_gain).clamp(0.0, 1.0);
    }
}
```

---

## 7. 기존 calibration-demo에서 변경할 부분

| 항목 | 현재 | 개선 |
|------|------|------|
| 측정 | weight=1.0에서 1회 | weight=[0.05, 0.25, 0.5, 0.75, 1.0]에서 5회 |
| 적용 | `output × gain` (선형) | `adaptiveRemap(output, ref, user)` (power curve + range mapping) |
| Dead zone | 미지원 | 자동 탐지 후 매핑에서 skip |
| Saturation | 미지원 | 자동 탐지 후 soft clamp |
| 유저 조절 | 없음 | 52채널 개별 슬라이더 |
| 프리뷰 | 없음 | 채널별 0→1→0 사이클 프리뷰 |
| 프로파일 | 없음 | JSON 저장/불러오기 |
