← 블로그 목록
2026-05-19
FOV 시야 안개: 120 Ray Raycasting
탑다운 슈터에서 "전체 맵이 다 보임"은 너무 쉽습니다. 적의 위치를 화면 한 번에 다 알면 게임이 시시해집니다. Dogkov Arena는 플레이어 전방 90° 시야 원뿔만 보여주고 나머지는 검은 안개로 가립니다. 라이트맵·셰이더 없이 Canvas 2D만으로 매 프레임 다시 그리는 방법.
알고리즘 개요
- 별도의 fog canvas에 반투명 검은색 채우기
destination-out합성 모드로 전환 — 이후 그리는 것은 "지움"- 플레이어 위치에서 fov/2 ~ fov/2 사이 N개 ray 발사, 벽 충돌까지의 polygon을 fill
- 가까운 거리 원(80px)도 추가로 fill (피격 인지용)
- fog canvas를 메인 canvas 위에 덮음
결과: 시야 안에 있는 영역은 fog가 "구멍 뚫린" 상태가 되어 아래 메인 캔버스가 비치고, 시야 밖은 검은색 그대로.
핵심 코드
const FOV_RADIANS = 1.6; // ~92°
const FOV_RAYS = 120;
const FOV_RANGE = 1800;
const FOV_NEAR_RADIUS = 90;
function drawFog(me) {
// 1) 안개 채우기
fogCtx.fillStyle = 'rgba(0,0,0,0.92)';
fogCtx.fillRect(0, 0, W, H);
// 2) 월드 좌표계로 (메인 ctx와 동일 변환)
fogCtx.translate(W/2, H/2);
fogCtx.scale(zoom, zoom);
fogCtx.translate(-cam.x, -cam.y);
// 3) 시야 polygon "구멍"
fogCtx.globalCompositeOperation = 'destination-out';
fogCtx.beginPath();
fogCtx.moveTo(me.x, me.y);
for (let i = 0; i <= FOV_RAYS; i++) {
const a = me.angle - FOV_RADIANS/2 + (FOV_RADIANS/FOV_RAYS) * i;
let ex = me.x + Math.cos(a) * FOV_RANGE;
let ey = me.y + Math.sin(a) * FOV_RANGE;
// 벽 충돌까지 자르기
let bestT = 1;
for (const w of walls) {
const hit = rayAABB(me.x, me.y, ex, ey, w);
if (hit && hit.t < bestT) {
bestT = hit.t; ex = hit.x; ey = hit.y;
}
}
fogCtx.lineTo(ex, ey);
}
fogCtx.lineTo(me.x, me.y);
fogCtx.fill();
// 4) 근거리 원
fogCtx.beginPath();
fogCtx.arc(me.x, me.y, FOV_NEAR_RADIUS, 0, Math.PI*2);
fogCtx.fill();
// 5) 메인 캔버스에 덮기
ctx.drawImage(fogCanvas, 0, 0);
}
Ray vs AABB 충돌
벽은 모두 축에 정렬된 AABB(axis-aligned bounding box). Ray vs AABB는 slab 메소드:
function rayAABB(x0, y0, x1, y1, rect) {
const dx = x1 - x0, dy = y1 - y0;
let tmin = 0, tmax = 1;
// x slab
if (Math.abs(dx) >= 1e-9) {
const t1 = (rect.x - x0) / dx;
const t2 = (rect.x + rect.w - x0) / dx;
tmin = Math.max(tmin, Math.min(t1, t2));
tmax = Math.min(tmax, Math.max(t1, t2));
if (tmin > tmax) return null;
} else if (x0 < rect.x || x0 > rect.x + rect.w) return null;
// (y slab 동일)
...
return { x: x0 + dx*tmin, y: y0 + dy*tmin, t: tmin };
}
왜 120 ray인가
각도 해상도 = 92° / 120 = 0.77°. 충분히 부드럽고, 벽 경계가 들쭉날쭉하지 않습니다. 60개면 좀 거칠고, 240개면 CPU 부담 (특히 벽이 30개 있으면 120*30=3600 검사/프레임). 120이 sweet spot.
최적화 한 가지
벽 멀리 있으면 검사 스킵:
if (Math.abs(w.x - me.x) > FOV_RANGE && Math.abs(w.y - me.y) > FOV_RANGE) continue;
맵 끝에 있는 벽들은 시야 안에 절대 안 들어오므로 ray 검사 자체를 스킵. 8% 정도 빨라집니다.
본인 화면 가운데 = 본인 위치
카메라가 플레이어 따라가니까 fog 마스크의 polygon은 항상 화면 중앙에서 시작. 시각적으로 손전등 같은 느낌이 납니다.