유튜브에서 스크롤에 따라 3D 모델이 회전하고 특 시점에서는 바코드 효과음 소리가 나면서 3D 모델이 사라지는 웹을 소개하는 영상을 보았다. 3D 모델을 웹에 적용하는 것에 흥미를 느끼는 나로서는 어떻게 이런 인터랙션을 구현한 것인지 궁금했다. 다행히 영상에서 구현 코드를 함께 소개해주어 면밀히 파헤쳐 보기로 하였다.

1. html,css로 기본 구조 잡기
- section별로 범위를 나누었고, 요소들을 대부분 가운데 정렬하였다.
- 반응형 웹으로 구현하였다.
- 이번에 배운 점은 반응형 웹을 구현하는데 px보다는 em 단위를 쓰는게 훨씬 편하다는 점이다.
- font-size도 px보다는 vw로 하는 것을 추천.
- z-index는 position:static(기본값)일 때는 적용되지 않는다.
- position: absolute,relative,fixed의 경우에만 적용
html,css만 적용한 결과
- 구현 목표 : 첫 번째 이미지에서 3D 모델을 나타나게 하고 두 번째 이미지에 있는 섹션을 지나서 세 번째 있는 섹션의 박스에 도달할 때까지 3D 모델이 회전을 하게끔 애니메이션 효과를 줄 것이다. 그리고 박스에 도달하면 바코드 효과음이 나타나면서 3D 모델을 사라지게 할 것이며 네 번째 섹션부터는 3D 모델이 나타나지 않는다.
2. 필요한 라이브러리 가져오기
<body>
<script src="https://unpkg.com/lenis@1.1.18/dist/lenis.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="/script/scroll_animaion.js"></script>
</body>
- lenis, three.js, gsap, scrollTrigger, gltfloader 라이브러리를 import 했다.
- lenis를 이번에 처음 접해보게되었는데, 부드러운 스크롤 경험을 제공하는 JavaScript 라이브러리며 특히 3D 모델과 같은 스크롤 기반 인터랙션 구현에 유용하며 Three.js, GSAP, ScrollTrigger와 함께 사용된다.
3. scroll_animation.js 작성하기
3-1. lenis setup -> lenis+gsap 같이 사용하는 방법
const lenis = new Lenis(); // lenis 기본 초기화
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time*1000);
});
gsap.ticker.lagSmoothing(0);
lenis만 사용하는 방법과 달리, gsap와 함께 쓰는 방식은 조금 이해가 가진 않았다. 그래서 코드 한줄한줄 이해하는데 오랜시간이 걸렸다.
lenis의 scroll 이벤트를 ScrollTrigger에게 알려 update하는 것은 이해가 되었는데 gsap의 ticker는 무슨 역할을 하는걸까? 기존 Lenis만 사용하는 방식에서는 raf라는 함수를 선언하고 그 안에 requestAnimationFrame(raf)를 호출하는 방식이었다. gsap.ticker.add가 그 부분을 대체하는 것 같았다.
- GSAP의 애니메이션 루프가(gsap.ticker)가 실행될 때마다 lenis.raf()를 호출해 Lenis의 상태를 업데이트한다.
- 목적: GSAP 애니메이션 루프와 Lenis 스크롤 애니메이션 동기화
- time은 requestAnimationFrame의 타임스탬프. 브라우저의 화면 새로고침과 동기화된 애니메이션 루프를 제공하는 API로 매 프레임이 시작될 때마다 현재 경과된 시간을 전달함
- gsap는 초단위를 사용, lenis는 밀리초단위를 사용하여 gsap의 단위를 밀리초로 변환
3-2. 3D 렌더링 환경 준비하기
3D 모델을 웹 브라우저에 렌더링하기 위해서는 3D 렌더링 환경을 먼저 구성하여야하고 기본적으로 필요한 주요 요소들은 scene, camera, rederer, 3d model, light가 있다. 추가적으로 material, texture, animaion, cotrols를 적용해서 풍부한 인터랙션을 경험을 제공할 수 있다.
// scene & camera setup
const scene = new THREE.Scene(); // Scene은 three.js의 3D 공간, 3D 객체들(mesh, light, camera)을 담는 컨테이너 역할
scene.background = new THREE.Color(0xfefdfd);
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
- scene은 3D 공간을 정의하는 기본 객체로, 모든 3D 요소를 한 곳에 모아 관리한다. light, camera를 scene에 추가해야 렌더링할 대상이 준비된다.
- 주요 메서드
- scene.add(object) : 오브젝트를 scene에 추가
- remove(object) : scene에서 오브젝트를 제거
- 주요 메서드
// renderer setup
const renderer = new THREE.WebGLRenderer({
antialias: true, // 3D 객체의 가장자리를 부드럽게 처리리
alpha: true // 투명 배경 허용
})
renderer.setClearColor(0xffffff, 1); // 배경색 설정(흰색, 투명도 1)
renderer.setSize(window.innerWidth, window.innerHeight); // 렌더러 크기를 윈도우 크기로 설정
renderer.setPixelRatio(window.devicePixelRatio); // 기기의 픽셀 비율에 맞에 설정
renderer.shadowMap.enabled = true; // 그림자 활성화 -> 입체감 있고 사실적
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // PCFSoft 알고리즘을 사용하여 부드러운 그림자 설정
renderer.physicallyCorrectLights = true; // 더 사실적인 렌더링링
renderer.toneMapping = THREE.ACESFilmicToneMapping; // ACES Filmic 톤매핑 적용
renderer.toneMappingExposure = 2.5; // 톤매핑 노출값 설정(밝기 조절) -> 더 좋은 색상 표현현
document.querySelector(".model").appendChild(renderer.domElement);
- scene과 camera 정보를 사용해 3D 데이터를 html canvas에 렌더링하는 역할을 한다. 즉, gpu를 활용하여 실제로 시각적으로 보여지는 결과물을 생성
- 주로 사용하는 렌더러는 WebGLRender로, WebGL 기술을 사용해 빠르고 효율적인 렌더링을 제공
- 카메라로 무대의 특정 부분(scene)을 촬영하여 스크린에 보여주는 카메라맨과 같은 역할을 함
- 주요 메서드
- renderer.setSize(width, height) : 렌더링할 캔버스의 크기를 설정
- renderer(scene, camera) : 특정 scene과 camera를 기준으로 화면에 렌더링
- 주요 메서드
// light setup
// light setup
const ambientLight = new THREE.AmbientLight(0xffffff, 3);
scene.add(ambientLight);
// main light -> 주로 빛과 그림자를 만드는 광원
const mainLight = new THREE.DirectionalLight(0xffffff,1);
mainLight.position.set(5, 10, 7.5); // 조명 위치:오른쪽으로 5만큼, 위로 10만큼, 앞으로 7.5만큼 -> 오른쪽 위에서 비춤춤
scene.add(mainLight);
// main light가 만든 그림자를 부분적으로 비춤, 너무 어두운 부분을 보완
const fillLight = new THREE.DirectionalLight(0xffffff, 3);
fillLight.position.set(-5, 0, -5);// 왼쪽 아래에서 비춤춤
scene.add(fillLight);
const hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 2);
hemiLight.position.set(0, 25, 0);
scene.add(hemiLight);
총 정리
- scene은 3d 모델, 데이터를 관리하는 객체
- renderer는 canvas를 생성하거나 이미 존재하는 canvas에 접근하여 scene과 camera를 연결
- 3d 데이터를 렌더링하는 역할
- light나 3D Model들은 전부 scene에 추가해야함 -> render가 camera로 본 scene을 브라우저 화면에 표시
// 임시 렌더링 루프 애니메이션
requestAnimationFrame을 baseicAnimate 반복실행!
function basicAnimate(){
// 렌더링
renderer.render(scene, camera)
requestAnimationFrame(basicAnimate);
}
basicAnimate();
requestAnimationFrame을 통해 브라우저의 화면 새로고침 주기에 맞춰 최적화된 방식으로 애니메이션을 실행시킨다. 나는 문득 '엇? 그러면 위 코드에서 gsap.ticker도 브라우저 주기(?)에 맞춰 lenis의 상태를 업데이트 한다고 했는데 주기가 어긋나면 어쩌지? ' 라는 생각을 하게 되었다. 내가 코드를 잘 이해하지 못해서 생긴 의문일수도 있다.
chatgpt의 대답이다!! ㅋㅋ
어긋날 문제는 없다고 한다! gsap.ticker도 내부적으로 reqeustAnimationFrame을 사용해 실행되기 때문에 같은 주기로 작동된다고 한다. 그렇다면 왜 둘 다 쓰이는가?
- requestAnimationFrame은 Three.js의 기본 렌더링 루프를 실행해 3D 렌더링 환경을 브라우저 갱신 주기에 맞게 렌더링
- Three.js의 렌더링
- gsap.ticker는 GSAP 애니메이션이나 Lenis 스크롤 업데이트를 실행함
- 이 프로젝트에서는 스크롤에 따라 3d 모델이 회전하고 사라지는 인터랙션이 있는데 그 부분을 gsap.ticker가 업데이 한다.
- gsap 애니메이션 업데이트
-> 정리하고 나니까 requestAnimationFrame과 gsap.ticker는 '브라우저 화면 갱신 주기에 맞춰 업데이트'를 한다는 기능은 같을지라도 완전히 다른 목적을 위해 사용되고 있다는 것을 알았다. 다시 한번 정리하자면, gsap.ticker는 gsap 관련 애니메이션을 업데이트, requestAnimtaionFrame은 three.js 3d 렌더링 환경을 업데이트하고 있다.
각 루프에서 처리하는 작업이 완벽히 분리되어 있어서 중복되지 않은 점이 이제야 이해가 되었다!!

// load 3D Model
// 3D Model에 적용할 애니메이션1 - playInitialAnimation
3D 모델 렌더링 환경을 준비하였으니 이 환경에 3D 모델을 가져와서 렌더링 해보자! GLTFLoader를 이용해서 glb 파일을 가져온 후, Three.js scene에 추가할 것이다. gtlf/glb 파일은 3d 모델의 기하학 데이터, 애니메이션, 텍스쳐, 재질 등을 포함한다. 이 속성들을 가져와서 재질이나 텍스쳐를 변경/추가하여 사실감 있는 물체로 만들 수 있고 애니메이션을 조작하여 다이나믹한 장면을 구현할 수 있다.
3D 모델을 로드한 후, scene에 추가하고 모델이 없었다가 나타나는 애니메이션(playInitialAnimation)을 추가하겠다.
let model;
const loader = new THREE.GLTFLoader(); // gltfloader 인스턴스 생성
loader.load(
"../assets/josta.glb",
function(gltf){
model = gltf.scene;
// 모델 재질 설정
model.traverse((node) => {
if (node.isMesh) {
if (node.material) { // 해당 메쉬에 재질이 있을 경우
node.material.metalness = 0.3;
node.material.roughness = 0.4;
node.material.envMapIntensity = 1.5;
}
// 그림자 설정
node.castShadow = true;
node.receiveShadow = true;
}
});
// 모델 위치 중앙 정렬
const box = new THREE.Box3().setFromObject(model); // 모델을 정확히 감싸는 직육면체 생성
const center = box.getCenter(new THREE.Vector3()); // 모델의 중앙 좌표를 계산
model.position.sub(center); // 모델의 중앙을 원점으로 맞춤
scene.add(model); // 모델 scene에 추가!!!
// 카메라 위치 설정
const size = box.getSize(new THREE.Vector3()); // 모델의 크기를 계산
const maxDim = Math.max(size.x, size.y, size.z); // 직육면체의 너비/높이/깊이 중 가장 큰 값이 maxDim이 됨.
camera.position.z = maxDim * 1.5; // 카메라가 모델로부터 충분히 멀어져서 전체가 보이게됨됨
// 애니메이션 준비
model.scale.set(0, 0, 0); // 초기 크기 0
playInitialAnimation(); // 초기 애니메이션 시작
// cancelAnimationFrame(basicAnimate); // 기존 애니메이션 취소
// animate(); // 새로운 애니메이션 시작
},
function (xhr) {
console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
},
function (error) {
console.error("An error happened:", error);
}
);
// 모델의 크기를 원래 크기로 확대
function playInitialAnimation() {
if(model){
gsap.to(model.scale,{
x:1,
y:1,
z:1,
duration:1,
ease:"power2.out",
});
}
}
- 3d model을 담을 변수를 전역 변수로 선언
- loader.load(url, onLoad, onProgress, onError)
- onLoad: 모델 로드 완료 시 실행되는 콜백 함수
- onProgress: 로드 진행 상황을 추적하는 콜백 함수
- onError: 로드 실패 시 호출되는 콜백 함수
- model.traverse : 모델의 모든 하위 노드를 순회
결과 -> 3D 모델 렌더링 완료!
캔 모양의 3D Model이 점차 커지면서 화면 중앙에 나타나는 애니메이션을 구현하였다!
// 3D 모델에 적용할 애니메이션2 - Full Animation Loop(animate)
animate는 requestAnimationFrame(animate)를 통해 3D 모델에 적용될 계속 반복 실행되는 애니메이션 함수다. 흐름은 다음과 같다.
- 1단계: 떠오르는 효과(Floating Effect)
- 모델이 부드럽게 위아래로 떠오르는 애니메이션 적용
- 2단계: 스크롤 진행도에 따른 x축 회전
- 스크롤이 진행됨에 따라 scrollProgress 값이 증가하면서 모델은 x축을 기준으로 회전
- 3단계: 컨테이너에 도달하면 y축 회전 및 사라짐
- 스크롤이 scannerSection에 도달하면 모델은 y축을 따라 회전하며, 점점 작아지면서 사라짐
- 다시 스크롤이 'body'의 'top'으로 돌아가면 애니메이션 다시 시작
const floatAmplitude = 0.2; // 떠오르는 범위 조정
const floatSpeed = 1.5; // 떠오르는 속도 조정
const rotationSpeed = 0.3;
let isFloating = true;
let currentScroll = 0;
function animate() {
if (model) {
if (isFloating) {
const floatOffset =
Math.sin(Date.now() * 0.001 * floatSpeed) * floatAmplitude; // 사인 함수를 사용해 부드럽게 오르내리는 효과를 적용
model.position.y = floatOffset;
}
//
const scrollProgress = Math.min(currentScroll / scannerPosition, 1);
if (scrollProgress < 1) {
model.rotation.x = scrollProgress * Math.PI * 2;
}
if (scrollProgress < 1) {
model.rotation.y += 0.001 * rotationSpeed;
}
}
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
이번 프로젝트에서 내가 가장 이해하고 싶었던 부분은 스크롤에 따라 3d 모델이 회전하고 스크롤의 진행도에 따라 모델의 위치가 변화하는 모션을 구현하는 것이었다. 이러한 인터랙션이 3d 인터랙티브 웹을 가장 풍부하게 만들어주는 핵심 요소라고 생각했고 나의 포트폴리오에도 적용하고 싶은 부분이었다. 그래서 이 부분을 확실하게 이해하고자 했다!!
그러한 모션을 구현하려면 ScrollTrigger와 GSAP를 적절하게 활용하여야 한다. 자세하게 알아볼까!?!?

ScrollTrigger
- ScrollTrigger.create()
- 스크롤을 기반으로 애니메이션을 트리거하고 제어하는 방법을 설정하는 함수.
ScrollTrigger.create가 스크롤에 따라 트리거한 요소를 제어하고 애니메이션을 트리거한다는 것은 이해하기 쉽다. 그러나 주요 옵션인 start와 end의 설정은 매번 헷갈린다. start : "top top", end: " top -10" 처럼 스크롤 위치에 따라 트리거 타이밍을 다루는 방식은 매번 헷갈려서 머리가 아프다~ 아마도 절대적인 위치(px)와 상대적인 위치(center, top ..)를 혼합해서 사용해서 그런것 같다. 이번에 정확하게 이해해볼 것이다!!
- 기본 문법
ScrollTrigger.create({
trigger: ".your-element", // 트리거 요소
start: "top center", // 애니메이션 시작 지점
end: "bottom 100px", // 애니메이션 종료 지점
markers: true, // 스크롤 시작/종료 지점 시각적 표시 (디버깅용)
onEnter: () => { /* 스크롤 다운 시 실행되는 코드 */ },
onLeave: () => { /* 스크롤 업 시 실행되는 코드 */ },
onEnterBack: () => { /* 되돌아올 때 실행되는 코드 */ },
onLeaveBack: () => { /* 위로 떠날 때 실행되는 코드 */ },
});
- 이번 프로젝트에서
ScrollTrigger.create({
trigger: "body",
start: "top top",
end: "top -10",
onEnterBack: () => {
if (model) {
gsap.to(model.scale, {
x: 1,
y: 1,
z: 1,
duration: 1,
ease: "power2.out",
});
isFloating = true;
}
gsap.to(scanContainer, {
scale: 1,
duration: 1,
ease: "power2.out",
});
},
});
'3D Project > 3D 인터랙티브 웹' 카테고리의 다른 글
[3D 인터랙티브 웹] 3D Scene - 2 렌더링 (1) | 2024.01.15 |
---|---|
[3D 인터랙티브 웹] 3D Scene - 1(클릭 인터랙션) 렌더링 (1) | 2024.01.14 |
[3D 인터랙티브 웹] 파티클 시스템/애니메이션 만들기 by Three.js (1) | 2024.01.14 |
[3D 인터랙티브 웹] 스크롤 인터랙션/애니메이션 적용하기 (1) | 2024.01.14 |
[3D 인터랙티브 웹] Three.js에서 회전 애니메이션 적용시키기 - 3D 시계 모델링 (0) | 2024.01.14 |