Приветствую! Я Алексей, frontend‑разработчик в SimbirSoft. Вы, возможно, видели потрясающие веб‑сайты, представленные на www.awwwards.com. Это онлайн‑каталог лучших веб‑сайтов, который оценивает и награждает творческие и инновационные работы веб‑дизайнеров и разработчиков. На этом сайте можно найти множество примеров креативного веб‑дизайна, анимаций и визуальных эффектов. Такие удивительные анимации обычно разрабатываются с использованием WebGL. Эта технология позволяет более свободно и творчески подходить к созданию впечатляющих визуальных эффектов без ущерба для производительности. Для работы с WebGL используются такие библиотеки, как Three.js, PIXIJS или BABYLON, которые также популярны при создании игр.
В данной статье мы рассмотрим совмещение WebGL‑анимации с прокруткой страницы HTML, используя библиотеку Three.js. Работа с ней во многом схожа с работой 3D‑редактора (3ds Max, Maya, Blender и т. д.). Для получения результата в виде картинки или анимации необходимо создать сцену, поместить в нее камеру, создать примитив (геометрию или 3D‑модель), создать источник освещения и запустить процесс рендеринга.
Эта статья будет полезна middle и senior фронтенд‑разработчикам, которые хотят ознакомиться с Three. В статье очень мало теории и вводных материалов, акцент сделан на практической части. Если вы совсем не знаете, как работает Three.js и шейдеры, рекомендую вначале ознакомиться с этой технологией, а после вернуться к статье.

Как это будет работать на базовом уровне
У нас есть HTML‑страница со скрытыми картинками. Блок canvas абсолютно позиционирован относительно body и находится за HTML‑страницей. Внутри сцены расположены плоскости, на которых будут отображаться наши картинки (те же самые, что и на HTML‑странице). Текстуры картинок будут реализованы с помощью вершинных и пиксельных шейдеров, благодаря этому мы сможем добавлять различные эффекты при наведении мыши. Для получения размеров и координат картинок мы вызовем функцию getBoundingClientRect и применим эти данные к нашим плоскостям в сцене, что позволит разместить их соответствующим образом и сделать их такого же размера, как и картинки. При прокрутке также будем получать значение скролла и двигать наши плоскости вверх.

Настройка проекта
Для сборки я буду использовать vite. Как установить vite, можно почитать тут. Команда
npm create vite@latest app --template vanilla
создаст базовый шаблон для разработки на JavaScript. Сразу установим Three.js, выполнив команду:
npm install three
Чтобы свободно импортировать шейдеры, необходимо установить библиотеку vite-plugin-glsl, устанавливаем библиотеку с помощью команды:
npm i vite-plugin-glsl --save-dev
Теперь создаем файл vite.config.js и прописываем там конфиг:
// vite.config.js
import glsl from 'vite-plugin-glsl'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [glsl()] });
Согласно этой библиотеке, структура проекта должна выглядеть следующим образом:
Не забудьте поменять путь до файла main.js в файле index.html, который должен находиться в папке src.
Базовые настройки сцены
В нашей сцене будет установлена перспективная камера. Чтобы камера захватывала всю ширину и высоту экрана, рассчитываем угол обзора fov:

А теперь небольшой урок школьной тригонометрии 🙂 Камера расположена на расстоянии 1000 px от плоскости (на рисунке показана как canvas), на которой будут располагаться наши картинки. Арктангенс fov/2 будет равен (отношение противолежащего катета к прилежащему) window.innerHeight / 2 / perspective. Чтобы получить угол в градусах, умножим удвоенное значение на 180 и разделим на PI:

Если вы будете делать свой проект из моего репозитория на github, то вам следует закомментировать класс main в css, чтобы в дальнейшем все совпадало.
Изначально скроем все наши картинки, установив им значение непрозрачности (opacity: 0). Для тега canvas зададим следующие стили:
Создадим класс Sketch и вызовем экземпляр этого класса:
файл:src/main.js
import "/src/assets/style.scss"; import * as THREE from 'three'; class Sketch { constructor() { this.body = document.querySelector('body'); this.createScene(); this.createCamera(); this.createMesh(); this.initRenderer(); this.render(); } get viewport() { const width = window.innerWidth; const height = window.innerHeight; const aspectRatio = width / height; return { width, height, aspectRatio }; } createScene() { this.scene = new THREE.Scene(); } createCamera() { const perspective = 1000; const fov = (180 * (2 * Math.atan(window.innerHeight / 2 / perspective))) / Math.PI; this.camera = new THREE.PerspectiveCamera(fov, this.viewport.aspectRatio, 1, 1000) this.camera.position.set(0, 0, perspective); } createMesh() { const geometry = new THREE.PlaneGeometry( 250, 250, 10, 10 ); const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }); const mesh = new THREE.Mesh(geometry, material); this.scene.add(mesh); } onWindowResize() { this.camera.aspect = this.viewport.aspectRatio; this.createCamera(); this.camera.updateProjectionMatrix(); this.renderer.setSize(this.viewport.width, this.viewport.height); } initRenderer() { this.renderer = new THREE.WebGL1Renderer({ antialias: true, alpha: true }); this.renderer.setSize(this.viewport.width, this.viewport.height); this.renderer.setPixelRatio(window.devicePixelRatio); this.body.appendChild(this.renderer.domElement); } render() { this.renderer.render(this.scene, this.camera); requestAnimationFrame(this.render.bind(this)); } } new Sketch();
Описание методов класса Sketch:
viewport — получаем размеры и соотношение сторон экрана;
createScene — создаем сцену;
createCamera — создаем камеру с расчетными параметрами;
createMesh — создаем материал, геометрическую плоскость и добавляем ее в нашу сцену;
THREE.PlaneGeometry (250, 250, 10, 10) — задаем размер плоскости 250×250 и 10×10 размер полигональной сетки деления плоскости по горизонтали и вертикали;
THREE.MeshBasicMaterial({ color: 0×00ff00, wireframe: true }) — задаем цвет плоскости в формате Hex Color. wireframe: true — отображение плоскости в виде полигональной сетки (позже мы это исправим);
onWindowResize — функция для обновления параметров рендера при изменении размеров экрана;
initRenderer — инициализация рендера, при изменении размеров экрана будет вызывать функцию onWindowResize;
setPixelRatio — устанавливает соотношение пикселей устройства, чтобы предотвратить размытие выходного холста. Возможно, на устройствах mac retina это значение нужно будет изменить, чтобы анимация не тормозила.
render — рекурсивная функция, которая будет запускать саму себя для обновления рендера сцены.
Страница должны выглядеть так:

Замена картинок на Three.js плоскости
Для начала доработаем наш класс Sketch.
файл: src/main.js
сlass Sketch{ constructor(){ this.body = document.querySelector('body'); this.images = [...document.querySelectorAll('img')]; this.meshItems = []; this.createScene(); this.createCamera(); this.createMesh(); this.initRenderer(); this.render(); } initRenderer() { window.addEventListener('resize', this.onWindowResize.bind(this), false); this.renderer = new THREE.WebGL1Renderer({ antialias: true, alpha: true }); this.renderer.setSize(this.viewport.width, this.viewport.height); this.renderer.setPixelRatio(window.devicePixelRatio); this.body.appendChild(this.renderer.domElement); } ... createMesh() { // const geometry = new THREE.PlaneGeometry( 250, 250, 10, 10 ); // const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }); // const mesh = new THREE.Mesh(geometry, material); // this.scene.add(mesh); this.images.map((image) => { const meshItem = new MeshItem(image, this.scene); this.meshItems.push(meshItem); }) } ... render(){ for(let i = 0; i < this.meshItems.length; i++){ this.meshItems[i].render(); } this.renderer.render(this.scene, this.camera); requestAnimationFrame(this.render.bind(this)); } }
this.images — получаем массив картинок;
this.meshItems — изначально равен пустому массиву;
В методе createMesh удалим ранее существовавший код и вставим цикл, который будет обходить массив изображений. Создадим экземпляр класса MeshItem для каждой фотографии и разместим его в массиве this.meshItems. Присвоим каждой картинке уникальный id, соответствующий ее индексу в массиве. Для метода render добавим цикл, который будет проходить по массиву изображений и делать их перерендер на каждом этапе requestAnimationFrame;
Все остальные методы в классе Sketch останутся без изменений.
Далее создадим класс MeshItem:
файл: src/main.js
class MeshItem{ constructor(element, scene){ this.element = element; this.scene = scene; this.offset = new THREE.Vector2(0,0); this.sizes = new THREE.Vector2(0,0); this.createMesh(); } getDimensions(){ const {width, height, top, left} = this.element.getBoundingClientRect(); this.sizes.set(width, height); this.offset.set(left - window.innerWidth / 2 + width / 2, -top + window.innerHeight / 2 - height / 2); } createMesh(){ const geometry = new THREE.PlaneGeometry( 1, 1, 10, 10 ); const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }); this.mesh = new THREE.Mesh(geometry, material); this.scene.add(this.mesh); } render(){ this.getDimensions(); this.mesh.position.set(this.offset.x, this.offset.y, 0); this.mesh.scale.set(this.sizes.x, this.sizes.y, 1); } }
THREE.Vector2 — класс, представляющий двумерный вектор. Точка в 2D пространстве (то есть положение на плоскости) или размер плоскости, как у нас. Экземпляр Vector2 создаст объект с полями (x, y), зададим им начальные значения (0,0);
this.offset — координаты расположения плоскости;
this.sizes — размер плоскости;
getDimensions — задает размеры и координаты плоскостям в соответствии с размерами и координатами картинок. В THREE JS координаты расположения объектов задается не как в браузере от верхнего левого угла, а от координат x=0, y=0, z=0 (соответствующих центру экрана в нашем случае) до центральной точки объекта.
render — обновляет координаты и размеры картинок и устанавливает соответствующие размеры и координаты плоскости.
Должно получиться так:

При прокрутке страницы можно заметить, что наши плоскости немного запаздывают за картинками (при прокрутке за картинками видны наши зеленые плоскости). На анимации я сделал картинки видимыми (opacity: 1), чтобы эффект был лучше виден. Нужно сделать плавную прокрутку на странице, чтобы избавиться от этого.

Добавляем плавную прокрутку на страницу
При скролле значения прокрутки изменяются не последовательно 1,2,3,4,5, а с пропуском некоторых тактов для увеличения производительности. Из-за этого в нашей анимации плоскости не всегда успевают за картинками.
Плавный скролл можно сделать по-разному, я сделаю через свойство transform: translate3d.
файл: src/main.js
import "/src/assets/style.scss"; import * as THREE from 'three'; const lerp = (start, end, t) => { return start * (1 - t) + end * t; }; class Sketch { constructor() { this.body = document.querySelector('body'); this.images = [...document.querySelectorAll('img')]; this.meshItems = []; this.scrollable = document.querySelector(".smooth-scroll"); this.current = 0; this.target = 0; this.ease = 0.065; this.createScene(); this.createCamera(); this.createMesh(); this.initRenderer(); this.render(); } get viewport() { const width = window.innerWidth; const height = window.innerHeight; const aspectRatio = width / height; return { width, height, aspectRatio }; } smoothScroll = () => { this.target = window.scrollY; this.current = lerp(this.current, this.target, this.ease); this.scrollable.style.transform = `translate3d(0,${-this.current}px, 0)`; }; createScene() { this.scene = new THREE.Scene(); } createCamera() { const perspective = 1000; const fov = (180 * (2 * Math.atan(window.innerHeight / 2 / perspective))) / Math.PI; this.camera = new THREE.PerspectiveCamera(fov, this.viewport.aspectRatio, 1, 1000) this.camera.position.set(0, 0, perspective); } createMesh() { this.images.map((image) => { const meshItem = new MeshItem(image, this.scene); this.meshItems.push(meshItem); }) document.body.style.height = `${this.scrollable.getBoundingClientRect().height}px`; } onWindowResize() { document.body.style.height = `${this.scrollable.getBoundingClientRect().height}px`; this.camera.aspect = this.viewport.aspectRatio; this.createCamera(); this.camera.updateProjectionMatrix(); this.renderer.setSize(this.viewport.width, this.viewport.height); } initRenderer() { window.addEventListener('resize', this.onWindowResize.bind(this), false); this.renderer = new THREE.WebGL1Renderer({ antialias: true, alpha: true }); this.renderer.setSize(this.viewport.width, this.viewport.height); this.renderer.setPixelRatio(window.devicePixelRatio); this.body.appendChild(this.renderer.domElement); } render() { this.smoothScroll(); for(let i = 0; i < this.meshItems.length; i++){ this.meshItems[i].render(); } this.renderer.render(this.scene, this.camera); requestAnimationFrame(this.render.bind(this)); } } new Sketch();
С помощью translate3d будем сдвигать блок с классом smooth‑scroll вверх.
this.target — количество пикселей в документе, которые были пролистаны на данный момент от начальной позиции;
lerp — функция линейной интерполяции;
this.current — текущее значение прокрутки;
this.ease — коэффициент линейной интерполяции для прокрутки;
this.scrollable.getBoundingClientRect().height — будет задавать значение высоты тегу body равное значению высоты контента.
CSS свойства, которые прописаны для блоков html, body, main, scrollable тоже влияют на качество и плавность прокрутки. В HTML-документе это выглядит так:
файл: index.html

файл:src/assets/style.scss

Отображение картинок с помощью шейдеров
Добавим шейдеры которые будут отвечать нашу анимацию. Создадим файл vertex.glsl в папке glsl:
файл:src/glsl/vertex.glsl
uniform sampler2D uTexture; uniform vec2 uOffset; uniform vec2 u_mouse; uniform float u_time; varying vec2 vUv; #define PI 3.14 vec3 deformationCurve(vec3 position, vec2 uv, vec2 offset) { position.y = position.y + (sin(uv.x * PI) * offset.y); return position; } void main() { vUv = uv; float noise = 1. - sin(4. * uv.x + u_mouse.x * 90.) / 30.; vec3 newPosition = deformationCurve(position, uv , uOffset + noise * 0.02 * sin(u_time)); gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 ); }
Создадим файл fragment.gls lв папке glsl:
файл:src/glsl/fragment.glsl
uniform sampler2D uTexture; uniform float uAlpha; uniform vec2 uOffset; varying vec2 vUv; uniform vec2 u_mouse; uniform float u_time; vec3 rgbShift(sampler2D textureImage, vec2 uv, vec2 offset) { float noise = 1. - sin(2. * sin(uv.x * u_mouse.x) * 20. + u_time * 0.6) / 30.; vec3 rgb = texture2D(textureImage, uv * noise).rgb; rgb.b = texture2D(textureImage, uv * noise + sin(u_mouse.x * u_time - u_time * 0.3)*0.015).b; rgb.r = texture2D(textureImage, uv * noise - sin(u_mouse.x * u_time + u_time * 0.3)*-0.02).r; return vec3(rgb); } void main() { vec3 color = rgbShift(uTexture, vUv, uOffset); gl_FragColor = vec4(color, uAlpha); }
Импортируем шейдеры:
файл: src/main.js
import "/src/assets/style.scss"; import * as THREE from 'three'; import vertex from './glsl/vertex.glsl'; import fragment from './glsl/fragment.glsl';
В этой статье не будет описания работы с шейдерами. Тема достаточно объемная и в рамках одной статьи не получится сделать это качественно. Для изучения шейдеров можно воспользоваться The Book of Shaders – это пошаговое руководство по абстрактной и сложной вселенной фрагментных шейдеров.
Создадим новый материал для нашей плоскости:
файл:src/main.js класс:MeshItem
createMesh() { const geometry = new THREE.PlaneGeometry(1, 1, 10, 10); const imageTexture = new THREE.TextureLoader().load(this.element.src); imageTexture.minFilter = THREE.LinearFilter; this.uniforms = { uTexture: { value: imageTexture }, uOffset: { value: new THREE.Vector2(0.0, 0.0) }, uAlpha: { value: 1.0 }, u_mouse: { type: "v2", value: new THREE.Vector2() }, u_time: { type: "f", value: 0.0 }, }; const material = new THREE.ShaderMaterial({ uniforms: this.uniforms, vertexShader: vertex, fragmentShader: fragment, transparent: true, //wireframe: true, side: THREE.DoubleSide }) this.mesh = new THREE.Mesh(geometry, material); this.scene.add(this.mesh); }
imageTexture — асинхронно загружает нашу текстуру по указанному пути;
THREE.LinearFilter — нужен для правильного отображения асинхронно загружаемых текстур;
this.uniforms — специальное свойство Three.js, через которое можно передавать параметры в шейдеры;
uTexture — в это поле передается текстура;
uOffset — двумерный вектор, в который будем передавать параметры;
uAlpha — значение альфа канала. 0 — прозрачный, 1 — непрозрачный;
u_mouse — в это поле будут записываться координаты (x и y) положения мыши;
u_time — в это поле будет записываться значение счетчика времени.
Создадим новый материал и передадим в него параметры вершинных и вертексных шейдеров, значение юниформы, установим значение непрозрачности и сделаем материал двухсторонним:
файл: src/main.js класс: Sketch
class Sketch { imageLoaded(url) { return new Promise(function (resolve, reject) { var img = new Image(); img.onload = function () { resolve(url); }; img.onerror = function () { reject(url); }; img.src = url; }); } createMesh() { const imagesLoaded = this.images.map((image) => { const meshItem = new MeshItem(image, this.scene); this.meshItems.push(meshItem); return this.imageLoaded(image.src); }) Promise.all(imagesLoaded).then(() => { document.body.style.height = ${this.scrollable.getBoundingClientRect().height}px`; }) } } new Sketch();
imageLoaded — проверяем, загружена ли картинка и возвращает промис;
Promise.all(imagesLoaded) — если все картинки загружены, то обновляем значение высоты body (если этого не сделать, то высота прокрутки рассчитается до загрузки картинок, и значение высоты будет меньше).
Делаем Hover-эффект при наведении курсора на картинку
файл: src/main.js класс: MeshItem
class Sketch { constructor() { this.body = document.querySelector('body'); this.images = [...document.querySelectorAll('img')]; this.scrollable = document.querySelector(".smooth-scroll"); this.current = 0; this.target = 0; this.ease = 0.065; this.meshItems = []; this.planeItems = []; this.mouseCoordinates = new THREE.Vector2(); this.raycaster = new THREE.Raycaster(); this.selectMesh = null; this.onMouse(); this.onTouchMove(); this.createScene() this.createCamera(); this.createMesh(); this.initRenderer(); this.render(); } onTouchMove() { document.addEventListener("touchmove", (event) => { const x = (event?.touches[0].clientX / window.innerWidth) * 2 - 1; const y = -(event?.touches[0].clientY / window.innerHeight) * 2 + 1; this.mouseCoordinates = { x, y: window.innerWidth > 450 ? y : 0. }; }) } onMouse() { document.addEventListener("mousemove", (event) => { const x = (event.clientX / window.innerWidth) * 2 - 1; const y = -(event.clientY / window.innerHeight) * 2 + 1; this.mouseCoordinates = { x, y } }) } createMesh() { const imagesLoaded = this.images.map((image) => { const meshItem = new MeshItem(image, this.scene); this.meshItems.push(meshItem); return this.imageLoaded(image.src); }) Promise.all(imagesLoaded).then(() => { document.body.style.height = ${this.scrollable.getBoundingClientRect().height}px`; }) this.scene.traverse((item) => { if (item.isMesh) { this.planeItems.push(item); } }) } render() { this.smoothScroll(); this.raycaster.setFromCamera(this.mouseCoordinates, this.camera); const intersects = this.raycaster.intersectObjects(this.planeItems, true); if (intersects.length > 0) { this.selectMesh = intersects[0].object; } else { if (this.selectMesh !== null) { this.selectMesh = null; } } const velocity = (this.target - this.current); for (let i = 0; i < this.meshItems.length; i++) { this.meshItems[i].render(velocity, this.mouseCoordinates, this.selectMesh); } this.renderer.render(this.scene, this.camera); requestAnimationFrame(this.render.bind(this)); } } new Sketch()
this.mouseCoordinates — двумерный вектор с координатами x, y, куда будут записываться координаты мыши;
Raycaster — специальный класс Three. Используется для выбора объектов в трехмерной сцене;
selectMesh — объект, на который наведена мышь;
onTouchMove, onMouse — функции, которые будут записывать координаты мыши или координаты движения пальца на мобильном в переменную this.mouseCoordinates. Но перед этим мы должны нормализовать координаты мыши в диапазоне от -1 до 1;

this.scene.traverse — будет проходить по массиву сцены и, если это геометрия, то будем ее записывать в this.planeItems;
this.raycaster.setFromCamera(this.mouseCoordinates, this.camera) — передаем координаты мыши и камеру в raycaster;
this.raycaster.intersectObjects — передаем массив с геометрией (this.planeItems) пересечение с которой нужно отслеживать. this.raycaster.intersectObjects вернет отсортированный массив с объектами сцены, которые пересеклись с мышью, начиная от самого ближнего к камере. В нулевой индекс запишется первый объект, который находился ближе к камере, и на который была наведена мышь.
Далее проверяем, есть ли пересечение, и записываем геометрию пересечения в переменную selectMesh. В противном случае обнуляем эту переменную.
this.meshItems[i].render(velocity, this.mouseCoordinates, this.selectMesh) – передаем скорость прокрутки, координаты мыши и выбранную геометрию в метод render-класса meshItems для обновления параметров шейдера:
файл: src/main.js класс: MeshItem
class MeshItem { render(velocity = 0, mouseCoordinates, selectMesh) { this.getDimensions(); this.mesh.position.set(this.offset.x, this.offset.y, 0); this.mesh.scale.set(this.sizes.x, this.sizes.y, 1); this.uniforms.uOffset.value.set(this.offset.x * 0.5, -(velocity) * 0.0003); if (this.mesh.uuid === selectMesh?.uuid) { this.uniforms.u_mouse.value.x = lerp(0.0, mouseCoordinates.x, 0.6); this.uniforms.u_mouse.value.y = lerp(0.0, mouseCoordinates.y, 0.6); this.uniforms.u_time.value += 0.05; return; } this.uniforms.u_mouse.value.x = lerp(this.uniforms.u_mouse.value.x, 0.0, 0.02); this.uniforms.u_mouse.value.y = lerp(this.uniforms.u_mouse.value.y, 0.0, 0.02); this.uniforms.u_time.value = lerp(this.uniforms.u_time.value, 0.0, 0.02); } }
this.uniforms.uOffset.value.set(this.offset.x * 0.5, -(velocity) * 0.0003) — передаем скорость прокрутки в шейдер;
this.mesh.uuid === selectMesh?.uuid — проверяем, если выбранный uuid геометрии совпадает с uuid текущей геометрии, то обновляем параметры шейдера через функцию линейной интерполяции. Если uuid не совпадает, то возвращаем параметры шейдера в первоначальное состояние;
Наглядный пример анимации без линейной интерполяции, если бы мы сразу присваивали значение напрямую и плавное завершение анимации с функцией линейной интерполяции.
Без линейной интерполяции:

С линейной интерполяцией:

Заключение
В этой статье мы рассмотрели пример встраивания WebGL в HTML‑страницу, с синхронизацией по прокрутке. Картинки заменены на плоскости Three.js, мы также можем управлять расположением наших картинок, используя CSS (посмотрите, как представленное демо работает на мобильной версии экрана). Благодаря использованию 3D‑графики и шейдеров можно делать различные анимации, что дает больше возможностей для творческой реализации. Единственное ограничение — это ваше воображение и уровень мастерства:)
В качестве самостоятельного домашнего задания можете сделать курсор, который следует за движением мыши вот так:

Спасибо за внимание!
Полезные материалы о frontend-разработке мы также публикуем в наших соцсетях – ВКонтакте и Telegram.
ссылка на оригинал статьи https://habr.com/ru/company/simbirsoft/blog/721912/
Добавить комментарий