# Caméras

Cet article fait partie d'une série d'articles sur three.js. Le premier article traitait des bases. Si vous ne l'avez pas encore lu, vous pourriez vouloir commencer par là.

Parlons des caméras dans three.js. Nous avons abordé une partie de cela dans le premier article, mais nous allons le couvrir plus en détail ici.

La caméra la plus courante dans three.js, et celle que nous avons utilisée jusqu'à présent, est la `PerspectiveCamera`. Elle donne une vue 3D où les objets au loin apparaissent plus petits que les objets proches.

La `PerspectiveCamera` définit un frustum. Un frustum est une forme pyramidale solide dont l'extrémité est coupée. Par nom de solide, j'entends par exemple qu'un cube, un cône, une sphère, un cylindre et un frustum sont tous des noms de différents types de solides.

cube
cône
sphère
cylindre
frustum

Je ne le signale que parce que je ne le savais pas pendant des années. Un livre ou une page mentionnait frustum et mes yeux se voilaient. Comprendre que c'est le nom d'un type de forme solide a rendu ces descriptions soudainement plus logiques 😅

Une `PerspectiveCamera` définit son frustum en fonction de 4 propriétés. `near` définit où commence l'avant du frustum. `far` définit où il se termine. `fov`, le champ de vision, définit la hauteur de l'avant et de l'arrière du frustum en calculant la hauteur correcte pour obtenir le champ de vision spécifié à `near` unités de la caméra. L'`aspect` définit la largeur de l'avant et de l'arrière du frustum. La largeur du frustum est simplement la hauteur multipliée par l'aspect.

Utilisons la scène de l'article précédent qui contient un plan au sol, une sphère et un cube, et faisons en sorte de pouvoir ajuster les paramètres de la caméra.

Pour ce faire, nous allons créer un `MinMaxGUIHelper` pour les paramètres `near` et `far` afin que `far` soit toujours supérieur à `near`. Il aura des propriétés `min` et `max` que lil-gui ajustera. Lorsqu'elles seront ajustées, elles définiront les 2 propriétés que nous spécifions.

    class MinMaxGUIHelper {
      constructor(obj, minProp, maxProp, minDif) {
        this.obj = obj;
        this.minProp = minProp;
        this.maxProp = maxProp;
        this.minDif = minDif;
      }
      get min() {
        return this.obj[this.minProp];
      }
      set min(v) {
        this.obj[this.minProp] = v;
        this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
      }
      get max() {
        return this.obj[this.maxProp];
      }
      set max(v) {
        this.obj[this.maxProp] = v;
        this.min = this.min;  // ceci appellera le setter de min
      }
    }

Maintenant, nous pouvons configurer notre interface graphique comme ceci

    function updateCamera() {
      camera.updateProjectionMatrix();
    }

    const gui = new GUI();
    gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);

Chaque fois que les paramètres de la caméra changent, nous devons appeler la fonction `updateProjectionMatrix` de la caméra. Nous avons donc créé une fonction nommée `updateCamera` et l'avons passée à lil-gui pour qu'elle soit appelée lorsque les choses changent.

cliquez ici pour ouvrir dans une fenêtre séparée

Vous pouvez ajuster les valeurs et voir comment elles fonctionnent. Notez que nous n'avons pas rendu l'`aspect` modifiable car il est tiré de la taille de la fenêtre. Donc, si vous voulez ajuster l'aspect, ouvrez l'exemple dans une nouvelle fenêtre et redimensionnez-la.

Néanmoins, je pense que c'est un peu difficile à voir, alors changeons l'exemple pour qu'il comporte 2 caméras. L'une montrera notre scène telle que nous la voyons ci-dessus, l'autre montrera une autre caméra regardant la scène que la première caméra dessine et affichant le frustum de cette caméra.

Pour ce faire, nous pouvons utiliser la fonction scissor de three.js. Changeons-le pour dessiner 2 scènes avec 2 caméras côte à côte en utilisant la fonction scissor.

Tout d'abord, utilisons du HTML et du CSS pour définir 2 éléments côte à côte. Cela nous aidera également avec les événements afin que les deux caméras puissent facilement avoir leurs propres `OrbitControls`.

    <body>
      <canvas id="c"></canvas>
    +  <div class="split">
    +     <div id="view1" tabindex="1"></div>
    +     <div id="view2" tabindex="2"></div>
    +  </div>
    </body>

Et le CSS qui fera apparaître ces 2 vues côte à côte superposées sur le canvas

    .split {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
    }
    .split>div {
      width: 100%;
      height: 100%;
    }

Puis dans notre code, nous ajouterons un `CameraHelper`. Un `CameraHelper` dessine le frustum d'une `Camera`

    const cameraHelper = new THREE.CameraHelper(camera);

    ...

    scene.add(cameraHelper);

Maintenant, cherchons les 2 éléments de vue.

    const view1Elem = document.querySelector('#view1');
    const view2Elem = document.querySelector('#view2');

Et nous configurerons nos `OrbitControls` existants pour qu'ils ne répondent qu'au premier élément de vue.

    -const controls = new OrbitControls(camera, canvas);
    +const controls = new OrbitControls(camera, view1Elem);

Créons une deuxième `PerspectiveCamera` et un deuxième `OrbitControls`. Le deuxième `OrbitControls` est lié à la deuxième caméra et reçoit l'entrée du deuxième élément de vue.

    const camera2 = new THREE.PerspectiveCamera(
      60,  // fov
      2,   // aspect
      0.1, // near
      500, // far
    );
    camera2.position.set(40, 10, 30);
    camera2.lookAt(0, 5, 0);

    const controls2 = new OrbitControls(camera2, view2Elem);
    controls2.target.set(0, 5, 0);
    controls2.update();

Enfin, nous devons rendre la scène du point de vue de chaque caméra en utilisant la fonction scissor pour ne rendre qu'une partie du canvas.

Voici une fonction qui, étant donné un élément, calculera le rectangle de cet élément qui chevauche le canvas. Elle définira ensuite le scissor et le viewport sur ce rectangle et renverra l'aspect pour cette taille.

    function setScissorForElement(elem) {
      const canvasRect = canvas.getBoundingClientRect();
      const elemRect = elem.getBoundingClientRect();

      // calculer un rectangle relatif au canvas
      const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
      const left = Math.max(0, elemRect.left - canvasRect.left);
      const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
      const top = Math.max(0, elemRect.top - canvasRect.top);

      const width = Math.min(canvasRect.width, right - left);
      const height = Math.min(canvasRect.height, bottom - top);

      // configurer le scissor pour ne rendre que cette partie du canvas
      const positiveYUpBottom = canvasRect.height - bottom;
      renderer.setScissor(left, positiveYUpBottom, width, height);
      renderer.setViewport(left, positiveYUpBottom, width, height);

      // retourner l'aspect
      return width / height;
    }

Et maintenant, nous pouvons utiliser cette fonction pour dessiner la scène deux fois dans notre fonction `render`.

      function render() {

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

    +    resizeRendererToDisplaySize(renderer);
    +
    +    // activer le scissor
    +    renderer.setScissorTest(true);
    +
    +    // rendre la vue originale
    +    {
    +      const aspect = setScissorForElement(view1Elem);
    +
    +      // ajuster la caméra pour cet aspect
    +      camera.aspect = aspect;
    +      camera.updateProjectionMatrix();
    +      cameraHelper.update();
    +
    +      // ne pas dessiner l'helper de caméra dans la vue originale
    +      cameraHelper.visible = false;
    +
    +      scene.background.set(0x000000);
    +
    +      // rendre
    +      renderer.render(scene, camera);
    +    }
    +
    +    // rendre depuis la 2ème caméra
    +    {
    +      const aspect = setScissorForElement(view2Elem);
    +
    +      // ajuster la caméra pour cet aspect
    +      camera2.aspect = aspect;
    +      camera2.updateProjectionMatrix();
    +
    +      // dessiner l'helper de caméra dans la 2ème vue
    +      cameraHelper.visible = true;
    +
    +      scene.background.set(0x000040);
    +
    +      renderer.render(scene, camera2);
    +    }

    -    renderer.render(scene, camera);

        requestAnimationFrame(render);
      }

      requestAnimationFrame(render);
    }

Le code ci-dessus définit la couleur de fond de la scène lors du rendu de la deuxième vue sur bleu foncé juste pour faciliter la distinction entre les deux vues.

Nous pouvons également supprimer notre code `updateCamera` puisque nous mettons tout à jour dans la fonction `render`.

    -function updateCamera() {
    -  camera.updateProjectionMatrix();
    -}

    const gui = new GUI();
    -gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    +gui.add(camera, 'fov', 1, 180);
    const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    +gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
    +gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');

Et maintenant, vous pouvez utiliser une vue pour voir le frustum de l'autre.

cliquez ici pour ouvrir dans une fenêtre séparée

À gauche, vous pouvez voir la vue originale et à droite, vous pouvez voir une vue montrant le frustum de la caméra de gauche. En ajustant `near`, `far`, `fov` et en déplaçant la caméra avec la souris, vous pouvez voir que seul ce qui se trouve à l'intérieur du frustum affiché à droite apparaît dans la scène de gauche.

Ajustez `near` jusqu'à environ 20 et vous verrez facilement l'avant des objets disparaître car ils ne sont plus dans le frustum. Ajustez `far` en dessous d'environ 35 et vous commencerez à voir le plan au sol disparaître car il n'est plus dans le frustum.

Cela soulève la question : pourquoi ne pas simplement régler `near` sur 0.0000000001 et `far` sur 10000000000000 ou quelque chose de similaire afin de tout voir ? La raison est que votre GPU n'a qu'une précision limitée pour décider si quelque chose est devant ou derrière autre chose. Cette précision est répartie entre `near` et `far`. Pire encore, par défaut, la précision près de la caméra est détaillée et la précision loin de la caméra est grossière. Les unités commencent à `near` et s'étendent lentement à mesure qu'elles s'approchent de `far`.

En partant de l'exemple du haut, changeons le code pour insérer 20 sphères d'affilée.

    {
      const sphereRadius = 3;
      const sphereWidthDivisions = 32;
      const sphereHeightDivisions = 16;
      const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
      const numSpheres = 20;
      for (let i = 0; i < numSpheres; ++i) {
        const sphereMat = new THREE.MeshPhongMaterial();
        sphereMat.color.setHSL(i * .73, 1, 0.5);
        const mesh = new THREE.Mesh(sphereGeo, sphereMat);
        mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
        scene.add(mesh);
      }
    }

et réglons `near` sur 0.00001

    const fov = 45;
    const aspect = 2;  // the canvas default
    -const near = 0.1;
    +const near = 0.00001;
    const far = 100;
    const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

Nous devons également ajuster un peu le code de l'interface graphique pour permettre 0.00001 si la valeur est éditée.

    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    +gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);

Que pensez-vous qu'il va se passer ?

cliquez ici pour ouvrir dans une fenêtre séparée

C'est un exemple de z-fighting (chevauchement en Z) où le GPU de votre ordinateur n'a pas assez de précision pour décider quels pixels sont devant et quels pixels sont derrière.

Juste au cas où le problème n'apparaîtrait pas sur votre machine, voici ce que je vois sur la mienne

Une solution consiste à indiquer à three.js d'utiliser une méthode différente pour calculer quels pixels sont devant et quels pixels sont derrière. Nous pouvons le faire en activant `logarithmicDepthBuffer` lorsque nous créons le `WebGLRenderer`

    -const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    +const renderer = new THREE.WebGLRenderer({
    +  antialias: true,
    +  canvas,
    +  logarithmicDepthBuffer: true,
    +});

et avec cela, cela pourrait fonctionner

cliquez ici pour ouvrir dans une fenêtre séparée

Si cela n'a pas résolu le problème pour vous, alors vous avez rencontré l'une des raisons pour lesquelles vous ne pouvez pas toujours utiliser cette solution. Cette raison est que seuls certains GPU la supportent. En septembre 2018, presque aucun appareil mobile ne supportait cette solution, alors que la plupart des ordinateurs de bureau le faisaient.

Une autre raison de ne pas choisir cette solution est qu'elle peut être significativement plus lente que la solution standard.

Même avec cette solution, la résolution reste limitée. Rendez `near` encore plus petit ou `far` encore plus grand et vous rencontrerez finalement les mêmes problèmes.

Ce que cela signifie, c'est que vous devriez toujours faire un effort pour choisir un paramètre `near` et `far` qui convient à votre cas d'utilisation. Réglez `near` aussi loin de la caméra que possible sans que les objets ne disparaissent. Réglez `far` aussi près de la caméra que possible sans que les objets ne disparaissent. Si vous essayez de dessiner une scène géante et de montrer un gros plan du visage de quelqu'un afin que vous puissiez voir ses cils tout en voyant à l'arrière-plan des montagnes à 50 kilomètres de distance, eh bien, vous devrez alors trouver d'autres solutions créatives que nous aborderons peut-être plus tard. Pour l'instant, sachez simplement que vous devez faire attention à choisir des valeurs `near` et `far` appropriées à vos besoins.

La deuxième caméra la plus courante est l'`OrthographicCamera`. Au lieu de spécifier un frustum, elle spécifie une boîte avec les paramètres `left`, `right` `top`, `bottom`, `near` et `far`. Comme elle projette une boîte, il n'y a pas de perspective.

Changeons l'exemple à 2 vues ci-dessus pour utiliser une `OrthographicCamera` dans la première vue.

Tout d'abord, configurons une `OrthographicCamera`.

    const left = -1;
    const right = 1;
    const top = 1;
    const bottom = -1;
    const near = 5;
    const far = 50;
    const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    camera.zoom = 0.2;

Nous réglons `left` et `bottom` à -1 et `right` et `top` à 1. Cela créerait une boîte de 2 unités de large et 2 unités de haut, mais nous allons ajuster `left` et `top` en fonction de l'aspect du rectangle dans lequel nous dessinons. Nous utiliserons la propriété `zoom` pour faciliter l'ajustement du nombre d'unités réellement affichées par la caméra.

Ajoutons un paramètre GUI pour `zoom`.

    const gui = new GUI();
    +gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();

L'appel à `listen` indique à lil-gui de surveiller les changements. Ceci est ici car les `OrbitControls` peuvent également contrôler le zoom. Par exemple, la molette de la souris effectuera un zoom via les `OrbitControls`.

Enfin, il nous suffit de modifier la partie qui rend le côté gauche pour mettre à jour l'`OrthographicCamera`.

    {
      const aspect = setScissorForElement(view1Elem);

      // mettre à jour la caméra pour cet aspect
    -  camera.aspect = aspect;
    +  camera.left   = -aspect;
    +  camera.right  =  aspect;
      camera.updateProjectionMatrix();
      cameraHelper.update();

      // ne pas dessiner l'helper de caméra dans la vue originale
      cameraHelper.visible = false;

      scene.background.set(0x000000);
      renderer.render(scene, camera);
    }

et maintenant vous pouvez voir une `OrthographicCamera` en action.

cliquez ici pour ouvrir dans une fenêtre séparée

Une autre utilisation courante pour une `OrthographicCamera` est de dessiner les vues du dessus, du dessous, de gauche, de droite, de face, d'arrière d'un programme de modélisation 3D ou de l'éditeur d'un moteur de jeu.

Dans la capture d'écran ci-dessus, vous pouvez voir qu'une vue est une vue en perspective et 3 vues sont des vues orthographiques.

Une `OrthographicCamera` est le plus souvent utilisée si vous utilisez three.js pour dessiner des éléments 2D. Vous décidez combien d'unités vous voulez que la caméra affiche. Par exemple, si vous voulez qu'un pixel du canvas corresponde à une unité dans la caméra, vous pourriez faire quelque chose comme

Pour placer l'origine au centre et avoir 1 pixel = 1 unité three.js, quelque chose comme

    camera.left = -canvas.width / 2;
    camera.right = canvas.width / 2;
    camera.top = canvas.height / 2;
    camera.bottom = -canvas.height / 2;
    camera.near = -1;
    camera.far = 1;
    camera.zoom = 1;

Ou si nous voulions que l'origine soit en haut à gauche, comme sur un canvas 2D, nous pourrions utiliser ceci

    camera.left = 0;
    camera.right = canvas.width;
    camera.top = 0;
    camera.bottom = canvas.height;
    camera.near = -1;
    camera.far = 1;
    camera.zoom = 1;

Dans ce cas, le coin supérieur gauche serait 0,0, comme sur un canvas 2D.

Essayons ! Tout d'abord, configurons la caméra.

    const left = 0;
    const right = 300;  // taille par défaut du canvas
    const top = 0;
    const bottom = 150;  // taille par défaut du canvas
    const near = -1;
    const far = 1;
    const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    camera.zoom = 1;

Puis chargeons 6 textures et créons 6 plans, un pour chaque texture. Nous associerons chaque plan à un `THREE.Object3D` pour faciliter le décalage du plan afin que son centre apparaisse à son coin supérieur gauche.

Si vous l'exécutez localement, vous devrez également avoir effectué la configuration. Vous pourriez également vouloir lire l'article sur l'utilisation des textures.

    const loader = new THREE.TextureLoader();
    const textures = [
      loader.load('resources/images/flower-1.jpg'),
      loader.load('resources/images/flower-2.jpg'),
      loader.load('resources/images/flower-3.jpg'),
      loader.load('resources/images/flower-4.jpg'),
      loader.load('resources/images/flower-5.jpg'),
      loader.load('resources/images/flower-6.jpg'),
    ];
    const planeSize = 256;
    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planes = textures.map((texture) => {
      const planePivot = new THREE.Object3D();
      scene.add(planePivot);
      texture.magFilter = THREE.NearestFilter;
      const planeMat = new THREE.MeshBasicMaterial({
        map: texture,
        side: THREE.DoubleSide,
      });
      const mesh = new THREE.Mesh(planeGeo, planeMat);
      planePivot.add(mesh);
      // déplacer le plan pour que le coin supérieur gauche soit l'origine
      mesh.position.set(planeSize / 2, planeSize / 2, 0);
      return planePivot;
    });

et nous devons mettre à jour la caméra si la taille du canvas change.

    function render() {

      if (resizeRendererToDisplaySize(renderer)) {
        camera.right = canvas.width;
        camera.bottom = canvas.height;
        camera.updateProjectionMatrix();
      }

      ...

`planes` est un tableau de `THREE.Mesh`, un pour chaque plan. Déplaçons-les en fonction du temps.

    function render(time) {
      time *= 0.001;  // convertir en secondes ;

      ...

      const distAcross = Math.max(20, canvas.width - planeSize);
      const distDown = Math.max(20, canvas.height - planeSize);

      // distance totale pour se déplacer en travers et en arrière
      const xRange = distAcross * 2;
      const yRange = distDown * 2;
      const speed = 180;

      planes.forEach((plane, ndx) => {
        // calculer un temps unique pour chaque plan
        const t = time * speed + ndx * 300;

        // obtenir une valeur entre 0 et la plage
        const xt = t % xRange;
        const yt = t % yRange;

        // définir notre position en avant si 0 à la moitié de la plage
        // et en arrière si la moitié de la plage à la plage
        const x = xt < distAcross ? xt : xRange - xt;
        const y = yt < distDown   ? yt : yRange - yt;

        plane.position.set(x, y, 0);
      });

      renderer.render(scene, camera);

Et vous pouvez voir les images rebondir parfaitement au pixel près sur les bords du canvas en utilisant des calculs de pixels, tout comme un canvas 2D.

cliquez ici pour ouvrir dans une fenêtre séparée

Une autre utilisation courante pour une `OrthographicCamera` est de dessiner les vues du dessus, du dessous, de gauche, de droite, de face, d'arrière d'un programme de modélisation 3D ou de l'éditeur d'un moteur de jeu.

Dans la capture d'écran ci-dessus, vous pouvez voir qu'une vue est une vue en perspective et 3 vues sont des vues orthographiques.

Voilà les bases des caméras. Nous aborderons quelques méthodes courantes pour déplacer les caméras dans d'autres articles. Pour l'instant, passons aux ombres.