«Шторм по курсу» — небольшая 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 тоже заканчивается рискованной ставкой на открытое море.