Three.js 텍스트 포트폴리오 구현 (Ilithya 스타일)
개요
화면 중앙에 3D 텍스트를 배치하고, 주변에 도넛(Torus)을 무작위로 떠다니게 배치
고성능 MeshMatcapMaterial을 사용해 멋진 외관과 성능을 동시에 확보
글꼴은 .typeface.json 형식을 사용하며, FontLoader와 TextGeometry로 구현
center()와 boundingBox를 활용해 텍스트 정렬
글꼴 불러오기
helvetiker_regular.typeface.json 등 .typeface.json 파일 필요
변환 사이트
또는 Three.js 내장 폰트 사용: three/examples/fonts/
파일을 /static/fonts/ 같은 정적 폴더에 위치
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js';
const fontLoader = new FontLoader();
fontLoader.load('/fonts/helvetiker_regular.typeface.json', (font) => {
// 이후 로직 작성
});
텍스트 기하 구조 만들기
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js';
const textGeometry = new TextGeometry('Hello Three.js', {
font, // 필수: FontLoader로 로드한 폰트 객체
size: 0.5, // 텍스트의 기본 크기 (기본값: 100)
depth: 0.2, // 텍스트의 두께 (extrude 깊이)
curveSegments: 12, // 글자의 곡선을 얼마나 부드럽게 표현할지 (값이 높을수록 곡선이 정밀해짐)
bevelEnabled: true, // 베벨(모서리 라운드 처리) 활성화 여부
bevelThickness: 0.03, // 베벨의 두께 (앞면에서 얼마나 돌출될지)
bevelSize: 0.02, // 외곽선으로부터의 거리 (베벨이 얼마나 퍼질지)
bevelOffset: 0, // 베벨 시작 위치 오프셋 (기본값은 0, 음수면 안쪽으로 들어감)
bevelSegments: 5 // 베벨 부분을 구성하는 세그먼트 수 (값이 클수록 부드러움)
});
텍스트 정렬
textGeometry.center()로 간단 정렬 가능
또는 boundingBox 수동 정렬 방식도 가능
textGeometry.center(); // 자동 중앙 정렬
재질 설정 (Matcap)
const matcapTexture = textureLoader.load('/textures/matcaps/1.png');
matcapTexture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshMatcapMaterial({ matcap: matcapTexture });
도넛 100개 배치
const donutGeometry = new THREE.TorusGeometry(0.3, 0.2, 20, 45);
for (let i = 0; i < 100; i++) {
const donut = new THREE.Mesh(donutGeometry, material);
donut.position.x = (Math.random() - 0.5) * 10;
donut.position.y = (Math.random() - 0.5) * 10;
donut.position.z = (Math.random() - 0.5) * 10;
donut.rotation.x = Math.random() * Math.PI;
donut.rotation.y = Math.random() * Math.PI;
const scale = Math.random();
donut.scale.set(scale, scale, scale);
scene.add(donut);
}
최적화 팁
지오메트리와 재질은 반복문 밖에서 미리 생성하여 공유
TextGeometry와 TorusGeometry 모두 같은 MeshMatcapMaterial 사용
const material = new THREE.MeshMatcapMaterial({ matcap: matcapTexture });
const text = new THREE.Mesh(textGeometry, material);
const donut = new THREE.Mesh(donutGeometry, material);
참고
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import GUI from 'lil-gui';
import { FontLoader } from 'three/examples/jsm/Addons.js';
import { TextGeometry } from 'three/examples/jsm/Addons.js';
/**
* Base
*/
// Debug
const gui = new GUI();
// Canvas
const canvas = document.querySelector('canvas.webgl');
// Scene
const scene = new THREE.Scene();
// Axes Helper
const axesHelper = new THREE.AxesHelper();
scene.add(axesHelper);
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader();
const matcapTexture = textureLoader.load('/textures/matcaps/2.png');
const matcapTexture2 = textureLoader.load('/textures/matcaps/8.png');
matcapTexture.colorSpace = THREE.SRGBColorSpace;
/**
* Fonts
*/
const fontLoader = new FontLoader();
fontLoader.load('/fonts/helvetiker_regular.typeface.json', (font) => {
// console.log(font);
const textGeometry = new TextGeometry('Hello Three.js', {
font: font,
size: 0.5,
depth: 0.2, // 최신 height에서 depth로 변경됨.
curveSegments: 5,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 4,
});
/* 중앙 정렬 방법 1*/
// textGeometry.computeBoundingBox();
// 1) console.log(textGeometry.boundingBox);
// -max 값 * 0.5로 정렬한 뒤 정렬된 max - min으로 세부 조정을 한다.
// --> -(max - (조정된 max - 조정된 min)) * 0.5
// textGeometry.translate(
// -(textGeometry.boundingBox.max.x - 0.02) * 0.5,
// -(textGeometry.boundingBox.max.y - 0.02) * 0.5,
// -(textGeometry.boundingBox.max.z - 0.03) * 0.5,
// );
// 2) console.log(textGeometry.boundingBox);
/* 중앙 정렬 방법 2*/
textGeometry.center();
const textMaterial = new THREE.MeshMatcapMaterial({ matcap: matcapTexture });
const text = new THREE.Mesh(textGeometry, textMaterial);
scene.add(text);
console.time('donuts');
// ** loop문 밖에서 Geometry를 선언하면 로딩 시간이 줄어든다.
const donutGeometry = new THREE.TorusGeometry(0.3, 0.2, 20, 45);
const donutMaterial = new THREE.MeshMatcapMaterial({
matcap: matcapTexture2,
});
for (let i = 0; i < 300; i++) {
const donut = new THREE.Mesh(donutGeometry, donutMaterial);
donut.position.x = (Math.random() - 0.5) * 10;
donut.position.y = (Math.random() - 0.5) * 10;
donut.position.z = (Math.random() - 0.5) * 10;
donut.rotation.x = Math.random() * Math.PI;
donut.rotation.y = Math.random() * Math.PI;
const scale = Math.random();
donut.scale.set(scale, scale, scale);
scene.add(donut);
}
console.timeEnd('donuts');
});
/**
* 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.x = 1;
camera.position.y = 1;
camera.position.z = 2;
scene.add(camera);
// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
/**
* Animate
*/
const clock = new THREE.Clock();
const tick = () => {
const elapsedTime = clock.getElapsedTime();
// Update controls
controls.update();
// Render
renderer.render(scene, camera);
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
tick();