연재

앱인토스 입점 후기 1. 왁뿌볼 뿌수기

TL;DR

  • 실물 왁뿌말랑이의 재미는 “누르는 동안 버티는 구간”과 “임계점에서 한 번에 깨지는 순간”에 있었다.
  • 화면은 압력을 읽지 못한다. 그래서 압력 자체가 아니라 압력이 하던 역할을 시간 기반 hold interaction으로 옮겼다.
  • Force Click과 pointer event를 겪으면서 hold 상태가 브라우저가 보장하는 값이 아니라 애플리케이션이 추정하는 상태라는 것을 알게 됐다.
  • 웹 프로토타입은 청사과 하나로 충분했지만, 앱인토스 출시 버전은 반복 방문을 위해 여러 볼과 3D 에셋 파이프라인이 필요했다.

동묘에서 청사과 왁뿌말랑이를 샀다. 개당 3,500원이었다. 손으로 누르면 겉면의 왁스층이 잠깐 버티다가 갈라지고, 안쪽의 흰 속살이 보인다. 웹으로 옮기고 싶었던 것은 촉감 전체가 아니었다. 재미를 만드는 구조는 더 좁았다.

누른다
  -> 버틴다
  -> 한계에 닿는다
  -> 한 번에 깨진다

웹으로 옮길 때 그대로 가져올 수 없는 값은 압력이다. 모바일 브라우저는 사용자가 화면을 얼마나 세게 누르는지 일반적으로 알려주지 않는다. 그래서 압력을 복제하는 대신, 압력이 하던 역할을 시간으로 바꿨다.

클릭 삭제 방식의 한계

초기 웹 프로토타입은 three.js 위에 만든 구 하나였다. 캔버스로 만든 노이즈 텍스처를 입히고, 구면을 여러 조각으로 나눈 뒤 클릭하면 조각이 하나씩 깨지는 방식이었다. 기능은 동작했지만, 실물에서 느꼈던 재미와는 거리가 있었다.

방식 문제
클릭마다 조각 제거 입력 즉시 결과가 확정됨
조각이 순서대로 사라짐 “버티다가 터지는” 구간이 없음
균열이 점진적으로 보임 압력보다 애니메이션 재생에 가까움

왁뿌말랑이의 재미는 조각 수가 아니라 임계점에 있었다. 사용자가 누르고 있는 동안에는 결과가 보류되고, 어느 순간 전체가 무너져야 한다. 그래서 인터랙션을 hold 기반으로 바꿨다.

pointerdown
  -> hold progress 증가
  -> 일정 시간 전까지 crack 없음
  -> threshold 도달
  -> 짧은 시간 안에 crack 전개

구체적인 설계는 이렇다.

상태 화면 피드백
hold 초반 눌린 자국만 깊어짐
hold 후반 미세한 떨림으로 임계점 예고
threshold 도달 크랙이 빠르게 전개
release 진행률 초기화 또는 감쇠

압력을 시간으로 바꾸되, 균열을 천천히 보여주지는 않았다. 균열이 서서히 번지면 “누르고 있다”보다 “애니메이션이 재생된다”에 가깝게 느껴진다.

pointer event와 hold 상태

hold interaction은 단순해 보이지만 입력 이벤트 관점에서는 꽤 취약하다. 브라우저가 직접 제공하는 것은 pointerdown, pointermove, pointerup, pointercancel 같은 이벤트다. “계속 누르고 있음”이라는 안정적인 상태는 애플리케이션이 이 이벤트들을 조합해서 만든다.

function onPointerDown() {
  setHolding(true);
}

function onPointerUp() {
  setHolding(false);
}

이 모델은 마우스 클릭에서는 잘 맞는다. 하지만 트랙패드 Force Click이나 모바일 제스처가 끼어들면 쉽게 끊긴다.

이벤트 hold에 미치는 영향
pointerup 실제 손을 떼지 않았다고 느껴도 발생할 수 있음
pointercancel 브라우저가 제스처를 가져가면 발생
pointermove 오래 누르는 동안의 미세 이동도 이동으로 잡힘
buttons === 0 압력 변화와 release를 구분하기 어려움

이 문제는 더 정교한 감지만으로 풀기 어렵다. 이벤트가 같은 모양으로 들어오면 애플리케이션은 더 세게 누른 것인지, 실제로 손을 뗀 것인지 구분할 수 없다. 그래서 해결 방향은 감지 정확도를 높이는 것이 아니라 상태 모델이 끊김을 흡수하게 만드는 쪽이었다.

끊김을 흡수하는 상태 모델

초기 대응은 세 가지였다.

규칙 효과
release 직후 0.25초 유예 순간적인 pointerup을 바로 취소로 처리하지 않음
진행률 감쇠 release 후 즉시 0으로 떨어지지 않음
재입력 시 이어받기 끊긴 hold가 다시 이어질 수 있음

이 모델에서는 hold가 잠깐 끊겨도 다시 누르면 남아 있는 압력에서 이어진다.

holdProgress = 0.72
pointerup
  -> 바로 0으로 초기화하지 않음
  -> 감쇠 시작
pointerdown
  -> 0.60 근처에서 다시 증가

드래그와의 충돌도 별도 기준이 필요했다. 볼을 회전시키려는 드래그와 누르기를 구분해야 했기 때문이다. 초기에는 작은 이동도 release처럼 처리했지만, 1초 이상 누르는 동안 손이 몇 픽셀도 움직이지 않기는 어렵다. 그래서 이동 취소 기준을 넓히고, hold progress가 일정 값 이상이면 이동을 무시했다.

holdProgress < 0.25
  -> 일정 거리 이상 이동하면 rotate

holdProgress >= 0.25
  -> 이동보다 hold 유지 우선

이 판단은 입력을 더 정확히 읽는 방식이 아니다. 부정확한 입력이 들어와도 기대한 결과로 수렴하게 만드는 방식이다.

2.2초에서 1.2초

앱인토스 버전으로 옮기면서 hold 시간은 짧아졌다. 초기에 잡은 값은 2초대였고, 이후 1.2초 수준으로 줄었다. 시간을 줄인 이유는 명확했다. hold가 길수록 pointer event의 불안정성이 누적된다. 트랙패드나 모바일에서 중간 이벤트가 끊길 가능성도 커진다. 반대로 hold 시간이 짧아지면 복잡한 유예·감쇠 장치의 필요도 줄어든다.

긴 hold
  -> 압력감은 커짐
  -> 끊김 가능성도 커짐

짧은 hold
  -> 압력감은 약해짐
  -> 성공률과 반복성이 좋아짐

앱인토스처럼 가볍게 들어와 짧게 즐기는 환경에서는 후자가 더 맞았다. 촉감을 완전히 흉내 내기보다, 여러 번 눌러볼 수 있는 리듬을 만드는 쪽이 중요했다.

진행률 피드백

hold 시간이 짧아져도 누르는 동안의 변화는 필요하다. 화면이 아무 반응 없이 1초 가까이 멈춰 있으면 입력이 먹히는지 알기 어렵다. 그래서 hold 중에는 균열 대신 진행률 피드백을 둔다.

피드백 역할
눌린 자국 사용자의 입력이 들어갔음을 표시
미세 떨림 임계점이 가까워졌음을 표시
진행률 UI 남은 시간을 예측 가능하게 함
짧은 haptic 깨진 순간을 강조

핵심은 크랙을 너무 빨리 보여주지 않는 것이다. 깨지는 결과가 threshold 이전에 보이면 “버티는 구간”이 사라진다.

웹에서 앱인토스로

웹 프로토타입은 청사과 하나만 있어도 충분했다. 링크로 공유하고 반응을 확인하는 목적이었기 때문이다. Vercel에 올렸고, 도메인은 따로 사지 않았다. 링크만 돌리면 되는 단계였다. 하지만 앱인토스 미니앱으로 옮기려면 반복해서 들어올 이유가 필요했다. 왁뿌볼의 반복성은 두 가지에서 나온다.

  1. 다시 부술 수 있다.
  2. 다른 볼을 부술 수 있다.

실물 왁뿌말랑이는 한 번 깨면 끝난다. 앱은 그 제약이 없다. 이 장점은 웹과 앱 모두에서 가져갈 수 있다. 반면 여러 볼을 제공하려면 3D 모델과 텍스처를 계속 만들어야 한다. 여기서 앱인토스가 선택지가 됐다. 이미 웹으로 만든 인터랙션을 크게 버리지 않고 앱 안으로 옮길 수 있고, 혼자 만든 작은 사이드 프로젝트를 빠르게 출시할 수 있는 경로였기 때문이다. 그렇게 앱인토스 버전의 「왁뿌볼 뿌수기」를 만들기 시작했다.

정리

왁뿌볼을 화면으로 옮기는 문제는 3D 모델을 예쁘게 만드는 문제보다 먼저 입력 모델의 문제였다. 실물의 핵심을 그대로 옮기면 “압력”이지만, 브라우저가 안정적으로 제공하는 값은 압력이 아니다. 그래서 압력이 하던 역할을 시간, 진행률, threshold, 짧은 haptic으로 나눠 옮겼다. 이 과정에서 기준은 하나였다.

촉감을 그대로 옮길 수 없으면
촉감이 만들던 행동 구조를 옮긴다.

「왁뿌볼 뿌수기」는 이 구조 위에 볼 종류와 3D 에셋을 얹은 형태다. 다음 문제는 입력이 아니라 에셋 제작이었다. Blender를 직접 다루지 못하는 상태에서 여러 왁뿌볼 모델을 만들어야 했다.