본문 바로가기
Graphic/ThreeJS

47 로딩 스크린과 인트로(Intro and loading progress)

by curious week 2025. 8. 6.

로딩 스크린과 인트로

강의 주제: Three.js 프로젝트에서 ShaderMaterial 기반의 오버레이 HTML/CSS 로딩 바를 활용하여
에셋이 모두 로드될 때까지 자연스럽게 장면을 가리는 방법을 배웁니다.


1. 개요 및 목표

기본적으로 Three.js 앱은 WebGL 캔버스만 있고, 에셋이 로드되든 말든 곧바로 장면을 렌더링합니다. 이 레슨에서는:

  • HTML과 CSS를 활용한 로딩 바 생성
  • ShaderMaterial을 이용한 검은색 오버레이 화면 구현
  • 모든 에셋이 로드된 후 자연스럽게 장면이 fade in되도록 구현

2. 오버레이 구현 (ShaderMaterial)

기본 오버레이 Plane 구성

const overlayGeometry = new THREE.PlaneGeometry(2, 2, 1, 1);
const overlayMaterial = new THREE.ShaderMaterial({
  transparent: true,
  uniforms: {
    uAlpha: { value: 1.0 },
  },
  vertexShader: `
    void main() {
      // gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); //행렬적용
      gl_Position = vec4(position, 1.0); // 행렬을 무시하여 전체 화면 채우기 
    }
  `,
  fragmentShader: `
    uniform float uAlpha;
    void main() {
      gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha); // 투명도 조절
    }
  `,
});
const overlay = new THREE.Mesh(overlayGeometry, overlayMaterial);
scene.add(overlay);

PlaneGeometry(2, 2)는 화면 전체를 덮는 정사각형을 만듭니다.
ShaderMaterial로 작성했기 때문에 알파 블렌딩, fade 애니메이션이 가능합니다.


3. LoadingManager로 에셋 로드 상태 감지

const loadingManager = new THREE.LoadingManager(
  // 모든 에셋 로딩 완료 시 실행
  () => {
    setTimeout(() => {
      gsap.to(overlayMaterial.uniforms.uAlpha, {
        duration: 3,
        value: 0,
        delay: 1,
      });
      loadingBarElement.classList.add('ended');
      loadingBarElement.style.transform = '';
    }, 500);
  },
  // 로딩 중 진행 상황
  (itemUrl, itemsLoaded, itemsTotal) => {
    const progressRatio = itemsLoaded / itemsTotal;
    loadingBarElement.style.transform = `scaleX(${progressRatio})`;
  },
);

LoadingManager는 GLTFLoader, CubeTextureLoader에 함께 전달되어 로딩 상황을 감지합니다.


4. HTML과 CSS 기반의 로딩 바

/src/index.html

<canvas class="webgl"></canvas>
<div class="loading-bar"></div>

/src/style.css

.loading-bar {
  position: absolute;
  top: 50%;
  width: 100%;
  height: 2px;
  background: #ffffff;
  transform: scaleX(0);
  transform-origin: top left;
  transition: transform 0.5s;
}

.loading-bar.ended {
  transform: scaleX(0);
  transform-origin: 100% 0;
  transition: transform 1.5s ease-in-out;
}

.loading-bar.ended는 로딩 완료 후 오른쪽으로 사라지는 애니메이션을 위해 사용됩니다.


5. GSAP으로 오버레이 애니메이션

import { gsap } from 'gsap';

// 로딩 완료 후 오버레이를 자연스럽게 fade out
gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 });

GSAP은 셰이더의 uniform 값에 애니메이션을 적용할 수 있어 활용도가 높습니다.


6. 로딩 테스트 팁 (Chrome)

네트워크 속도 시뮬레이션 방법

  1. DevTools > Network 탭 이동
  2. "Disable Cache" 체크
  3. Throttling > Add... > Custom profile 생성
  4. 예: "Fast 3G" 또는 사용자 정의 100000Kbps 등 설정

이 방법을 사용하면 로딩이 충분히 느려져서 로딩 애니메이션이 실제처럼 보임


7. 로딩 흐름 요약

  1. HTML/CSS로 로딩 바 구성 (transform: scaleX)
  2. Three.js LoadingManager로 진행률 계산
  3. 진행률에 따라 .style.transform = scaleX(progress) 적용
  4. 에셋 로딩 완료 후:
    • .loading-bar.ended 클래스 적용 (오른쪽으로 사라짐)
    • ShaderMaterial.uAlpha를 GSAP으로 0으로 애니메이션 (검은 오버레이 사라짐)

✨ 마무리 요약

  • 오버레이는 ShaderMaterial + PlaneGeometry 조합으로 구현
  • 로딩 바는 HTML/CSS + JavaScript DOM 조작으로 구현
  • 모든 에셋 로드 감지는 LoadingManager로 처리
  • GSAP으로 fade-out 트랜지션 구현

이 방법을 응용하면 Three.js 프로젝트에서 훨씬 더 부드럽고 고급스러운 UX의 인트로 효과를 줄 수 있습니다.


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Intro and loading progress</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <canvas class="webgl"></canvas>
    <div class="loading-bar"></div>
    <script type="module" src="./script.js"></script>
  </body>
</html>
* {
  margin: 0;
  padding: 0;
}

html,
body {
  overflow: hidden;
}

.webgl {
  position: fixed;
  top: 0;
  left: 0;
  outline: none;
}

.loading-bar {
  position: absolute;
  top: 50%;
  width: 100%;
  height: 2px;
  background: #fff;
  transform: scaleX(0);
  transform-origin: top left;
  transition: transform 0.5s;
  will-change: transform;
}
.loading-bar.ended {
  transform: scaleX(0);
  transform-origin: top right;
  transition: transform 1.5s ease-in-out;
}
import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { gsap } from 'gsap'; /** * Loaders */ const loadingBarElement = document.querySelector('.loading-bar'); const loadingManager = new THREE.LoadingManager( // Loaded () => { gsap.delayedCall(0.5, () => { gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0 }); // 상대적으로 가벼운 gsap 사용 loadingBarElement.classList.add('ended'); loadingBarElement.style.transform = ``; // js가 css보다 나중에 적용되므로 progress transform 무효화 }); // css 애니메이션을 조금 더 보여줌 window.setTimeout(() => {}, 500);과 동일 }, // Progress (itemUrl, itemsLoaded, itemsTotal) => { const progressRatio = itemsLoaded / itemsTotal; loadingBarElement.style.transform = `scaleX(${progressRatio})`; }, ); const gltfLoader = new GLTFLoader(loadingManager); const cubeTextureLoader = new THREE.CubeTextureLoader(loadingManager); /** * Base */ // Debug const debugObject = {}; // Canvas const canvas = document.querySelector('canvas.webgl'); // Scene const scene = new THREE.Scene(); /** * Overlay */ const overlayGeometry = new THREE.PlaneGeometry(2, 2, 1, 1); const overlayMaterial = new THREE.ShaderMaterial({ transparent: true, uniforms: { uAlpha: { value: 1 }, }, vertexShader: ` void main() { // gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position = vec4(position, 1.0); } `, fragmentShader: ` uniform float uAlpha; void main() { gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha); } `, }); const overlay = new THREE.Mesh(overlayGeometry, overlayMaterial); scene.add(overlay); /** * Update all materials */ const updateAllMaterials = () => { scene.traverse((child) => { if ( child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial ) { // child.material.envMap = environmentMap child.material.envMapIntensity = debugObject.envMapIntensity; child.material.needsUpdate = true; child.castShadow = true; child.receiveShadow = true; } }); }; /** * Environment map */ const environmentMap = cubeTextureLoader.load([ '/textures/environmentMaps/0/px.jpg', '/textures/environmentMaps/0/nx.jpg', '/textures/environmentMaps/0/py.jpg', '/textures/environmentMaps/0/ny.jpg', '/textures/environmentMaps/0/pz.jpg', '/textures/environmentMaps/0/nz.jpg', ]); environmentMap.colorSpace = THREE.SRGBColorSpace; scene.background = environmentMap; scene.environment = environmentMap; debugObject.envMapIntensity = 2.5; /** * Models */ gltfLoader.load('/models/FlightHelmet/glTF/FlightHelmet.gltf', (gltf) => { gltf.scene.scale.set(10, 10, 10); gltf.scene.position.set(0, -4, 0); gltf.scene.rotation.y = Math.PI * 0.5; scene.add(gltf.scene); updateAllMaterials(); }); /** * Lights */ const directionalLight = new THREE.DirectionalLight('#ffffff', 3); directionalLight.castShadow = true; directionalLight.shadow.camera.far = 15; directionalLight.shadow.mapSize.set(1024, 1024); directionalLight.shadow.normalBias = 0.05; directionalLight.position.set(0.25, 3, -2.25); 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 */ // Base camera const camera = new THREE.PerspectiveCamera( 75, sizes.width / sizes.height, 0.1, 100, ); camera.position.set(4, 1, -4); scene.add(camera); // Controls const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; /** * Renderer */ const renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, }); renderer.toneMapping = THREE.ReinhardToneMapping; renderer.toneMappingExposure = 3; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.setSize(sizes.width, sizes.height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); /** * Animate */ const tick = () => { // Update controls controls.update(); // Render renderer.render(scene, camera); // Call tick again on the next frame window.requestAnimationFrame(tick); }; tick();