반응형

개념 설명: 3D → 2D 투영의 기본 원리

 
3D에 있는 한 점을 2D 화면에 찍는 것을 투영이라고 합니다. 투영을 쉽게 이해하려면, 손전등과 그림자 비유를 떠올릴 수 있습니다. 벽을 화면이라고 생각하고, 3D 물체에 빛을 비춰 벽에 드리운 그림자가 바로 그 물체의 투영입니다. 이때 손전등을 물체에 바짝 가까이 대면 그림자가 물체 크기와 다르게 크게 또는 작게 일그러져 보이는데, 이것이 원근 투영과 비슷합니다. 반대로 태양빛처럼 아주 멀리서 평행하게 오는 빛을 생각해보면, 물체의 그림자는 물체의 크기와 동일한 비율로 찍히게 됩니다. 이처럼 빛이 평행하게 온다면 물체까지의 거리에 상관없이 그림자 크기가 변하지 않는데, 이것이 정투영의 원리입니다.
 
컴퓨터 그래픽스에서 정투영 투영은 실제로 아주 간단한 수학적 변환입니다. 3차원 좌표 (X, Y, Z)가 주어지면, 그대로 X, Y 좌표만 가져오고 Z 좌표는 버립니다. 이렇게 X와 Y를 사용해 평면에 찍으면 3D 점을 2D 화면에 옮길 수 있습니다.
(다른 방법으로는 원근 투영이 있습니다)
 
예를 들어, 높이가 다른 두 사람이 있다고 해도 정투영으로 보면 둘 다 같은 키로 보이겠지만, 원근 투영으로 보면 가까이 선 사람은 크게, 멀리 선 사람은 작게 표현되는 차이가 생기는 것입니다.

 정투영이란 무엇인가?

 
정투영(Orthographic Projection, 직교 투영이라고도 합니다)은 3D 공간에 있는 물체를 2D 화면에 그릴 때, 거리와 상관없이 똑같은 비율로 투영하는 방법입니다. 다시 말해, 가까이 있는 물체나 멀리 있는 물체나 크기 변화 없이 동일한 비율로 그려집니다
 
반대로 현실 세계에서 우리가 보는 모습이나 3D 게임의 일반 카메라 시점은 원근 투영(Perspective Projection)이라고 하는데, 이는 가까운 것은 크게, 먼 것은 작게 보이도록 그리는 방식입니다. 원근 투영 덕분에 우리는 깊이감과 거리감을 느낄 수 있지만, 치수를 정확하게 재거나 평면도면을 그릴 때는 오히려 불편할 수 있습니다.
 

정투영과 원근 투영 차이

 
위 그림은 원근 투영정투영의 차이를 간단히 보여줍니다. 왼쪽은 원근 투영으로 본 큐브(정육면체)이고, 오른쪽은 정투영으로 본 모습입니다. 왼쪽 그림에서는 앞쪽 면의 빨간 테두리가 크게 보이고, 뒤쪽 면의 파란 테두리는 멀어지면서 작아져 내부의 작은 사각형처럼 보입니다.
 
회색으로 그린 모서리 선들도 멀어지면서 서로 가까워져, 마치 선들이 한 점으로 모이는 듯한 원근감이 나타납니다. 반면 오른쪽 정투영 그림에서는 앞면(빨간색)과 뒷면(파란색)의 크기가 동일하게 그려져 있습니다. 모든 모서리 선이 서로 평행하게 표시되며, 거리에 따른 크기 왜곡이 없어서 뒷면이 앞면에 정확히 겹쳐 보입니다. 이처럼 정투영은 거리와 무관하게 실제 크기를 그대로 보여주기 때문에, 도면 작업이나 멀리 있는 객체까지 정확한 치수로 표현해야 하는 경우에 유용합니다.
 
 
이제 실제 코드에서 이 단계들이 어떻게 구현되는지 projectOrtho 함수를 통해 알아보겠습니다.
우선, 원리를 간단히 설명해보자면 아래와 같습니다.
 

  1. 피벗 이동: 3D 점을 기준점(pivot)이 중심이 되도록 좌표를 이동합니다. (예: 카메라가 보는 중심으로 좌표계 원점을 맞춤)
  2. 회전 변환: X축, Y축, Z축 순서로 3D 점을 회전시킵니다. (예: 장면을 위아래로 보기 위해 X축 회전, 옆으로 보기 위해 Y축 회전 등)
  3. 투영: 회전된 3D 좌표에서 Z 값을 버리고 X, Y만 남겨 2D 평면에 투영합니다. (정투영이라서 원근 왜곡 없음)
  4. 배율 조정: 3D 단위를 픽셀 크기에 맞게 확대하거나 축소합니다. (예: 1미터를 100픽셀로 보이게 스케일링)
  5. 오프셋 이동: 계산된 2D 좌표에 화면상의 위치 offset을 더해 캔버스 좌표로 변환합니다. (예: 캔버스 중심이나 좌상단 기준으로 옮김)

 
실제 코드에서 이 단계들이 어떻게 구현되는지 projectOrtho 함수를 통해 알아보겠습니다.
 

코드 분석: projectOrtho 함수 단계별 설명

 
이제 provided된 projectOrtho 함수의 내부를 살펴보며, 앞에서 설명한 정투영 변환의 각 단계를 코드로 확인해봅시다. 이 함수는 3D 좌표를 받아 정투영을 적용한 2D 화면 좌표를 계산해주는데, React + TypeScript로 Canvas에 그릴 때 사용할 핵심 로직입니다. 복잡해 보이지만, 우리가 방금 이해한 다섯 단계를 차례로 수행하고 있을 뿐입니다. 코드와 함께 하나씩 살펴볼까요
 

// 3D 점을 정투영하여 2D 화면 좌표로 변환하는 함수
function projectOrtho(
  point: { x: number, y: number, z: number }, 
  pivot: { x: number, y: number, z: number }, 
  rotation: { x: number, y: number, z: number },  // 각도 값(라디안)
  scale: number, 
  offset: { x: number, y: number } 
) {
  // 1. 피벗 기준으로 좌표 이동 (pivot을 원점으로 옮기기)
  let x = point.x - pivot.x;
  let y = point.y - pivot.y;
  let z = point.z - pivot.z;

  // 2. X축 회전: 위아래 방향 회전 (pitch)
  const cosX = Math.cos(rotation.x);
  const sinX = Math.sin(rotation.x);
  let y1 = y * cosX - z * sinX;
  let z1 = y * sinX + z * cosX;
  y = y1;
  z = z1;

  // 3. Y축 회전: 좌우 방향 회전 (yaw)
  const cosY = Math.cos(rotation.y);
  const sinY = Math.sin(rotation.y);
  let x2 = x * cosY + z * sinY;
  let z2 = -x * sinY + z * cosY;
  x = x2;
  z = z2;

  // 4. Z축 회전: 평면 회전 (roll)
  const cosZ = Math.cos(rotation.z);
  const sinZ = Math.sin(rotation.z);
  let x3 = x * cosZ - y * sinZ;
  let y3 = x * sinZ + y * cosZ;
  x = x3;
  y = y3;

  // 이제 정투영이므로 z는 사용하지 않습니다 (Z값 버림)

  // 5. 스케일 조정: 3D 단위를 화면 확대배율에 맞춤
  x = x * scale;
  y = y * scale;

  // 6. 오프셋 적용: 캔버스 좌표계로 이동 (예: 캔버스 중심을 (0,0)→(offset.x, offset.y)로)
  const screenX = x + offset.x;
  const screenY = y + offset.y;

  // 계산된 2D 화면 좌표 반환
  return { x: screenX, y: screenY };
}

 
 
 

  • 피벗 이동 전후: 3D 공간에 좌표축과 점이 있다고 생각해 봅니다. 피벗 이동을 하기 전에는 점이 원점에서 point.x, point.y, point.z만큼 떨어진 곳에 있습니다. 피벗을 원점으로 맞추고 나면, 이제 그 점은 새로운 좌표계에서 (x - pivot.x, y - pivot.y, z - pivot.z) 위치로 보이게 됩니다. 쉽게 말하면, 기준점을 가운데로 옮겼더니 점의 좌표가 바뀌었다고 이해하면 됩니다. 하지만 실제 공간에서 점의 물리적 위치는 변하지 않았고, 우리가 좌표만 옮겨서 바라보고 있는 것입니다.

 

  • 축별 회전 효과: 3차원 회전 행렬을 이용합니다. 
Rotation Transformation

 
X축, Y축, Z축으로 차례로 회전하면, 3D 점은 공간에서 이리저리 자리를 바꿉니다. X축으로 회전하면 점이 위아래로 이동하는 것처럼 보이고, Y축으로 회전하면 좌우로, Z축으로 회전하면 화면 평면상에서 회전합니다. 최종적으로 세 번의 회전을 모두 마치면, 처음의 3D 점은 우리가 정한 각도에서 바라본 위치로 옮겨져 있게 됩니다. 여러 회전이 한꺼번에 적용되었기 때문에, 점의 새 좌표 (x3, y3, z3)는 원래 좌표와 많이 달라졌겠지만, 이 좌표는 우리가 장면을 해당 각도로 본 경우에 그 점이 어디 있는지를 나타낸다고 생각하면 됩니다.
 

  • 투영 및 스케일: 이제 3D 좌표의 z는 버리고 (x3, y3)만 남깁니다. 이 단계에서 이미 2D 투영이 이루어졌다고 볼 수 있습니다. 남은 (x3, y3)는 아직 수치적으로는 작은 값일 수 있는데, 스케일을 곱해서 화면에 보일 크기로 확대합니다. 만약 점들이 서로 가까이 모여 있었다면 스케일을 키워서 벌려주고, 너무 넓게 퍼져 있었다면 스케일을 줄여서 화면에 잘 들어오도록 할 수 있습니다.

 

  • 오프셋 적용: 마지막으로 offset을 더해 캔버스 좌표로 변환하면, 이제 진짜 화면 픽셀 좌표가 나옵니다. 이 좌표를 사용해서 Canvas에 점을 찍으면, 우리가 3D에서 지정했던 그 점이 2D 화면상의 정확한 위치에 표시됩니다.

 
 
요약하면, projectOrtho 함수는 (피벗 이동) → (회전) → (투영) → (스케일) → (오프셋) 순서로 3D 좌표를 변환하여 2D 화면 좌표를 반환합니다. 
 
 
이제 projectOrtho 함수를 이용해서 실제 HTML5 Canvas에 3D 점들을 찍어보는 간단한 React 컴포넌트를 만들어보겠습니다. React와 TypeScript를 사용하므로, 함수형 컴포넌트와 훅(hook)을 활용해볼게요. 예제에서는 정육면체 모서리 8개 점을 3D 좌표로 정의하고, 이를 정투영으로 화면에 표시해보겠습니다.
 

render({ ctx, engine, id }) {
      const cfg = shapePrefabs.box3D as Shape3DConfig;
      const pos = engine.position3DStore.get(id)!; // { x, y, z }
      const rotation = engine.rotationStore.get(id)!; // { x, y, z }

      const perspective = engine.perspective3DManager.getRotation3D();
      const cameraDistance = engine.perspective3DManager.getCameraDistance();

      const camera = engine.perspective3DManager.getCamera();

      const { width, height, depth } = cfg.size as Shape3DConfig["size"];

      // 8개 꼭짓점 정의 (center 기준)
      const verts = [
        { x: -width / 2, y: -height / 2, z: -depth / 2 },
        { x: width / 2, y: -height / 2, z: -depth / 2 },
        { x: width / 2, y: height / 2, z: -depth / 2 },
        { x: -width / 2, y: height / 2, z: -depth / 2 },
        { x: -width / 2, y: -height / 2, z: depth / 2 },
        { x: width / 2, y: -height / 2, z: depth / 2 },
        { x: width / 2, y: height / 2, z: depth / 2 },
        { x: -width / 2, y: height / 2, z: depth / 2 },
      ];


      // 월드 좌표로 이동 및 투영
      const projected = verts.map((v) => {
        const point = { x: v.x + pos.x - camera.x, y: v.y + pos.y - camera.y, z: v.z + pos.z - camera.z };

        return projectOrtho({
          canvas: ctx.canvas,
          point: point,
          pivot: { x: pos.x - camera.x, y: pos.y - camera.y, z: pos.z - camera.z },
          rotation: {
            ...rotation,
            x: rotation.x + perspective.x,
            y: rotation.y + perspective.y,
            z: rotation.z + perspective.z,
          },
          offset: { x: 0, y: 0 },
          cameraDistance: cameraDistance,
        });
      });

      // 연결할 에지(12개)
      const edges = [
        [0, 1],
        [1, 2],
        [2, 3],
        [3, 0],
        [4, 5],
        [5, 6],
        [6, 7],
        [7, 4],
        [0, 4],
        [1, 5],
        [2, 6],
        [3, 7],
      ];

      for (let k = 0; k < edges.length; k++) {
        const [i, j] = edges[k];
        const p = projected[i];
        const q = projected[j];

        // HSL로 무지갯빛 색상 생성
        const hue = (k / edges.length) * 360;
        ctx.strokeStyle = `hsl(${hue}, 100%, 50%)`;

        // 선 하나만 그리기
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(q.x, q.y);
        ctx.stroke();
      }

      ctx.stroke();
    },

 
위 코드에 추상화된 클래스 or 함수가 있어서 이해하기 어려울수도 있는데,

핵심적인 부분은 projectOrtho 함수로 3D를 2D로 변환하는 부분이 핵심이고
나머지는 사각형 정보를 담는 헬퍼 함수라고 보시면 됩니다.  (x,y,z 좌표 정보나 카메라 위치, 줌 인 & 아웃 정보 등)
 
 

canvas에 정투영한 3D 정사각형

 
 
 
 
 
reference
 
https://cynthis-programming-life.tistory.com/entry/3%EC%B0%A8%EC%9B%90-%ED%9A%8C%EC%A0%84-%ED%96%89%EB%A0%AC-%EA%B5%AC%ED%95%98%EA%B8%B0-by-%EC%98%A4%EC%9D%BC%EB%9F%AC%EA%B0%81-Input
 

반응형

+ Recent posts