본문 바로가기
2D&3D/ThreeJS

19 스크롤 기반 애니메이션

by curious week 2025. 7. 28.

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