최근에 지구를 3D로 그리고, 그 위에 내가 다녀온 곳들을 핀으로 찍어서 여행 사진을 보여줄 수 있는 사이트를 만드는 중이다.
이 프로젝트에서 Three.js의 리액트용 라이브러리인 react-three/fiber 라이브러리를 사용하여 3D 지구를 그린 방법을 소개해보고자 한다.
⚠️ Three.js를 처음 다뤄본 터라 게시물에 부정확한 내용이 있을 수 있습니다.
Three.js로 그린 3D 지구
시작하기에 앞서, 리액트가 설치되어 있어야 하며 추가로 다음 두 패키지를 설치해준다.
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>
우리가 그릴 지구는 크게 다음 두 가지 요소로 이루어져있다.
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
좌표들을 모두 변환해준후, 좌표들을 서로 이어서 해안선을 렌더링해보자.
line
은 gl.LINE_STRIP
을 사용하는데 gl.LINE_STRIP
은 다음 vertex를 잇는 직선을 그린다. 반면 lineSegments
는 gl.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>
);
}
<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
지구를 그려보자
지금은 스크롤에 따라 카메라를 이동시키는 작업까지 완료했다 😀
흥미로운 내용 공유해주셔서 감사합니다!