1인칭 시점 화면 전환

Ethan·2025년 4월 20일

↑ 완성된 1인칭 시점

FPS용 에임 테스트 게임을 만들려고 했으니, 가장 먼저 구현해야 할 것은 1인칭 시점의 화면 전환이었다. 실현 가능성을 찾아보던 중 사용하려 했던 FullscreenAPIPointerLockAPI를 사용해 전체화면에서 마우스 포인터를 고정시키고, 마우스를 이동한다면 이동량에 맞게 마우스 대신 배경 이미지를 이동시켜 1인칭 시점을 구현하려고 했다.

처음에는 맵을 구현할 때 캔바스를 쓰지 않고 그냥 렌더링을 시도했었다. 하지만 그 때 시도했던 두 가지 방식엔 문제가 있었다.

첫 번째 시도 : state 기반 이벤트 처리 방식

const [position, setPosition] = useState({ x: 0, y: 0 });

const handleMouseMove = (event: MouseEvent) => {
  if (isPointerLocked.current) {
    setPosition(prev => ({
      x: prev.x - event.movementX,
      y: prev.y - event.movementY
    }));
  }
};

가장 먼저 시도한 방식은 마우스 위치 정보를 state로 저장하고, 마우스를 이동시킬 때마다 상태를 갱신하여 화면을 리렌더링 하는 것이었다.

잘 되는가 싶더니, 큰 문제가 발생했다. 마우스를 조금만 빠르게 움직이거나, 클릭 이벤트 등 다른 이벤트가 도중에 추가되면 리렌더링 과정에서 화면이 이동 방향으로 순간이동했다.

명확하게 원인을 규명한 것은 아니지만, 직관적으로 생각해서 과도한 이벤트 발생과 setter호출에 따른 리렌더링이 문제가 되는 것 같았다. 에임 테스트인 이상 시점 전환은 반드시 문제없이 구현되어야 하고, 그렇다면 다른 방식으로 이벤트 발생량을 줄이는 것도 불가능했다.

두 번째 시도 : CSS Transform 방식

단순하게 생각했을 때 두 번째로 시도한 방법은 CSS를 사용한 방법이었다. 그러나 이 역시 DOM 요소의 Style 속성을 조작하는 것이라서 그런지, 앞선 방법과 동일한 문제가 해결되지 않았다.

해결책 : Canvas + requestAnimationFrame

  // 렌더링
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    const render = () => {
      ctx.fillStyle = '#000';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      if (!imageRef.current || !drawSizeRef.current.width) {
        requestAnimationFrame(render);
        return;
      }

      // 위치 업데이트
      if (isPointerLocked.current) {
        // 마우스 움직임의 반대 방향으로 이동 (속도 조정)
        position.current.x -= mouseMovement.current.x * 1;
        position.current.y -= mouseMovement.current.y * 1;

        // 이동 제한을 맵 크기에 맞게 조정 (범위 조정)
        const maxX = (drawSizeRef.current.width - canvas.width) * 0.5;
        const maxY = (drawSizeRef.current.height - canvas.height) * 0.5;
        position.current.x = Math.max(-maxX, Math.min(maxX, position.current.x));
        position.current.y = Math.max(-maxY, Math.min(maxY, position.current.y));

        mouseMovement.current = { x: 0, y: 0 };
      }

      // 변환 적용
      ctx.save();
      ctx.translate(canvas.width / 2 + position.current.x, canvas.height / 2 + position.current.y);

      // 이미지 그리기
      ctx.drawImage(
        imageRef.current,
        -drawSizeRef.current.width / 2,
        -drawSizeRef.current.height / 2,
        drawSizeRef.current.width,
        drawSizeRef.current.height
      );

      ctx.restore();

      requestAnimationFrame(render);
    };

    render();
  }, []);

해결책을 찾아낸 것은 Canvas를 사용하는 것이었다.
맵 렌더링을 img태그나 css background를 사용하는 대신, Canvas로 그렸다.

그리고 풀스크린과 포인터락과 같은 브라우저 표준 API중 하나인 requestAnimationFrame을 사용했다.

requestAnimationFrame은 에니메이션을 위한 타이밍 제어 메커니즘을 제공한다. 화면을 그리고, 다음 화면을 그리기 전에 콜백을 미리 실행시켜서 다음 프레임을 만들어 줄 수 있도록 하는 기능이다. 나는 가능한 부드러운 화면 전환이 필요했고, DOM트리 리렌더링 없이 Context 조작을 통해 화면을 바로 그리는 Canvas와, 브라우저 프레임에 맞추어 빠르게 다음 프레임을 그리고 전환할 수 있도록 해주는 requestAnimationFrame을 사용해서 1인칭 시점을 구현할 수 있었다.


생각보다 복잡했지만 그래도 1인칭 시점은 잘 구현할 수 있었다!

이제 다음은 타겟을 생성하고 스코어를 측정하는 작업이다.

profile
"Actions speak louder than words"

0개의 댓글