← 블로그 목록 2026-05-19

FOV 시야 안개: 120 Ray Raycasting

탑다운 슈터에서 "전체 맵이 다 보임"은 너무 쉽습니다. 적의 위치를 화면 한 번에 다 알면 게임이 시시해집니다. Dogkov Arena는 플레이어 전방 90° 시야 원뿔만 보여주고 나머지는 검은 안개로 가립니다. 라이트맵·셰이더 없이 Canvas 2D만으로 매 프레임 다시 그리는 방법.

알고리즘 개요

  1. 별도의 fog canvas에 반투명 검은색 채우기
  2. destination-out 합성 모드로 전환 — 이후 그리는 것은 "지움"
  3. 플레이어 위치에서 fov/2 ~ fov/2 사이 N개 ray 발사, 벽 충돌까지의 polygon을 fill
  4. 가까운 거리 원(80px)도 추가로 fill (피격 인지용)
  5. 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은 항상 화면 중앙에서 시작. 시각적으로 손전등 같은 느낌이 납니다.

← Node Listen Server Quake식 무기 픽업 →