Aller au contenu

Scroll et survol

Faire descendre la caméra au rythme du scroll de la page, puis détecter l’objet sous le pointeur avec un raycaster pour le faire grossir.

main.js
import * as THREE from "three";

const SECTION_HEIGHT = 4;

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  100,
);
camera.position.z = 6;

const light = new THREE.DirectionalLight("#ffffff", 3);
light.position.set(1, 1, 1);
const ambientLight = new THREE.AmbientLight("#ffffff", 0.3);
scene.add(light, ambientLight);

const material = new THREE.MeshStandardMaterial({ color: "#d5d30e" });

const objects = [
  new THREE.Mesh(new THREE.TorusGeometry(0.6, 0.25, 32, 64), material),
  new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.4, 32), material),
  new THREE.Mesh(new THREE.TorusKnotGeometry(0.5, 0.18, 128, 16), material),
];

objects.forEach((object, index) => {
  object.position.y = -index * SECTION_HEIGHT;
  scene.add(object);
});

let scrollY = window.scrollY;

window.addEventListener("scroll", () => {
  scrollY = window.scrollY;
});

const renderer = new THREE.WebGLRenderer({
  antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

let hovered = null;
const mouse = new THREE.Vector2(10, 10);
const raycaster = new THREE.Raycaster();

window.addEventListener("pointermove", (event) => {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});

renderer.setAnimationLoop(() => {
  raycaster.setFromCamera(mouse, camera);
  const hits = raycaster.intersectObjects(objects);

  hovered = hits.length > 0 ? hits[0].object : null;

  for (const object of objects) {
    const targetScale = object === hovered ? 1.3 : 1;

    object.scale.setScalar(
      THREE.MathUtils.lerp(object.scale.x, targetScale, 0.15),
    );
  }

  document.body.style.cursor = hovered ? "pointer" : "";

  camera.position.y = -(scrollY / window.innerHeight) * SECTION_HEIGHT;
  renderer.render(scene, camera);
});

window.addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();

  renderer.setSize(window.innerWidth, window.innerHeight);
});

Une page qui défile devant la scène

Ici, la page HTML a un vrai contenu : trois sections de la hauteur de l’écran. Le canvas, lui, reste fixe derrière elles :

index.html
canvas {
  position: fixed;
  top: 0;
  left: 0;
  z-index: -1;
}

position: fixed le colle à l’écran pendant que la page défile, z-index: -1 le passe sous le texte. Fais défiler la démo : c’est la caméra qui descend, au même rythme que les sections.

Un objet par section

main.js
const SECTION_HEIGHT = 4;

objects.forEach((object, index) => {
  object.position.y = -index * SECTION_HEIGHT;
  scene.add(object);
});

SECTION_HEIGHT est la distance, en unités 3D, qui correspond à une section (un écran) de la page. Le premier objet est à y = 0, le deuxième à -4, le troisième à -8. Les trois partagent le même matériau : inutile d’en créer un par objet.

Relier le scroll à la caméra

main.js
let scrollY = window.scrollY;

window.addEventListener("scroll", () => {
  scrollY = window.scrollY;
});

Puis, dans la boucle d’animation :

main.js
camera.position.y = -(scrollY / window.innerHeight) * SECTION_HEIGHT;

scrollY / window.innerHeight compte le nombre d’écrans déjà défilés : 0 en haut, 1 à la deuxième section, 2 à la troisième. En multipliant par SECTION_HEIGHT, la caméra arrive pile sur l’objet de chaque section. Le signe moins la fait descendre quand la page descend.

Si un objet n’est pas centré dans sa section, change SECTION_HEIGHT ou la position z de la caméra : les deux sont liés.

Savoir quel objet est sous le pointeur

Pour savoir ce que le pointeur survole, on lance un rayon invisible depuis la caméra, à travers le pointeur, et on regarde ce qu’il touche : c’est le rôle du raycaster.

main.js
const mouse = new THREE.Vector2(10, 10);
const raycaster = new THREE.Raycaster();

window.addEventListener("pointermove", (event) => {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});

Comme dans la doc « Suivre la souris », la position est ramenée entre -1 et 1, mais cette fois le y est inversé tout de suite : le raycaster attend un y qui monte. On démarre à (10, 10), hors de l’écran, pour que rien ne soit survolé avant le premier mouvement.

main.js
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(objects);

hovered = hits.length > 0 ? hits[0].object : null;

intersectObjects renvoie la liste des objets touchés, du plus proche au plus lointain. Le premier est celui qu’on voit sous le pointeur.

Grossir en douceur

main.js
for (const object of objects) {
  const targetScale = object === hovered ? 1.3 : 1;

  object.scale.setScalar(
    THREE.MathUtils.lerp(object.scale.x, targetScale, 0.15),
  );
}

document.body.style.cursor = hovered ? "pointer" : "";

lerp (interpolation linéaire) avance de 15 % vers la taille visée à chaque image : l’objet grossit et rétrécit en douceur au lieu de sauter d’une taille à l’autre. Le curseur devient une main au survol, comme sur un lien.

Pour aller plus loin

  • Ouvre un lien ou change de couleur au clic : écoute click et regarde si hovered n’est pas null.
  • Fais tourner chaque objet sur lui-même dans la boucle.
  • Ajoute une quatrième section et un quatrième objet.

Mise à jour : ven. 30 oct. à 17:05