의사 3D(pseudo 3D) 방식으로 2D 캔버스에서 3D 레이싱 게임처럼 만들기
2026년 11월 1일 · 기술
고속도로 드라이버는 뒤에서 본 차로 굽고 오르내리는 고속도로를 달리는 레이싱이다. 시간이 다 되기 전에 다음 체크포인트에 닿아 시간을 늘린다. 화면은 3D 처럼 보이지만 3D 라이브러리는 쓰지 않았다. 640×360 의 2D 캔버스에 사다리꼴(네 점짜리 다각형)과 평면 그림만 그린다.
1980년대 아케이드 레이싱 게임들이 쓰던 의사 3D(pseudo 3D) 방식이다. 세계는 3D 좌표로 생각하지만 실제 3D 모델은 없다. 도로는 짧은 조각을 원근 투영해 띠처럼 이어 그리고, 차와 나무는 크기만 바꾼 평면 그림이다. 이 방식은 Louis Gorenfeld 의 "Lou's Pseudo 3d Page" 와 Jake Gordon 의 "How to build a racing game" 연재에 잘 설명되어 있고, 이 게임도 그 틀을 따랐다.
도로는 칸 수천 개, 칸 하나는 사다리꼴 하나
도로를 길이 200 단위짜리 칸으로 나눈다. 1m 를 166 단위로 잡았으니 칸 하나가 1.2m 쯤이고, 2km 구간 하나에 1600칸 남짓이다. 칸마다 두 가지 값만 있다. 얼마나 굽었는지(curve)와 끝점의 높이(y)다. 화면에는 지금 칸부터 앞으로 220칸만 그린다. 260km/h 로 달리면 초당 60칸쯤이 지나간다.
칸의 앞 끝과 뒤 끝을 화면 좌표로 바꾸는 것이 투영이다. 카메라에서 그 점까지의 거리 cz 로 나누면 멀수록 작아진다.
const DEPTH = 1 / Math.tan((50 * Math.PI) / 180); // 100° field of view
const U = W / 2, Y0 = 168; // screen scale, horizon line
function project(p, camX, camY, camZ) {
p.cz = p.z - camZ; // distance from the camera
p.s = DEPTH / p.cz; // scale: smaller when farther
p.X = U + p.s * (p.x - camX) * U;
p.Y = Y0 - p.s * (p.y - camY) * U;
p.Wd = p.s * ROAD * U; // half road width on screen
}
카메라는 도로 위 1000 단위 높이에 있고, 지평선은 화면 위에서 168px 이다. 칸 하나의 앞 끝과 뒤 끝을 투영하면 두 점과 두 폭이 나온다. 이것으로 사다리꼴을 그리면 그 칸의 도로가 된다. 실제로는 한 칸에 몇 겹을 그린다. 화면 폭 전체의 풀밭 띠, 도로보다 1.17배 넓은 갓길, 도로, 그리고 차선 줄이다. 칸 번호를 3으로 나눠 홀짝에 따라 풀밭·갓길·도로 색을 조금씩 바꾸고, 줄무늬 칸에만 차선 줄을 그린다. 이 줄무늬가 지나가는 속도로 속도감이 난다.
굽은 길은 옆으로 미는 것뿐이다
굽은 길이라고 해서 칸마다 실제 x 좌표를 만들지 않는다. 가까운 칸부터 차례로 투영하면서 옆으로 미는 양을 쌓는다. 칸을 하나 지날 때마다 미는 양 x 에 dx 를 더하고, dx 에는 그 칸의 curve 를 더한다. 미는 양이 거리의 제곱처럼 커지니 도로가 2차 곡선처럼 휜다.
let x = 0, dx = -(base.curve * partInSeg); // start smoothly inside the first segment
for (let n = 0; n < DRAW; n++) {
const s = segs[first + n];
project(s.p1, camX - x, camY, camZ);
project(s.p2, camX - x - dx, camY, camZ);
x += dx;
dx += s.curve;
}
카메라를 반대쪽으로 옮기는 것과 길을 미는 것은 같은 일이라, 투영할 때 카메라 x 에서 빼기만 하면 된다. 첫 줄의 dx 는 지금 칸 안에서 얼마나 왔는지만큼 빼 둔다. 이것이 없으면 칸 경계를 넘는 순간 굽은 정도가 한꺼번에 바뀌어 도로가 옆으로 툭 뛴다.
길 조각은 들어가기·유지·나오기 세 부분으로 만든다. 굽은 정도는 들어갈 때 점점 세지고(easeIn), 나올 때 부드럽게 0 으로 돌아온다(easeInOut). 언덕 높이도 조각 전체에 걸쳐 easeInOut 으로 오르내린다. 풍경마다 성격을 달리했다. 협곡은 가장 많이 굽고, 숲은 언덕이 많고, 노을 다리는 길고 완만하다.
굽은 길에서는 차가 바깥으로 밀린다. 실제 물리는 아니고, 조향 값에서 속도 비율 × 굽은 정도 × 0.25 를 빼는 것뿐이다. 처음 값은 260km/h 에서 가장 많이 굽은 곳을 조향을 끝까지 꺾으면 버티게 맞췄다. 나중에 체크포인트마다 최고 속도가 20km/h 씩 올라 420km/h 까지 가게 바꿨다. 이 식은 원심력이 속도 비율의 제곱만큼 세지는데, 그대로 두면 420km/h 에서 버틸 수 있는 굽은 정도가 2.5 라 협곡에서는 브레이크만 밟게 된다. 그래서 260km/h 를 넘으면 원심력이 √ 배로만 세지게 했다. 420km/h 에서도 굽은 정도 3.1 까지는 버틴다.
먼 칸부터 그려야 언덕이 뒤를 가린다
언덕은 칸마다 높이가 다를 뿐이고, 투영 식의 y 가 그 값을 화면 높이로 바꿔 준다. 카메라 높이는 내 차가 있는 자리의 도로 높이에 1000 을 더해 따라간다. 문제는 고개 너머다. 언덕 꼭대기 너머의 도로는 보이면 안 되는데, 칸을 가까운 순서로 그리면 먼 도로가 가까운 언덕 위에 덮인다.
그래서 투영과 그리기의 순서를 나눴다. 굽은 길의 미는 양은 가까운 칸부터 쌓아야 하니 투영은 가까운 칸부터 해서 목록을 만든다. 그리기는 그 목록을 거꾸로, 먼 칸부터 한다. 화가가 먼 배경부터 칠하고 가까운 것을 덧칠하듯이(화가 알고리즘) 가까운 언덕이 나중에 그려져 뒤의 도로를 덮는다. 칸마다 화면 폭 전체의 풀밭 띠를 함께 칠하기 때문에 덮이는 부분이 깔끔하다.
for (let n = list.length - 1; n >= 0; n--) { // far to near
const { p1, p2 } = list[n];
if (p1.cz <= DEPTH) continue; // behind the camera
if (p2.Y >= p1.Y) continue; // back face beyond a crest: skip
ctx.fillRect(left, p2.Y, width, p1.Y - p2.Y); // grass band, full width
poly(roadColor, p1.X - p1.Wd, p1.Y, p1.X + p1.Wd, p1.Y,
p2.X + p2.Wd, p2.Y, p2.X - p2.Wd, p2.Y);
}
뒤 끝이 앞 끝보다 화면에서 아래에 있는 칸은 고개 너머로 내려가는 칸이다. 화면에 뒤집힌 사다리꼴이 되므로 건너뛴다. 어차피 더 가까운 언덕에 가려질 자리다. 지평선 아래는 먼저 안개 색으로 다 칠해 두어, 고개 너머에 아무것도 그리지 않은 곳도 비어 보이지 않는다.
먼 칸은 안개 색과 섞는다. 거리 비율 n / 220 에 대해 1 − exp(−(n/220)² × 5) 만큼 섞으니 가까운 곳은 거의 그대로이고 멀어질수록 빨리 흐려진다. 섞는 정도는 16단계로 나눠, 한 번 섞은 색은 기억해 두고 다시 쓴다. 220칸 끝의 도로는 거의 안개 색이라 새 칸이 생겨도 티가 나지 않는다. 길가 물건과 차는 가장 먼 5분의 1 구간에서 투명도를 낮춰, 갑자기 생기지 않고 서서히 나타나게 했다.

차와 나무는 그 칸의 축척으로 그린다
길가 물건과 다른 차는 생성 그림 아틀라스의 평면 그림이다. 칸을 투영할 때 나온 축척 s 에 물건의 세계 높이를 곱하면 화면 높이가 나온다. 그리고 그 칸을 그릴 때 함께 그린다. 먼 칸부터 그리니 물건도 자연스럽게 앞뒤가 맞고, 고개 너머의 나무와 차는 가까운 언덕에 가려진다.
그대로 그렸더니 멀리 있는 차가 거의 보이지 않았다. 원근 그대로면 60m 앞의 차가 14px 쯤이라 다가와서야 갑자기 나타났다. 그래서 멀리 있는 차는 원근보다 크게 그린다. 내 차 앞 거리의 10배까지 가면서 점점 커져 최대 1.8배다. 판정은 부딪힐 만큼 가까운 거리에서만 하므로 과장해도 영향이 없다.
판정은 그리기와 같은 좌표로 한다. 가로는 도로 가운데가 0, 도로 끝이 ±1 이고, 세로는 달린 거리다. 다른 차의 뒤가 내 차 앞 길이 안에 있고 가로 폭이 겹치면 부딪힌다. 길가 물건은 그림 전체가 아니라 기둥이나 밑동 자리로 잰다. 야자수는 그림 폭의 5분의 1 정도, 바위는 85% 를 판정에 쓴다.
평면 그림이라 생긴 일도 있었다. 의사 3D 는 소실점이 화면 가운데라, 길 양쪽 물건 모두 가운데 쪽 옆면이 보여야 한다. 빌딩 그림은 오른쪽 옆면이 보이는 그림이라 도로 오른쪽 빌딩은 각도가 어색했다. 오른쪽 줄만 좌우로 뒤집었다. 가로등도 처음에는 양쪽 등이 모두 도로 바깥을 향했다. 그림이 어느 쪽을 향하는지 보지 않고 왼쪽 줄을 뒤집은 탓이었다. 지금은 그림이 향하는 쪽을 먼저 보고 뒤집을 줄을 정한다.
내 차는 곧은 그림, 왼쪽으로 꺾은 그림, 오른쪽으로 꺾은 그림 세 장이다. 꺾은 그림 한 장을 좌우로 뒤집어 반대쪽에 쓰면 될 것 같았는데, 그러자 한쪽에만 있던 운전자가 꺾을 때마다 좌우로 옮겨 다녔다. 반대쪽 그림을 따로 만들었다. 꺾은 그림은 먼 쪽 바퀴가 그림 아래에서 20~30px 위에 있어서 가운데 납작한 타원 그림자로는 앞바퀴가 떠 보였다. 그래서 꺾은 그림의 그림자는 바퀴가 닿는 자리를 따라 그린 다각형이다.
해가 제자리에 있었다
하늘·먼 산·해는 3D 로 투영하지 않는다. 무한히 먼 것이라 굽은 길을 돌 때 옆으로 밀리기만 하면 된다. 굽은 정도에 그 프레임에 달린 칸 수를 곱해 farX 에 쌓고, 먼 산의 능선을 그만큼 밀어 그린다. 그보다 가까운 언덕과 도시 실루엣은 2배 남짓 빠르게 밀어 깊이감을 낸다.

처음에는 해와 달을 먼 산의 0.1배로 밀었다. 그랬더니 굽은 길을 한참 돌아도 해가 제자리에 있어 어색했다. 해·달·별은 먼 산과 같은 거리, 사실상 무한히 먼 곳에 있으니 같은 속도로 밀려야 한다. 지금은 farX 그대로 밀고, 2400px 를 한 바퀴로 잡아 한쪽으로 나가면 반대쪽에서 다시 나온다.
const SKY_P = 2400; // one loop of the sky
const skyX = (x0) => {
const a = view.left - 120;
return a + ((((x0 - farX) - a) % SKY_P) + SKY_P) % SKY_P;
};
풍경은 2km 마다 해안, 사막 협곡, 숲 언덕, 노을 다리, 밤 도시로 바뀐다. 체크포인트 앞 300m 동안 하늘 그라데이션과 먼 풍경을 다음 풍경과 섞어, 아치를 지날 즈음에는 하늘이 이미 바뀌어 있다.
모든 계산이 칸마다 덧셈 몇 번과 나눗셈 한 번이고, 그리는 것은 사다리꼴과 그림뿐이다. 3D 모델이 없으니 그림을 바꾸고 싶으면 평면 그림 한 장만 다시 만들면 된다. 대신 평면 그림이라 어느 쪽을 향하는지, 뒤집으면 무엇까지 뒤집히는지를 직접 챙겨야 했다. 시행착오의 대부분이 투영 식이 아니라 그쪽에서 나왔다.
참고
- Louis Gorenfeld, Lou's Pseudo 3d Page: http://www.extentofthejam.com/pseudo/
- Jake Gordon, How to build a racing game: https://jakesgordon.com/writing/javascript-racer/
- Jake Gordon, How to build a racing game - curves: https://jakesgordon.com/writing/javascript-racer-v2-curves/
- Jake Gordon, How to build a racing game - hills: https://jakesgordon.com/writing/javascript-racer-v3-hills/
- Wikipedia, Painter's algorithm: https://en.wikipedia.org/wiki/Painter%27s_algorithm
이 글과 관련된 게임
다른 글
- 젤리 캐릭터는 뼈대 대신 모양 다섯 개로 움직인다 2026년 11월 1일
- 라이브러리 없이 3D 주사위를 굴려 원하는 면에서 멈추기 2026년 11월 1일
- 카드를 옮길 때마다 아이폰에서 판이 번쩍였다 2026년 11월 1일
- 3D 모델의 텍스처가 하얗게 나왔다 2026년 10월 30일
- 2D 게임 틀에 three.js 를 끼워 넣기 2026년 10월 30일
