지난 게시글에서 게임 종료 직전 타겟 색상을 바꾸는 기능을 추가하고 발견한 문제가 하나 있었다.
타겟의 색상을 여러 번 바꾸고 되돌리는 과정에서,
타겟의 외곽선이 점점 거칠어지고 흐려지는 현상이 나타난 것이다.
처음엔 색상 처리 로직이나 픽셀 좌표를 의심했지만,
결국 원인은 생각보다 깊은 곳 — 렌더 루프의 이원화에 있었다.
추가한 기능은 의도대로 완벽히 작동했다.
그러나 테스트 중, 타겟 색이 바뀌는 타이밍이 반복되면
테두리가 점점 거칠어지는 것을 확인해버렸다.
“픽셀을 다시 그릴 때 잔상이 남는 걸까?”
“좌표 반올림 문제인가?”
“캔버스 해상도 때문인가?”
처음엔 단순히 그래픽 이슈로 생각했다.
찾아보니 앨리어싱(Aliasing) 현상이라는 걸 알게 되었고, 이 현상을 제거하는 안티 앨리어싱 기법을 먼저 찾아보기도 했다.
하지만 이런 저런 방법을 적용해도 해결되지 않아서, 잠시 뒤로 몇 걸음 떨어져 코드 전체를 천천히 살펴봤다. 그리고 의심이 가는 정황을 발견했고, 더 알아보니 거의 확실했다.
그 현상은 바로 두 개의 렌더 루프가 같은 캔버스를 번갈아 건드리는 구조적 문제였다.
FPS Aim Test의 렌더 구조는 다음과 같았다.
GameWorld → 맵과 카메라 이동을 담당TargetRenderer → 타겟 렌더 전용 루프두 컴포넌트 모두 requestAnimationFrame을 사용해 독립적인 루프를 돌리고 있었다.
// GameWorld 내부
useEffect(() => {
requestAnimationFrame(renderMap);
}, []);
// TargetRenderer 내부
useEffect(() => {
requestAnimationFrame(renderTargets);
}, [targets, position, isGameOver, graceStartAt]);
각 루프는 동일한 캔버스 컨텍스트(ctx)를 공유하면서도
서로 다른 시점에서 ctx.save() / ctx.restore() / ctx.translate()를 호출했다.
문제는 이 함수들이 모두 비동기적으로 작동한다는 것이고, 이는 ctx.save(a)로 상태를 저장하고 ctx.restore()로 pop했는데 다른 루프에서 저장한 상태가 pop되는 현상이 발생한다는 것이다.
결국 컨텍스트의 상태 스택이 꼬이면서
안티앨리어싱이 깨지고,
테두리 픽셀이 조금씩 훼손되는 현상이 누적되었다.
즉, 문제의 본질은 그래픽스가 아니라 루프 간 동기화의 부재였으며,
이는 Canvas 를 사용하면서도 그 아키텍쳐의 동작방식을 명확히 이해하지 않았던 나의 부족함으로 발생한 문제였고, 이미지의 변화 없이 생성과 삭제만 반복했던 기존의 구조에선 발견할 수 없었던 문제이기도 했다.
원인을 파악하고 나니 문제 해결의 핵심은 단순했다.
“하나의 캔버스 컨텍스트엔 하나의 루프만 존재해야 한다.”
그래서 TargetRenderer의 루프를 완전히 제거하고
GameWorld의 메인 루프에 통합했다.
이제 하나의 컨텍스트 안에 루프도 단 하나,
그 안에서 다음 순서로 동작한다.
clearCanvas)applyCameraTransform)renderMapAndBounds)renderTargets)endCameraTransform)이전의 TargetRenderer는 하나의 독립 컴포넌트이자 루프였다.
이제는 순수한 렌더 함수로 역할이 축소됐다.
// renderTargets.ts
export const renderTargets = ({ ctx, targets, graceStartAt, isGameOver }) => {
const color = decideTargetColor(isGameOver, graceStartAt);
for (const t of targets) {
drawCircle(ctx, t.x, t.y, t.size / 2, color);
}
};
mapRenderer도 같은 방식으로 GameWorld에서 분리하고 함수화하여,
GameWorld는 이제 단일 루프에서 모든 렌더링 함수들을 순차적으로 호출한다.
전보다 훨씬 구조에 안정감이 생겼다.
렌더링 파이프라인이 명확하게 정해지면서
컨텍스트의 상태 관리가 안정되고,
각 렌더 함수는 독립적으로 유지보수할 수 있게 되었다.
루프를 하나로 합치자, 또 예상치 못한 문제가 발생했다.
타겟이 생성되어도 화면에 보이지 않았다.
이번 문제의 원인은 React의 클로저 캡처 문제(stale closure)였다.
현재 Gamworld내 렌더링을 담당하는 useEffect([]) 안에서 시작된 루프는
마운트 시점의 state를 영원히 참조한다.
즉, 게임은 실제로 진행 중인데
루프는 초기 타겟 배열만 계속 그리는 셈이었다.
이전엔 이런 문제가 없었던 이유는, 기존 TargetRenderer에서 돌리던 타겟 렌더링 루프는 타겟 상태가 실시간으로 변하기 때문에 의존성으로 targets 을 추가해뒀기 때문이다. 하지만 루프를 통합한 이상 그렇게 의존성을 추가하면 맵 렌더링에도 부하가 가해질 것이다.
React 상태(useState)는 그대로 두되,
렌더 루프에서는 ref를 통해 최신 스냅샷만 읽는 구조로 바꿨다.
const targetsRef = useRef<Target[]>([]);
useEffect(() => {
targetsRef.current = targetManagerState.targets;
}, [targetManagerState.targets]);
const gameRef = useRef({ graceStartAt: null, isGameOver: false });
useEffect(() => {
gameRef.current = {
graceStartAt: gameState.graceStartAt,
isGameOver: gameState.isGameOver,
};
}, [gameState.graceStartAt, gameState.isGameOver]);
렌더 루프에서는 이제 항상 최신 참조값을 그린다.
renderTargets({
ctx,
targets: targetsRef.current,
graceStartAt: gameRef.current.graceStartAt,
isGameOver: gameRef.current.isGameOver,
});
이로써 루프는 한 번만 돌지만,
매 프레임마다 최신 상태를 반영하게 되었다.
이번 프로젝트를 진행하는 동안 실시간 렌더링에서의 useRef의 강력함을 열렬히 체득하는 중이다.
이번엔 다행히 엘리어싱 문제를 금방 발견하고 해결 방안도 금방 찾을 수 있었다.
하지만 애초에 문제가 발생하지 않도록 하면 좋으니, 비슷하게 발생할 수 있는 문제를 찾아보고 이에 대비하는 보조장치들을 추가했다.
고해상도 환경에서의 픽셀 깨짐을 방지하기 위해
devicePixelRatio 기반 보정 로직을 추가했다.
기기별로 다른 CSS 픽셀과 실제 기기 픽셀의 비율(Device Pixel Ratio, DPR)에 맞춰
캔버스 내부 해상도를 보정하는 로직이다.
const dpr = window.devicePixelRatio || 1;
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
applyCameraTransform을 CSS 픽셀 기준으로 수정하여
DPR 확대 후에도 중심 이동이 정확하게 유지되도록 했다.
setTransform → clearRect → setTransform(dpr) 순서로 픽셀 잔상을 확실하게 제거했다.
setTransForm : DPR 보정을 초기화clearRect : 초기화된 기준으로 좌표를 초기화setTransform(dpr) : 다시 좌표에 DPR 보정 적용결과는 즉각적이었다.
앨리어싱 현상이 사라지고,
체감은 되지 않지만 화면 전체의 선명도가 개선되었다.
렌더 루프는 하나로 단순해져서 가독성과 유지보수성이 향상되었고, 컨텍스트는 더 이상 꼬이지 않았다.
루프를 통합하는 작업이 안티 앨리어싱이라고 부르기엔 모자란 감이 있지만, 결과적으로 앨리어싱을 해결하기 위한 작업이었으니 이렇게 불러도 되지 않을까?
돌이켜보면 처음 타겟 렌더와 맵 렌더를 각각의 루프로 설계할 때 조금 찝찝한 감이 있었는지도 모르겠다.
이제라도 문제를 인식하고 개선한 이번 리팩토링은 겉보기엔 단순히 렌더 루프를 합친 것처럼 보이지만,
사실상 렌더링 파이프라인의 전면 재설계였다.
작업을 시작할 땐 많이 복잡하고 시행착오도 있을 것 같아 걱정했지만, 생각보다 금방 수정할 수 있었다.
“잘 보이게 만드는 것보다, 제대로 만드는 게 먼저다.”
React와 Canvas를 함께 사용할 때,
루프의 개수와 시간축의 일관성이 얼마나 중요한지를
직접 체감한 경험이었다.
이제 FPS Aim Test의 캔버스는 이전보다 훨씬 단단하다.
🧩 다음 목표
이제 프로젝트를 완성했다 판단하고 리드미를 작성할 때, 조금 마음에 걸렸던 부분은 모두 리팩토링을 완료했다.
타겟 생성 시스템 수정
setInterval 의 중첩 방식 -> 누적 프레임 루프로 변경
타겟이 10개에 도달하는 즉시 게임 종료
타겟이 도달한 이후 3초의 유예 시간을 주고, 그 동안 타겟 색상 변화를 통해 경고하는 기능까지 추가해서 게임성을 높였다.
앨리어싱 문제까지 해결하면서 게임 종료 기준을 바꾼 기능 추가와 리팩토링은 이번 작업으로 마무리했다.
현재로선 구조적으로 개선할 점은 많지 않아 보이지만, 그래도 아직 추가하고 싶은 기능은 남아있다. 이것까지만 작업하고 기획중인 다음 프로젝트를 시작하려고 한다.
타겟 사격 시 플로팅 스코어 UI 추가
지금 타겟은 적중 여부로만 점수가 집계되는 것이 아닌,
타겟 내부 사격 위치에 따른 차등 점수 시스템이다.
그래서 타겟을 사격했을 때 몇 점을 획득했는지
사용자에게 게임 중 실시간으로 알림으로서 자신이 전반적으로 얼마나 정확하게 맞추고 있는지를 파악할 수 있도록 하고싶다.