Cet article fait partie d'une série d'articles sur three.js. Le premier article est les fondamentaux de three.js. Si vous ne l'avez pas encore lu et que vous débutez avec three.js, vous pourriez envisager de commencer par là. L'article précédent portait sur les caméras, ce qui est important à lire avant de lire cet article, tout comme l'article précédent sur les lumières.
Les ombres sur ordinateur peuvent être un sujet complexe. Il existe diverses solutions et toutes impliquent des compromis, y compris les solutions disponibles dans three.js.
Three.js utilise par défaut des cartes d'ombres. Le fonctionnement d'une carte d'ombres est le suivant : pour chaque lumière qui projette des ombres, tous les objets marqués pour projeter des ombres sont rendus du point de vue de la lumière. **LISEZ CELA À NOUVEAU !** et laissez-le s'imprégner.
En d'autres termes, si vous avez 20 objets et 5 lumières, et que les 20 objets projettent des ombres et les 5 lumières projettent des ombres, alors toute votre scène sera dessinée 6 fois. Les 20 objets seront dessinés pour la lumière n°1, puis les 20 objets seront dessinés pour la lumière n°2, puis n°3, etc., et enfin la scène réelle sera dessinée en utilisant les données des 5 premiers rendus.
Pire encore, si vous avez une lumière ponctuelle (point light) qui projette des ombres, la scène doit être dessinée 6 fois juste pour cette lumière !
Pour ces raisons, il est courant de trouver d'autres solutions plutôt que d'avoir un tas de lumières générant toutes des ombres. Une solution courante consiste à avoir plusieurs lumières mais seulement une lumière directionnelle (directional light) générant des ombres.
Une autre solution consiste à utiliser des lightmaps (cartes d'éclairage) et/ou des ambient occlusion maps (cartes d'occlusion ambiante) pour pré-calculer les effets d'éclairage hors ligne. Cela se traduit par un éclairage statique ou des indices d'éclairage statique, mais au moins c'est rapide. Nous aborderons ces deux points dans un autre article.
Une autre solution consiste à utiliser de fausses ombres. Créez un plan, placez une texture en niveaux de gris sur le plan qui approxime une ombre, dessinez-le au-dessus du sol, sous votre objet.
Par exemple, utilisons cette texture comme fausse ombre
Nous utiliserons une partie du code de l'article précédent.
Définissons la couleur de fond sur blanc.
const scene = new THREE.Scene(); +scene.background = new THREE.Color('white');
Ensuite, nous allons configurer le même sol en damier, mais cette fois-ci en utilisant
un MeshBasicMaterial
car nous n'avons pas besoin d'éclairage pour le sol.
+const loader = new THREE.TextureLoader(); { const planeSize = 40; - const loader = new THREE.TextureLoader(); const texture = loader.load('resources/images/checker.png'); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.magFilter = THREE.NearestFilter; const repeats = planeSize / 2; texture.repeat.set(repeats, repeats); const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize); const planeMat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, }); + planeMat.color.setRGB(1.5, 1.5, 1.5); const mesh = new THREE.Mesh(planeGeo, planeMat); mesh.rotation.x = Math.PI * -.5; scene.add(mesh); }
Notez que nous définissons la couleur sur 1.5, 1.5, 1.5
. Cela multipliera les couleurs
de la texture en damier par 1.5, 1.5, 1.5. Étant donné que les couleurs de la texture sont 0x808080 et 0xC0C0C0,
ce qui correspond à un gris moyen et un gris clair, les multiplier par 1.5 nous donnera un damier
blanc et gris clair.
Chargeons la texture d'ombre
const shadowTexture = loader.load('resources/images/roundshadow.png');
et créons un tableau pour mémoriser chaque sphère et les objets associés.
const sphereShadowBases = [];
Ensuite, nous allons créer une géométrie de sphère
const sphereRadius = 1; const sphereWidthDivisions = 32; const sphereHeightDivisions = 16; const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
Et une géométrie de plan pour la fausse ombre
const planeSize = 1; const shadowGeo = new THREE.PlaneGeometry(planeSize, planeSize);
Maintenant, nous allons créer un tas de sphères. Pour chaque sphère, nous créerons une base
THREE.Object3D
et nous ferons du maillage du plan d'ombre et du maillage de la sphère des enfants de la base.
De cette façon, si nous déplaçons la base, la sphère et l'ombre se déplaceront. Nous devons placer l'ombre légèrement au-dessus du sol pour éviter le z-fighting.
Nous définissons également depthWrite
à false afin que les ombres ne s'entremêlent pas.
Nous aborderons ces deux problèmes dans un autre article.
L'ombre est un MeshBasicMaterial
car elle n'a pas besoin d'éclairage.
Nous donnons à chaque sphère une teinte différente, puis nous enregistrons la base, le maillage de la sphère, le maillage de l'ombre et la position y initiale de chaque sphère.
const numSpheres = 15; for (let i = 0; i < numSpheres; ++i) { // créer une base pour l'ombre et la sphère // afin qu'elles se déplacent ensemble. const base = new THREE.Object3D(); scene.add(base); // ajouter l'ombre à la base // note : nous créons un nouveau matériau pour chaque sphère // afin de pouvoir définir la transparence du matériau de cette sphère // séparément. const shadowMat = new THREE.MeshBasicMaterial({ map: shadowTexture, transparent: true, // pour que nous puissions voir le sol depthWrite: false, // pour ne pas avoir à trier }); const shadowMesh = new THREE.Mesh(shadowGeo, shadowMat); shadowMesh.position.y = 0.001; // pour être légèrement au-dessus du sol shadowMesh.rotation.x = Math.PI * -.5; const shadowSize = sphereRadius * 4; shadowMesh.scale.set(shadowSize, shadowSize, shadowSize); base.add(shadowMesh); // ajouter la sphère à la base const u = i / numSpheres; // va de 0 à 1 au fur et à mesure que nous parcourons les sphères. const sphereMat = new THREE.MeshPhongMaterial(); sphereMat.color.setHSL(u, 1, .75); const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat); sphereMesh.position.set(0, sphereRadius + 2, 0); base.add(sphereMesh); // mémoriser les 3 plus la position y sphereShadowBases.push({base, sphereMesh, shadowMesh, y: sphereMesh.position.y}); }
Nous configurons 2 lumières. L'une est une HemisphereLight
avec l'intensité définie à 2 pour vraiment
éclaircir les choses.
{ const skyColor = 0xB1E1FF; // bleu clair const groundColor = 0xB97A20; // orange brunâtre const intensity = 2; const light = new THREE.HemisphereLight(skyColor, groundColor, intensity); scene.add(light); }
L'autre est une DirectionalLight
afin que les sphères obtiennent une certaine définition
{ const color = 0xFFFFFF; const intensity = 1; const light = new THREE.DirectionalLight(color, intensity); light.position.set(0, 10, 5); light.target.position.set(-5, 0, 0); scene.add(light); scene.add(light.target); }
Cela rendrait tel quel, mais animons ces sphères.
Pour chaque ensemble sphère, ombre, base, nous déplaçons la base dans le plan xz, nous
déplaçons la sphère de haut en bas en utilisant Math.abs(Math.sin(time))
ce qui nous donne une animation rebondissante. Et nous définissons également l'opacité du matériau de l'ombre
afin que, à mesure que chaque sphère monte, son ombre s'estompe.
function render(time) { time *= 0.001; // convertir en secondes ... sphereShadowBases.forEach((sphereShadowBase, ndx) => { const {base, sphereMesh, shadowMesh, y} = sphereShadowBase; // u est une valeur qui va de 0 à 1 au fur et à mesure que nous parcourons les sphères const u = ndx / sphereShadowBases.length; // calculer une position pour la base. Cela déplacera // à la fois la sphère et son ombre const speed = time * .2; const angle = speed + u * Math.PI * 2 * (ndx % 1 ? 1 : -1); const radius = Math.sin(speed - ndx) * 10; base.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); // yOff est une valeur qui va de 0 à 1 const yOff = Math.abs(Math.sin(time * 2 + ndx)); // déplacer la sphère de haut en bas sphereMesh.position.y = y + THREE.MathUtils.lerp(-2, 2, yOff); // estomper l'ombre à mesure que la sphère monte shadowMesh.material.opacity = THREE.MathUtils.lerp(1, .25, yOff); }); ...
Et voici 15 sortes de balles rebondissantes.
Dans certaines applications, il est courant d'utiliser une ombre ronde ou ovale pour tout, mais bien sûr, vous pourriez aussi utiliser des textures d'ombre de formes différentes. Vous pourriez également donner à l'ombre un bord plus net. Un bon exemple de l'utilisation de ce type d'ombre est Animal Crossing Pocket Camp où vous pouvez voir que chaque personnage a une simple ombre ronde. C'est efficace et peu coûteux. Monument Valley semble également utiliser ce type d'ombre pour le personnage principal.
Passons maintenant aux cartes d'ombres. Il existe 3 types de lumières qui peuvent projeter des ombres : la DirectionalLight
,
la PointLight
et la SpotLight
.
Commençons par la DirectionalLight
en utilisant l'exemple avec helper de l'article sur les lumières.
La première chose à faire est d'activer les ombres dans le rendu (renderer).
const renderer = new THREE.WebGLRenderer({antialias: true, canvas}); +renderer.shadowMap.enabled = true;
Ensuite, nous devons également dire à la lumière de projeter une ombre
const light = new THREE.DirectionalLight(color, intensity); +light.castShadow = true;
Nous devons également parcourir chaque maillage dans la scène et décider s'il doit à la fois projeter des ombres et/ou recevoir des ombres.
Faisons en sorte que le plan (le sol) reçoive uniquement les ombres, car nous ne nous soucions pas vraiment de ce qui se passe en dessous.
const mesh = new THREE.Mesh(planeGeo, planeMat); mesh.receiveShadow = true;
Pour le cube et la sphère, faisons en sorte qu'ils reçoivent et projettent tous deux des ombres
const mesh = new THREE.Mesh(cubeGeo, cubeMat); mesh.castShadow = true; mesh.receiveShadow = true; ... const mesh = new THREE.Mesh(sphereGeo, sphereMat); mesh.castShadow = true; mesh.receiveShadow = true;
Et ensuite, nous l'exécutons.
Que s'est-il passé ? Pourquoi des parties des ombres sont-elles manquantes ?
La raison est que les cartes d'ombres sont créées en rendant la scène du point
de vue de la lumière. Dans ce cas, il y a une caméra au niveau de la DirectionalLight
qui regarde sa cible. Tout comme les caméras que nous avons précédemment couvertes,
la caméra d'ombre de la lumière définit une zone à l'intérieur de laquelle
les ombres sont rendues. Dans l'exemple ci-dessus, cette zone est trop petite.
Afin de visualiser cette zone, nous pouvons obtenir la caméra d'ombre de la lumière et ajouter
un CameraHelper
à la scène.
const cameraHelper = new THREE.CameraHelper(light.shadow.camera); scene.add(cameraHelper);
Et maintenant, vous pouvez voir la zone pour laquelle les ombres sont projetées et reçues.
Ajustez la valeur x de la cible d'avant en arrière et il devrait être assez clair que seules les ombres sont dessinées dans la zone de la caméra d'ombre de la lumière.
Nous pouvons ajuster la taille de cette boîte en ajustant la caméra d'ombre de la lumière.
Ajoutons des paramètres d'interface graphique pour ajuster la boîte de la caméra d'ombre de la lumière. Comme une
DirectionalLight
représente une lumière allant dans une direction parallèle, la
DirectionalLight
utilise une OrthographicCamera
pour sa caméra d'ombre.
Nous avons vu comment fonctionne une OrthographicCamera
dans l'article précédent sur les caméras.
Rappelons qu'une OrthographicCamera
définit
sa boîte ou son frustum de visualisation par ses propriétés left
, right
, top
, bottom
, near
, far
,
et zoom
.
À nouveau, créons une classe d'aide pour lil-gui. Nous créerons une DimensionGUIHelper
à laquelle nous passerons un objet et 2 propriétés. Elle présentera une propriété que lil-gui
peut ajuster et, en réponse, définira les deux propriétés, l'une positive et l'autre négative.
Nous pouvons l'utiliser pour définir left
et right
comme width
, et up
et down
comme height
.
class DimensionGUIHelper { constructor(obj, minProp, maxProp) { this.obj = obj; this.minProp = minProp; this.maxProp = maxProp; } get value() { return this.obj[this.maxProp] * 2; } set value(v) { this.obj[this.maxProp] = v / 2; this.obj[this.minProp] = v / -2; } }
Nous utiliserons également la MinMaxGUIHelper
que nous avons créée dans l'article sur les caméras
pour ajuster near
et far
.
const gui = new GUI(); gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color'); gui.add(light, 'intensity', 0, 2, 0.01); +{ + const folder = gui.addFolder('Caméra d\'ombre'); + folder.open(); + folder.add(new DimensionGUIHelper(light.shadow.camera, 'left', 'right'), 'value', 1, 100) + .name('largeur') + .onChange(updateCamera); + folder.add(new DimensionGUIHelper(light.shadow.camera, 'bottom', 'top'), 'value', 1, 100) + .name('hauteur') + .onChange(updateCamera); + const minMaxGUIHelper = new MinMaxGUIHelper(light.shadow.camera, 'near', 'far', 0.1); + folder.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('proche').onChange(updateCamera); + folder.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('loin').onChange(updateCamera); + folder.add(light.shadow.camera, 'zoom', 0.01, 1.5, 0.01).onChange(updateCamera); +}
Nous demandons à l'interface graphique d'appeler notre fonction updateCamera
chaque fois que quelque chose change.
Écrivons cette fonction pour mettre à jour la lumière, l'helper pour la lumière, la
caméra d'ombre de la lumière et l'helper affichant la caméra d'ombre de la lumière.
function updateCamera() { // mettre à jour le matrixWorld de la cible de la lumière car il est nécessaire pour l'helper light.target.updateMatrixWorld(); helper.update(); // mettre à jour la matrice de projection de la caméra d'ombre de la lumière light.shadow.camera.updateProjectionMatrix(); // et maintenant mettre à jour l'helper caméra que nous utilisons pour afficher la caméra d'ombre de la lumière cameraHelper.update(); } updateCamera();
Et maintenant que nous avons donné à la caméra d'ombre de la lumière une interface graphique, nous pouvons jouer avec les valeurs.
Définissez la largeur
et la hauteur
à environ 30 et vous pourrez voir que les ombres sont correctes
et que les zones qui doivent être dans l'ombre pour cette scène sont entièrement couvertes.
Mais cela soulève la question : pourquoi ne pas simplement définir la largeur
et la hauteur
à des
nombres géants pour tout couvrir ? Définissez la largeur
et la hauteur
à 100
et vous pourriez voir quelque chose comme ceci
Qu'est-ce qui se passe avec ces ombres basse résolution ?!
Ce problème est un autre paramètre lié aux ombres dont il faut être conscient. Les cartes d'ombres sont des textures dans lesquelles les ombres sont dessinées. Ces textures ont une taille. La zone de la caméra d'ombre que nous avons définie ci-dessus est étirée sur cette taille. Cela signifie que plus la zone que vous définissez est grande, plus vos ombres seront pixelisées.
Vous pouvez définir la résolution de la texture de la carte d'ombre en définissant light.shadow.mapSize.width
et light.shadow.mapSize.height
. Par défaut, elles sont de 512x512.
Plus vous les augmentez, plus elles consomment de mémoire et plus elles sont lentes à calculer, vous voulez donc
les définir aussi petites que possible tout en faisant fonctionner votre scène. Il en va de même pour la
zone de la caméra d'ombre de la lumière. Plus elle est petite, meilleures sont les ombres, alors rendez la zone aussi petite que possible et
continuez à couvrir votre scène. Sachez que la machine de chaque utilisateur a une taille de texture maximale
autorisée qui est disponible sur le renderer sous la forme de renderer.capabilities.maxTextureSize
.
En passant à la SpotLight
, la caméra d'ombre de la lumière devient une PerspectiveCamera
. Contrairement à la caméra d'ombre
de la DirectionalLight
où nous pouvions définir manuellement la plupart de ses paramètres, la caméra d'ombre
de la SpotLight
est contrôlée par la SpotLight
elle-même. Le fov
(champ de vision) pour l'ombre
de la caméra est directement lié au paramètre angle
de la SpotLight
.
L'aspect
est défini automatiquement en fonction de la taille de la carte d'ombre.
-const light = new THREE.DirectionalLight(color, intensity); +const light = new THREE.SpotLight(color, intensity);
et nous avons rajouté les paramètres penumbra
et angle
de notre article sur les lumières.
Et enfin, il y a les ombres avec une PointLight
. Comme une PointLight
brille dans toutes les directions, les seuls paramètres pertinents sont near
et far
.
Sinon, l'ombre d'une PointLight
est effectivement composée de 6 ombres de SpotLight
, chacune pointant vers une face d'un cube autour de la lumière. Cela signifie
que les ombres des PointLight
sont beaucoup plus lentes car toute la scène doit être
dessinée 6 fois, une pour chaque direction.
Mettons une boîte autour de notre scène pour que nous puissions voir les ombres sur les murs
et le plafond. Nous définirons la propriété side
du matériau sur THREE.BackSide
afin de rendre l'intérieur de la boîte au lieu de l'extérieur. Comme le sol,
nous la définirons pour qu'elle ne reçoive que les ombres. De plus, nous définirons la position de la
boîte de manière à ce que son bas soit légèrement en dessous du sol afin que le sol et le bas
de la boîte ne causent pas de z-fighting.
{ const cubeSize = 30; const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize); const cubeMat = new THREE.MeshPhongMaterial({ color: '#CCC', side: THREE.BackSide, }); const mesh = new THREE.Mesh(cubeGeo, cubeMat); mesh.receiveShadow = true; mesh.position.set(0, cubeSize / 2 - 0.1, 0); scene.add(mesh); }
Et bien sûr, nous devons changer la lumière en PointLight
.
-const light = new THREE.SpotLight(color, intensity); +const light = new THREE.PointLight(color, intensity); .... // pour pouvoir facilement voir où se trouve la lumière ponctuelle +const helper = new THREE.PointLightHelper(light); +scene.add(helper);
Utilisez les paramètres d'interface graphique position
pour déplacer la lumière
et vous verrez les ombres tomber sur tous les murs. Vous pouvez
également ajuster les paramètres near
et far
et voir, tout comme
pour les autres ombres, que lorsque les objets sont plus proches que near
, ils
ne reçoivent plus d'ombre et lorsqu'ils sont plus loin que far
, ils sont toujours dans l'ombre.