일회성 애니메이션을 자연스러운 대기 포즈로 확장하기
프로젝트 요약
전용 대기 애니메이션이 부족한 상황에서 기존 리소스를 조합해 특정 포즈를 유지하는 대기 연출을 제작했습니다.
얼굴과 몸 애니메이션의 재생 속도만 개별적으로 정지시키고, 눈 깜빡임과 호흡은 별도의 Additive 레이어로 계속 재생했습니다. 또한 원하는 정지 지점을 Scene View에서 직접 선택할 수 있는 커스텀 인스펙터를 제작했습니다.
| 항목 | 내용 |
|---|---|
| 프로젝트 | 3D 캐릭터 기반 비주얼 노벨 |
| 담당 | Unity 클라이언트 개발 |
| 기술 | Unity, C#, Animator, StateMachineBehaviour, Custom Editor |
| 직접 구현 | 포즈 정지·복구 로직, Additive 레이어 연동, 포즈 프리뷰 도구 |
| 핵심 성과 | 기존 클립을 수정하지 않고 대기 연출로 재사용할 수 있는 워크플로우 구축 |

문제 상황
비주얼 노벨 대사에 맞는 감정 애니메이션을 재생한 뒤, 다음 대사가 나오기 전까지 해당 감정과 자세를 자연스럽게 유지하는 연출이 필요했습니다.
하지만 모든 표정과 감정 애니메이션을 추가적인 루프 애니메이션으로 제작할 수 없는 상황이었습니다.
해결해야 했던 조건
- 원본 Animation Clip을 수정하거나 복제하지 않을 것
- 원하는 포즈에 도달한 뒤 해당 자세를 유지할 것
- 얼굴과 몸을 독립적으로 제어할 수 있을 것
- 정지 상태에서도 눈 깜빡임과 호흡은 유지할 것
- 다음 모션 요청 시 정상적으로 재생을 복구할 것
- 정지 포즈를 실제 캐릭터를 보면서 선택할 수 있을 것
해결 방법
감정 애니메이션을 원하는 프레임에서 정지해 자세를 유지하고, 그 위에 호흡, 눈깜박임 애니메이션 레이어를 Additive Blending 방식으로 적용했습니다.
flowchart LR A["Animation Clip 재생"] subgraph BASE["Base Motion Layer : Body, Face Layer"] direction LR B["지정한 포즈 도달"] C["Base Motion 포즈 유지"] I["잔여 Base Motion 재생"] B ~~~ C C ~~~ I end subgraph ADDITIVE["Additive Motion Layer : Breath, Blink Layer"] direction LR E["Additive Motion Layer Weight 0"] F["애니메이션 재시작"] H["Additive Motion Layer Weight 초기화"] E ~~~ F F ~~~ H end G["새로운 모션 요청"] A --> B A --> E B -->|"FaceSpeed, BodySpeed를<br/>0으로 설정"| C B -->|"Additive Motion Layer Weight를<br/>목표값까지 점진적으로 보간"| F C --> G F --> G G --> H G -->|"FaceSpeed, BodySpeed를<br/> 1로 설정"| I
Animator 전체를 멈추지 않고 현재 State가 사용하는 FaceSpeed 또는 BodySpeed만 0으로 설정해, 다른 Animator 레이어는 계속 동작할 수 있었습니다.
State별 정지 포즈 제어
각 Animator State에 설정을 개별적으로 적용할 수 있도록 StopPoseLoopingMotion을 StateMachineBehaviour로 구현했습니다.
State마다 다음 값을 설정할 수 있습니다.
| 설정값 | 역할 |
|---|---|
motionInt | 현재 State에 대응하는 얼굴 또는 몸 모션 번호 |
loopPoseOffset | 정지할 0~1 범위의 정규화 시간 |
motionTypeParam | FaceInt 또는 BodyInt |
motionSpeedParam | FaceSpeed 또는 BodySpeed |
addictiveLayer | Blink 또는 Breath |
maxWeight | Additive Motion의 최대 적용 강도 |
정지 조건
현재 Animator 파라미터가 해당 State의 모션 번호와 일치하고, 애니메이션이 지정한 시점에 도달하면 정지 로직을 한 번 실행합니다.
bool shouldPause =
animator.GetInteger(motionTypeHash) == motionInt;
if (!isPausing &&
shouldPause &&
loopPoseOffset <= stateInfo.normalizedTime)
{
isPausing = true;
animator.SetFloat(motionSpeedHash, 0f);
animator.SetTrigger(addictiveLayerTriggerHash);
}애니메이션을 특정 시간으로 강제 이동시키는 대신, 원래 모션이 자연스럽게 재생되어 목표 지점에 도달한 순간 속도를 0으로 변경했습니다.
Animator.Play() 대신 Speed 파라미터를 사용한 이유
정확한 정지 위치를 맞추기 위해 아래와 같이 같은 State를 다시 재생하는 방법도 검토했습니다.
animator.Play(
stateInfo.fullPathHash,
layerIndex,
loopPoseOffset
);하지만 같은 State를 다시 재생하면 OnStateEnter()가 반복되면서 상태 초기화와 재진입이 발생할 수 있었습니다.
따라서 State를 다시 시작하지 않고, 해당 State가 이미 사용하고 있던 Speed 파라미터만 0으로 변경하는 방식을 선택했습니다.
이 방식은 현재 포즈를 그대로 유지하면서 State 생명주기를 불필요하게 다시 실행하지 않는다는 장점이 있습니다.
눈 깜빡임과 호흡 유지
포즈를 정지한 뒤에도 캐릭터가 살아 있는 것처럼 보이도록 두 개의 Additive 레이어를 사용했습니다.
| 정지 대상 | 정지 파라미터 | 보조 레이어 | 재시작 Trigger |
|---|---|---|---|
| 얼굴 | FaceSpeed | Blink | BlinkRestart |
| 몸 | BodySpeed | Breath | BreathRestart |
트리거 이름은 코드에 각각 하드코딩하지 않고 레이어 이름으로 조합했습니다.
addictiveLayerTriggerHash =
Animator.StringToHash(addictiveLayer + "Restart");따라서 State 설정이 Blink이면 BlinkRestart, Breath이면 BreathRestart가 생성됩니다.
포즈가 정지되는 순간 해당 트리거를 발동합니다.
animator.SetTrigger(addictiveLayerTriggerHash);Animator의 Any State 전이는 이 트리거를 받아 Blink 또는 Breath State를 처음부터 다시 실행합니다.
Additive Weight 보간
보조 레이어의 Weight를 즉시 높이면 정지 포즈와 Additive Motion 사이에서 자세가 튈 수 있습니다.
이를 줄이기 위해 시간값을 누적하고 OutExpo Easing을 적용했습니다.
t += Time.deltaTime * 5f;
t = Mathf.Clamp01(t);
float nextWeight = MathfEx.Lerp(
0f,
maxWeight,
t,
MathfEx.EaseType.OutExpo
);
animator.SetLayerWeight(
addictiveLayerIndex,
nextWeight
);State마다 maxWeight를 다르게 설정할 수 있어 포즈에 따라 눈 깜빡임과 호흡의 적용 강도를 조정할 수 있습니다.
Scene View 포즈 선택 도구
런타임 기능만 구현하면 개발자가 loopPoseOffset에 값을 입력하고 플레이 모드에서 결과를 확인하는 작업을 반복해야 합니다.
이 과정을 개선하기 위해 StopPoseLoopingMotionEditor 커스텀 인스펙터를 제작했습니다.

사용 흐름
- Scene의 캐릭터 Animator를 선택합니다.
- 확인할 Animation Clip을 선택합니다.
Start Preview Mode를 실행합니다.Loop Pose Offset슬라이더를 움직입니다.- Scene View에서 실제 정지 포즈를 확인합니다.
float normalizedTime =
loopPoseOffsetProp.floatValue;
float clipTime =
normalizedTime * previewClip.length;
AnimationMode.SampleAnimationClip(
previewAnimator.gameObject,
previewClip,
clipTime
);
SceneView.RepaintAll();정규화 시간에 클립 길이를 곱해 실제 샘플링 시간을 계산했습니다. 클립 길이가 달라도 동일한 0~1 인터페이스로 정지 지점을 선택할 수 있습니다.
Inspector가 닫히거나 프리뷰가 중지될 때는 Animation Mode를 종료해 Scene 오브젝트가 샘플링된 포즈에 남지 않도록 처리했습니다.
private void OnDisable()
{
StopPreview();
}직접 기여한 부분
이번 작업에서 다음 영역을 직접 설계하고 구현했습니다.
StateMachineBehaviour기반 포즈 정지 시스템- 얼굴과 몸을 분리한 Speed 파라미터 제어
BlinkRestart,BreathRestartTrigger 연동- Additive 레이어 Weight Easing
- 다음 모션 전환 시 Speed와 Weight 복구
- Animation Mode 기반 Scene View 프리뷰
- State별 정지 위치와 보조 동작 강도 설정 구조
단순히 애니메이션을 멈추는 런타임 기능만 만드는 데 그치지 않고, 실제로 여러 State에 정지 포즈를 설정하는 제작 과정까지 함께 개선하려 했습니다.
결과
- 별도의 대기 루프를 추가하지 않고 기존 일회성 클립을 대기 연출로 재사용했습니다.
- Animator 전체가 아닌 얼굴과 몸의 Base Motion만 선택적으로 정지했습니다.
- 눈 깜빡임과 호흡을 분리해 정지 상태의 생동감을 유지했습니다.
- State마다 정지 시점과 Additive 강도를 독립적으로 설정할 수 있게 했습니다.
- 플레이 모드 반복 없이 Scene View에서 정지 포즈를 탐색할 수 있게 했습니다.
- 새로운 모션 요청 시 정지 상태가 남지 않도록 복구 경계를 명시했습니다.
정확한 작업 시간 감소량은 측정하지 않았기 때문에 정량적인 수치로 표현하지 않았습니다. 대신 실제 포즈 설정 절차와 최종 연출을 영상으로 비교해 개선 효과를 보여줄 수 있습니다.