빨려들어간다... 🫨

­가은·2023년 12월 28일
19
post-thumbnail

오늘은 빨려들어가는 듯한 애니메이션을 보여주는 화면을 만들어보자.
Three.js와 React-Three-Fiber(R3F)를 사용한다.

이 화면은 이전에 올렸던 너와의 추억을 우주의 별로 띄울게 라는 게시글의 코드를 기반으로 조금씩 변경하여 구현하였다.
저 게시글을 먼저 읽고 온다면 이해가 빠를 것이다.

오늘은 Three.js의 기본적인 지식들에 대한 설명은 건너뛰고, 구현 원리를 설명하는 데 집중할 것이다.
Three.js에 대한 지식이 아예 없다면 아래의 글을 읽어보는 것을 추천한다.
Three.js와의 설레는 첫만남
JS로 자전과 공전을 구현할 수 있다고?

그럼 이제 본격적으로 시작해보자.

시작하기 전에 잠시..
저 화면을 사용한 프로젝트는 아래에서 확인할 수 있다.

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

( 스타는 언제나 환영입니다ㅎ )




🍞 어떤 방식으로 구현할까?

이 화면은 카메라를 고정시켜놓고 선에 애니메이션을 주거나, 선을 고정해놓고 카메라를 움직여 구현할 수 있을 것이다.
나는 고정된 선에 카메라를 움직이는 방법을 택했다.
이 화면을 구현하는 다양한 방법을 알고 싶다면 Space warp라는 키워드로 검색해보는 것을 추천한다.

그럼 내가 이 화면을 구현한 방법을 그림을 통해 간단히 설명해보겠다.


먼저 선을 배치할 제한된 공간을 마련한다.


그리고 그 공간 내에서 선을 무작위로 배치한다.


공간의 가장 아래에는 빛나는 광원을 두고, 공간의 가장 위에는 카메라를 둔다.
이 상태에서 카메라를 아래로 이동시키면 마치 하얀 빛 속으로 빨려들어가는 듯한 느낌을 낼 수 있다.

이 화면은 Canvas를 생성하고 Camera 설정을 하는 WarpScreen.tsx,
빨려들어가는 애니메이션을 구현하는 SpaceWarp.tsx,
하얀 빛을 표현하는 BrightSphere.tsx 파일로 구성되어있다.
WarpScreen.tsx 안에서 SpaceWarpBrightSphere를 사용한다.
매직넘버들을 상수로 분리해놓은 constants.ts 파일도 있지만 이 글에서는 따로 분리하지 않고 보여주도록 하겠다.


🍞 WarpScreen 만들기

1. Canvas와 Camera 생성

먼저 WarpScreen에 기본이 되는 Canvas를 생성해보자.

import { Canvas } from '@react-three/fiber';
import { EffectComposer, Bloom } from '@react-three/postprocessing';

export default function WarpScreen() {
    // 카메라 설정
	const camera = {
		position: [0, 100000, 0], // y축으로 100000만큼 올라간 위치
		up: [0, 0, 1], // 카메라의 상단이 z축을 향하고 있음
		far: 100000, // 100000만큼 떨어진 곳까지 볼 수 있음
	};

    // 캔버스 스타일 설정
	const canvasStyle: React.CSSProperties = {
		position: 'absolute',
		height: '100vh',
		width: '100vw',
		zIndex: 999,
		backgroundColor: '#000000',
	};

	return (
		<Canvas camera={camera} style={canvasStyle}></Canvas>
	);
}

Canvas를 생성하고 위와 같이 스타일을 적용해주었다.
굳이 position: absolutez-index: 999를 적용해준 이유는 이 화면이 로딩 화면의 역할도 할 것이기 때문이다.
우리는 페이지 간 이동할 때 이 화면을 띄워준다.
단순히 애니메이션을 주어 눈을 즐겁게 하려는 의도도 있지만, 다음 화면이 렌더링될 때까지 시간을 벌기 위함도 있다.
그래서 다음 화면을 먼저 띄워둔 후 그 위에 잠시동안 이 WarpScreen 화면을 덮어둘 것이다.
이건 상황에 따라 다르기 때문에 스타일은 각자 원하는대로 적용해주면 되겠다.

그리고 Camera 설정을 살펴보자.
Camera는 우리의 시점과 같은 역할을 한다.
이전 글을 봤다면 대충 알 것이므로 Camera의 속성에 관해서는 자세히 설명하지 않고 넘어가겠다.

Camera는 원점 기준으로 y축으로 100000만큼 올라가있게 설정했다.
이후 100000에서 0까지 빠르게 이동시킬 것이다.

far 값으로 100000이라는 숫자를 설정한 명확한 이유가 있는 것은 아니다.
far 값을 조정해가며 테스트한 결과 이 값일 때 가장 예쁜 애니메이션이 나와서 이렇게 했다.
실제로 값을 조절하며 가장 마음에 드는 값을 넣으면 되겠다.

아래 gif중 첫 번째는 far를 100000으로 한 것이고, 두 번째는 30000으로 한 것이다.


2. Bloom 설정

다음으로 Bloom 설정을 해보자.
Bloom은 R3F에 있는 후처리 기능이다.
보다 더 실제같은 느낌을 주기 위해 사용한다.

import { Canvas } from '@react-three/fiber';
import { EffectComposer, Bloom } from '@react-three/postprocessing';

export default function WarpScreen() {
	const camera = {
		position: [0, 100000, 0], 
		up: [0, 0, 1], 
		far: 100000, 
	};

	const canvasStyle: React.CSSProperties = {
		position: 'absolute',
		height: '100vh',
		width: '100vw',
		zIndex: 999,
		backgroundColor: '#000000',
	};

	return (
		<Canvas camera={camera} style={canvasStyle}>
			<EffectComposer>
				<Bloom
					intensity={2} // 강도
					mipmapBlur={true} // 블러 효과 사용 여부
					luminanceThreshold={0.55} // 효과가 적용되는 최소 밝기 임계값
					luminanceSmoothing={0} // 밝기의 부드러움
				/>
			</EffectComposer>
		</Canvas>
	);
}

값은 실제로 조절해보며 눈으로 확인해봐야 감이 올 것이다.
이 부분은 정말 취향이 갈리기 때문에 직접 원하는 값을 찾아보길 추천한다.
그러기 위해서 leva라는 라이브러리를 추천한다.

화면에 컨트롤창을 띄워놓고, 코드가 아닌 GUI로 값을 조절할 수 있다.
나도 상당히 많은 시도 끝에 원하는 값을 얻어냈다. 🕺🏼

먼저 intensity는 빛의 강도 (밝기)이다.
높일수록 영롱해보이긴 하지만 물체의 선명도는 떨어져보인다.

mipmapBlur는 말 그대로 블러 효과이다.
어기서는 true로 설정하는 것이 좋다.
이 값을 false로 주면 아래에 위치한 원이 빛이 아니라 진짜 그냥 동그란 원처럼 보인다.
블러효과를 주어 빛을 자연스럽게 퍼뜨려주자.

luminanceThreshold는 Bloom 효과를 적용할 픽셀의 최소 밝기이다.
이 값보다 밝기가 낮은 픽셀에는 효과가 적용되지 않는다.
0에서 1까지의 값을 설정할 수 있다.

luminanceSmoothingluminanceThreshold를 기준으로 Bloom 효과가 얼마나 부드럽게 변화할지 결정한다.
이렇게 말하면 먼.. 소리를 하는지 잘 이해가 안될 것이다.
예를 들어 luminanceSmoothing 값이 작다면 밝기가 luminanceThresold를 넘는 픽셀과 그렇지 않은 픽셀의 차이가 뚜렷하게 나타난다.
픽셀이 밝게 빛나거나 아예 빛나지 않는 두 가지 상태로 갈리게 된다.
luminanceSmoothing 값이 크다면 luminanceThreshold를 넘는 픽셀과 그렇지 않은 픽셀의 차이가 덜 뚜렷해진다.
픽셀이 서서히 밝아지는 느낌을 낼 수 있다.


3. ambientLight 추가

import { Canvas } from '@react-three/fiber';
import { EffectComposer, Bloom } from '@react-three/postprocessing';

export default function WarpScreen() {
	const camera = {
		position: [0, 100000, 0], 
		up: [0, 0, 1], 
		far: 90000, 
	};

	const canvasStyle: React.CSSProperties = {
		position: 'absolute',
		height: '100vh',
		width: '100vw',
		zIndex: 999,
		backgroundColor: '#000000',
	};

	return (
		<Canvas camera={camera} style={canvasStyle}>
			<EffectComposer>
				<Bloom
					intensity={2} 
					mipmapBlur={true} 
					luminanceThreshold={0.55} 
					luminanceSmoothing={0} 
				/>
			</EffectComposer>

			<ambientLight intensity={15} />
		</Canvas>
	);
}

ambientLight는 방향이 없으며 scene 안의 모든 물체를 동일하게 비춘다.
사실 아래에 위치시킬 하얀 빛 (하얀색 구)은 실제로 스스로 빛나지 않는다...
그래서 ambientLight가 없으면 화면 상에 구가 보이지 않게 된다.

뭔가 속은듯한 기분이 들 수 있을텐데, 이 하얀 구에 대해서는 조금 이따 이어서 설명해보도록 하겠다.


4. 파라미터 받아오기

import { Canvas } from '@react-three/fiber';
import { EffectComposer, Bloom } from '@react-three/postprocessing';

type WarpStateType = 'warp' | 'fade' | 'end'; // WarpScreen 상태 

interface PropsType {
	isSwitching: WarpStateType;
	setIsSwitching: React.Dispatch<React.SetStateAction<WarpStateType>>;
}

export default function WarpScreen({ isSwitching, setIsSwitching }) {
	const camera = {
		position: [0, 100000, 0], 
		up: [0, 0, 1], 
		far: 90000, 
	};

	const canvasStyle: React.CSSProperties = {
		position: 'absolute',
		height: '100vh',
		width: '100vw',
		zIndex: 999,
		backgroundColor: '#000000',
	};

	return (
		<Canvas camera={camera} style={canvasStyle}>
			<EffectComposer>
				<Bloom
					intensity={2} 
					mipmapBlur={true} 
					luminanceThreshold={0.55} 
					luminanceSmoothing={0} 
				/>
			</EffectComposer>

			<ambientLight intensity={15} />
		</Canvas>
	);
}

isSwitching으로는 현재 화면 전환 상태를 알 수 있다.

isSwitchingwarp면 SpaceWarp 화면을 보여주고,
fadeSpaceWarp 화면에서 서서히 다음 화면으로 전환되고,
end면 SpaceWarp 화면이 사라진다.

isSwitching이 무엇인지에 따라 WarpScreen.tsx가 리턴할 값이 달라지기 때문에 isSwitching을 파라미터로 받아왔고,
Camera가 원점에 도달하게 되면 SpaceWarp를 끝내주어야 하기에 setIsSwitching을 파라미터로 받아왔다.


🍞 BrightShpere 만들기

코드는 상당히 간단하다.

export default function BrightSphere() {
	return (
		<mesh position={[0, 0, 0]}>
			<sphereGeometry attach="geometry" args={[600, 32, 32]} />
			<meshStandardMaterial attach="material" color="white" />
		</mesh>
	);
}

위에서 말했던 것과 같이 이 Sphere는 스스로 빛나지 않는다.
대신 Bloom 효과를 주어 빛이 퍼져보이게 함으로써 하얀 빛같이 보이게 한 것이다. 하핫

아무튼... 그래도 빛 역할이니까 이름은 BrightSphere로 해주었다.
Camera가 원점을 향해 위에서부터 아래로 이동할 것이기 때문에, BrightShpere를 원점에 위치시켜준다.

그리고 sphereGeometry에서 600은 구의 반지름 (radius)을 의미한다.

두 개의 32는 각각 수평과 수직 세그먼트 수 (widthSegments, heightSegments)이다.
객체를 구성하는 다각형의 개수를 의미한다고 생각하면 된다.
나에게 돌 한 덩어리와 조각칼이 주어졌다고 했을 때, 조각칼로 더 많이 깎을수록 돌은 더 원에 가까워질 것이다.
여기서도 마찬가지로 숫자가 커질수록 더 부드러운 원 모양에 가까워진다.

여기서는 이게 중요한게 아니니 자세히는 설명하지 않겠다.
잘 모르겠다면 공식문서에 들어가서 widthSegmentsheightSegments를 조절해보길 추천한다.


🍞 SpaceWarp 만들기

이 부분은 이전에 은하 배경별을 만들었던 부분과 상당히 유사하다.

type WarpStateType = 'warp' | 'fade' | 'end';

interface PropsType {
	setIsSwitching: React.Dispatch<React.SetStateAction<WarpStateType>>;
}

export default function SpaceWarp({ setIsSwitching }: PropsType) {
	return (
		<lineSegments>
			<bufferGeometry attach="geometry">
				<bufferAttribute 
					attach="attributes-position" // 선의 위치 설정
					count={1300} // 선의 개수
					array={positions} // 선의 위치를 담은 배열
					itemSize={3} // 각 항목이 차지하는 크기 (x, y, z)
				/>
				<bufferAttribute
					attach="attributes-color" // 선의 색상 설정
					count={1300} // 선의 개수
					array={colors} // 선의 색상을 담은 배열
					itemSize={3} // 각 항목이 차지하는 크기 (r, g, b)
				/>
			</bufferGeometry>

			<lineBasicMaterial attach="material" vertexColors={true} />
		</lineSegments>
	);
}

lineSegmentsbufferGeometry, lineBasicMaterial을 이용해 선들을 만들어준다.
이제부터 Attribute의 array에 들어가있는 positionscolors를 만들어보자.


1. 선의 색상과 위치 결정

const SPACE_WARP_LINES_NUM = 1300; // 총 선 개수 
const SPACE_WARP_LINE_LENGTH = 18000; // 선의 길이

const SPACE_WARP_Y_MAX = 100000; // y 좌표 최대 값
const SPACE_WARP_Y_MIN = 0; // y 좌표 최소 값
const SPACE_WARP_XZ_MAX = 10000; // x, z 좌표 최대 값
const SPACE_WARP_XZ_MIN = -10000; // x, z 좌표 최소 값

const SPACE_WARP_LINE_COLORS = [
	'#627BFF',
	'#3A5AFF',
	'#6D3AFF',
	'#9734FF',
	'#EFE0FF',
	'#DDDDDD',
	'#FDFFE2',
]; // 선 색상

const getSpaceWarpLinesInfo = () => {
	const positions = Array.from({ length: SPACE_WARP_LINES_NUM }, () => {
		const x = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX);
		const y = getRandomFloat(SPACE_WARP_Y_MIN, SPACE_WARP_Y_MAX);
		const z = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX);

		return [x, y, z, x, y - SPACE_WARP_LINE_LENGTH, z];
	}).flat(); // 선들의 한쪽 끝 x, y, z 좌표, 다른 한 쪽 끝 x, y, z 좌표를 담은 배열

	const colors = Array.from({ length: SPACE_WARP_LINES_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)];
};

위 함수는 선들의 색상과 위치를 결정해 리턴하는 함수이다.
여기서는 의미를 파악하기 쉽게 하기 위해 예외적으로 숫자들을 상수로 분리해주었다.

여기서 getRandomFloat(min, max)getRandomInt(min, max)는 각각 min 이상 max 미만의 Float 형식, Int 형식 수를 리턴하는 함수이다.

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;
};

중요한 부분은 아니니 간단히 코드만 보여주고 넘어가도록 하겠다.

먼저 선의 위치를 결정하는 positions 배열부터 살펴보자.

const positions = Array.from({ length: SPACE_WARP_LINES_NUM }, () => {
  const x = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX); // x 좌표 랜덤 추출
  const y = getRandomFloat(SPACE_WARP_Y_MIN, SPACE_WARP_Y_MAX); // y 좌표 랜덤 추출
  const z = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX); // z 좌표 랜덤 추출

  return [x, y, z, x, y - SPACE_WARP_LINE_LENGTH, z]; 
}).flat(); 

positions 배열의 길이는 (선 개수 * 2 * 3)이다.
2를 곱해주는 이유는 선의 양쪽 끝 좌표를 모두 포함해야 하기 때문이고,
3을 곱해주는 이유는 한 좌표당 x, y, z 세 값이 들어가기 때문이다.

그리고 정해진 범위 내에서 x, y, z 좌표를 랜덤 추출한다.
나는 위아래로 길쭉한 범위를 만들기 위해 x, z좌표의 범위보다 y 좌표 범위를 훨씬 넓게 해주었다.
이후 [x, y, z, x, y - SPACE_WARP_LINE_LENGTH, z] 형태로 리턴해준다.
앞 세 좌표가 한쪽 끝, 뒤 세 좌표가 다른 한 쪽 끝 좌표라고 생각하면 된다.
나는 선을 수직으로 만들기 위해 양쪽 끝의 x, z 좌표를 같게 하고 y좌표는 선의 길이만큼 차이나게 넣어주었다.

다음으로 선의 색상을 결정하는 colors 배열을 살펴보자.

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

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

여기서는 BACKGROUND_STAR_COLORS 내에서 랜덤으로 색상을 추출하여, 해당 색상의 r, g, b값을 배열로 리턴한다.
결과적으로 (선 개수 * 3) 의 길이를 갖게 된다.

나는 BACKGROUND_STAR_COLORS에 서비스 UI 색과 비슷한 컨셉을 유지하기 위해 파랑-보라 계열로 색상들을 넣어주었는데, 각자 마음에 드는 색들을 넣어주면 되겠다.

이후로는 return [new Float32Array(positions), new Float32Array(colors)]; 와 같이 Float32Array 형식으로 변경하여 데이터를 리턴해준다.


2. 카메라 이동

import { useFrame } from '@react-three/fiber';
import { getRandomFloat, getRandomInt } from '@utils/random';
import React, { useMemo } from 'react';
import * as THREE from 'three';

const SPACE_WARP_LINES_NUM = 1300;  
const SPACE_WARP_LINE_LENGTH = 18000; 

const SPACE_WARP_Y_MAX = 100000; 
const SPACE_WARP_Y_MIN = 0; 
const SPACE_WARP_XZ_MAX = 10000; 
const SPACE_WARP_XZ_MIN = -10000; 

const SPACE_WARP_LINE_COLORS = [
	'#627BFF',
	'#3A5AFF',
	'#6D3AFF',
	'#9734FF',
	'#EFE0FF',
	'#DDDDDD',
	'#FDFFE2',
]; 

const getSpaceWarpLinesInfo = () => {
	const positions = Array.from({ length: SPACE_WARP_LINES_NUM }, () => {
		const x = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX);
		const y = getRandomFloat(SPACE_WARP_Y_MIN, SPACE_WARP_Y_MAX);
		const z = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX);

		return [x, y, z, x, y - SPACE_WARP_LINE_LENGTH, z];
	}).flat();

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

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

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

// 여기까지 위에서 다룬 내용

type WarpStateType = 'warp' | 'fade' | 'end';

interface PropsType {
	setIsSwitching: React.Dispatch<React.SetStateAction<WarpStateType>>;
}

export default function SpaceWarp({ setIsSwitching }: PropsType) {
	const [positions, colors] = useMemo(() => getSpaceWarpLinesInfo(), []); // positions, colors에 결과 getSpaceWarpLinesInfo 결과값 할당

	useFrame((state, delta) => {
		if (state.camera.position.y <= SPACE_WARP_Y_MIN) { // 카메라가 끝에 도달하면
			state.scene.background = new THREE.Color(0xffffff); // 배경을 하얗게 변경
			setIsSwitching('fade'); // isSwitching의 값을 'fade'로 변경
			return;
		}
      
		state.camera.position.y -= 75000 * delta; // camera의 y 좌표 값을 계속 감소시킴
	});

	return (
		<lineSegments>
			<bufferGeometry attach="geometry">
				<bufferAttribute
					attach="attributes-position"
					count={SPACE_WARP_LINES_NUM}
					array={positions}
					itemSize={3}
				/>
				<bufferAttribute
					attach="attributes-color"
					count={SPACE_WARP_LINES_NUM}
					array={colors}
					itemSize={3}
				/>
			</bufferGeometry>

			<lineBasicMaterial attach="material" vertexColors={true} />
		</lineSegments>
	);
}

그리고 getSpaceWarpLinesInfo()의 결과값을 positions, colors에 각각 할당해준다.
getSpaceWarpLinesInfo()의 계산량이 많으므로 성능 개선을 위해 useMemo를 사용해주었다.

이제 useFrame 내에서 Camera의 이동에 관한 로직을 작성해보자.
Camera의 y 좌표 값이 SPACE_WARP_Y_MIN, 즉 y 범위의 가장 끝에 도달하면 빨려들어가는 느낌을 주는 애니메이션을 끝내야 한다.

그러려면 먼저 Scene의 배경색을 흰색으로 지정해준다.
흰색 원에 가까워지다가 배경색을 아예 흰색으로 바꿔줌으로써 원 속으로 빨려들어간 느낌을 주기 위해서이다.

그리고 isSwitching값을 fade로 바꾼다.

이외의 경우, 즉 Camera가 끝까지 도달하기 전에는 75000 * delta값만큼 Camera의 y 좌표값을 계속 감소시킨다.
delta는 useFrame 함수의 두 번째 인자로 전달되며, 이전 프레임과 현재 프레임 사이의 시간 간격을 나타낸다.
delta 값이 크면 Camera가 더 빠르게 이동하고, delta 값이 작으면 더 느리게 이동한다.

state.camera.position.y -= 75000; 와 같이 특정 수만큼 y 좌표값을 감소시키지 않고 75000 * delta만큼 감소시키는 이유는, 사용자의 장치 성능과 관계없이 동일한 애니메이션 경험을 제공하기 위해서이다.

여기에 대해 조금만 더 자세히 이야기해보겠다.
프레임 레이트라는 개념이 있는데, 단위 시간당 화면이 갱신되는 횟수를 뜻한다.
프레임 레이트가 60FPS라면 1초에 화면이 60번 갱신된다는 뜻이다.
성능이 좋은 장치에서는 높은 프레임 레이트를 유지할 수 있지만, 성능이 나쁜 장치에서는 낮은 프레임 레이트를 보이기도 한다.

그래서 delta를 사용하지 않는다면, 프레임 레이트가 높은 장치에서는 빠르게 움직이고 프레임 레이트가 낮은 장치에서는 느리게 움직이게 되는 것이다.
이전 프레임과 현재 프레임 사이 시간 간격인 delta를 사용함으로써 프레임 레이트와 관계없이 일정한 속도를 유지할 수 있게 된다.

실제로 처음에는 delta를 사용하지 않았는데, 컴퓨터마다 이동 속도가 다르게 보여서 당황했다...

이렇게 SpaceWarp에서 Camera를 이동시키고, Camera가 원점에 도달했을 때 특정 작업을 수행하는 것까지 해보았다.
이제 다시 WarpScreen.tsx 쪽으로 돌아가서 isSwitching 값에 따라 리턴값을 다르게 처리해보자.


🍞 WarpScreen 케이스 분리

아래는 isSwitchingwarp가 아닐 때 리턴값을 별도로 추가한 코드이다.
warp일 때, fade일 때, end 일 때 리턴값을 각각 다르게 처리해주었다.

import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { Canvas } from '@react-three/fiber';
import { Bloom, EffectComposer } from '@react-three/postprocessing';
import { theme } from 'shared/styles';
import BrightSphere from './ui/BrightSphere';
import SpaceWarp from './ui/SpaceWarp';

type WarpStateType = 'warp' | 'fade' | 'end';

interface PropsType {
	isSwitching: WarpStateType;
	setIsSwitching: React.Dispatch<React.SetStateAction<WarpStateType>>;
}

export default function WarpScreen({ isSwitching, setIsSwitching }: PropsType) {
	const camera = {
		position: [0, 100000, 0], 
		up: [0, 0, 1], 
		far: 100000, 
	};

	const canvasStyle: React.CSSProperties = {
		position: 'absolute',
		height: '100vh',
		width: '100vw',
		zIndex: 999,
		backgroundColor: '#000000',
	};

    // SpaceWarp가 끝났을 때
	if (isSwitching === 'end') return null; 

    // SpaceWarp에서 다음 화면으로 넘어갈 때
	if (isSwitching === 'fade')
		return <FadeoutScreen onAnimationEnd={() => setIsSwitching('end')} />; 

    // SpaceWarp 진행중일 때
	return (
		<Canvas camera={camera} style={canvasStyle}>
			<EffectComposer>
				<Bloom
					intensity={2} 
					mipmapBlur={true} 
					luminanceThreshold={0.55} 
					luminanceSmoothing={0} 
				/>
			</EffectComposer> 
        
        	<BrightSphere /> // BrightSphere 추가 
			<SpaceWarp setIsSwitching={setIsSwitching} /> // SpaceWarp 추가

			<ambientLight intensity={15} />
		</Canvas>
	);
}

const fadeout = keyframes`
	0% {
		opacity: 1;
	}
	100% {
		opacity: 0;
		display: none;
	}
`;

const FadeoutScreen = styled.div`
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: 101;
	background-color: white;
	animation: ${fadeout} 0.5s linear forwards;
`;

먼저 isSwitchingend일 때는 더 이상 WarpScreen을 보여줄 필요가 없으므로 null을 리턴한다.

다음 isSwitchingfade일 때는 FadeoutScreen을 리턴하는데, 이 부분 코드를 살펴보자.

const fadeout = keyframes`
	0% {
		opacity: 1;
	}
	100% {
		opacity: 0;
		display: none;
	}
`;

keyframe을 이용해서 fadeout 애니메이션을 만들었다.
애니메이션 시작 시에는 opacity가 1이었다가 종료 시에 0으로 설정함으로써, 요소가 서서히 사라지는 시각적인 효과를 주었다.

const FadeoutScreen = styled.div`
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: 101;
	background-color: white;
	animation: ${fadeout} 0.5s linear forwards;
`;

다음으로 FadeoutScreen을 정의하여 fadeout 애니메이션을 적용했다.
결과적으로 FadeoutScreen은 처음에는 하얀색 화면이었다가, 서서히 투명해지며 0.5초 뒤에는 보이지 않게 된다.

SpaceWarp가 끝난 후 갑자기 다음 화면이 뿅 나타나는 듯한 느낌을 없애고, 최대한 자연스럽게 화면이 전환되는 느낌을 주고싶어서 이렇게 처리했다.

if (isSwitching === 'fade')
		return <FadeoutScreen onAnimationEnd={() => setIsSwitching('end')} />; 

그리고 FadeoutScreenonAnimationEnd를 적용해주었다.
onAnimationEnd는 CSS 애니메이션이 끝났을 때 발생하는 이벤트이다.
여기서는 fadeout 애니메이션이 끝나면 isSwitchingend로 바꿔준다.

정리해보자면 아래와 같이 진행된다.

  1. SpaceWarp 애니메이션이 보여짐
  2. Camera가 원점에 도달하며 isSwitching이 fade로 바뀜
  3. SpaceWarp 대신 FadeoutScreen이 띄워짐
  4. 0.5초 뒤 isSwitching이 end로 바뀜
  5. WarpScreen 사라짐

🍞 전체 코드 & 결과 화면

이제 전체적으로 코드를 다시 살펴보자.
전체 흐름을 보는게 목적이기에 숫자들은 모두 상수로 대체하고 import도 삭제했다.

WarpScreen.tsx

interface PropsType {
	isSwitching: WarpStateType;
	setIsSwitching: React.Dispatch<React.SetStateAction<WarpStateType>>;
}

export default function WarpScreen({ isSwitching, setIsSwitching }: PropsType) {
	const camera = {
		position: SPACE_WARP_CAMERA_POSITION,
		up: SPACE_WARP_CAMERA_UP,
		far: SPACE_WARP_CAMERA_FAR,
	};

	const canvasStyle: React.CSSProperties = {
		position: 'absolute',
		height: '100vh',
		width: '100vw',
		zIndex: 999,
		backgroundColor: theme.colors.background.bdp04,
	};

	if (isSwitching === 'end') return null;

	if (isSwitching === 'fade')
		return <FadeoutScreen onAnimationEnd={() => setIsSwitching('end')} />;

	return (
		<Canvas camera={camera} style={canvasStyle}>
			<EffectComposer>
				<Bloom
					intensity={BLOOM_INTENSITY}
					mipmapBlur={BLOOM_MIMPAP_BLUR}
					luminanceThreshold={BLOOM_LUMINANCE_THRESHOLD}
					luminanceSmoothing={BLOOM_LUMINANCE_SMOOTHING}
				/>
			</EffectComposer>

			<ambientLight intensity={AMBIENT_LIGHT_INTENSITY} />
			<BrightSphere />
			<SpaceWarp setIsSwitching={setIsSwitching} />
		</Canvas>
	);
}

const fadeout = keyframes`
	0% {
		opacity: 1;
	}
	100% {
		opacity: 0;
		display: none;
	}
`;

const FadeoutScreen = styled.div`
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: 101;
	background-color: white;
	animation: ${fadeout} 0.5s linear forwards;
`;

SpaceWarp.tsx

const getSpaceWarpLinesInfo = () => {
	const positions = Array.from({ length: SPACE_WARP_LINES_NUM }, () => {
		const x = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX);
		const y = getRandomFloat(SPACE_WARP_Y_MIN, SPACE_WARP_Y_MAX);
		const z = getRandomFloat(SPACE_WARP_XZ_MIN, SPACE_WARP_XZ_MAX);

		return [x, y, z, x, y - SPACE_WARP_LINE_LENGTH, z];
	}).flat();

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

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

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

interface PropsType {
	setIsSwitching: React.Dispatch<React.SetStateAction<WarpStateType>>;
}

export default function SpaceWarp({ setIsSwitching }: PropsType) {
	const [positions, colors] = useMemo(() => getSpaceWarpLinesInfo(), []);

	useFrame((state, delta) => {
		if (state.camera.position.y <= SPACE_WARP_Y_MIN) {
			state.scene.background = new THREE.Color(0xffffff);
			setIsSwitching('fade');
			return;
		}
		state.camera.position.y -= 75000 * delta;
	});

	return (
		<lineSegments>
			<bufferGeometry attach="geometry">
				<bufferAttribute
					attach="attributes-position"
					count={SPACE_WARP_LINES_NUM}
					array={positions}
					itemSize={3}
				/>
				<bufferAttribute
					attach="attributes-color"
					count={SPACE_WARP_LINES_NUM}
					array={colors}
					itemSize={3}
				/>
			</bufferGeometry>

			<lineBasicMaterial attach="material" vertexColors={true} />
		</lineSegments>
	);
}

BrightSphere.tsx

export default function BrightSphere() {
	return (
		<mesh position={[0, 0, 0]}>
			<sphereGeometry attach="geometry" args={[600, 32, 32]} />
			<meshStandardMaterial attach="material" color="white" />
		</mesh>
	);
}

전체 코드를 보고싶다면 프로젝트 코드를 참고하길 바란다 💋

그럼 이제 결과 화면을 보자.
아래 gif는 로그인 후 내 우주로 이동할 때 WarpScreen을 적용한 모습이다.
gif라서 살짝 버벅거리는 것처럼 보이지만 🥹 실제로는 부드럽게 동작한다.

그럼이만


참고자료

2개의 댓글

comment-user-thumbnail
2024년 5월 23일

블로그 글 잘봤습니다! 프론트엔드 개발하며 궁금했던 사항인데 많이 참조가 되었습니다!! 종종 들러 글 확인하겠습니다

1개의 답글