개요
Three.js를 활용하여 스크롤에 반응하는 동적인 3D 배경을 만드는 방법을 단계별로 알아보겠습니다. 사용자의 스크롤에 따라 카메라가 움직이고, 마우스 위치에 따라 패럴랙스 효과가 나타나며, 특정 섹션에 도달했을 때 오브젝트 애니메이션이 실행되는 인터랙티브한 웹페이즈를 만들어 봅시다.
기본 환경 설정 및 배경 투명화
가장 먼저 HTML, CSS 그리고 Three.js의 기본 구조를 설정합니다. 초기 문제점은 스크롤 시 Three.js 캔버스 영역 외에 흰색 배경이 나타나는 것입니다. 이를 해결하기 위해 캔버스를 투명하게 만들고 HTML 배경색이 보이도록 설정해야 합니다.
핵심 수정 사항:
- WebGLRenderer 설정 변경: alpha: true 옵션을 추가하여 캔버스 배경을 투명하게 만듭니다.
- CSS 배경색 지원: html과 body에 원하는 배경색을 지정합니다.
HTML(index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scroll base animation</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<canvas class="webgl"></canvas>
<section class="section">
<h1>My Portfolio</h1>
</section>
<section class="section">
<h2>My projects</h2>
</section>
<section class="section">
<h2>Contact me</h2>
</section>
<script type="module" src="./script.js"></script>
</body>
</html>
JavaScript(script.js)
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
alpha: true // 캔버스 배경을 투명하게 만듭니다.
});
// renderer.setClearAlpha(0); // 이 방법도 가능합니다.
CSS(style.css)
*
{
margin: 0;
padding: 0;
}
html,
body
{
background-color: #1e1a20;
}
.webgl
{
position: fixed;
top: 0;
left: 0;
outline: none;
}
.section
{
display: flex;
align-items: center;
height: 100vh;
position: relative;
font-family: 'Cabin', sans-serif;
color: #ffeded;
text-transform: uppercase;
font-size: 7vmin;
padding-left: 10%;
padding-right: 10%;
}
section:nth-child(odd)
{
justify-content: flex-end;
}
이제 Three.js 캔버스는 투명해지고, 그 뒤로 CSS에서 지정한 어두운 배경색이 보이게 되어 스크롤에도 이질감 없는 화면을 유지할 수 있습니다.
오브젝트와 머터리얼 설정
이제 Scene에 표시할 3D Object(MEsh)들을 추가하고 미적인 감각을 더해줄 머터리얼을 적용해 보겠습니다. 여기서는 독특한 스타일을 위해 MeshToonMaterial을 사용합니다.
/**
* Objects
*/
// 머티리얼
const textureLoader = new THREE.TextureLoader();
const gradientTexture = textureLoader.load('textures/gradients/3.jpg');
gradientTexture.magFilter = THREE.NearestFilter; // 그라데이션을 툰 렌더링처럼 끊어지게 표현
const material = new THREE.MeshToonMaterial({
color: parameters.materialColor,
gradientMap: gradientTexture
});
// 메쉬
const objectsDistance = 4; // 오브젝트 사이의 간격
const mesh1 = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material);
const mesh2 = new THREE.Mesh(new THREE.ConeGeometry(1, 2, 32), material);
const mesh3 = new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16), material);
// 위치 설정
mesh1.position.y = -objectsDistance * 0;
mesh2.position.y = -objectsDistance * 1;
mesh3.position.y = -objectsDistance * 2;
// x축 위치 조절 (겹치지 않도록)
mesh1.position.x = 2;
mesh2.position.x = -2;
mesh3.position.x = 2;
scene.add(mesh1, mesh2, mesh3);
const sectionMeshes = [mesh1, mesh2, mesh3];
/**
* Lights
*/
const directionalLight = new THREE.DirectionalLight('#ffffff', 3);
directionalLight.position.set(1, 1, 0);
scene.add(directionalLight);
주요 포인트:
- MeshToonMaterial: 이 머터리얼은 빛의 영향을 받아 만화 같은 음영을 만듭니다. 따라서 Scene에서는 반드시 조명(Light)이 필요합니다.
- gradientMap: 툰 렌더링의 음영 단계를 그라데이션 텍스처를 이용해 다채롭게 표현할 수 있습니다.
- magFilter = THREE.NearestFilter: 텍스처를 확대할 때 픽셀을 보간(부드럽게 섞는)하는 대신 가장 가까운 픽셀 색상을 그대로 사용합니다. 이를 통해 툰 렌더링 특유의 딱딱 끊어지는 음영을 만들 수 있습니다.
- Debug UI 연동: lil-gui로 색상을 변경할 때 실시간으로 머터리얼에 반영되도록 .onChange() 콜백 함수를 사용합니다.
스크롤과 카메라 연동하기
이제 가장 핵심적인 기능인 '스크롤에 따른 카메라 이동'을 구현할 차례입니다. 사용자가 페이지를 스크롤하는 만큼 카메라의 y축 ㅜ이치를 이동시켜 마치 아래로 탐험하는 듯한 느낌을 줍니다.
/**
* Scroll
*/
let scrollY = window.scrollY;
window.addEventListener('scroll', () => {
scrollY = window.scrollY;
});
// tick 함수 내부
const tick = () => {
// ... 이전 코드
// 카메라 위치 업데이트
camera.position.y = -scrollY / sizes.height * objectsDistance;
// ... 렌더링 코드
}
작동 원리:
- -scrollY: 스크롤 값(scrollY)에 음수 부호를 붙여 방향을 맞춥니다. 사용자가 아래로 스크롤할 때(값이 증가) 카메라가 3D 공간의 아래(Y축 음수 방향)으로 이동하도록 합니다.
- / sizes.height: 스크롤 값을 현재 화면 높이로 나누어 '스크롤 진행률'을 계산합니다. 이 정규화 과정은 스크롤된 거리와 카메라가 이동하는 거리 사이의 비율을 모든 사용자의 화면 크기에서 동일하게 만들어줍니다.
- * objectDistance: 계산된 진행률에 오브젝트 사이의 거리를 곱합니다. 이를 통해 스크롤 위치와 3D 공간의 오브젝트 위치를 정확하게 동기화시킬 수 있습니다. 한 화면만큼 스크롤하면 정확히 오브젝트 하나만큼의 거리를 이동하게 됩니다.
마우스 패럴랙스(Parallax) 효과와 이징(Easing)
사용자의 마우스 움직임에 따라 화면이 약간씩 움직이는 패럴랙스 효과를 추가하여 깊이감과 상호작용성을 더합니다. 이때, 카메라를 그룹으로 감싸 스크롤에 의한 움직임과 패럴랙스에 의한 움직임이 서로 간섭하지 않도록 하는 것이 중요합니다.
/**
* Cursor
*/
const cursor = { x: 0, y: 0 };
window.addEventListener('mousemove', (event) => {
cursor.x = event.clientX / sizes.width - 0.5; // -0.5 ~ 0.5 범위로 정규화
cursor.y = event.clientY / sizes.height - 0.5; // -0.5 ~ 0.5 범위로 정규화
});
/**
* Camera Group
*/
const cameraGroup = new THREE.Group();
scene.add(cameraGroup);
cameraGroup.add(camera); // 기존 카메라를 그룹에 추가
// tick 함수 내부
let previousTime = 0;
const tick = () => {
const elapsedTime = clock.getElapsedTime();
const deltaTime = elapsedTime - previousTime;
previousTime = elapsedTime;
// ... 스크롤에 따른 카메라 위치 업데이트
// Parallax
const parallaxX = cursor.x * 0.5;
const parallaxY = -cursor.y * 0.5;
// Easing (부드러운 움직임)
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime;
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime;
// ... 렌더링 코드
};
핵심 개념:
- 카메라 그룹: 스크롤은 caemra자체의 y축을 패럴랙스는 cameraGroup의 x, y축을 제어합니다. 이렇게 역할을 분리하면 두 움직임이 충돌 없이 자연스럽게 결합됩니다.
- 이징(Easing): cameraGroup.position.x = parallaxX;처럼 값을 직접 할당하면 움직임이 딱딱 끊어집니다. 대신 (목표값 - 현재값) * 가중치를 현재값에 계속 더해주면, 목표에 가까워질수록 이동량이 줄어들어 부드러운 감속 효과를 낼 수 있습니다.
- deltaTime: 프레임 속도(FPS)가 다른 환경에서도 일관된 속도를 유지하기 위해 사용합니다. tick 함수가 호출되는 시간 간격을 곱해주어 애니메이션 속도를 보정합니다.
파티클로 공간 채우기
장면이 밋밋하지 않도록 무작위로 분포된 파티클을 추가하여 우주 공간 같은 느낌을 연출합니다.
/**
* Particles
*/
const particlesCount = 200;
const positions = new Float32Array(particlesCount * 3);
for (let i = 0; i < particlesCount; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 10; // x
positions[i * 3 + 1] = objectsDistance * 0.5 - Math.random() * objectsDistance * sectionMeshes.length; // y
positions[i * 3 + 2] = (Math.random() - 0.5) * 10; // z
}
const particlesGeometry = new THREE.BufferGeometry();
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const particlesMaterial = new THREE.PointsMaterial({
color: parameters.materialColor,
sizeAttenuation: true,
size: 0.03
});
const particles = new THREE.Points(particlesGeometry, particlesMaterial);
scene.add(particles);
파티클의 X와 Z좌표는 장면에 넓게 퍼지도록 단순한 난수(Math.random() - 0.5) * 10)를 사용했지만, Y 좌표는 스크롤 전체 영역에 걸쳐 자연스럽게 분포해야 하므로 조금 더 복잡한 계산이 필요합니다.
// ...
positions[i * 3 + 1] = objectsDistance * 0.5 - Math.random() * objectsDistance * sectionMeshes.length; // y
// ...
파티클 Y 좌표 심층 분석
1. 파티클이 분포될 전체 세로 길이 계산
- objectDistance * sectionMeshes.length
- objectDistance는 메쉬 사이의 거리(예: 4)이고, sectionMeshes.length는 메쉬의 총개수(예: 3)입니다.
- 이 둘을 곱하면 (4 * 3 = 12) 파티클이 분포될 전체 세로 길이(범위)가 계산됩니다. 즉, 첫 번째 오브젝트부터 마지막 오브젝트까지의 전체 스크롤 영역을 커버하는 높이가 됩니다.
- Math.random()을 여기에 곱하면 0에서 12사이의 임의의 값을 얻게 되며, 이는 파티클이 위치할 아래 방향으로의 임의의 거리가 됩니다.
2. 파티클의 시작점 보정
- objectDistance * 0.5 - ...
- 만약 0에서부터 위에서 계산한 값을 빼기 시작하면, 가장 위에 있는 파티클의 Y좌표는 0이 됩니다.
- objectDistance * 0.5(예: 4 * 0.5 = 2)를 더해주는 것은 파티클이 분포되는 시작점을 첫 번째 오브젝트(y=0)보다 약간 위에서 시작하도록 보장하는 역할을 합니다. 이렇게 하면 첫 번째 섹션 위쪽에도 파티클이 존재하여 공간이 더 풍성해 보입니다.
GSAP으로 섹션 진입 시 애니메이션 트리거
마지막으로, 각 섹션에 진입했을 때 해당 섹션의 오브젝트가 회전하는 애니메이션을 추가하여 사용자에게 시각적인 피드백을 줍니다. 복잡한 애니메이션은 GSAP 라이브러리를 사용하면 쉽게 구현할 수 있습니다.(npm install gsap)
import gsap from 'gsap';
// ...
let currentSection = 0;
window.addEventListener('scroll', () => {
scrollY = window.scrollY;
const newSection = Math.round(scrollY / sizes.height);
if (newSection !== currentSection) {
currentSection = newSection;
gsap.to(
sectionMeshes[currentSection].rotation,
{
duration: 1.5,
ease: 'power2.inOut',
x: '+=6',
y: '+=3',
z: '+=1.5'
}
);
}
});
// tick 함수 내부
const tick = () => {
// ...
// 오브젝트 상시 회전
for (const mesh of sectionMeshes) {
mesh.rotation.x += deltaTime * 0.1;
mesh.rotation.y += deltaTime * 0.12;
}
// ...
}
구현 포인트:
- 현재 섹션을 currentSection 변수에 저장합니다.
- 스크롤 이벤트가 발생할 때 마다 현재 섹션(newSection)을 다시 계산합니다.
- newSection이 currentSection과 다를 때, 즉 새로운 섹션으로 진입했을 때만 GSAP 애니메이션을 실행합니다.
- gsap.to()를 사용해 현재 섹션에 해당하는 메쉬의 회전 값을 부드럽게 변경합니다. +=를 사용하면 현재 회전 값에 추가로 회전시켜 애니메이션이 중첩되어도 자연스럽습니다.
- tick 함수에 있던 기본 회전 애니메이션은 모든 오브젝트가 계속해서 미세하게 움직이도록 하여 생동감을 더합니다.
결론
Three.js를 단순히 3D 캔버스가 아닌, 웹페이지의 스크롤과 상호작용하는 동적인 배경으로 만들어봤습니다. 사용자의 행동에 실시간으로 반응하는 웹사이트는 훨씬 더 몰입감 있고 기억에 남는 경험을 선사합니다.
이 프로젝트에서 다음과 같은 핵심 기술들을 다루었습니다.
- 스크롤 위치와 3D 카메라를 연결하여 탐험하는 듯한 느낌 구현
- 마우스 움직임에 반응하는 부드러운 패럴랙스 효과 추가
- 공간을 채우는 파티클 생성 및 동적 배치
- GSAP를 활용한 스크롤 기반 애니메이션 트리거
'Three.js > Class techniques' 카테고리의 다른 글
[threejs-journey 2-5] Galaxy Generator (0) | 2025.08.13 |
---|---|
[threejs-journey 2-4] Particles (0) | 2025.08.12 |
[threejs-journey 2-3] Haunted House (0) | 2025.08.10 |
[threejs-journey 2-2] shadows (0) | 2025.07.23 |
[threejs-journey 2-1] Lights (1) | 2025.07.15 |