주성진·article·2026.10.05·16 min read·조회수198
커서를 따라보는 캐릭터 만들기 3편, 잔상 없이 부드럽게 스크러빙하기
21개 포즈를 그대로 쓰면 섞을 땐 잔상이 남고, 안 섞으면 툭툭 끊겼습니다. 광학 흐름으로 중간 프레임을 만들고, 주사율과 상관없이 같은 속도로 따라가는 스크러버를 만든 과정입니다.
AI로 만든 히어로 캐릭터· 3 / 3펼치기접기
- 1. 커서를 따라보는 캐릭터 만들기 1편, 이미지 한 장을 Higgsfield로 영상화하기
- 2. 커서를 따라보는 캐릭터 만들기 2편, 193프레임에서 21개 포즈 고르기
- 3. 커서를 따라보는 캐릭터 만들기 3편, 잔상 없이 부드럽게 스크러빙하기
지난 편까지 해서 영상 193프레임 중 21개 포즈를 골라 스프라이트 시트를 만들었습니다. 이제 커서에 연결만 하면 끝이라고 생각했습니다.
그런데 여기서 두 번 실패했습니다. 한 번은 잔상 때문에, 한 번은 끊김 때문에요. 이번 글에서는 그 두 번의 실패와, 결국 빌드 단계에서 중간 프레임을 만들어 해결한 과정, 그리고 바닐라 JS로 짠 스크러버 코드를 다룹니다.
1. 첫 번째 시도: 두 프레임을 섞었더니 잔상이 남았습니다
커서가 프레임 사이, 예를 들어 7.4 위치에 있으면 7번 프레임 위에 8번 프레임을 40% 투명도로 겹쳐 그리는 방식입니다. 프레임이 촘촘한 영상에서는 흔히 쓰는 방법이라 별 고민 없이 이렇게 짰습니다.
const i = Math.floor(current);
base.style.backgroundPosition = pos(i);
blend.style.backgroundPosition = pos(i + 1);
blend.style.opacity = String(current - i); // 소수부만큼 겹친다그런데 이 영상은 포즈 사이 간격이 컸습니다. 고개가 꽤 돌아간 두 포즈를 겹치니 윤곽선이 두 겹으로 보였습니다. 특히 커서를 왼쪽 끝에서 오른쪽 끝으로 쭉 쓸면 21프레임을 연달아 지나가면서 계속 겹쳐져서, 잔상이 확실히 눈에 띄었습니다.
2. 두 번째 시도: 안 섞었더니 툭툭 끊겼습니다
그럼 겹치지 말자 싶어서 Math.round(current)로 가장 가까운 프레임 하나만 그리게 바꿨습니다. 잔상은 사라졌습니다. 대신 포즈와 포즈 사이를 건너뛰는 느낌이 났습니다. 영상 자체가 고개 돌림을 3~4개 포즈 만에 끝내니까 당연한 결과였습니다.
3. 결국 문제는 프레임 간격이었습니다
두 번 실패하고 나서야 런타임에서 아무리 이리저리 해 봐도 소용없다는 걸 알았습니다. 문제는 이웃한 포즈 사이 간격이 너무 크다는 데 있었습니다. 그렇다면 빌드할 때 그 사이를 채우면 됩니다.
그래서 두 이미지 사이의 픽셀 이동을 추정하는 광학 흐름(optical flow)으로 중간 프레임을 만들었습니다.

가운데 두 장을 비교해 보면 차이가 보입니다. 50% 크로스페이드는 머리카락과 손가락이 두 겹이지만, 광학 흐름으로 만든 쪽은 그냥 중간 각도를 그린 그림 한 장처럼 보입니다.
3-1. 구현
OpenCV의 DIS 광학 흐름으로 A에서 B로, B에서 A로 가는 흐름을 각각 구하고, 중간 시점 t에서 양쪽 이미지를 휘어서(와핑) 섞었습니다. 중간 시점의 흐름은 영상 보간 논문인 Super SloMo에서 쓰는 선형 근사를 그대로 가져왔습니다.
import cv2
import numpy as np
def flow(a, b):
gray = lambda x: cv2.cvtColor(to_uint8_on_gray(x), cv2.COLOR_RGB2GRAY)
dis = cv2.DISOpticalFlow_create(cv2.DISOPTICAL_FLOW_PRESET_MEDIUM)
return dis.calc(gray(a), gray(b), None)
def inbetweens(a, b):
"""a, b: 프리멀티플라이드 RGBA(float). 움직임이 클수록 중간 프레임을 많이 만든다."""
fab, fba = flow(a, b), flow(b, a)
moving = np.maximum(a[..., 3], b[..., 3]) > 0.5
p99 = np.percentile(np.linalg.norm(fab, axis=2)[moving], 99)
n = int(min(8, max(0, np.ceil(p99 / STEP_PX) - 1))) # 이웃 프레임 간 이동이 STEP_PX 이하가 되도록
h, w = a.shape[:2]
gx, gy = np.meshgrid(np.arange(w, dtype=np.float32), np.arange(h, dtype=np.float32))
warp = lambda img, f: cv2.remap(img, gx + f[..., 0], gy + f[..., 1], cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
out = []
for k in range(1, n + 1):
t = k / (n + 1)
ft0 = -(1 - t) * t * fab + t * t * fba # 중간 시점에서 A로
ft1 = (1 - t) ** 2 * fab - t * (1 - t) * fba # 중간 시점에서 B로
out.append((1 - t) * warp(a, ft0) + t * warp(b, ft1))
return out코드에서 신경 쓴 부분이 몇 군데 있습니다.
중간 프레임 개수는 움직임 크기로 정했습니다. 흐름 크기의 99번째 백분위를 STEP_PX로 나눠서, 이웃 프레임 사이 이동이 일정 픽셀 이하가 되게 했습니다. 거의 같은 포즈 사이에는 한 장도 안 넣고, 크게 바뀌는 구간에는 최대 8장까지 넣습니다.
섞을 때는 프리멀티플라이드 알파를 썼습니다. 투명 배경 이미지를 그냥 섞으면 투명 픽셀의 색(검정)이 경계에 번집니다. RGB에 알파를 곱한 상태로 와핑하고 섞은 뒤, 마지막에 다시 나눠서 되돌렸습니다.
흐름은 회색 배경 위에서 계산했습니다. 투명한 부분이 흐름 추정에 끼어들지 않도록, 알파를 50% 회색 배경에 합성한 이미지로 흐름을 구했습니다.
3-2. 결과
프레임은 21장에서 62장으로 늘었고, 이웃 프레임 사이 최대 이동은 1.5px까지 줄었습니다. 빌드 시간은 15초 정도입니다. 스크립트 마지막에 이웃 프레임 사이 이동을 다시 재서 찍게 해 뒀는데, 이 숫자 하나로 충분히 촘촘한지 바로 확인할 수 있어서 편했습니다.
extracted 193 frames → {'frames': 62, 'idle': 38, 'cols': 8, 'rows': 8, ...}
sprite.webp: 5120×5848, 파일 2548KB, 디코딩 114MB
sprite-sm.webp: 2880×3288, 파일 1250KB, 디코딩 36MB
이웃 프레임 최대 이동(p99): 1.5px4. 스크러버: 커서 위치를 프레임으로
이제 런타임 쪽입니다. 라이브러리는 쓰지 않고 바닐라 JS로 짰습니다.
4-1. 커서 x를 프레임 번호로 바꾸기
캐릭터 중심을 0으로 두고, 커서가 왼쪽 끝이면 -1, 오른쪽 끝이면 1로 정규화합니다. 중심 근처에서 좀 더 민감하게 반응하도록 ease-out 곡선을 한 번 걸고, 정면 프레임을 기준으로 양쪽에 나눠서 매핑합니다.
export function cursorToFrame(x: number, center: number, left: number, right: number, frames: number, idle: number) {
const span = x < center ? center - left : right - center;
const u = Math.max(-1, Math.min(1, span > 0 ? (x - center) / span : 0));
const eased = Math.sign(u) * (1 - (1 - Math.abs(u)) ** 2);
return eased < 0 ? idle + eased * idle : idle + eased * (frames - 1 - idle);
}정면 프레임이 시트 한가운데가 아니어도(62장 중 38번) 왼쪽과 오른쪽 각각 끝까지 정확하게 맞아떨어집니다.
4-2. 120Hz 모니터에서 두 배 빨라지지 않게
현재 프레임이 목표 프레임을 따라가는 방식은 지수 감쇠를 썼습니다. 흔히 current += (target - current) * 0.1처럼 고정 비율로 짜는데, 이러면 60Hz와 120Hz 모니터에서 속도가 두 배 차이 납니다. 경과 시간 dt를 넣어 주면 주사율과 상관없이 같은 시간에 같은 만큼 움직입니다.
const tick = (t: number) => {
const dt = last ? Math.min(t - last, 64) : 16; // 탭 전환 후 dt가 튀지 않게 상한을 둔다
last = t;
current += (target - current) * (1 - Math.exp(-dt / tau));
if (Math.abs(target - current) < 0.005) {
current = target;
raf = last = 0; // 도착하면 루프를 멈춘다
} else raf = requestAnimationFrame(tick);
draw();
};커서를 따라갈 때는 tau = 140ms, 영역을 벗어나 정면으로 돌아갈 때는 tau = 360ms로 좀 더 느리게 했습니다. 돌아올 때 여유 있게 고개를 돌리는 느낌을 주고 싶었습니다.
4-3. 그리는 건 한 프레임만, 바뀔 때만
let shown = -1;
const draw = () => {
const i = Math.round(current);
if (i === shown) return; // 같은 프레임이면 DOM을 건드리지 않는다
shown = i;
el.style.backgroundPosition = `${pct(i % cols, cols)}% ${pct(Math.floor(i / cols), rows)}%`;
onFrame?.(i); // 프레임 번호 HUD 갱신용
};격자 시트에서 background-position의 퍼센트는 "남는 공간 대비 위치"입니다. 그래서 열 번호를 (cols - 1)로 나누면 정확히 그 칸이 보입니다. background-size는 ${cols * 100}% ${rows * 100}%로 두면 됩니다.
4-4. 추적 영역을 페이지 전체로 바꾼 이유
처음엔 히어로 <section>을 추적 영역으로 잡았습니다. 그런데 이 섹션이 1200px 콘텐츠 폭이라 캐릭터 창 오른쪽 끝에서 바로 끝나 버렸습니다. 커서가 그보다 오른쪽으로 가면 pointerleave가 발생해서, 오른쪽을 보던 캐릭터가 갑자기 정면으로 돌아갔습니다. 실제로 써 보면서 "오른쪽으로 갈수록 왜 왼쪽으로 돌아오지?" 하는 피드백을 받고 나서야 알았습니다.
createSpriteScrubber(el, {
frames: sprite.frames,
cols: sprite.cols,
idle: sprite.idle,
area: document.documentElement, // 화면 양 끝까지 추적하고, 브라우저 창을 벗어나면 정면으로
});4-5. 프레임 번호를 보여 주는 HUD
캐릭터를 감싼 창 위쪽에는 지금 보이는 프레임 번호(frame 39 / 62)를, 아래쪽에는 진행 바를 붙였습니다. 그냥 움직이는 그림이 아니라 프레임 단위로 제어되고 있다는 걸 보여 주고 싶었습니다.

HUD는 React 상태로 관리하지 않았습니다. 초당 수십 번 바뀌는 값을 setState로 올리면 그때마다 컴포넌트가 다시 렌더링됩니다. 그래서 스크러버의 onFrame 콜백에서 DOM을 직접 고치게 했고, 덕분에 리렌더가 한 번도 일어나지 않습니다.
onFrame: (i) => {
countRef.current!.textContent = String(i + 1).padStart(2, "0");
barRef.current!.style.transform = `scaleX(${i / (sprite.frames - 1)})`; // width 대신 transform이라 레이아웃 계산이 없다
},4-6. 접근성과 정리
OS에서 동작 줄이기(prefers-reduced-motion: reduce)를 켜 둔 사용자에게는 추적하지 않고 정면만 보여 줍니다. 설정이 바뀌는 순간에도 바로 반영되게 change 이벤트를 받았습니다. 탭이 숨겨지면(visibilitychange) 루프를 멈추고 정면으로 돌려 둡니다.
터치는 손을 떼면 정면으로 돌아오게 했고, 히어로에 touch-action: pan-y를 줘서 세로 스크롤은 막지 않도록 했습니다. 컴포넌트가 사라질 때는 rAF와 리스너를 전부 해제합니다.
5. 애니메이션도 테스트할 수 있습니다
애니메이션은 테스트하기 어렵다고 생각하기 쉬운데, requestAnimationFrame을 큐로 바꿔 끼우면 시간을 직접 돌릴 수 있습니다.
let queue = new Map<number, FrameRequestCallback>();
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
queue.set(++sequence, cb);
return sequence;
});
const settle = () => {
for (let i = 0; queue.size && i < 500; i++) {
time += 16;
const callbacks = [...queue.values()];
queue.clear();
callbacks.forEach((cb) => cb(time));
}
expect(queue.size).toBe(0); // 멈춘 뒤에 rAF가 남아 있으면 안 된다
};
it("onFrame은 보이는 프레임이 바뀔 때만 호출된다", () => {
const seen: number[] = [];
createSpriteScrubber(base, { frames: 29, cols: 8, idle: 17, area, onFrame: (i) => seen.push(i) });
point("pointermove", 0);
settle();
expect(seen.at(-1)).toBe(0);
expect(new Set(seen).size).toBe(seen.length);
});양쪽 끝까지 가는지, 벗어나면 돌아오는지, 동작 줄이기와 탭 숨김, 정리 함수까지 테스트 6개로 확인하고 있습니다.
마치며
이 캐릭터를 만들면서 제일 크게 남은 건, AI가 만든 결과물을 그대로 쓰기보다 재료로 다루는 게 맞다는 생각입니다. AI 영상은 그대로는 쓸 수 없었지만, 분석해서 다시 조립하니까 원하던 인터랙션이 나왔습니다.
잔상이냐 끊김이냐 하는 문제도 런타임에서 요령을 부려서가 아니라, 빌드 단계에서 데이터 자체를 촘촘하게 만들어서 풀렸습니다. 그리고 에이전트와 일할 때 "더 부드럽게" 같은 말을 "이웃 프레임 간 이동 1.5px 이하"처럼 잴 수 있는 기준으로 바꾸니 대화가 훨씬 빨라졌습니다.
비슷한 인터랙션을 고민하고 계신다면, 런타임 트릭부터 찾기 전에 데이터가 충분히 촘촘한지 먼저 재 보시길 권합니다.
Comments (0)