Aligner les éléments HTML en 3D

Cet article fait partie d'une série d'articles sur three.js. Le premier article est les bases de three.js. Si vous ne l'avez pas encore lu et que vous débutez avec three.js, vous pourriez vouloir commencer par là.

Parfois, vous aimeriez afficher du texte dans votre scène 3D. Vous avez plusieurs options, chacune avec ses avantages et ses inconvénients.

  • Utiliser du texte 3D

    Si vous regardez l'article sur les primitives, vous verrez la TextGeometry qui permet de créer du texte 3D. Cela peut être utile pour des logos volants, mais probablement moins pour des statistiques, des informations, ou l'étiquetage de nombreux éléments.

  • Utiliser une texture avec du texte 2D dessiné dessus.

    L'article sur l'utilisation d'un Canvas comme texture montre comment utiliser un canvas comme texture. Vous pouvez dessiner du texte dans un canvas et l'afficher comme un panneau (billboard). L'avantage ici pourrait être que le texte est intégré à la scène 3D. Pour quelque chose comme un terminal d'ordinateur montré dans une scène 3D, cela pourrait être parfait.

  • Utiliser des éléments HTML et les positionner pour correspondre à la 3D

    L'avantage de cette approche est que vous pouvez utiliser tout le HTML. Votre HTML peut contenir plusieurs éléments. Il peut être stylisé avec du CSS. Il peut également être sélectionné par l'utilisateur car c'est du vrai texte.

Cet article couvrira cette dernière approche.

Commençons simplement. Nous allons créer une scène 3D avec quelques primitives et ajouter une étiquette à chaque primitive. Nous commencerons avec un exemple tiré de l'article sur les pages responsives

Nous allons ajouter des OrbitControls comme nous l'avons fait dans l'article sur l'éclairage.

import * as THREE from 'three';
+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0, 0);
controls.update();

Nous devons fournir un élément HTML pour contenir nos éléments d'étiquette

<body>
-  <canvas id="c"></canvas>
+  <div id="container">
+    <canvas id="c"></canvas>
+    <div id="labels"></div>
+  </div>
</body>

En plaçant à la fois le canvas et le <div id="labels"> à l'intérieur d'un conteneur parent, nous pouvons les faire se superposer avec ce CSS

#c {
-    width: 100%;
-    height: 100%;
+    width: 100%;  /* laisser notre conteneur décider de notre taille */
+    height: 100%;
    display: block;
}
+#container {
+  position: relative;  /* fait de ceci l'origine de ses enfants */
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+#labels {
+  position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
+  left: 0;             /* place notre position en haut à gauche du conteneur */
+  top: 0;
+  color: white;
+}

ajoutons également du CSS pour les étiquettes elles-mêmes

#labels>div {
  position: absolute;  /* nous permet de les positionner à l'intérieur du conteneur */
  left: 0;             /* place leur position par défaut en haut à gauche du conteneur */
  top: 0;
  cursor: pointer;     /* change le curseur en main quand la souris est dessus */
  font-size: large;
  user-select: none;   /* empêche la sélection du texte */
  text-shadow:         /* crée un contour noir */
    -1px -1px 0 #000,
     0   -1px 0 #000,
     1px -1px 0 #000,
     1px  0   0 #000,
     1px  1px 0 #000,
     0    1px 0 #000,
    -1px  1px 0 #000,
    -1px  0   0 #000;
}
#labels>div:hover {
  color: red;
}

Maintenant, dans notre code, nous n'avons pas grand-chose à ajouter. Nous avions une fonction makeInstance que nous utilisions pour générer des cubes. Faisons en sorte qu'elle ajoute également un élément d'étiquette.

+const labelContainerElem = document.querySelector('#labels');

-function makeInstance(geometry, color, x) {
+function makeInstance(geometry, color, x, name) {
  const material = new THREE.MeshPhongMaterial({color});

  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);

  cube.position.x = x;

+  const elem = document.createElement('div');
+  elem.textContent = name;
+  labelContainerElem.appendChild(elem);

-  return cube;
+  return {cube, elem};
}

Comme vous pouvez le voir, nous ajoutons un <div> au conteneur, un pour chaque cube. Nous retournons également un objet avec à la fois le cube et l'elem pour l'étiquette.

Pour l'appeler, nous devons fournir un nom pour chacun

const cubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
+  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
+  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
+  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
];

Ce qui reste est le positionnement des éléments d'étiquette au moment du rendu

const tempV = new THREE.Vector3();

...

-cubes.forEach((cube, ndx) => {
+cubes.forEach((cubeInfo, ndx) => {
+  const {cube, elem} = cubeInfo;
  const speed = 1 + ndx * .1;
  const rot = time * speed;
  cube.rotation.x = rot;
  cube.rotation.y = rot;

+  // obtient la position du centre du cube
+  cube.updateWorldMatrix(true, false);
+  cube.getWorldPosition(tempV);
+
+  // obtient la coordonnée d'écran normalisée de cette position
+  // x et y seront dans la plage de -1 à +1, avec x = -1 étant
+  // à gauche et y = -1 étant en bas
+  tempV.project(camera);
+
+  // convertit la position normalisée en coordonnées CSS
+  const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
+  const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
+
+  // déplace l'élément à cette position
+  elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
});

Et avec cela, nous avons des étiquettes alignées sur leurs objets correspondants.

Il y a quelques problèmes que nous voudrons probablement résoudre.

L'un d'eux est que si nous faisons pivoter les objets de manière à ce qu'ils se chevauchent, toutes les étiquettes se chevauchent également.

Un autre est que si nous dézoomons beaucoup, de sorte que les objets sortent du frustum, les étiquettes apparaîtront toujours.

Une solution possible au problème des objets qui se chevauchent est d'utiliser le code de sélection (picking) de l'article sur la sélection. Nous passerons la position de l'objet à l'écran, puis nous demanderons au RayCaster de nous dire quels objets ont été intersectés. Si notre objet n'est pas le premier, alors il n'est pas à l'avant.

const tempV = new THREE.Vector3();
+const raycaster = new THREE.Raycaster();

...

cubes.forEach((cubeInfo, ndx) => {
  const {cube, elem} = cubeInfo;
  const speed = 1 + ndx * .1;
  const rot = time * speed;
  cube.rotation.x = rot;
  cube.rotation.y = rot;

  // obtient la position du centre du cube
  cube.updateWorldMatrix(true, false);
  cube.getWorldPosition(tempV);

  // obtient la coordonnée d'écran normalisée de cette position
  // x et y seront dans la plage de -1 à +1, avec x = -1 étant
  // à gauche et y = -1 étant en bas
  tempV.project(camera);

+  // demande au raycaster tous les objets qui intersectent
+  // depuis l'œil vers la position de cet objet
+  raycaster.setFromCamera(tempV, camera);
+  const intersectedObjects = raycaster.intersectObjects(scene.children);
+  // Nous sommes visibles si la première intersection est cet objet.
+  const show = intersectedObjects.length && cube === intersectedObjects[0].object;
+
+  if (!show) {
+    // cache l'étiquette
+    elem.style.display = 'none';
+  } else {
+    // affiche l'étiquette
+    elem.style.display = '';

    // convertit la position normalisée en coordonnées CSS
    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;

    // déplace l'élément à cette position
    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+  }
});

Cela gère le chevauchement.

Pour gérer la sortie du frustum, nous pouvons ajouter cette vérification si l'origine de l'objet est en dehors du frustum en vérifiant tempV.z

-  if (!show) {
+  if (!show || Math.abs(tempV.z) > 1) {
    // cache l'étiquette
    elem.style.display = 'none';

Cela fonctionne plus ou moins car les coordonnées normalisées que nous avons calculées incluent une valeur z qui va de -1 lorsqu'elle est à la partie near de notre frustum de caméra à +1 lorsqu'elle est à la partie far de notre frustum de caméra.

Pour la vérification du frustum, la solution ci-dessus échoue car nous ne vérifions que l'origine de l'objet. Pour un objet volumineux, cette origine pourrait sortir du frustum, mais la moitié de l'objet pourrait encore s'y trouver.

Une solution plus correcte serait de vérifier si l'objet lui-même est dans le frustum ou non. Malheureusement, cette vérification est lente. Pour 3 cubes, ce ne sera pas un problème, mais pour de nombreux objets, cela pourrait l'être.

Three.js fournit quelques fonctions pour vérifier si la sphère englobante d'un objet est dans un frustum

// au moment de l'initialisation
const frustum = new THREE.Frustum();
const viewProjection = new THREE.Matrix4();

...

// avant de vérifier
camera.updateMatrix();
camera.updateMatrixWorld();
camera.matrixWorldInverse.copy(camera.matrixWorld).invert();

...

// puis pour chaque maillage
someMesh.updateMatrix();
someMesh.updateMatrixWorld();

viewProjection.multiplyMatrices(
    camera.projectionMatrix, camera.matrixWorldInverse);
frustum.setFromProjectionMatrix(viewProjection);
const inFrustum = frustum.contains(someMesh));

Notre solution actuelle de chevauchement a des problèmes similaires. La sélection est lente. Nous pourrions utiliser la sélection basée sur le GPU comme nous l'avons vu dans l'article sur la sélection, mais ce n'est pas non plus gratuit. La solution que vous choisirez dépend de vos besoins.

Un autre problème est l'ordre d'apparition des étiquettes. Si nous modifions le code pour avoir des étiquettes plus longues

const cubes = [
-  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
-  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
-  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
+  makeInstance(geometry, 0x44aa88,  0, 'Boîte Couleur Aqua'),
+  makeInstance(geometry, 0x8844aa, -2, 'Boîte Couleur Violet'),
+  makeInstance(geometry, 0xaa8844,  2, 'Boîte Couleur Or'),
];

et définir le CSS de manière à ce qu'elles ne s'enroulent pas (wrap)

#labels>div {
+  white-space: nowrap;

Alors nous pouvons rencontrer ce problème

Vous pouvez voir ci-dessus que la boîte violette est à l'arrière, mais son étiquette est devant la boîte aqua.

Nous pouvons résoudre ce problème en définissant le zIndex de chaque élément. La position projetée a une valeur z qui va de -1 à l'avant à +1 à l'arrière. Le zIndex doit être un entier et va dans la direction opposée, ce qui signifie que pour le zIndex, les valeurs plus grandes sont à l'avant, donc le code suivant devrait fonctionner.

// convertit la position normalisée en coordonnées CSS
const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
const y = (tempV.y * -.5 + .5) * canvas.clientHeight;

// déplace l'élément à cette position
elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;

+// définit le zIndex pour le tri
+elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;

En raison de la façon dont fonctionne la valeur z projetée, nous devons choisir un grand nombre pour étaler les valeurs, sinon beaucoup auront la même valeur. Pour s'assurer que les étiquettes ne se chevauchent pas avec d'autres parties de la page, nous pouvons demander au navigateur de créer un nouveau contexte d'empilement en définissant le z-index du conteneur des étiquettes

#labels {
  position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
+  z-index: 0;          /* crée un nouveau contexte d'empilement pour que les enfants ne soient pas triés avec le reste de la page */
  left: 0;             /* place notre position en haut à gauche du conteneur */
  top: 0;
  color: white;
  z-index: 0;
}

et maintenant les étiquettes devraient toujours être dans le bon ordre.

Tant que nous y sommes, faisons un autre exemple pour montrer un problème supplémentaire. Dessinons un globe comme Google Maps et étiquetons les pays.

J'ai trouvé ces données qui contiennent les frontières des pays. Elles sont sous licence CC-BY-SA.

J'ai écrit du code pour charger les données et générer les contours des pays ainsi que des données JSON avec les noms des pays et leurs emplacements.

Les données JSON sont un tableau d'entrées ressemblant à ceci

[
  {
    "name": "Algeria",
    "min": [
      -8.667223,
      18.976387
    ],
    "max": [
      11.986475,
      37.091385
    ],
    "area": 238174,
    "lat": 28.163,
    "lon": 2.632,
    "population": {
      "2005": 32854159
    }
  },
  ...

où min, max, lat, lon sont tous en degrés de latitude et de longitude.

Chargeons-les. Le code est basé sur les exemples de l'optimisation de nombreux objets. Bien que nous ne dessinions pas beaucoup d'objets, nous utiliserons les mêmes solutions pour le rendu à la demande.

La première chose est de créer une sphère et d'utiliser la texture des contours.

{
  const loader = new THREE.TextureLoader();
  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
  const geometry = new THREE.SphereGeometry(1, 64, 32);
  const material = new THREE.MeshBasicMaterial({map: texture});
  scene.add(new THREE.Mesh(geometry, material));
}

Ensuite, chargeons le fichier JSON en créant d'abord un chargeur

async function loadJSON(url) {
  const req = await fetch(url);
  return req.json();
}

puis en l'appelant

let countryInfos;
async function loadCountryData() {
  countryInfos = await loadJSON('resources/data/world/country-info.json');
     ...
  }
  requestRenderIfNotRequested();
}
loadCountryData();

Maintenant, utilisons ces données pour générer et placer les étiquettes.

Dans l'article sur l'optimisation de nombreux objets, nous avions mis en place un petit graphe de scène d'objets auxiliaires pour faciliter le calcul des positions de latitude et de longitude sur notre globe. Consultez cet article pour une explication de leur fonctionnement.

const lonFudge = Math.PI * 1.5;
const latFudge = Math.PI;
// ces helpers (aides) faciliteront le positionnement des boîtes
// Nous pouvons faire pivoter le lon helper sur son axe Y pour la longitude
const lonHelper = new THREE.Object3D();
// Nous faisons pivoter le latHelper sur son axe X pour la latitude
const latHelper = new THREE.Object3D();
lonHelper.add(latHelper);
// Le position helper déplace l'objet vers le bord de la sphère
const positionHelper = new THREE.Object3D();
positionHelper.position.z = 1;
latHelper.add(positionHelper);

Nous utiliserons cela pour calculer une position pour chaque étiquette

const labelParentElem = document.querySelector('#labels');
for (const countryInfo of countryInfos) {
  const {lat, lon, name} = countryInfo;

  // ajuste les aides pour pointer vers la latitude et la longitude
  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;

  // obtient la position de la lat/lon
  positionHelper.updateWorldMatrix(true, false);
  const position = new THREE.Vector3();
  positionHelper.getWorldPosition(position);
  countryInfo.position = position;

  // ajoute un élément pour chaque pays
  const elem = document.createElement('div');
  elem.textContent = name;
  labelParentElem.appendChild(elem);
  countryInfo.elem = elem;

Le code ci-dessus ressemble beaucoup au code que nous avons écrit pour créer les étiquettes de cube, créant un élément par étiquette. Lorsque nous avons terminé, nous avons un tableau, countryInfos, avec une entrée pour chaque pays, à laquelle nous avons ajouté une propriété elem pour l'élément d'étiquette de ce pays et une position avec sa position sur le globe.

Tout comme nous l'avons fait pour les cubes, nous devons mettre à jour la position des étiquettes et le temps de rendu.

const tempV = new THREE.Vector3();

function updateLabels() {
  // quitte si nous n'avons pas encore chargé le fichier JSON
  if (!countryInfos) {
    return;
  }

  for (const countryInfo of countryInfos) {
    const {position, elem} = countryInfo;

    // obtient la coordonnée d'écran normalisée de cette position
    // x et y seront dans la plage de -1 à +1, avec x = -1 étant
    // à gauche et y = -1 étant en bas
    tempV.copy(position);
    tempV.project(camera);

    // convertit la position normalisée en coordonnées CSS
    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;

    // déplace l'élément à cette position
    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;

    // définit le zIndex pour le tri
    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
  }
}

Vous pouvez voir que le code ci-dessus est sensiblement similaire à l'exemple des cubes précédent. La seule différence majeure est que nous avons précalculé les positions des étiquettes au moment de l'initialisation. Nous pouvons le faire car le globe ne bouge jamais. Seule notre caméra bouge.

Enfin, nous devons appeler updateLabels dans notre boucle de rendu

function render() {
  renderRequested = false;

  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }

  controls.update();

+  updateLabels();

  renderer.render(scene, camera);
}

Et voici ce que nous obtenons

Il y a beaucoup trop d'étiquettes !

Nous avons 2 problèmes.

  1. Les étiquettes qui font face à l'opposé de nous apparaissent.

  2. Il y a trop d'étiquettes.

Pour le problème n°1, nous ne pouvons pas vraiment utiliser le RayCaster comme nous l'avons fait ci-dessus car il n'y a rien à intersecter, à part la sphère. Au lieu de cela, ce que nous pouvons faire est de vérifier si ce pays particulier est tourné vers l'opposé de nous ou non. Cela fonctionne car les positions des étiquettes sont autour d'une sphère. En fait, nous utilisons une sphère unitaire, une sphère avec un rayon de 1,0. Cela signifie que les positions sont déjà des vecteurs unitaires, ce qui rend les calculs relativement faciles.

const tempV = new THREE.Vector3();
+const cameraToPoint = new THREE.Vector3();
+const cameraPosition = new THREE.Vector3();
+const normalMatrix = new THREE.Matrix3();

function updateLabels() {
  // quitte si nous n'avons pas encore chargé le fichier JSON
  if (!countryInfos) {
    return;
  }

+  const minVisibleDot = 0.2;
+  // obtient une matrice qui représente une orientation relative de la caméra
+  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
+  // obtient la position de la caméra
+  camera.getWorldPosition(cameraPosition);
  for (const countryInfo of countryInfos) {
    const {position, elem} = countryInfo;

+    // Oriente la position en fonction de l'orientation de la caméra.
+    // Comme la sphère est à l'origine et que la sphère est une sphère unitaire
+    // cela nous donne un vecteur direction relatif à la caméra pour la position.
+    tempV.copy(position);
+    tempV.applyMatrix3(normalMatrix);
+
+    // calcule la direction vers cette position depuis la caméra
+    cameraToPoint.copy(position);
+    cameraToPoint.applyMatrix4(camera.matrixWorldInverse).normalize();
+
+    // obtient le produit scalaire de la direction relative à la caméra vers cette position
+    // sur le globe avec la direction de la caméra vers ce point.
+    // 1 = fait face directement à la caméra
+    // 0 = exactement tangente à la sphère vue de la caméra
+    // < 0 = fait face à l'opposé
+    const dot = tempV.dot(cameraToPoint);
+
+    // si l'orientation ne nous fait pas face, la cacher.
+    if (dot < minVisibleDot) {
+      elem.style.display = 'none';
+      continue;
+    }
+
+    // restaure le style d'affichage par défaut de l'élément
+    elem.style.display = '';

    // obtient la coordonnée d'écran normalisée de cette position
    // x et y seront dans la plage de -1 à +1, avec x = -1 étant
    // à gauche et y = -1 étant en bas
    tempV.copy(position);
    tempV.project(camera);

    // convertit la position normalisée en coordonnées CSS
    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;

    // déplace l'élément à cette position
    countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;

    // définit le zIndex pour le tri
    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
  }
}

Ci-dessus, nous utilisons les positions comme direction et obtenons cette direction par rapport à la caméra. Ensuite, nous obtenons la direction relative à la caméra depuis la caméra vers cette position sur le globe et calculons le produit scalaire. Le produit scalaire renvoie le cosinus de l'angle entre les deux vecteurs. Cela nous donne une valeur de -1 à +1, où -1 signifie que l'étiquette fait face à la caméra, 0 signifie que l'étiquette est exactement sur le bord de la sphère par rapport à la caméra, et toute valeur supérieure à zéro est derrière. Nous utilisons ensuite cette valeur pour afficher ou masquer l'élément.

Dans le diagramme ci-dessus, nous pouvons voir le produit scalaire de la direction vers laquelle l'étiquette est orientée et de la direction de la caméra vers cette position. Si vous faites pivoter la direction, vous verrez que le produit scalaire est de -1,0 lorsque la direction est directement face à la caméra, il est de 0,0 lorsqu'il est exactement tangent à la sphère par rapport à la caméra, ou pour le dire autrement, il est de 0 lorsque les 2 vecteurs sont perpendiculaires l'un à l'autre, à 90 degrés. Il est supérieur à zéro lorsque l'étiquette est derrière la sphère.

Pour le problème n°2, trop d'étiquettes, nous avons besoin d'un moyen de décider quelles étiquettes afficher. Une façon serait de n'afficher les étiquettes que pour les grands pays. Les données que nous chargeons contiennent les valeurs min et max pour la superficie qu'un pays couvre. À partir de là, nous pouvons calculer une superficie, puis utiliser cette superficie pour décider d'afficher ou non le pays.

Au moment de l'initialisation, calculons la superficie

const labelParentElem = document.querySelector('#labels');
for (const countryInfo of countryInfos) {
  const {lat, lon, min, max, name} = countryInfo;

  // ajuste les aides pour pointer vers la latitude et la longitude
  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;

  // obtient la position de la lat/lon
  positionHelper.updateWorldMatrix(true, false);
  const position = new THREE.Vector3();
  positionHelper.getWorldPosition(position);
  countryInfo.position = position;

+  // calcule la superficie pour chaque pays
+  const width = max[0] - min[0];
+  const height = max[1] - min[1];
+  const area = width * height;
+  countryInfo.area = area;

  // ajoute un élément pour chaque pays
  const elem = document.createElement('div');
  elem.textContent = name;
  labelParentElem.appendChild(elem);
  countryInfo.elem = elem;
}

Puis au moment du rendu, utilisons la superficie pour décider d'afficher l'étiquette ou non

+const large = 20 * 20;
const maxVisibleDot = 0.2;
// obtient une matrice qui représente une orientation relative de la caméra
normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
// obtient la position de la caméra
camera.getWorldPosition(cameraPosition);
for (const countryInfo of countryInfos) {
-  const {position, elem} = countryInfo;
+  const {position, elem, area} = countryInfo;
+  // assez grand ?
+  if (area < large) {
+    elem.style.display = 'none';
+    continue;
+  }

  ...

Enfin, comme je ne suis pas sûr des bonnes valeurs pour ces paramètres, ajoutons une GUI pour que nous puissions jouer avec elles

import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
+const settings = {
+  minArea: 20,
+  maxVisibleDot: -0.2,
+};
+const gui = new GUI({width: 300});
+gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
+gui.add(settings, 'maxVisibleDot', -1, 1, 0.01).onChange(requestRenderIfNotRequested);

function updateLabels() {
  if (!countryInfos) {
    return;
  }

-  const large = 20 * 20;
-  const maxVisibleDot = -0.2;
+  const large = settings.minArea * settings.minArea;
  // obtient une matrice qui représente une orientation relative de la caméra
  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
  // obtient la position de la caméra
  camera.getWorldPosition(cameraPosition);
  for (const countryInfo of countryInfos) {

    ...

    // si l'orientation ne nous fait pas face, la cacher.
-    if (dot > maxVisibleDot) {
+    if (dot > settings.maxVisibleDot) {
      elem.style.display = 'none';
      continue;
    }

et voici le résultat

Vous pouvez voir qu'en faisant pivoter la Terre, les étiquettes qui passent derrière disparaissent. Ajustez le minVisibleDot pour voir le changement de seuil. Vous pouvez également ajuster la valeur de minArea pour voir apparaître des pays plus grands ou plus petits.

Plus j'ai travaillé là-dessus, plus j'ai réalisé l'énorme travail investi dans Google Maps. Eux aussi doivent décider quelles étiquettes afficher. Je suis à peu près sûr qu'ils utilisent toutes sortes de critères. Par exemple, votre position actuelle, votre paramètre de langue par défaut, les paramètres de votre compte si vous en avez un, ils utilisent probablement la population ou la popularité, ils pourraient donner la priorité aux pays au centre de la vue, etc... Beaucoup de choses à considérer.

En tout cas, j'espère que ces exemples vous ont donné une idée de la façon d'aligner les éléments HTML avec votre 3D. Quelques choses que je pourrais changer.

Prochaine étape, faisons en sorte que vous puissiez sélectionner et surligner un pays.