Девлог · three.js

Штормовой океан на three.js: волны, пена и корабль, который на них держится

Одна функция высоты, которую спрашивают все: как в сцене «Шторм по курсу» движется море, как корабль держится на плаву без физического движка и как получается убедительный шторм.

Обновлено Автор ShengyuStudio8 мин чтения

«Шторм по курсу» — небольшая 3D-сцена в реальном времени на нашем сайте: деревянный парусник идёт по тяжёлой зыби под чёрным небом, дождь косо хлещет перед камерой, вокруг кружат акулы и морской змей, а на носу стоит арбалетчик, которым можно целиться и стрелять левой кнопкой мыши. Всё работает в браузере на three.js (r160) без готовых моделей и текстур — всё построено в коде.

В этой статье мы разбираем, как устроены океан и движение корабля, на фрагментах реального исходного кода. Если коротко: одна дешёвая функция высоты — единственный источник истины о море, и всё, что касается воды, — сетка, корабль, акулы, брызги, арбалетные болты — спрашивает у этой же функции, где поверхность.

Деревянный парусник кренится на серой штормовой зыби
Сцена «Шторм по курсу»: корабль, зыбь, дождь и туман — всё генерируется во время работы.

Одна функция высоты на всё море

Настоящие рендереры океана часто используют FFT-спектры или волны Герстнера. Мы используем кое-что гораздо проще — сумму пяти синусоид, бегущих в разных направлениях с разной скоростью:

function waveH(x, z, t) {
  return Math.sin(x * 0.055 + t * 0.9) * 1.55
       + Math.sin(z * 0.042 - t * 0.7 + 1.7) * 1.25
       + Math.sin((x + z) * 0.03 + t * 0.55) * 1.85
       + Math.sin(x * 0.14 - z * 0.09 + t * 1.6) * 0.42
       + Math.sin((x * 0.75 - z) * 0.11 + t * 2.1) * 0.22;
}

Первые три слагаемых — зыбь: длинные волны, большие амплитуды (1.25–1.85 единицы) и низкие скорости, вдоль x, вдоль z и по диагонали. Последние два — короткая быстрая рябь с малыми амплитудами поверх. Поскольку направления разные, пики никогда не выстраиваются в явную повторяющуюся сетку, и поверхность выглядит неровной, хотя полностью детерминирована.

Почему не что-то физически точнее? Потому что эту функцию можно вызвать где угодно, когда угодно, на CPU, и для одних и тех же (x, z, t) она всегда возвращает один и тот же ответ. Для сцены это свойство оказалось куда важнее спектральной точности: именно оно позволяет кораблю, акулам и стрелам совпадать с той водой, которую вы видите.

Двигаем сетку и считаем нормали

Океан — это одна PlaneGeometry размером 520 × 520 единиц со 112 × 112 сегментами — 113 × 113 = 12 769 вершин, — повёрнутая горизонтально. Каждый кадр мы записываем в неё новые высоты и сами пересчитываем нормали:

function updateOcean(t) {
  const p = oceanGeo.attributes.position, n = oceanGeo.attributes.normal;
  const e = 0.6;
  for (let i = 0; i < p.count; i++) {
    const x = p.getX(i), z = p.getZ(i);
    const h = waveH(x, z, t);
    p.setY(i, h);
    const hx = waveH(x + e, z, t), hz = waveH(x, z + e, t);
    let nx = -(hx - h) / e, nz = -(hz - h) / e;
    const inv = 1 / Math.hypot(nx, 1, nz);
    n.setXYZ(i, nx * inv, inv, nz * inv);
  }
  p.needsUpdate = true;
  n.needsUpdate = true;
}

Нормаль получается конечными разностями. Для поля высот y = h(x, z) нормаль к поверхности пропорциональна (−∂h/∂x, 1, −∂h/∂z). Мы оцениваем каждый наклон, беря высоту на 0.6 единицы дальше по x и по z, а затем нормализуем. Это дешевле, чем computeVertexNormals() (не нужно обходить треугольники), и даёт гладкое освещение, не зависящее от разрешения сетки.

Не расточительно ли обновлять ~12,8 тыс. вершин на CPU каждый кадр? Немного — это три вычисления высоты на вершину. Перенести ту же математику в вершинный шейдер было бы быстрее, но тогда форма моря жила бы и в GLSL, и в JavaScript, и две копии пришлось бы держать идентичными. При таком размере сетки версия на CPU работает с запасом, и одна реализация стоила для нас больше сэкономленных миллисекунд.

Пена на гребнях через onBeforeCompile

Вода использует стандартный MeshStandardMaterial (тёмный сине-зелёный, roughness 0.38, лёгкий metalness), освещённый картой окружения, сгенерированной из неба, — так отражения достаются бесплатно. Пена добавляется не собственным материалом, а патчем встроенного шейдера через onBeforeCompile. Вершинный шейдер передаёт высоту во фрагментный, а тот осветляет цвет у гребней:

float crest = smoothstep(1.9, 3.7, vH);
float streak = 0.7 + 0.3 * sin(vWp.x * 0.35 + vWp.z * 0.5 + uTime * 0.8)
                         * sin(vWp.x * 0.13 - vWp.z * 0.21 - uTime * 0.5);
diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.87, 0.93, 0.95), crest * streak * 0.92);

crest равен 0 ниже высоты 1.9 и 1 выше 3.7, с плавным переходом между ними, так что пенятся только самые высокие пики. streak разбивает пену на дрейфующие пятна, чтобы она не выглядела сплошной белой шапкой. Патч #include <color_fragment> сохраняет всё освещение, тени, туман и тональную компрессию three.js.

Как заставить корабль идти по волнам

Корабль не симулируется физически. Нет ни твёрдого тела, ни силы Архимеда, ни массы. Вместо этого мы сэмплируем море под корпусом и разворачиваем корабль под него. Хватает пяти точек: одна в центре, по одной к носу и корме (±10 вдоль корпуса) и по одной к левому и правому борту (±4 поперёк).

const hC = waveH(0, 0, t);
const hBow = waveH(10, 0, t), hStern = waveH(-10, 0, t);
const hPort = waveH(0, -4, t), hStar = waveH(0, 4, t);
const k = 1 - Math.exp(-dt * 2.1);
shipState.y  += (hC * 0.85 - shipState.y) * k;
shipState.rz += (Math.atan2(hBow - hStern, 20) * 0.85 - shipState.rz) * k;
shipState.rx += (Math.atan2(hPort - hStar, 8) * 0.8 - shipState.rx) * k;
  • Вертикальная качка (вверх-вниз) следует за высотой в центре.
  • Килевая качка (нос вверх или вниз) — это угол линии от точки у кормы до точки у носа: atan2(разность высот, 20), потому что точки разнесены на 20 единиц.
  • Бортовая качка (с борта на борт) — та же идея поперёк корпуса шириной 8 единиц.

Вес корабля передают две детали. Во-первых, каждая цель уменьшена до 80–85%, так что корпус никогда полностью не повторяет наклон воды — тяжёлое судно не следует за каждой волной идеально. Во-вторых, корабль не прыгает к цели, а плавно подтягивается к ней с k = 1 − e−2.1·dt. Такое экспоненциальное сглаживание ведёт себя одинаково при 30, 60 и 144 кадрах в секунду (обычный lerp(a, b, 0.05) на кадр — нет) и даёт естественную задержку: корабль всегда чуть отстаёт от волны, будто у него есть инерция.

Поскольку корпус — это группа, мачты, паруса, леера, бочки, фонарь и арбалетчик автоматически наследуют это движение.

Брызги у носа и всё остальное, что касается воды

Зная позу корабля, можно понять, когда нос врезается в волну. Мы оцениваем мировую высоту носа по вертикальной и килевой качке, сравниваем её с высотой моря прямо перед ним, и если вода выше больше чем на 0.35 единицы, создаём большой спрайт брызг — не чаще раза в 0.9 секунды, чтобы не брызгало непрерывно:

const bowWorldY = shipState.y + Math.sin(shipState.rz) * 12;
if (t > bowSplashCd && waveH(12, 0, t) > bowWorldY + 0.35) {
  spawnFrom(splashes, _v1.set(13.5, waveH(13.5, 0, t) + 0.5, (Math.random() - 0.5) * 3), 4.5);
  bowSplashCd = t + 0.9;
}

Брызги берутся из небольшого пула объектов на 26 спрайтов, которые растут, поднимаются и тают, так что во время игры ничего не выделяется. Тот же вызов waveH управляет всем остальным в воде:

  • Акулы кружат вокруг корабля на глубине, привязанной к местной высоте волны, и каждые 8–15 секунд каждая выпрыгивает по дуге полусинусоиды, поднимая брызги при выходе из воды и входе обратно.
  • Морской змей — это десять сфер, расставленных по окружности; высота каждого сегмента — высота волны плюс бегущий горб, поэтому кажется, что его спина перекатывается сквозь зыбь, а брызги вылетают, когда голова пробивает поверхность.
  • Арбалетные болты летят под действием простой гравитации и считаются упавшими в воду в тот момент, когда их высота опускается ниже waveH в их точке.

Прицеливание арбалета следует той же философии «просто и предсказуемо». Он вычисляет время полёта до цели (расстояние / скорость), берёт упреждение по движущейся цели в 80% от её скорости, умноженной на это время, и поднимает точку прицеливания на ½·g·t², чтобы компенсировать падение. Это не точное баллистическое решение, но на таких дистанциях болт попадает туда, куда вы ожидаете.

Корпус из коробки, паруса из плоскости

Корпус начинается как подразделённая BoxGeometry и вершина за вершиной изгибается в форму корабля. По длине ширина сужается по степенной кривой — резко у носа, мягче у кормы. Кривая седловатости поднимает оба конца, так что палуба взмывает вверх у носа и кормы, а днище уже, чем верх. Палуба — более тонкая коробка, изогнутая той же функцией, поэтому садится точно на место.

Паруса — подразделённые плоскости, выдавленные наружу по формуле sin(πx)·sin(πy)0.7: плавное «пузо», нулевое по краям и самое полное в середине. Крошечный патч вершинного шейдера добавляет две синусоиды во времени для лёгкого трепетания, а красный флаг на топе мачты использует более быструю и сильную версию того же приёма, амплитуда которой растёт к свободному краю флага. Все текстуры — доски корпуса, палуба, парусина, облака, брызги, дождь — рисуются в canvas при запуске.

Как продать шторм: небо, туман, дождь, молнии, грейдинг

Океан выглядит штормовым только благодаря тому, что его окружает:

  • Небо. Объект Sky из three.js с высокой мутностью и низким солнцем даёт туманный пасмурный купол. Сгенерированная из него карта окружения PMREM освещает воду и корабль.
  • Туман. Экспоненциальный туман серо-синего оттенка поглощает горизонт, скрывает край 520-единичной плоскости океана и создаёт ощущение замкнутого пространства.
  • Дождь. 800 точек с текстурой штриха и аддитивным смешиванием падают с немного разной скоростью и косо — позиции обновляются в одном типизированном массиве каждый кадр.
  • Облака. Спрайты медленно дрейфуют по кольцу не ближе 130 единиц от центра, так что никогда не окажутся между камерой и кораблём.
  • Молнии. Направленный свет, обычно с нулевой интенсивностью. Время от времени он вспыхивает на полную в случайной точке неба, может снова мигнуть, пока ещё ярок, и затухает экспоненциально (flash *= 0.03dt). Шанс вспышки разыгрывается каждый кадр, поэтому при высокой частоте кадров молнии бьют чуть чаще — упрощение, с которым мы вполне согласны.
  • Тёплый фонарь. Один точечный свет на корме мерцает суммой двух синусоид. Это единственный тёплый свет в сцене, и глазу есть на чём отдохнуть.
  • Постобработка. Кинематографичная тональная компрессия ACES при низкой экспозиции, мягкий bloom и собственный проход цветокоррекции: S-кривая для контраста, стально-синие тени, слегка тёплые света и виньетка, затемняющая углы.

Как сохранить лёгкость

Сцена работает внутри обычной веб-страницы, поэтому должна вести себя прилично:

  • Соотношение пикселей устройства ограничено 1.75 — телефонам с высокой плотностью не нужно рендерить в 3×.
  • Время кадра ограничено 50 мс, чтобы зависшая вкладка не заставила корабль дёрнуться при возобновлении.
  • Болты, брызги и искры берутся из фиксированных пулов; во время игры ничего не создаётся.
  • Рендеринг останавливается, когда сцена уходит за пределы экрана или вкладка скрыта, а медленный автооблёт камеры отключается для посетителей, предпочитающих уменьшенное движение.

Выводы

  • Сумма нескольких синусоид — вполне хороший стилизованный океан, если выбрать разные направления, длины волн и скорости.
  • Сделайте функцию высоты единственным источником истины. Если всё, что касается воды, сэмплирует одну и ту же функцию, всё сходится.
  • Для плавающего корабля физика не нужна. Возьмите точки у носа, кормы, левого и правого борта, выведите вертикальную, килевую и бортовую качку, сгладьте их и немного уменьшите.
  • Используйте 1 − e−rate·dt для сглаживания, не зависящего от частоты кадров.
  • Атмосфера делает половину работы: туман, дождь, вспышка света и цветокоррекция превращают синюю плоскость в шторм.

Сцена доступна в разделе «Шторм по курсу» — вращайте камеру, целитесь мышью и выпустите пару болтов по акулам. А если вам нравятся океаны, где есть что терять, наша будущая игра про выживание Island Escape тоже заканчивается рискованной ставкой на открытое море.