이번 글에서는 프로젝트의 시작인 은하 만들기를 시도한 과정을 설명하겠다. 이 과정은 두 명의 팀원 분들과의 페어프로그래밍으로 진행했다.
팀원들의 블로그 링크
https://velog.io/@greencloud
https://velog.io/@200tiger
R3F 라이브러리를 활용해서 위처럼 멋진 3D 은하를 만들어보자. 이 글에서는 R3F 설치를 비롯한 너무 기초적인 설명은 제외하겠다. 구글에 좋은 자료들이 많으니 따라서 설치하고 간단하게 배워보자.
본격적으로 은하를 만들기 전에 기본적인 세팅을 진행하고, 필요한 수학적 지식을 이야기해보자. 필자도 수학을 잘하진 않으니 너무 걱정할 필요는 없다.
먼저 R3F에서 사용할 수 있는 유용한 함수들과 컴포넌트들이 존재하는 drei 패키지를 설치해주자.
yarn add @react-three/drei
이후 Vite 또는 CRA 등 원하는 방식으로 프로젝트를 생성하고, 필요없는 파일들을 모두 정리한 뒤 App.tsx
를 다음과 같이 작성하자.
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
function App() {
return (
<div style={{ width: "100vw", height: "100vh" }}>
<Canvas
camera={{
position: [10000, 10000, 10000],
rotation: [-0.5, 0, 0],
far: 100000,
}}
>
<color attach="background" args={["#000"]} />
<ambientLight color={"#fff"} intensity={5} />
<axesHelper args={[20000]} />
<OrbitControls />
<mesh position={[0, 0, 0]}>
<boxGeometry args={[100, 100, 100]} />
<meshStandardMaterial color={"#f00"} />
</mesh>
</Canvas>
</div>
);
}
export default App;
개발 서버를 실행했을 때 다음과 같은 결과가 나오면 성공이다. 마우스를 이용해 화면을 돌리거나 확대/축소해보자.
간단한 설명을 하자면, Canvas
컴포넌트로 3D 오브젝트들을 렌더링할 공간을 만들어주고, color
태그를 활용해 배경색을 지정, ambientLight
로 광원을 지정해주었다.
axesHelper
는 화면에 보이는 빨간색 x축, 초록색 y축, 파란색 z축을 표시해준다. OrbitControls
를 포함시켜주는 것으로 드래그 및 마우스 휠로 시점 변경, 확대/축소를 할 수 있다.
R3F에서 오브젝트를 생성하는 방법
R3F에서는 geometry
를 통해 오브젝트의 모양을, material
을 통해 오브젝트의 재질을 정의한다. 정말 다양한 geometry
와 material
들이 존재하므로 원하는 것을 선택해 mesh
로 감싸주고 Canvas
안에 위치시키면 오브젝트가 생성된다. 각각의 자세한 모양 및 재질, 속성들에 대해서는 구글링이나 R3F 공식문서, drei 공식문서를 참고하자.
편한 개발을 위해 정수 난수 생성 함수, 실수 난수 생성 함수가 필요하다. utils/random.ts
파일을 생성해 두 함수를 넣어두고 필요할 때 import 해오자. 정수 난수 생성 함수와 실수 난수 생성 함수는 간단하게 구현하였으므로 추가적인 설명은 하지 않겠다.
export function getRandomInt(min: number, max: number) {
const minCeil = Math.ceil(min);
const maxFloor = Math.floor(max);
return Math.floor(Math.random() * (maxFloor - minCeil)) + minCeil;
}
export function getRandomFloat(min: number, max: number) {
return Math.random() * (max - min) + min;
}
우선은 이정도만 준비해두고 이제 구현을 시작해보자.
배경별과 은하 구성에 사용될 별 오브젝트를 만들어보자. 코드는 다음과 같다.
import { getRandomInt } from '@utils/random';
import * as THREE from 'three';
interface PropsType {
position: THREE.Vector3;
size: number;
}
export default function Star({ position, size }: PropsType) {
const COLOR = ['#88beff', 'white', '#f9d397', '#fd6b6b', '#ffffac'];
const colorIndex = getRandomInt(0, COLOR.length);
return (
<mesh
position={position}
>
<sphereGeometry args={[size, 32, 16]} />
<meshStandardMaterial color={COLOR[colorIndex]} />
</mesh>
);
}
별의 모양은 구이므로 sphereGeometry
를 선택했고, 재질은 기본적인 재질인 meshStandardMaterial
을 이용했다. 컴포넌트를 이용할 때 position과 size에 대한 정보를 props로 받아 위치와 크기를 지정한다. 색깔은 아무 색깔이나 임의로 설정해주었다. 원하는 색깔로 지정해보자.
이렇게 끝낼 수도 있지만, 사용자가 마우스 휠로 화면을 축소했을때, 별의 크기가 너무 줄어들면 별로 예쁘지 않다. 따라서 줄어드는 최소 크기를 설정하는 것이 좋은데, R3F의 useRef
와 useFrame
훅을 이용하면 구현할 수 있다. 또한 별이 가만히 있으면 심심하므로 공전도 시켜보자.
useFrame
훅에 대해 짧게 설명하자면 매 프레임마다 수행될 콜백함수 하나를 인자로 받고, 해당 콜백함수에는 현재 어플리케이션의 상태를 나타내는 state
객체와 직전 프레임과 현재 프레임 사이의 간격인 delta
가 주어진다. 이를 활용해 오브젝트들의 상태를 업데이트하거나 애니메이션을 구현할 수 있다.
먼저 useRef
훅을 통해 메쉬에 대한 접근을 얻어오자.
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
...
const meshRef = useRef<THREE.Mesh>(null!);
...
return (
<mesh
ref={meshRef}
position={position}
>
...
이제 meshRef
를 통해 메쉬에 접근할 수 있다. 이를 활용해 useFrame 훅을 구성해보자.
const Y_AXIS = new THREE.Vector3(0, 1, 0);
const DIST_LIMIT = 10000;
useFrame((state, delta) => {
const pos = meshRef.current.position.applyAxisAngle(Y_AXIS, delta / 100);
const dist = state.camera.position.distanceTo(meshRef.current.position);
if (dist > DIST_LIMIT)
meshRef.current.scale.set(
dist / DIST_LIMIT,
dist / DIST_LIMIT,
dist / DIST_LIMIT,
);
meshRef.current.position.x = pos.x;
meshRef.current.position.y = pos.y;
meshRef.current.position.z = pos.z;
});
먼저 meshRef.current.position.applyAxisAngle(Y_AXIS, delta / 100)
을 통해 공전을 구현해줬다. meshRef.current.position
을 통해 현재 메쉬의 위치 정보를 벡터의 형태로 가져오고, apllyAxisAngle
메소드를 통해 벡터를 회전시킬 수 있다. applyAxisAngle
메소드는 회전시킬 기준 벡터와 각도를 인자로 받는데, 자전을 구현하기 위해 기준 벡터는 Y축 ([0, 1, 0] 벡터)로 지정해줬고, 회전의 각도는 프레임의 간격 / 100 정도로 지정해줬다. 회전의 각도를 늘리면 빠르게 회전하고, 회전의 각도를 줄이면 느리게 회전하므로 원하는 값으로 조정 후 meshRef.current.position
의 x, y, z값을 계산된 pos의 값들로 변경해주자. meshRef.current.position
자체는 read-only 이므로 하나씩 넣어주어야한다.
다음으로 최소 크기를 제한하는 부분을 설명하자면, 간단히 DIST_LIMIT
으로 선언된 일정 범위만큼 카메라가 멀어지면 meshRef.current.scale
의 값을 비율만큼 조정해 더이상 작아지지 않게 구현해주었다. 카메라로부터 메쉬까지의 거리는 state.camera.position
을 통해 카메라의 위치에 접근할 수 있고, distanceTo
메서드를 통해 meshRef.current.position
까지의 거리를 얻어올 수 있다.
결과를 미리 조금 스포일러해보자면,
이것이 최소 크기를 제한하지 않았을 때의 은하 모습이고
이것이 최소 크기를 제한했을 때의 은하 모습이다. 멀리서 봤을 때 그 차이가 잘 드러난다.
최종적으로 Star 컴포넌트의 코드는 다음과 같다.
import { getRandomInt } from '@utils/random';
import * as THREE from 'three';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
interface PropsType {
position: THREE.Vector3;
size: number;
}
export default function Star({ position, size }: PropsType) {
const meshRef = useRef<THREE.Mesh>(null!);
const COLOR = ['#88beff', 'white', '#f9d397', '#fd6b6b', '#ffffac'];
const colorIndex = getRandomInt(0, COLOR.length);
const Y_AXIS = new THREE.Vector3(0, 1, 0);
const DIST_LIMIT = 10000;
useFrame((state, delta) => {
const pos = meshRef.current.position.applyAxisAngle(Y_AXIS, delta / 100);
const dist = state.camera.position.distanceTo(meshRef.current.position);
if (dist > DIST_LIMIT)
meshRef.current.scale.set(
dist / DIST_LIMIT,
dist / DIST_LIMIT,
dist / DIST_LIMIT,
);
meshRef.current.position.x = pos.x;
meshRef.current.position.y = pos.y;
meshRef.current.position.z = pos.z;
});
return (
<mesh
ref={meshRef}
position={position}
>
<sphereGeometry args={[size, 32, 16]} />
<meshStandardMaterial color={COLOR[colorIndex]} />
</mesh>
);
}
이제 별 컴포넌트가 준비되었으므로, 배경별부터 생성해보자. 배경별은 정말 간단하게 위에서 만든 별 컴포넌트를 우주공간에 랜덤하게 배치해주기만 하면된다.
배경별들을 생성하는 함수는 다음과 같다.
function genBackgroundStars() {
const stars = [];
for (let i = 0; i < 500; i++) {
const size = getRandomInt(15, 20);
const pos = new THREE.Vector3(
getRandomInt(-50000, 50000),
getRandomInt(-50000, 50000),
getRandomInt(-50000, 50000)
);
stars.push(<Star position={pos} size={size} />);
}
return stars;
}
함수의 내용 그대로 size
는 15~20중 랜덤으로, position
은 50000크기의 공간 상에 랜덤으로 배치되도록 해주었다. for문으로 500개의 별을 생성하고 하나의 배열로 리턴하는 모습이다.
리턴받은 배열을 App.tsx
의 캔버스에 위치시켜주자.
<Canvas
camera={{
position: [10000, 10000, 10000],
rotation: [-0.5, 0, 0],
far: 100000,
}}
>
<color attach="background" args={["#000"]} />
<ambientLight color={"#fff"} intensity={5} />
<axesHelper args={[20000]} />
<OrbitControls />
{genBackgroundStars()}
</Canvas>
실행시켜보면
배경별이 멋지게 나오는 모습을 확인할 수 있다.
이제 주인공인 은하를 만들어보자. 조금 어려울 수도 있지만 천천히 따라가면 할 만 하다.
은하는 중심에 별이 많이 존재하므로 은하를 구성하는 별의 위치를 특정하기 위해 정규분포를 활용해야하는데, 배운지 너무 오래되어서 기억이 잘 나지 않는 사람들을 위해(사실 필자다) 간단히 짚고 넘어가자.
이미지 출처 - Khan Academy
정규분포 또는 가우스 분포라고도 하는 이 확률분포는 평균을 기준으로 종모양으로 대칭이며, 평균값과 표준편차로 그 모양이 정해진다. 우리는 이 정규분포를 활용하여 랜덤한 값을 리턴해주는 gaussianRandom 함수를 구현해 별들의 위치를 계산할 것이다. 함수의 모습을 먼저 확인해보자.
export function gaussianRandom(mean: number = 0, deviation: number = 1) {
const u = 1 - Math.random();
const v = Math.random();
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
return mean + deviation * z;
}
평균값과 표준편차를 인자로 받아, 정규분포를 구성하고 해당 정규분포를 따르는 랜덤한 값을 리턴해주는 함수이다. 정규분포를 따르는 랜덤한 값이란, 난수가 해당 정규분포의 모양대로 평균값이 나올 확률이 가장 높고, 평균값을 기준으로 멀어진 값일 수록 나올 확률이 대칭적으로 점점 감소한다는 뜻이다.
함수의 자세한 수식은 Box muller
변환이라는 방식을 사용하는데, 가장 간단하게 정규분포 난수를 생성할 수 있는 방법이다.
이미지 출처 - 위키백과
이러한 수식이라고하는데, 수학 관련 포스트는 아니므로 자세한 것은 위키백과를 확인해보자. 우리에게 중요한 것은 이제 정규분포를 따르는 난수를 생성할 수 있다는 것이다. 위 함수를 utils/random.ts
파일에 위치시켜주자.
은하의 모양을 만들기 위해, 나선 좌표를 구하는 함수를 구현해야한다. 이 함수는 인자로 원래 x, y, z 좌표와 나선의 시작각도를 받아 꼬아진 나선에서의 좌표를 리턴할 것이다. 함수의 모습은 다음과 같다.
const SPIRAL = 3.5;
const ARM_X_DIST = 3000;
function spiral(x: number, y: number, z: number, offset: number) {
const r = Math.sqrt(x ** 2 + z ** 2);
let theta = offset;
theta += x > 0 ? Math.atan(z / x) : Math.atan(z / x) + Math.PI;
theta += (r / ARM_X_DIST) * SPIRAL;
return new THREE.Vector3(r * Math.cos(theta), y, r * Math.sin(theta));
}
어려워 보이지만 하나씩 천천히 살펴보자.
const r = Math.sqrt(x ** 2 + z ** 2);
이 라인은 원점으로부터 좌표의 xz평면상의 위치까지의 거리, 반지름 r을 거리공식
을 이용해 계산한 것이다.
이후 theta
를 이용해 나선의 꼬인 각도를 계산한다. 먼저 시작각도인 offset
을 이용해 초기화 해준다.
theta += x > 0 ? Math.atan(z / x) : Math.atan(z / x) + Math.PI;
이 라인은 각도를 보정하는 라인으로, Math.atan
을 이용해 좌표 (x, z)의 각도를 계산하여 나선 모양의 각도를 계산한다.
다음으로 theta += (r / ARM_X_DIST) * SPIRAL;
이 라인은 나선의 꼬임정도를 계산하여 각도를 보정하는 라인으로, ARM_X_DIST
는 x좌표의 표준편차를, SPIRAL
은 나선의 꼬임 정도를 의미한다.
이후 계산된 각도값 theta
와 반지름 r
값으로 이루어진 극좌표값을 직교 좌표값으로 변환하기 위해 각각 Math.cos
와 Math.sin
을 이용해준다.
이제 이 함수를 이용하여 은하를 구성해보자.
const ARM_X_MEAN = 1500;
const ARM_X_DIST = 3000;
const ARM_Z_MEAN = 900;
const ARM_Z_DIST = 1000;
const GALAXY_THICKNESS = 300;
const NUM_STARS = 5000;
const STAR_MIN_SIZE = 5;
const STAR_MAX_SIZE = 15;
const ARMS = 2;
export default function Galaxy() {
const stars = [];
for (let arm = 0; arm < ARMS; arm++) {
for (let star = 0; star < NUM_STARS / ARMS; star++) {
const size = getRandomInt(STAR_MIN_SIZE, STAR_MAX_SIZE);
const pos = spiral(
gaussianRandom(ARM_X_MEAN, ARM_X_DIST),
gaussianRandom(0, GALAXY_THICKNESS),
gaussianRandom(ARM_Z_MEAN, ARM_Z_DIST),
(arm * 2 * Math.PI) / ARMS
);
stars.push(<Star position={pos} size={size} />);
}
}
return <group>{stars}</group>;
}
먼저 사용되는 상수들부터 설명하겠다.
ARMS
: 나선팔의 개수
NUM_STARS
: 은하를 구성하는 별의 총 개수
ARM_X_MEAN
: x좌표 정규분포의 평균값
ARM_X_DIST
: x좌표 정규분포의 표준편차
ARM_Z_MEAN
: z좌표 정규분포의 평균값
ARM_Z_DIST
: z좌표 정규분포의 표준편차
STAR_MIN_SIZE
: 별의 최소 크기
STAR_MAX_SIZE
: 별의 최대 크기
GALAXY_THICKNESS
: 은하의 굵기(y좌표의 최대값)
흐름은 다음과 같다.
나선팔의 개수만큼 반복문을 순회하며 각 나선팔을 구성한다. 각 나선팔은 나선팔에 배정되는 별의 개수(NUM_STARS / ARMS
)만큼 반복문을 순회하며 각 별을 구성한다.
별의 size
는 간단하게 최소 크기와 최대 크기 사이의 값을 랜덤으로 부여하고, 위치 벡터는 위에서 구현한 spiral
함수를 통해 계산한다.
초기의 좌표값은 각 상수값을 활용한 정규분포 난수를 통해 생성한다. 각도 초기값 offset
은 arm * 2 * Math.PI
를 나선팔의 개수ARMS
로 나눠주어 각 나선팔의 시작 각도를 계산해준다.
완성된 은하 컴포넌트를 캔버스에 위치시키면 멋진 은하를 만나볼 수 있다!
은하 컴포넌트의 전체 코드는 다음과 같다.
import * as THREE from "three";
import { getRandomInt, gaussianRandom } from "./random";
import Star from "./Star";
const SPIRAL = 3.5;
const ARM_X_MEAN = 1500;
const ARM_X_DIST = 3000;
const ARM_Z_MEAN = 900;
const ARM_Z_DIST = 1000;
const GALAXY_THICKNESS = 300;
const NUM_STARS = 5000;
const STAR_MIN_SIZE = 5;
const STAR_MAX_SIZE = 15;
const ARMS = 2;
function spiral(x: number, y: number, z: number, offset: number) {
const r = Math.sqrt(x ** 2 + z ** 2);
let theta = offset;
theta += x > 0 ? Math.atan(z / x) : Math.atan(z / x) + Math.PI;
theta += (r / ARM_X_DIST) * SPIRAL;
return new THREE.Vector3(r * Math.cos(theta), y, r * Math.sin(theta));
}
export default function Galaxy() {
const stars = [];
for (let arm = 0; arm < ARMS; arm++) {
for (let star = 0; star < NUM_STARS / ARMS; star++) {
const size = getRandomInt(STAR_MIN_SIZE, STAR_MAX_SIZE);
const pos = spiral(
gaussianRandom(ARM_X_MEAN, ARM_X_DIST),
gaussianRandom(0, GALAXY_THICKNESS),
gaussianRandom(ARM_Z_MEAN, ARM_Z_DIST),
(arm * 2 * Math.PI) / ARMS
);
stars.push(<Star position={pos} size={size} />);
}
}
return <group>{stars}</group>;
}
수식이 너무 난해하여 이해하기 어렵다면,leva
라는 패키지의 도움을 받아 각 값들을 조정해보며 결과를 확인하면 꼭 100% 이해하지 않더라도 사용할 수 있다. 그 방법을 알아보자.
먼저 yarn add leva -D
커맨드를 통해 패키지를 설치해주자. leva
패키지는 useControls
라는 훅을 제공하는데, 이를 활용해 실행화면에서 GUI로 원하는 값들을 조정하며 결과를 바로바로 확인해볼 수 있다. 위에서 사용했던 상수들을 useControls
훅을 이용해 조정하고 확인해보자.
const {
SPIRAL,
ARM_X_MEAN,
ARM_X_DIST,
ARM_Z_MEAN,
ARM_Z_DIST,
GALAXY_THICKNESS,
NUM_STARS,
STAR_MIN_SIZE,
STAR_MAX_SIZE,
ARMS,
} = useControls({
SPIRAL: 3.5,
ARM_X_MEAN: 1500,
ARM_X_DIST: 3000,
ARM_Z_MEAN: 900,
ARM_Z_DIST: 1000,
GALAXY_THICKNESS: 300,
NUM_STARS: 5000,
STAR_MIN_SIZE: 5,
STAR_MAX_SIZE: 15,
ARMS: 2,
});
이와 같이 useControls
훅의 인자로 값들의 이름과 초기값을 객체로 넘긴 뒤, 구조분해할당을 이용해 가져와서 사용하면 된다.
실행 결과를 확인해보면
사진처럼 우측 상단에 값을 조정할 수 있는 컨트롤 박스가 생성되어 키보드로 값을 입력할 수 있다. 값을 변경하면 즉시 화면에 반영되므로 결과를 확인하며 값에 대한 의미를 파악하기에 좋다. 이를 활용해 수식에 대한 완벽한 이해없이도 각 값들이 의미하는 바와 어떤식으로 조절해야 원하는 모양을 만들어낼 수 있을지 확인할 수 있다.
leva
를 활용한 은하의 전체코드는 다음과 같다.
import * as THREE from "three";
import { getRandomInt, gaussianRandom } from "./random";
import Star from "./Star";
import { useControls } from "leva";
function spiral(
x: number,
y: number,
z: number,
offset: number,
SPIRAL: number,
ARM_X_DIST: number
) {
const r = Math.sqrt(x ** 2 + z ** 2);
let theta = offset;
theta += x > 0 ? Math.atan(z / x) : Math.atan(z / x) + Math.PI;
theta += (r / ARM_X_DIST) * SPIRAL;
return new THREE.Vector3(r * Math.cos(theta), y, r * Math.sin(theta));
}
export default function Galaxy() {
const stars = [];
const {
SPIRAL,
ARM_X_MEAN,
ARM_X_DIST,
ARM_Z_MEAN,
ARM_Z_DIST,
GALAXY_THICKNESS,
NUM_STARS,
STAR_MIN_SIZE,
STAR_MAX_SIZE,
ARMS,
} = useControls({
SPIRAL: 3.5,
ARM_X_MEAN: 1500,
ARM_X_DIST: 3000,
ARM_Z_MEAN: 900,
ARM_Z_DIST: 1000,
GALAXY_THICKNESS: 300,
NUM_STARS: 5000,
STAR_MIN_SIZE: 5,
STAR_MAX_SIZE: 15,
ARMS: 2,
});
for (let arm = 0; arm < ARMS; arm++) {
for (let star = 0; star < NUM_STARS / ARMS; star++) {
const size = getRandomInt(STAR_MIN_SIZE, STAR_MAX_SIZE);
const pos = spiral(
gaussianRandom(ARM_X_MEAN, ARM_X_DIST),
gaussianRandom(0, GALAXY_THICKNESS),
gaussianRandom(ARM_Z_MEAN, ARM_Z_DIST),
(arm * 2 * Math.PI) / ARMS,
SPIRAL,
ARM_X_DIST
);
stars.push(<Star position={pos} size={size} />);
}
}
return <group>{stars}</group>;
}
이번 글에서는 R3F를 활용해 3D로 은하를 만들어보았다. 수학 수식이 많이 들어가고 처음 접해보는 3D 개발이라 익숙하지 않은 부분들이 많았지만, 하나하나 따라가다보면 멋진 결과물을 만들 수 있어 좋은 경험이었던 것 같다. 이제 이 은하를 조금씩 발전시켜나가 프로젝트를 성공적으로 마무리 할 수 있기를 바란다.
잘못된 정보나 궁금한점이 있다면 댓글 남겨주세요!
Refereces
정규분포 Khan Academy
Box Muller transform 위키백과
R3F 공식 문서
정말 멋있습니다.