Three.js를 이용한 HTML 배경과 패럴랙스 스크롤 통합 구현
소개
WebGL로 구성된 인터랙티브한 콘텐츠는 훌륭하지만, 종종 기존의 HTML 페이지와 통합하여 배경 효과로 활용하고 싶을 때가 있습니다. 이 문서는 Three.js를 활용해 HTML 페이지 스크롤과 커서 위치에 따라 반응하는 배경 효과를 구현하는 방법을 설명합니다.
주요 구현 목표는 다음과 같습니다:
- 카메라가 스크롤을 따라가도록 구현
- 마우스 위치에 따른 패럴랙스 효과 적용
- 특정 섹션에 도달 시 애니메이션 트리거
기본 설정
- OrbitControls 제거: 스크롤 기반 카메라 제어를 위해 마우스 회전 기능 비활성화
- lil-gui 적용: 재질 색상 조정용 UI 제공
const gui = new GUI();
const parameters = {
materialColor: '#ffeded',
};
gui.addColor(parameters, 'materialColor');
HTML 스크롤 활성화
스크롤 비활성화 해제
/src/style.css:
html,
body {
overflow: hidden; /* 이 줄을 제거하여 스크롤 활성화 */
}
배경색 설정 (탄성 스크롤 시 대비)
html {
background: #1e1a20;
}
알파 배경 설정 (Three.js 배경 투명화)
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
alpha: true,
});
renderer.setClearAlpha(0);
오브젝트 생성
const material = new THREE.MeshToonMaterial({
color: parameters.materialColor,
});
const mesh1 = new THREE.Mesh(new THREE.TorusGeometry(1, 0.4, 16, 60), 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,
);
scene.add(mesh1, mesh2, mesh3);
Toon Material 및 조명 설정
const directionalLight = new THREE.DirectionalLight('#ffffff', 3);
directionalLight.position.set(1, 1, 0);
scene.add(directionalLight);
색상 트윅 반영:
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor);
});
그라디언트 텍스처 적용 (툰 쉐이딩 개선)
const gradientTexture = textureLoader.load('textures/gradients/3.jpg');
gradientTexture.magFilter = THREE.NearestFilter;
const material = new THREE.MeshToonMaterial({
color: parameters.materialColor,
gradientMap: gradientTexture,
});
오브젝트 배치
y축 기준 거리 설정
const objectsDistance = 4;
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;
영구 회전 적용
const sectionMeshes = [mesh1, mesh2, mesh3];
const tick = () => {
const deltaTime = elapsedTime - previousTime;
for (const mesh of sectionMeshes) {
mesh.rotation.x += deltaTime * 0.1;
mesh.rotation.y += deltaTime * 0.12;
}
previousTime = elapsedTime;
// ...
};
스크롤 기반 카메라 이동
camera.position.y = (-scrollY / sizes.height) * objectsDistance;
패럴랙스 효과 구현
커서 위치 정규화
cursor.x = event.clientX / sizes.width - 0.5;
cursor.y = event.clientY / sizes.height - 0.5;
카메라를 그룹으로 감싸기
const cameraGroup = new THREE.Group();
cameraGroup.add(camera);
scene.add(cameraGroup);
부드러운 이동 적용
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime;
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime;
파티클 생성
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;
positions[i * 3 + 1] =
objectsDistance * 0.5 -
Math.random() * objectsDistance * sectionMeshes.length;
positions[i * 3 + 2] = (Math.random() - 0.5) * 10;
}
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);
트윅 반응 적용:
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor);
particlesMaterial.color.set(parameters.materialColor);
});
트리거된 회전 애니메이션 (GSAP)
현재 섹션 추적
let currentSection = 0;
window.addEventListener('scroll', () => {
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',
});
}
});
애니메이션 적용을 위해 기존 rotation 설정을 += 기반으로 변경했습니다.
마무리 팁
- 다양한 색상과 모델 적용
- HTML 요소와 인터랙션
- 입자 개선 (랜덤 크기, 알파, 애니메이션)
- UI 옵션 추가 (디버깅, 파라미터 변경 등)
import * as THREE from 'three';
import GUI from 'lil-gui';
import gsap from 'gsap';
/**
* Debug
*/
const gui = new GUI();
const parameters = {
materialColor: '#ffeded',
};
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor);
particleMaterial.color.set(parameters.materialColor);
});
/**
* Base
*/
// Canvas
const canvas = document.querySelector('canvas.webgl');
// Scene
const scene = new THREE.Scene();
/**
* Object
*/
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,
});
// Meshes
const objectsDistance = 4;
const mesh1 = new THREE.Mesh(new THREE.TorusGeometry(1, 0.4, 16, 60), 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;
mesh1.position.x = 2;
mesh2.position.x = -2;
mesh3.position.x = 2;
scene.add(mesh1, mesh2, mesh3);
const sectionMeshes = [mesh1, mesh2, mesh3];
/**
* Particles
*/
// Geometry
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;
positions[i * 3 + 1] =
objectsDistance * 0.5 -
Math.random() * objectsDistance * sectionMeshes.length;
positions[i * 3 + 2] = (Math.random() - 0.5) * 10;
}
const particlesGeometry = new THREE.BufferGeometry();
particlesGeometry.setAttribute(
'position',
new THREE.BufferAttribute(positions, 3),
);
// Material
const particleMaterial = new THREE.PointsMaterial({
color: parameters.materialColor,
sizeAttenuation: true,
size: 0.03,
});
const particles = new THREE.Points(particlesGeometry, particleMaterial);
scene.add(particles);
/**
* Light
*/
const directionalLight = new THREE.DirectionalLight('#ffffff', 3);
directionalLight.position.set(1, 1, 0);
scene.add(directionalLight);
/**
* Sizes
*/
const sizes = {
width: window.innerWidth,
height: window.innerHeight,
};
window.addEventListener('resize', () => {
// Update sizes
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
// Update camera
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
// Update renderer
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});
/**
* Camera
*/
// Group
const cameraGroup = new THREE.Group();
scene.add(cameraGroup);
// Base camera
const camera = new THREE.PerspectiveCamera(
35,
sizes.width / sizes.height,
0.1,
100,
);
camera.position.z = 6;
cameraGroup.add(camera);
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
alpha: true, // WebGL 배경을 투명하게 설정 (CSS 배경색 보이게 함)
});
// renderer.setClearAlpha(1); // 1은 불투명함 의미. 기본값은 0 (완전 투명)
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
/**
* Scroll
*/
let scrollY = window.scrollY;
let currentSection = 0;
window.addEventListener('scroll', () => {
scrollY = window.scrollY;
const newSection = Math.round(scrollY / sizes.height); // 현재 섹션
if (newSection != currentSection) {
currentSection = newSection;
console.log(sectionMeshes[currentSection].rotation);
gsap.to(sectionMeshes[currentSection].rotation, {
duration: 1.5,
ease: 'power2.inOut',
x: '+=6',
y: '+=3',
z: '+=1.5',
});
}
});
/**
* Cursor
*/
const cursor = {};
cursor.x = 0;
cursor.y = 0;
window.addEventListener('mousemove', (e) => {
cursor.x = e.clientX / sizes.width - 0.5;
cursor.y = e.clientY / sizes.height - 0.5;
});
/**
* Animate
*/
const clock = new THREE.Clock();
let previousTime = 0;
const tick = () => {
const elapsedTime = clock.getElapsedTime();
const deltaTime = elapsedTime - previousTime;
previousTime = elapsedTime;
// Animate camera
camera.position.y = (-scrollY / sizes.height) * objectsDistance; // - scroll / viewport * objectsDistance
const parallaxX = cursor.x * 0.5;
const parallaxY = -cursor.y * 0.5;
cameraGroup.position.x +=
(parallaxX - cameraGroup.position.x) * 5 * deltaTime;
cameraGroup.position.y +=
(parallaxY - cameraGroup.position.y) * 5 * deltaTime; // camera의 position이면, scroll이 동작하지 않음
// Animation meshes
for (const mesh of sectionMeshes) {
mesh.rotation.x += deltaTime * 0.1;
mesh.rotation.y += deltaTime * 0.12;
}
// Render
renderer.render(scene, camera);
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
tick();
'2D&3D > ThreeJS' 카테고리의 다른 글
21 모델 불러오기(Imported models) (2) | 2025.07.29 |
---|---|
20 물리엔진(Physics) (2) | 2025.07.29 |
18 Galaxy Generator (2) | 2025.07.28 |
17 Particle(Points) (2) | 2025.07.28 |
16 Haunted House 프로젝트 (1) | 2025.07.26 |