Une question courante est de savoir comment utiliser THREE.js avec plusieurs canvases.
Disons que vous voulez faire un site de commerce électronique ou que vous voulez créer
une page avec beaucoup de diagrammes 3D. À première vue, cela semble facile.
Faites simplement un canvas partout où vous voulez un diagramme. Pour chaque canvas,
créez un Renderer
.
Vous découvrirez rapidement, cependant, que vous rencontrez des problèmes.
Le navigateur limite le nombre de contextes WebGL que vous pouvez avoir.
Typiquement, cette limite est d'environ 8. Dès que vous créez le 9ème contexte, le plus ancien sera perdu.
Les ressources WebGL ne peuvent pas être partagées entre les contextes
Cela signifie que si vous voulez charger un modèle de 10 Mo dans 2 canvases et que ce modèle utilise 20 Mo de textures, votre modèle de 10 Mo devra être chargé deux fois et vos textures seront également chargées deux fois. Rien ne peut être partagé entre les contextes. Cela signifie également que les choses doivent être initialisées deux fois, les shaders compilés deux fois, etc. Cela empire à mesure qu'il y a plus de canvases.
Alors, quelle est la solution ?
La solution est un canvas qui remplit la zone d'affichage en arrière-plan et un autre élément pour représenter chaque canvas "virtuel". Nous créons un seul Renderer
, puis une Scene
pour chaque canvas virtuel. Nous vérifierons ensuite les positions des éléments de canvas virtuels et s'ils sont à l'écran, nous demanderons à THREE.js de dessiner leur scène à l'endroit correct.
Avec cette solution, il n'y a qu'un seul canvas, nous résolvons donc les problèmes 1 et 2 ci-dessus. Nous ne rencontrerons pas la limite de contextes WebGL car nous n'utiliserons qu'un seul contexte. Nous ne rencontrerons pas non plus les problèmes de partage pour les mêmes raisons.
Commençons par un exemple simple avec seulement 2 scènes. D'abord, nous allons créer le HTML
<canvas id="c"></canvas> <p> <span id="box" class="diagram left"></span> J'aime les boîtes. Les cadeaux viennent dans des boîtes. Quand je trouve une nouvelle boîte, je suis toujours impatient de découvrir ce qu'il y a dedans. </p> <p> <span id="pyramid" class="diagram right"></span> Quand j'étais enfant, je rêvais de partir en expédition à l'intérieur d'une pyramide et de trouver un tombeau inconnu rempli de momies et de trésors. </p>
Ensuite, nous pouvons configurer le CSS peut-être comme ceci
#c { position: fixed; left: 0; top: 0; width: 100%; height: 100%; display: block; z-index: -1; } .diagram { display: inline-block; width: 5em; height: 3em; border: 1px solid black; } .left { float: left; margin-right: .25em; } .right { float: right; margin-left: .25em; }
Nous configurons le canvas pour qu'il remplisse l'écran et nous définissons son z-index
à
-1 pour qu'il apparaisse derrière les autres éléments. Nous devons également spécifier une sorte de largeur et de hauteur
pour nos éléments de canvas virtuels puisqu'il n'y a rien à l'intérieur
pour leur donner une taille.
Maintenant, nous allons créer 2 scènes, chacune avec une lumière et une caméra. À une scène, nous ajouterons un cube et à l'autre une sphère.
function makeScene(elem) { const scene = new THREE.Scene(); const fov = 45; const aspect = 2; // the canvas default const near = 0.1; const far = 5; const camera = new THREE.PerspectiveCamera(fov, aspect, near, far); camera.position.z = 2; camera.position.set(0, 1, 2); camera.lookAt(0, 0, 0); { const color = 0xFFFFFF; const intensity = 1; const light = new THREE.DirectionalLight(color, intensity); light.position.set(-1, 2, 4); scene.add(light); } return {scene, camera, elem}; } function setupScene1() { const sceneInfo = makeScene(document.querySelector('#box')); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshPhongMaterial({color: 'red'}); const mesh = new THREE.Mesh(geometry, material); sceneInfo.scene.add(mesh); sceneInfo.mesh = mesh; return sceneInfo; } function setupScene2() { const sceneInfo = makeScene(document.querySelector('#pyramid')); const radius = .8; const widthSegments = 4; const heightSegments = 2; const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments); const material = new THREE.MeshPhongMaterial({ color: 'blue', flatShading: true, }); const mesh = new THREE.Mesh(geometry, material); sceneInfo.scene.add(mesh); sceneInfo.mesh = mesh; return sceneInfo; } const sceneInfo1 = setupScene1(); const sceneInfo2 = setupScene2();
Et ensuite, nous allons créer une fonction pour rendre chaque scène
uniquement si l'élément est à l'écran. Nous pouvons indiquer à THREE.js
de ne rendre qu'une partie du canvas en activant le test scissor
avec Renderer.setScissorTest
, puis en définissant à la fois le scissor et le viewport avec Renderer.setViewport
et Renderer.setScissor
.
function renderSceneInfo(sceneInfo) { const {scene, camera, elem} = sceneInfo; // obtenir la position relative à la zone d'affichage de cet élément const {left, right, top, bottom, width, height} = elem.getBoundingClientRect(); const isOffscreen = bottom < 0 || top > renderer.domElement.clientHeight || right < 0 || left > renderer.domElement.clientWidth; if (isOffscreen) { return; } camera.aspect = width / height; camera.updateProjectionMatrix(); const positiveYUpBottom = canvasRect.height - bottom; renderer.setScissor(left, positiveYUpBottom, width, height); renderer.setViewport(left, positiveYUpBottom, width, height); renderer.render(scene, camera); }
Et ensuite, notre fonction de rendu commencera par effacer l'écran, puis rendra chaque scène.
function render(time) { time *= 0.001; resizeRendererToDisplaySize(renderer); renderer.setScissorTest(false); renderer.clear(true, true); renderer.setScissorTest(true); sceneInfo1.mesh.rotation.y = time * .1; sceneInfo2.mesh.rotation.y = time * .1; renderSceneInfo(sceneInfo1); renderSceneInfo(sceneInfo2); requestAnimationFrame(render); }
Et voici le résultat
Vous pouvez voir où se trouve le premier <span>
, il y a un cube rouge, et où se trouve le deuxième span
, il y a une sphère bleue.
Le code ci-dessus fonctionne, mais il y a un petit problème. Si vos scènes sont compliquées ou si, pour une raison quelconque, le rendu prend trop de temps, la position des scènes dessinées dans le canvas sera en décalage par rapport au reste de la page.
Si nous donnons une bordure à chaque zone
.diagram { display: inline-block; width: 5em; height: 3em; + border: 1px solid black; }
Et nous définissons le fond de chaque scène
const scene = new THREE.Scene(); +scene.background = new THREE.Color('red');
Et si nous faisons défiler rapidement de haut en bas, nous verrons le problème. Voici une animation du défilement ralenti par 10.
Nous pouvons passer à une méthode différente qui présente un compromis différent. Nous allons changer le CSS du canvas de position: fixed
à position: absolute
.
#c { - position: fixed; + position: absolute;
Ensuite, nous définirons la transformation du canvas pour le déplacer afin que le haut du canvas soit au niveau du haut de la partie de la page actuellement défilée.
function render(time) { ... const transform = `translateY(${window.scrollY}px)`; renderer.domElement.style.transform = transform;
position: fixed
empêchait le canvas de défiler du tout
tandis que le reste de la page défilait par-dessus. position: absolute
permettra au canvas de défiler avec le reste de la page, ce qui signifie
que ce que nous dessinons restera avec la page pendant le défilement,
même si nous sommes trop lents à rendre. Lorsque nous aurons enfin l'occasion de rendre,
nous déplacerons le canvas pour qu'il corresponde à l'endroit où la page
a été défilée, puis nous referons le rendu. Cela signifie que seuls les bords
de la fenêtre montreront des morceaux non rendus pendant un instant, mais le contenu
au milieu de la page devrait correspondre et ne pas glisser. Voici une vue
des résultats de la nouvelle méthode ralentie par 10.
Maintenant que nous avons fait fonctionner plusieurs scènes, rendons cela un peu plus générique.
Nous pourrions faire en sorte que la fonction de rendu principale, celle qui gère le canvas, contienne simplement une liste d'éléments et leur fonction de rendu associée. Pour chaque élément, elle vérifierait si l'élément est à l'écran et, si oui, appellerait la fonction de rendu correspondante. De cette manière, nous aurions un système générique où les scènes individuelles ne sont pas vraiment conscientes d'être rendues dans un espace plus petit.
Voici la fonction de rendu principale
const sceneElements = []; function addScene(elem, fn) { sceneElements.push({elem, fn}); } function render(time) { time *= 0.001; resizeRendererToDisplaySize(renderer); renderer.setScissorTest(false); renderer.setClearColor(clearColor, 0); renderer.clear(true, true); renderer.setScissorTest(true); const transform = `translateY(${window.scrollY}px)`; renderer.domElement.style.transform = transform; for (const {elem, fn} of sceneElements) { // obtenir la position relative à la zone d'affichage de cet élément const rect = elem.getBoundingClientRect(); const {left, right, top, bottom, width, height} = rect; const isOffscreen = bottom < 0 || top > renderer.domElement.clientHeight || right < 0 || left > renderer.domElement.clientWidth; if (!isOffscreen) { const positiveYUpBottom = renderer.domElement.clientHeight - bottom; renderer.setScissor(left, positiveYUpBottom, width, height); renderer.setViewport(left, positiveYUpBottom, width, height); fn(time, rect); } } requestAnimationFrame(render); }
Vous pouvez voir qu'elle boucle sur sceneElements
, qui est censé être un tableau d'objets, chacun ayant une propriété elem
et fn
.
Elle vérifie si l'élément est à l'écran. Si c'est le cas, elle appelle fn
et lui passe l'heure actuelle et son rectangle.
Maintenant, le code de configuration pour chaque scène s'ajuste simplement pour s'ajouter à la liste des scènes
{ const elem = document.querySelector('#box'); const {scene, camera} = makeScene(); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshPhongMaterial({color: 'red'}); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); addScene(elem, (time, rect) => { camera.aspect = rect.width / rect.height; camera.updateProjectionMatrix(); mesh.rotation.y = time * .1; renderer.render(scene, camera); }); } { const elem = document.querySelector('#pyramid'); const {scene, camera} = makeScene(); const radius = .8; const widthSegments = 4; const heightSegments = 2; const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments); const material = new THREE.MeshPhongMaterial({ color: 'blue', flatShading: true, }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); addScene(elem, (time, rect) => { camera.aspect = rect.width / rect.height; camera.updateProjectionMatrix(); mesh.rotation.y = time * .1; renderer.render(scene, camera); }); }
Avec cela, nous n'avons plus besoin de sceneInfo1
et sceneInfo2
, et le code qui faisait pivoter les maillages est maintenant spécifique à chaque scène.
Une dernière chose encore plus générique que nous pouvons faire est d'utiliser l'attribut dataset HTML. C'est une façon d'ajouter vos propres données à un élément HTML. Au lieu d'utiliser id="..."
, nous utiliserons data-diagram="..."
comme ceci
<canvas id="c"></canvas> <p> - <span id="box" class="diagram left"></span> + <span data-diagram="box" class="left"></span> J'aime les boîtes. Les cadeaux viennent dans des boîtes. Quand je trouve une nouvelle boîte, je suis toujours impatient de découvrir ce qu'il y a dedans. </p> <p> - <span id="pyramid" class="diagram left"></span> + <span data-diagram="pyramid" class="right"></span> Quand j'étais enfant, je rêvais de partir en expédition à l'intérieur d'une pyramide et de trouver un tombeau inconnu rempli de momies et de trésors. </p>
Nous pouvons ensuite modifier le sélecteur CSS pour sélectionner cela
-.diagram +*[data-diagram] { display: inline-block; width: 5em; height: 3em; }
Nous allons modifier le code de configuration de la scène pour qu'il soit simplement une correspondance de noms avec des fonctions d'initialisation de scène qui renvoient une fonction de rendu de scène.
const sceneInitFunctionsByName = { 'box': () => { const {scene, camera} = makeScene(); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshPhongMaterial({color: 'red'}); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); return (time, rect) => { mesh.rotation.y = time * .1; camera.aspect = rect.width / rect.height; camera.updateProjectionMatrix(); renderer.render(scene, camera); }; }, 'pyramid': () => { const {scene, camera} = makeScene(); const radius = .8; const widthSegments = 4; const heightSegments = 2; const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments); const material = new THREE.MeshPhongMaterial({ color: 'blue', flatShading: true, }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); return (time, rect) => { mesh.rotation.y = time * .1; camera.aspect = rect.width / rect.height; camera.updateProjectionMatrix(); renderer.render(scene, camera); }; }, };
Et pour initialiser, nous pouvons simplement utiliser querySelectorAll
pour trouver tous les diagrammes et appeler la fonction d'initialisation correspondante pour ce diagramme.
document.querySelectorAll('[data-diagram]').forEach((elem) => { const sceneName = elem.dataset.diagram; const sceneInitFunction = sceneInitFunctionsByName[sceneName]; const sceneRenderFunction = sceneInitFunction(elem); addScene(elem, sceneRenderFunction); });
Pas de changement visuel, mais le code est encore plus générique.
Ajouter de l'interactivité, par exemple un TrackballControls
, est tout aussi simple. Nous ajoutons d'abord le script pour le contrôle.
import {TrackballControls} from 'three/addons/controls/TrackballControls.js';
Et ensuite, nous pouvons ajouter un TrackballControls
à chaque scène en passant l'élément associé à cette scène.
-function makeScene() { +function makeScene(elem) { const scene = new THREE.Scene(); const fov = 45; const aspect = 2; // the canvas default const near = 0.1; const far = 5; const camera = new THREE.PerspectiveCamera(fov, aspect, near, far); camera.position.set(0, 1, 2); camera.lookAt(0, 0, 0); + scene.add(camera); + const controls = new TrackballControls(camera, elem); + controls.noZoom = true; + controls.noPan = true; { const color = 0xFFFFFF; const intensity = 1; const light = new THREE.DirectionalLight(color, intensity); light.position.set(-1, 2, 4); - scene.add(light); + camera.add(light); } - return {scene, camera}; + return {scene, camera, controls}; }
Vous remarquerez que nous avons ajouté la caméra à la scène et la lumière à la caméra.
Cela rend la lumière relative à la caméra. Comme les TrackballControls
déplacent la caméra, c'est probablement ce que nous voulons.
Cela permet de maintenir la lumière éclairant le côté de l'objet que nous regardons.
Nous devons mettre à jour ces contrôles dans nos fonctions de rendu
const sceneInitFunctionsByName = { - 'box': () => { - const {scene, camera} = makeScene(); + 'box': (elem) => { + const {scene, camera, controls} = makeScene(elem); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshPhongMaterial({color: 'red'}); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); return (time, rect) => { mesh.rotation.y = time * .1; camera.aspect = rect.width / rect.height; camera.updateProjectionMatrix(); + controls.handleResize(); + controls.update(); renderer.render(scene, camera); }; }, - 'pyramid': () => { - const {scene, camera} = makeScene(); + 'pyramid': (elem) => { + const {scene, camera, controls} = makeScene(elem); const radius = .8; const widthSegments = 4; const heightSegments = 2; const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments); const material = new THREE.MeshPhongMaterial({ color: 'blue', flatShading: true, }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); return (time, rect) => { mesh.rotation.y = time * .1; camera.aspect = rect.width / rect.height; camera.updateProjectionMatrix(); + controls.handleResize(); + controls.update(); renderer.render(scene, camera); }; }, };
Et maintenant, si vous faites glisser les objets, ils pivoteront.
Ces techniques sont utilisées sur ce site même. En particulier, l'article sur les primitives et l'article sur les matériaux utilisent cette technique pour ajouter les différents exemples tout au long de l'article.
Une autre solution consisterait à rendre sur un canvas hors écran et à copier le résultat sur un canvas 2D à chaque élément. L'avantage de cette solution est qu'il n'y a aucune limite à la manière dont vous pouvez composer chaque zone séparée. Avec la solution précédente, nous avions un seul canvas en arrière-plan. Avec cette solution, nous avons des éléments HTML normaux.
L'inconvénient est que c'est plus lent car une copie doit avoir lieu pour chaque zone. La lenteur dépend du navigateur et du GPU.
Les modifications nécessaires sont assez minimes
D'abord, nous allons modifier le HTML car nous n'avons plus besoin d'un canvas sur la page
<body> - <canvas id="c"></canvas> ... </body>
puis nous allons modifier le CSS
-#c { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - display: block; - z-index: -1; -} canvas { width: 100%; height: 100%; display: block; } *[data-diagram] { display: inline-block; width: 5em; height: 3em; }
Nous avons fait en sorte que tous les canvases remplissent leur conteneur.
Maintenant, changeons le JavaScript. D'abord, nous ne recherchons plus le canvas. Au lieu de cela, nous en créons un. Nous activons également simplement le test scissor au début.
function main() { - const canvas = document.querySelector('#c'); + const canvas = document.createElement('canvas'); const renderer = new THREE.WebGLRenderer({antialias: true, canvas, alpha: true}); + renderer.setScissorTest(true); ...
Ensuite, pour chaque scène, nous créons un contexte de rendu 2D et ajoutons son canvas à l'élément pour cette scène
const sceneElements = []; function addScene(elem, fn) { + // ajouter un canvas à l'élément + const ctx = document.createElement('canvas').getContext('2d'); + elem.appendChild(ctx.canvas); - sceneElements.push({elem, fn}); + sceneElements.push({elem, ctx, fn}); }
Ensuite, lors du rendu, si le canvas du renderer n'est pas assez grand pour rendre cette zone, nous augmentons sa taille. De même, si le canvas de cette zone n'a pas la bonne taille, nous changeons sa taille. Enfin, nous définissons le scissor et le viewport, rendons la scène pour cette zone, puis copions le résultat sur le canvas de la zone.
function render(time) { time *= 0.001; - resizeRendererToDisplaySize(renderer); - - renderer.setScissorTest(false); - renderer.setClearColor(clearColor, 0); - renderer.clear(true, true); - renderer.setScissorTest(true); - - const transform = `translateY(${window.scrollY}px)`; - renderer.domElement.style.transform = transform; - for (const {elem, fn} of sceneElements) { + for (const {elem, fn, ctx} of sceneElements) { // obtenir la position relative à la zone d'affichage de cet élément const rect = elem.getBoundingClientRect(); const {left, right, top, bottom, width, height} = rect; + const rendererCanvas = renderer.domElement; const isOffscreen = bottom < 0 || - top > renderer.domElement.clientHeight || + top > window.innerHeight || right < 0 || - left > renderer.domElement.clientWidth; + left > window.innerWidth; if (!isOffscreen) { - const positiveYUpBottom = renderer.domElement.clientHeight - bottom; - renderer.setScissor(left, positiveYUpBottom, width, height); - renderer.setViewport(left, positiveYUpBottom, width, height); + // s'assurer que le canvas du renderer est assez grand + if (rendererCanvas.width < width || rendererCanvas.height < height) { + renderer.setSize(width, height, false); + } + + // s'assurer que le canvas pour cette zone est de la même taille que la zone + if (ctx.canvas.width !== width || ctx.canvas.height !== height) { + ctx.canvas.width = width; + ctx.canvas.height = height; + } + + renderer.setScissor(0, 0, width, height); + renderer.setViewport(0, 0, width, height); fn(time, rect); + // copier la scène rendue sur le canvas de cet élément + ctx.globalCompositeOperation = 'copy'; + ctx.drawImage( + rendererCanvas, + 0, rendererCanvas.height - height, width, height, // src rect + 0, 0, width, height); // dst rect } } requestAnimationFrame(render); }
Le résultat est identique
Un autre avantage de cette solution est que vous pourriez potentiellement utiliser
OffscreenCanvas
pour rendre depuis un web worker et toujours utiliser cette technique. Malheureusement, en juillet 2020,
OffscreenCanvas
n'est pris en charge que par Chrome.