Введение

Веб-разработка давно вышла за рамки статичных страниц и плоских интерфейсов. Современные пользователи ожидают от сайтов вау-эффекта, плавности анимации и глубокой интерактивности. Одним из самых завораживающих визуальных элементов в веб-дизайне являются симуляции жидкостей. Когда интерфейс реагирует на движение курсора так, будто под стеклом находится вода или вязкий гель, это привлекает внимание и надолго удерживает пользователя на странице.

Создание подобных эффектов на чистом JavaScript и HTML5 Canvas раньше требовало колоссальных затрат времени и глубоких познаний в математическом моделировании. Сегодня на помощь разработчикам приходит Three.js — мощная библиотека-обертка вокруг WebGL, которая существенно упрощает работу с 3D-графикой, шейдерами и анимацией. В этой статье мы подробно разберем, как реализовать эффект растекания жидкости (Liquid Spreading) с помощью Three.js, настроить геометрию, применить кастомные шейдеры и заставить сцену интерактивно реагировать на действия пользователя.

Теоретическая база: Как работает симуляция жидкости в WebGL

Прежде чем писать код, важно понять базовую концепцию того, как имитируется жидкость в реальном времени в браузерной среде. Точная физическая симуляция (например, решение уравнений Навье-Стокса для каждого кадра) требует огромных вычислительных мощностей и не подходит для стандартных веб-приложений из-за ограничений производительности процессора и видеокарты клиента (в конце концов, нам еще нужно, чтобы вкладка с открытым Slack не вешала всю систему).

Поэтому на практике в веб-разработке применяются два основных подхода:

  • Карты смещения (Displacement Maps) и текстурные возмущения: Самый производительный метод, при котором плоская сетка (PlaneGeometry) деформируется по оси Z на основе карты высот или шума Перлина (Perlin Noise).
  • Шейдеры вершинами и фрагментами (Vertex & Fragment Shaders): Продвинутый подход, использующий GLSL (OpenGL Shading Language). Вычисления происходят непосредственно на GPU, что позволяет создавать реалистичные волны, преломления света (refraction) и каустику без существенной нагрузки на CPU.

Для создания эффекта «растекания» мы объединим эти подходы: создадим сетку высокой детализации в Three.js и напишем кастомный фрагментный шейдер, который будет динамически изменять координаты текстуры в точках соприкосновения с курсором мыши, создавая иллюзию расходящихся волн и растекающейся капли.

Шаг 1: Инициализация базовой сцены Three.js

Любой проект на Three.js начинается с создания трех китов: сцены (Scene), камеры (Camera) и рендерера (Renderer). Давайте настроим базовое окружение, которое станет фундаментом для нашей жидкой поверхности.

import * as THREE from 'three';

// 1. Инициализация сцены
const scene = new THREE.Scene();

// 2. Настройка камеры
const camera = new THREE.PerspectiveCamera(
    60,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.z = 5;

// 3. Создание рендерера с поддержкой прозрачности и сглаживания
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

Этот базовый код создает контекст WebGL, разворачивает его на весь экран и настраивает камеру так, чтобы она смотрела прямо на центр будущей плоскости. Обратите внимание на параметр setPixelRatio: ограничение значения двойкой позволяет избежать падения производительности на устройствах с Retina-дисплеями высокой плотности пикселей.

Шаг 2: Создание геометрии сетки и кастомного материала

Чтобы жидкость могла растекаться и деформироваться, нам нужна сетка с большим количеством полигонов. Обычный квадрат из двух треугольников не подойдет — ему просто не хватит вершин для создания плавных волн. Мы используем PlaneGeometry с высоким разрешением (например, 128 на 128 сегментов).

// Создаем геометрию с высокой плотностью вершин
const geometry = new THREE.PlaneGeometry(6, 6, 128, 128);

// Пишем кастомный ShaderMaterial для эффекта жидкости
const liquidMaterial = new THREE.ShaderMaterial({
    uniforms: {
        uTime: { value: 0.0 },
        uMouse: { value: new THREE.Vector2(0.5, 0.5) },
        uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
        uProgress: { value: 0.0 }
    },
    vertexShader: `
        uniform float uTime;
        uniform vec2 uMouse;
        varying vec2 vUv;
        varying float vElevation;

        void main() {
            vUv = uv;
            
            // Вычисляем расстояние от вершины до позиции мыши
            float dist = distance(uv, uMouse);
            
            // Создаем волновой эффект растекания
            float wave = sin(dist * 20.0 - uTime * 5.0) * 0.1;
            
            // Затухание волны по мере удаления от курсора
            wave *= max(0.0, 1.0 - dist * 2.0);

            vec3 newPosition = position;
            newPosition.z += wave; // Смещаем вершину по оси Z
            
            vElevation = wave;

            gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
        }
    `,
    fragmentShader: `
        uniform float uTime;
        varying vec2 vUv;
        varying float vElevation;

        void main() {
            // Базовый цвет жидкости с градиентным переливом
            vec3 deepColor = vec3(0.05, 0.15, 0.4);
            vec3 surfaceColor = vec3(0.1, 0.6, 0.9);
            
            vec3 finalColor = mix(deepColor, surfaceColor, vElevation * 5.0 + 0.5);

            gl_FragColor = vec4(finalColor, 0.85);
        }
    `,
    wireframe: false,
    transparent: true
});

const mesh = new THREE.Mesh(geometry, liquidMaterial);
scene.add(mesh);

В этом блоке кода мы задали логику на языке GLSL. Вершинный шейдер (vertexShader) вычисляет расстояние от каждой точки сетки до текущих координат мыши, после чего генерирует синусоидальное смещение по оси Z, создавая эффект расходящейся ряби. Фрагментный шейдер (fragmentShader) отвечает за финальную раскраску: мы смешиваем глубокий синий оттенок с более светлым голубым в зависимости от высоты волны.

Шаг 3: Обработка интерактивности и движения мыши

Жидкость не должна просто пульсировать сама по себе — главный шарм симуляции заключается в реакции на действия пользователя. Нам нужно отслеживать движение курсора по экрану, переводить эти координаты в пространство WebGL (в диапазоне от 0.0 до 1.0) и передавать их в качестве униформы (uniforms) в наш шейдер.

const mouse = new THREE.Vector2();
const targetMouse = new THREE.Vector2(0.5, 0.5);

window.addEventListener('mousemove', (event) => {
    // Нормализуем координаты мыши от -1 до 1, затем приводим к диапазону 0 до 1 для UV
    targetMouse.x = event.clientX / window.innerWidth;
    targetMouse.y = 1.0 - (event.clientY / window.innerHeight);
});

// Функция сглаживания движения (lerp) для максимальной плавности
function updateMouse() {
    mouse.x += (targetMouse.x - mouse.x) * 0.05;
    mouse.y += (targetMouse.y - mouse.y) * 0.05;
    
    liquidMaterial.uniforms.uMouse.value.copy(mouse);
}
Совет разработчика: Использование линейной интерполяции (lerp) при обновлении координат мыши критически важно. Без этого курсор будет дергаться, а волны — возникать резкими скачками, что полностью разрушит иллюзию физической вязкости и растекания реальной жидкости (и заставит вас испытать флешбэки от попыток починить баг с дёргающимся скроллом в легаси-проекте).

Шаг 4: Организация цикла анимации и оптимизация

Теперь, когда сцена и интерактивность настроены, нам необходимо запустить цикл рендеринга (animation loop). Внутри этого цикла мы будем обновлять время для шейдера, вызывать функцию сглаживания мыши и перерисовывать кадр.

const clock = new THREE.Clock();

function animate() {
    requestAnimationFrame(animate);

    const elapsedTime = clock.getElapsedTime();
    
    // Обновляем униформы времени и позиции мыши
    liquidMaterial.uniforms.uTime.value = elapsedTime;
    updateMouse();

    // Рендерим сцену
    renderer.render(scene, camera);
}

animate();

// Корректная обработка изменения размеров окна браузера
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    
    renderer.setSize(window.innerWidth, window.innerHeight);
    liquidMaterial.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight);
});

Обязательным этапом разработки является обработка события ресайза окна. Если пренебречь обновлением аспектов камеры и разрешением рендерера, при изменении размера вкладки браузера вся жидкая текстура исказится и потеряет пропорции.

Шаг 5: Продвинутые улучшения: Добавление текстур и эффекта преломления

Чтобы сделать симуляцию по-настоящему кинематографичной, плоского цветового градиента бывает недостаточно. Профессиональные веб-студии используют карты нормалей (Normal Maps) для симуляции световых бликов и преломлений.

Мы можем загрузить текстуру воды через THREE.TextureLoader и передать ее в шейдер материала:

const textureLoader = new THREE.TextureLoader();
const waterNormal = textureLoader.load('path/to/water-normal-map.jpg', (texture) => {
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
});

// Добавляем текстуру в униформы материала
liquidMaterial.uniforms.uNormalMap = { value: waterNormal };

Внутри фрагментного шейдера карта нормалей позволяет вычислять фальшивое освещение (освещенность по модели Фонга или Блинна-Фонга), благодаря чему капли и волны начинают реалистично блестеть под виртуальным источником света, создавая глубокий трехмерный объем.

Заключение

Создание эффекта растекания жидкости с помощью Three.js — это отличный способ прокачать свои навыки работы с WebGL, геометрией и кастомными шейдерами на GLSL. Мы рассмотрели архитектуру базовой сцены, написали вершинный и фрагментный шейдеры для деформации сетки, связали анимацию с движениями пользователя и обсудили методы оптимизации.

Подобные интерактивные элементы мгновенно выделяют сайт на фоне конкурентов. Экспериментируйте с параметрами волн, добавляйте кастомные текстуры, настраивайте освещение — и вы получите уникальный цифровой продукт, способный удивить самого искушенного пользователя.