Встраивание WebGL в HTML-страницу с помощью Three.JS

от автора

demo, github 

Приветствую! Я Алексей, 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *