본문 바로가기
Graphic/ThreeJS

48 3D 위치에 HTML 요소 고정하기 (HTML과 WebGL 혼합)

by curious week 2025. 8. 7.

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