Three.js에서 3D 위치에 HTML 요소 고정하기 (HTML과 WebGL 혼합)
3D 장면 위에 HTML로 만든 인터랙티브한 정보 포인트(Interest Point) 를 부착하고, 3D 객체와 함께 카메라 이동 시 따라다니도록 구현합니다. 이 방식은 WebGL 요소와 HTML UI를 혼합해서 사용할 때 유용하지만 성능에 주의해야 합니다.
1. HTML 구조 설정
<canvas class="webgl"></canvas>
<div class="loading-bar"></div>
<!-- 포인트 요소 (1번 예시) -->
<div class="point point-0 visible">
<div class="label">1</div>
<!-- 동그란 라벨 -->
<div class="text">Lorem ipsum...</div>
<!-- 마우스오버 시 보이는 설명 -->
</div>
- .point: 모든 포인트에 공통적으로 적용
- .point-0, .point-1...: 각 포인트 개별 제어를 위한 클래스
- .label: 항상 보이는 작은 번호 원
- .text: 마우스를 올리면 나타나는 설명 텍스트
2. CSS 스타일링
기본 포지션
.point {
position: absolute;
top: 50%;
left: 50%;
}
WebGL 기준 (0,0)이 화면 중앙이기 때문에, 초기 포인트도 화면 중앙에서 시작합니다.
라벨 스타일
.point .label {
position: absolute;
top: -20px;
left: -20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #00000077;
border: 1px solid #ffffff77;
color: white;
font-size: 14px;
line-height: 40px;
text-align: center;
font-weight: 100;
font-family: Helvetica, Arial, sans-serif;
cursor: help;
transform: scale(0);
transition: transform 0.3s;
}
.point.visible .label {
transform: scale(1);
}
- transform: scale(0)으로 기본은 숨김 처리
- .visible 클래스가 있을 때만 보이게 설정
- cursor: help는 호버 시 ? 마우스 커서 표시
설명 텍스트 스타일
.point .text {
position: absolute;
top: 30px;
left: -120px;
width: 200px;
padding: 20px;
background: #00000077;
border: 1px solid #ffffff77;
color: white;
font-size: 14px;
font-weight: 100;
line-height: 1.3em;
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.point:hover .text {
opacity: 1;
}
- 마우스오버 시 opacity: 1로 fade-in
- pointer-events: none으로 hover 이벤트 비활성화 (보이지 않을 때 hover 방지)
3. JavaScript 로직 구성
포인트 정의
const points = [
{
position: new THREE.Vector3(1.55, 0.3, -0.6),
element: document.querySelector('.point-0'),
},
{
position: new THREE.Vector3(0.5, 0.8, -1.6),
element: document.querySelector('.point-1'),
},
{
position: new THREE.Vector3(1.6, -1.3, -0.7),
element: document.querySelector('.point-2'),
},
];
- position: 해당 포인트가 3D 공간 내에서 부착될 위치 (THREE.Vector3)
- element: HTML DOM 요소 참조
tick() 내 위치 업데이트
const tick = () => {
// 카메라 컨트롤 업데이트
controls.update();
if (sceneReady) {
for (const point of points) {
const screenPosition = point.position.clone();
screenPosition.project(camera); // NDC 좌표계로 투영
// 픽셀 좌표로 변환
const translateX = screenPosition.x * sizes.width * 0.5;
const translateY = -screenPosition.y * sizes.height * 0.5; // Y축 반전 필요
// 위치 반영
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`;
// Raycasting을 통해 가려졌는지 확인
raycaster.setFromCamera(screenPosition, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length === 0) {
point.element.classList.add('visible');
} else {
const intersectionDistance = intersects[0].distance;
const pointDistance = point.position.distanceTo(camera.position);
if (intersectionDistance < pointDistance)
point.element.classList.remove('visible');
else point.element.classList.add('visible');
}
}
}
// 렌더링 요청
renderer.render(scene, camera);
window.requestAnimationFrame(tick);
};
보조 설명
- .project(camera):
- 3D 좌표를 [-1, 1] 범위의 NDC(Normalized Device Coordinates)로 변환
- .unproject(camera): Vector3.unproject(camera)는 NDC → 3D 공간으로 역변환
- x * width * 0.5: NDC x좌표 → 픽셀 x
- -y * height * 0.5: NDC y좌표를 픽셀로 (y축 반전 필요)
- .setFromCamera(ndc, camera):
- NDC에서 카메라를 기준으로 레이 설정
- .intersectObjects(scene.children, true):
- 재귀적으로 모든 자식 객체에 레이 충돌 검사
- .distanceTo():
- 카메라에서 포인트까지의 거리 (실제 거리 측정 시 사용)
- .distanceToSquared(v): 거리 제곱값 계산 (√ 연산 없이 계산하므로 distanceTo()보다 빠름.), 거리 비교 시 최적화용
sceneReady 변수 도입 (로딩 완료 후 활성화)
let sceneReady = false;
const loadingManager = new THREE.LoadingManager(() => {
window.setTimeout(() => {
sceneReady = true;
}, 2000);
});
로딩 애니메이션이 끝난 후 포인트를 보이게 제어
⚠️ 퍼포먼스 주의 사항
- 매 프레임마다 DOM 요소 위치 갱신은 렌더링 비용이 큽니다.
- 포인트 수가 많아질수록 성능에 영향을 미치므로:
- requestAnimationFrame을 통한 업데이트 최소화
- IntersectionObserver 또는 visibility check 로직 최적화
- WebGL만으로 표현 가능한 경우 WebGL로 구현 권장
✅ 예시 화면 결과
- 헬멧 모델 위에 1, 2, 3번 정보가 각각의 부품 위치에 부착됨
- 마우스를 올리면 설명이 부드럽게 나타남
- 카메라 이동 시에도 3D 위치를 따라다님
- 객체에 가려지면 자동으로 숨겨짐
🔁 정리 요약
| .point HTML | 3D 오브젝트와 연동되는 UI 포인트 |
| .label | 항상 표시되는 원형 번호 |
| .text | hover 시 나타나는 설명 텍스트 |
| screenPosition.project(camera) | 3D → NDC 변환 |
| transform: translateX/Y | NDC → 화면 픽셀 위치 변환 |
| Raycaster | 카메라에서의 가림 여부 판단 |
| .visible | label 표시 여부 제어 (scale(1)) |
🧠 보충 개념
- NDC (Normalized Device Coordinates):
- 카메라 투영 이후의 좌표 시스템
- X, Y, Z가 모두 [-1, 1] 범위
- Raycasting:
- 특정 방향으로 가상의 광선을 쏴서 장면의 어떤 객체와 교차하는지 판단하는 방식
- 절대 위치 HTML (absolute):
- WebGL 캔버스 위에 겹쳐서 표시되는 UI에 주로 사용
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mixing HTML and WebGL</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<canvas class="webgl"></canvas>
<div class="loading-bar"></div>
<div class="point point-0">
<div class="label">1</div>
<div class="text">
Lorem ipsum dolor sit amet consectetur adipisicing elit
</div>
</div>
<div class="point point-1">
<div class="label">2</div>
<div class="text">
Lorem ipsum dolor sit amet consectetur adipisicing elit
</div>
</div>
<div class="point point-2">
<div class="label">3</div>
<div class="text">
Lorem ipsum dolor sit amet consectetur adipisicing elit
</div>
</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: #ffffff;
transform: scaleX(0.3);
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;
}
.point {
position: absolute;
top: 50%;
left: 50%;
/* pointer-events: none; */
}
.point .label {
position: absolute;
top: -20px;
left: -20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
font-family: Helvetica, Arial, sans-serif;
text-align: center;
line-height: 40px;
font-weight: 100;
font-size: 14px;
cursor: help;
transform: scale(0, 0);
transition: transform 0.3s;
}
.point .text {
position: absolute;
top: 30px;
left: -120px;
width: 200px;
padding: 20px;
border-radius: 4px;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
line-height: 1.3em;
font-family: Helvetica, Arial, sans-serif;
font-weight: 100;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.point:hover .text {
opacity: 1;
}
.point.visible .label {
transform: scale(1, 1);
}
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');
let sceneReady = false;
const loadingManager = new THREE.LoadingManager(
// Loaded
() => {
// Wait a little
window.setTimeout(() => {
// Animate overlay
gsap.to(overlayMaterial.uniforms.uAlpha, {
duration: 3,
value: 0,
delay: 1,
});
// Update loadingBarElement
loadingBarElement.classList.add('ended');
loadingBarElement.style.transform = '';
}, 500);
window.setTimeout(() => {
sceneReady = true;
}, 2000);
},
// Progress
(itemUrl, itemsLoaded, itemsTotal) => {
// Calculate the progress and update the loadingBarElement
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({
// wireframe: true,
transparent: true,
uniforms: {
uAlpha: { value: 1 },
},
vertexShader: `
void main()
{
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/DamagedHelmet/glTF/DamagedHelmet.gltf', (gltf) => {
gltf.scene.scale.set(2.5, 2.5, 2.5);
gltf.scene.rotation.y = Math.PI * 0.5;
scene.add(gltf.scene);
updateAllMaterials();
});
/**
* Points of interest
*/
const raycaster = new THREE.Raycaster();
const points = [
{
position: new THREE.Vector3(1.55, 0.3, -0.6),
element: document.querySelector('.point-0'),
},
{
position: new THREE.Vector3(0.5, 0.8, -1.6),
element: document.querySelector('.point-1'),
},
{
position: new THREE.Vector3(1.6, -1.3, -0.7),
element: document.querySelector('.point-2'),
},
];
/**
* 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();
// Update points only when the scene is ready
if (sceneReady) {
// Go through each point
for (const point of points) {
// Get 2D screen position
const screenPosition = point.position.clone();
screenPosition.project(camera);
// Set the raycaster
raycaster.setFromCamera(screenPosition, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
// No intersect found
if (intersects.length === 0) {
// Show
point.element.classList.add('visible');
}
// Intersect found
else {
// Get the distance of the intersection and the distance of the point
const intersectionDistance = intersects[0].distance;
const pointDistance = point.position.distanceTo(camera.position);
// Intersection is close than the point
if (intersectionDistance < pointDistance) {
// Hide
point.element.classList.remove('visible');
}
// Intersection is further than the point
else {
// Show
point.element.classList.add('visible');
}
}
const translateX = screenPosition.x * sizes.width * 0.5;
const translateY = -screenPosition.y * sizes.height * 0.5;
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`;
}
}
// Render
renderer.render(scene, camera);
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
tick();
'Graphic > ThreeJS' 카테고리의 다른 글
| Three.js 전체 가이드 (1) | 2025.09.05 |
|---|---|
| Model Lisence CC0 (4) | 2025.08.18 |
| 47 로딩 스크린과 인트로(Intro and loading progress) (2) | 2025.08.06 |
| 46 퍼포먼스 최적화 (6) | 2025.08.06 |
| 45 후처리(Post-Processing) (5) | 2025.08.06 |