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à.
Il existe de nombreuses façons d'optimiser les choses pour three.js. Une méthode est souvent appelée
fusion de géométrie. Chaque Mesh
que vous créez et que three.js représente est 1 ou
plusieurs requêtes du système pour dessiner quelque chose. Dessiner 2 choses a plus
de surcoût que d'en dessiner 1, même si les résultats sont les mêmes, donc une façon d'optimiser
est de fusionner les maillages (meshes).
Voyons un exemple où cela est une bonne solution pour un problème. Recréons le Globe WebGL.
La première chose à faire est d'obtenir des données. Le Globe WebGL a dit que les données qu'ils utilisent proviennent de SEDAC. En consultant le site, j'ai vu qu'il y avait des données démographiques au format grille. J'ai téléchargé les données avec une résolution de 60 minutes. Ensuite, j'ai examiné les données
Cela ressemble à ceci
ncols 360 nrows 145 xllcorner -180 yllcorner -60 cellsize 0.99999999999994 NODATA_value -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ... 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999... 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999... -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
Il y a quelques lignes qui sont comme des paires clé/valeur suivies de lignes avec une valeur par point de grille, une ligne pour chaque rangée de points de données.
Pour nous assurer que nous comprenons les données, essayons de les tracer en 2D.
D'abord un peu de code pour charger le fichier texte
async function loadFile(url) { const res = await fetch(url); return res.text(); }
Le code ci-dessus renvoie une Promise
avec le contenu du fichier à l'url
;
Ensuite, nous avons besoin de code pour analyser le fichier
function parseData(text) { const data = []; const settings = {data}; let max; let min; // split into lines text.split('\n').forEach((line) => { // split the line by whitespace const parts = line.trim().split(/\s+/); if (parts.length === 2) { // only 2 parts, must be a key/value pair settings[parts[0]] = parseFloat(parts[1]); } else if (parts.length > 2) { // more than 2 parts, must be data const values = parts.map((v) => { const value = parseFloat(v); if (value === settings.NODATA_value) { return undefined; } max = Math.max(max === undefined ? value : max, value); min = Math.min(min === undefined ? value : min, value); return value; }); data.push(values); } }); return Object.assign(settings, {min, max}); }
Le code ci-dessus renvoie un objet avec toutes les paires clé/valeur du fichier ainsi
qu'une propriété data
contenant toutes les données dans un grand tableau et les valeurs min
et
max
trouvées dans les données.
Ensuite, nous avons besoin de code pour dessiner ces données
function drawData(file) { const {min, max, data} = file; const range = max - min; const ctx = document.querySelector('canvas').getContext('2d'); // make the canvas the same size as the data ctx.canvas.width = ncols; ctx.canvas.height = nrows; // but display it double size so it's not too small ctx.canvas.style.width = px(ncols * 2); ctx.canvas.style.height = px(nrows * 2); // fill the canvas to dark gray ctx.fillStyle = '#444'; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); // draw each data point data.forEach((row, latNdx) => { row.forEach((value, lonNdx) => { if (value === undefined) { return; } const amount = (value - min) / range; const hue = 1; const saturation = 1; const lightness = amount; ctx.fillStyle = hsl(hue, saturation, lightness); ctx.fillRect(lonNdx, latNdx, 1, 1); }); }); } function px(v) { return `${v | 0}px`; } function hsl(h, s, l) { return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`; }
Et enfin, en liant le tout
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc') .then(parseData) .then(drawData);
Nous donne ce résultat
Donc, cela semble fonctionner.
Essayons-le en 3D. En partant du code de rendu à la demande, nous allons créer une boîte par donnée dans le fichier.
Commençons par créer une simple sphère avec une texture du monde. Voici la texture
Et le code pour le mettre en place.
{ const loader = new THREE.TextureLoader(); const texture = loader.load('resources/images/world.jpg', render); const geometry = new THREE.SphereGeometry(1, 64, 32); const material = new THREE.MeshBasicMaterial({map: texture}); scene.add(new THREE.Mesh(geometry, material)); }
Notez l'appel à render
lorsque la texture a fini de charger. Nous en avons besoin
car nous faisons du rendu à la demande au lieu de le faire en
continu, nous devons donc rendre la scène une fois que la texture est chargée.
Ensuite, nous devons modifier le code qui dessinait un point par point de donnée ci-dessus pour créer une boîte par point de donnée à la place.
function addBoxes(file) { const {min, max, data} = file; const range = max - min; // make one box geometry const boxWidth = 1; const boxHeight = 1; const boxDepth = 1; const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth); // make it so it scales away from the positive Z axis geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5)); // these helpers will make it easy to position the boxes // We can rotate the lon helper on its Y axis to the longitude const lonHelper = new THREE.Object3D(); scene.add(lonHelper); // We rotate the latHelper on its X axis to the latitude const latHelper = new THREE.Object3D(); lonHelper.add(latHelper); // The position helper moves the object to the edge of the sphere const positionHelper = new THREE.Object3D(); positionHelper.position.z = 1; latHelper.add(positionHelper); const lonFudge = Math.PI * .5; const latFudge = Math.PI * -0.135; data.forEach((row, latNdx) => { row.forEach((value, lonNdx) => { if (value === undefined) { return; } const amount = (value - min) / range; const material = new THREE.MeshBasicMaterial(); const hue = THREE.MathUtils.lerp(0.7, 0.3, amount); const saturation = 1; const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount); material.color.setHSL(hue, saturation, lightness); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); // adjust the helpers to point to the latitude and longitude lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge; latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge; // use the world matrix of the position helper to // position this mesh. positionHelper.updateWorldMatrix(true, false); mesh.applyMatrix4(positionHelper.matrixWorld); mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount)); }); }); }
Le code est principalement direct par rapport à notre code de dessin de test.
Nous créons une boîte et ajustons son centre de manière à ce qu'elle s'éloigne de l'axe Z positif. Si nous ne faisions pas cela, elle s'agrandirait à partir du centre, mais nous voulons qu'elles s'éloignent de l'origine.
Bien sûr, nous pourrions aussi résoudre cela en faisant de la boîte un enfant d'autres objets THREE.Object3D
comme nous l'avons vu dans les graphes de scène, mais plus nous ajoutons de
nœuds à un graphe de scène, plus cela devient lent.
Nous avons également mis en place cette petite hiérarchie de nœuds : lonHelper
, latHelper
, et
positionHelper
. Nous utilisons ces objets pour calculer une position autour de la sphère
où placer la boîte.
Ci-dessus, la barre verte représente lonHelper
et
est utilisée pour pivoter vers la longitude sur l'équateur. La
barre bleue représente latHelper
qui est utilisée pour pivoter vers une
latitude au-dessus ou en dessous de l'équateur. La sphère
rouge représente le décalage que fournit positionHelper
.
Nous pourrions faire tous les calculs manuellement pour déterminer les positions sur le globe, mais faire les choses de cette manière laisse la majeure partie des calculs à la librairie elle-même, de sorte que nous n'avons pas à nous en occuper.
Pour chaque point de donnée, nous créons un MeshBasicMaterial
et un Mesh
et
puis nous demandons la matrice monde du positionHelper
et l'appliquons au nouveau Mesh
.
Enfin, nous mettons à l'échelle le maillage à sa nouvelle position.
Comme ci-dessus, nous aurions pu également créer un latHelper
, un lonHelper
et un
positionHelper
pour chaque nouvelle boîte, mais cela aurait été encore plus lent.
Il y a jusqu'à 360x145 boîtes que nous allons créer. Cela représente jusqu'à 52000 boîtes. Comme certains points de données sont marqués comme "NO_DATA", le nombre réel de boîtes que nous allons créer est d'environ 19000. Si nous ajoutions 3 objets d'aide supplémentaires par boîte, cela représenterait près de 80000 nœuds dans le graphe de scène pour lesquels THREE.js devrait calculer les positions. En utilisant plutôt un seul ensemble d'aides pour positionner simplement les maillages, nous économisons environ 60000 opérations.
Une note sur lonFudge
et latFudge
. lonFudge
est π/2, ce qui correspond à un quart de tour.
Cela a du sens. Cela signifie simplement que la texture ou les coordonnées de texture commencent à un
décalage différent autour du globe. latFudge
, d'un autre côté, je n'ai aucune idée
pourquoi il doit être π * -0.135, c'est juste un montant qui a permis aux boîtes de s'aligner
avec la texture.
La dernière chose à faire est d'appeler notre chargeur
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc') .then(parseData) - .then(drawData) + .then(addBoxes) + .then(render);
Une fois que les données ont fini de charger et d'être analysées, nous devons rendre la scène au moins une fois puisque nous faisons du rendu à la demande.
Si vous essayez de faire pivoter l'exemple ci-dessus en faisant glisser la souris sur l'échantillon, vous remarquerez probablement que c'est lent.
Nous pouvons vérifier la fréquence d'images en ouvrant les outils de développement et en activant l'indicateur de fréquence d'images du navigateur.
Sur ma machine, je vois une fréquence d'images inférieure à 20 ips.
Cela ne me semble pas très fluide et je suspecte que beaucoup de gens ont des machines plus lentes, ce qui rendrait la situation encore pire. Nous ferions mieux d'étudier l'optimisation.
Pour ce problème particulier, nous pouvons fusionner toutes les boîtes en une seule géométrie. Nous dessinons actuellement environ 19000 boîtes. En les fusionnant en une seule géométrie, nous supprimerions 18999 opérations.
Voici le nouveau code pour fusionner les boîtes en une seule géométrie.
function addBoxes(file) { const {min, max, data} = file; const range = max - min; - // make one box geometry - const boxWidth = 1; - const boxHeight = 1; - const boxDepth = 1; - const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth); - // make it so it scales away from the positive Z axis - geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5)); // these helpers will make it easy to position the boxes // We can rotate the lon helper on its Y axis to the longitude const lonHelper = new THREE.Object3D(); scene.add(lonHelper); // We rotate the latHelper on its X axis to the latitude const latHelper = new THREE.Object3D(); lonHelper.add(latHelper); // The position helper moves the object to the edge of the sphere const positionHelper = new THREE.Object3D(); positionHelper.position.z = 1; latHelper.add(positionHelper); + // Utilisé pour déplacer le centre de la boîte afin qu'elle s'agrandisse à partir de l'axe Z positif + const originHelper = new THREE.Object3D(); + originHelper.position.z = 0.5; + positionHelper.add(originHelper); const lonFudge = Math.PI * .5; const latFudge = Math.PI * -0.135; + const geometries = []; data.forEach((row, latNdx) => { row.forEach((value, lonNdx) => { if (value === undefined) { return; } const amount = (value - min) / range; - const material = new THREE.MeshBasicMaterial(); - const hue = THREE.MathUtils.lerp(0.7, 0.3, amount); - const saturation = 1; - const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount); - material.color.setHSL(hue, saturation, lightness); - const mesh = new THREE.Mesh(geometry, material); - scene.add(mesh); + const boxWidth = 1; + const boxHeight = 1; + const boxDepth = 1; + const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth); // adjust the helpers to point to the latitude and longitude lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge; latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge; - // use the world matrix of the position helper to - // position this mesh. - positionHelper.updateWorldMatrix(true, false); - mesh.applyMatrix4(positionHelper.matrixWorld); - - mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount)); + // use the world matrix of the origin helper to + // position this geometry + positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount)); + originHelper.updateWorldMatrix(true, false); + geometry.applyMatrix4(originHelper.matrixWorld); + + geometries.push(geometry); }); }); + const mergedGeometry = BufferGeometryUtils.mergeGeometries( + geometries, false); + const material = new THREE.MeshBasicMaterial({color:'red'}); + const mesh = new THREE.Mesh(mergedGeometry, material); + scene.add(mesh); }
Ci-dessus, nous avons supprimé le code qui modifiait le point central de la géométrie de la boîte
et le faisons à la place en ajoutant un originHelper
. Auparavant, nous utilisions la même
géométrie 19000 fois. Cette fois, nous créons une nouvelle géométrie pour chaque boîte et
comme nous allons utiliser applyMatrix
pour déplacer les sommets de chaque géométrie de boîte,
autant le faire une fois au lieu de deux.
À la fin, nous passons un tableau de toutes les géométries à
BufferGeometryUtils.mergeGeometries
, ce qui les combinera toutes
en un seul maillage.
Nous devons également inclure le BufferGeometryUtils
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
Et maintenant, du moins sur ma machine, j'obtiens 60 images par seconde
Cela a donc fonctionné, mais comme il s'agit d'un seul maillage, nous n'obtenons qu'un seul matériau, ce qui signifie que nous n'avons qu'une seule couleur, alors qu'avant, nous avions une couleur différente sur chaque boîte. Nous pouvons y remédier en utilisant les couleurs de sommet.
Les couleurs de sommet ajoutent une couleur par sommet. En réglant toutes les couleurs de chaque sommet de chaque boîte sur des couleurs spécifiques, chaque boîte aura une couleur différente.
+const color = new THREE.Color(); const lonFudge = Math.PI * .5; const latFudge = Math.PI * -0.135; const geometries = []; data.forEach((row, latNdx) => { row.forEach((value, lonNdx) => { if (value === undefined) { return; } const amount = (value - min) / range; const boxWidth = 1; const boxHeight = 1; const boxDepth = 1; const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth); // adjust the helpers to point to the latitude and longitude lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge; latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge; // use the world matrix of the origin helper to // position this geometry positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount)); originHelper.updateWorldMatrix(true, false); geometry.applyMatrix4(originHelper.matrixWorld); + // calculer une couleur + const hue = THREE.MathUtils.lerp(0.7, 0.3, amount); + const saturation = 1; + const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount); + color.setHSL(hue, saturation, lightness); + // obtenir les couleurs sous forme de tableau de valeurs de 0 à 255 + const rgb = color.toArray().map(v => v * 255); + + // créer un tableau pour stocker les couleurs pour chaque sommet + const numVerts = geometry.getAttribute('position').count; + const itemSize = 3; // r, g, b + const colors = new Uint8Array(itemSize * numVerts); + + // copier la couleur dans le tableau de couleurs pour chaque sommet + colors.forEach((v, ndx) => { + colors[ndx] = rgb[ndx % 3]; + }); + + const normalized = true; + const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized); + geometry.setAttribute('color', colorAttrib); geometries.push(geometry); }); });
Le code ci-dessus recherche le nombre ou les sommets nécessaires en obtenant l'attribut position
de la géométrie. Nous créons ensuite un Uint8Array
pour y mettre les couleurs.
Il ajoute ensuite cela comme un attribut en appelant geometry.setAttribute
.
Enfin, nous devons dire à three.js d'utiliser les couleurs de sommet.
const mergedGeometry = BufferGeometryUtils.mergeGeometries( geometries, false); -const material = new THREE.MeshBasicMaterial({color:'red'}); +const material = new THREE.MeshBasicMaterial({ + vertexColors: true, +}); const mesh = new THREE.Mesh(mergedGeometry, material); scene.add(mesh);
Et avec cela, nous retrouvons nos couleurs
La fusion de géométrie est une technique d'optimisation courante. Par exemple, au lieu de 100 arbres, vous pourriez fusionner les arbres en 1 seule géométrie, un tas de roches individuelles en une seule géométrie de roches, une clôture de piquets individuels en un seul maillage de clôture. Un autre exemple dans Minecraft, il ne dessine probablement pas chaque cube individuellement, mais crée plutôt des groupes de cubes fusionnés et supprime également sélectivement les faces qui ne sont jamais visibles.
Le problème avec le fait de tout transformer en un seul maillage est qu'il n'est plus facile de déplacer une partie qui était auparavant séparée. Cependant, selon notre cas d'utilisation, il existe des solutions créatives. Nous en explorerons une dans un autre article.