При разработке игр про лодки, да и любых других игр с обширными водными поверхностями, существует проблема сокрытия поверхности воды, когда на ней что-то плавает. Я расскажу о решении, используемом в моей игре Sail Forth на движке Unity, но эта методика применима для любого другого движка.


Та самая проблема. Тащите ведро!

Так как в большинстве игр вода — это просто большая плоскость, логично, что плавающие на ней объекты будут пересекаться с её поверхностью!

Как же нам устранить эту проблему? Мне известны две основные методики: одна основана на деформировании меша воды вокруг корпуса судна, вторая заключается в маскировании поверхности воды внутри судна. Я знаю, как использовать вторую методику, поэтому мы реализуем её.

Решение состоит из трёх компонентов:

  • Создание меша «маски» для каждого судна
  • Написание шейдера для меша «маски»
  • Изменение шейдера воды для использования маски

Меш маски


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


Меш маски воды

Лучше всего создавать такой меш, взяв рёбра вдоль края внутренностей лодки, и заполнив их. Важно, чтобы меш или был отдельным объектом, или имел в движке собственный материал, чтобы мы могли назначить ему маскирующий материал.

Шейдер маски


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

Shader "Custom/WaterMask"
{
  SubShader 
  {
    Pass 
    {
      // Render the mask after regular geometry, but before masked geometry and
      // transparent things.
      // You may need to adjust the queue value depending on your setup
      Tags {"RenderType"="Opaque" "Queue"="Geometry+10" "IgnoreProjector"="True" }

      // Don't draw in the RGBA channels; just the depth buffer
      // This is important for making our mask mesh invisible
      ColorMask 0

      // We write to the depth buffer which will hide the water below our mask mesh
      ZWrite On

      // We don't want anything to draw in front of our mask, 
      // as it would allow the water to then be drawn on top of us
      ZTest Off
    }
  }
}

Шейдер для меша маски воды

Пока таким будет весь шейдер для маски воды! Краткое описание: он выполняет рендеринг после всей непрозрачной (opaque) геометрии (например, лодки) и до воды, и не записывает никаких цветов, зато выполняет запись глубин. Последнее означает, что мы не сможем его увидеть, но он перекрывает объекты за собой. Он не перекрывает саму лодку, потому что отрисовывается после лодки.


Включение и отключение меша маски

Если мы применим этот шейдер к мешу маски, при условии, что очередь рендеринга воды находится после очереди рендеринга маски, то вы увидите, что это уже работает!

По сути, здесь вода перекрывается нашим невидимым мешем маски аналогично тому, как она перекрывается другими частями лодки.

Это вполне может быть достаточным решением для вашей игры, как и для меня в течение первых нескольких лет разработки. Однако возникает одна проблема:


Ужасно

Что здесь происходит? Лодка качается на волнах, и иногда часть волн поднимается над верхней частью лодки. Это означает, что вода ближе к камере, чем маска, поэтому проходит Z-тест и рендерится.

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

Стенсил-буфер


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

Мы модифицируем наш шейдер маскировки, добавив использование стенсил-буфера, для чего нам также понадобится модифицировать шейдер воды.

Shader "Custom/WaterMask"
{
  SubShader 
  {
    Pass 
    {
      // Render the mask after regular geometry, but before masked geometry and
      // transparent things.
      // You may need to adjust the queue value depending on your setup
      Tags {"RenderType"="Opaque" "Queue"="Geometry+10" "IgnoreProjector"="True" }

      // Don't draw in the RGBA channels; just the depth buffer
      // This is important for making our mask mesh invisible
      ColorMask 0

      // Writing to z depth isn't necessarily required, but might hide 
      // any extra effects your water has like caustics when the boat interior is below the water surface
      ZWrite On

      // We don't want anything to draw in front of our mask, 
      // as it would allow the water to then be drawn on top of us
      ZTest Off

      // The real meat of the solution. 
      // Ref - The value this stencil operation is in reference to. I arbitrarily picked '1'.
      // Comp - The comparison method for deciding whether to draw a pixel. 
      //        For drawing the mask, we always want it to render regardless of the 
      //        stencil state, so I chose 'always'
      // Pass - What to do with the stencil state after drawing a pixel. I chose 'replace', 
      //        which means that whatever was in the stencil buffer will be replaced with '1' 
      //        where our mask is drawn.
      Stencil 
      {
        Ref 1
        Comp always
        Pass replace
      }
    }
  }
}

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

Stencil — это просто означает, что на данном проходе шейдера мы будем выполнять операцию со стенсилом

Ref 1 — значение стенсила, на которое мы будем ссылаться, равно 1

Comp always — когда мы смотрим на текущее значение стенсила, шейдер всегда должен отрисовывать пиксель вне зависимости от значения стенсила.

Pass replace — при отрисовке пикселя мы должны заменять текущее значение стенсила нашим значением, то есть 1.

Итак, результатом выполнения этого шейдера станет то, что стенсил-буфер будет содержать 1 в каждом пикселе, в котором отрисовывается объект.

Теперь нам нужно использовать эту информацию стенсила в шейдере воды.

Маскирование в шейдере воды


Шейдеры воды могут быть довольно сложными, поэтому я опущу всё, кроме относящегося непосредственно к стенсилу. Вероятно, с тем, что вы используете для воды, связан какой-то специальный шейдер, поэтому достаточно будет просто отредактировать этот шейдер и вставить блок Stencil.

Pass
{
  ZWrite On

  // Mask the water using the stencil buffer
  Stencil 
  {
    Ref 1
    Comp notequal
    Pass keep
  }

  CGPROGRAM
  #pragma vertex waterVert
  #pragma fragment waterFrag
  ENDCG
}

Это очень похоже на код стенсила, который мы писали в шейдере маскировки, но немного с другими параметрами.

Мы по-прежнему ссылаемся на значение 1, но здесь наша задача — не рендерить воду, если стенсил-буфер равен 1, потому что мы знаем, что 1 записана там, где находится наш маскирующий объект.

Поэтому мы делаем параметр Comp равным «notequal», то есть сравнение будет выполняться на неравенство. Если значение стенсила не равно 1, то тест стенсила оказывается пройденным.

На самом деле, не важно, что мы будем делать со значением стенсила в случае прохождения теста, потому что мы не используем его больше нигде, поэтому я указал, что нужно сохранять («keep») значение стенсила в случае прохождении теста.


Вода, вода повсюду, но ни капли в нашей лодке!

После внесения этого изменения мы сможем увидеть стенсил-буфер в действии! Здесь я перемещаю лодку вверх и вниз, намного глубже поверхности воды, но мы видим, что вода никогда не отрисовывается поверх внутренней части лодки.

При этом возникает ещё одна проблема: что если лодка потонет? Я решил отключать рендерер маски воды, как только лодка начинает тонуть. Во всех остальных случаях я предполагаю, что нет никаких причин отрисовывать воду поверх внутренностей лодки.

Ещё одна проблема, похожая на ситуацию с потоплением, рассмотрена в этой замечательной статье: https://simonschreibt.de/gat/black-flag-waterplane/. Высокая волна может встать между лодкой и камерой, которая в таком случае неправильно создаст стенсил-буфер, что приведёт к некрасивому артефакту. Конкретно в вашей игре такая ситуация может и не возникнуть, или наоборот, будет очень заметной.


Упс…

Пока я создал такое решение: я переключаюсь между исходной реализацией маски глубин и стенсил-маской в зависимости от расстояния до камеры. Это значительно снижает серьёзность проблемы.

При близком расстоянии до камеры очень мала вероятность того, что волна встанет между лодкой и камерой, поэтому подходит более точное решение со стенсил-буфером.

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


Артефакт «устранён»