너와의 추억을 우주의 별로 띄울게

­가은·2023년 11월 15일
24
post-thumbnail

부스트캠프 웹모바일 8기 Web16팀. 우리 프로젝트의 내용이다.

'내 삶의 반짝이는 기억들을 은하로 만들 수 있다면 어떨까' 라는 생각에서 나온 주제로,
자신만의 우주에 기억을 담은 별을 띄울 수 있는 서비스이다.

우리가 프로젝트를 구현해나가는 과정을 블로그로 작성해보려 한다.
그 중 <너와의 추억을 우주의 별로 띄울게> 시리즈는 3D로 우주를 구현하는 것에 초점을 맞춰 글을 작성한다.

프로젝트를 구경하고 싶으면 아래의 깃헙으로.. 🚀
https://github.com/boostcampwm2023/web16-B1G1

프로젝트 진행과정은 wiki에 기록하고 있다.
https://github.com/boostcampwm2023/web16-B1G1/wiki

글을 작성하고 있는 오늘을 기준으로 프로젝트 극 초반이기 때문에 아직 코드가 많이 작성되어 있지 않다.
앞으로의 진행과정이 궁금하다면 가끔 와서 구경해주시길.. 🙏🏻

피드백과 지적은 언제나 환영합니다.
PR에 냅다 리뷰 다셔도 좋습니다.

++ 아래 내용들은 프론트엔드 팀원분들과 페어프로그래밍한 결과물임을 밝힌다.
팀원들의 블로그 링크 투척하고 간다
https://velog.io/@minboykim
https://velog.io/@200tiger




아무튼 이제 본격적으로 코드를 작성해보자.
우리 프로젝트에서는 Three.js + React-Three-Fiber를 사용하여 3D 요소들을 구현한다.
Three.js에 대한 기본적인 지식을 공부하고 싶다면 아래 글을 읽어보고 오는 것을 추천한다.

Three.js와의 설레는 첫만남 😳
JS로 자전과 공전을 구현할 수 있다고?

오늘은 배경이 될 우주를 만들어보겠다.
까만 배경에 작은 별들을 렌더링하는 과정이다.


🍞 Screen 컴포넌트 생성

우리는 Canvas를 통해 Scene을 구성할 수 있다.
Scene은 도화지같은 역할이다.
Scene이라는 도화지가 있어야 그 위에 다른 요소들을 그려낼 수 있다.

Screen이라는 컴포넌트 내에 Canvas를 생성해보자.

import { Canvas } from '@react-three/fiber';

export default function Screen() {
	return (
		<Canvas style={{ height: '100vh', width: '100vw' }}></Canvas>
	);
}

먼저 Canvas에 style을 주어 전체 화면을 차지하도록 하자.

import { Canvas } from '@react-three/fiber';

type Vector3 = [number, number, number];

const CAMERA_POSITION: Vector3 = [0, 2000, 2000];
const CAMERA_ROTATION: Vector3 = [-0.5, 0, 0];
const CAMERA_FAR = 100000;

export default function Screen() {
  	const camera = {
		position: CAMERA_POSITION, // 카메라의 위치
		rotation: CAMERA_ROTATION, // 카메라의 회전
		far: CAMERA_FAR, // 
	};
  
	return (
		<Canvas camera={camera} style={{ height: '100vh', width: '100vw' }}></Canvas>
	);
}

다음으로 camera 속성을 추가한다.
이 속성을 통해 우리가 3D 화면을 어디서 어떻게 바라볼지 설정할 수 있다.

position 속성은 카메라의 위치를 의미한다.
[0, 2000, 2000]은 각각 x, y, z축에 대한 위치를 의미한다.
즉 카메라를 y축과 z축 방향으로 2000만큼 떨어진 위치에 놓은 것이다.

rotation은 카메라의 회전을 설정한다.
[-0.5, 0, 0]은 각각 x, y, z축에 대한 회전 각도를 의미한다.
여기서 회전 각도는 라디안 단위로 표현된다.
문과생인 나는 여기서부터 살짝 당황했다.
하지만 여기까지는 아직 쉽다.
라디안은 각도 측정 단위중 하나이다.
원주율 π 라디안이 180도에 해당한다.
즉 -0.5 라디안은 약 -28.65도 정도이다.
카메라의 회전을 [-0.5, 0, 0]으로 설정했다는 것은 카메라의 시점이 y축의 음의 방향으로 28.65도정도 기울어져있다는 뜻이다.

far은 카메라가 보여줄 수 있는 최대 거리를 설정한다.
far을 100000을 설정했다는 것은, 요소와 카메라 간 거리가 100000보다 멀어지면 요소가 보이지 않는다는 뜻이다.

사실 현재 카메라 설정값들은 다 임시로 한 것이라, 값에는 큰 의미를 두지 않아도 된다.
모두 개발을 진행함에 따라 변경될 예정이다.
각각의 속성이 어떤 것을 뜻하는지만 이해하면 될 것 같다.

import { Canvas } from '@react-three/fiber';

type Vector3 = [number, number, number];

const CAMERA_POSITION: Vector3 = [0, 2000, 2000];
const CAMERA_ROTATION: Vector3 = [-0.5, 0, 0];
const CAMERA_FAR = 100000;

export default function Screen() {
	const camera = {
		position: CAMERA_POSITION,
		rotation: CAMERA_ROTATION,
		far: CAMERA_FAR,
	};

	return (
		<Canvas camera={camera} style={{ height: '100vh', width: '100vw' }}>
			<color attach="background" args={['#000']} />
			<ambientLight color="#fff" intensity={5} />
		</Canvas>
	);
}

color에서는 background 속성의 색상을 #000으로 설정했다.
이렇게 하면 화면 전체가 까맣게 보인다.
attach에는 background 말고도 Scene 객체가 가진 속성 중 color 값을 가질 수 있는 속성들을 넣을 수 있다.

ambientLight는 주변 광원을 설정한다.
이 광원은 방향이 없으며 Scene에 있는 모든 요소를 균일하게 비춘다.
여기서는 색상을 흰색으로, 강도를 5로 설정했다.
광원이 있어야 보이는 material이 있고, 없어도 보이는 material이 있다.
우리는 이후 광원이 있어야 보이는 material도 사용할 예정이라 ambientLight를 설정해주었다.

import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';

type Vector3 = [number, number, number];

const CAMERA_POSITION: Vector3 = [0, 2000, 2000];
const CAMERA_ROTATION: Vector3 = [-0.5, 0, 0];
const CAMERA_FAR = 100000;

export default function Screen() {
	const camera = {
		position: CAMERA_POSITION,
		rotation: CAMERA_ROTATION,
		far: CAMERA_FAR,
	};

	return (
		<Canvas camera={camera} style={{ height: '100vh', width: '100vw' }}>
			<color attach="background" args={['#000']} />
			<ambientLight color="#fff" intensity={5} />
			<OrbitControls />
		</Canvas>
	);
}

마지막으로 OrbitControls를 추가했다.
이것을 추가함으로써 우리는 이제 3D 화면을 탐색할 수 있다.
이것이 없을 땐 우린 그저 화면을 바라보기만 할 수 있다.
우리가 OrbitControls를 추가함으로써 마우스나 터치 이벤트를 통해 카메라의 줌, 회전 등이 가능해졌다.

이제 Canvas 설정은 끝났다.
본격적으로 배경 별들을 만들어보자.


🍞 BackgroundStars 컴포넌트 생성

1. Geometry, Material 생성

우리는 pointsMaterial을 사용하여 배경 별을 만들 것이다.
잠시 다시 짚고 넘어가자면 하나의 덩어리, 즉 Mesh를 만들기 위해서는 Geometry와 Material이 필요하다.
먼저 이것들을 만들어보자.

import * as THREE from 'three';

export default function BackgroundStars() {
	return (
		<points>
			<bufferGeometry attach="geometry"></bufferGeometry>
			<pointsMaterial attach="material"/>
		</points>
	);
}

points는 Points 객체를 생성하는데, 이 객체는 각각의 점들로 구성되었다.
여기서 각 점이 별 하나를 나타낼 것이다.

bufferGeometry는 점들의 위치를 정의하는 BufferGeometry 객체를 생성한다.
우리는 attach="geometry"를 통해 이를 Points 객체의 geometry 속성으로 설정한다.

pointsMaterial은 점들의 물리적 특성을 정의하는 PointsMaterial 객체를 생성한다.
마찬가지로 attach="material"을 통해 Points 객체의 material 속성으로 설정한다.

다음으로는 pointsMaterial 속성을 채워보자.

import * as THREE from 'three';

const BACKGROUND_STAR_SIZE = 1.5;

export default function BackgroundStars() {
	return (
		<points>
			<bufferGeometry attach="geometry"></bufferGeometry>
			<pointsMaterial
				attach="material"
				size={BACKGROUND_STAR_SIZE}
				vertexColors={true}
				sizeAttenuation={false}
			/>
		</points>
	);
}

먼저 size 속성으로 각 별의 크기를 1.5로 설정했다.

다음으로 vertexColors를 true로 설정하면, 각 점의 색상이 해당 점의 vertex color에 의해 결정된다.
vertex color는 BufferGeometrycolor 속성을 통해 설정할 것이다.

sizeAttenuation 속성으로는 별들이 카메라와의 거리에 따라 어떻게 보일지 결정할 수 있다.
이 속성을 false로 설정하면 점들의 크기가 카메라와의 거리에 관계없이 일정하게 보인다.
즉 원근법을 무시하게 된다.
별들이 멀든 가깝든 항상 조그맣게 보이게 설정하여, 말 그대로 '뒷 배경의 별'처럼 보이게 했다.

잠깐 최종 완성화면을 보자.
sizeAttenuation이 true일 때는 첫 번째 사진과 같고, false일 때는 아래 사진과 같다.

이제 별들의 위치와 색상을 설정할 때 쓰일 함수를 만들어보자.

const getRandomInt = (min: number, max: number) => {
	const minCeil = Math.ceil(min);
	const maxFloor = Math.floor(max);

	return Math.floor(Math.random() * (maxFloor - minCeil)) + minCeil;
};

const getRandomFloat = (min: number, max: number) => {
	return Math.random() * (max - min) + min;
};

getRandomInt는 인수로 min과 max를 받아, min 이상 max 미만의 Integer 형식 수를 리턴한다.
getRandomFloat는 인수로 min과 max를 받아, min 이상 max 미만의 Float 형식 수를 리턴한다.

여기서 getRandomInt는 별의 색상을 결정할 때 쓰고, getRandomFloat는 별의 위치를 결정할 때 쓸 것이다.


2. 색상과 위치 결정

그다음은 별들의 색상과 위치를 결정하는 함수를 만들 것이다.
여기서부터 좀 길어질 수 있으니 해당 함수 부분만 따로 떼서 보자.

const BACKGROUND_STARS_NUM = 500;
const BACKGROUND_STAR_COLORS = [
	'#8fa8f6',
	'#b4ffb8',
	'#ffdd8f',
	'#ff8fba',
];

const SPACE_MIN_SIZE = -50000;
const SPACE_MAX_SIZE = 50000;
const DIMENSION = 3;

const getBackgroundStarsInfo = () => {
	const positions = Array.from(
		{ length: BACKGROUND_STARS_NUM * DIMENSION },
		() => getRandomFloat(SPACE_MIN_SIZE, SPACE_MAX_SIZE),
	); // 별들의 x, y, z 좌표를 담은 배열

	const colors = Array.from({ length: BACKGROUND_STARS_NUM }, () => {
		const color = new THREE.Color(
			BACKGROUND_STAR_COLORS[getRandomInt(0, BACKGROUND_STAR_COLORS.length)],
		);

		return [color.r, color.g, color.b];
	}).flat(); // 별들의 색상 r, g, b 값을 담은 배열

	return [new Float32Array(positions), new Float32Array(colors)]; // Float32Array 형식으로 변경
};

먼저 별들의 x, y, z 좌표를 담은 positions 배열을 만든다.
한 별 당 x, y, z 세 개의 좌표가 들어가므로 배열의 길이는 ( 배경 별 수 * 3 ) 가 된다.
그리고 우주의 범위 내에서 랜덤으로 좌표를 생성해 배열에 담는다.

SPACE_MIN_SIZE가 -50000, SPACE_MAX_SIZE가 50000으로 되어있는데,
이는 원점 (0, 0, 0)을 기준으로 x, y, z좌표에서 각각 양옆으로 50000씩 뻗어나간 곳까지 우주가 존재할 것이라는 뜻이다.
예를 들어 SPACE_MIN_SIZE가 -5, SPACE_MAX_SIZE가 5일 경우 우주는 아래와 같은 영역까지 존재하게 된다.

그러므로 x, y, z 좌표 모두 SPACE_MIN_SIZE 이상 SPACE_MAX_SIZE 미만의 범위에서 float 형식으로 랜덤 추출했다.


다음으로 색상을 보자.
별들의 색상 r, g, b값을 담은 colors 배열을 만들었다.

여기서는 배열의 길이를 배경 별 수로 하되, 하나의 요소가 [color.r, color.g, color.b] 형식이 되도록 했다.
그리고 flat()으로 배열을 평탄화했다.
BACKGROUND_STAR_COLORS의 인덱스 값으로는 0부터 3까지의 정수만 되므로 getRandomFloat가 아닌 getRandomInt를 사용했다.
4개 색상 중 랜덤으로 각 별의 색상이 결정될 것이다.

이제 positions와 colors가 완성되었다.
Three.js에서는 Float32Array를 사용하여 버퍼 데이터를 저장한다.
배열을 해당 형식으로 변경하자.

참고로 Float64Array를 사용하면 Unsupported buffer data format 에러가 발생한다.
WebGL에서 기본적으로 Float32Array를 지원하기 때문이라고 한다.

import { useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';

const BACKGROUND_STARS_NUM = 500;
const BACKGROUND_STAR_COLORS = [
	'#8fa8f6',
	'#b4ffb8',
	'#ffdd8f',
	'#ff8fba',
];
const BACKGROUND_STAR_SIZE = 1.5;

const SPACE_MIN_SIZE = -50000;
const SPACE_MAX_SIZE = 50000;
const DIMENSION = 3;

const getRandomInt = (min: number, max: number) => {
	const minCeil = Math.ceil(min);
	const maxFloor = Math.floor(max);

	return Math.floor(Math.random() * (maxFloor - minCeil)) + minCeil;
};

const getRandomFloat = (min: number, max: number) => {
	return Math.random() * (max - min) + min;
};

const getBackgroundStarsInfo = () => {
	const positions = Array.from(
		{ length: BACKGROUND_STARS_NUM * DIMENSION },
		() => getRandomFloat(SPACE_MIN_SIZE, SPACE_MAX_SIZE),
	);

	const colors = Array.from({ length: BACKGROUND_STARS_NUM }, () => {
		const color = new THREE.Color(
			BACKGROUND_STAR_COLORS[getRandomInt(0, BACKGROUND_STAR_COLORS.length)],
		);

		return [color.r, color.g, color.b];
	}).flat();

	return [new Float32Array(positions), new Float32Array(colors)];
};

export default function BackgroundStars() {
	const [positions, colors] = useMemo(() => getBackgroundStarsInfo(), []);

	return (
		<points>
			<bufferGeometry attach="geometry">
				<bufferAttribute
					attach="attributes-position"
					count={BACKGROUND_STARS_NUM}
					array={positions}
					itemSize={3}
				/>
                <bufferAttribute
					attach="attributes-color"
					count={BACKGROUND_STARS_NUM}
					array={colors}
					itemSize={3}
				/>
			</bufferGeometry>
			<pointsMaterial
				attach="material"
				size={BACKGROUND_STAR_SIZE}
				vertexColors={true}
				sizeAttenuation={false}
			/>
		</points>
	);
}

여기까지 적용한 전체 코드는 위와 같다.
getBackgroundStarsInfo 함수의 결과값을 BackgroundStars 내에서 각각 positions, colors에 할당해주자.
여기서 getBackgroundStarsInfo 함수는 계산량이 많으므로 성능 개선을 위해 useMemo를 사용했다.

첫 번째 bufferAttribute에는 attach="attributes-position"속성을 주어 위치 관련 속성들을 지정해주었다.
itemSize는 각 항목이 차지하는 크기를 나타낸다.
우리는 3차원 좌표를 사용하므로, 한 항목이 (x, y, z)로 구성되어 3만큼 차지한다.

두 번째 bufferAttribute에는 attach="attributes-color"속성을 주어 색상 관련 속성들을 지정해주었다.

여기까지 하면 이렇게 예쁜 우주 배경이 보이게 된다.

아래는 동작 화면이다.
gif 형식으로 바꿨더니 좀 느려져서 렉이 걸리는 것처럼 보이는데..
실제로는 전혀 렉이 걸리지 않고 잘 움직인다.

하지만 이게 끝이 아니다.
별들이 회전하도록 만들어볼 것이다.
조금만..힘내보자


3. 회전시키기

회전 부분 코드에 집중하기 위해 잠시 BackgroundStars 내부의 코드만 살펴보자.

import { useMemo, useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';

export default function BackgroundStars() {
    
	const pointsRef = useRef<THREE.Points>(null!); // 추가된 코드 

	const [positions, colors] = useMemo(() => getBackgroundStarsInfo(), []);

	useFrame((_, delta) => (pointsRef.current.rotation.y += delta / 150)); // 추가된 코드 

	return (
		<points ref={pointsRef}>
			<bufferGeometry attach="geometry">
				<bufferAttribute
					attach="attributes-position"
					count={BACKGROUND_STARS_NUM}
					array={positions}
					itemSize={3}
				/>
				<bufferAttribute
					attach="attributes-color"
					count={BACKGROUND_STARS_NUM}
					array={colors}
					itemSize={3}
				/>
			</bufferGeometry>
			<pointsMaterial
				attach="material"
				size={BACKGROUND_STAR_SIZE}
				vertexColors={true}
				sizeAttenuation={false}
			/>
		</points>
	);
}

주석 달린 부분 코드가 추가되었고, points에 ref 속성이 추가되었다.

먼저 useFrame에 대해 간략히 알아보자.
useFrame의 콜백함수는 매 프레임이 렌더링되기 직전마다 실행된다.
프레임은 화면이 한 번 갱신되는데 필요한 시간 간격이다.
화면은 초당 특정 횟수로 갱신되며 이를 주로 FPS (Frame per Second)라고 한다.
기본적으로는 60FPS로 작동하는데, 초당 60번 화면이 갱신된다는 뜻이다.

그리고 첫 번째 인수는 현재 렌더러 상태, 두 번째 인수는 이전 프레임과의 시간 간격을 나타내는 delta 값이다.

useFrame((_, delta) => (pointsRef.current.rotation.y += delta / 150));

즉 여기서는 매 프레임이 렌더링 될 때마다 pointsRef.current.rotation.y 값을 업데이트하고 있다.
pointsRef의 y축 회전 값을 바꾸는 것이다.
( 이전 프레임과 현재 프레임 간 시간 차이 ) / 150 만큼을 계속해서 회전 값에 더해주어 points를 회전시킨다.
150보다 값이 커지면 회전이 느려지고, 값이 작아지면 회전이 빨라진다.
우리는 사용자의 눈이 피로하지 않도록 굉장히 느리게 회전하게 설정했다.

이제 모든 코드가 완성되었다.
동작 화면을 보자.

gif 변환하면서 영상이 느려지니 delta / 15로 설정하고 녹화했는데 실제로는 이것보다 더 느리게 회전한다.

이제 최종 코드를 보자.

import { useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';

const BACKGROUND_STARS_NUM = 500;
const BACKGROUND_STAR_COLORS = [
	'#8fa8f6',
	'#b4ffb8',
	'#ffdd8f',
	'#ff8fba',
];
const BACKGROUND_STAR_SIZE = 1.5;

const SPACE_MIN_SIZE = -50000;
const SPACE_MAX_SIZE = 50000;
const DIMENSION = 3;

const getRandomInt = (min: number, max: number) => {
	const minCeil = Math.ceil(min);
	const maxFloor = Math.floor(max);

	return Math.floor(Math.random() * (maxFloor - minCeil)) + minCeil;
};

const getRandomFloat = (min: number, max: number) => {
	return Math.random() * (max - min) + min;
};

const getBackgroundStarsInfo = () => {
	const positions = Array.from(
		{ length: BACKGROUND_STARS_NUM * DIMENSION },
		() => getRandomFloat(SPACE_MIN_SIZE, SPACE_MAX_SIZE),
	);

	const colors = Array.from({ length: BACKGROUND_STARS_NUM }, () => {
		const color = new THREE.Color(
			BACKGROUND_STAR_COLORS[getRandomInt(0, BACKGROUND_STAR_COLORS.length)],
		);

		return [color.r, color.g, color.b];
	}).flat();

	return [new Float32Array(positions), new Float32Array(colors)];
};

export default function BackgroundStars() {
	const pointsRef = useRef<THREE.Points>(null!);

	const [positions, colors] = useMemo(() => getBackgroundStarsInfo(), []);

	useFrame((_, delta) => (pointsRef.current.rotation.y += delta / 150));

	return (
		<points ref={pointsRef}>
			<bufferGeometry attach="geometry">
				<bufferAttribute
					attach="attributes-position"
					count={BACKGROUND_STARS_NUM}
					array={positions}
					itemSize={3}
				/>
                <bufferAttribute
					attach="attributes-color"
					count={BACKGROUND_STARS_NUM}
					array={colors}
					itemSize={3}
				/>
			</bufferGeometry>
			<pointsMaterial
				attach="material"
				size={BACKGROUND_STAR_SIZE}
				vertexColors={true}
				sizeAttenuation={false}
			/>
		</points>
	);
}

실제 프로젝트에서는는 함수나 상수를 다른 곳에서 정의하고 import 해오는 식으로 했는데, 블로그 작성을 위해 한 파일에 모두 옮겼더니 코드가 좀 길어보인다. 😅


아무튼 이렇게 우주의 배경 별 만들기가 끝났다.
다음 글은 은하 만들기가 될 것 같다.
살짝 스포를 해보면... 이런 느낌이 되겠다.

아직 미완이라 확정은 아니지만 대충 저런 느낌으로 될 것 같다.
다음 글은 훨씬 어려운 내용이 되겠지만 다들 기다려줘....


참고 자료

1개의 댓글

comment-user-thumbnail
2023년 11월 24일

썸네일 보고 들어왔는데 재밌는 내용이네요 잘봤습니다!

답글 달기