blcklamb에 어울리는 랜딩페이지를 만들어 보아요.
언젠가 나만의 포트폴리오 페이지에서 3D 그래픽을 추가하고 싶어 해당 라이브러리들을 추가하게 되었습니다. 우선은 랜딩페이지에만 적용이 되었는데, 해당 부분에서 점진적으로 여러 가지를 시도해보려고 합니다. 다른 3D 그래픽 라이브러리를 사용해보진 못했으나, 지금껏 학습 및 실습해 본 결과 다음과 같은 이점이 있습니다.
r3f가 생소하다고 생각하시는 분들은 r3f를 운영하고 있는 pmnd에서 만든 다른 라이브러리를 들으시면 내적 친밀감을 얻으실 수 있을 것입니다. 전역 상태관리 라이브러리인 jotai, zustand, valtio, react 기반 애니메이션 라이브러리인 react-spring도 pmnd에서 전부 시작되고 관리되고 있는 라이브러리입니다.
yarn add @react-three/drei @react-three/fiber @react-three/postprocessing @types/three three
tailwind를 주 css 프레임워크로 쓰고 있다면 익숙한 라이브러리들 입니다.
tailwind는 코드를 실행시키거나 파싱하지 않고 html의 className에 적힌 것을 그대로 가져다가 정규식을 돌려 tailwind 상의 규칙에 해당되는 것이 있다면 스타일을 씌우는 동작 방식을 가지고 있습니다. 그리하여 불완전한 상태의 utility를 이용한 동적 스타일링이 불가능합니다. 이럴 경우 inline style을 사용하는 방법도 있지만 상위 컴포넌트에서 className을 부여하여 커스텀해야할 경우 해당 방법을 사용할 수 없습니다. 이 때 필요한 라이브러리가 바로 조건부 className의 문자열을 완전한 형태로 합쳐주는 clsx, 또한 중복되는 충돌하는 클래스를 제거하는 tailwind-merge입니다.
해당 두 라이브러리가 합쳐져 아래의 util 함수로 만들어진 다음 쓰는 것이 일반적입니다.
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
이 함수 외에는 쓰이는 곳이 없으니 아예 cn이라는 라이브러리만 있었다면 더 좋지 않았을까 하는 생각이 듭니다.
yarn add clsx tailwind-merge
랜딩페이지로서 꽤나 그럴싸하게 만들어야 겠다는 생각에 세련된 스타일 컴포넌트들을 찾던 중, aceternity UI를 알게 되었습니다. 모던한 디자인의 웹사이트에 있을 법한 여러 스타일 컴포넌트들이 있고, 해당 컴포넌트의 코드를 그대로 copy&paste만 하면 되는, 편리한 UI 템플릿입니다. 그중에서 저는 sparkles를 추가하고 싶었습니다.
추가 방법은 manual이 친절하게 명세돼 있어서 어려울 것이 하나도 없습니다. 하지만 몇 번 써보니 다음과 같은 문제점이 있습니다. sparkles는 아니었지만 간혹 다른 컴포넌트에서 eslint 규칙이 어긋난 코드들이 있어 해당 부분들에 대해 eslint-disabled 해야하는 점이 있었습니다. 또한 event handler와 관련된 커스텀이 들어가야할 경우 handler의 props가 이미 정해져 있어 확장시키기 어려운 점이 있었습니다. (예시. File Upload)
yarn add @tsparticles/engine @tsparticles/react @tsparticles/slim framer-motion
먼저 둥둥 띄우고 싶은 asset을 가져와야 합니다.
blender나 웹 3D 모델 편집기인 spline로 직접 그려도 되지만, 빠르게 작업하기 위해 전문가들이 만들어 놓은 asset을 가져오기로 했습니다. 다양한 사이트가 있는데 저는 마음에 드는 asset이 있는 sketchfab으로 선택했습니다.
이렇게 원하는 모델을 찾았다면 [Download 3D Model]을 클릭하여 3D 모델 파일을 다운받으면 됩니다. gltf, glb 둘 중 상황에 맞는 것을 선택하면 됩니다.
참고로 저작권 규정에 맞추어 첨부되어야 할 credit이 있다면 반드시 작성해주어야 합니다. 해당 credit을 첨부하는 것은 6단계에서 다시 붙여보도록 하겠습니다.
저는 양을 검은색으로 편집하고 싶었기 때문에 처음에는 해당 모델을 앞서 언급했던 spline에서 직접 편집하려고 했습니다. 하지만 color를 추출하려고 하니 유료 플랜을 결제해야하더라구요. 그래서 어쩔 수 없이 r3f 내에서 직접 mesh에 접근하는 것으로 방법을 생각해 보았습니다.
먼저 모델을 불러올 컴포넌트를 작성합니다.
SampleModel.tsx
"use client";
import { useGLTF } from "@react-three/drei";
import { useMemo } from "react";
export function SampleModel() {
const { scene } = useGLTF("/sheep.glb");
const clonedScene = useMemo(() => scene.clone(), [scene]);
return <primitive object={clonedScene} />;
}
해당 모델 컴포넌트를 감싸는 캔버스 컴포넌트를 렌더하면 아래처럼 보이게 됩니다.
SampleCanvas.tsx
import { Canvas } from "@react-three/fiber";
export function SampleCanvas() {
return (
<Canvas>
<SampleModel />;
<ambientLight intensity={1} />
</Canvas>
);
}
목표는 양의 털을 검게 변경하는 것이니 캔버스를 새로운 div로 감싸 배경을 바꿔보겠습니다.
SampleCanvas.tsx
import { Environment } from "@react-three/drei";
export function SampleCanvas() {
return (
<div className="bg-white h-screen">
<Canvas flat dpr={[1, 1.5]}>
<SampleModel />;
<ambientLight intensity={1} />
<Environment preset="forest" />
</Canvas>
</div>
);
}
오우 조금 부담스럽지만 그래도 양의 털만 바꾸면 되니깐 냅둡시다.
여기서 전체 모델의 색을 변경하는 것이 아니라 양의 '털'만 바꾸기 위해서 모델이 어떻게 구성되어 있는지 뜯어볼 필요가 있습니다. 그러기 위해 r3f에서 제공하는 useThree 훅을 사용했습니다.
SampleModel.tsx
import { useThree } from "@react-three/fiber";
export function SampleModel() {
const { scene } = useGLTF("/sheep.glb");
const clonedScene = useMemo(() => scene.clone(), [scene]);
console.log("🐏", clonedScene);
return <primitive object={clonedScene} />;
}
먼저 '털'에 접근해야합니다. scenc의 children을 타고타고 들어가다보면 Object3D를 요소로 가진 배열을 볼 수 있는데, 해당 부분이 양의 털, 양의 다리, 양의 머리 등 각 요소를 별도로 가지고 있는 것을 확인할 수 있고, 이제 양의 털만 집어서 색상을 변경하면 됩니다.
3D 모델에서 색상을 결정하는 것은 Material, 그중에서 MeshStandardMaterial의 프로퍼티에 'color'가 있습니다. console에 찍힌 것처럼 제가 불러온 모델은 MeshPhysicalMaterial이지만 해당 Material은 MeshStandardMaterial의 프로퍼티를 공통으로 가지고 있습니다. 'color'가 어딨는지 찾았으니 이제 검정으로 대체하는 코드를 작성하면 됩니다.
SampleModel.tsx
import { useThree } from "@react-three/fiber";
import { Color, Mesh, MeshStandardMaterial } from "three";
export function SampleModel() {
const { scene } = useGLTF("/sheep/scene.gltf");
const clonedScene = useMemo(() => scene.clone(), [scene]);
const { raycaster } = useThree();
const intersects = raycaster.intersectObjects(clonedScene.children);
for (const intersect of intersects) {
if (intersect.object.name.includes("Fur")) {
const firstObj = intersect.object as Mesh;
const firstMaterial = firstObj.material as MeshStandardMaterial;
firstMaterial.color = new Color("black");
}
}
return <primitive object={clonedScene} />;
}
짜잔!
(참고. 양의 크기가 작아진 건 제가 OrbitControls를 추가했기 때문입니다)
이제 모델이 자리에서 움직이는 것을 구현해보겠습니다.
각 코드별 설명에 대해 주석을 달아보았습니다. 참고로 MovingModel
의 props로 index는 0, speed는 1, z는 0으로 임의의 값을 설정했습니다.
import { useRef, useState } from "react";
import { SampleModel } from "./Sample";
import { useFrame, useThree } from "@react-three/fiber";
import { MathUtils, Mesh } from "three";
export function MovingModel({
index,
z,
speed,
}: {
index: number;
z: number;
speed: number;
}) {
const ref = useRef<Mesh>(null!);
const { viewport, camera } = useThree();
// 뷰포트의 너비, 높이를 계산해줍니다.
const { width, height } = viewport.getCurrentViewport(camera, [0, 0, -z]);
// 위치에 대해 고정값을 정해줍니다.
const [data] = useState({
// 높이의 두 배 정도를 잡아 랜덤한 값을 지정합니다.
y: MathUtils.randFloatSpread(height * 2),
// -1과 1 사이의 랜덤한 값으로, 추후 너비 배율로 조정할 예정입니다.
x: MathUtils.randFloatSpread(2),
// 얼마나 빠르게 돌 것인지 랜덤한 값을 지정합니다.
spin: MathUtils.randFloat(8, 12),
// x축, z축으로의 회전 초기값을 랜덤하게 지정합니다.
rX: Math.random() * Math.PI,
rZ: Math.random() * Math.PI,
});
// useFrame은 초당 60번 실행됩니다.
useFrame((state, dt) => {
// X 위치를 반응형으로 만들고, Y축으로 천천히 객체를 스크롤하며, Z축을 따라 분배합니다.
// dt는 델타로, 이 프레임과 이전 프레임 사이의 시간입니다. 화면 새로고침 속도와 독립적이게 하기 위해 사용할 수 있습니다.
// dt를 0.1로 제한하여 사용자가 탭을 변경하는 동안 누적되지 않고 단순히 멈추도록 합니다.
if (dt < 0.1)
if (ref.current) {
ref.current.position.set(
index === 0 ? 0 : data.x * width,
(data.y += dt * speed),
-z
);
}
// 객체를 회전시킵니다.
if (ref.current) {
ref.current.rotation.set(
(data.rX += dt / data.spin),
Math.sin(index * 1000 + state.clock.elapsedTime / 10) * Math.PI,
(data.rZ += dt / data.spin)
);
}
// 지정된 높이 이상을 올라갔다면(화면 밖으로 벗어나간 다음 그 이상) 다시 아래로 위치를 변경합니다.
if (data.y > height * (index === 0 ? 4 : 1))
data.y = -(height * (index === 0 ? 4 : 1));
});
return (
<mesh ref={ref}>
<SampleModel />
</mesh>
);
}
이렇게 되면 위로 뜨는 양이 나오게 됩니다. 만약 아래로 떨어지는 양을 구현하고 싶으면 data.y
를 증가가 아니라 감소시키면 되겠죠!
MovingModel
에 정적으로 설정한 props를 동적으로 조절하기 위해 input을 포함한 컴포넌트를 만들어봅시다. 3D 객체와 직접적으로 인터렉션 하는 것이 아닌, 단지 3D 객체의 값을 조정하는 기능을 추가할 때는, Canvas 안에 controller 역할의 요소를 집어넣는 것보다 Canvas 위에 별도의 div 태그로 감싸진 controller 요소를 두는 것이 일반적입니다.
SampleCanvas.tsx
"use client";
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { MovingModel } from "./MovingSample";
export function SampleCanvas() {
return (
<div className="bg-white h-screen">
<Canvas flat dpr={[1, 1.5]}>
<OrbitControls />
<MovingModel index={0} speed={1} z={0} />;
<ambientLight intensity={1} />
</Canvas>
</div>
);
}
SampleCanvas.tsx
"use client";
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { MovingModel } from "./MovingSample";
import { useState } from "react";
export function SampleCanvas() {
const [speed, setSpeed] = useState(1);
return (
<div className="bg-white h-screen relative">
<div className="absolute z-30 bottom-1/2 md:right-1 -right-6 transform rotate-90">
<input
type="range"
min={0}
max={10}
value={speed}
step={1}
onChange={(e) => setSpeed(Number(e.target.value))}
className="appearance-none cursor-pointer bg-gray-200 rounded-lg w-full h-2 accent-slate-500"
/>
</div>
<Canvas flat dpr={[1, 1.5]}>
<OrbitControls />
<MovingModel index={0} speed={speed} z={0} />
<ambientLight intensity={1} />
</Canvas>
</div>
);
}
이렇게 오른쪽에 input을 두고 value에 따라 양이 떠오르는 속도를 조절할 수 있는 것을 확인할 수 있습니다.
하나만 있으면 심심하니, 여러 양을 랜덤한 위치에 두어보도록 합시다. MovingModel
은 z와 index에 의해 위치가 지정되고, 각 모델마다 정해진 width, height를 갖게 됩니다. 따라서 원하는 양의 갯수만큼의 길이를 가진 Array의 index에 따라서 적절히 분배된 값을 z에 주입하면 됩니다. 해당 값은 easing이라는 util 함수로 겹치지 않게 분포를 시켜주었습니다.
"use client";
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { MovingModel } from "./MovingSample";
import { useState } from "react";
export function SampleCanvas() {
const COUNT = 120;
const easing = (x: number) => Math.sqrt(1 - Math.pow(x - 1, 2));
const [speed, setSpeed] = useState(1);
return (
<div className="bg-white h-screen relative">
<div className="absolute z-30 bottom-1/2 md:right-1 -right-6 transform rotate-90">
<input
type="range"
min={0}
max={10}
value={speed}
step={1}
onChange={(e) => setSpeed(Number(e.target.value))}
className="appearance-none cursor-pointer bg-gray-200 rounded-lg w-full h-2 accent-slate-500"
/>
</div>
<Canvas flat dpr={[1, 1.5]}>
<OrbitControls />
{Array.from({ length: COUNT }, (_, i) => (
<MovingModel
key={i}
index={i}
z={Math.round(easing(i / COUNT) * 80)}
speed={speed}
/>
))}
<ambientLight intensity={1} />
</Canvas>
</div>
);
}
COUNT
의 경우도 조절할 수 있으나, 해당 값은 아직 인터렉티브하게 가고 싶진 않아서 별도 처리를 하진 않았습니다. 참고로, 30, 80, 120일때의 분포 정도를 아래 캡쳐로 첨부하였습니다.
아무래도 넉넉하게 떠 다니는 게 더 마음에 듭니다.
이제 대망의 랜딩 제목을 꾸며보도록 합시다. 저는 최근 빠르고 세련된 디자인을 위해 앞서 추가된 라이브러리에서 언급했던 aceternity UI를 도입했습니다.
sparkles를 추가하기 위해 컴포넌트 코드를 그대로 가져오고, 지금까지 작업한 컴포넌트보다 높은 z-index를 가진 Overlay 컴포넌트를 만들어보았습니다. 텍스트는 반응형으로 적용했습니다.
LandingOverlay.tsx
import { SparklesCore } from "./ui/sparkles";
export default function LandingOverlay() {
return (
<div className="absolute top-0 z-20 w-full h-full flex flex-col justify-center text-center">
<div className="flex flex-col items-center justify-center">
<span className="absolute z-10 mx-auto text-white flex font-bold text-center ">
깜냥을 쫓는 개발자, 김채정입니다.
</span>
<span className="relative top-0 w-fit h-auto justify-center blur-sm flex bg-gradient-to-r items-center from-blue-500 via-teal-500 to-pink-500 bg-clip-text font-bold text-transparent text-center select-auto">
깜냥을 쫓는 개발자, 김채정입니다.
</span>
</div>
<div className="flex flex-col items-center justify-center">
<span className="absolute mx-auto py-4 flex border w-fit bg-gradient-to-r blur-xl from-blue-500 via-teal-500 to-pink-500 bg-clip-text text-3xl md:text-6xl box-content font-extrabold text-transparent text-center select-none">
BLCKLAMB.LOG
</span>
<h1 className="relative top-0 w-fit h-auto py-4 justify-center flex bg-gradient-to-r items-center from-blue-500 via-teal-500 to-pink-500 bg-clip-text text-3xl md:text-6xl font-extrabold text-transparent text-center select-auto">
BLCKLAMB.LOG
</h1>
</div>
<div className="w-full absolute inset-0 h-screen">
<SparklesCore
id="tsparticlesfullpage"
background="transparent"
minSize={0.6}
maxSize={1.4}
particleDensity={100}
className="w-full h-full"
particleColor="#FFFFFF"
/>
</div>
<div className="text-gray-400 text-xs z-10 absolute bottom-1 text-center w-full">
<span>
<a href="https://skfb.ly/oERRF">"Sheep"</a>
</span>
by Kinga Kroliczek is licensed under
<span>
<a href="http://creativecommons.org/licenses/by/4.0/">
Creative Commons Attribution
</a>
</span>
</div>
</div>
);
}
작업을 마무리할 때쯤, 이전 토스페이스를 클론코딩해보자 포스팅에서 추가하고 싶은 기능으로 Suspense가 있었다는 것을 기억했습니다. 그래서 이번 랜딩페이지 제작기에는 Model load가 완전히 끝나는 시점을 알려주는 Suspense의 fallback에 넣어줄 Loader를 추가해보았습니다.
Loader.tsx
import { Html, useProgress } from "@react-three/drei";
export default function Loader() {
const { progress } = useProgress();
return (
<Html
center
className="bg-black/80 w-screen h-screen absolute z-50 flex justify-center items-center"
>
{progress} % loaded
</Html>
);
}
이 포트폴리오 프로젝트 세팅을 1년 전에 한 다음 먼지 털어서 다시 작업하는 건데 당시 yarn berry로 패키지 매니저를 선택하게 되었습니다.
그런데 오랜만에 작업을 시작하니 현재 굴러가는 라이브러리와 기존 라이브러리의 버전 충돌이 났었고, 이에 대한 변경 파일에 대해 git이 꼬여버려 작업을 시작하는 데 가장 많은 시간이 들었었습니다.
(혹시 짧다란 스크롤바가 보이시나요... 98%가 전부 yarn package 관련 파일입니다)
협업하지 않는 환경에서 패키지 매니저로 yarn berry를 쓰는 것은 좋은 선택이 아니란 것을 깨달았고, 더불어 냅다 쓰기보다는 패키지 매니저의 작동 원리를 파악하는 것이 중요한 것을 깨달았습니다. 이에 대해서는 추후 변경하는 작업을 기록한 포스팅을 남겨보도록 하겠습니다.
이번 개발에서는 앞서 서술한대로 1년 전 세팅, 이른바 레거시에서 다시 작업을 한 경험이었는데요, 1년동안 kebab-case에 너무 익숙해진 것 같습니다. PascalCase로 작성된 파일명들이 미관상 불편해서 이 부분은 점진적으로 변경할 예정입니다.
오랜만의 포트폴리오 프로젝트를 마주하니, 1년 전의 저와 조우하는 느낌이 들었습니다. 프로젝트는 계속해서 발전시키는 것에 대한 필요성을 깨닫게 된 경험이기도 했습니다.
포스팅 중 의아한 부분, 개선되었으면 하는 부분은 물론 랜딩페이지에 대한
감상평도 좋으니 피드백을 댓글로 작성해주시면 감사하겠습니다!