Phaser 4 Pseudo 3D 레이싱 게임 - 헤드라이트를 사다리꼴 하나에서 두 개의 광원으로 바꾸기
Apex Seoul의 야간 헤드라이트가 조향과 고저차에서 어색해진 이유를 좌표계·차량 sprite 방향·빛 합성으로 나눠 추적했습니다. camera 기준 cone, 단일 회전 사다리꼴, 과도한 중심 수렴, 잘못된 lift를 버리고 실제 램프 위치에서 시작하는 두 광원 구조와 terrain별 위치 검증으로 정리한 기록입니다.
Cover image: OpenAI imagegen으로 생성한 CRT/VHS 야간 레이싱 콘셉트 이미지
지난 글에서는 모바일 조작과 PWA를 준비하는 방향을 정리했다. 이번에는 화면의 분위기를 조금 바꿔보기로 했다.
Apex Seoul은 야간 산길을 달리는 레트로 레이싱 게임이다. 도로와 차량은 그 분위기에 가까워지고 있는데, 화면 자체는 여전히 깔끔한 브라우저 캔버스였다. 여기에 90년대 비디오테이프를 재생하는 듯한 질감을 더하면 어떨까 싶었다.
처음 생각한 것은 스캔라인, 화면 왜곡, 그리고 가끔 생기는 떨림이었다. Shadertoy에서 본 듯한 인상이 떠올랐지만, 특정 작품의 코드를 가져오지는 않았다. 현재 게임의 렌더링 구조에 맞춰 vhsEffect.ts에 직접 효과를 구성했다.

이 화면은 효과를 확인하기 위해 주행 상태를 고정한 실제 실행 화면이다. 왼쪽의 타임어택 기록은 멈춰 있지만 오른쪽의 테이프 카운터는 별도로 흐른다.
첫 구현에는 옅은 스캔라인과 색 번짐, 가로 방향의 작은 흔들림을 넣었다. 효과가 들어갔다는 것은 알 수 있었지만, 바라던 브라운관 느낌보다는 화면이 살짝 일렁이는 인상이 강했다.
원했던 모습에는 두 가지가 섞여 있었다. 둥근 화면과 주사선은 CRT 쪽의 특징이고, 색이 어긋나거나 화면 일부가 순간적으로 밀리는 연출은 VHS 테이프 쪽에 가깝다. 이번 효과는 이 둘을 함께 표현한다. 실제 장비의 신호를 정밀하게 재현하기보다는, 게임 안에서 그 인상이 읽히도록 만드는 쪽으로 잡았다.
그래서 일렁임을 줄이고 화면의 형태부터 다시 만들었다. 네 가장자리가 휘고 모서리가 둥글게 닫히도록 하니, 같은 야간 도로도 오래된 화면 안에 들어가 있는 느낌이 났다.
효과는 Phaser 4의 카메라 필터로 붙였다. 카메라가 그린 결과를 텍스처로 읽어 한 번의 후처리 패스에서 바꾼다. 차량 물리나 도로 투영 좌표는 건드리지 않고, 화면을 읽는 UV 좌표를 변형한다.
곡률을 만드는 부분은 다음과 같다.
vec2 centered = uv * 2.0 - 1.0;
vec2 curved = centered * (1.0 + 0.035 * centered.yx * centered.yx);
uv = curved * 0.5 + 0.5;
화면 중심에서 멀어질수록 반대 축의 위치에 따라 더 휘도록 했다. 여기에 화면 비율을 반영한 둥근 사각형 마스크를 더해 유리 화면의 경계를 만든다. 경계 바깥은 거의 검은색으로 채우고, 안쪽도 가장자리에 가까워지면 조금 어두워진다.
순서도 중요했다. 텍스처 좌표를 화면 안으로 제한한 다음 외곽을 계산하면, 바깥에 검은 테두리가 생기는 대신 끝부분의 픽셀이 늘어져 보일 수 있다. 그래서 둥근 경계는 좌표를 제한하기 전에 계산하고, 텍스처를 읽을 때만 가장자리를 벗어나지 않도록 했다.
이렇게 화면의 형태를 잡은 뒤 스캔라인과 작은 RGB 줄무늬를 더했다. 계기판 숫자와 도로 경계는 읽혀야 하므로, 선명한 효과를 넣되 화면 전체가 지나치게 어두워지지 않도록 밝기도 함께 조정했다.
게임의 논리 해상도는 1200 × 760이지만, 브라우저에서 항상 그 크기로 보이는 것은 아니다. 모바일 가로 화면에서는 캔버스가 훨씬 작아진다.
원본 해상도에만 맞춰 촘촘한 선을 그리면 축소될 때 선이 사라지거나 굵은 간섭무늬처럼 보일 수 있다. 이번에는 uDisplayResolution으로 실제 표시 크기를 전달하고, 이를 기준으로 스캔라인 간격을 잡았다.
float scanline = 0.5 + 0.5 * cos(
uv.y * uDisplayResolution.y * 2.0943951
);
color *= 1.10 * (1.0 - scanline * 0.25);
표시 좌표 기준으로 약 3픽셀마다 밝기가 반복되는 형태다. 그 위에 RGB 채널을 조금씩 달리 밝히는 패턴을 얹어 형광체 질감을 보탰다. 모든 디스플레이에서 같은 모습이 된다고 할 수는 없지만, 게임 화면이 줄어들 때도 주사선의 인상이 남도록 한 것이다.
테이프 쪽 연출은 계속 크게 흔들지 않도록 했다. 작은 색 번짐과 노이즈를 기본으로 두고, 짧은 트래킹 어긋남이 간헐적으로 나타난다. 평소에는 도로를 읽을 수 있고, 가끔 화면 일부가 밀릴 때 오래된 테이프라는 느낌이 드는 정도를 의도했다.
처음에는 오른쪽 위에 웹 버튼을 올렸다. VHS PLAY와 시간을 보여 주면서 클릭하면 효과를 끄는 방식이었다.
기능은 편했지만, 게임 화면 위에 웹 UI 하나가 붙어 있는 느낌이 강했다. 화면은 휘고 흔들리는데 버튼만 깔끔하게 남아 있으니 더 눈에 띄었다.
그래서 버튼을 없애고 다음 두 줄만 남겼다.
PLAY ▶
00:00:00
이 글자는 HTML이나 Phaser의 Text 객체가 아니다. 숫자와 P, L, A, Y, 콜론, 재생 삼각형을 각각 5 × 7 픽셀 패턴으로 정의하고 프래그먼트 셰이더에서 그린다.
글자를 합성하는 위치도 바꿨다. 왜곡된 UV로 글자의 위치를 계산하고, 스캔라인과 외곽 음영을 적용하기 전에 화면에 섞는다. 덕분에 글자도 브라운관 곡률을 따라 휘고, 트래킹이 어긋나면 같이 밀리며, 주사선이 글자 위를 지나간다. 옅은 그림자와 색 번짐도 넣었다.
구현 중에는 WebGL 셰이더 컴파일 오류도 한 번 만났다. 글자 행을 고를 때 벡터를 동적 인덱스로 읽었는데, 검증 환경에서 허용되지 않았다. 해당 부분을 upper.x, upper.y처럼 고정된 성분을 선택하는 분기로 바꾼 뒤 정상적으로 렌더링됐다. 빌드가 통과해도 셰이더는 실제 브라우저에서 확인해야 했다.
시간은 게임을 켠 세션에서 효과가 활성화된 동안 누적되는 연출용 카운터다. 타임어택 기록이나 실제 영상 녹화 시간이 아니다. 메뉴와 주행 화면 사이에서도 이어지고, 효과를 끄면 누적을 멈춘다. 새로고침하면 다시 시작한다.
ON/OFF는 OPTIONS → DISPLAY → CRT / VHS EFFECT로 옮겼다. 화면 오른쪽 위는 재생 상태를 보여 주는 용도로만 남는다.

선택은 기존 게임 설정과 함께 저장한다. 앞서 화면 버튼으로 효과를 꺼둔 값이 있다면, 새 설정 항목이 없는 이전 저장 데이터를 읽을 때 그 선택도 이어받는다. OFF에서는 글자만 숨기는 것이 아니라 필터 자체를 비활성화한다.
운영체제의 ‘동작 줄이기’ 설정이나 게임 설정의 reducedMotion 값이 켜져 있으면 움직이는 왜곡과 노이즈의 시간 진행을 멈춘다. 둥근 외곽과 스캔라인은 유지하고, 테이프 카운터는 계속 표시한다.
설정 저장 테스트와 게임 빌드를 통과했고, 실제 Edge 브라우저에서 셰이더 출력과 OPTIONS의 ON/OFF, 새로고침 후 선택 유지까지 확인했다. 모바일 가로 크기로 줄였을 때 글자와 옵션 배치도 살펴봤다. 다만 이것이 모바일 실기기의 GPU 성능까지 검증했다는 뜻은 아니다. 텍스처 샘플은 세 번으로 유지했지만, 전체 화면 후처리와 픽셀 글자 계산에는 추가 비용이 든다.
이번 작업으로 차가 더 빨라지거나 코스가 늘어난 것은 아니다. 대신 밤길과 계기판, 오른쪽 위의 작은 재생 표시가 한 화면의 질감으로 묶였다. 이제 북악을 달리는 장면을 잠깐 멈춰 놓아도, 어떤 시대의 레이싱 화면을 떠올리며 만들고 있는지 조금 더 잘 보인다.
Apex Seoul의 야간 헤드라이트가 조향과 고저차에서 어색해진 이유를 좌표계·차량 sprite 방향·빛 합성으로 나눠 추적했습니다. camera 기준 cone, 단일 회전 사다리꼴, 과도한 중심 수렴, 잘못된 lift를 버리고 실제 램프 위치에서 시작하는 두 광원 구조와 terrain별 위치 검증으로 정리한 기록입니다.
가상 버튼을 기본으로, 기울기 조향은 선택으로. Apex Seoul의 모바일 입력과 권한 처리, 화면 회전 시 일시정지, 설치형 PWA까지 앞으로 구현할 방향을 정리했다.
세 차량의 코너링 설정을 프로파일로 분리했다. 터보 차량의 변속과 RPM 컷을 수정하고, Retry 뒤 나무가 사라지던 Scene 수명주기 버그를 정리했다.
코너 충돌 뒤 가속해도 움직이지 못하는 상황에 자동 복귀를 붙였다. 정상 정차와 고착을 구분하는 조건, 도로 중앙 배치와 차량 상태 초기화, 깜박임 연출, 타임어택 기록을 지키는 처리까지 정리했다.
최고 시간 하나만 저장하던 레이싱 게임에 차량별 PB와 최근 완주 이력을 붙였다. localStorage 저장부터 내부 배열 기반 기본값, 리셋 후 기록이 되살아나지 않게 하는 처리까지 정리했다.
아날로그 RPM, 디지털 속도계, 차량별 부스트 계기를 구현했다. 트윈터보의 과급 상태를 두 단계로 분리하고, 계기판을 오른쪽 하단에 배치했다.