Phaser 4 Pseudo 3D 레이싱 게임 - 숲, 어둠, 두 개의 헤드라이트를 다시 맞추기
Apex Seoul의 밤 다운힐을 실제 주행 기준으로 다시 보며, 수평선에 잘리던 숲을 SVG 군집으로 교체하고, road와 roadside를 같은 어둠으로 수렴시키고, 차량 전방의 두 타원 헤드라이트와 FT86형 파워밴드를 시행착오 끝에 정리한 기록입니다.
Cover image: OpenAI image generation — article illustration
이전 글에서는 밤 숲과 도로의 어둠을 정리하면서, 차량 앞에 두 개의 눌린 타원 광원을 놓는 데까지 갔다.
정면에서는 충분히 그럴듯했다. 문제는 차가 좌우로 꺾일 때였다. 차체 sprite는 이미 측면 pose로 바뀌는데, 빛은 여전히 화면 위쪽을 향하거나 하나의 큰 회전 사다리꼴로 보였다. 내리막과 오르막에서는 광원 시작점이 차체보다 낮게 깔려 보이기도 했다.
정면: 두 개의 빛이 보인다.
측면: 빛이 차와 다른 방향을 본다.
고저차: 빛의 시작점이 차체 아래에 붙는다.
이번 작업의 목표는 실차 조명 시뮬레이션이 아니었다. rear-view pseudo-3D 화면에서 플레이어가 다음 세 가지를 믿게 만드는 일이었다.

이 삽화는 구현 목표를 설명하기 위한 제작 이미지다. 아래의 큰 장면 이미지는 실제 실행 화면을 캡처한 것이다.
가장 처음의 접근은 단순했다. 도로 위에 사다리꼴 cone을 그리고, 더 밝게 하거나 더 넓게 하면 야간 주행처럼 보일 것이라고 생각했다.
하지만 cone은 camera near-plane에서 시작하고, 차량 sprite는 road contact anchor에 붙어 있다. 둘은 같은 화면에 있어도 같은 좌표계가 아니다.
camera near-plane cone
≠ visible vehicle front
그래서 밝은 부분이 차량 앞이 아니라 차량 아래·뒤에 생겼다. 색과 alpha를 조절해도 해결되지 않았다. 빛의 모양 문제가 아니라 광원의 시작 기준이 차량이 아니었던 문제였다.
다음에는 빛이 guardrail이나 숲으로 새지 않도록 offscreen road mask를 붙이려 했다. 목적은 맞았다. 다만 Phaser의 offscreen Graphics capture와 shader output을 연결하는 과정에서 mask가 shader 결과를 통째로 가렸다. 화면 전체에 빛을 더하는 shader pass에는 일반 GameObject mask API도 그대로 적용할 수 없었다.
이 단계에서 얻은 원칙은 단순했다.
가림을 먼저 완벽하게 만들지 않는다. 먼저 차량과 같은 기준에서 시작하는 빛을 만든다.
road mask는 독립 POC가 성공할 때 다시 시도할 항목으로 남겼다.
camera cone을 버린 뒤에는 화면 전체에 빛을 더하는 shader에서 좌우 lamp pool을 각각 계산했다.
lamp left oval
+ lamp right oval
= overlap brighter
정면 화면에서는 이 구조가 잘 작동했다. 두 광원이 차체 앞에 붙고, 중앙의 overlap이 조금 더 밝아졌다. 하지만 steering sprite는 정면 이미지를 회전한 것이 아니라, 이미 다른 방향으로 그려진 atlas frame이다. 화면의 X/Y만으로 pool을 옮기면 차체와 조명 방향이 다시 갈라졌다.
그 다음에는 두 pool을 하나의 회전 사다리꼴로 합쳤다. 이 역시 정면에는 보기 좋았다. 그러나 78°에 가까운 strong pose에서는 폭과 길이가 비슷해져, 빛이 도로를 비추는 것이 아니라 세로로 돌아간 광판처럼 읽혔다.

여기서 중요한 관찰은 “조향에 따라 빛을 조금 이동한다”로는 부족하다는 점이었다. 차량 pose가 가진 forward axis 자체를 빛이 알아야 했다.
차량 sprite 묶음(atlas)의 각 방향 profile은 이미 아래 정보를 가지고 있었다.
lampLeft / lampRight : sprite 안의 두 램프 위치
emitterForwardYawDeg: 해당 frame이 그려진 전방 방향
footprint : reach, near padding, far width
optical : 작은 corner swivel과 fill
실행 중에는 lamp anchor에 sprite origin, display size, flipX, 작은 sprite rotation을 같은 순서로 적용한다. 그 뒤 atlas의 emitterForwardYawDeg와 runtime rotation을 합쳐 기본 조사축을 만든다.
atlas frame forward yaw
+ runtime sprite rotation
= base beam axis
base beam axis
+ small optical swivel
= final optical axis
큰 방향 변화는 atlas frame이 담당한다. 실행 중에는 최대 8°의 작은 보정 회전만 더한다. 이 분리를 하지 않으면 조향할 때 이미 옆을 보는 차량 위로 큰 회전값이 중복된다.
좌우 left pose는 별도 asset이 아니라 right pose의 flipX를 재사용한다. 따라서 frame yaw와 lamp 순서를 함께 mirror해야 한다. lamp 위치만 뒤집거나 yaw만 부호 반전하면 둘 중 하나가 차체와 어긋난다.
frame 축을 얻어도, 하나의 큰 사다리꼴은 여전히 기계적이었다. 해결은 광원을 하나의 polygon으로 더 정교하게 그리는 것이 아니라, 역할을 나누는 것이었다.
약한 바탕 빛
+ 왼쪽 램프의 주광
+ 오른쪽 램프의 주광
→ 너무 밝아지지 않는 겹침
+ 코너 안쪽의 짧은 보조 빛
이 레이어는 road 위에 남는 약한 청색 면이다. 가장자리와 먼쪽의 부드러운 끝만 담당하고, 강한 중심부는 만들지 않는다. 이것만 강하게 만들면 다시 회전한 사다리꼴 판으로 돌아간다.
두 주광은 계산용 평균점이 아니라 실제 lampLeft, lampRight 화면 위치에서 각각 시작한다. 시작부에서 두 개의 동그란 pool로 읽히지 않도록 좁게 시작하고, 거리가 늘면서 폭이 넓어진다.
처음에는 두 주광을 화면 중앙까지 완전히 모았다. 논리적으로는 두 빛이 하나로 합쳐지는 것처럼 보였지만 medium pose에서는 S자형 합류와 둥근 덩어리가 생겼다. 실제 자동차의 low beam은 두 빛이 화면 중앙에서 교차하는 것보다 거의 평행하게 진행하며 폭으로 겹친다.

그래서 최종적으로는 두 빛을 안쪽으로 최대 28%만 살짝 향하게 했다. 나머지 겹침은 빛이 자연스럽게 넓어지면서 만든다.
vec3 overlap = max(leftCore, rightCore)
+ min(leftCore, rightCore) * 0.32;
vec3 light = max(sharedSpill, overlap);
max()만 쓰면 겹쳐도 평평하다. 단순 additive는 중앙이 두 배로 포화된다. 위 식은 겹치는 영역을 단일 주광보다 밝게 만들되, 두 배가 되지 않게 제한한다.
중간/강한 조향에서 주광을 과하게 꺾으면 바깥쪽 도로가 어두워진다. 그래서 주광 방향은 보존하고, 코너 안쪽에만 짧은 보조 빛을 추가했다.
| pose | main swivel | corner fill yaw | fill reach | fill intensity |
|---|---|---|---|---|
| center | 0° | 없음 | 없음 | 없음 |
| medium | 4° | 7° | main의 55% | 20% |
| strong | 8° | 12° | main의 65% | 30% |
이 보조 빛도 단순히 더하지 않는다. 이미 밝은 주광 위에 파란색이 겹쳐 과포화되지 않게, 둘 중 밝은 쪽만 남기는 방식으로 합쳤다.
“너무 넓다”라는 문제를 곧바로 light intensity로 해결하면 원인을 잃는다. 이번에는 reach, far width, core fade, intensity를 따로 조정했다.
초기 downhill medium은 far half-width가 0.135h, reach가 0.145h였다. 760px viewport에서는 각각 약 103px, 110px다. 이 비율을 42° 정도 회전하면 사실상 긴 빔보다 넓은 삼각형이 된다.
그래서 medium far width만 다음처럼 줄였다. center와 strong의 기준은 이 단계에서 건드리지 않았다.
| terrain | 이전 medium width | 최종 medium width |
|---|---|---|
| level | 0.120h | 0.090h |
| downhill | 0.135h | 0.100h |
| uphill | 0.110h | 0.080h |
반대편 lamp는 perspective상 멀고 일부 가려진 것으로 보고, medium/strong에서 별도로 감쇠했다.
| pose | far lamp intensity | far lamp reach |
|---|---|---|
| center | 1.00 | 1.00 |
| medium | 0.65 | 0.90 |
| strong | 0.35 | 0.82 |
처음에는 램프별 주광이 reach의 55%부터 사라지게 했다. 먼 꼬리를 짧게 만드는 데는 좋았지만, 두 빛이 충분히 겹치기 전에 끊겼다. 최종적으로는 68%부터 흐리게 만들고, 마지막 부드러운 꼬리는 바탕 빛만 남도록 했다.
dual emitter가 안정된 뒤에도 정면 빔은 콘셉트 이미지보다 멀리 뻗었다. 여기서는 폭이나 광량을 다시 건드리지 않고 center reach만 이전의 70%로 줄였다.
| terrain | 이전 center reach | 최종 center reach |
|---|---|---|
| level | 0.140h | 0.098h |
| downhill | 0.150h | 0.105h |
| uphill | 0.120h | 0.084h |
이렇게 하면 정면에서는 가까운 road를 읽게 하는 밝은 면이 남고, 멀리까지 뻗는 사다리꼴은 줄어든다. 조향 pose의 reach는 이 변경과 섞지 않았다. 길이를 바꾸는 실험과 방향을 바꾸는 실험을 분리하기 위해서다.
이 작업에서 가장 유용했던 실패는 측면 헤드라이트의 높이였다.
내리막과 오르막에서 빛이 차체 아래에 깔려 보였다. 처음에는 beam progress에 따라 shader 전체를 화면 위로 들어 올리는 screenLift를 넣었다.
near edge : 그대로
mid / far : 위로 lift
이 방식은 빛의 끝부분만 들어 올릴 뿐, 광원이 실제로 시작하는 점은 바꾸지 않았다. 결과적으로 조사각이 휘어 보였고, 사용자가 느낀 “차체보다 낮은 시작점”은 남았다.

정답은 shader가 아니라 atlas였다. 측면 profile의 lampLeft.y, lampRight.y를 같은 양만큼 위로 옮겼다.
medium: two lamp Y -= 0.030 frame ratio
strong: two lamp Y -= 0.040 frame ratio
두 lamp에 같은 delta를 적용하면 램프 사이의 기울기와 빛의 방향은 그대로다. 즉 빛의 방향을 고친 것이 아니라, 빛이 출발하는 위치와 전체 footprint를 평행 이동한 것이다.

평지 left/right는 이 보정 후 괜찮았지만, downhill/uphill에는 elevation용 기존 Y offset이 남아 있었다. terrain side profile에만 -0.030 frame ratio를 추가 적용했다.
level side : 승인값 유지
downhill side : terrain anchor만 추가 상승
uphill side : terrain anchor만 추가 상승
최종 규칙은 “terrain side lamp 평균 Y가 승인된 level side lamp보다 의미 있게 아래로 내려갈 수 없다”다. 이를 자동 검증으로 고정했다.


시각 효과는 최종적으로 캡처를 봐야 한다. 그렇다고 캡처만 보면 차량 데이터 규칙이 조용히 깨졌을 때 원인을 찾기 어렵다. 그래서 qa:headlight-profiles에 다음을 고정했다.
2 vehicle atlases
18 sprite anchor checks
12 directional profiles
left/right mirror
frame yaw + sprite rotation + optical correction composition
medium/strong far lamp intensity and reach mirror
terrain side anchor must not sit below approved level anchor
그리고 FT86과 Genesis G70에서 각각 15개 장면을 캡처한다.
npm run qa:headlight-profiles --workspace @games/apex-seoul
npm run qa:runtime-vehicle --workspace @games/apex-seoul -- --vehicle ft86-retro
npm run qa:runtime-vehicle --workspace @games/apex-seoul -- --vehicle genesis-g70-poc
npm run build --workspace @games/apex-seoul


이 검증은 광량의 미적 판단까지 대신하지 않는다. 대신 “left에는 맞는데 right에서 lamp 순서가 바뀌었다”, “terrain profile에서 시작점만 아래로 내려갔다”, “frame yaw가 sprite rotation과 중복됐다” 같은 회귀를 빠르게 막는다.
이번 작업을 지나며 헤드라이트에는 다음 순서가 남았다.
1. 광원 시작점은 실제 sprite lamp anchor에서 얻는다.
2. 큰 방향 변화는 atlas pose가 소유한다.
3. runtime optical은 작은 상대 swivel만 담당한다.
4. 두 램프는 각각의 주광으로 계산한다.
5. 겹치는 곳은 밝되 포화되지 않게 제한한다.
6. terrain은 조사각이 아니라 anchor height를 먼저 확인한다.
아직 road-only mask와 wet asphalt 반사는 남아 있다. 하지만 그 전에 해결해야 했던 것은 빛을 더 화려하게 만드는 일이 아니었다. 플레이어가 차를 꺾었을 때, 빛도 같은 차에서 나오는 것처럼 보이게 하는 일이었다.
이번 반복의 결론은 이렇다.
헤드라이트는 하나의 예쁜 사다리꼴이 아니라, 차량 pose·두 lamp 위치·고저차 profile·합성 규칙이 함께 만드는 화면상의 약속이다.
Apex Seoul의 밤 다운힐을 실제 주행 기준으로 다시 보며, 수평선에 잘리던 숲을 SVG 군집으로 교체하고, road와 roadside를 같은 어둠으로 수렴시키고, 차량 전방의 두 타원 헤드라이트와 FT86형 파워밴드를 시행착오 끝에 정리한 기록입니다.
Apex Seoul의 밤 다운힐 비주얼을 왼쪽 절벽·가드레일, 오른쪽 옹벽·수목 구조로 재정리하고, 가드레일 충돌, 저속 조향 pose, standing start 파워밴드까지 주행 로그와 자동 QA로 다시 맞췄습니다.
Apex Seoul에서 200km/h grip 주행이 너무 쉽게 느껴진 이유를 주행 로그로 분해하고, 코스 곡률을 바꾸지 않은 채 하향 코너 속도 예산·understeer·안전 여유 비용·자동 검증으로 다운힐의 선택을 만들었습니다.
Apex Seoul에 스페이스 브레이크 기반 드리프트와 카운터 스티어를 넣고, 입력을 놓았을 때의 복귀·전환을 별도 상태로 분리했습니다. 주행 로그와 자동 시나리오로 감각을 수치화하며, 스핀은 아직 구현하지 않기로 한 이유도 정리합니다.
Apex Seoul을 한 번 내려오는 다운힐 런으로 정리하고, 고속 조향과 코너 감속을 실제 주행 로그로 다시 튜닝한 뒤, FT86 기반 Raven Coupe에 NA 고회전 RPM, 기어, 토크 곡선, fuel cut 상태를 붙였습니다.
Apex Seoul의 주행 감각을 OutRun식 grip 기준으로 다시 보고, 고정 화면 비율, WebGL 전환, 속도 표시 보정, shader speed cue, 자동 handling simulation으로 핸들링 후보를 반복 비교했습니다.