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.
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.
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.
À 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 ?
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
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.
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.
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.