Three.js를 사용하여 3D 지구 그리기

dahyeon·2023년 5월 29일
2

최근에 지구를 3D로 그리고, 그 위에 내가 다녀온 곳들을 핀으로 찍어서 여행 사진을 보여줄 수 있는 사이트를 만드는 중이다.
이 프로젝트에서 Three.js의 리액트용 라이브러리인 react-three/fiber 라이브러리를 사용하여 3D 지구를 그린 방법을 소개해보고자 한다.
⚠️ Three.js를 처음 다뤄본 터라 게시물에 부정확한 내용이 있을 수 있습니다.

Three.js로 그린 3D 지구

시작하기에 앞서, 리액트가 설치되어 있어야 하며 추가로 다음 두 패키지를 설치해준다.

  • react-three/fiber
    - react-three/fiber 라이브러리는 Three.js의 React Renderer 이다. 관련 문서는 이 링크에서 확인할 수 있다.
  • three

1. Canvas 컴포넌트 만들기

  • 렌더링할 컴포넌트 내부에 Canvas 컴포넌트를 넣어준다.
  • Canvas 내부에 객체, 조명 등을 배치할 수 있고, 배치된 것들이 렌더링된다.
import { Canvas } from "@react-three/fiber";

...

	<Canvas camera={{ fov: 45, near: 0.1, far: 1000, position: [0, 0, 4] }}>
       <pointLight position={[0, 0, 8]} />
       <Earth />
    </Canvas>
  • 이제 Earth 컴포넌트 안에 지구를 그려볼 것이다.

2. Earth 컴포넌트 만들기

우리가 그릴 지구는 크게 다음 두 가지 요소로 이루어져있다.

  • 해안선
const Earth = ({ radius }: { radius: number }) => {
    
    return (
      <>
        <mesh ref={ref}>
          <sphereGeometry args={[radius, 32, 32]} />
          <CoastLine radius={radius} />
        </mesh>
      </>
    );
}

구 그리기

구는 위 코드에서 볼 수 있듯이 손쉽게 그릴 수 있다.
SphereGeometry를 사용하면 되는데(링크) args로 들어가는 배열 중 첫 번째 요소는 구의 반지름, 두 번째 요소는 widthSegments, 세 번째 요소는 heightSegments이다. 이 segments는 구를 얼마나 부드럽게 그릴 것인지와 연관되어 있다. 기본값은 32이다.
8로 설정할 시 아래와 같이 각진 형태의 입체도형이 나오게 된다.

해안선 그리기

해안선 그리는 과정이 좀 까다롭지만, 차근차근 따라해보면 그릴 수 있다.

1. 해안선 데이터 다운로드
아래 사이트에서 구할 수 있는데, Medium Scale Data - Physical - Coastline 에 해당하는 데이터를 다운받았다.
https://www.naturalearthdata.com/downloads/

2. 데이터를 JSON 형식으로 변환
다운로드 된 압축 파일을 풀면 여러 확장자를 가진 데이터들이 나온다. 좌표를 그리기 위해서는 JSON 형태의 데이터가 필요했기 때문에 데이터 변환 과정을 거쳤다. 아래 사이트에서 .shp 확장자를 가진 파일을 geojson 형식으로 변환할 수 있다.
MyGeodata Cloud - GIS Data Warehouse, Converter, Maps

변환한 파일을 프로젝트 폴더 내에 넣어준다.
JSON 데이터를 얻기 위해서 확장자를 .geojson -> json으로 바꿔주었다. (좋은 방법인지는 잘 모르겠지만 잘 작동한다.)

3. 해안선 데이터 normalize하기

우선 데이터의 생김새를 살펴보면, features가 배열이고 각 배열 요소는 객체이다.
각 배열 요소 객체 내의 geometry - coordinates 안에 좌표들이 들어있다.

"features": [
    {
      "type": "Feature",
      "properties": {
        "scalerank": 0,
        "featurecla": "Coastline",
        "min_zoom": 1.5
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [180.0, -16.152929687500006],
          [179.84814453125, -16.214257812500009],
          [179.788867187500045, -16.221484375],
  		  ...

처음에는 이 coordinates 데이터만 추출해서 내부 좌표들을 이어주는 방식으로 하나의 대륙(단위)를 완성하는 방식으로 그렸다. 근데 일부 대륙이 그려지지 않는 문제점이 발생했다.
그 이유는 일부 coordinates의 경우 아래와 같이 배열로 한 번 더 감싸진 형태를 띄고 있었기 때문이다.

        "coordinates": [
          [
          	[180.0, -16.152929687500006], 
          	[179.84814453125, -16.214257812500009]],
          ...

따라서, 만약 coordinates 배열 내 요소 중 좌표들의 배열이 있다면 이를 한 단계 flatten 해주는 normalize 과정이 필요하다.

coordinates 내의 요소가 좌표 형태인지 확인하고, 좌표 형태가 아니라 좌표들의 배열이라면 배열 내의 좌표들을 normalizedCoordinates 리스트에 넣어준다. 즉, 한 단계 flatten 해준다.
만약 좌표 형태라면 그대로 normalizedCoordinates 리스트에 넣어준다.

코드는 다음과 같다.

import coastLineData from "@/assets/coastline.json";

const isCoordinates = (element: any): element is [number, number] => {
  return (
    Array.isArray(element) &&
    element.length === 2 &&
    element.every((el) => typeof el === "number")
  );
};

const normalizeCoordinates = (coordinates: Array<[number, number]>) => {
  const normalizedCoordinates: Array<[number, number]> = [];

  coordinates.forEach((coordinate) => {
    let isAllNumber = true;
    for (let i = 0; i < coordinate.length; i++) {
      const coord: [number, number] | number = coordinate[i];
      if (isCoordinates(coord)) {
        normalizedCoordinates.push(coord);
        isAllNumber = false;
      }
    }
    if (isAllNumber && isCoordinates(coordinate))
      normalizedCoordinates.push(coordinate);
  });

  return normalizedCoordinates;
};

export const normalizedCoordinatesList = coastLineData.features.map((data) =>
  normalizeCoordinates(data.geometry.coordinates as Array<[number, number]>)
);

4. (대망의) 해안선 그리기

(내가 이해한 바로는) 각 coordinatesList 내의 요소들은 좌표들의 배열이며, 각 좌표들을 이어서 그리면 하나의 대륙 또는 섬이 나올 것이다.
따라서 각 단위를 그려주는 컴포넌트(SingleCoastLine)를 만들고, 우리가 얻은 normalizedCoordinatesList에 map을 돌려 모든 단위들을 그려주면 된다.

import SingleCoastLine from "./singleCoastLine";
import { normalizedCoordinatesList } from "@/models/normalizedCoordinatesList";

export default function CoastLine({ radius }: { radius: number }) {
  const coordinatesList = normalizedCoordinatesList;

  return (
    <>
      {coordinatesList.map((coordinates, index) => {
        return (
          <SingleCoastLine
            coordinates={coordinates}
            material={{ color: "black" }}
            radius={radius}
            key={index}
          />
        );
      })}
    </>
  );
}

그럼 이제 하나의 대륙 단위의 해안선을 그리는 SingleCoastLine 컴포넌트를 작성해보자.
이를 위해서는 우선 [경도, 위도] 형태의 각 좌표들을 실제 좌표로 변환해주는 작업이 필요하다.

코드는 다음과 같다. 여기서 radius는 구의 반지름과 동일해야 하며, 구의 중심이 (0, 0, 0)에 있을 때의 좌표로 변환해준다.

const convertCoordinateToVector = (
  [longitude, latitude]: [number, number],
  radius: number
) => {
  const lambda = (longitude * Math.PI) / 180;
  const phi = (latitude * Math.PI) / 180;

  return [
    radius * Math.cos(phi) * Math.cos(lambda),
    radius * Math.sin(phi),
    -radius * Math.cos(phi) * Math.sin(lambda),
  ];
};

위 함수는 아래 링크 참고해서 작성하였다.
GeoJSON in Three.js

좌표들을 모두 변환해준후, 좌표들을 서로 이어서 해안선을 렌더링해보자.

  • 우선 좌표들을 변환해서 Three의 Vector3 객체로 만들어주어야 한다. 이는 아래 코드에서 vertex 함수가 담당한다.
  • 좌표를 잇는 선을 렌더링하기 위해 BufferGeometry를 생성해준다. BufferGeometry에 대한 설명은 이 링크에 나와 있다. 이는 wireframe 함수에서 담당한다. points 배열을 만들고, 그 안에 좌표(Vector3 객체)를 넣어준다. geometry의 속성으로 해당 points를 설정해준다.
  • 마지막으로 line의 edgesGeometry로 생성한 geometry를 설정해준다. Three에서 linegl.LINE_STRIP을 사용하는데 gl.LINE_STRIP은 다음 vertex를 잇는 직선을 그린다. 반면 lineSegmentsgl.LINES를 사용하고 이는 두 vertex 쌍 사이를 잇는 직선을 그린다고 한다. 그래서 그런지 lineSegments를 사용하면 아래와 같이 해안선이 스티치 형태로 나온다.

어쨌든간 line 컴포넌트를 사용해서 해안선을 그려주면 완성이다!
코드는 다음과 같다.

import * as THREE from "three";
import { convertCoordinateToVector } from "@/utils/convertCoordinateToVector";

interface SingleCoastLineProps {
  coordinates: Array<[number, number]>;
  radius: number;
  material: any;
}

export default function SingleCoastLine({
  coordinates,
  radius,
  material,
}: SingleCoastLineProps) {
  const vertex = (location: [number, number], radius: number) => {
    const vector = convertCoordinateToVector(location, radius);

    return new THREE.Vector3(...vector);
  };

  const wireframe = (coordinates: Array<[number, number]>, radius: number) => {
    const geometry = new THREE.BufferGeometry();
    const points = [] as Array<THREE.Vector3>;

    for (let i = 0; i < coordinates.length; i++) {
      const vector3 = vertex(coordinates[i], radius);
      points.push(vector3);
    }

    geometry.setFromPoints(points);

    return geometry;
  };

  return (
    <line>
      <edgesGeometry
        attach="geometry"
        args={[wireframe(coordinates, radius)]}
      />
      <lineBasicMaterial {...material} />
    </line>
  );
}

지구를 내 맘대로 돌리고 싶다면?

  • react-three/drei 라이브러리를 설치한다.
  • OrbitControls를 import 해서 Canvas 내에 넣어준다.
      <CanvasContainer>
        <Canvas camera={{ fov: 45, near: 0.1, far: 1000, position: [0, 0, 4] }}>
          <pointLight position={[0, 0, 8]} />
          <EarthCanvas />
          <OrbitControls />
        </Canvas>


참고자료

Downloads
MyGeodata Cloud - GIS Data Warehouse, Converter, Maps
GeoJSON in Three.js
지구를 그려보자


지금은 스크롤에 따라 카메라를 이동시키는 작업까지 완료했다 😀

profile
https://github.com/dahyeon405

1개의 댓글

comment-user-thumbnail
2024년 6월 26일

흥미로운 내용 공유해주셔서 감사합니다!

답글 달기