일회성 애니메이션을 자연스러운 대기 포즈로 확장하기

프로젝트 요약

전용 대기 애니메이션이 부족한 상황에서 기존 일회성 Animation Clip의 특정 포즈를 대기 연출로 재사용했습니다.

얼굴과 몸 애니메이션의 재생 속도만 개별적으로 정지시키고, 눈 깜빡임과 호흡은 별도의 Additive 레이어로 계속 재생했습니다. 또한 원하는 정지 지점을 Scene View에서 직접 선택할 수 있는 커스텀 인스펙터를 제작했습니다.

항목내용
프로젝트3D 캐릭터 기반 비주얼 노벨
담당Unity 클라이언트 개발
기술Unity, C#, Animator, StateMachineBehaviour, Custom Editor
직접 구현포즈 정지·복구 로직, Additive 레이어 연동, 포즈 프리뷰 도구
핵심 성과기존 클립을 수정하지 않고 대기 연출로 재사용할 수 있는 워크플로우 구축

문제 상황

비주얼 노벨의 대화 장면에서는 캐릭터가 행동을 마친 자세를 일정 시간 유지해야 합니다.

하지만 모든 표정과 동작에 대응하는 전용 대기 루프를 새로 제작하기는 어려웠습니다. 반면 기존 일회성 애니메이션 안에는 대기 자세로 활용할 수 있는 좋은 포즈가 이미 존재했습니다.

처음 생각할 수 있는 방법은 해당 프레임에서 애니메이션을 멈추는 것이었습니다. 그러나 Animator 전체를 정지하면 호흡과 눈 깜빡임까지 사라져 캐릭터가 마네킹처럼 보였습니다.

정지 지점을 숫자로만 조정하는 작업에도 문제가 있었습니다. 개발자가 normalizedTime 값을 입력하고 플레이 모드에서 결과를 확인하는 과정을 반복해야 했기 때문입니다.

해결해야 했던 조건

  • 원본 Animation Clip을 수정하거나 복제하지 않을 것
  • 원하는 포즈에 도달한 뒤 해당 자세를 유지할 것
  • 얼굴과 몸을 독립적으로 제어할 수 있을 것
  • 정지 상태에서도 눈 깜빡임과 호흡은 유지할 것
  • 다음 모션 요청 시 정상적으로 재생을 복구할 것
  • 정지 포즈를 실제 캐릭터를 보면서 선택할 수 있을 것

해결 전략

캐릭터 애니메이션을 하나의 동작으로 보지 않고 다음 두 역할로 분리했습니다.

  1. 캐릭터의 큰 자세를 만드는 Base Motion
  2. 정지 상태에서도 생동감을 유지하는 Additive Motion
flowchart LR
    A["기존 Animation Clip 재생"] --> B["지정한 포즈 도달"]
    B --> C["FaceSpeed 또는 BodySpeed를 0으로 설정"]
    C --> D["Base Motion 포즈 유지"]
    C --> E["Blink 또는 Breath 레이어 재시작"]
    E --> F["Additive Weight를 부드럽게 증가"]
    D --> G["새로운 모션 요청"]
    F --> G
    G --> H["Speed와 Additive Weight 복구"]

Animator 전체를 멈추지 않고 현재 State가 사용하는 FaceSpeed 또는 BodySpeed만 0으로 설정했습니다.

덕분에 얼굴과 몸의 큰 자세는 고정하면서도 다른 Animator 레이어는 계속 동작할 수 있었습니다.


구현 1. State별 정지 포즈 제어

각 Animator State에 설정을 개별적으로 적용할 수 있도록 StopPoseLoopingMotionStateMachineBehaviour로 구현했습니다.

State마다 다음 값을 설정할 수 있습니다.

설정값역할
motionInt현재 State에 대응하는 얼굴 또는 몸 모션 번호
loopPoseOffset정지할 0~1 범위의 정규화 시간
motionTypeParamFaceInt 또는 BodyInt
motionSpeedParamFaceSpeed 또는 BodySpeed
addictiveLayerBlink 또는 Breath
maxWeightAdditive 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 생명주기를 불필요하게 다시 실행하지 않는다는 장점이 있습니다.


구현 2. 눈 깜빡임과 호흡 유지

포즈를 정지한 뒤에도 캐릭터가 살아 있는 것처럼 보이도록 두 개의 Additive 레이어를 사용했습니다.

정지 대상정지 파라미터보조 레이어재시작 Trigger
얼굴FaceSpeedBlinkBlinkRestart
BodySpeedBreathBreathRestart

트리거 이름은 코드에 각각 하드코딩하지 않고 레이어 이름으로 조합했습니다.

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를 다르게 설정할 수 있어 포즈에 따라 눈 깜빡임과 호흡의 적용 강도를 조정할 수 있습니다.


구현 3. 다음 모션으로 안전하게 복구

정지 로직에서는 멈추는 것뿐 아니라 다음 State로 정상적으로 전환되는 경계도 중요했습니다.

현재 FaceInt 또는 BodyInt가 State의 motionInt와 달라지면 새로운 모션 요청으로 판단합니다.

이때 정지했던 Speed를 복구하고 Additive 레이어 Weight를 초기화합니다.

else if (isPausing && !shouldPause)
{
    isPausing = false;
 
    animator.SetFloat(motionSpeedHash, 1f);
    animator.SetLayerWeight(
        addictiveLayerIndex,
        0f
    );
}

또한 새로운 State에 진입할 때도 Speed를 1로 초기화했습니다. 이전 State의 정지 상태가 다음 애니메이션까지 전파되는 것을 방지하기 위한 처리입니다.

결과적으로 포즈는 별도의 타이머 없이 유지되며, 새로운 얼굴 또는 몸 모션이 요청될 때 해제됩니다.


구현 4. Scene View 포즈 선택 도구

런타임 기능만 구현하면 개발자가 loopPoseOffset에 값을 입력하고 플레이 모드에서 결과를 확인하는 작업을 반복해야 합니다.

이 과정을 개선하기 위해 StopPoseLoopingMotionEditor 커스텀 인스펙터를 제작했습니다.

사용 흐름

  1. Scene의 캐릭터 Animator를 선택합니다.
  2. 확인할 Animation Clip을 선택합니다.
  3. Start Preview Mode를 실행합니다.
  4. Loop Pose Offset 슬라이더를 움직입니다.
  5. 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, BreathRestart Trigger 연동
  • Additive 레이어 Weight Easing
  • 다음 모션 전환 시 Speed와 Weight 복구
  • Animation Mode 기반 Scene View 프리뷰
  • State별 정지 위치와 보조 동작 강도 설정 구조

단순히 애니메이션을 멈추는 런타임 기능만 만드는 데 그치지 않고, 실제로 여러 State에 정지 포즈를 설정하는 제작 과정까지 함께 개선하려 했습니다.


결과

  • 별도의 대기 루프를 추가하지 않고 기존 일회성 클립을 대기 연출로 재사용했습니다.
  • Animator 전체가 아닌 얼굴과 몸의 Base Motion만 선택적으로 정지했습니다.
  • 눈 깜빡임과 호흡을 분리해 정지 상태의 생동감을 유지했습니다.
  • State마다 정지 시점과 Additive 강도를 독립적으로 설정할 수 있게 했습니다.
  • 플레이 모드 반복 없이 Scene View에서 정지 포즈를 탐색할 수 있게 했습니다.
  • 새로운 모션 요청 시 정지 상태가 남지 않도록 복구 경계를 명시했습니다.

정확한 작업 시간 감소량은 측정하지 않았기 때문에 정량적인 수치로 표현하지 않았습니다. 대신 실제 포즈 설정 절차와 최종 연출을 영상으로 비교해 개선 효과를 보여줄 수 있습니다.


트레이드오프와 개선 방향

원본 클립 변경에 영향을 받음

정지 위치는 클립의 normalizedTime을 기준으로 저장됩니다. 원본 클립의 길이나 내용이 변경되면 같은 값이 다른 포즈를 가리킬 수 있습니다.

향후에는 Animation Event나 별도의 Pose Marker를 이용해 정지 지점을 의미 단위로 관리할 수 있습니다.

Additive Motion 품질에 의존함

Avatar Mask와 Additive 기준 자세가 올바르게 구성되지 않으면 원본 포즈가 예상보다 크게 변형될 수 있습니다.

Inspector에서 레이어 존재 여부, 파라미터 타입, Avatar Mask 설정을 검증하는 기능을 추가할 수 있습니다.

포즈 탐색 기능 확장

현재는 하나의 슬라이더로 포즈를 탐색합니다. 다음 기능을 추가하면 여러 후보를 비교하기 쉬워집니다.

  • 실제 시간과 프레임 번호 표시
  • 후보 포즈 북마크
  • 이전·다음 프레임 이동
  • 여러 포즈 비교 프리뷰
  • 클립 변경 감지 및 정지 지점 경고

회고

이 작업을 통해 애니메이션 리소스가 부족한 상황에서도 런타임 레이어 구성을 재설계하면 기존 자산의 활용 범위를 넓힐 수 있다는 것을 확인했습니다.

특히 다음 두 가지를 중요하게 배웠습니다.

첫째, 애니메이션 정지는 하나의 기능이 아니라 정지 대상과 계속 움직여야 할 대상을 구분하는 문제였습니다. Base Motion과 Additive Motion의 역할을 나누면서 포즈 유지와 생동감을 함께 확보할 수 있었습니다.

둘째, 조정 가능한 값을 제공하는 것만으로는 제작 효율이 좋아지지 않습니다. 반복적으로 값을 입력하고 결과를 확인해야 한다면, 실제 콘텐츠를 보면서 조정할 수 있는 에디터 워크플로우까지 함께 설계해야 합니다.

이번 구현은 런타임 연출과 제작 도구를 하나의 작업 흐름으로 연결했다는 점에서 의미가 있었습니다.


포트폴리오 영상 구성

  1. 원본 일회성 애니메이션 재생
  2. 선택한 피크 포즈에서 정지
  3. 눈 깜빡임과 호흡이 유지되는 모습
  4. 커스텀 인스펙터에서 포즈 변경
  5. 새로운 모션으로 정상 전환

전체 과정을 30~45초 이내로 구성합니다.

← 포트폴리오 홈으로