Cet article fait partie d'une série consacrée à Three.js. Le premier article s'intitule Principes de base. Si vous ne l'avez pas encore lu, vous voudriez peut-être commencer par là.
Parlons des caméras dans Three.js. Nous en avons déjà parlé dans le premier article mais ici nous allons entrer dans le détail.
La caméra la plus courante dans Three.js et celle que nous avons utilisée jusqu'à présent, la PerspectiveCamera
. Elle donne une vue 3D où les choses lointaines semblent plus petites que les plus proches.
La PerspectiveCamera
définit un frustum. Un frustum (tronc) est une forme pyramidale solide dont la pointe est coupée). Par nom de solide, j'entends par exemple un cube, un cône, une sphère, un cylindre et un frustum sont tous des noms de différents types de solides.
Je le signale seulement parce que je ne le savais pas. Et quand je voyais le mot frustum dans un livre mes yeux buggaient. 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 selon 4 propriétés. near
définit l'endroit où commence l'avant du frustum. far
où il finit. fov
, le champ de vision, définit la hauteur de l'avant et de l'arrière du tronc en fonction de la propriété near
. L'aspect
se rapporte à la largeur de l'avant et de l'arrière du tronc. La largeur du tronc est juste la hauteur multipliée par l'aspect.
Utilisons la scène de l'article précédent avec son plan, sa sphère et son cube, et faisons en sorte que nous puissions ajuster les paramètres de la caméra.
Pour ce faire, nous allons créer un MinMaxGUIHelper
pour les paramètres near
et far
où far
est toujours supérieur near
. Il aura des propriétés min
et max
que lil-gui
pourra ajuster. Une fois ajustés, ils 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; // this will call the min setter } }
Maintenant, nous pouvons configurer lil-gui comme ça
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, il faut appeler la fonction
updateProjectionMatrix
. Nous avons donc créé une fonction updateCamera
transmise à lil-gui pour l'appeler lorsque les choses changent.
Vous pouvez ajuster les valeurs et voir comment elles fonctionnent. Notez que nous n'avons pas rendu aspect
réglable car il est pris à partir de la taille de la fenêtre, donc si vous souhaitez ajuster l'aspect, ouvrez l'exemple dans une nouvelle fenêtre, puis redimensionnez la fenêtre.
Néanmoins, je pense que c'est un peu difficile à voir, alors modifions l'exemple pour qu'il ait 2 caméras. L'un 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 montrant le frustum de cette caméra.
Pour ce faire, nous pouvons utiliser la fonction ciseaux de three.js. Modifions-le pour dessiner 2 scènes avec 2 caméras côte à côte en utilisant la fonction ciseaux.
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 sur le canevas
.split { position: absolute; left: 0; top: 0; width: 100%; height: 100%; display: flex; } .split>div { width: 100%; height: 100%; }
Ensuite, ajoutons un CameraHelper
. Un CameraHelper
dessine le frustum d'une Camera
.
const cameraHelper = new THREE.CameraHelper(camera); ... scene.add(cameraHelper);
Récupérons maintenant nos 2 éléments.
const view1Elem = document.querySelector('#view1'); const view2Elem = document.querySelector('#view2');
Et nous allons configurer nos OrbitControls
pour qu'ils répondent uniquement au premier élément.
-const controls = new OrbitControls(camera, canvas); +const controls = new OrbitControls(camera, view1Elem);
Ajoutons une nouvelle PerspectiveCamera
et un second OrbitControls
.
Le deuxième OrbitControls
est lié à la deuxième caméra et reçoit view2Elem en paramètre.
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 setScissor
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. Il définira ensuite les ciseaux et la fenêtre 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 les ciseaux 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); // retourne 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); + + // déclenche la fonction setScissorTest + renderer.setScissorTest(true); + + // rend la vue originelle + { + const aspect = setScissorForElement(view1Elem); + + // adjuste la caméra pour cet aspect + camera.aspect = aspect; + camera.updateProjectionMatrix(); + cameraHelper.update(); + + // ne pas ajouter le camera helper dans la vue originelle + cameraHelper.visible = false; + + scene.background.set(0x000000); + + // rendu + renderer.render(scene, camera); + } + + // rendu de la 2e caméra + { + const aspect = setScissorForElement(view2Elem); + + // adjuste la caméra + camera2.aspect = aspect; + camera2.updateProjectionMatrix(); + + // camera helper dans la 2e 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 d'arrière-plan de la scène lors du rendu de la deuxième vue en bleu foncé juste pour faciliter la distinction des 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.
Sur la gauche, vous pouvez voir la vue d'origine et sur la droite, vous pouvez voir une vue montrant le frustum sur la gauche. Lorsque vous ajustez near
, far
, fov
et déplacez la caméra avec la souris, vous pouvez voir que seul ce qui se trouve à l'intérieur du frustum montré à droite apparaît dans la scène à gauche.
Ajustez near
d'environ 20 et vous verrez facilement le devant des objets disparaître car ils ne sont plus dans le tronc. Ajustez far
en dessous de 35 et vous commencerez à voir le plan de masse disparaître car il n'est plus dans le tronc.
Cela soulève la question, pourquoi ne pas simplement définir near
de 0,0000000001 et far
de 100000000000000 ou quelque chose comme ça pour que vous puissiez tout voir? Parce que votre GPU n'a qu'une précision limitée pour décider si quelque chose est devant ou derrière quelque chose d'autre. Cette précision se répartit entre near
et far
. Pire, par défaut la précision au plus près de la caméra est précise tandis que celle la plus lointaine de la caméra est grossière. Les unités commencent par near
et s'étendent lentement à mesure qu'elles s'approchent de far
.
En commençant par l'exemple du haut, modifions 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 définissons near
à 0.00001
const fov = 45; const aspect = 2; // valeur par défaut -const near = 0.1; +const near = 0.00001; const far = 100; const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
Nous devons également modifier un peu le code de lil-gui pour autoriser 0,00001 si la valeur est modifié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 ?
Ceci est un exemple de z fighting 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 ne s'afficherait 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 lesquels sont derrière. Nous pouvons le faire en activant logarithmicDepthBuffer
lorsque nous créons le WebGLRenderer
-const renderer = new THREE.WebGLRenderer({canvas}); +const renderer = new THREE.WebGLRenderer({ + canvas, + logarithmicDepthBuffer: true, +});
et avec ça, ça devrait marcher.
Si cela n'a pas résolu le problème pour vous, vous avez rencontré une raison pour laquelle vous ne pouvez pas toujours utiliser cette solution. Cette raison est due au fait que seuls certains GPU le prennent en charge. En septembre 2018, presque aucun appareil mobile ne prenait en charge cette solution, contrairement à la plupart des ordinateurs de bureau.
Une autre raison de ne pas choisir cette solution est qu'elle peut être nettement plus lente que la solution standard.
Même avec cette solution, la résolution est encore limitée. Rendez near
encore plus petit ou far
plus grand et vous finirez par rencontrer les mêmes problèmes.
Cela signifie que vous devez toujours faire un effort pour choisir un paramètre near
et far
qui correspond à votre cas d'utilisation. Placez near
aussi loin que possible de la caméra et rien ne disparaîtra. Placez far
aussi près que possible de la caméra et, de même, tout restera visible. Si vous essayez de dessiner une scène géante et de montrer en gros plan un visage de façon à voir ses cils, tandis qu'en arrière-plan il possible de voir les montagnes à 50 kilomètres de distance, vous devrez en trouver d'autres solutions créatives, nous-y reviendrons peut-être plus tard. Pour l'instant, sachez que vous devez prendre soin de choisir des valeurs proches et éloignées appropriées à vos besoins.
La deuxième caméra la plus courante est l'OrthographicCamera
. Plutôt que de définir un frustum, il 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 notre exemple précédent pour utiliser une OrthographicCamera
dans la première vue.
D'abord, paramétrons notre 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;
Définissons left
and bottom
à -1 et right
et top
à 1. On devrait obtenir 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 sur lequel nous dessinons. Nous utiliserons la propriété zoom
pour faciliter le réglage du nombre d'unités réellement affichées par la caméra.
Ajoutons un nouveau paramètre à lil-gui pour le zoom
.
const gui = new GUI(); +gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();
L'appel à listen
dit à lil-gui de surveiller les changements. Il faut faire cela parce que OrbitControls
peut contrôler le zoom. Par exemple, la molette de défilement d'une souris zoomera via les OrbitControls
.
Enfin, nous avons juste besoin de changer la partie qui rend le côté gauche pour mettre à jour la 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 le camera helper dans la vue d'origine cameraHelper.visible = false; scene.background.set(0x000000); renderer.render(scene, camera); }
et maintenant vous pouvez voir une OrthographicCamera
au boulot.
Une OrthographicCamera
est souvent utilisée pour dessiner des objets en 2D. Il faut décider du nombre d'unités que la caméra doit afficher. Par exemple, si vous voulez qu'un pixel du canvas corresponde à une unité de l'appareil photo, vous pouvez faire quelque chose comme.
Pour mettre 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 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 tout comme un canvas 2D.
Essayons! Commençons par installer la caméra.
const left = 0; const right = 300; // taille par défaut const top = 0; const bottom = 150; // taille par défaut const near = -1; const far = 1; const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far); camera.zoom = 1;
Chargeons ensuite 6 textures et créons 6 plans, un pour chaque texture. Nous allons associer chaque plan à un THREE.Object3D
pour faciliter le décalage du plan afin que son centre semble être dans son coin supérieur gauche.
Pour travailler en local sur votre machine, vous aurez besoin d'une configuration spécifique. Vous voudrez peut-être en savoir plus 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 de la toile change.
function render() { if (resizeRendererToDisplaySize(renderer)) { camera.right = canvas.width; camera.bottom = canvas.height; camera.updateProjectionMatrix(); } ...
planes
est un tableau de THREE.Mesh
.
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 à parcourir 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; // définir une valeur entre 0 et une plage const xt = t % xRange; const yt = t % yRange; // définit notre position en avant si 0 à la moitié de la plage // et vers l'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 sur les bords de la toile en utilisant les mathématiques des pixels, tout comme une toile 2D.
Une autre utilisation courante d'une caméra orthographique est de dessiner les vues haut, bas, gauche, droite, avant et arrière d'un programme de modélisation 3D ou d'un éditeur de moteur de jeu.
Dans la capture d'écran ci-dessus, vous pouvez voir que 1 vue est une vue en perspective et 3 vues sont des vues orthogonales.
C'est la base des caméras. Nous aborderons quelques façons courantes de déplacer les caméras dans d'autres articles. Pour l'instant passons aux ombres.